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

286 lines
11 KiB
Kotlin
Raw Normal View History

/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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
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 co.touchlab.kermit.Logger
import com.geeksville.mesh.repository.radio.MeshActivity
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
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
import org.jetbrains.compose.resources.getString
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.TracerouteMapAvailability
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
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.service.TracerouteResponse
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.client_notification
import org.meshtastic.core.ui.component.ScrollToTopEvent
2025-10-12 13:07:03 -04:00
import org.meshtastic.core.ui.component.toSharedContact
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.SharedContact
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?.firmware_edition }
val clientNotification: StateFlow<ClientNotification?> = serviceRepository.clientNotification
fun clearClientNotification(notification: 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)
private val _scrollToTopEventFlow =
MutableSharedFlow<ScrollToTopEvent>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val scrollToTopEventFlow: Flow<ScrollToTopEvent> = _scrollToTopEventFlow.asSharedFlow()
fun emitScrollToTopEvent(event: ScrollToTopEvent) {
_scrollToTopEventFlow.tryEmit(event)
}
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 tracerouteMapAvailability(forwardRoute: List<Int>, returnRoute: List<Int>): TracerouteMapAvailability =
evaluateTracerouteMapAvailability(
forwardRoute = forwardRoute,
returnRoute = returnRoute,
positionedNodeNums =
nodeDB.nodeDBbyNum.value.values.filter { it.validPosition != null }.map { it.num }.toSet(),
)
2025-05-17 11:39:53 -05:00
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
2020-04-07 17:42:31 -07:00
init {
serviceRepository.errorMessage
.filterNotNull()
.onEach {
showAlert(
title = getString(Res.string.client_notification),
message = it,
onConfirm = { serviceRepository.clearErrorMessage() },
dismissable = false,
)
}
.launchIn(viewModelScope)
Logger.d { "ViewModel created" }
2020-04-07 17:42:31 -07:00
}
private val _sharedContactRequested: MutableStateFlow<SharedContact?> = MutableStateFlow(null)
val sharedContactRequested: StateFlow<SharedContact?>
get() = _sharedContactRequested.asStateFlow()
fun setSharedContactRequested(url: Uri, onFailure: () -> Unit) {
runCatching { _sharedContactRequested.value = url.toSharedContact() }
.onFailure { ex ->
Logger.e(ex) { "Shared contact error" }
onFailure()
}
}
/** 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<ChannelSet?>(null)
val requestChannelSet: StateFlow<ChannelSet?>
get() = _requestChannelSet
2022-05-17 17:29:21 -03:00
fun requestChannelUrl(url: Uri, onFailure: () -> Unit) =
runCatching { _requestChannelSet.value = url.toChannelSet() }
.onFailure { ex ->
Logger.e(ex) { "Channel url error" }
onFailure()
}
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()
Logger.d { "ViewModel cleared" }
}
val tracerouteResponse: LiveData<TracerouteResponse?>
get() = serviceRepository.tracerouteResponse.asLiveData()
fun clearTracerouteResponse() {
serviceRepository.clearTracerouteResponse()
}
val neighborInfoResponse: LiveData<String?>
get() = serviceRepository.neighborInfoResponse.asLiveData()
fun clearNeighborInfoResponse() {
serviceRepository.clearNeighborInfoResponse()
}
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)
}
}