mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
Modularize messaging code (#3435)
This commit is contained in:
parent
cd1a54f506
commit
886e9cfede
37 changed files with 297 additions and 143 deletions
|
|
@ -17,39 +17,40 @@
|
|||
|
||||
package com.geeksville.mesh
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import com.geeksville.mesh.service.MeshServiceNotifications
|
||||
import com.geeksville.mesh.service.MeshServiceNotificationsImpl
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import org.meshtastic.core.common.BuildConfigProvider
|
||||
import org.meshtastic.core.service.MeshServiceNotifications
|
||||
import javax.inject.Singleton
|
||||
|
||||
@InstallIn(SingletonComponent::class)
|
||||
@Module
|
||||
object ApplicationModule {
|
||||
interface ApplicationModule {
|
||||
|
||||
@Provides fun provideProcessLifecycleOwner(): LifecycleOwner = ProcessLifecycleOwner.get()
|
||||
@Binds fun bindMeshServiceNotifications(impl: MeshServiceNotificationsImpl): MeshServiceNotifications
|
||||
|
||||
@Provides
|
||||
fun provideProcessLifecycle(processLifecycleOwner: LifecycleOwner): Lifecycle = processLifecycleOwner.lifecycle
|
||||
companion object {
|
||||
@Provides fun provideProcessLifecycleOwner(): LifecycleOwner = ProcessLifecycleOwner.get()
|
||||
|
||||
@Provides
|
||||
fun providesMeshServiceNotifications(application: Application): MeshServiceNotifications =
|
||||
MeshServiceNotifications(application)
|
||||
@Provides
|
||||
fun provideProcessLifecycle(processLifecycleOwner: LifecycleOwner): Lifecycle = processLifecycleOwner.lifecycle
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideBuildConfigProvider(): BuildConfigProvider = object : BuildConfigProvider {
|
||||
override val isDebug: Boolean = BuildConfig.DEBUG
|
||||
override val applicationId: String = BuildConfig.APPLICATION_ID
|
||||
override val versionCode: Int = BuildConfig.VERSION_CODE
|
||||
override val versionName: String = BuildConfig.VERSION_NAME
|
||||
override val absoluteMinFwVersion: String = BuildConfig.ABS_MIN_FW_VERSION
|
||||
override val minFwVersion: String = BuildConfig.MIN_FW_VERSION
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideBuildConfigProvider(): BuildConfigProvider = object : BuildConfigProvider {
|
||||
override val isDebug: Boolean = BuildConfig.DEBUG
|
||||
override val applicationId: String = BuildConfig.APPLICATION_ID
|
||||
override val versionCode: Int = BuildConfig.VERSION_CODE
|
||||
override val versionName: String = BuildConfig.VERSION_NAME
|
||||
override val absoluteMinFwVersion: String = BuildConfig.ABS_MIN_FW_VERSION
|
||||
override val minFwVersion: String = BuildConfig.MIN_FW_VERSION
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,10 +31,7 @@ import androidx.lifecycle.viewModelScope
|
|||
import androidx.navigation.NavHostController
|
||||
import com.geeksville.mesh.repository.radio.MeshActivity
|
||||
import com.geeksville.mesh.repository.radio.RadioInterfaceService
|
||||
import com.geeksville.mesh.service.MeshServiceNotifications
|
||||
import com.geeksville.mesh.ui.sharing.toSharedContact
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
|
|
@ -53,17 +50,17 @@ import org.meshtastic.core.data.repository.FirmwareReleaseRepository
|
|||
import org.meshtastic.core.data.repository.MeshLogRepository
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.data.repository.PacketRepository
|
||||
import org.meshtastic.core.data.repository.QuickChatActionRepository
|
||||
import org.meshtastic.core.data.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.database.entity.MyNodeEntity
|
||||
import org.meshtastic.core.database.entity.QuickChatAction
|
||||
import org.meshtastic.core.database.entity.asDeviceVersion
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.datastore.UiPreferencesDataSource
|
||||
import org.meshtastic.core.model.util.toChannelSet
|
||||
import org.meshtastic.core.service.IMeshService
|
||||
import org.meshtastic.core.service.MeshServiceNotifications
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.component.toSharedContact
|
||||
import org.meshtastic.proto.AdminProtos
|
||||
import org.meshtastic.proto.AppOnlyProtos
|
||||
import org.meshtastic.proto.ConfigProtos.Config
|
||||
|
|
@ -130,7 +127,6 @@ constructor(
|
|||
private val serviceRepository: ServiceRepository,
|
||||
radioInterfaceService: RadioInterfaceService,
|
||||
meshLogRepository: MeshLogRepository,
|
||||
private val quickChatActionRepository: QuickChatActionRepository,
|
||||
firmwareReleaseRepository: FirmwareReleaseRepository,
|
||||
private val uiPreferencesDataSource: UiPreferencesDataSource,
|
||||
private val meshServiceNotifications: MeshServiceNotifications,
|
||||
|
|
@ -217,12 +213,6 @@ constructor(
|
|||
.map { it.coerceAtLeast(0) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), 0)
|
||||
|
||||
val quickChatActions
|
||||
get() =
|
||||
quickChatActionRepository
|
||||
.getAllActions()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
// hardware info about our local device (can be null)
|
||||
val myNodeInfo: StateFlow<MyNodeEntity?>
|
||||
get() = nodeDB.myNodeInfo
|
||||
|
|
@ -337,20 +327,6 @@ constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun addQuickChatAction(action: QuickChatAction) =
|
||||
viewModelScope.launch(Dispatchers.IO) { quickChatActionRepository.upsert(action) }
|
||||
|
||||
fun deleteQuickChatAction(action: QuickChatAction) =
|
||||
viewModelScope.launch(Dispatchers.IO) { quickChatActionRepository.delete(action) }
|
||||
|
||||
fun updateActionPositions(actions: List<QuickChatAction>) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
for (position in actions.indices) {
|
||||
quickChatActionRepository.setItemPosition(actions[position].uuid, position)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val tracerouteResponse: LiveData<String?>
|
||||
get() = serviceRepository.tracerouteResponse.asLiveData()
|
||||
|
||||
|
|
|
|||
|
|
@ -24,13 +24,13 @@ import androidx.navigation.navDeepLink
|
|||
import androidx.navigation.navigation
|
||||
import androidx.navigation.toRoute
|
||||
import com.geeksville.mesh.ui.contact.ContactsScreen
|
||||
import com.geeksville.mesh.ui.message.MessageScreen
|
||||
import com.geeksville.mesh.ui.message.QuickChatScreen
|
||||
import com.geeksville.mesh.ui.sharing.ShareScreen
|
||||
import org.meshtastic.core.navigation.ChannelsRoutes
|
||||
import org.meshtastic.core.navigation.ContactsRoutes
|
||||
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
|
||||
import org.meshtastic.core.navigation.NodesRoutes
|
||||
import org.meshtastic.feature.messaging.MessageScreen
|
||||
import org.meshtastic.feature.messaging.QuickChatScreen
|
||||
|
||||
@Suppress("LongMethod")
|
||||
fun NavGraphBuilder.contactsGraph(navController: NavHostController) {
|
||||
|
|
|
|||
|
|
@ -81,6 +81,8 @@ import org.meshtastic.core.prefs.mesh.MeshPrefs
|
|||
import org.meshtastic.core.prefs.ui.UiPrefs
|
||||
import org.meshtastic.core.service.ConnectionState
|
||||
import org.meshtastic.core.service.IMeshService
|
||||
import org.meshtastic.core.service.MeshServiceNotifications
|
||||
import org.meshtastic.core.service.SERVICE_NOTIFY_ID
|
||||
import org.meshtastic.core.service.ServiceAction
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.core.strings.R
|
||||
|
|
@ -354,7 +356,7 @@ class MeshService : Service() {
|
|||
try {
|
||||
ServiceCompat.startForeground(
|
||||
this,
|
||||
MeshServiceNotifications.SERVICE_NOTIFY_ID,
|
||||
SERVICE_NOTIFY_ID,
|
||||
notification,
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
if (hasLocationPermission()) {
|
||||
|
|
|
|||
|
|
@ -37,13 +37,17 @@ import androidx.core.net.toUri
|
|||
import com.geeksville.mesh.MainActivity
|
||||
import com.geeksville.mesh.R.raw
|
||||
import com.geeksville.mesh.service.ReplyReceiver.Companion.KEY_TEXT_REPLY
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.meshtastic.core.database.entity.NodeEntity
|
||||
import org.meshtastic.core.model.util.formatUptime
|
||||
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
|
||||
import org.meshtastic.core.service.MeshServiceNotifications
|
||||
import org.meshtastic.core.service.SERVICE_NOTIFY_ID
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.proto.MeshProtos
|
||||
import org.meshtastic.proto.TelemetryProtos
|
||||
import org.meshtastic.proto.TelemetryProtos.LocalStats
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Manages the creation and display of all app notifications.
|
||||
|
|
@ -52,14 +56,14 @@ import org.meshtastic.proto.TelemetryProtos.LocalStats
|
|||
* notifications for various events like new messages, alerts, and service status changes.
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
class MeshServiceNotifications(private val context: Context) {
|
||||
class MeshServiceNotificationsImpl @Inject constructor(@ApplicationContext private val context: Context) :
|
||||
MeshServiceNotifications {
|
||||
|
||||
private val notificationManager = context.getSystemService<NotificationManager>()!!
|
||||
|
||||
companion object {
|
||||
private const val FIFTEEN_MINUTES_IN_MILLIS = 15L * 60 * 1000
|
||||
const val MAX_BATTERY_LEVEL = 100
|
||||
const val SERVICE_NOTIFY_ID = 101
|
||||
private val NOTIFICATION_LIGHT_COLOR = Color.BLUE
|
||||
}
|
||||
|
||||
|
|
@ -139,7 +143,7 @@ class MeshServiceNotifications(private val context: Context) {
|
|||
}
|
||||
}
|
||||
|
||||
fun clearNotifications() {
|
||||
override fun clearNotifications() {
|
||||
notificationManager.cancelAll()
|
||||
}
|
||||
|
||||
|
|
@ -147,7 +151,7 @@ class MeshServiceNotifications(private val context: Context) {
|
|||
* Creates all necessary notification channels on devices running Android O or newer. This should be called once
|
||||
* when the service is created.
|
||||
*/
|
||||
fun initChannels() {
|
||||
override fun initChannels() {
|
||||
NotificationType.allTypes().forEach { type -> createNotificationChannel(type) }
|
||||
}
|
||||
|
||||
|
|
@ -212,9 +216,9 @@ class MeshServiceNotifications(private val context: Context) {
|
|||
var cachedMessage: String? = null
|
||||
|
||||
// region Public Notification Methods
|
||||
fun updateServiceStateNotification(
|
||||
override fun updateServiceStateNotification(
|
||||
summaryString: String?,
|
||||
telemetry: TelemetryProtos.Telemetry? = cachedTelemetry,
|
||||
telemetry: TelemetryProtos.Telemetry?,
|
||||
): Notification {
|
||||
val hasLocalStats = telemetry?.hasLocalStats() == true
|
||||
val hasDeviceMetrics = telemetry?.hasDeviceMetrics() == true
|
||||
|
|
@ -249,39 +253,39 @@ class MeshServiceNotifications(private val context: Context) {
|
|||
return notification
|
||||
}
|
||||
|
||||
fun updateMessageNotification(contactKey: String, name: String, message: String, isBroadcast: Boolean) {
|
||||
override fun updateMessageNotification(contactKey: String, name: String, message: String, isBroadcast: Boolean) {
|
||||
val notification = createMessageNotification(contactKey, name, message, isBroadcast)
|
||||
// Use a consistent, unique ID for each message conversation.
|
||||
notificationManager.notify(contactKey.hashCode(), notification)
|
||||
}
|
||||
|
||||
fun showAlertNotification(contactKey: String, name: String, alert: String) {
|
||||
override fun showAlertNotification(contactKey: String, name: String, alert: String) {
|
||||
val notification = createAlertNotification(contactKey, name, alert)
|
||||
// Use a consistent, unique ID for each alert source.
|
||||
notificationManager.notify(name.hashCode(), notification)
|
||||
}
|
||||
|
||||
fun showNewNodeSeenNotification(node: NodeEntity) {
|
||||
override fun showNewNodeSeenNotification(node: NodeEntity) {
|
||||
val notification = createNewNodeSeenNotification(node.user.shortName, node.user.longName)
|
||||
notificationManager.notify(node.num, notification)
|
||||
}
|
||||
|
||||
fun showOrUpdateLowBatteryNotification(node: NodeEntity, isRemote: Boolean) {
|
||||
override fun showOrUpdateLowBatteryNotification(node: NodeEntity, isRemote: Boolean) {
|
||||
val notification = createLowBatteryNotification(node, isRemote)
|
||||
notificationManager.notify(node.num, notification)
|
||||
}
|
||||
|
||||
fun showClientNotification(clientNotification: MeshProtos.ClientNotification) {
|
||||
override fun showClientNotification(clientNotification: MeshProtos.ClientNotification) {
|
||||
val notification =
|
||||
createClientNotification(context.getString(R.string.client_notification), clientNotification.message)
|
||||
notificationManager.notify(clientNotification.toString().hashCode(), notification)
|
||||
}
|
||||
|
||||
fun cancelMessageNotification(contactKey: String) = notificationManager.cancel(contactKey.hashCode())
|
||||
override fun cancelMessageNotification(contactKey: String) = notificationManager.cancel(contactKey.hashCode())
|
||||
|
||||
fun cancelLowBatteryNotification(node: NodeEntity) = notificationManager.cancel(node.num)
|
||||
override fun cancelLowBatteryNotification(node: NodeEntity) = notificationManager.cancel(node.num)
|
||||
|
||||
fun clearClientNotification(notification: MeshProtos.ClientNotification) =
|
||||
override fun clearClientNotification(notification: MeshProtos.ClientNotification) =
|
||||
notificationManager.cancel(notification.toString().hashCode())
|
||||
|
||||
// endregion
|
||||
|
|
@ -22,6 +22,7 @@ import androidx.core.app.RemoteInput
|
|||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import jakarta.inject.Inject
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.service.MeshServiceNotifications
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
|
||||
/**
|
||||
|
|
@ -58,7 +59,7 @@ class ReplyReceiver : BroadcastReceiver() {
|
|||
val contactKey = intent.getStringExtra(CONTACT_KEY) ?: ""
|
||||
val message = remoteInput.getCharSequence(KEY_TEXT_REPLY)?.toString() ?: ""
|
||||
sendMessage(message, contactKey)
|
||||
MeshServiceNotifications(context).cancelMessageNotification(contactKey)
|
||||
MeshServiceNotificationsImpl(context).cancelMessageNotification(contactKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,7 +97,6 @@ import com.geeksville.mesh.ui.common.components.ScannedQrCodeDialog
|
|||
import com.geeksville.mesh.ui.connections.DeviceType
|
||||
import com.geeksville.mesh.ui.connections.components.ConnectionsNavIcon
|
||||
import com.geeksville.mesh.ui.metrics.annotateTraceroute
|
||||
import com.geeksville.mesh.ui.sharing.SharedContactDialog
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
|
|
@ -119,6 +118,7 @@ import org.meshtastic.core.ui.icon.Map
|
|||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.Nodes
|
||||
import org.meshtastic.core.ui.icon.Settings
|
||||
import org.meshtastic.core.ui.share.SharedContactDialog
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusBlue
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
|
||||
import org.meshtastic.feature.settings.navigation.settingsGraph
|
||||
|
|
|
|||
|
|
@ -1,817 +0,0 @@
|
|||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
@file:Suppress("TooManyFunctions")
|
||||
|
||||
package com.geeksville.mesh.ui.message
|
||||
|
||||
import android.content.ClipData
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.input.clearText
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Reply
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.ArrowDownward
|
||||
import androidx.compose.material.icons.filled.ChatBubbleOutline
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.SelectAll
|
||||
import androidx.compose.material.icons.filled.SpeakerNotes
|
||||
import androidx.compose.material.icons.filled.SpeakerNotesOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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 androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.geeksville.mesh.ui.sharing.SharedContactDialog
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.meshtastic.core.database.entity.QuickChatAction
|
||||
import org.meshtastic.core.database.model.Message
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.util.getChannel
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.component.SecurityIcon
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.feature.node.component.NodeKeyStatusIcon
|
||||
import org.meshtastic.proto.AppOnlyProtos
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
private const val MESSAGE_CHARACTER_LIMIT_BYTES = 200
|
||||
private const val SNIPPET_CHARACTER_LIMIT = 50
|
||||
private const val ROUNDED_CORNER_PERCENT = 100
|
||||
|
||||
/**
|
||||
* The main screen for displaying and sending messages to a contact or channel.
|
||||
*
|
||||
* @param contactKey A unique key identifying the contact or channel.
|
||||
* @param message An optional message to pre-fill in the input field.
|
||||
* @param viewModel The [MessageViewModel] instance for handling business logic and state.
|
||||
* @param navigateToMessages Callback to navigate to a different message thread.
|
||||
* @param navigateToNodeDetails Callback to navigate to a node's detail screen.
|
||||
* @param onNavigateBack Callback to navigate back from this screen.
|
||||
*/
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod") // Due to multiple states and event handling
|
||||
@Composable
|
||||
internal fun MessageScreen(
|
||||
contactKey: String,
|
||||
message: String,
|
||||
viewModel: MessageViewModel = hiltViewModel(),
|
||||
navigateToMessages: (String) -> Unit,
|
||||
navigateToNodeDetails: (Int) -> Unit,
|
||||
navigateToQuickChatOptions: () -> Unit,
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val clipboardManager = LocalClipboard.current
|
||||
|
||||
val nodes by viewModel.nodeList.collectAsStateWithLifecycle()
|
||||
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
|
||||
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
|
||||
val channels by viewModel.channels.collectAsStateWithLifecycle()
|
||||
val quickChatActions by viewModel.quickChatActions.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
val messages by viewModel.getMessagesFrom(contactKey).collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
|
||||
// UI State managed within this Composable
|
||||
var replyingToPacketId by rememberSaveable { mutableStateOf<Int?>(null) }
|
||||
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var sharedContact by rememberSaveable { mutableStateOf<Node?>(null) }
|
||||
val selectedMessageIds = rememberSaveable { mutableStateOf(emptySet<Long>()) }
|
||||
val messageInputState = rememberTextFieldState(message)
|
||||
val showQuickChat by viewModel.showQuickChat.collectAsStateWithLifecycle()
|
||||
|
||||
// Derived state, memoized for performance
|
||||
val channelInfo =
|
||||
remember(contactKey, channels) {
|
||||
val index = contactKey.firstOrNull()?.digitToIntOrNull()
|
||||
val id = contactKey.substring(1)
|
||||
val name = index?.let { channels.getChannel(it)?.name } // channels can be null initially
|
||||
Triple(index, id, name)
|
||||
}
|
||||
val (channelIndex, nodeId, rawChannelName) = channelInfo
|
||||
val unknownChannelText = stringResource(id = R.string.unknown_channel)
|
||||
val channelName = rawChannelName ?: unknownChannelText
|
||||
|
||||
val title =
|
||||
remember(nodeId, channelName, viewModel) {
|
||||
when (nodeId) {
|
||||
DataPacket.ID_BROADCAST -> channelName
|
||||
else -> viewModel.getUser(nodeId).longName
|
||||
}
|
||||
}
|
||||
|
||||
val isMismatchKey =
|
||||
remember(channelIndex, nodeId, viewModel) {
|
||||
channelIndex == DataPacket.PKC_CHANNEL_INDEX && viewModel.getNode(nodeId).mismatchKey
|
||||
}
|
||||
|
||||
val inSelectionMode by remember { derivedStateOf { selectedMessageIds.value.isNotEmpty() } }
|
||||
|
||||
val listState =
|
||||
rememberLazyListState(
|
||||
initialFirstVisibleItemIndex = remember(messages) { messages.indexOfLast { !it.read }.coerceAtLeast(0) },
|
||||
)
|
||||
|
||||
val onEvent: (MessageScreenEvent) -> Unit =
|
||||
remember(viewModel, contactKey, messageInputState, ourNode) {
|
||||
{ event ->
|
||||
when (event) {
|
||||
is MessageScreenEvent.SendMessage -> {
|
||||
viewModel.sendMessage(event.text, contactKey, event.replyingToPacketId)
|
||||
if (event.replyingToPacketId != null) replyingToPacketId = null
|
||||
messageInputState.clearText()
|
||||
}
|
||||
|
||||
is MessageScreenEvent.SendReaction ->
|
||||
viewModel.sendReaction(event.emoji, event.messageId, contactKey)
|
||||
|
||||
is MessageScreenEvent.DeleteMessages -> {
|
||||
viewModel.deleteMessages(event.ids)
|
||||
selectedMessageIds.value = emptySet()
|
||||
showDeleteDialog = false
|
||||
}
|
||||
|
||||
is MessageScreenEvent.ClearUnreadCount ->
|
||||
viewModel.clearUnreadCount(contactKey, event.lastReadMessageId)
|
||||
|
||||
is MessageScreenEvent.NodeDetails -> navigateToNodeDetails(event.node.num)
|
||||
|
||||
is MessageScreenEvent.SetTitle -> viewModel.setTitle(event.title)
|
||||
is MessageScreenEvent.NavigateToMessages -> navigateToMessages(event.contactKey)
|
||||
is MessageScreenEvent.NavigateToNodeDetails -> navigateToNodeDetails(event.nodeNum)
|
||||
MessageScreenEvent.NavigateBack -> onNavigateBack()
|
||||
is MessageScreenEvent.CopyToClipboard -> {
|
||||
clipboardManager.nativeClipboard.setPrimaryClip(ClipData.newPlainText(event.text, event.text))
|
||||
selectedMessageIds.value = emptySet()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showDeleteDialog) {
|
||||
DeleteMessageDialog(
|
||||
count = selectedMessageIds.value.size,
|
||||
onConfirm = { onEvent(MessageScreenEvent.DeleteMessages(selectedMessageIds.value.toList())) },
|
||||
onDismiss = { showDeleteDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
sharedContact?.let { contact -> SharedContactDialog(contact = contact, onDismiss = { sharedContact = null }) }
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
if (inSelectionMode) {
|
||||
ActionModeTopBar(
|
||||
selectedCount = selectedMessageIds.value.size,
|
||||
onAction = { action ->
|
||||
when (action) {
|
||||
MessageMenuAction.ClipboardCopy -> {
|
||||
val copiedText =
|
||||
messages
|
||||
.filter { it.uuid in selectedMessageIds.value }
|
||||
.joinToString("\n") { it.text }
|
||||
onEvent(MessageScreenEvent.CopyToClipboard(copiedText))
|
||||
}
|
||||
|
||||
MessageMenuAction.Delete -> showDeleteDialog = true
|
||||
MessageMenuAction.Dismiss -> selectedMessageIds.value = emptySet()
|
||||
MessageMenuAction.SelectAll -> {
|
||||
selectedMessageIds.value =
|
||||
if (selectedMessageIds.value.size == messages.size) {
|
||||
emptySet()
|
||||
} else {
|
||||
messages.map { it.uuid }.toSet()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
MessageTopBar(
|
||||
title = title,
|
||||
channelIndex = channelIndex,
|
||||
mismatchKey = isMismatchKey,
|
||||
onNavigateBack = { onEvent(MessageScreenEvent.NavigateBack) },
|
||||
channels = channels,
|
||||
channelIndexParam = channelIndex,
|
||||
showQuickChat = showQuickChat,
|
||||
onToggleQuickChat = viewModel::toggleShowQuickChat,
|
||||
onNavigateToQuickChatOptions = navigateToQuickChatOptions,
|
||||
)
|
||||
}
|
||||
},
|
||||
) { paddingValues ->
|
||||
Column(Modifier.padding(paddingValues)) {
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
MessageList(
|
||||
nodes = nodes,
|
||||
ourNode = ourNode,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
listState = listState,
|
||||
messages = messages,
|
||||
selectedIds = selectedMessageIds,
|
||||
onUnreadChanged = { messageId -> onEvent(MessageScreenEvent.ClearUnreadCount(messageId)) },
|
||||
onSendReaction = { emoji, id -> onEvent(MessageScreenEvent.SendReaction(emoji, id)) },
|
||||
onDeleteMessages = { viewModel.deleteMessages(it) },
|
||||
onSendMessage = { text, contactKey -> viewModel.sendMessage(text, contactKey) },
|
||||
contactKey = contactKey,
|
||||
onReply = { message -> replyingToPacketId = message?.packetId },
|
||||
onClickChip = { onEvent(MessageScreenEvent.NodeDetails(it)) },
|
||||
)
|
||||
// Show FAB if we can scroll towards the newest messages (index 0).
|
||||
if (listState.canScrollBackward) {
|
||||
ScrollToBottomFab(coroutineScope, listState)
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(visible = showQuickChat) {
|
||||
QuickChatRow(
|
||||
enabled = connectionState.isConnected(),
|
||||
actions = quickChatActions,
|
||||
onClick = { action ->
|
||||
handleQuickChatAction(
|
||||
action = action,
|
||||
messageInputState = messageInputState,
|
||||
onSendMessage = { text -> onEvent(MessageScreenEvent.SendMessage(text)) },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
val originalMessage by
|
||||
remember(replyingToPacketId, messages) {
|
||||
derivedStateOf {
|
||||
replyingToPacketId?.let { messages.firstOrNull { it.packetId == replyingToPacketId } }
|
||||
}
|
||||
}
|
||||
ReplySnippet(
|
||||
originalMessage = originalMessage,
|
||||
onClearReply = { replyingToPacketId = null },
|
||||
ourNode = ourNode,
|
||||
)
|
||||
MessageInput(
|
||||
isEnabled = connectionState.isConnected(),
|
||||
textFieldState = messageInputState,
|
||||
onSendMessage = {
|
||||
val messageText = messageInputState.text.toString().trim()
|
||||
if (messageText.isNotEmpty()) {
|
||||
onEvent(MessageScreenEvent.SendMessage(messageText, replyingToPacketId))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A FloatingActionButton that scrolls the message list to the bottom (most recent messages).
|
||||
*
|
||||
* @param coroutineScope The coroutine scope for launching the scroll animation.
|
||||
* @param listState The [LazyListState] of the message list.
|
||||
*/
|
||||
@Composable
|
||||
private fun BoxScope.ScrollToBottomFab(coroutineScope: CoroutineScope, listState: LazyListState) {
|
||||
FloatingActionButton(
|
||||
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
// Assuming messages are ordered with the newest at index 0
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowDownward,
|
||||
contentDescription = stringResource(id = R.string.scroll_to_bottom),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a snippet of the message being replied to.
|
||||
*
|
||||
* @param originalMessage The message being replied to, or null if not replying.
|
||||
* @param onClearReply Callback to clear the reply state.
|
||||
* @param ourNode The current user's node information, to display "You" if replying to self.
|
||||
*/
|
||||
@Composable
|
||||
private fun ReplySnippet(originalMessage: Message?, onClearReply: () -> Unit, ourNode: Node?) {
|
||||
AnimatedVisibility(visible = originalMessage != null) {
|
||||
originalMessage?.let { message ->
|
||||
val isFromLocalUser = message.node.user.id == DataPacket.ID_LOCAL
|
||||
val replyingToNodeUser = if (isFromLocalUser) ourNode?.user else message.node.user
|
||||
val unknownUserText = stringResource(R.string.unknown)
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Default.Reply,
|
||||
contentDescription = stringResource(R.string.reply), // Decorative
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.replying_to, replyingToNodeUser?.shortName ?: unknownUserText),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = message.text.ellipsize(SNIPPET_CHARACTER_LIMIT),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
IconButton(onClick = onClearReply) {
|
||||
Icon(
|
||||
Icons.Filled.Close,
|
||||
contentDescription = stringResource(R.string.cancel_reply), // Specific action
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ellipsizes a string if its length exceeds [maxLength].
|
||||
*
|
||||
* @param maxLength The maximum number of characters to display before adding "…".
|
||||
* @return The ellipsized string.
|
||||
* @receiver The string to ellipsize.
|
||||
*/
|
||||
private fun String.ellipsize(maxLength: Int): String = if (length > maxLength) "${take(maxLength)}…" else this
|
||||
|
||||
/**
|
||||
* Handles a quick chat action, either appending its message to the input field or sending it directly.
|
||||
*
|
||||
* @param action The [QuickChatAction] to handle.
|
||||
* @param messageInputState The [TextFieldState] of the message input field.
|
||||
* @param onSendMessage Lambda to call when a message needs to be sent.
|
||||
*/
|
||||
private fun handleQuickChatAction(
|
||||
action: QuickChatAction,
|
||||
messageInputState: TextFieldState,
|
||||
onSendMessage: (String) -> Unit,
|
||||
) {
|
||||
when (action.mode) {
|
||||
QuickChatAction.Mode.Append -> {
|
||||
val originalText = messageInputState.text.toString()
|
||||
// Avoid appending if the exact message is already present (simple check)
|
||||
if (!originalText.contains(action.message)) {
|
||||
val newText =
|
||||
buildString {
|
||||
append(originalText)
|
||||
if (originalText.isNotEmpty() && !originalText.endsWith(' ')) {
|
||||
append(' ')
|
||||
}
|
||||
append(action.message)
|
||||
}
|
||||
.limitBytes(MESSAGE_CHARACTER_LIMIT_BYTES)
|
||||
messageInputState.setTextAndPlaceCursorAtEnd(newText)
|
||||
}
|
||||
}
|
||||
|
||||
QuickChatAction.Mode.Instant -> {
|
||||
// Byte limit for 'Send' mode messages is handled by the backend/transport layer.
|
||||
onSendMessage(action.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncates a string to ensure its UTF-8 byte representation does not exceed [maxBytes].
|
||||
*
|
||||
* This implementation iterates by characters and checks byte length to avoid splitting multi-byte characters.
|
||||
*
|
||||
* @param maxBytes The maximum allowed byte length.
|
||||
* @return The truncated string, or the original string if it's within the byte limit.
|
||||
* @receiver The string to limit.
|
||||
*/
|
||||
private fun String.limitBytes(maxBytes: Int): String {
|
||||
val bytes = this.toByteArray(StandardCharsets.UTF_8)
|
||||
if (bytes.size <= maxBytes) {
|
||||
return this
|
||||
}
|
||||
|
||||
var currentBytesSum = 0
|
||||
var validCharCount = 0
|
||||
for (charIndex in this.indices) {
|
||||
val charToTest = this[charIndex]
|
||||
val charBytes = charToTest.toString().toByteArray(StandardCharsets.UTF_8).size
|
||||
if (currentBytesSum + charBytes > maxBytes) {
|
||||
break
|
||||
}
|
||||
currentBytesSum += charBytes
|
||||
validCharCount++
|
||||
}
|
||||
return this.substring(0, validCharCount)
|
||||
}
|
||||
|
||||
/**
|
||||
* A dialog confirming the deletion of messages.
|
||||
*
|
||||
* @param count The number of messages to be deleted.
|
||||
* @param onConfirm Callback invoked when the user confirms the deletion.
|
||||
* @param onDismiss Callback invoked when the dialog is dismissed.
|
||||
*/
|
||||
@Composable
|
||||
private fun DeleteMessageDialog(count: Int, onConfirm: () -> Unit, onDismiss: () -> Unit) {
|
||||
val deleteMessagesString = pluralStringResource(R.plurals.delete_messages, count, count)
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
title = { Text(stringResource(R.string.delete_messages_title)) },
|
||||
text = { Text(text = deleteMessagesString) },
|
||||
confirmButton = { TextButton(onClick = onConfirm) { Text(stringResource(R.string.delete)) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
|
||||
)
|
||||
}
|
||||
|
||||
/** Actions available in the message selection mode's top bar. */
|
||||
internal sealed class MessageMenuAction {
|
||||
data object ClipboardCopy : MessageMenuAction()
|
||||
|
||||
data object Delete : MessageMenuAction()
|
||||
|
||||
data object Dismiss : MessageMenuAction()
|
||||
|
||||
data object SelectAll : MessageMenuAction()
|
||||
}
|
||||
|
||||
/**
|
||||
* The top app bar displayed when in message selection mode.
|
||||
*
|
||||
* @param selectedCount The number of currently selected messages.
|
||||
* @param onAction Callback for when a menu action is triggered.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ActionModeTopBar(selectedCount: Int, onAction: (MessageMenuAction) -> Unit) = TopAppBar(
|
||||
title = { Text(text = selectedCount.toString()) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { onAction(MessageMenuAction.Dismiss) }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(id = R.string.clear_selection),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { onAction(MessageMenuAction.ClipboardCopy) }) {
|
||||
Icon(imageVector = Icons.Default.ContentCopy, contentDescription = stringResource(id = R.string.copy))
|
||||
}
|
||||
IconButton(onClick = { onAction(MessageMenuAction.Delete) }) {
|
||||
Icon(imageVector = Icons.Default.Delete, contentDescription = stringResource(id = R.string.delete))
|
||||
}
|
||||
IconButton(onClick = { onAction(MessageMenuAction.SelectAll) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.SelectAll,
|
||||
contentDescription = stringResource(id = R.string.select_all),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* The default top app bar for the message screen.
|
||||
*
|
||||
* @param title The title to display (contact or channel name).
|
||||
* @param channelIndex The index of the current channel, if applicable.
|
||||
* @param mismatchKey True if there's a key mismatch for the current PKC.
|
||||
* @param onNavigateBack Callback for the navigation icon.
|
||||
* @param channels The set of all channels, used for the [SecurityIcon].
|
||||
* @param channelIndexParam The specific channel index for the [SecurityIcon].
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun MessageTopBar(
|
||||
title: String,
|
||||
channelIndex: Int?,
|
||||
mismatchKey: Boolean,
|
||||
onNavigateBack: () -> Unit,
|
||||
channels: AppOnlyProtos.ChannelSet?,
|
||||
channelIndexParam: Int?,
|
||||
showQuickChat: Boolean,
|
||||
onToggleQuickChat: () -> Unit,
|
||||
onNavigateToQuickChatOptions: () -> Unit = {},
|
||||
) = TopAppBar(
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
|
||||
if (channels != null && channelIndexParam != null) {
|
||||
SecurityIcon(channels, channelIndexParam)
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(id = R.string.navigate_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
MessageTopBarActions(
|
||||
showQuickChat,
|
||||
onToggleQuickChat,
|
||||
onNavigateToQuickChatOptions,
|
||||
channelIndex,
|
||||
mismatchKey,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun MessageTopBarActions(
|
||||
showQuickChat: Boolean,
|
||||
onToggleQuickChat: () -> Unit,
|
||||
onNavigateToQuickChatOptions: () -> Unit,
|
||||
channelIndex: Int?,
|
||||
mismatchKey: Boolean,
|
||||
) {
|
||||
if (channelIndex == DataPacket.PKC_CHANNEL_INDEX) {
|
||||
NodeKeyStatusIcon(hasPKC = true, mismatchKey = mismatchKey)
|
||||
}
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
IconButton(onClick = { expanded = true }, enabled = true) {
|
||||
Icon(imageVector = Icons.Default.MoreVert, contentDescription = stringResource(id = R.string.overflow_menu))
|
||||
}
|
||||
OverFlowMenu(
|
||||
expanded = expanded,
|
||||
onDismiss = { expanded = false },
|
||||
showQuickChat = showQuickChat,
|
||||
onToggleQuickChat = onToggleQuickChat,
|
||||
onNavigateToQuickChatOptions = onNavigateToQuickChatOptions,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OverFlowMenu(
|
||||
expanded: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
showQuickChat: Boolean,
|
||||
onToggleQuickChat: () -> Unit,
|
||||
onNavigateToQuickChatOptions: () -> Unit,
|
||||
) {
|
||||
if (expanded) {
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
|
||||
val quickChatToggleTitle =
|
||||
if (showQuickChat) {
|
||||
stringResource(R.string.quick_chat_hide)
|
||||
} else {
|
||||
stringResource(R.string.quick_chat_show)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(quickChatToggleTitle) },
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onToggleQuickChat()
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (showQuickChat) {
|
||||
Icons.Default.SpeakerNotesOff
|
||||
} else {
|
||||
Icons.Default.SpeakerNotes
|
||||
},
|
||||
contentDescription = quickChatToggleTitle,
|
||||
)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(id = R.string.quick_chat)) },
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onNavigateToQuickChatOptions()
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ChatBubbleOutline,
|
||||
contentDescription = stringResource(id = R.string.quick_chat),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A row of quick chat action buttons.
|
||||
*
|
||||
* @param enabled Whether the buttons should be enabled.
|
||||
* @param actions The list of [QuickChatAction]s to display.
|
||||
* @param onClick Callback when a quick chat button is clicked.
|
||||
*/
|
||||
@Composable
|
||||
private fun QuickChatRow(
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean,
|
||||
actions: List<QuickChatAction>,
|
||||
onClick: (QuickChatAction) -> Unit,
|
||||
) {
|
||||
val alertActionMessage = stringResource(R.string.alert_bell_text)
|
||||
val alertAction =
|
||||
remember(alertActionMessage) {
|
||||
// Memoize if content is static
|
||||
QuickChatAction(
|
||||
name = "🔔",
|
||||
message = "🔔 $alertActionMessage ", // Bell character added to message
|
||||
mode = QuickChatAction.Mode.Append,
|
||||
position = -1, // Assuming -1 means it's a special prepended action
|
||||
)
|
||||
}
|
||||
|
||||
val allActions = remember(alertAction, actions) { listOf(alertAction) + actions }
|
||||
|
||||
LazyRow(modifier = modifier.padding(vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
items(allActions, key = { it.uuid }) { action ->
|
||||
Button(onClick = { onClick(action) }, enabled = enabled) { Text(text = action.name) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val MAX_LINES = 3
|
||||
|
||||
/**
|
||||
* The text input field for composing messages.
|
||||
*
|
||||
* @param isEnabled Whether the input field should be enabled.
|
||||
* @param textFieldState The [TextFieldState] managing the input's text.
|
||||
* @param modifier The modifier for this composable.
|
||||
* @param maxByteSize The maximum allowed size of the message in bytes.
|
||||
* @param onSendMessage Callback invoked when the send button is pressed or send IME action is triggered.
|
||||
*/
|
||||
@Suppress("LongMethod") // Due to multiple parts of the OutlinedTextField
|
||||
@Composable
|
||||
private fun MessageInput(
|
||||
isEnabled: Boolean,
|
||||
textFieldState: TextFieldState,
|
||||
modifier: Modifier = Modifier,
|
||||
maxByteSize: Int = MESSAGE_CHARACTER_LIMIT_BYTES,
|
||||
onSendMessage: () -> Unit,
|
||||
) {
|
||||
val currentText = textFieldState.text.toString()
|
||||
val currentByteLength =
|
||||
remember(currentText) {
|
||||
// Recalculate only when text changes
|
||||
currentText.toByteArray(StandardCharsets.UTF_8).size
|
||||
}
|
||||
|
||||
val isOverLimit = currentByteLength > maxByteSize
|
||||
val canSend = !isOverLimit && currentText.isNotEmpty() && isEnabled
|
||||
|
||||
OutlinedTextField(
|
||||
modifier = modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
state = textFieldState,
|
||||
lineLimits = TextFieldLineLimits.MultiLine(1, MAX_LINES),
|
||||
label = { Text(stringResource(R.string.message_input_label)) },
|
||||
enabled = isEnabled,
|
||||
shape = RoundedCornerShape(ROUNDED_CORNER_PERCENT.toFloat()),
|
||||
isError = isOverLimit,
|
||||
placeholder = { Text(stringResource(R.string.type_a_message)) },
|
||||
supportingText = {
|
||||
if (isEnabled) { // Only show supporting text if input is enabled
|
||||
Text(
|
||||
text = "$currentByteLength/$maxByteSize",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color =
|
||||
if (isOverLimit) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.End,
|
||||
)
|
||||
}
|
||||
},
|
||||
// Direct byte limiting via inputTransformation in TextFieldState is complex.
|
||||
// The current approach (show error, disable send) is generally preferred for UX.
|
||||
// If strict real-time byte trimming is required, it needs careful handling of
|
||||
// cursor position and multi-byte characters, likely outside simple inputTransformation.
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { if (canSend) onSendMessage() }, enabled = canSend) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Default.Send,
|
||||
contentDescription = stringResource(id = R.string.send),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun MessageInputPreview() {
|
||||
AppTheme {
|
||||
Surface {
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
MessageInput(isEnabled = true, textFieldState = rememberTextFieldState("Hello"), onSendMessage = {})
|
||||
Spacer(Modifier.size(16.dp))
|
||||
MessageInput(isEnabled = false, textFieldState = rememberTextFieldState("Disabled"), onSendMessage = {})
|
||||
Spacer(Modifier.size(16.dp))
|
||||
MessageInput(
|
||||
isEnabled = true,
|
||||
textFieldState =
|
||||
rememberTextFieldState(
|
||||
"A very long message that might exceed the byte limit " +
|
||||
"and cause an error state display for the user to see clearly.",
|
||||
),
|
||||
onSendMessage = {},
|
||||
maxByteSize = 50, // Test with a smaller limit
|
||||
)
|
||||
Spacer(Modifier.size(16.dp))
|
||||
// Test Japanese characters (multi-byte)
|
||||
MessageInput(
|
||||
isEnabled = true,
|
||||
textFieldState = rememberTextFieldState("こんにちは世界"), // Hello World in Japanese
|
||||
onSendMessage = {},
|
||||
maxByteSize = 10,
|
||||
// Each char is 3 bytes, so "こん" (6 bytes) is ok, "こんに" (9 bytes) is ok, "こんにち"
|
||||
// (12 bytes) is over
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,220 +0,0 @@
|
|||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.message
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
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.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.ui.message.components.MessageItem
|
||||
import com.geeksville.mesh.ui.message.components.ReactionDialog
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.launch
|
||||
import org.meshtastic.core.database.entity.Reaction
|
||||
import org.meshtastic.core.database.model.Message
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.model.MessageStatus
|
||||
import org.meshtastic.core.strings.R
|
||||
|
||||
@Composable
|
||||
fun DeliveryInfo(
|
||||
@StringRes title: Int,
|
||||
@StringRes text: Int? = null,
|
||||
onConfirm: (() -> Unit) = {},
|
||||
onDismiss: () -> Unit = {},
|
||||
resendOption: Boolean,
|
||||
) = AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
dismissButton = {
|
||||
FilledTonalButton(onClick = onDismiss, modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
Text(text = stringResource(id = R.string.close))
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
if (resendOption) {
|
||||
FilledTonalButton(onClick = onConfirm, modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
Text(text = stringResource(id = R.string.resend))
|
||||
}
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(id = title),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
},
|
||||
text = {
|
||||
text?.let {
|
||||
Text(
|
||||
text = stringResource(id = it),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
)
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
internal fun MessageList(
|
||||
nodes: List<Node>,
|
||||
ourNode: Node?,
|
||||
modifier: Modifier = Modifier,
|
||||
listState: LazyListState = rememberLazyListState(),
|
||||
messages: List<Message>,
|
||||
selectedIds: MutableState<Set<Long>>,
|
||||
onUnreadChanged: (Long) -> Unit,
|
||||
onSendReaction: (String, Int) -> Unit,
|
||||
onClickChip: (Node) -> Unit,
|
||||
onDeleteMessages: (List<Long>) -> Unit,
|
||||
onSendMessage: (messageText: String, contactKey: String) -> Unit,
|
||||
contactKey: String,
|
||||
onReply: (Message?) -> Unit,
|
||||
) {
|
||||
val haptics = LocalHapticFeedback.current
|
||||
val inSelectionMode by remember { derivedStateOf { selectedIds.value.isNotEmpty() } }
|
||||
AutoScrollToBottom(listState, messages)
|
||||
UpdateUnreadCount(listState, messages, onUnreadChanged)
|
||||
|
||||
var showStatusDialog by remember { mutableStateOf<Message?>(null) }
|
||||
if (showStatusDialog != null) {
|
||||
val msg = showStatusDialog ?: return
|
||||
val (title, text) = msg.getStatusStringRes()
|
||||
DeliveryInfo(
|
||||
title = title,
|
||||
text = text,
|
||||
onConfirm = {
|
||||
val deleteList: List<Long> = listOf(msg.uuid)
|
||||
onDeleteMessages(deleteList)
|
||||
showStatusDialog = null
|
||||
onSendMessage(msg.text, contactKey)
|
||||
},
|
||||
onDismiss = { showStatusDialog = null },
|
||||
resendOption = msg.status?.equals(MessageStatus.ERROR) ?: false,
|
||||
)
|
||||
}
|
||||
|
||||
var showReactionDialog by remember { mutableStateOf<List<Reaction>?>(null) }
|
||||
if (showReactionDialog != null) {
|
||||
val reactions = showReactionDialog ?: return
|
||||
ReactionDialog(reactions) { showReactionDialog = null }
|
||||
}
|
||||
|
||||
fun MutableState<Set<Long>>.toggle(uuid: Long) = if (value.contains(uuid)) {
|
||||
value -= uuid
|
||||
} else {
|
||||
value += uuid
|
||||
}
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
LazyColumn(modifier = modifier.fillMaxSize(), state = listState, reverseLayout = true) {
|
||||
items(messages, key = { it.uuid }) { msg ->
|
||||
if (ourNode != null) {
|
||||
val selected by remember { derivedStateOf { selectedIds.value.contains(msg.uuid) } }
|
||||
val node by remember { derivedStateOf { nodes.find { it.num == msg.node.num } ?: msg.node } }
|
||||
|
||||
MessageItem(
|
||||
modifier = Modifier.animateItem(),
|
||||
node = node,
|
||||
ourNode = ourNode,
|
||||
message = msg,
|
||||
selected = selected,
|
||||
onClick = { if (inSelectionMode) selectedIds.toggle(msg.uuid) },
|
||||
onLongClick = {
|
||||
selectedIds.toggle(msg.uuid)
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
},
|
||||
onClickChip = onClickChip,
|
||||
onStatusClick = { showStatusDialog = msg },
|
||||
onReply = { onReply(msg) },
|
||||
emojis = msg.emojis,
|
||||
sendReaction = { onSendReaction(it, msg.packetId) },
|
||||
onShowReactions = { showReactionDialog = msg.emojis },
|
||||
onNavigateToOriginalMessage = {
|
||||
coroutineScope.launch {
|
||||
val targetIndex = messages.indexOfFirst { it.packetId == msg.replyId }
|
||||
if (targetIndex != -1) {
|
||||
listState.animateScrollToItem(index = targetIndex)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun <T> AutoScrollToBottom(listState: LazyListState, list: List<T>, itemThreshold: Int = 3) = with(listState) {
|
||||
val shouldAutoScroll by remember { derivedStateOf { firstVisibleItemIndex < itemThreshold } }
|
||||
if (shouldAutoScroll) {
|
||||
LaunchedEffect(list) {
|
||||
if (!isScrollInProgress) {
|
||||
scrollToItem(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
@Composable
|
||||
private fun UpdateUnreadCount(listState: LazyListState, messages: List<Message>, onUnreadChanged: (Long) -> Unit) {
|
||||
LaunchedEffect(messages) {
|
||||
snapshotFlow { listState.firstVisibleItemIndex }
|
||||
.debounce(timeoutMillis = 500L)
|
||||
.collectLatest { index ->
|
||||
val lastUnreadIndex = messages.indexOfLast { !it.read }
|
||||
if (lastUnreadIndex != -1 && index <= lastUnreadIndex && index < messages.size) {
|
||||
val visibleMessage = messages[index]
|
||||
onUnreadChanged(visibleMessage.receivedTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.message
|
||||
|
||||
import org.meshtastic.core.database.model.Node
|
||||
|
||||
/**
|
||||
* Defines the various user interactions that can occur on the [MessageScreen]. These events are typically handled by
|
||||
* the [com.geeksville.mesh.model.UIViewModel].
|
||||
*/
|
||||
internal sealed interface MessageScreenEvent {
|
||||
/** Send a new text message. */
|
||||
data class SendMessage(val text: String, val replyingToPacketId: Int? = null) : MessageScreenEvent
|
||||
|
||||
/** Send an emoji reaction to a specific message. */
|
||||
data class SendReaction(val emoji: String, val messageId: Int) : MessageScreenEvent
|
||||
|
||||
/** Delete one or more selected messages. */
|
||||
data class DeleteMessages(val ids: List<Long>) : MessageScreenEvent
|
||||
|
||||
/** Mark messages up to a certain ID as read. */
|
||||
data class ClearUnreadCount(val lastReadMessageId: Long) : MessageScreenEvent
|
||||
|
||||
/** Handle an action from a node's context menu. */
|
||||
data class NodeDetails(val node: Node) : MessageScreenEvent
|
||||
|
||||
/** Set the title of the screen (typically the contact or channel name). */
|
||||
data class SetTitle(val title: String) : MessageScreenEvent
|
||||
|
||||
/** Navigate to a different message thread. */
|
||||
data class NavigateToMessages(val contactKey: String) : MessageScreenEvent
|
||||
|
||||
/** Navigate to the details screen for a specific node. */
|
||||
data class NavigateToNodeDetails(val nodeNum: Int) : MessageScreenEvent
|
||||
|
||||
/** Navigate back to the previous screen. */
|
||||
data object NavigateBack : MessageScreenEvent
|
||||
|
||||
/** Copy the given text to the clipboard. */
|
||||
data class CopyToClipboard(val text: String) : MessageScreenEvent
|
||||
}
|
||||
|
|
@ -1,214 +0,0 @@
|
|||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.message
|
||||
|
||||
import android.os.RemoteException
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.geeksville.mesh.service.MeshServiceNotifications
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.data.repository.PacketRepository
|
||||
import org.meshtastic.core.data.repository.QuickChatActionRepository
|
||||
import org.meshtastic.core.data.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.database.model.Message
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.DeviceVersion
|
||||
import org.meshtastic.core.prefs.ui.UiPrefs
|
||||
import org.meshtastic.core.service.ServiceAction
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.proto.channelSet
|
||||
import org.meshtastic.proto.sharedContact
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val VERIFIED_CONTACT_FIRMWARE_CUTOFF = "2.7.12"
|
||||
|
||||
@HiltViewModel
|
||||
class MessageViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val nodeRepository: NodeRepository,
|
||||
radioConfigRepository: RadioConfigRepository,
|
||||
quickChatActionRepository: QuickChatActionRepository,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
private val packetRepository: PacketRepository,
|
||||
private val uiPrefs: UiPrefs,
|
||||
private val meshServiceNotifications: MeshServiceNotifications,
|
||||
) : ViewModel() {
|
||||
private val _title = MutableStateFlow("")
|
||||
val title: StateFlow<String> = _title.asStateFlow()
|
||||
|
||||
val ourNodeInfo = nodeRepository.ourNodeInfo
|
||||
|
||||
val connectionState = serviceRepository.connectionState
|
||||
|
||||
val nodeList: StateFlow<List<Node>> =
|
||||
nodeRepository
|
||||
.getNodes()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = emptyList(),
|
||||
)
|
||||
|
||||
val channels =
|
||||
radioConfigRepository.channelSetFlow.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5_000),
|
||||
channelSet {},
|
||||
)
|
||||
|
||||
private val _showQuickChat = MutableStateFlow(uiPrefs.showQuickChat)
|
||||
val showQuickChat: StateFlow<Boolean> = _showQuickChat
|
||||
|
||||
val quickChatActions =
|
||||
quickChatActionRepository
|
||||
.getAllActions()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
private val contactKeyForMessages: MutableStateFlow<String?> = MutableStateFlow(null)
|
||||
private val messagesForContactKey: StateFlow<List<Message>> =
|
||||
contactKeyForMessages
|
||||
.filterNotNull()
|
||||
.flatMapLatest { contactKey -> packetRepository.getMessagesFrom(contactKey, ::getNode) }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = emptyList(),
|
||||
)
|
||||
|
||||
fun setTitle(title: String) {
|
||||
viewModelScope.launch { _title.value = title }
|
||||
}
|
||||
|
||||
fun getMessagesFrom(contactKey: String): StateFlow<List<Message>> {
|
||||
contactKeyForMessages.value = contactKey
|
||||
return messagesForContactKey
|
||||
}
|
||||
|
||||
fun toggleShowQuickChat() = toggle(_showQuickChat) { uiPrefs.showQuickChat = it }
|
||||
|
||||
private fun toggle(state: MutableStateFlow<Boolean>, onChanged: (newValue: Boolean) -> Unit) {
|
||||
(!state.value).let { toggled ->
|
||||
state.update { toggled }
|
||||
onChanged(toggled)
|
||||
}
|
||||
}
|
||||
|
||||
fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST)
|
||||
|
||||
fun getUser(userId: String?) = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST)
|
||||
|
||||
/**
|
||||
* Sends a message to a contact or channel.
|
||||
*
|
||||
* If the message is a direct message (no channel specified), this function will:
|
||||
* - If the device firmware version is older than 2.7.12, it will mark the destination node as a favorite to prevent
|
||||
* it from being removed from the on-device node database.
|
||||
* - If the device firmware version is 2.7.12 or newer, it will send a shared contact to the destination node.
|
||||
*
|
||||
* @param str The message content.
|
||||
* @param contactKey The unique contact key, which is a combination of channel (optional) and node ID. Defaults to
|
||||
* broadcasting on channel 0.
|
||||
* @param replyId The ID of the message this is a reply to, if any.
|
||||
*/
|
||||
@Suppress("NestedBlockDepth")
|
||||
fun sendMessage(str: String, contactKey: String = "0${DataPacket.ID_BROADCAST}", replyId: Int? = null) {
|
||||
// contactKey: unique contact key filter (channel)+(nodeId)
|
||||
val channel = contactKey[0].digitToIntOrNull()
|
||||
val dest = if (channel != null) contactKey.substring(1) else contactKey
|
||||
|
||||
// if the destination is a node, we need to ensure it's a
|
||||
// favorite so it does not get removed from the on-device node database.
|
||||
if (channel == null) { // no channel specified, so we assume it's a direct message
|
||||
val fwVersion = ourNodeInfo.value?.metadata?.firmwareVersion
|
||||
val destNode = nodeRepository.getNode(dest)
|
||||
|
||||
fwVersion?.let { fw ->
|
||||
val ver = DeviceVersion(asString = fw)
|
||||
val verifiedSharedContactsVersion =
|
||||
DeviceVersion(
|
||||
asString = VERIFIED_CONTACT_FIRMWARE_CUTOFF,
|
||||
) // Version cutover to verified shared contacts
|
||||
|
||||
if (ver >= verifiedSharedContactsVersion) {
|
||||
sendSharedContact(destNode)
|
||||
} else {
|
||||
if (!destNode.isFavorite) {
|
||||
favoriteNode(destNode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val p = DataPacket(dest, channel ?: 0, str, replyId)
|
||||
sendDataPacket(p)
|
||||
}
|
||||
|
||||
fun sendReaction(emoji: String, replyId: Int, contactKey: String) =
|
||||
viewModelScope.launch { serviceRepository.onServiceAction(ServiceAction.Reaction(emoji, replyId, contactKey)) }
|
||||
|
||||
fun deleteMessages(uuidList: List<Long>) =
|
||||
viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteMessages(uuidList) }
|
||||
|
||||
fun clearUnreadCount(contact: String, timestamp: Long) = viewModelScope.launch(Dispatchers.IO) {
|
||||
packetRepository.clearUnreadCount(contact, timestamp)
|
||||
val unreadCount = packetRepository.getUnreadCount(contact)
|
||||
if (unreadCount == 0) meshServiceNotifications.cancelMessageNotification(contact)
|
||||
}
|
||||
|
||||
private fun favoriteNode(node: Node) = viewModelScope.launch {
|
||||
try {
|
||||
serviceRepository.onServiceAction(ServiceAction.Favorite(node))
|
||||
} catch (ex: RemoteException) {
|
||||
Timber.e(ex, "Favorite node error")
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendSharedContact(node: Node) = viewModelScope.launch {
|
||||
try {
|
||||
val contact = sharedContact {
|
||||
nodeNum = node.num
|
||||
user = node.user
|
||||
manuallyVerified = node.manuallyVerified
|
||||
}
|
||||
serviceRepository.onServiceAction(ServiceAction.SendContact(contact = contact))
|
||||
} catch (ex: RemoteException) {
|
||||
Timber.e(ex, "Send shared contact error")
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendDataPacket(p: DataPacket) {
|
||||
try {
|
||||
serviceRepository.meshService?.send(p)
|
||||
} catch (ex: RemoteException) {
|
||||
Timber.e("Send DataPacket error: ${ex.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,373 +0,0 @@
|
|||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.message
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.DragHandle
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.FastForward
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusEvent
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
import org.meshtastic.core.database.entity.QuickChatAction
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.component.dragContainer
|
||||
import org.meshtastic.core.ui.component.dragDropItemsIndexed
|
||||
import org.meshtastic.core.ui.component.rememberDragDropState
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
|
||||
@Composable
|
||||
internal fun QuickChatScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: UIViewModel = hiltViewModel(),
|
||||
onNavigateUp: () -> Unit,
|
||||
) {
|
||||
val actions by viewModel.quickChatActions.collectAsStateWithLifecycle()
|
||||
var showActionDialog by remember { mutableStateOf<QuickChatAction?>(null) }
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
val dragDropState =
|
||||
rememberDragDropState(listState) { fromIndex, toIndex ->
|
||||
val list = actions.toMutableList().apply { add(toIndex, removeAt(fromIndex)) }
|
||||
viewModel.updateActionPositions(list)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
MainAppBar(
|
||||
title = stringResource(id = R.string.quick_chat),
|
||||
ourNode = null,
|
||||
showNodeChip = false,
|
||||
canNavigateUp = true,
|
||||
onNavigateUp = onNavigateUp,
|
||||
actions = {},
|
||||
onClickChip = {},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Box(modifier = modifier.fillMaxSize().padding(innerPadding)) {
|
||||
showActionDialog?.let {
|
||||
EditQuickChatDialog(
|
||||
action = it,
|
||||
onSave = viewModel::addQuickChatAction,
|
||||
onDelete = viewModel::deleteQuickChatAction,
|
||||
) {
|
||||
showActionDialog = null
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.dragContainer(dragDropState = dragDropState, haptics = LocalHapticFeedback.current),
|
||||
state = listState,
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
) {
|
||||
dragDropItemsIndexed(items = actions, dragDropState = dragDropState, key = { _, item -> item.uuid }) {
|
||||
_,
|
||||
action,
|
||||
isDragging,
|
||||
->
|
||||
QuickChatItem(action = action, onEdit = { showActionDialog = it })
|
||||
}
|
||||
}
|
||||
|
||||
FloatingActionButton(
|
||||
onClick = { showActionDialog = QuickChatAction(position = actions.size) },
|
||||
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.Add, contentDescription = stringResource(id = R.string.add))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private fun getMessageName(message: String): String = if (message.length <= 3) {
|
||||
message.uppercase()
|
||||
} else {
|
||||
buildString {
|
||||
append(message.first().uppercase())
|
||||
append(message[message.length / 2].uppercase())
|
||||
append(message.last().uppercase())
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun EditQuickChatDialog(
|
||||
action: QuickChatAction,
|
||||
onSave: (QuickChatAction) -> Unit,
|
||||
onDelete: (QuickChatAction) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var actionInput by remember { mutableStateOf(action) }
|
||||
val newQuickChat = remember { action.uuid == 0L }
|
||||
val isInstant = actionInput.mode == QuickChatAction.Mode.Instant
|
||||
val title = if (newQuickChat) R.string.quick_chat_new else R.string.quick_chat_edit
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
if (newQuickChat) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
text = {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = stringResource(id = title),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
style =
|
||||
MaterialTheme.typography.titleLarge.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextFieldWithCounter(
|
||||
label = stringResource(R.string.name),
|
||||
value = actionInput.name,
|
||||
maxSize = 5,
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
actionInput = actionInput.copy(name = it.uppercase())
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextFieldWithCounter(
|
||||
label = stringResource(id = R.string.message),
|
||||
value = actionInput.message,
|
||||
maxSize = 200,
|
||||
getSize = { it.toByteArray().size + 1 },
|
||||
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
|
||||
) {
|
||||
actionInput = actionInput.copy(message = it)
|
||||
if (newQuickChat) {
|
||||
actionInput = actionInput.copy(name = getMessageName(it))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
val (text, icon) =
|
||||
if (isInstant) {
|
||||
R.string.quick_chat_instant to Icons.Default.FastForward
|
||||
} else {
|
||||
R.string.quick_chat_append to Icons.Default.Add
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (isInstant) {
|
||||
Icon(imageVector = icon, contentDescription = stringResource(id = text))
|
||||
Spacer(Modifier.width(12.dp))
|
||||
}
|
||||
|
||||
Text(text = stringResource(text), modifier = Modifier.weight(1f))
|
||||
|
||||
Switch(
|
||||
checked = isInstant,
|
||||
onCheckedChange = { checked ->
|
||||
actionInput =
|
||||
actionInput.copy(
|
||||
mode =
|
||||
when (checked) {
|
||||
true -> QuickChatAction.Mode.Instant
|
||||
false -> QuickChatAction.Mode.Append
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 24.dp, end = 24.dp, bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TextButton(modifier = Modifier.weight(1f), onClick = onDismiss) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
|
||||
if (!newQuickChat) {
|
||||
Button(
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = {
|
||||
onDelete(actionInput)
|
||||
onDismiss()
|
||||
},
|
||||
) {
|
||||
Text(text = stringResource(R.string.delete))
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = {
|
||||
onSave(actionInput)
|
||||
onDismiss()
|
||||
},
|
||||
enabled = actionInput.name.isNotEmpty() && actionInput.message.isNotEmpty(),
|
||||
) {
|
||||
Text(text = stringResource(R.string.save))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OutlinedTextFieldWithCounter(
|
||||
label: String,
|
||||
value: String,
|
||||
modifier: Modifier = Modifier,
|
||||
singleLine: Boolean = false,
|
||||
maxSize: Int,
|
||||
getSize: (String) -> Int = { it.length },
|
||||
onValueChange: (String) -> Unit = {},
|
||||
) = Column(modifier) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = {
|
||||
if (getSize(it) <= maxSize) {
|
||||
onValueChange(it)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.onFocusEvent { isFocused = it.isFocused },
|
||||
label = { Text(text = label) },
|
||||
singleLine = singleLine,
|
||||
)
|
||||
if (isFocused) {
|
||||
Text(
|
||||
text = "${getSize(value)}/$maxSize",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.align(Alignment.End).padding(top = 4.dp, end = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QuickChatItem(
|
||||
action: QuickChatAction,
|
||||
modifier: Modifier = Modifier,
|
||||
onEdit: (QuickChatAction) -> Unit = {},
|
||||
) {
|
||||
Card(modifier = modifier.fillMaxWidth().padding(8.dp), shape = RoundedCornerShape(12.dp)) {
|
||||
ListItem(
|
||||
leadingContent = {
|
||||
if (action.mode == QuickChatAction.Mode.Instant) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.FastForward,
|
||||
contentDescription = stringResource(id = R.string.quick_chat_instant),
|
||||
)
|
||||
}
|
||||
},
|
||||
headlineContent = { Text(text = action.name) },
|
||||
supportingContent = { Text(text = action.message) },
|
||||
trailingContent = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = { onEdit(action) }, modifier = Modifier.size(48.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Edit,
|
||||
contentDescription = stringResource(id = R.string.quick_chat_edit),
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Default.DragHandle,
|
||||
contentDescription = stringResource(id = R.string.quick_chat),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun QuickChatItemPreview() {
|
||||
AppTheme { QuickChatItem(action = QuickChatAction(name = "TST", message = "Test", position = 0)) }
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun EditQuickChatDialogPreview() {
|
||||
AppTheme {
|
||||
EditQuickChatDialog(
|
||||
action = QuickChatAction(name = "TST", message = "Test", position = 0),
|
||||
onSave = {},
|
||||
onDelete = {},
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.message.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Reply
|
||||
import androidx.compose.material.icons.filled.EmojiEmotions
|
||||
import androidx.compose.material.icons.twotone.Cloud
|
||||
import androidx.compose.material.icons.twotone.CloudDone
|
||||
import androidx.compose.material.icons.twotone.CloudOff
|
||||
import androidx.compose.material.icons.twotone.CloudUpload
|
||||
import androidx.compose.material.icons.twotone.HowToReg
|
||||
import androidx.compose.material.icons.twotone.Warning
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
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 org.meshtastic.core.model.MessageStatus
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.emoji.EmojiPickerDialog
|
||||
|
||||
@Composable
|
||||
fun ReactionButton(onSendReaction: (String) -> Unit = {}) {
|
||||
var showEmojiPickerDialog by remember { mutableStateOf(false) }
|
||||
if (showEmojiPickerDialog) {
|
||||
EmojiPickerDialog(
|
||||
onConfirm = { selectedEmoji ->
|
||||
showEmojiPickerDialog = false
|
||||
onSendReaction(selectedEmoji)
|
||||
},
|
||||
onDismiss = { showEmojiPickerDialog = false },
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { showEmojiPickerDialog = true }) {
|
||||
Icon(imageVector = Icons.Default.EmojiEmotions, contentDescription = stringResource(R.string.react))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ReplyButton(onClick: () -> Unit = {}) = IconButton(
|
||||
onClick = onClick,
|
||||
content = {
|
||||
Icon(imageVector = Icons.AutoMirrored.Filled.Reply, contentDescription = stringResource(R.string.reply))
|
||||
},
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun MessageStatusButton(onStatusClick: () -> Unit = {}, status: MessageStatus, fromLocal: Boolean) =
|
||||
AnimatedVisibility(visible = fromLocal) {
|
||||
IconButton(onClick = onStatusClick) {
|
||||
Icon(
|
||||
imageVector =
|
||||
when (status) {
|
||||
MessageStatus.RECEIVED -> Icons.TwoTone.HowToReg
|
||||
MessageStatus.QUEUED -> Icons.TwoTone.CloudUpload
|
||||
MessageStatus.DELIVERED -> Icons.TwoTone.CloudDone
|
||||
MessageStatus.ENROUTE -> Icons.TwoTone.Cloud
|
||||
MessageStatus.ERROR -> Icons.TwoTone.CloudOff
|
||||
else -> Icons.TwoTone.Warning
|
||||
},
|
||||
contentDescription = stringResource(R.string.message_delivery_status),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun MessageActions(
|
||||
modifier: Modifier = Modifier,
|
||||
isLocal: Boolean = false,
|
||||
status: MessageStatus?,
|
||||
onSendReaction: (String) -> Unit = {},
|
||||
onSendReply: () -> Unit = {},
|
||||
onStatusClick: () -> Unit = {},
|
||||
) {
|
||||
Row(modifier = modifier.wrapContentSize()) {
|
||||
ReactionButton { onSendReaction(it) }
|
||||
ReplyButton { onSendReply() }
|
||||
MessageStatusButton(
|
||||
onStatusClick = onStatusClick,
|
||||
status = status ?: MessageStatus.UNKNOWN,
|
||||
fromLocal = isLocal,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,346 +0,0 @@
|
|||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.message.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.FormatQuote
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardColors
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.contentColorFor
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.meshtastic.core.database.entity.Reaction
|
||||
import org.meshtastic.core.database.model.Message
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.model.MessageStatus
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.component.AutoLinkText
|
||||
import org.meshtastic.core.ui.component.NodeChip
|
||||
import org.meshtastic.core.ui.component.Rssi
|
||||
import org.meshtastic.core.ui.component.Snr
|
||||
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.core.ui.theme.MessageItemColors
|
||||
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
internal fun MessageItem(
|
||||
modifier: Modifier = Modifier,
|
||||
node: Node,
|
||||
ourNode: Node,
|
||||
message: Message,
|
||||
selected: Boolean,
|
||||
onReply: () -> Unit = {},
|
||||
sendReaction: (String) -> Unit = {},
|
||||
onShowReactions: () -> Unit = {},
|
||||
emojis: List<Reaction> = emptyList(),
|
||||
onClick: () -> Unit = {},
|
||||
onLongClick: () -> Unit = {},
|
||||
onClickChip: (Node) -> Unit = {},
|
||||
onStatusClick: () -> Unit = {},
|
||||
onNavigateToOriginalMessage: (Int) -> Unit = {},
|
||||
) = Column(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.background(color = if (selected) Color.Gray else MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
val containsBel = message.text.contains('\u0007')
|
||||
val containerColor =
|
||||
Color(
|
||||
if (message.fromLocal) {
|
||||
ourNode.colors.second
|
||||
} else {
|
||||
node.colors.second
|
||||
},
|
||||
)
|
||||
.copy(alpha = 0.2f)
|
||||
val cardColors =
|
||||
CardDefaults.cardColors()
|
||||
.copy(containerColor = containerColor, contentColor = contentColorFor(containerColor))
|
||||
val messageModifier =
|
||||
Modifier.padding(start = 8.dp, top = 8.dp, end = 8.dp)
|
||||
.then(
|
||||
if (containsBel) {
|
||||
Modifier.border(2.dp, MessageItemColors.Red, shape = MaterialTheme.shapes.medium)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
Box {
|
||||
Card(
|
||||
modifier =
|
||||
Modifier.align(if (message.fromLocal) Alignment.BottomEnd else Alignment.BottomStart)
|
||||
.padding(
|
||||
top = 4.dp,
|
||||
start = if (!message.fromLocal) 0.dp else 16.dp,
|
||||
end = if (message.fromLocal) 0.dp else 16.dp,
|
||||
)
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
||||
.then(messageModifier),
|
||||
colors = cardColors,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
OriginalMessageSnippet(
|
||||
message = message,
|
||||
ourNode = ourNode,
|
||||
cardColors = cardColors,
|
||||
onNavigateToOriginalMessage = onNavigateToOriginalMessage,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
val chipNode = if (message.fromLocal) ourNode else node
|
||||
NodeChip(node = chipNode, onClick = onClickChip)
|
||||
Text(
|
||||
text = with(if (message.fromLocal) ourNode.user else node.user) { "$longName ($id)" },
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.weight(1f, fill = true),
|
||||
)
|
||||
if (message.viaMqtt) {
|
||||
Icon(
|
||||
Icons.Default.Cloud,
|
||||
contentDescription = stringResource(R.string.via_mqtt),
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
MessageActions(
|
||||
isLocal = message.fromLocal,
|
||||
status = message.status,
|
||||
onSendReaction = sendReaction,
|
||||
onSendReply = onReply,
|
||||
onStatusClick = onStatusClick,
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
|
||||
AutoLinkText(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = message.text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = cardColors.contentColor,
|
||||
)
|
||||
|
||||
val topPadding = if (!message.fromLocal) 2.dp else 0.dp
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = topPadding, bottom = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (!message.fromLocal) {
|
||||
if (message.hopsAway == 0) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Snr(message.snr)
|
||||
Rssi(message.rssi)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(R.string.hops_away_template, message.hopsAway),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (containsBel) {
|
||||
Text(text = "\uD83D\uDD14", modifier = Modifier.padding(end = 4.dp))
|
||||
}
|
||||
Text(text = message.time, style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ReactionRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
reactions = emojis,
|
||||
onSendReaction = sendReaction,
|
||||
onShowReactions = onShowReactions,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OriginalMessageSnippet(
|
||||
message: Message,
|
||||
ourNode: Node,
|
||||
cardColors: CardColors = CardDefaults.cardColors(),
|
||||
onNavigateToOriginalMessage: (Int) -> Unit,
|
||||
) {
|
||||
val originalMessage = message.originalMessage
|
||||
if (originalMessage != null && originalMessage.packetId != 0) {
|
||||
val originalMessageNode = if (originalMessage.fromLocal) ourNode else originalMessage.node
|
||||
OutlinedCard(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth().padding(4.dp).clickable {
|
||||
onNavigateToOriginalMessage(originalMessage.packetId)
|
||||
},
|
||||
colors = cardColors,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.FormatQuote,
|
||||
contentDescription = stringResource(R.string.reply), // Add to strings.xml
|
||||
)
|
||||
Text(
|
||||
text = originalMessageNode.user.shortName,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.weight(1f, fill = true),
|
||||
text = originalMessage.text, // Should not be null if isAReply is true
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1, // Keep snippet brief
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun MessageItemPreview() {
|
||||
val sent =
|
||||
Message(
|
||||
text = stringResource(R.string.sample_message),
|
||||
time = "10:00",
|
||||
fromLocal = true,
|
||||
status = MessageStatus.DELIVERED,
|
||||
snr = 20.5f,
|
||||
rssi = 90,
|
||||
hopsAway = 0,
|
||||
uuid = 1L,
|
||||
receivedTime = System.currentTimeMillis(),
|
||||
node = NodePreviewParameterProvider().mickeyMouse,
|
||||
read = false,
|
||||
routingError = 0,
|
||||
packetId = 4545,
|
||||
emojis = listOf(),
|
||||
replyId = null,
|
||||
viaMqtt = false,
|
||||
)
|
||||
val received =
|
||||
Message(
|
||||
text = "This is a received message",
|
||||
time = "10:10",
|
||||
fromLocal = false,
|
||||
status = MessageStatus.RECEIVED,
|
||||
snr = 2.5f,
|
||||
rssi = 90,
|
||||
hopsAway = 0,
|
||||
uuid = 2L,
|
||||
receivedTime = System.currentTimeMillis(),
|
||||
node = NodePreviewParameterProvider().minnieMouse,
|
||||
read = false,
|
||||
routingError = 0,
|
||||
packetId = 4545,
|
||||
emojis = listOf(),
|
||||
replyId = null,
|
||||
viaMqtt = false,
|
||||
)
|
||||
val receivedWithOriginalMessage =
|
||||
Message(
|
||||
text = "This is a received message w/ original, this is a longer message to test next-lining.",
|
||||
time = "10:20",
|
||||
fromLocal = false,
|
||||
status = MessageStatus.RECEIVED,
|
||||
snr = 2.5f,
|
||||
rssi = 90,
|
||||
hopsAway = 2,
|
||||
uuid = 2L,
|
||||
receivedTime = System.currentTimeMillis(),
|
||||
node = NodePreviewParameterProvider().minnieMouse,
|
||||
read = false,
|
||||
routingError = 0,
|
||||
packetId = 4545,
|
||||
emojis = listOf(),
|
||||
replyId = null,
|
||||
originalMessage = received,
|
||||
viaMqtt = true,
|
||||
)
|
||||
AppTheme {
|
||||
Column(modifier = Modifier.background(MaterialTheme.colorScheme.background).padding(vertical = 16.dp)) {
|
||||
MessageItem(
|
||||
message = sent,
|
||||
node = sent.node,
|
||||
selected = false,
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
onStatusClick = {},
|
||||
ourNode = sent.node,
|
||||
)
|
||||
|
||||
MessageItem(
|
||||
message = received,
|
||||
node = received.node,
|
||||
selected = false,
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
onStatusClick = {},
|
||||
ourNode = sent.node,
|
||||
)
|
||||
|
||||
MessageItem(
|
||||
message = receivedWithOriginalMessage,
|
||||
node = receivedWithOriginalMessage.node,
|
||||
selected = false,
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
onStatusClick = {},
|
||||
ourNode = sent.node,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,180 +0,0 @@
|
|||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.message.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
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.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.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.meshtastic.core.database.entity.Reaction
|
||||
import org.meshtastic.core.ui.component.BottomSheetDialog
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.proto.MeshProtos
|
||||
|
||||
@Composable
|
||||
private fun ReactionItem(emoji: String, emojiCount: Int = 1, onClick: () -> Unit = {}, onLongClick: () -> Unit = {}) {
|
||||
BadgedBox(
|
||||
badge = {
|
||||
if (emojiCount > 1) {
|
||||
Badge { Text(fontWeight = FontWeight.Bold, text = emojiCount.toString()) }
|
||||
}
|
||||
},
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
shape = CircleShape,
|
||||
) {
|
||||
Text(text = emoji, modifier = Modifier.padding(4.dp).clip(CircleShape))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun ReactionRow(
|
||||
modifier: Modifier = Modifier,
|
||||
reactions: List<Reaction> = emptyList(),
|
||||
onSendReaction: (String) -> Unit = {},
|
||||
onShowReactions: () -> Unit = {},
|
||||
) {
|
||||
val emojiList = reduceEmojis(reactions.reversed().map { it.emoji }).entries
|
||||
|
||||
AnimatedVisibility(emojiList.isNotEmpty()) {
|
||||
LazyRow(
|
||||
modifier = modifier.padding(horizontal = 4.dp),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
items(emojiList.size) { index ->
|
||||
val entry = emojiList.elementAt(index)
|
||||
ReactionItem(
|
||||
emoji = entry.key,
|
||||
emojiCount = entry.value,
|
||||
onClick = { onSendReaction(entry.key) },
|
||||
onLongClick = onShowReactions,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun reduceEmojis(emojis: List<String>): Map<String, Int> = emojis.groupingBy { it }.eachCount()
|
||||
|
||||
@Composable
|
||||
fun ReactionDialog(reactions: List<Reaction>, onDismiss: () -> Unit = {}) =
|
||||
BottomSheetDialog(onDismiss = onDismiss, modifier = Modifier.fillMaxHeight(fraction = .3f)) {
|
||||
val groupedEmojis = reactions.groupBy { it.emoji }
|
||||
var selectedEmoji by remember { mutableStateOf<String?>(null) }
|
||||
val filteredReactions = selectedEmoji?.let { groupedEmojis[it] ?: emptyList() } ?: reactions
|
||||
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
items(groupedEmojis.entries.toList()) { (emoji, reactions) ->
|
||||
Text(
|
||||
text = "$emoji${reactions.size}",
|
||||
modifier =
|
||||
Modifier.clip(CircleShape)
|
||||
.background(if (selectedEmoji == emoji) Color.Gray else Color.Transparent)
|
||||
.padding(8.dp)
|
||||
.clickable { selectedEmoji = if (selectedEmoji == emoji) null else emoji },
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
|
||||
LazyColumn(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
items(filteredReactions) { reaction ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(text = reaction.user.longName, style = MaterialTheme.typography.titleMedium)
|
||||
Text(text = reaction.emoji, style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
fun ReactionItemPreview() {
|
||||
AppTheme {
|
||||
Column(modifier = Modifier.background(MaterialTheme.colorScheme.background)) {
|
||||
ReactionItem(emoji = "\uD83D\uDE42")
|
||||
ReactionItem(emoji = "\uD83D\uDE42", emojiCount = 2)
|
||||
ReactionButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ReactionRowPreview() {
|
||||
AppTheme {
|
||||
ReactionRow(
|
||||
reactions =
|
||||
listOf(
|
||||
Reaction(
|
||||
replyId = 1,
|
||||
user = MeshProtos.User.getDefaultInstance(),
|
||||
emoji = "\uD83D\uDE42",
|
||||
timestamp = 1L,
|
||||
),
|
||||
Reaction(
|
||||
replyId = 1,
|
||||
user = MeshProtos.User.getDefaultInstance(),
|
||||
emoji = "\uD83D\uDE42",
|
||||
timestamp = 1L,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -36,10 +36,10 @@ import androidx.compose.ui.res.stringResource
|
|||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.ui.sharing.SharedContactDialog
|
||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.component.SharedContactDialog
|
||||
import org.meshtastic.core.ui.component.TitledCard
|
||||
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
|
|
|
|||
|
|
@ -57,14 +57,14 @@ import androidx.compose.ui.res.stringResource
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.geeksville.mesh.ui.sharing.AddContactFAB
|
||||
import com.geeksville.mesh.ui.sharing.supportsQrCodeSharing
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.model.DeviceVersion
|
||||
import org.meshtastic.core.service.ConnectionState
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.component.AddContactFAB
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.component.rememberTimeTickWithLifecycle
|
||||
import org.meshtastic.core.ui.component.supportsQrCodeSharing
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
|
||||
import org.meshtastic.feature.node.component.NodeActionDialogs
|
||||
import org.meshtastic.feature.node.component.NodeFilterTextField
|
||||
|
|
|
|||
|
|
@ -469,7 +469,7 @@ private fun QrCodeImage(
|
|||
) = Image(
|
||||
painter =
|
||||
channelSet.qrCode(shouldAddChannel)?.let { BitmapPainter(it.asImageBitmap()) }
|
||||
?: painterResource(id = com.geeksville.mesh.R.drawable.qrcode),
|
||||
?: painterResource(id = org.meshtastic.core.ui.R.drawable.qrcode),
|
||||
contentDescription = stringResource(R.string.qr_code),
|
||||
modifier = modifier,
|
||||
contentScale = ContentScale.Inside,
|
||||
|
|
|
|||
|
|
@ -1,306 +0,0 @@
|
|||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.sharing
|
||||
|
||||
import android.Manifest
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.QrCodeScanner
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.google.protobuf.ByteString
|
||||
import com.google.protobuf.Descriptors
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.MultiFormatWriter
|
||||
import com.google.zxing.WriterException
|
||||
import com.journeyapps.barcodescanner.BarcodeEncoder
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.journeyapps.barcodescanner.ScanOptions
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.model.DeviceVersion
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.component.CopyIconButton
|
||||
import org.meshtastic.core.ui.component.SimpleAlertDialog
|
||||
import org.meshtastic.proto.AdminProtos
|
||||
import org.meshtastic.proto.MeshProtos
|
||||
import timber.log.Timber
|
||||
import java.net.MalformedURLException
|
||||
|
||||
/**
|
||||
* Composable FloatingActionButton to initiate scanning a QR code for adding a contact. Handles camera permission
|
||||
* requests using Accompanist Permissions.
|
||||
*
|
||||
* @param modifier Modifier for this composable.
|
||||
*/
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
fun AddContactFAB(
|
||||
sharedContact: AdminProtos.SharedContact?,
|
||||
modifier: Modifier = Modifier,
|
||||
onSharedContactRequested: (AdminProtos.SharedContact?) -> Unit,
|
||||
) {
|
||||
val barcodeLauncher =
|
||||
rememberLauncherForActivityResult(ScanContract()) { result ->
|
||||
if (result.contents != null) {
|
||||
val uri = result.contents.toUri()
|
||||
val sharedContact =
|
||||
try {
|
||||
uri.toSharedContact()
|
||||
} catch (ex: MalformedURLException) {
|
||||
Timber.e("URL was malformed: ${ex.message}")
|
||||
null
|
||||
}
|
||||
if (sharedContact != null) {
|
||||
onSharedContactRequested(sharedContact)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sharedContact?.let { SharedContactDialog(sharedContact = it, onDismiss = { onSharedContactRequested(null) }) }
|
||||
|
||||
fun zxingScan() {
|
||||
Timber.d("Starting zxing QR code scanner")
|
||||
val zxingScan = ScanOptions()
|
||||
zxingScan.setCameraId(CAMERA_ID)
|
||||
zxingScan.setPrompt("")
|
||||
zxingScan.setBeepEnabled(false)
|
||||
zxingScan.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
|
||||
barcodeLauncher.launch(zxingScan)
|
||||
}
|
||||
|
||||
val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA)
|
||||
|
||||
LaunchedEffect(cameraPermissionState.status) {
|
||||
if (cameraPermissionState.status.isGranted) {
|
||||
Timber.d("Camera permission granted")
|
||||
} else {
|
||||
Timber.d("Camera permission denied")
|
||||
}
|
||||
}
|
||||
|
||||
FloatingActionButton(
|
||||
modifier = modifier,
|
||||
onClick = {
|
||||
if (cameraPermissionState.status.isGranted) {
|
||||
zxingScan()
|
||||
} else {
|
||||
cameraPermissionState.launchPermissionRequest()
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(imageVector = Icons.TwoTone.QrCodeScanner, contentDescription = stringResource(R.string.scan_qr_code))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QrCodeImage(uri: Uri, modifier: Modifier = Modifier) = Image(
|
||||
painter =
|
||||
uri.qrCode?.let { BitmapPainter(it.asImageBitmap()) }
|
||||
?: painterResource(id = com.geeksville.mesh.R.drawable.qrcode),
|
||||
contentDescription = stringResource(R.string.qr_code),
|
||||
modifier = modifier,
|
||||
contentScale = ContentScale.Inside,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun SharedContact(contactUri: Uri) {
|
||||
Column {
|
||||
QrCodeImage(uri = contactUri, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(4.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = contactUri.toString(), modifier = Modifier.weight(1f))
|
||||
CopyIconButton(valueToCopy = contactUri.toString(), modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a dialog with the contact's information as a QR code and URI.
|
||||
*
|
||||
* @param contact The node representing the contact to share. Null if no contact is selected.
|
||||
* @param onDismiss Callback invoked when the dialog is dismissed.
|
||||
*/
|
||||
@Composable
|
||||
fun SharedContactDialog(contact: Node?, onDismiss: () -> Unit) {
|
||||
if (contact == null) return
|
||||
val sharedContact = AdminProtos.SharedContact.newBuilder().setUser(contact.user).setNodeNum(contact.num).build()
|
||||
val uri = sharedContact.getSharedContactUrl()
|
||||
SimpleAlertDialog(
|
||||
title = R.string.share_contact,
|
||||
text = {
|
||||
Column {
|
||||
Text(contact.user.longName)
|
||||
SharedContact(contactUri = uri)
|
||||
}
|
||||
},
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ShareContactPreview() {
|
||||
SharedContact(contactUri = "https://example.com".toUri())
|
||||
}
|
||||
|
||||
/** Bitmap representation of the Uri as a QR code, or null if generation fails. */
|
||||
val Uri.qrCode: Bitmap?
|
||||
get() =
|
||||
try {
|
||||
val multiFormatWriter = MultiFormatWriter()
|
||||
val bitMatrix =
|
||||
multiFormatWriter.encode(this.toString(), BarcodeFormat.QR_CODE, BARCODE_PIXEL_SIZE, BARCODE_PIXEL_SIZE)
|
||||
val barcodeEncoder = BarcodeEncoder()
|
||||
barcodeEncoder.createBitmap(bitMatrix)
|
||||
} catch (ex: WriterException) {
|
||||
Timber.e("URL was too complex to render as barcode: ${ex.message}")
|
||||
null
|
||||
}
|
||||
|
||||
private const val REQUIRED_MIN_FIRMWARE = "2.6.8"
|
||||
private const val BARCODE_PIXEL_SIZE = 960
|
||||
private const val MESHTASTIC_HOST = "meshtastic.org"
|
||||
private const val CONTACT_SHARE_PATH = "/v/"
|
||||
|
||||
/** Prefix for Meshtastic contact sharing URLs. */
|
||||
internal const val URL_PREFIX = "https://$MESHTASTIC_HOST$CONTACT_SHARE_PATH#"
|
||||
private const val BASE64FLAGS = Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PADDING
|
||||
private const val CAMERA_ID = 0
|
||||
|
||||
/** Checks if the device firmware version supports QR code sharing. */
|
||||
fun DeviceVersion.supportsQrCodeSharing(): Boolean = this >= DeviceVersion(REQUIRED_MIN_FIRMWARE)
|
||||
|
||||
/**
|
||||
* Converts a URI to a [AdminProtos.SharedContact].
|
||||
*
|
||||
* @throws MalformedURLException if the URI is not a valid Meshtastic contact sharing URL.
|
||||
*/
|
||||
@Suppress("MagicNumber")
|
||||
@Throws(MalformedURLException::class)
|
||||
fun Uri.toSharedContact(): AdminProtos.SharedContact {
|
||||
if (fragment.isNullOrBlank() || !host.equals(MESHTASTIC_HOST, true) || !path.equals(CONTACT_SHARE_PATH, true)) {
|
||||
throw MalformedURLException("Not a valid Meshtastic URL: ${toString().take(40)}")
|
||||
}
|
||||
val url = AdminProtos.SharedContact.parseFrom(Base64.decode(fragment!!, BASE64FLAGS))
|
||||
return url.toBuilder().build()
|
||||
}
|
||||
|
||||
/** Converts a [AdminProtos.SharedContact] to its corresponding URI representation. */
|
||||
fun AdminProtos.SharedContact.getSharedContactUrl(): Uri {
|
||||
val bytes = this.toByteArray() ?: ByteArray(0)
|
||||
val enc = Base64.encodeToString(bytes, BASE64FLAGS)
|
||||
return "$URL_PREFIX$enc".toUri()
|
||||
}
|
||||
|
||||
/** Compares two [MeshProtos.User] objects and returns a string detailing the differences. */
|
||||
fun compareUsers(oldUser: MeshProtos.User, newUser: MeshProtos.User): String {
|
||||
val changes = mutableListOf<String>()
|
||||
|
||||
// Iterate over all fields in the User message descriptor
|
||||
for (fieldDescriptor: Descriptors.FieldDescriptor in MeshProtos.User.getDescriptor().fields) {
|
||||
val fieldName = fieldDescriptor.name
|
||||
val oldValue = if (oldUser.hasField(fieldDescriptor)) oldUser.getField(fieldDescriptor) else null
|
||||
val newValue = if (newUser.hasField(fieldDescriptor)) newUser.getField(fieldDescriptor) else null
|
||||
|
||||
if (oldValue != newValue) {
|
||||
val oldValueString = valueToString(oldValue, fieldDescriptor)
|
||||
val newValueString = valueToString(newValue, fieldDescriptor)
|
||||
changes.add("$fieldName: $oldValueString -> $newValueString")
|
||||
}
|
||||
}
|
||||
|
||||
return if (changes.isEmpty()) {
|
||||
"No changes detected."
|
||||
} else {
|
||||
"Changes:\n" + changes.joinToString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
/** Converts a [MeshProtos.User] object to a string representation of its fields and values. */
|
||||
fun userFieldsToString(user: MeshProtos.User): String {
|
||||
val fieldLines = mutableListOf<String>()
|
||||
|
||||
for (fieldDescriptor: Descriptors.FieldDescriptor in MeshProtos.User.getDescriptor().fields) {
|
||||
val fieldName = fieldDescriptor.name
|
||||
if (user.hasField(fieldDescriptor)) {
|
||||
val value = user.getField(fieldDescriptor)
|
||||
val valueString = valueToString(value, fieldDescriptor) // Using the helper from previous example
|
||||
fieldLines.add("$fieldName: $valueString")
|
||||
} else if (fieldDescriptor.isRepeated || fieldDescriptor.hasDefaultValue() || fieldDescriptor.isOptional) {
|
||||
val defaultValue = fieldDescriptor.defaultValue
|
||||
val valueString =
|
||||
if (fieldDescriptor.isRepeated) {
|
||||
"[]" // Empty list
|
||||
} else if (user.hasField(fieldDescriptor)) {
|
||||
valueToString(user.getField(fieldDescriptor), fieldDescriptor)
|
||||
} else {
|
||||
valueToString(defaultValue, fieldDescriptor)
|
||||
}
|
||||
|
||||
fieldLines.add("$fieldName: $valueString")
|
||||
}
|
||||
}
|
||||
return if (fieldLines.isEmpty()) {
|
||||
"User object has no fields set."
|
||||
} else {
|
||||
fieldLines.joinToString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
private fun valueToString(value: Any?, fieldDescriptor: Descriptors.FieldDescriptor): String {
|
||||
if (value == null) {
|
||||
return "null"
|
||||
}
|
||||
return when (fieldDescriptor.type) {
|
||||
Descriptors.FieldDescriptor.Type.BYTES -> {
|
||||
// For ByteString, you might want to display it as hex or Base64
|
||||
// For simplicity, here we'll just show its size.
|
||||
if (value is ByteString) {
|
||||
Base64.encodeToString(value.toByteArray(), Base64.DEFAULT).trim()
|
||||
} else {
|
||||
value.toString().trim()
|
||||
}
|
||||
}
|
||||
// Add more custom formatting for other types if needed
|
||||
else -> value.toString().trim()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.sharing
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.component.SimpleAlertDialog
|
||||
import org.meshtastic.proto.AdminProtos
|
||||
|
||||
/** A dialog for importing a shared contact that was scanned from a QR code. */
|
||||
@Composable
|
||||
fun SharedContactDialog(
|
||||
sharedContact: AdminProtos.SharedContact,
|
||||
onDismiss: () -> Unit,
|
||||
viewModel: SharedContactViewModel = hiltViewModel(),
|
||||
) {
|
||||
val unfilteredNodes by viewModel.unfilteredNodes.collectAsStateWithLifecycle()
|
||||
|
||||
val nodeNum = sharedContact.nodeNum
|
||||
val node = unfilteredNodes.find { it.num == nodeNum }
|
||||
|
||||
SimpleAlertDialog(
|
||||
title = R.string.import_shared_contact,
|
||||
text = {
|
||||
Column {
|
||||
if (node != null) {
|
||||
Text(text = stringResource(R.string.import_known_shared_contact_text))
|
||||
if (node.user.publicKey.size() > 0 && node.user.publicKey != sharedContact.user?.publicKey) {
|
||||
Text(
|
||||
text = stringResource(R.string.public_key_changed),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
HorizontalDivider()
|
||||
Text(text = compareUsers(node.user, sharedContact.user))
|
||||
} else {
|
||||
Text(text = userFieldsToString(sharedContact.user))
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissText = stringResource(R.string.cancel),
|
||||
onDismiss = onDismiss,
|
||||
confirmText = stringResource(R.string.import_label),
|
||||
onConfirm = {
|
||||
viewModel.addSharedContact(sharedContact)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.sharing
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.service.ServiceAction
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.proto.AdminProtos
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class SharedContactViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
nodeRepository: NodeRepository,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val unfilteredNodes: StateFlow<List<Node>> =
|
||||
nodeRepository
|
||||
.getNodes()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = emptyList(),
|
||||
)
|
||||
|
||||
fun addSharedContact(sharedContact: AdminProtos.SharedContact) =
|
||||
viewModelScope.launch { serviceRepository.onServiceAction(ServiceAction.ImportContact(sharedContact)) }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue