mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor: KMP Migration, Messaging Modularization, and Handshake Robustness (#4631)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
b3f88bd94f
commit
d408964f07
144 changed files with 1460 additions and 664 deletions
|
|
@ -23,10 +23,10 @@ import android.content.Context
|
|||
import kotlinx.coroutines.runBlocking
|
||||
import no.nordicsemi.android.dfu.DfuBaseService
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.meshtastic.core.model.BuildConfig
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.firmware_update_channel_description
|
||||
import org.meshtastic.core.resources.firmware_update_channel_name
|
||||
import org.meshtastic.core.model.util.isDebug as isDebugFlag
|
||||
|
||||
class FirmwareDfuService : DfuBaseService() {
|
||||
override fun onCreate() {
|
||||
|
|
@ -57,5 +57,5 @@ class FirmwareDfuService : DfuBaseService() {
|
|||
null
|
||||
}
|
||||
|
||||
override fun isDebug(): Boolean = BuildConfig.DEBUG
|
||||
override fun isDebug(): Boolean = isDebugFlag
|
||||
}
|
||||
|
|
|
|||
|
|
@ -580,7 +580,8 @@ private fun DeviceInfoCard(
|
|||
val currentVersionString =
|
||||
stringResource(
|
||||
Res.string.firmware_update_currently_installed,
|
||||
currentFirmwareVersion ?: stringResource(Res.string.firmware_update_unknown_release),
|
||||
currentFirmwareVersion?.takeIf { it.isNotBlank() }
|
||||
?: stringResource(Res.string.firmware_update_unknown_release),
|
||||
)
|
||||
Text(modifier = Modifier.fillMaxWidth(), text = currentVersionString)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
|
@ -825,7 +826,7 @@ private fun VerificationFailedState(onRetry: () -> Unit, onIgnore: () -> Unit) {
|
|||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Row(horizontalArrangement = spacedBy(16.dp)) {
|
||||
OutlinedButton(onClick = onRetry) {
|
||||
Icon(MeshtasticIcons.Refresh, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ package org.meshtastic.feature.map
|
|||
|
||||
import android.Manifest
|
||||
import android.graphics.Paint
|
||||
import android.text.format.DateUtils
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
|
|
@ -85,6 +84,7 @@ import org.jetbrains.compose.resources.StringResource
|
|||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.common.gpsDisabled
|
||||
import org.meshtastic.core.common.hasGps
|
||||
import org.meshtastic.core.common.util.DateFormatter
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.database.entity.Packet
|
||||
import org.meshtastic.core.database.model.Node
|
||||
|
|
@ -496,12 +496,7 @@ fun MapView(
|
|||
val pt = waypoint.data.waypoint ?: return@mapNotNull null
|
||||
if (!mapFilterState.showWaypoints) return@mapNotNull null // Use collected mapFilterState
|
||||
val lock = if ((pt.locked_to ?: 0) != 0) "\uD83D\uDD12" else ""
|
||||
val time =
|
||||
DateUtils.formatDateTime(
|
||||
context,
|
||||
waypoint.received_time,
|
||||
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_ABBREV_ALL,
|
||||
)
|
||||
val time = DateFormatter.formatDateTime(waypoint.received_time)
|
||||
val label = (pt.name ?: "") + " " + formatAgo((waypoint.received_time / 1000).toInt())
|
||||
val emoji = String(Character.toChars(if ((pt.icon ?: 0) == 0) 128205 else pt.icon!!))
|
||||
val now = nowMillis
|
||||
|
|
@ -510,14 +505,7 @@ fun MapView(
|
|||
when {
|
||||
(pt.expire ?: 0) == 0 || pt.expire == Int.MAX_VALUE -> "Never"
|
||||
expireTimeMillis <= now -> "Expired"
|
||||
else ->
|
||||
DateUtils.getRelativeTimeSpanString(
|
||||
expireTimeMillis,
|
||||
now,
|
||||
DateUtils.MINUTE_IN_MILLIS,
|
||||
DateUtils.FORMAT_ABBREV_RELATIVE,
|
||||
)
|
||||
.toString()
|
||||
else -> DateFormatter.formatRelativeTime(expireTimeMillis)
|
||||
}
|
||||
MarkerWithLabel(this, label, emoji).apply {
|
||||
id = "${pt.id}"
|
||||
|
|
@ -719,6 +707,7 @@ fun MapView(
|
|||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
)
|
||||
} else {
|
||||
@Suppress("MagicNumber")
|
||||
Column(
|
||||
modifier = Modifier.padding(top = 16.dp, end = 16.dp).align(Alignment.TopEnd),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
|
|
@ -805,6 +794,7 @@ fun MapView(
|
|||
text = stringResource(Res.string.show_precision_circle),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
@Suppress("MagicNumber")
|
||||
Checkbox(
|
||||
checked = mapFilterState.showPrecisionCircle,
|
||||
onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() },
|
||||
|
|
@ -1075,7 +1065,8 @@ private const val TRACEROUTE_SINGLE_POINT_ZOOM = 12.0
|
|||
private const val TRACEROUTE_ZOOM_OUT_LEVELS = 0.5
|
||||
private const val WAYPOINT_ZOOM = 15.0
|
||||
|
||||
private fun Double.toRad(): Double = Math.toRadians(this)
|
||||
@Suppress("MagicNumber")
|
||||
private fun Double.toRad(): Double = this * Math.PI / 180.0
|
||||
|
||||
private fun bearingRad(from: GeoPoint, to: GeoPoint): Double {
|
||||
val lat1 = from.latitude.toRad()
|
||||
|
|
@ -1116,6 +1107,8 @@ private fun offsetPolyline(
|
|||
|
||||
return points.mapIndexed { index, point ->
|
||||
val heading = headings[index.coerceIn(0, headings.lastIndex)]
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
val perpendicularHeading = heading + (Math.PI / 2 * sideMultiplier)
|
||||
point.offsetPoint(perpendicularHeading, abs(offsetMeters))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
* Copyright (c) 2025-2026 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
|
||||
|
|
@ -14,11 +14,10 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.meshtastic.feature.map.model
|
||||
|
||||
import android.content.res.Resources
|
||||
import android.util.Log
|
||||
import co.touchlab.kermit.Logger
|
||||
import org.osmdroid.api.IMapView
|
||||
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourcePolicy
|
||||
|
|
@ -78,7 +77,7 @@ open class NOAAWmsTileSource(
|
|||
private var forceHttp = false
|
||||
|
||||
init {
|
||||
Log.i(IMapView.LOGTAG, "WMS support is BETA. Please report any issues")
|
||||
Logger.withTag(IMapView.LOGTAG).i { "WMS support is BETA. Please report any issues" }
|
||||
layer = layername
|
||||
this.version = version
|
||||
this.srs = srs
|
||||
|
|
@ -165,7 +164,7 @@ open class NOAAWmsTileSource(
|
|||
sb.append(bbox[minY]).append(",")
|
||||
sb.append(bbox[maxX]).append(",")
|
||||
sb.append(bbox[maxY])
|
||||
Log.i(IMapView.LOGTAG, sb.toString())
|
||||
Logger.withTag(IMapView.LOGTAG).i { sb.toString() }
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@
|
|||
<path
|
||||
android:fillColor="#3388ff"
|
||||
android:strokeWidth="1.25"
|
||||
android:strokeColor="@android:color/white"
|
||||
android:strokeColor="#ffffffff"
|
||||
android:pathData="M12,2C8.13,2 5,5.13 5,9c0,5.25 7,13 7,13s7,-7.75 7,-13c0,-3.87 -3.13,-7 -7,-7zM12,11.5c-1.38,0 -2.5,-1.12 -2.5,-2.5s1.12,-2.5 2.5,-2.5 2.5,1.12 2.5,2.5 -1.12,2.5 -2.5,2.5z" />
|
||||
<path
|
||||
android:fillColor="@android:color/transparent"
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M12,9m-2.5,0a2.5,2.5 0,1 1,5 0a2.5,2.5 0,1 1,-5 0" />
|
||||
</vector>
|
||||
</vector>
|
||||
|
|
@ -7,5 +7,5 @@
|
|||
android:fillColor="#3388ff"
|
||||
android:pathData="M12,12m-8,0a8,8 0,1 1,16 0a8,8 0,1 1,-16 0"
|
||||
android:strokeWidth="2.0"
|
||||
android:strokeColor="@android:color/white" />
|
||||
</vector>
|
||||
android:strokeColor="#ffffffff" />
|
||||
</vector>
|
||||
|
|
@ -7,5 +7,5 @@
|
|||
android:fillColor="#3388ff"
|
||||
android:pathData="M12,2L4.5,20.29l0.71,0.71L12,18l6.79,3 0.71,-0.71z"
|
||||
android:strokeWidth="1.5"
|
||||
android:strokeColor="@android:color/white" />
|
||||
</vector>
|
||||
android:strokeColor="#ffffffff" />
|
||||
</vector>
|
||||
|
|
@ -16,23 +16,6 @@
|
|||
*/
|
||||
import com.android.build.api.dsl.LibraryExtension
|
||||
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.meshtastic.android.library)
|
||||
alias(libs.plugins.meshtastic.android.library.compose)
|
||||
|
|
@ -42,19 +25,29 @@ plugins {
|
|||
configure<LibraryExtension> { namespace = "org.meshtastic.feature.messaging" }
|
||||
|
||||
dependencies {
|
||||
implementation(projects.core.analytics)
|
||||
implementation(projects.core.data)
|
||||
implementation(projects.core.database)
|
||||
implementation(projects.core.model)
|
||||
implementation(projects.core.navigation)
|
||||
implementation(projects.core.prefs)
|
||||
implementation(projects.core.proto)
|
||||
implementation(projects.core.service)
|
||||
implementation(projects.core.resources)
|
||||
implementation(projects.core.ui)
|
||||
|
||||
implementation(libs.androidx.compose.material.iconsExtended)
|
||||
implementation(libs.accompanist.permissions)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.material3.adaptive)
|
||||
implementation(libs.androidx.compose.material3.adaptive.layout)
|
||||
implementation(libs.androidx.compose.material3.adaptive.navigation)
|
||||
implementation(libs.androidx.compose.material.iconsExtended)
|
||||
implementation(libs.androidx.compose.ui.text)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
implementation(libs.androidx.paging.compose)
|
||||
implementation(libs.kermit)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,182 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.messaging.ui.contact
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
import androidx.compose.material3.adaptive.layout.AnimatedPane
|
||||
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
|
||||
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
|
||||
import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior
|
||||
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavDestination.Companion.hasRoute
|
||||
import androidx.navigation.NavHostController
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.navigation.ChannelsRoutes
|
||||
import org.meshtastic.core.navigation.ContactsRoutes
|
||||
import org.meshtastic.core.navigation.NodesRoutes
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.conversations
|
||||
import org.meshtastic.core.ui.component.ScrollToTopEvent
|
||||
import org.meshtastic.core.ui.icon.Conversations
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.feature.messaging.MessageScreen
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import org.meshtastic.proto.SharedContact
|
||||
|
||||
@Suppress("LongMethod", "LongParameterList")
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Composable
|
||||
fun AdaptiveContactsScreen(
|
||||
navController: NavHostController,
|
||||
scrollToTopEvents: Flow<ScrollToTopEvent>,
|
||||
sharedContactRequested: SharedContact?,
|
||||
requestChannelSet: ChannelSet?,
|
||||
onHandleScannedUri: (Uri, onInvalid: () -> Unit) -> Unit,
|
||||
onClearSharedContactRequested: () -> Unit,
|
||||
onClearRequestChannelUrl: () -> Unit,
|
||||
initialContactKey: String? = null,
|
||||
initialMessage: String = "",
|
||||
) {
|
||||
val navigator = rememberListDetailPaneScaffoldNavigator<String>()
|
||||
val scope = rememberCoroutineScope()
|
||||
val backNavigationBehavior = BackNavigationBehavior.PopUntilScaffoldValueChange
|
||||
|
||||
val handleBack: () -> Unit = {
|
||||
val currentEntry = navController.currentBackStackEntry
|
||||
val isContactsRoute = currentEntry?.destination?.hasRoute<ContactsRoutes.Contacts>() == true
|
||||
|
||||
// Check if we navigated here from another screen (e.g., from Nodes or Map)
|
||||
val previousEntry = navController.previousBackStackEntry
|
||||
val isFromDifferentGraph = previousEntry?.destination?.hasRoute<ContactsRoutes.ContactsGraph>() == false
|
||||
|
||||
if (isFromDifferentGraph && !isContactsRoute) {
|
||||
// Navigate back via NavController to return to the previous screen (e.g. Node Details)
|
||||
navController.navigateUp()
|
||||
} else {
|
||||
// Close the detail pane within the adaptive scaffold
|
||||
scope.launch { navigator.navigateBack(backNavigationBehavior) }
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail) { handleBack() }
|
||||
|
||||
LaunchedEffect(initialContactKey) {
|
||||
if (initialContactKey != null) {
|
||||
navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, initialContactKey)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(scrollToTopEvents) {
|
||||
scrollToTopEvents.collect { event ->
|
||||
if (
|
||||
event is ScrollToTopEvent.ConversationsTabPressed &&
|
||||
navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail
|
||||
) {
|
||||
if (navigator.canNavigateBack(backNavigationBehavior)) {
|
||||
navigator.navigateBack(backNavigationBehavior)
|
||||
} else {
|
||||
navigator.navigateTo(ListDetailPaneScaffoldRole.List)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ListDetailPaneScaffold(
|
||||
directive = navigator.scaffoldDirective,
|
||||
value = navigator.scaffoldValue,
|
||||
listPane = {
|
||||
AnimatedPane {
|
||||
ContactsScreen(
|
||||
onNavigateToShare = { navController.navigate(ChannelsRoutes.ChannelsGraph) },
|
||||
sharedContactRequested = sharedContactRequested,
|
||||
requestChannelSet = requestChannelSet,
|
||||
onHandleScannedUri = onHandleScannedUri,
|
||||
onClearSharedContactRequested = onClearSharedContactRequested,
|
||||
onClearRequestChannelUrl = onClearRequestChannelUrl,
|
||||
onClickNodeChip = {
|
||||
navController.navigate(NodesRoutes.NodeDetailGraph(it)) {
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
onNavigateToMessages = { contactKey ->
|
||||
scope.launch { navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, contactKey) }
|
||||
},
|
||||
onNavigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
|
||||
scrollToTopEvents = scrollToTopEvents,
|
||||
activeContactKey = navigator.currentDestination?.contentKey,
|
||||
)
|
||||
}
|
||||
},
|
||||
detailPane = {
|
||||
AnimatedPane {
|
||||
navigator.currentDestination?.contentKey?.let { contactKey ->
|
||||
key(contactKey) {
|
||||
MessageScreen(
|
||||
contactKey = contactKey,
|
||||
message = if (contactKey == initialContactKey) initialMessage else "",
|
||||
navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
|
||||
navigateToQuickChatOptions = { navController.navigate(ContactsRoutes.QuickChat) },
|
||||
onNavigateBack = handleBack,
|
||||
)
|
||||
}
|
||||
} ?: PlaceholderScreen()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaceholderScreen() {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
|
||||
Icon(
|
||||
imageVector = MeshtasticIcons.Conversations,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = stringResource(Res.string.conversations),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.messaging.ui.contact
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.twotone.VolumeOff
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.AssistChipDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.common.util.DateFormatter
|
||||
import org.meshtastic.core.model.Contact
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.sample_message
|
||||
import org.meshtastic.core.resources.some_username
|
||||
import org.meshtastic.core.resources.unknown_username
|
||||
import org.meshtastic.core.ui.component.SecurityIcon
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun ContactItem(
|
||||
contact: Contact,
|
||||
selected: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
isActive: Boolean = false,
|
||||
onClick: () -> Unit = {},
|
||||
onLongClick: () -> Unit = {},
|
||||
onNodeChipClick: () -> Unit = {},
|
||||
channels: ChannelSet? = null,
|
||||
) = with(contact) {
|
||||
val isOutlined = !selected && !isActive
|
||||
|
||||
val colors =
|
||||
if (isOutlined) {
|
||||
CardDefaults.outlinedCardColors(containerColor = Color.Transparent)
|
||||
} else {
|
||||
val containerColor = if (selected) Color.Gray else MaterialTheme.colorScheme.surfaceVariant
|
||||
CardDefaults.cardColors(containerColor = containerColor)
|
||||
}
|
||||
|
||||
val border =
|
||||
if (isOutlined) {
|
||||
CardDefaults.outlinedCardBorder()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier =
|
||||
modifier
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.semantics { contentDescription = shortName },
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = colors,
|
||||
border = border,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
||||
ContactHeader(contact = contact, channels = channels, onNodeChipClick = onNodeChipClick)
|
||||
|
||||
ChatMetadata(modifier = Modifier.padding(top = 4.dp), contact = contact)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactHeader(
|
||||
contact: Contact,
|
||||
channels: ChannelSet?,
|
||||
modifier: Modifier = Modifier,
|
||||
onNodeChipClick: () -> Unit = {},
|
||||
) {
|
||||
val colors =
|
||||
contact.nodeColors?.let {
|
||||
AssistChipDefaults.assistChipColors(labelColor = Color(it.first), containerColor = Color(it.second))
|
||||
} ?: AssistChipDefaults.assistChipColors()
|
||||
|
||||
Row(modifier = modifier.padding(0.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
AssistChip(
|
||||
onClick = onNodeChipClick,
|
||||
modifier =
|
||||
Modifier.width(IntrinsicSize.Min).height(32.dp).semantics { contentDescription = contact.shortName },
|
||||
label = {
|
||||
Text(
|
||||
text = contact.shortName,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
},
|
||||
colors = colors,
|
||||
)
|
||||
|
||||
// Show unlock icon for broadcast with default PSK
|
||||
val isBroadcast = with(contact.contactKey) { getOrNull(1) == '^' || endsWith("^all") || endsWith("^broadcast") }
|
||||
|
||||
if (isBroadcast && channels != null) {
|
||||
val channelIndex = contact.contactKey[0].digitToIntOrNull()
|
||||
channelIndex?.let { index -> SecurityIcon(channels, index) }
|
||||
}
|
||||
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 8.dp).weight(1f),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
text = contact.longName,
|
||||
)
|
||||
Text(
|
||||
text = contact.lastMessageTime?.let { DateFormatter.formatShortDate(it) }.orEmpty(),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val UNREAD_MESSAGE_LIMIT = 99
|
||||
|
||||
@Composable
|
||||
private fun ChatMetadata(contact: Contact, modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = contact.lastMessageText.orEmpty(),
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 2,
|
||||
)
|
||||
AnimatedVisibility(visible = contact.isMuted) {
|
||||
Icon(
|
||||
modifier = Modifier.padding(start = 4.dp).size(20.dp),
|
||||
imageVector = Icons.AutoMirrored.TwoTone.VolumeOff,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(modifier = Modifier.padding(start = 4.dp), visible = contact.unreadCount > 0) {
|
||||
val text =
|
||||
if (contact.unreadCount > UNREAD_MESSAGE_LIMIT) {
|
||||
"$UNREAD_MESSAGE_LIMIT+"
|
||||
} else {
|
||||
contact.unreadCount.toString()
|
||||
}
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
modifier =
|
||||
Modifier.background(MaterialTheme.colorScheme.primary, shape = CircleShape)
|
||||
.defaultMinSize(minWidth = 20.dp)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun ContactItemPreview() {
|
||||
val sampleContact =
|
||||
Contact(
|
||||
contactKey = "0^all",
|
||||
shortName = stringResource(Res.string.some_username),
|
||||
longName = stringResource(Res.string.unknown_username),
|
||||
lastMessageTime = 0L,
|
||||
lastMessageText = stringResource(Res.string.sample_message),
|
||||
unreadCount = 2,
|
||||
messageCount = 10,
|
||||
isMuted = true,
|
||||
isUnmessageable = false,
|
||||
)
|
||||
|
||||
val contactsList =
|
||||
listOf(
|
||||
sampleContact,
|
||||
sampleContact.copy(
|
||||
shortName = "0",
|
||||
longName = "A very long contact name that should be truncated.",
|
||||
lastMessageTime = 1000L,
|
||||
),
|
||||
)
|
||||
|
||||
AppTheme { Column { contactsList.forEach { contact -> ContactItem(contact = contact, selected = false) } } }
|
||||
}
|
||||
|
|
@ -0,0 +1,616 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.messaging.ui.contact
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.pluralStringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.database.entity.ContactSettings
|
||||
import org.meshtastic.core.model.Contact
|
||||
import org.meshtastic.core.model.util.TimeConstants
|
||||
import org.meshtastic.core.model.util.formatMuteRemainingTime
|
||||
import org.meshtastic.core.model.util.getChannel
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.are_you_sure
|
||||
import org.meshtastic.core.resources.cancel
|
||||
import org.meshtastic.core.resources.channel_invalid
|
||||
import org.meshtastic.core.resources.close_selection
|
||||
import org.meshtastic.core.resources.conversations
|
||||
import org.meshtastic.core.resources.currently
|
||||
import org.meshtastic.core.resources.delete
|
||||
import org.meshtastic.core.resources.delete_messages
|
||||
import org.meshtastic.core.resources.delete_selection
|
||||
import org.meshtastic.core.resources.mute_1_week
|
||||
import org.meshtastic.core.resources.mute_8_hours
|
||||
import org.meshtastic.core.resources.mute_always
|
||||
import org.meshtastic.core.resources.mute_notifications
|
||||
import org.meshtastic.core.resources.mute_status_always
|
||||
import org.meshtastic.core.resources.mute_status_muted_for_days
|
||||
import org.meshtastic.core.resources.mute_status_muted_for_hours
|
||||
import org.meshtastic.core.resources.mute_status_unmuted
|
||||
import org.meshtastic.core.resources.okay
|
||||
import org.meshtastic.core.resources.select_all
|
||||
import org.meshtastic.core.resources.unmute
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.component.MeshtasticDialog
|
||||
import org.meshtastic.core.ui.component.MeshtasticImportFAB
|
||||
import org.meshtastic.core.ui.component.MeshtasticTextDialog
|
||||
import org.meshtastic.core.ui.component.ScrollToTopEvent
|
||||
import org.meshtastic.core.ui.component.smartScrollToTop
|
||||
import org.meshtastic.core.ui.icon.Close
|
||||
import org.meshtastic.core.ui.icon.Delete
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.SelectAll
|
||||
import org.meshtastic.core.ui.icon.VolumeMuteTwoTone
|
||||
import org.meshtastic.core.ui.icon.VolumeUpTwoTone
|
||||
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
|
||||
import org.meshtastic.core.ui.util.showToast
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import org.meshtastic.proto.SharedContact
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod", "LongParameterList")
|
||||
@Composable
|
||||
fun ContactsScreen(
|
||||
onNavigateToShare: () -> Unit,
|
||||
sharedContactRequested: SharedContact?,
|
||||
requestChannelSet: ChannelSet?,
|
||||
onHandleScannedUri: (Uri, onInvalid: () -> Unit) -> Unit,
|
||||
onClearSharedContactRequested: () -> Unit,
|
||||
onClearRequestChannelUrl: () -> Unit,
|
||||
viewModel: ContactsViewModel = hiltViewModel<ContactsViewModel>(),
|
||||
onClickNodeChip: (Int) -> Unit = {},
|
||||
onNavigateToMessages: (String) -> Unit = {},
|
||||
onNavigateToNodeDetails: (Int) -> Unit = {},
|
||||
scrollToTopEvents: Flow<ScrollToTopEvent>? = null,
|
||||
activeContactKey: String? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
|
||||
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
|
||||
var showMuteDialog by remember { mutableStateOf(false) }
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
|
||||
// State for managing selected contacts
|
||||
val selectedContactKeys = remember { mutableStateListOf<String>() }
|
||||
val isSelectionModeActive by remember { derivedStateOf { selectedContactKeys.isNotEmpty() } }
|
||||
|
||||
// State for contacts list
|
||||
val pagedContacts = viewModel.contactListPaged.collectAsLazyPagingItems()
|
||||
|
||||
// Create channel placeholders (always show broadcast contacts, even when empty)
|
||||
val channels by viewModel.channels.collectAsStateWithLifecycle()
|
||||
val channelPlaceholders =
|
||||
remember(channels.settings.size) {
|
||||
(0 until channels.settings.size).map { ch ->
|
||||
Contact(
|
||||
contactKey = "$ch^all",
|
||||
shortName = "$ch",
|
||||
longName = channels.getChannel(ch)?.name ?: "Channel $ch",
|
||||
lastMessageTime = null,
|
||||
lastMessageText = "",
|
||||
unreadCount = 0,
|
||||
messageCount = 0,
|
||||
isMuted = false,
|
||||
isUnmessageable = false,
|
||||
nodeColors = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val contactsListState = rememberLazyListState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(scrollToTopEvents) {
|
||||
scrollToTopEvents?.collectLatest { event ->
|
||||
if (event is ScrollToTopEvent.ConversationsTabPressed) {
|
||||
contactsListState.smartScrollToTop(coroutineScope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Derived state for selected contacts and count
|
||||
val selectedContacts =
|
||||
remember(pagedContacts.itemCount, selectedContactKeys) {
|
||||
(0 until pagedContacts.itemCount)
|
||||
.mapNotNull { pagedContacts[it] }
|
||||
.filter { it.contactKey in selectedContactKeys }
|
||||
}
|
||||
// Get message count directly from repository for selected contacts
|
||||
var selectedCount by remember { mutableIntStateOf(0) }
|
||||
LaunchedEffect(selectedContactKeys.size, selectedContactKeys.joinToString(",")) {
|
||||
selectedCount = viewModel.getTotalMessageCount(selectedContactKeys.toList())
|
||||
}
|
||||
val isAllMuted = remember(selectedContacts) { selectedContacts.all { it.isMuted } }
|
||||
|
||||
requestChannelSet?.let { ScannedQrCodeDialog(it, onDismiss = { onClearRequestChannelUrl() }) }
|
||||
|
||||
// Callback functions for item interaction
|
||||
val onContactClick: (Contact) -> Unit = { contact ->
|
||||
if (isSelectionModeActive) {
|
||||
// If in selection mode, toggle selection
|
||||
if (selectedContactKeys.contains(contact.contactKey)) {
|
||||
selectedContactKeys.remove(contact.contactKey)
|
||||
} else {
|
||||
selectedContactKeys.add(contact.contactKey)
|
||||
}
|
||||
} else {
|
||||
// If not in selection mode, navigate to messages
|
||||
onNavigateToMessages(contact.contactKey)
|
||||
}
|
||||
}
|
||||
|
||||
val onNodeChipClick: (Contact) -> Unit = { contact ->
|
||||
if (contact.contactKey.contains("!")) {
|
||||
// if it's a node, look up the nodeNum including the !
|
||||
val nodeKey = contact.contactKey.substring(1)
|
||||
val node = viewModel.getNode(nodeKey)
|
||||
onNavigateToNodeDetails(node.num)
|
||||
} else {
|
||||
// Channels
|
||||
}
|
||||
}
|
||||
|
||||
val onContactLongClick: (Contact) -> Unit = { contact ->
|
||||
// Enter selection mode and select the item on long press
|
||||
if (!isSelectionModeActive) {
|
||||
selectedContactKeys.add(contact.contactKey)
|
||||
} else {
|
||||
// If already in selection mode, toggle selection
|
||||
if (selectedContactKeys.contains(contact.contactKey)) {
|
||||
selectedContactKeys.remove(contact.contactKey)
|
||||
} else {
|
||||
selectedContactKeys.add(contact.contactKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
MainAppBar(
|
||||
title = stringResource(Res.string.conversations),
|
||||
ourNode = ourNode,
|
||||
showNodeChip = ourNode != null && connectionState.isConnected(),
|
||||
canNavigateUp = false,
|
||||
onNavigateUp = {},
|
||||
actions = {},
|
||||
onClickChip = { onClickNodeChip(it.num) },
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (connectionState.isConnected()) {
|
||||
MeshtasticImportFAB(
|
||||
sharedContact = sharedContactRequested,
|
||||
onImport = { uri ->
|
||||
onHandleScannedUri(uri) { scope.launch { context.showToast(Res.string.channel_invalid) } }
|
||||
},
|
||||
onShareChannels = onNavigateToShare,
|
||||
onDismissSharedContact = { onClearSharedContactRequested() },
|
||||
isContactContext = true,
|
||||
)
|
||||
}
|
||||
},
|
||||
) { paddingValues ->
|
||||
Column(modifier = Modifier.padding(paddingValues)) {
|
||||
if (isSelectionModeActive) {
|
||||
// Display selection toolbar when in selection mode
|
||||
SelectionToolbar(
|
||||
selectedCount = selectedContactKeys.size,
|
||||
onCloseSelection = { selectedContactKeys.clear() },
|
||||
onMuteSelected = { showMuteDialog = true },
|
||||
onDeleteSelected = { showDeleteDialog = true },
|
||||
onSelectAll = {
|
||||
selectedContactKeys.clear()
|
||||
selectedContactKeys.addAll(
|
||||
(0 until pagedContacts.itemCount).mapNotNull { pagedContacts[it]?.contactKey },
|
||||
)
|
||||
},
|
||||
isAllMuted = isAllMuted, // Pass the derived state
|
||||
)
|
||||
}
|
||||
|
||||
ContactListViewPaged(
|
||||
contacts = pagedContacts,
|
||||
channelPlaceholders = channelPlaceholders,
|
||||
selectedList = selectedContactKeys,
|
||||
activeContactKey = activeContactKey,
|
||||
onClick = onContactClick,
|
||||
onLongClick = onContactLongClick,
|
||||
onNodeChipClick = onNodeChipClick,
|
||||
listState = contactsListState,
|
||||
channels = channels,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showDeleteDialog) {
|
||||
DeleteConfirmationDialog(
|
||||
selectedCount = selectedCount,
|
||||
onDismiss = { showDeleteDialog = false },
|
||||
onConfirm = {
|
||||
showDeleteDialog = false
|
||||
viewModel.deleteContacts(selectedContactKeys.toList())
|
||||
selectedContactKeys.clear()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Get contact settings for the dialog
|
||||
val contactSettings by viewModel.getContactSettings().collectAsStateWithLifecycle(initialValue = emptyMap())
|
||||
|
||||
if (showMuteDialog) {
|
||||
MuteNotificationsDialog(
|
||||
selectedContactKeys = selectedContactKeys.toList(),
|
||||
contactSettings = contactSettings,
|
||||
onDismiss = { showMuteDialog = false },
|
||||
onConfirm = { muteUntil ->
|
||||
showMuteDialog = false
|
||||
viewModel.setMuteUntil(selectedContactKeys.toList(), muteUntil)
|
||||
selectedContactKeys.clear()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun MuteNotificationsDialog(
|
||||
selectedContactKeys: List<String>,
|
||||
contactSettings: Map<String, ContactSettings>,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (Long) -> Unit, // Lambda to handle the confirmed mute duration
|
||||
) {
|
||||
// Options for mute duration
|
||||
val muteOptions = remember {
|
||||
listOf(
|
||||
Res.string.unmute to 0L,
|
||||
Res.string.mute_8_hours to TimeConstants.EIGHT_HOURS.inWholeMilliseconds,
|
||||
Res.string.mute_1_week to 7.days.inWholeMilliseconds,
|
||||
Res.string.mute_always to Long.MAX_VALUE,
|
||||
)
|
||||
}
|
||||
|
||||
// State to hold the selected mute duration index
|
||||
var selectedOptionIndex by remember { mutableIntStateOf(2) } // Default to "Always"
|
||||
|
||||
MeshtasticDialog(
|
||||
onDismiss = onDismiss, // Dismiss the dialog when clicked outside
|
||||
titleRes = Res.string.mute_notifications,
|
||||
confirmTextRes = Res.string.okay,
|
||||
onConfirm = {
|
||||
val selectedMuteDuration = muteOptions[selectedOptionIndex].second
|
||||
onConfirm(selectedMuteDuration)
|
||||
onDismiss() // Dismiss the dialog after confirming
|
||||
},
|
||||
dismissTextRes = Res.string.cancel,
|
||||
text = {
|
||||
Column {
|
||||
// Show current mute status
|
||||
selectedContactKeys.forEach { contactKey ->
|
||||
contactSettings[contactKey]?.let { settings ->
|
||||
val now = nowMillis
|
||||
val statusText =
|
||||
when {
|
||||
settings.muteUntil > 0 && settings.muteUntil != Long.MAX_VALUE -> {
|
||||
val remaining = settings.muteUntil - now
|
||||
if (remaining > 0) {
|
||||
val (days, hours) = formatMuteRemainingTime(remaining)
|
||||
if (days >= 1) {
|
||||
stringResource(Res.string.mute_status_muted_for_days, days, hours)
|
||||
} else {
|
||||
stringResource(Res.string.mute_status_muted_for_hours, hours)
|
||||
}
|
||||
} else {
|
||||
stringResource(Res.string.mute_status_unmuted)
|
||||
}
|
||||
}
|
||||
settings.muteUntil == Long.MAX_VALUE -> stringResource(Res.string.mute_status_always)
|
||||
else -> stringResource(Res.string.mute_status_unmuted)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(Res.string.currently) + " " + statusText,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
muteOptions.forEachIndexed { index, (stringRes, _) ->
|
||||
val isSelected = index == selectedOptionIndex
|
||||
val text = stringResource(stringRes)
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.selectable(selected = isSelected, onClick = { selectedOptionIndex = index })
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(selected = isSelected, onClick = { selectedOptionIndex = index })
|
||||
Text(text = text, modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeleteConfirmationDialog(
|
||||
selectedCount: Int, // Number of items to be deleted
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit, // Lambda to handle the delete action
|
||||
) {
|
||||
val deleteMessage =
|
||||
pluralStringResource(
|
||||
Res.plurals.delete_messages,
|
||||
selectedCount,
|
||||
selectedCount, // Pass the count as a format argument
|
||||
)
|
||||
|
||||
MeshtasticTextDialog(
|
||||
titleRes = Res.string.are_you_sure,
|
||||
message = deleteMessage,
|
||||
confirmTextRes = Res.string.delete,
|
||||
onConfirm = {
|
||||
onConfirm()
|
||||
onDismiss() // Dismiss the dialog after confirming
|
||||
},
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SelectionToolbar(
|
||||
selectedCount: Int,
|
||||
onCloseSelection: () -> Unit,
|
||||
onMuteSelected: () -> Unit,
|
||||
onDeleteSelected: () -> Unit,
|
||||
onSelectAll: () -> Unit,
|
||||
isAllMuted: Boolean,
|
||||
) {
|
||||
TopAppBar(
|
||||
title = { Text(text = "$selectedCount") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onCloseSelection) {
|
||||
Icon(MeshtasticIcons.Close, contentDescription = stringResource(Res.string.close_selection))
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onMuteSelected) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (isAllMuted) {
|
||||
MeshtasticIcons.VolumeUpTwoTone
|
||||
} else {
|
||||
MeshtasticIcons.VolumeMuteTwoTone
|
||||
},
|
||||
contentDescription =
|
||||
if (isAllMuted) {
|
||||
"Unmute selected"
|
||||
} else {
|
||||
"Mute selected"
|
||||
},
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onDeleteSelected) {
|
||||
Icon(MeshtasticIcons.Delete, contentDescription = stringResource(Res.string.delete_selection))
|
||||
}
|
||||
IconButton(onClick = onSelectAll) {
|
||||
Icon(MeshtasticIcons.SelectAll, contentDescription = stringResource(Res.string.select_all))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactListViewPaged(
|
||||
contacts: LazyPagingItems<Contact>,
|
||||
channelPlaceholders: List<Contact>,
|
||||
selectedList: List<String>,
|
||||
activeContactKey: String?,
|
||||
onClick: (Contact) -> Unit,
|
||||
onLongClick: (Contact) -> Unit,
|
||||
onNodeChipClick: (Contact) -> Unit,
|
||||
listState: LazyListState,
|
||||
modifier: Modifier = Modifier,
|
||||
channels: ChannelSet? = null,
|
||||
) {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
if (contacts.loadState.refresh is LoadState.Loading && contacts.itemCount == 0) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
} else {
|
||||
ContactListContentInternal(
|
||||
contacts = contacts,
|
||||
channelPlaceholders = channelPlaceholders,
|
||||
selectedList = selectedList,
|
||||
activeContactKey = activeContactKey,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
onNodeChipClick = onNodeChipClick,
|
||||
listState = listState,
|
||||
channels = channels,
|
||||
haptic = haptic,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactListContentInternal(
|
||||
contacts: LazyPagingItems<Contact>,
|
||||
channelPlaceholders: List<Contact>,
|
||||
selectedList: List<String>,
|
||||
activeContactKey: String?,
|
||||
onClick: (Contact) -> Unit,
|
||||
onLongClick: (Contact) -> Unit,
|
||||
onNodeChipClick: (Contact) -> Unit,
|
||||
listState: LazyListState,
|
||||
channels: ChannelSet?,
|
||||
haptic: HapticFeedback,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val visiblePlaceholders = rememberVisiblePlaceholders(contacts, channelPlaceholders)
|
||||
|
||||
LazyColumn(state = listState, modifier = modifier.fillMaxSize()) {
|
||||
contactListPlaceholdersItems(
|
||||
placeholders = visiblePlaceholders,
|
||||
selectedList = selectedList,
|
||||
activeContactKey = activeContactKey,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
onNodeChipClick = onNodeChipClick,
|
||||
channels = channels,
|
||||
haptic = haptic,
|
||||
)
|
||||
|
||||
contactListPagedItems(
|
||||
contacts = contacts,
|
||||
selectedList = selectedList,
|
||||
activeContactKey = activeContactKey,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
onNodeChipClick = onNodeChipClick,
|
||||
channels = channels,
|
||||
haptic = haptic,
|
||||
)
|
||||
|
||||
contactListAppendLoadingItem(contacts)
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.contactListPlaceholdersItems(
|
||||
placeholders: List<Contact>,
|
||||
selectedList: List<String>,
|
||||
activeContactKey: String?,
|
||||
onClick: (Contact) -> Unit,
|
||||
onLongClick: (Contact) -> Unit,
|
||||
onNodeChipClick: (Contact) -> Unit,
|
||||
channels: ChannelSet?,
|
||||
haptic: HapticFeedback,
|
||||
) {
|
||||
items(count = placeholders.size, key = { index -> placeholders[index].contactKey }) { index ->
|
||||
val contact = placeholders[index]
|
||||
ContactItem(
|
||||
contact = contact,
|
||||
selected = selectedList.contains(contact.contactKey),
|
||||
isActive = contact.contactKey == activeContactKey,
|
||||
onClick = { onClick(contact) },
|
||||
onLongClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onLongClick(contact)
|
||||
},
|
||||
onNodeChipClick = { onNodeChipClick(contact) },
|
||||
channels = channels,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.contactListPagedItems(
|
||||
contacts: LazyPagingItems<Contact>,
|
||||
selectedList: List<String>,
|
||||
activeContactKey: String?,
|
||||
onClick: (Contact) -> Unit,
|
||||
onLongClick: (Contact) -> Unit,
|
||||
onNodeChipClick: (Contact) -> Unit,
|
||||
channels: ChannelSet?,
|
||||
haptic: HapticFeedback,
|
||||
) {
|
||||
items(count = contacts.itemCount, key = { index -> contacts[index]?.contactKey ?: index }) { index ->
|
||||
contacts[index]?.let { contact ->
|
||||
ContactItem(
|
||||
contact = contact,
|
||||
selected = selectedList.contains(contact.contactKey),
|
||||
isActive = contact.contactKey == activeContactKey,
|
||||
onClick = { onClick(contact) },
|
||||
onLongClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onLongClick(contact)
|
||||
},
|
||||
onNodeChipClick = { onNodeChipClick(contact) },
|
||||
channels = channels,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.contactListAppendLoadingItem(contacts: LazyPagingItems<Contact>) {
|
||||
if (contacts.loadState.append is LoadState.Loading) {
|
||||
item {
|
||||
Box(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberVisiblePlaceholders(
|
||||
contacts: LazyPagingItems<Contact>,
|
||||
channelPlaceholders: List<Contact>,
|
||||
): List<Contact> = remember(contacts.itemCount, channelPlaceholders) {
|
||||
val pagedKeys = (0 until contacts.itemCount).mapNotNull { contacts[it]?.contactKey }.toSet()
|
||||
channelPlaceholders.filter { it.contactKey !in pagedKeys }
|
||||
}
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.messaging.ui.contact
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.cachedIn
|
||||
import androidx.paging.map
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.data.repository.PacketRepository
|
||||
import org.meshtastic.core.data.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.database.entity.ContactSettings
|
||||
import org.meshtastic.core.database.entity.MyNodeEntity
|
||||
import org.meshtastic.core.database.entity.Packet
|
||||
import org.meshtastic.core.model.Contact
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.util.getChannel
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import javax.inject.Inject
|
||||
import kotlin.collections.map as collectionsMap
|
||||
|
||||
@HiltViewModel
|
||||
class ContactsViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val nodeRepository: NodeRepository,
|
||||
private val packetRepository: PacketRepository,
|
||||
radioConfigRepository: RadioConfigRepository,
|
||||
serviceRepository: ServiceRepository,
|
||||
) : ViewModel() {
|
||||
val ourNodeInfo = nodeRepository.ourNodeInfo
|
||||
|
||||
val connectionState = serviceRepository.connectionState
|
||||
|
||||
val channels = radioConfigRepository.channelSetFlow.stateInWhileSubscribed(initialValue = ChannelSet())
|
||||
|
||||
// Combine node info and myId to reduce argument count in subsequent combines
|
||||
private val identityFlow: Flow<Pair<MyNodeEntity?, String?>> =
|
||||
combine(nodeRepository.myNodeInfo, nodeRepository.myId) { info, id -> Pair(info, id) }
|
||||
|
||||
/**
|
||||
* Non-paginated contact list.
|
||||
*
|
||||
* NOTE: This is kept for ShareScreen which needs a simple, non-paginated list of contacts. The main ContactsScreen
|
||||
* uses [contactListPaged] instead for better performance with large contact lists.
|
||||
*
|
||||
* @see contactListPaged for the paginated version used in ContactsScreen
|
||||
*/
|
||||
val contactList =
|
||||
combine(identityFlow, packetRepository.getContacts(), channels, packetRepository.getContactSettings()) {
|
||||
identity,
|
||||
contacts,
|
||||
channelSet,
|
||||
settings,
|
||||
->
|
||||
val (myNodeInfo, myId) = identity
|
||||
val myNodeNum = myNodeInfo?.myNodeNum ?: return@combine emptyList()
|
||||
// Add empty channel placeholders (always show Broadcast contacts, even when empty)
|
||||
val placeholder =
|
||||
(0 until channelSet.settings.size).associate { ch ->
|
||||
val contactKey = "$ch${DataPacket.ID_BROADCAST}"
|
||||
val data = DataPacket(bytes = null, dataType = 1, time = 0L, channel = ch)
|
||||
contactKey to Packet(0L, myNodeNum, 1, contactKey, 0L, true, data)
|
||||
}
|
||||
|
||||
(contacts + (placeholder - contacts.keys)).values.collectionsMap { packet ->
|
||||
val data = packet.data
|
||||
val contactKey = packet.contact_key
|
||||
|
||||
// Determine if this is my message (originated on this device)
|
||||
val fromLocal = (data.from == DataPacket.ID_LOCAL || (myId != null && data.from == myId))
|
||||
val toBroadcast = data.to == DataPacket.ID_BROADCAST
|
||||
|
||||
// grab usernames from NodeInfo
|
||||
val userId = if (fromLocal) data.to else data.from
|
||||
val user = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST)
|
||||
val node = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST)
|
||||
|
||||
val shortName = user.short_name
|
||||
val longName =
|
||||
if (toBroadcast) {
|
||||
channelSet.getChannel(data.channel)?.name ?: "Channel ${data.channel}"
|
||||
} else {
|
||||
user.long_name
|
||||
}
|
||||
|
||||
Contact(
|
||||
contactKey = contactKey,
|
||||
shortName = if (toBroadcast) data.channel.toString() else shortName,
|
||||
longName = longName,
|
||||
lastMessageTime = if (data.time != 0L) data.time else null,
|
||||
lastMessageText = if (fromLocal) data.text else "$shortName: ${data.text}",
|
||||
unreadCount = packetRepository.getUnreadCount(contactKey),
|
||||
messageCount = packetRepository.getMessageCount(contactKey),
|
||||
isMuted = settings[contactKey]?.isMuted == true,
|
||||
isUnmessageable = user.is_unmessagable ?: false,
|
||||
nodeColors =
|
||||
if (!toBroadcast) {
|
||||
node.colors
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
.stateInWhileSubscribed(initialValue = emptyList())
|
||||
|
||||
val contactListPaged: Flow<PagingData<Contact>> =
|
||||
combine(identityFlow, channels, packetRepository.getContactSettings()) { identity, channelSet, settings ->
|
||||
val (myNodeInfo, myId) = identity
|
||||
ContactsPagedParams(myNodeInfo?.myNodeNum, channelSet, settings, myId)
|
||||
}
|
||||
.flatMapLatest { params ->
|
||||
val channelSet = params.channelSet
|
||||
val settings = params.settings
|
||||
val myId = params.myId
|
||||
|
||||
packetRepository.getContactsPaged().map { pagingData ->
|
||||
pagingData.map { packet ->
|
||||
val data = packet.data
|
||||
val contactKey = packet.contact_key
|
||||
|
||||
// Determine if this is my message (originated on this device)
|
||||
val fromLocal = (data.from == DataPacket.ID_LOCAL || (myId != null && data.from == myId))
|
||||
val toBroadcast = data.to == DataPacket.ID_BROADCAST
|
||||
|
||||
// grab usernames from NodeInfo
|
||||
val userId = if (fromLocal) data.to else data.from
|
||||
val user = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST)
|
||||
val node = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST)
|
||||
|
||||
val shortName = user.short_name
|
||||
val longName =
|
||||
if (toBroadcast) {
|
||||
channelSet.getChannel(data.channel)?.name ?: "Channel ${data.channel}"
|
||||
} else {
|
||||
user.long_name
|
||||
}
|
||||
|
||||
Contact(
|
||||
contactKey = contactKey,
|
||||
shortName = if (toBroadcast) data.channel.toString() else shortName,
|
||||
longName = longName,
|
||||
lastMessageTime = if (data.time != 0L) data.time else null,
|
||||
lastMessageText = if (fromLocal) data.text else "$shortName: ${data.text}",
|
||||
unreadCount = packetRepository.getUnreadCount(contactKey),
|
||||
messageCount = packetRepository.getMessageCount(contactKey),
|
||||
isMuted = settings[contactKey]?.isMuted == true,
|
||||
isUnmessageable = user.is_unmessagable ?: false,
|
||||
nodeColors =
|
||||
if (!toBroadcast) {
|
||||
node.colors
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.cachedIn(viewModelScope)
|
||||
|
||||
fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST)
|
||||
|
||||
fun deleteContacts(contacts: List<String>) =
|
||||
viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteContacts(contacts) }
|
||||
|
||||
fun setMuteUntil(contacts: List<String>, until: Long) =
|
||||
viewModelScope.launch(Dispatchers.IO) { packetRepository.setMuteUntil(contacts, until) }
|
||||
|
||||
fun getContactSettings() = packetRepository.getContactSettings()
|
||||
|
||||
fun setContactFilteringDisabled(contactKey: String, disabled: Boolean) {
|
||||
viewModelScope.launch(Dispatchers.IO) { packetRepository.setContactFilteringDisabled(contactKey, disabled) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total message count for a list of contact keys. This queries the repository directly, so it works even if
|
||||
* contacts aren't loaded in the paged list.
|
||||
*/
|
||||
suspend fun getTotalMessageCount(contactKeys: List<String>): Int = if (contactKeys.isEmpty()) {
|
||||
0
|
||||
} else {
|
||||
contactKeys.sumOf { contactKey -> packetRepository.getMessageCount(contactKey) }
|
||||
}
|
||||
|
||||
private data class ContactsPagedParams(
|
||||
val myNodeNum: Int?,
|
||||
val channelSet: ChannelSet,
|
||||
val settings: Map<String, ContactSettings>,
|
||||
val myId: String?,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.messaging.ui.sharing
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Scaffold
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.model.Contact
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.sample_message
|
||||
import org.meshtastic.core.resources.share
|
||||
import org.meshtastic.core.resources.share_to
|
||||
import org.meshtastic.core.resources.some_username
|
||||
import org.meshtastic.core.resources.unknown_username
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.feature.messaging.ui.contact.ContactItem
|
||||
import org.meshtastic.feature.messaging.ui.contact.ContactsViewModel
|
||||
|
||||
@Composable
|
||||
fun ShareScreen(viewModel: ContactsViewModel = hiltViewModel(), onConfirm: (String) -> Unit, onNavigateUp: () -> Unit) {
|
||||
val contactList by viewModel.contactList.collectAsStateWithLifecycle()
|
||||
|
||||
ShareScreen(contacts = contactList, onConfirm = onConfirm, onNavigateUp = onNavigateUp)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ShareScreen(contacts: List<Contact>, onConfirm: (String) -> Unit, onNavigateUp: () -> Unit) {
|
||||
var selectedContact by remember { mutableStateOf("") }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
MainAppBar(
|
||||
title = stringResource(Res.string.share_to),
|
||||
ourNode = null,
|
||||
showNodeChip = false,
|
||||
canNavigateUp = true,
|
||||
onNavigateUp = onNavigateUp,
|
||||
actions = {},
|
||||
onClickChip = {},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(modifier = Modifier.padding(innerPadding)) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = PaddingValues(6.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
itemsIndexed(contacts, key = { index, contact -> "${contact.contactKey}#$index" }) { _, 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(Res.string.share),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewScreenSizes
|
||||
@Composable
|
||||
private fun ShareScreenPreview() {
|
||||
AppTheme {
|
||||
ShareScreen(
|
||||
contacts =
|
||||
listOf(
|
||||
Contact(
|
||||
contactKey = "0^all",
|
||||
shortName = stringResource(Res.string.some_username),
|
||||
longName = stringResource(Res.string.unknown_username),
|
||||
lastMessageTime = 0L,
|
||||
lastMessageText = stringResource(Res.string.sample_message),
|
||||
unreadCount = 2,
|
||||
messageCount = 10,
|
||||
isMuted = true,
|
||||
isUnmessageable = false,
|
||||
),
|
||||
),
|
||||
onConfirm = {},
|
||||
onNavigateUp = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -29,12 +29,12 @@ import kotlinx.coroutines.flow.combine
|
|||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.meshtastic.core.common.util.bearing
|
||||
import org.meshtastic.core.common.util.latLongToMeter
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.common.util.nowSeconds
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.model.util.bearing
|
||||
import org.meshtastic.core.model.util.latLongToMeter
|
||||
import org.meshtastic.core.model.util.toDistanceString
|
||||
import org.meshtastic.core.ui.component.precisionBitsToMeters
|
||||
import org.meshtastic.proto.Config
|
||||
|
|
|
|||
|
|
@ -40,8 +40,8 @@ import androidx.core.net.toUri
|
|||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.common.util.GPSFormat
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.model.util.GPSFormat
|
||||
import org.meshtastic.core.model.util.metersIn
|
||||
import org.meshtastic.core.model.util.toString
|
||||
import org.meshtastic.core.resources.Res
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@
|
|||
*/
|
||||
package org.meshtastic.feature.node.metrics
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
|
|
@ -39,11 +38,11 @@ import androidx.compose.runtime.mutableStateOf
|
|||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.common.util.DateFormatter
|
||||
import org.meshtastic.core.model.getNeighborInfoResponse
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.neighbor_info
|
||||
|
|
@ -85,8 +84,6 @@ fun NeighborInfoLogScreen(
|
|||
fun getUsername(nodeNum: Int): String =
|
||||
with(viewModel.getUser(nodeNum)) { "${long_name ?: ""} (${short_name ?: ""})" }
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
val statusGreen = MaterialTheme.colorScheme.StatusGreen
|
||||
val statusYellow = MaterialTheme.colorScheme.StatusYellow
|
||||
val statusOrange = MaterialTheme.colorScheme.StatusOrange
|
||||
|
|
@ -128,12 +125,7 @@ fun NeighborInfoLogScreen(
|
|||
}
|
||||
}
|
||||
|
||||
val time =
|
||||
DateUtils.formatDateTime(
|
||||
context,
|
||||
log.received_date,
|
||||
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_ABBREV_ALL,
|
||||
)
|
||||
val time = DateFormatter.formatDateTime(log.received_date)
|
||||
val text = if (result != null) "Success" else stringResource(Res.string.routing_error_no_response)
|
||||
val icon = if (result != null) MeshtasticIcons.Groups else MeshtasticIcons.PersonOff
|
||||
val header = stringResource(Res.string.neighbor_info)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@
|
|||
*/
|
||||
package org.meshtastic.feature.node.metrics
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
|
|
@ -40,7 +39,6 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
|
|
@ -49,6 +47,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
|||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.jetbrains.compose.resources.pluralStringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.common.util.DateFormatter
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.model.fullRouteDiscovery
|
||||
import org.meshtastic.core.model.getTracerouteResponse
|
||||
|
|
@ -106,7 +105,6 @@ fun TracerouteLogScreen(
|
|||
fun getUsername(nodeNum: Int): String =
|
||||
with(viewModel.getUser(nodeNum)) { "${long_name ?: ""} (${short_name ?: ""})" }
|
||||
|
||||
val context = LocalContext.current
|
||||
val statusGreen = MaterialTheme.colorScheme.StatusGreen
|
||||
val statusYellow = MaterialTheme.colorScheme.StatusYellow
|
||||
val statusOrange = MaterialTheme.colorScheme.StatusOrange
|
||||
|
|
@ -151,12 +149,7 @@ fun TracerouteLogScreen(
|
|||
}
|
||||
val route = remember(result) { result?.fromRadio?.packet?.fullRouteDiscovery }
|
||||
|
||||
val time =
|
||||
DateUtils.formatDateTime(
|
||||
context,
|
||||
log.received_date,
|
||||
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_ABBREV_ALL,
|
||||
)
|
||||
val time = DateFormatter.formatDateTime(log.received_date)
|
||||
val (text, icon) = route.getTextAndIcon()
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
|
|
@ -278,12 +271,7 @@ private fun RouteDiscovery?.getTextAndIcon(): Pair<String, ImageVector> = when {
|
|||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun TracerouteItemPreview() {
|
||||
val time =
|
||||
DateUtils.formatDateTime(
|
||||
LocalContext.current,
|
||||
nowMillis,
|
||||
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_ABBREV_ALL,
|
||||
)
|
||||
val time = DateFormatter.formatDateTime(nowMillis)
|
||||
AppTheme {
|
||||
MetricLogItem(
|
||||
icon = MeshtasticIcons.Group,
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import org.jetbrains.compose.resources.stringResource
|
|||
import org.meshtastic.core.barcode.extractWifiCredentials
|
||||
import org.meshtastic.core.barcode.rememberBarcodeScanner
|
||||
import org.meshtastic.core.model.util.handleMeshtasticUri
|
||||
import org.meshtastic.core.model.util.toCommonUri
|
||||
import org.meshtastic.core.nfc.NfcScannerEffect
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.advanced
|
||||
|
|
@ -120,7 +121,7 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBac
|
|||
if (contents != null) {
|
||||
val handled =
|
||||
handleMeshtasticUri(
|
||||
uri = contents.toUri(),
|
||||
uri = contents.toUri().toCommonUri(),
|
||||
onChannel = {}, // No-op, not supported in network config
|
||||
onContact = {}, // No-op, not supported in network config
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue