diff --git a/app/src/main/java/com/geeksville/mesh/ui/NavGraph.kt b/app/src/main/java/com/geeksville/mesh/ui/NavGraph.kt index 992d14ec5..ace607905 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NavGraph.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NavGraph.kt @@ -71,6 +71,7 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import androidx.navigation.toRoute import com.geeksville.mesh.MeshProtos.DeviceMetadata import com.geeksville.mesh.R import com.geeksville.mesh.android.Logging @@ -186,6 +187,8 @@ enum class AdminRoute(@StringRes val title: Int) { sealed interface Route { @Serializable data class Messages(val contactKey: String, val message: String = "") : Route + @Serializable + data class Share(val message: String) : Route @Serializable data class RadioConfig(val destNum: Int? = null) : Route @@ -448,5 +451,15 @@ fun NavGraph( val parentEntry = remember { navController.getBackStackEntry() } PaxcounterConfigScreen(hiltViewModel(parentEntry)) } + composable { backStackEntry -> + val message = backStackEntry.toRoute().message + ShareScreen( + navigateUp = navController::navigateUp, + ) { + navController.navigate(Route.Messages(it, message)) { + popUpTo { inclusive = true } + } + } + } } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/ShareFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/ShareFragment.kt index c4d566dd5..40340a631 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/ShareFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/ShareFragment.kt @@ -21,24 +21,38 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Button +import androidx.compose.material.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewScreenSizes import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.fragment.app.FragmentManager import androidx.fragment.app.activityViewModels +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.geeksville.mesh.android.Logging import com.geeksville.mesh.R -import com.geeksville.mesh.databinding.ShareFragmentBinding +import com.geeksville.mesh.android.Logging import com.geeksville.mesh.model.Contact import com.geeksville.mesh.model.UIViewModel +import com.geeksville.mesh.ui.components.BaseScaffold import com.geeksville.mesh.ui.message.navigateToMessages import com.geeksville.mesh.ui.theme.AppTheme import dagger.hilt.android.AndroidEntryPoint @@ -54,93 +68,114 @@ internal fun FragmentManager.navigateToShareMessage(message: String) { } @AndroidEntryPoint -class ShareFragment : ScreenFragment("Messages"), Logging { - +class ShareFragment : ScreenFragment("ShareFragment"), Logging { private val model: UIViewModel by activityViewModels() - private var _binding: ShareFragmentBinding? = null - // This property is only valid between onCreateView and onDestroyView. - private val binding get() = _binding!! - - private val contacts get() = model.contactList.value - private var selectedContact = mutableStateOf("") - - private fun shareMessage(contact: Contact) { - debug("calling MessagesFragment filter:${contact.contactKey}") + private fun shareMessage(contactKey: String) { + debug("calling MessagesFragment filter:$contactKey") parentFragmentManager.navigateToMessages( - contact.contactKey, + contactKey, arguments?.getString("message").toString() ) } - private fun onClick(contact: Contact) { - if (selectedContact.value == contact.contactKey) { - selectedContact.value = "" - binding.shareButton.isEnabled = false - } else { - selectedContact.value = contact.contactKey - binding.shareButton.isEnabled = true - } - } - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { - _binding = ShareFragmentBinding.inflate(inflater, container, false) - return binding.root - } - - @Suppress("LongMethod", "CyclomaticComplexMethod") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.toolbar.setNavigationOnClickListener { - parentFragmentManager.popBackStack() - } - - binding.shareButton.isEnabled = false - - binding.shareButton.setOnClickListener { - debug("User clicked shareButton") - val contact = contacts.find { c -> c.contactKey == selectedContact.value } - if (contact != null) { - shareMessage(contact) - } - } - - binding.contactListView.setContent { - val contacts by model.contactList.collectAsStateWithLifecycle() - AppTheme { - ShareContactListView( - contacts = contacts, - selectedContact = selectedContact.value, - onClick = ::onClick, - ) + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + AppTheme { + ShareScreen( + viewModel = model, + navigateUp = parentFragmentManager::popBackStack, + onConfirm = ::shareMessage + ) + } } } } } @Composable -fun ShareContactListView( - contacts: List, - selectedContact: String, - onClick: (Contact) -> Unit, +internal fun ShareScreen( + viewModel: UIViewModel = hiltViewModel(), + navigateUp: () -> Unit, + onConfirm: (String) -> Unit ) { - LazyColumn( - modifier = Modifier - .fillMaxSize(), - contentPadding = PaddingValues(6.dp), + val contactList by viewModel.contactList.collectAsStateWithLifecycle() + + BaseScaffold( + title = stringResource(R.string.share_to), + canNavigateBack = true, + navigateUp = navigateUp, ) { - items(contacts, key = { it.contactKey }) { contact -> - val selected = contact.contactKey == selectedContact - ContactItem( - contact = contact, - selected = selected, - onClick = { onClick(contact) }, - onLongClick = {}, + ShareContent( + contacts = contactList, + onConfirm = onConfirm, + ) + } +} + +@Composable +private fun ShareContent( + contacts: List, + onConfirm: (String) -> Unit = {} +) { + var selectedContact by rememberSaveable { mutableStateOf("") } + + Column { + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(6.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + items(contacts, key = { it.contactKey }) { contact -> + val selected = contact.contactKey == selectedContact + ContactItem( + contact = contact, + selected = selected, + onClick = { selectedContact = contact.contactKey }, + ) + } + } + + Button( + onClick = { + onConfirm(selectedContact) + }, + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + enabled = selectedContact.isNotEmpty(), + ) { + Icon( + imageVector = Icons.AutoMirrored.Default.Send, + contentDescription = stringResource(id = R.string.share) ) } } } + +@PreviewScreenSizes +@Composable +private fun ShareContentPreview() { + AppTheme { + ShareContent( + contacts = listOf( + Contact( + contactKey = "0^all", + shortName = stringResource(R.string.some_username), + longName = stringResource(R.string.unknown_username), + lastMessageTime = "3 minutes ago", + lastMessageText = stringResource(R.string.sample_message), + unreadCount = 2, + messageCount = 10, + isMuted = true, + ), + ), + ) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/BaseScaffold.kt b/app/src/main/java/com/geeksville/mesh/ui/components/BaseScaffold.kt new file mode 100644 index 000000000..596d59f09 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/BaseScaffold.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.geeksville.mesh.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material.FabPosition +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.SnackbarHost +import androidx.compose.material.SnackbarHostState +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.material.contentColorFor +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.dropUnlessResumed +import com.geeksville.mesh.R + +@Composable +fun BaseScaffold( + title: String, + modifier: Modifier = Modifier, + canNavigateBack: Boolean = true, + navigateUp: (() -> Unit)? = null, + actions: @Composable (RowScope.() -> Unit)? = null, + floatingActionButton: @Composable () -> Unit = {}, + floatingActionButtonPosition: FabPosition = FabPosition.End, + contentWindowInsets: WindowInsets = WindowInsets(0, 0, 0, 0), + content: @Composable () -> Unit, +) { + BaseScaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text(text = title) }, + navigationIcon = if (canNavigateBack) { + { + IconButton(onClick = dropUnlessResumed { navigateUp?.invoke() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(id = R.string.navigate_back), + modifier = Modifier + ) + } + } + } else { + null + }, + actions = { actions?.invoke(this) }, + ) + }, + floatingActionButton = floatingActionButton, + floatingActionButtonPosition = floatingActionButtonPosition, + contentWindowInsets = contentWindowInsets, + content = content + ) +} + +@Composable +fun BaseScaffold( + modifier: Modifier = Modifier, + topBar: @Composable () -> Unit = {}, + bottomBar: @Composable () -> Unit = {}, + snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) }, + floatingActionButton: @Composable () -> Unit = {}, + floatingActionButtonPosition: FabPosition = FabPosition.End, + backgroundColor: Color = MaterialTheme.colors.background, + contentColor: Color = contentColorFor(backgroundColor), + contentWindowInsets: WindowInsets = WindowInsets(0, 0, 0, 0), + content: @Composable () -> Unit, +) { + Scaffold( + modifier = modifier.imePadding(), + topBar = topBar, + bottomBar = bottomBar, + snackbarHost = snackbarHost, + floatingActionButton = floatingActionButton, + floatingActionButtonPosition = floatingActionButtonPosition, + backgroundColor = backgroundColor, + contentColor = contentColor, + contentWindowInsets = contentWindowInsets, + ) { innerPadding -> + Box(modifier = Modifier.padding(innerPadding)) { + content() + } + } +} diff --git a/app/src/main/res/layout/share_fragment.xml b/app/src/main/res/layout/share_fragment.xml deleted file mode 100644 index 809650518..000000000 --- a/app/src/main/res/layout/share_fragment.xml +++ /dev/null @@ -1,63 +0,0 @@ - - - - - - - - - - - - - - - - - -