Meshtastic-Android/app/src/main/java/com/geeksville/mesh/model/UIState.kt

277 lines
10 KiB
Kotlin
Raw Normal View History

/*
2025-01-02 06:50:26 -03:00
* 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/>.
*/
2020-02-17 13:34:52 -08:00
package com.geeksville.mesh.model
import android.app.Application
2020-03-17 11:35:19 -07:00
import android.net.Uri
2025-08-20 18:49:06 -04:00
import androidx.compose.material3.SnackbarDuration
2025-05-17 11:39:53 -05:00
import androidx.compose.material3.SnackbarHostState
2025-08-20 18:49:06 -04:00
import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.Composable
2022-04-22 17:22:06 -03:00
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
2022-09-15 22:24:04 -03:00
import androidx.lifecycle.asLiveData
2020-09-23 22:47:45 -04:00
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import com.geeksville.mesh.repository.radio.MeshActivity
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.shareIn
2020-09-23 22:47:45 -04:00
import kotlinx.coroutines.launch
import org.meshtastic.core.analytics.platform.PlatformAnalytics
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
2025-09-24 16:23:05 -04:00
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.asDeviceVersion
2025-09-23 15:52:09 -04:00
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.model.util.toChannelSet
2025-09-30 16:55:56 -04:00
import org.meshtastic.core.service.IMeshService
2025-10-12 13:07:03 -04:00
import org.meshtastic.core.service.MeshServiceNotifications
2025-09-30 16:55:56 -04:00
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.R
2025-10-12 13:07:03 -04:00
import org.meshtastic.core.ui.component.toSharedContact
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
2025-10-08 14:20:09 -04:00
import org.meshtastic.proto.AdminProtos
import org.meshtastic.proto.AppOnlyProtos
import org.meshtastic.proto.MeshProtos
import timber.log.Timber
import javax.inject.Inject
// Given a human name, strip out the first letter of the first three words and return that as the
// initials for
// that user, ignoring emojis. If the original name is only one word, strip vowels from the original
// name and if the result is 3 or more characters, use the first three characters. If not, just take
// the first 3 characters of the original name.
fun getInitials(fullName: String): String {
val maxInitialLength = 4
val minWordCountForInitials = 2
val name = fullName.trim().withoutEmojis()
val words = name.split(Regex("\\s+")).filter { it.isNotEmpty() }
val initials =
when (words.size) {
in 0 until minWordCountForInitials -> {
val nameWithoutVowels =
if (name.isNotEmpty()) {
name.first() + name.drop(1).filterNot { c -> c.lowercase() in "aeiou" }
} else {
""
}
if (nameWithoutVowels.length >= maxInitialLength) nameWithoutVowels else name
}
2025-05-17 11:39:53 -05:00
else -> words.map { it.first() }.joinToString("")
}
return initials.take(maxInitialLength)
}
2020-03-15 16:30:12 -07:00
private fun String.withoutEmojis(): String = filterNot { char -> char.isSurrogate() }
@Suppress("LongParameterList", "LargeClass", "UnusedPrivateProperty")
@HiltViewModel
class UIViewModel
@Inject
constructor(
private val app: Application,
private val nodeDB: NodeRepository,
private val serviceRepository: ServiceRepository,
radioInterfaceService: RadioInterfaceService,
meshLogRepository: MeshLogRepository,
firmwareReleaseRepository: FirmwareReleaseRepository,
2025-09-23 15:52:09 -04:00
private val uiPreferencesDataSource: UiPreferencesDataSource,
private val meshServiceNotifications: MeshServiceNotifications,
private val analytics: PlatformAnalytics,
packetRepository: PacketRepository,
) : ViewModel() {
2020-09-23 22:47:45 -04:00
2025-09-23 15:52:09 -04:00
val theme: StateFlow<Int> = uiPreferencesDataSource.theme
2025-05-17 11:39:53 -05:00
val firmwareEdition = meshLogRepository.getMyNodeInfo().map { nodeInfo -> nodeInfo?.firmwareEdition }
val clientNotification: StateFlow<MeshProtos.ClientNotification?> = serviceRepository.clientNotification
fun clearClientNotification(notification: MeshProtos.ClientNotification) {
serviceRepository.clearClientNotification()
meshServiceNotifications.clearClientNotification(notification)
}
/**
* Emits events for mesh network send/receive activity. This is a SharedFlow to ensure all events are delivered,
* even if they are the same.
*/
val meshActivity: SharedFlow<MeshActivity> =
radioInterfaceService.meshActivity.shareIn(viewModelScope, SharingStarted.Eagerly, 0)
2025-05-17 11:39:53 -05:00
data class AlertData(
val title: String,
val message: String? = null,
val html: String? = null,
val onConfirm: (() -> Unit)? = null,
val onDismiss: (() -> Unit)? = null,
val choices: Map<String, () -> Unit> = emptyMap(),
2025-05-17 11:39:53 -05:00
)
private val _currentAlert: MutableStateFlow<AlertData?> = MutableStateFlow(null)
val currentAlert = _currentAlert.asStateFlow()
fun showAlert(
title: String,
message: String? = null,
html: String? = null,
onConfirm: (() -> Unit)? = {},
dismissable: Boolean = true,
choices: Map<String, () -> Unit> = emptyMap(),
2025-05-17 11:39:53 -05:00
) {
_currentAlert.value =
AlertData(
title = title,
message = message,
html = html,
onConfirm = {
onConfirm?.invoke()
dismissAlert()
2025-05-17 11:39:53 -05:00
},
onDismiss = { if (dismissable) dismissAlert() },
choices = choices,
2025-05-17 11:39:53 -05:00
)
}
private fun dismissAlert() {
_currentAlert.value = null
}
val meshService: IMeshService?
get() = serviceRepository.meshService
2023-02-02 17:13:44 -03:00
val unreadMessageCount =
packetRepository.getUnreadCountTotal().map { it.coerceAtLeast(0) }.stateInWhileSubscribed(initialValue = 0)
// hardware info about our local device (can be null)
val myNodeInfo: StateFlow<MyNodeEntity?>
get() = nodeDB.myNodeInfo
2025-08-20 18:49:06 -04:00
val snackBarHostState = SnackbarHostState()
fun showSnackBar(text: Int) = showSnackBar(app.getString(text))
2025-08-20 18:49:06 -04:00
fun showSnackBar(
text: String,
actionLabel: String? = null,
withDismissAction: Boolean = false,
duration: SnackbarDuration = if (actionLabel == null) SnackbarDuration.Short else SnackbarDuration.Indefinite,
onActionPerformed: (() -> Unit) = {},
onDismissed: (() -> Unit) = {},
) = viewModelScope.launch {
snackBarHostState.showSnackbar(text, actionLabel, withDismissAction, duration).run {
when (this) {
SnackbarResult.ActionPerformed -> onActionPerformed()
SnackbarResult.Dismissed -> onDismissed()
}
}
}
2020-04-07 17:42:31 -07:00
init {
serviceRepository.errorMessage
.filterNotNull()
.onEach {
showAlert(
title = app.getString(R.string.client_notification),
message = it,
onConfirm = { serviceRepository.clearErrorMessage() },
dismissable = false,
)
}
.launchIn(viewModelScope)
Timber.d("ViewModel created")
2020-04-07 17:42:31 -07:00
}
private val _sharedContactRequested: MutableStateFlow<AdminProtos.SharedContact?> = MutableStateFlow(null)
val sharedContactRequested: StateFlow<AdminProtos.SharedContact?>
get() = _sharedContactRequested.asStateFlow()
fun setSharedContactRequested(url: Uri) {
runCatching { _sharedContactRequested.value = url.toSharedContact() }
.onFailure { ex ->
Timber.e(ex, "Shared contact error")
showSnackBar(R.string.contact_invalid)
}
}
/** Called immediately after activity observes requestChannelUrl */
fun clearSharedContactRequested() {
_sharedContactRequested.value = null
}
// Connection state to our radio device
val connectionState
get() = serviceRepository.connectionState
private val _requestChannelSet = MutableStateFlow<AppOnlyProtos.ChannelSet?>(null)
val requestChannelSet: StateFlow<AppOnlyProtos.ChannelSet?>
get() = _requestChannelSet
2022-05-17 17:29:21 -03:00
fun requestChannelUrl(url: Uri) = runCatching { _requestChannelSet.value = url.toChannelSet() }
.onFailure { ex ->
Timber.e(ex, "Channel url error")
showSnackBar(R.string.channel_invalid)
}
2022-05-17 17:29:21 -03:00
val latestStableFirmwareRelease = firmwareReleaseRepository.stableRelease.mapNotNull { it?.asDeviceVersion() }
/** Called immediately after activity observes requestChannelUrl */
2022-05-17 17:29:21 -03:00
fun clearRequestChannelUrl() {
_requestChannelSet.value = null
2022-05-17 17:29:21 -03:00
}
override fun onCleared() {
super.onCleared()
Timber.d("ViewModel cleared")
}
val tracerouteResponse: LiveData<String?>
get() = serviceRepository.tracerouteResponse.asLiveData()
fun clearTracerouteResponse() {
serviceRepository.clearTracerouteResponse()
}
2025-09-23 15:52:09 -04:00
val appIntroCompleted: StateFlow<Boolean> = uiPreferencesDataSource.appIntroCompleted
fun onAppIntroCompleted() {
2025-09-23 15:52:09 -04:00
uiPreferencesDataSource.setAppIntroCompleted(true)
}
@Composable
fun AddNavigationTrackingEffect(navController: NavHostController) {
analytics.AddNavigationTrackingEffect(navController)
}
}