diff --git a/app/build.gradle b/app/build.gradle index b7cff6d..3af3186 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -50,6 +50,7 @@ android { } buildFeatures { viewBinding true + compose true } namespace 'dev.alenajam.opendialer' lint { @@ -58,6 +59,9 @@ android { compileOptions { coreLibraryDesugaringEnabled true } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.2" + } } dependencies { @@ -105,6 +109,17 @@ dependencies { testImplementation 'junit:junit:4.13.2' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4' + + def composeBom = platform('androidx.compose:compose-bom:2023.10.01') + implementation composeBom + androidTestImplementation composeBom + implementation 'androidx.compose.material3:material3' + implementation 'androidx.compose.ui:ui-tooling-preview' + debugImplementation 'androidx.compose.ui:ui-tooling' + androidTestImplementation 'androidx.compose.ui:ui-test-junit4' + debugImplementation 'androidx.compose.ui:ui-test-manifest' + implementation 'androidx.compose.material:material-icons-extended' + implementation "androidx.compose.ui:ui-viewbinding" } repositories { diff --git a/app/src/main/java/dev/alenajam/opendialer/features/main/MainFragment.kt b/app/src/main/java/dev/alenajam/opendialer/features/main/MainFragment.kt index d1fef9a..03b8ffe 100644 --- a/app/src/main/java/dev/alenajam/opendialer/features/main/MainFragment.kt +++ b/app/src/main/java/dev/alenajam/opendialer/features/main/MainFragment.kt @@ -6,6 +6,8 @@ import android.view.LayoutInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import androidx.viewpager2.adapter.FragmentStateAdapter @@ -22,6 +24,7 @@ import dev.alenajam.opendialer.feature.contacts.ContactsFragment import dev.alenajam.opendialer.feature.contactsSearch.SearchContactsFragment import dev.alenajam.opendialer.feature.settings.ProfileFragment import dev.alenajam.opendialer.features.main.MainFragmentDirections.Companion.actionHomeFragmentToSearchContactsFragment +import dev.alenajam.opendialer.ui.OpenDialerApp class MainFragment : Fragment(), @@ -53,8 +56,21 @@ class MainFragment : container: ViewGroup?, savedInstanceState: Bundle? ): View { - _binding = FragmentHomeBinding.inflate(inflater, container, false) - return binding.root + /*_binding = FragmentHomeBinding.inflate(inflater, container, false) + return binding.root*/ + + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenDialerApp( + openDialpad = { + val action = + actionHomeFragmentToSearchContactsFragment(SearchContactsFragment.InitiationType.DIALPAD) + findNavController().safeNavigate(action) + } + ) + } + } } override fun onDestroyView() { @@ -65,7 +81,7 @@ class MainFragment : override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - onStatusBarColorChange?.onColorChange(view.context.getColor(R.color.windowBackground)) + /*onStatusBarColorChange?.onColorChange(view.context.getColor(R.color.windowBackground)) binding.bottomNavigation.setOnNavigationItemSelectedListener(this) binding.bottomNavigation.itemIconTintList = null @@ -77,7 +93,7 @@ class MainFragment : adapter = viewPagerAdapter isUserInputEnabled = false registerOnPageChangeCallback(OnPageChange()) - } + }*/ } private fun setPage(fragment: Tab) { diff --git a/app/src/main/java/dev/alenajam/opendialer/ui/OpenDialerApp.kt b/app/src/main/java/dev/alenajam/opendialer/ui/OpenDialerApp.kt new file mode 100644 index 0000000..8b3b154 --- /dev/null +++ b/app/src/main/java/dev/alenajam/opendialer/ui/OpenDialerApp.kt @@ -0,0 +1,96 @@ +package dev.alenajam.opendialer.ui + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccessTimeFilled +import androidx.compose.material.icons.filled.People +import androidx.compose.material.icons.outlined.AccessTime +import androidx.compose.material.icons.outlined.Dialpad +import androidx.compose.material.icons.outlined.People +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SearchBar +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidViewBinding +import dev.alenajam.opendialer.R +import dev.alenajam.opendialer.databinding.AppFragmentCallsBinding +import dev.alenajam.opendialer.databinding.AppFragmentContactsBinding + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun OpenDialerApp( + openDialpad: () -> Unit +) { + var selectedNavigationItem by remember { mutableStateOf("CALLS") } + Scaffold( + topBar = { + SearchBar( + query = "", + active = false, + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 8.dp), + placeholder = { Text(text = stringResource(id = R.string.searchContacts)) }, + leadingIcon = { Icon(imageVector = Icons.Outlined.Search, contentDescription = null) }, + onSearch = {}, + onActiveChange = {}, + onQueryChange = {}, + ) {} + }, + bottomBar = { + NavigationBar { + val isSelected = { item: String -> item == selectedNavigationItem } + NavigationBarItem( + selected = isSelected("CALLS"), + icon = { + Icon( + imageVector = if (isSelected("CALLS")) Icons.Filled.AccessTimeFilled else Icons.Outlined.AccessTime, + contentDescription = null + ) + }, + label = { Text("Recents") }, + onClick = { selectedNavigationItem = "CALLS" }, + ) + + NavigationBarItem( + selected = isSelected("CONTACTS"), + icon = { + Icon( + imageVector = if (isSelected("CONTACTS")) Icons.Filled.People else Icons.Outlined.People, + contentDescription = null + ) + }, + label = { Text("Contacts") }, + onClick = { selectedNavigationItem = "CONTACTS" }, + ) + } + }, + floatingActionButton = { + FloatingActionButton(onClick = openDialpad) { + Icon(imageVector = Icons.Outlined.Dialpad, contentDescription = null) + } + } + ) { innerPadding -> + Surface(modifier = Modifier.padding(innerPadding)) { + when (selectedNavigationItem) { + "CALLS" -> AndroidViewBinding(AppFragmentCallsBinding::inflate) + "CONTACTS" -> AndroidViewBinding(AppFragmentContactsBinding::inflate) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/app_fragment_calls.xml b/app/src/main/res/layout/app_fragment_calls.xml new file mode 100644 index 0000000..d3f5b7f --- /dev/null +++ b/app/src/main/res/layout/app_fragment_calls.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/app_fragment_contacts.xml b/app/src/main/res/layout/app_fragment_contacts.xml new file mode 100644 index 0000000..0b2761a --- /dev/null +++ b/app/src/main/res/layout/app_fragment_contacts.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index b4aa165..ecd201f 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -27,7 +27,6 @@ android { } dependencies { - implementation("androidx.core:core-ktx:1.9.0") implementation("androidx.appcompat:appcompat:1.6.1") implementation("com.google.android.material:material:1.10.0") @@ -40,6 +39,18 @@ dependencies { implementation("androidx.navigation:navigation-ui-ktx:2.7.5") implementation("androidx.preference:preference-ktx:1.2.1") implementation("com.google.code.gson:gson:2.9.0") + + val composeBom = platform("androidx.compose:compose-bom:2023.10.01") + implementation(composeBom) + androidTestImplementation(composeBom) + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.ui:ui-tooling-preview") + debugImplementation("androidx.compose.ui:ui-tooling") + androidTestImplementation("androidx.compose.ui:ui-test-junit4") + debugImplementation("androidx.compose.ui:ui-test-manifest") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2") + implementation("androidx.compose.runtime:runtime-livedata") } kotlin { diff --git a/core/common/src/main/java/dev/alenajam/opendialer/core/common/CommonUtils.java b/core/common/src/main/java/dev/alenajam/opendialer/core/common/CommonUtils.java index 2bed1d8..5c7da1d 100644 --- a/core/common/src/main/java/dev/alenajam/opendialer/core/common/CommonUtils.java +++ b/core/common/src/main/java/dev/alenajam/opendialer/core/common/CommonUtils.java @@ -107,6 +107,7 @@ public static void startInCallUI(Context context) throws ClassNotFoundException public static void makeCall(Context context, String number) { if (PermissionUtils.hasMakeCallPermission(context)) { Intent intent = new Intent(Intent.ACTION_CALL, Uri.fromParts("tel", number, null)); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); TelecomManager telecomManager = (TelecomManager) context.getSystemService(TELECOM_SERVICE); if (telecomManager == null) return; @@ -153,7 +154,9 @@ public static void makeCall(Context context, String number) { } public static void makeSms(Context context, String number) { - context.startActivity(new Intent(Intent.ACTION_SENDTO, Uri.fromParts("smsto", number, null))); + Intent intent = new Intent(Intent.ACTION_SENDTO, Uri.fromParts("smsto", number, null)); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(intent); } public static void copyToClipobard(Context context, String text) { @@ -169,6 +172,7 @@ public static void showContactDetail(Context context, int contactId) { Intent intent = new Intent(Intent.ACTION_VIEW); Uri uri = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_URI, String.valueOf(contactId)); intent.setData(uri); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startActivity(intent); } @@ -197,6 +201,7 @@ public static void addContactAsExisting(Context context, String number) { Intent addExistingIntent = new Intent(Intent.ACTION_INSERT_OR_EDIT); addExistingIntent.setType(ContactsContract.Contacts.CONTENT_ITEM_TYPE); addExistingIntent.putExtra(ContactsContract.Intents.Insert.PHONE, number); + addExistingIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startActivity(addExistingIntent); } diff --git a/core/common/src/main/java/dev/alenajam/opendialer/core/common/forwardingPainter.kt b/core/common/src/main/java/dev/alenajam/opendialer/core/common/forwardingPainter.kt new file mode 100644 index 0000000..f5dd242 --- /dev/null +++ b/core/common/src/main/java/dev/alenajam/opendialer/core/common/forwardingPainter.kt @@ -0,0 +1,60 @@ +package dev.alenajam.opendialer.core.common + +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.DefaultAlpha +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.painter.Painter + +/** + * Create and return a new [Painter] that wraps [painter] with its [alpha], [colorFilter], or [onDraw] overwritten. + */ +fun forwardingPainter( + painter: Painter, + alpha: Float = DefaultAlpha, + colorFilter: ColorFilter? = null, + onDraw: DrawScope.(ForwardingDrawInfo) -> Unit = DefaultOnDraw, +): Painter = ForwardingPainter(painter, alpha, colorFilter, onDraw) + +data class ForwardingDrawInfo( + val painter: Painter, + val alpha: Float, + val colorFilter: ColorFilter?, +) + +private class ForwardingPainter( + private val painter: Painter, + private var alpha: Float, + private var colorFilter: ColorFilter?, + private val onDraw: DrawScope.(ForwardingDrawInfo) -> Unit, +) : Painter() { + + private var info = newInfo() + + override val intrinsicSize get() = painter.intrinsicSize + + override fun applyAlpha(alpha: Float): Boolean { + if (alpha != DefaultAlpha) { + this.alpha = alpha + this.info = newInfo() + } + return true + } + + override fun applyColorFilter(colorFilter: ColorFilter?): Boolean { + if (colorFilter != null) { + this.colorFilter = colorFilter + this.info = newInfo() + } + return true + } + + override fun DrawScope.onDraw() = onDraw(info) + + private fun newInfo() = ForwardingDrawInfo(painter, alpha, colorFilter) +} + +private val DefaultOnDraw: DrawScope.(ForwardingDrawInfo) -> Unit = { info -> + with(info.painter) { + draw(size, info.alpha, info.colorFilter) + } +} \ No newline at end of file diff --git a/data/calls/src/main/java/dev/alenajam/opendialer/data/calls/DialerCall.kt b/data/calls/src/main/java/dev/alenajam/opendialer/data/calls/DialerCall.kt index b8c18c0..d87dfe1 100644 --- a/data/calls/src/main/java/dev/alenajam/opendialer/data/calls/DialerCall.kt +++ b/data/calls/src/main/java/dev/alenajam/opendialer/data/calls/DialerCall.kt @@ -6,7 +6,7 @@ import java.io.Serializable import java.util.Date @Keep -class DialerCall( +data class DialerCall( val id: Int, val number: String?, val date: Date, @@ -107,6 +107,7 @@ class DialerCall( } fun isAnonymous(): Boolean = contactInfo.number.isNullOrBlank() + fun isContactSaved(): Boolean = !contactInfo.name.isNullOrBlank() } fun equalNumbers(number1: String?, number2: String?): Boolean { diff --git a/feature/callDetail/build.gradle.kts b/feature/callDetail/build.gradle.kts index e54d78b..857dd2d 100644 --- a/feature/callDetail/build.gradle.kts +++ b/feature/callDetail/build.gradle.kts @@ -24,6 +24,10 @@ android { } buildFeatures { viewBinding = true + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.2" } } @@ -65,6 +69,20 @@ dependencies { implementation("com.squareup.picasso:picasso:2.71828") implementation("org.ocpsoft.prettytime:prettytime:4.0.1.Final") + + val composeBom = platform("androidx.compose:compose-bom:2023.10.01") + implementation(composeBom) + androidTestImplementation(composeBom) + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.ui:ui-tooling-preview") + debugImplementation("androidx.compose.ui:ui-tooling") + androidTestImplementation("androidx.compose.ui:ui-test-junit4") + debugImplementation("androidx.compose.ui:ui-test-manifest") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2") + implementation("androidx.compose.runtime:runtime-livedata") + + implementation("io.coil-kt:coil-compose:2.5.0") } kotlin { diff --git a/feature/callDetail/src/main/java/dev/alenajam/opendialer/feature/callDetail/CallDetailFragment.kt b/feature/callDetail/src/main/java/dev/alenajam/opendialer/feature/callDetail/CallDetailFragment.kt index bafe99c..11f435b 100644 --- a/feature/callDetail/src/main/java/dev/alenajam/opendialer/feature/callDetail/CallDetailFragment.kt +++ b/feature/callDetail/src/main/java/dev/alenajam/opendialer/feature/callDetail/CallDetailFragment.kt @@ -8,11 +8,11 @@ import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController -import androidx.recyclerview.widget.LinearLayoutManager -import com.squareup.picasso.Picasso import com.squareup.picasso.Transformation import dagger.hilt.android.AndroidEntryPoint import dev.alenajam.opendialer.core.common.CALL_DETAIL_PARAM_CALL_IDS @@ -23,29 +23,15 @@ import dev.alenajam.opendialer.core.common.SharedPreferenceHelper import dev.alenajam.opendialer.core.common.ToolbarListener import dev.alenajam.opendialer.data.calls.CallOption import dev.alenajam.opendialer.data.calls.DialerCall -import dev.alenajam.opendialer.feature.callDetail.databinding.FragmentCallDetailBinding import kotlinx.coroutines.ExperimentalCoroutinesApi -private val circleTransform: Transformation = CircleTransform() -private val colorList = listOf( - Color.parseColor("#4FAF44"), - Color.parseColor("#F6D145"), - Color.parseColor("#FF9526"), - Color.parseColor("#EF4423"), - Color.parseColor("#328AF0") -) - @AndroidEntryPoint class CallDetailFragment : Fragment(), View.OnClickListener { private val viewModel: DialerViewModel by viewModels() - lateinit var adapter: RecentsAdapter private lateinit var callIds: List private lateinit var call: DialerCall - private var optionsAdapter: CallOptionsAdapter? = null private var toolbarListener: ToolbarListener? = null private var onStatusBarColorChange: OnStatusBarColorChange? = null - private var _binding: FragmentCallDetailBinding? = null - private val binding get() = _binding!! private val requestMakeCallPermissions = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { data -> /** Ensure that all permissions were allowed */ @@ -85,17 +71,19 @@ class CallDetailFragment : Fragment(), View.OnClickListener { container: ViewGroup?, savedInstanceState: Bundle? ): View { - _binding = FragmentCallDetailBinding.inflate( + /*_binding = FragmentCallDetailBinding.inflate( inflater, container, false ) - return binding.root - } + return binding.root*/ - override fun onDestroyView() { - super.onDestroyView() - _binding = null + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + CallDetailScreen() + } + } } @ExperimentalCoroutinesApi @@ -105,7 +93,7 @@ class CallDetailFragment : Fragment(), View.OnClickListener { onStatusBarColorChange?.onColorChange(view.context.getColor(R.color.colorPrimaryDark)) toolbarListener?.hideToolbar(false) - binding.toolbarLayout.toolbar.setNavigationOnClickListener { goBack() } + /*binding.toolbarLayout.toolbar.setNavigationOnClickListener { goBack() } context?.let { binding.toolbarLayout.toolbar.setTitle(R.string.call_details) } viewModel.call.observe(viewLifecycleOwner) { @@ -123,13 +111,13 @@ class CallDetailFragment : Fragment(), View.OnClickListener { dev.alenajam.opendialer.core.common.functional.EventObserver { handleBlockedCaller(false) }) binding.callButton.setOnClickListener(this) - binding.contactIcon.setOnClickListener(this) + binding.contactIcon.setOnClickListener(this)*/ viewModel.getCallByIds(callIds) } private fun renderCall() { - context?.let { context -> + /*context?.let { context -> viewModel.getDetailOptions(call) Picasso.get() @@ -163,11 +151,11 @@ class CallDetailFragment : Fragment(), View.OnClickListener { optionsAdapter = CallOptionsAdapter { option -> handleOptionClick(option) } - binding.recyclerViewCallDetailsOptions.adapter = optionsAdapter + binding.recyclerViewCallDetailsOptions.adapter = optionsAdapter*/ } private fun handleOptions(options: List) { - optionsAdapter?.setData(options) + // optionsAdapter?.setData(options) } private fun handleOptionClick(option: CallOption) { @@ -208,11 +196,11 @@ class CallDetailFragment : Fragment(), View.OnClickListener { } override fun onClick(v: View?) { - if (v?.id == binding.callButton.id) { + /*if (v?.id == binding.callButton.id) { activity?.let { makeCall() } } else if (v?.id == binding.contactIcon.id) { activity?.let { viewModel.openContact(it, call) } - } + }*/ } private fun goBack() { diff --git a/feature/callDetail/src/main/java/dev/alenajam/opendialer/feature/callDetail/CallDetailScreen.kt b/feature/callDetail/src/main/java/dev/alenajam/opendialer/feature/callDetail/CallDetailScreen.kt new file mode 100644 index 0000000..9f6dffd --- /dev/null +++ b/feature/callDetail/src/main/java/dev/alenajam/opendialer/feature/callDetail/CallDetailScreen.kt @@ -0,0 +1,277 @@ +package dev.alenajam.opendialer.feature.callDetail + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.outlined.ArrowBack +import androidx.compose.material.icons.outlined.Block +import androidx.compose.material.icons.outlined.Call +import androidx.compose.material.icons.outlined.CallMade +import androidx.compose.material.icons.outlined.CallMissed +import androidx.compose.material.icons.outlined.CallReceived +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Message +import androidx.compose.material.icons.outlined.Voicemail +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.BottomAppBarDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage +import dev.alenajam.opendialer.core.common.CommonUtils +import dev.alenajam.opendialer.core.common.forwardingPainter +import dev.alenajam.opendialer.data.calls.CallType +import dev.alenajam.opendialer.data.calls.ContactInfo +import dev.alenajam.opendialer.data.calls.DetailCall +import dev.alenajam.opendialer.data.calls.DialerCall +import org.ocpsoft.prettytime.PrettyTime +import java.util.Date + +@Composable +internal fun CallDetailScreen( + viewModel: DialerViewModel = viewModel() +) { + val call = viewModel.call.observeAsState() + val isAnon = call.value?.isAnonymous() == true + val childCalls = call.value?.childCalls ?: emptyList() + + Scaffold( + topBar = { + TopBar( + call = call.value + ) + }, + bottomBar = { + BottomBar( + isAnon = isAnon, + makeCall = { viewModel.makeCall(call.value!!.number!!) }, + sendMessage = viewModel::sendMessage, + copyNumber = { viewModel.copyNumber(call.value!!) }, + dialNumber = { viewModel.editNumberBeforeCall(call.value!!) }, + deleteCalls = { viewModel.deleteCalls(call.value!!) } + ) + } + ) { innerPadding -> + Column( + verticalArrangement = Arrangement.Bottom, + modifier = Modifier + .fillMaxHeight() + .padding(innerPadding) + ) { + LazyColumn { + items(childCalls) { call -> + CallRow(call = call) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TopBar( + call: DialerCall? +) { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.primary, + ), + title = { + if (call != null) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + val placeholder = forwardingPainter( + painter = rememberVectorPainter(Icons.Filled.AccountCircle), + colorFilter = ColorFilter.tint(color = MaterialTheme.colorScheme.onSurfaceVariant) + ) + AsyncImage( + model = call.contactInfo.photoUri, + contentDescription = null, + modifier = Modifier.size(50.dp), + placeholder = placeholder, + error = placeholder, + fallback = placeholder + ) + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = if (call.isAnonymous()) stringResource(id = R.string.anonymous) + else if (!call.contactInfo.name.isNullOrBlank()) call.contactInfo.name!! + else call.contactInfo.number!!, + ) + } + } + }, + navigationIcon = { + Icon(imageVector = Icons.Outlined.ArrowBack, contentDescription = null) + } + ) +} + +@Composable +private fun BottomBar( + isAnon: Boolean, + makeCall: () -> Unit, + sendMessage: () -> Unit, + copyNumber: () -> Unit, + dialNumber: () -> Unit, + deleteCalls: () -> Unit, +) { + BottomAppBar( + actions = { + if (!isAnon) { + IconButton(onClick = sendMessage) { + Icon(Icons.Outlined.Message, contentDescription = "Localized description") + } + IconButton(onClick = copyNumber) { + Icon(Icons.Outlined.ContentCopy, contentDescription = "Localized description") + } + IconButton(onClick = dialNumber) { + Icon(Icons.Outlined.Edit, contentDescription = "Localized description") + } + } + IconButton(onClick = deleteCalls) { + Icon(Icons.Outlined.Delete, contentDescription = "Localized description") + } + }, + floatingActionButton = { + if (!isAnon) { + FloatingActionButton( + onClick = makeCall, + containerColor = BottomAppBarDefaults.bottomAppBarFabColor, + elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation() + ) { + Icon(Icons.Outlined.Call, "Localized description") + } + } + } + ) +} + +@Composable +private fun CallRow( + call: DetailCall +) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 16.dp, horizontal = 16.dp), + ) { + Icon( + imageVector = when (call.type) { + CallType.INCOMING, CallType.ANSWERED_EXTERNALLY -> Icons.Outlined.CallReceived + CallType.OUTGOING -> Icons.Outlined.CallMade + CallType.MISSED, CallType.REJECTED -> Icons.Outlined.CallMissed + CallType.VOICEMAIL -> Icons.Outlined.Voicemail + CallType.BLOCKED -> Icons.Outlined.Block + }, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = when (call.type) { + CallType.OUTGOING -> stringResource(id = R.string.outgoing_call) + CallType.INCOMING, CallType.ANSWERED_EXTERNALLY -> stringResource(id = R.string.incoming_call) + CallType.MISSED -> stringResource(id = R.string.missed_call) + CallType.VOICEMAIL -> stringResource(id = R.string.voicemail_call) + CallType.REJECTED -> stringResource(id = R.string.rejected_call) + CallType.BLOCKED -> stringResource(id = R.string.blocked_call) + }, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = PrettyTime().format(call.date), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Text( + text = CommonUtils.getDurationTimeStringMinimal(call.duration * 1000), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +private val incomingDetailCallMock = DetailCall( + id = 1, + date = Date(), + type = CallType.INCOMING, + duration = 500L, +) + +private val callMock = DialerCall( + id = 1, + number = "333123456", + date = Date(), + type = CallType.OUTGOING, + options = emptyList(), + childCalls = listOf( + incomingDetailCallMock + ), + contactInfo = ContactInfo( + name = "John Doe", + number = "3331234567", + photoUri = null + ) +) + +@Preview(showBackground = true) +@Composable +private fun TopBarPreview() { + TopBar(call = callMock) +} + +@Preview(showBackground = true) +@Composable +private fun BottomBarPreview() { + BottomBar( + isAnon = false, + makeCall = {}, + sendMessage = {}, + copyNumber = {}, + dialNumber = {}, + deleteCalls = {}, + ) +} + +@Preview(showBackground = true) +@Composable +private fun CallRowPreview() { + CallRow(call = incomingDetailCallMock) +} \ No newline at end of file diff --git a/feature/callDetail/src/main/java/dev/alenajam/opendialer/feature/callDetail/CallDetailsAdapter.java b/feature/callDetail/src/main/java/dev/alenajam/opendialer/feature/callDetail/CallDetailsAdapter.java index ac4c26e..baa2725 100644 --- a/feature/callDetail/src/main/java/dev/alenajam/opendialer/feature/callDetail/CallDetailsAdapter.java +++ b/feature/callDetail/src/main/java/dev/alenajam/opendialer/feature/callDetail/CallDetailsAdapter.java @@ -50,7 +50,9 @@ public void onBindViewHolder(@NonNull ViewHolder holder, final int position) { if (currentCall.getDuration() == 0) { holder.duration.setVisibility(View.GONE); } else { - holder.duration.setText(CommonUtils.getDurationTimeStringMinimal(currentCall.getDuration() * 1000)); + holder.duration.setText( + CommonUtils.getDurationTimeStringMinimal(currentCall.getDuration() * 1000) + ); } // TODO refactor diff --git a/feature/callDetail/src/main/java/dev/alenajam/opendialer/feature/callDetail/DialerViewModel.kt b/feature/callDetail/src/main/java/dev/alenajam/opendialer/feature/callDetail/DialerViewModel.kt index a2f0d7f..60c2e36 100644 --- a/feature/callDetail/src/main/java/dev/alenajam/opendialer/feature/callDetail/DialerViewModel.kt +++ b/feature/callDetail/src/main/java/dev/alenajam/opendialer/feature/callDetail/DialerViewModel.kt @@ -49,6 +49,7 @@ class DialerViewModel getDetailOptions(viewModelScope, call) { it.fold({}, ::handleDetailOptions) } fun makeCall(activity: Activity, number: String) = CommonUtils.makeCall(activity, number) + fun makeCall(number: String) = CommonUtils.makeCall(app, number) fun copyNumber(call: DialerCall) = CommonUtils.copyToClipobard(app, call.contactInfo.number) fun openContact(activity: Activity, call: DialerCall) { @@ -57,6 +58,10 @@ class DialerViewModel } } + fun sendMessage() { + call.value?.number?.let { CommonUtils.makeSms(app, it) } + } + fun editNumberBeforeCall(activity: Activity, call: DialerCall) { val intent = Intent(Intent.ACTION_DIAL).apply { data = Uri.fromParts(PhoneAccount.SCHEME_TEL, call.number, null) @@ -64,6 +69,15 @@ class DialerViewModel activity.startActivity(intent) } + fun editNumberBeforeCall(call: DialerCall) { + val intent = Intent(Intent.ACTION_DIAL).apply { + data = Uri.fromParts(PhoneAccount.SCHEME_TEL, call.number, null) + } + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + app.startActivity(intent) + } + + fun deleteCalls(call: DialerCall) = deleteCallsUseCase(viewModelScope, call.childCalls) { it.fold( {}, diff --git a/feature/calls/build.gradle.kts b/feature/calls/build.gradle.kts index b3b9415..62d56c5 100644 --- a/feature/calls/build.gradle.kts +++ b/feature/calls/build.gradle.kts @@ -24,6 +24,10 @@ android { } buildFeatures { viewBinding = true + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.2" } } @@ -46,6 +50,7 @@ dependencies { kaptAndroidTest("com.google.dagger:hilt-compiler:2.48.1") testImplementation("com.google.dagger:hilt-android-testing:2.48.1") kaptTest("com.google.dagger:hilt-compiler:2.48.1") + implementation("com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava") implementation("com.squareup.picasso:picasso:2.71828") implementation("org.ocpsoft.prettytime:prettytime:4.0.1.Final") @@ -63,8 +68,23 @@ dependencies { implementation("androidx.lifecycle:lifecycle-reactivestreams-ktx:2.6.2") implementation("androidx.navigation:navigation-fragment-ktx:2.7.5") implementation("androidx.navigation:navigation-ui-ktx:2.7.5") + implementation("androidx.navigation:navigation-compose:2.7.5") implementation("androidx.preference:preference-ktx:1.2.1") implementation("androidx.recyclerview:recyclerview:1.3.2") + + val composeBom = platform("androidx.compose:compose-bom:2023.10.01") + implementation(composeBom) + androidTestImplementation(composeBom) + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.ui:ui-tooling-preview") + debugImplementation("androidx.compose.ui:ui-tooling") + androidTestImplementation("androidx.compose.ui:ui-test-junit4") + debugImplementation("androidx.compose.ui:ui-test-manifest") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2") + implementation("androidx.compose.runtime:runtime-livedata") + + implementation("io.coil-kt:coil-compose:2.5.0") } kotlin { diff --git a/feature/calls/src/main/java/dev/alenajam/opendialer/feature/calls/CallsScreen.kt b/feature/calls/src/main/java/dev/alenajam/opendialer/feature/calls/CallsScreen.kt new file mode 100644 index 0000000..aedfd0b --- /dev/null +++ b/feature/calls/src/main/java/dev/alenajam/opendialer/feature/calls/CallsScreen.kt @@ -0,0 +1,256 @@ +package dev.alenajam.opendialer.feature.calls + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.outlined.Block +import androidx.compose.material.icons.outlined.CallMade +import androidx.compose.material.icons.outlined.CallMissed +import androidx.compose.material.icons.outlined.CallReceived +import androidx.compose.material.icons.outlined.History +import androidx.compose.material.icons.outlined.Message +import androidx.compose.material.icons.outlined.PersonAddAlt +import androidx.compose.material.icons.outlined.Phone +import androidx.compose.material.icons.outlined.Voicemail +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import coil.compose.AsyncImage +import dev.alenajam.opendialer.core.common.forwardingPainter +import dev.alenajam.opendialer.data.calls.CallType +import dev.alenajam.opendialer.data.calls.ContactInfo +import dev.alenajam.opendialer.data.calls.DialerCall +import org.ocpsoft.prettytime.PrettyTime +import java.util.Date + +@Composable +internal fun CallsScreen( + viewModel: DialerViewModel = viewModel(), navController: NavController +) { + val calls = viewModel.calls.observeAsState(emptyList()) + var openRowId by remember { mutableStateOf(null) } + Surface(modifier = Modifier.fillMaxSize()) { + LazyColumn { + items(calls.value) { call -> + val isOpen = openRowId == call.id + CallRow(call = call, + isOpen = isOpen, + onClick = { openRowId = if (isOpen) null else call.id }, + makeCall = { viewModel.makeCall(call.contactInfo.number!!) }, + sendMessage = { viewModel.sendMessage(call.contactInfo.number!!) }, + addContact = { viewModel.addToContact(call.contactInfo.number!!) }, + openHistory = { viewModel.callDetail(navController, call) }) + } + } + } +} + +@Composable +private fun CallRow( + call: DialerCall, + isOpen: Boolean, + onClick: () -> Unit, + makeCall: () -> Unit, + sendMessage: () -> Unit, + addContact: () -> Unit, + openHistory: () -> Unit, +) { + Surface( + onClick = onClick, tonalElevation = if (isOpen) 8.dp else 0.dp + ) { + Column { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 16.dp, horizontal = 16.dp), + ) { + val placeholder = forwardingPainter( + painter = rememberVectorPainter(Icons.Filled.AccountCircle), + colorFilter = ColorFilter.tint(color = MaterialTheme.colorScheme.onSurfaceVariant) + ) + AsyncImage( + model = call.contactInfo.photoUri, + contentDescription = null, + modifier = Modifier.size(50.dp), + placeholder = placeholder, + error = placeholder, + fallback = placeholder + ) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = if (call.isAnonymous()) stringResource(id = R.string.anonymous) + else if (!call.contactInfo.name.isNullOrBlank()) call.contactInfo.name!! + else call.contactInfo.number!!, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + imageVector = when (call.type) { + CallType.INCOMING, CallType.ANSWERED_EXTERNALLY -> Icons.Outlined.CallReceived + CallType.OUTGOING -> Icons.Outlined.CallMade + CallType.MISSED, CallType.REJECTED -> Icons.Outlined.CallMissed + CallType.VOICEMAIL -> Icons.Outlined.Voicemail + CallType.BLOCKED -> Icons.Outlined.Block + }, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(14.dp) + ) + + Text( + text = PrettyTime().format(call.date), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + if (!call.isAnonymous()) { + IconButton(onClick = makeCall) { + Icon( + imageVector = Icons.Outlined.Phone, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + AnimatedVisibility(visible = isOpen) { + Divider() + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + if (!call.isAnonymous()) { + if (!call.isContactSaved()) { + CallRowButton( + label = "Add contact", icon = Icons.Outlined.PersonAddAlt, onClick = addContact + ) + } + + CallRowButton( + label = "Message", icon = Icons.Outlined.Message, onClick = sendMessage + ) + } + + CallRowButton( + label = "History", icon = Icons.Outlined.History, onClick = openHistory + ) + } + } + } + } +} + +@Composable +private fun RowScope.CallRowButton( + label: String, icon: ImageVector, onClick: () -> Unit +) { + Surface( + onClick = onClick, modifier = Modifier.weight(1f), color = Color.Transparent + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(vertical = 16.dp) + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +private val incomingCallMock = DialerCall( + id = 1, + number = "3331234567", + date = Date(), + type = CallType.INCOMING, + options = listOf(), + childCalls = listOf(), + contactInfo = ContactInfo( + number = "3331234567" + ) +) +private val outgoingCallMock = incomingCallMock.copy(type = CallType.OUTGOING) +private val anonymousCallMock = incomingCallMock.copy( + number = null, contactInfo = ContactInfo(number = null) +) + +@Preview(showBackground = true) +@Composable +private fun IncomingCallPreview() { + CallRow(call = incomingCallMock, + isOpen = false, + onClick = {}, + makeCall = {}, + addContact = {}, + sendMessage = {}, + openHistory = {}) +} + +@Preview(showBackground = true) +@Composable +private fun OutgoingCallPreview() { + CallRow(call = outgoingCallMock, + isOpen = false, + onClick = {}, + makeCall = {}, + addContact = {}, + sendMessage = {}, + openHistory = {}) +} + +@Preview(showBackground = true) +@Composable +private fun AnonymousCallPreview() { + CallRow(call = anonymousCallMock, + isOpen = false, + onClick = {}, + makeCall = {}, + addContact = {}, + sendMessage = {}, + openHistory = {}) +} \ No newline at end of file diff --git a/feature/calls/src/main/java/dev/alenajam/opendialer/feature/calls/DialerViewModel.kt b/feature/calls/src/main/java/dev/alenajam/opendialer/feature/calls/DialerViewModel.kt index 9da5575..e2d4e6e 100644 --- a/feature/calls/src/main/java/dev/alenajam/opendialer/feature/calls/DialerViewModel.kt +++ b/feature/calls/src/main/java/dev/alenajam/opendialer/feature/calls/DialerViewModel.kt @@ -26,7 +26,7 @@ class DialerViewModel @Inject constructor( dialerRepository: DialerRepositoryImpl, contactsRepository: dev.alenajam.opendialer.data.contacts.DialerRepositoryImpl, - app: Application, + private val app: Application, private val cacheRepository: CacheRepositoryImpl, private val startCacheUseCase: StartCache ) : ViewModel() { @@ -44,9 +44,11 @@ class DialerViewModel fun sendMessage(activity: Activity, call: DialerCall) = CommonUtils.makeSms(activity, call.contactInfo.number) + fun sendMessage(number: String) = + CommonUtils.makeSms(app, number) - // fun makeCall(activity: Activity, number: String) = CommonUtils.makeCall(activity, number) + fun makeCall(number: String) = CommonUtils.makeCall(app, number) fun callDetail(navController: NavController, call: DialerCall) { navController.navigateToCallDetail(call.childCalls.map { it.id }) @@ -57,6 +59,8 @@ class DialerViewModel fun addToContact(activity: Activity, call: DialerCall) = CommonUtils.addContactAsExisting(activity, call.contactInfo.number) + fun addToContact(number: String) = + CommonUtils.addContactAsExisting(app, number) fun openContact(activity: Activity, call: DialerCall) { ContactsHelper.getContactByPhoneNumber(activity, call.contactInfo.number)?.let { diff --git a/feature/calls/src/main/java/dev/alenajam/opendialer/feature/calls/RecentsFragment.kt b/feature/calls/src/main/java/dev/alenajam/opendialer/feature/calls/RecentsFragment.kt index 7e40a17..cd2a143 100644 --- a/feature/calls/src/main/java/dev/alenajam/opendialer/feature/calls/RecentsFragment.kt +++ b/feature/calls/src/main/java/dev/alenajam/opendialer/feature/calls/RecentsFragment.kt @@ -6,34 +6,29 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController -import androidx.recyclerview.widget.LinearLayoutManager import dagger.hilt.android.AndroidEntryPoint import dev.alenajam.opendialer.core.common.PermissionUtils import dev.alenajam.opendialer.data.calls.CallOption import dev.alenajam.opendialer.data.calls.DialerCall -import dev.alenajam.opendialer.feature.calls.databinding.FragmentRecentsBinding import kotlinx.coroutines.ExperimentalCoroutinesApi @AndroidEntryPoint class RecentsFragment : Fragment() { private val viewModel: DialerViewModel by viewModels() - lateinit var adapter: RecentsAdapter private var notCalledNumber = "" private var refreshNeeded = false - private var _binding: FragmentRecentsBinding? = null - private val binding get() = _binding!! - @ExperimentalCoroutinesApi private val requestPermissions = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { data -> /** Ensure that all permissions were allowed */ if (PermissionUtils.recentsPermissions.all { data[it] == true }) { /** Hide permission prompt */ - binding.permissionPrompt.visibility = View.GONE + // binding.permissionPrompt.visibility = View.GONE observeCalls() } @@ -55,13 +50,17 @@ class RecentsFragment : Fragment() { container: ViewGroup?, savedInstanceState: Bundle? ): View { - _binding = FragmentRecentsBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null + /*_binding = FragmentRecentsBinding.inflate(inflater, container, false) + return binding.root*/ + + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + CallsScreen( + navController = findNavController() + ) + } + } } override fun onStart() { @@ -74,11 +73,10 @@ class RecentsFragment : Fragment() { viewModel.stopCache() } - @ExperimentalCoroutinesApi override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - context?.let { + /*context?.let { adapter = RecentsAdapter( it, binding.recyclerViewCallLog, @@ -95,7 +93,7 @@ class RecentsFragment : Fragment() { if (PermissionUtils.hasRecentsPermission(context)) { observeCalls() } else { - /** Show permission prompt */ + *//** Show permission prompt *//* binding.permissionPrompt.visibility = View.VISIBLE binding.buttonPermission.setOnClickListener { requestPermissions.launch(PermissionUtils.recentsPermissions) @@ -104,7 +102,7 @@ class RecentsFragment : Fragment() { if (PermissionUtils.hasContactsPermission(context)) { observeContacts() - } + }*/ } override fun onResume() { @@ -161,14 +159,14 @@ class RecentsFragment : Fragment() { } private fun handleCalls(calls: List) { - adapter.setData(calls) + // adapter.setData(calls) } private fun refreshData() { if (refreshNeeded) { refreshNeeded = false viewModel.invalidateCache() - adapter.notifyDataSetChanged() + // adapter.notifyDataSetChanged() } } } \ No newline at end of file diff --git a/feature/contacts/build.gradle.kts b/feature/contacts/build.gradle.kts index ae3dddf..275d64e 100644 --- a/feature/contacts/build.gradle.kts +++ b/feature/contacts/build.gradle.kts @@ -24,6 +24,10 @@ android { } buildFeatures { viewBinding = true + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.2" } } @@ -63,6 +67,20 @@ dependencies { implementation("androidx.navigation:navigation-ui-ktx:2.7.5") implementation("androidx.preference:preference-ktx:1.2.1") implementation("androidx.recyclerview:recyclerview:1.3.2") + + val composeBom = platform("androidx.compose:compose-bom:2023.10.01") + implementation(composeBom) + androidTestImplementation(composeBom) + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.ui:ui-tooling-preview") + debugImplementation("androidx.compose.ui:ui-tooling") + androidTestImplementation("androidx.compose.ui:ui-test-junit4") + debugImplementation("androidx.compose.ui:ui-test-manifest") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2") + implementation("androidx.compose.runtime:runtime-livedata") + + implementation("io.coil-kt:coil-compose:2.5.0") } kotlin { diff --git a/feature/contacts/src/main/java/dev/alenajam/opendialer/feature/contacts/ContactsFragment.kt b/feature/contacts/src/main/java/dev/alenajam/opendialer/feature/contacts/ContactsFragment.kt index f9f16af..7b1216d 100644 --- a/feature/contacts/src/main/java/dev/alenajam/opendialer/feature/contacts/ContactsFragment.kt +++ b/feature/contacts/src/main/java/dev/alenajam/opendialer/feature/contacts/ContactsFragment.kt @@ -5,29 +5,23 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels -import androidx.recyclerview.widget.LinearLayoutManager import dagger.hilt.android.AndroidEntryPoint import dev.alenajam.opendialer.core.common.PermissionUtils import dev.alenajam.opendialer.data.contacts.DialerContact -import dev.alenajam.opendialer.feature.contacts.databinding.FragmentContactsBinding -import kotlinx.coroutines.ExperimentalCoroutinesApi @AndroidEntryPoint class ContactsFragment : Fragment() { private val viewModel: DialerViewModel by viewModels() - lateinit var adapter: ContactAdapter - private var _binding: FragmentContactsBinding? = null - private val binding get() = _binding!! - - @ExperimentalCoroutinesApi private val requestPermissions = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { data -> /** Ensure that all permissions were allowed */ if (PermissionUtils.contactsPermissions.all { data[it] == true }) { /** Hide permission promp */ - binding.permissionPrompt.visibility = View.GONE + //binding.permissionPrompt.visibility = View.GONE observeContacts() } } @@ -37,40 +31,41 @@ class ContactsFragment : Fragment() { container: ViewGroup?, savedInstanceState: Bundle? ): View { - _binding = FragmentContactsBinding.inflate(inflater, container, false) - return binding.root - } + /*_binding = FragmentContactsBinding.inflate(inflater, container, false) + return binding.root*/ - override fun onDestroyView() { - super.onDestroyView() - _binding = null + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + ContactsScreen() + } + } } - @ExperimentalCoroutinesApi override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - adapter = ContactAdapter(activity) + /*adapter = ContactAdapter(activity) binding.recyclerViewContacts.adapter = adapter binding.recyclerViewContacts.layoutManager = LinearLayoutManager(context) if (PermissionUtils.hasContactsPermission(context)) { observeContacts() } else { - /** Show permission prompt */ + */ + /** Show permission prompt *//* binding.permissionPrompt.visibility = View.VISIBLE binding.buttonPermission.setOnClickListener { requestPermissions.launch(PermissionUtils.contactsPermissions) } - } + }*/ } - - @ExperimentalCoroutinesApi + private fun observeContacts() { - viewModel.contacts.observe(viewLifecycleOwner) { handleContacts(it) } + // viewModel.contacts.observe(viewLifecycleOwner) { handleContacts(it) } } private fun handleContacts(list: List) { - adapter.contacts = ArrayList(list) + // adapter.contacts = ArrayList(list) } } \ No newline at end of file diff --git a/feature/contacts/src/main/java/dev/alenajam/opendialer/feature/contacts/ContactsScreen.kt b/feature/contacts/src/main/java/dev/alenajam/opendialer/feature/contacts/ContactsScreen.kt new file mode 100644 index 0000000..99b4292 --- /dev/null +++ b/feature/contacts/src/main/java/dev/alenajam/opendialer/feature/contacts/ContactsScreen.kt @@ -0,0 +1,96 @@ +package dev.alenajam.opendialer.feature.contacts + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.vector.RenderVectorGroup +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage +import dev.alenajam.opendialer.core.common.forwardingPainter +import dev.alenajam.opendialer.data.contacts.DialerContact + +@Composable +internal fun ContactsScreen( + viewModel: DialerViewModel = viewModel(), +) { + val calls = viewModel.contacts.observeAsState(emptyList()) + Surface(modifier = Modifier.fillMaxSize()) { + LazyColumn { + items(calls.value) { contact -> + ContactRow( + contact = contact, + onClick = { viewModel.openContact(contact.id) } + ) + } + } + } +} + +@Composable +private fun ContactRow( + contact: DialerContact, + onClick: () -> Unit, +) { + Surface( + onClick = onClick, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 16.dp, horizontal = 16.dp), + ) { + val placeholder = forwardingPainter( + painter = rememberVectorPainter(Icons.Filled.AccountCircle), + colorFilter = ColorFilter.tint(color = MaterialTheme.colorScheme.onSurfaceVariant) + ) + AsyncImage( + model = contact.image, + contentDescription = null, + modifier = Modifier.size(50.dp), + placeholder = placeholder, + error = placeholder, + fallback = placeholder + ) + + Text( + text = contact.name, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + } + } +} + +val contactMock = DialerContact( + id = 1, + name = "John Doe", + starred = false, + image = null +) + +@Preview(showBackground = true) +@Composable +private fun ContactRowPreview() { + ContactRow( + contact = contactMock, + onClick = {}, + ) +} diff --git a/feature/contacts/src/main/java/dev/alenajam/opendialer/feature/contacts/DialerViewModel.kt b/feature/contacts/src/main/java/dev/alenajam/opendialer/feature/contacts/DialerViewModel.kt index d756091..521bdbc 100644 --- a/feature/contacts/src/main/java/dev/alenajam/opendialer/feature/contacts/DialerViewModel.kt +++ b/feature/contacts/src/main/java/dev/alenajam/opendialer/feature/contacts/DialerViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.asLiveData import dagger.hilt.android.lifecycle.HiltViewModel +import dev.alenajam.opendialer.core.common.CommonUtils import dev.alenajam.opendialer.data.contacts.DialerContact import dev.alenajam.opendialer.data.contacts.DialerRepositoryImpl import kotlinx.coroutines.Dispatchers @@ -13,14 +14,18 @@ import kotlinx.coroutines.flow.map import javax.inject.Inject @HiltViewModel -class DialerViewModel +internal class DialerViewModel @Inject constructor( dialerRepository: DialerRepositoryImpl, - app: Application, + private val app: Application, ) : ViewModel() { val contacts: LiveData> = dialerRepository .getContacts(app.contentResolver) .map { DialerContact.mapList(it) } .flowOn(Dispatchers.IO) .asLiveData() + + fun openContact(contactId: Int) { + CommonUtils.showContactDetail(app, contactId) + } } \ No newline at end of file