feat: adopt Material 3 Expressive design system (M3-native APIs only) (#5479)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2
.github/agents/speckit.verify.run.agent.md
vendored
@@ -34,7 +34,7 @@ Validate the implementation against its specification artifacts (`spec.md`, `pla
|
||||
Run `.specify/scripts/bash/check-prerequisites.sh --json --paths-only` from repo root.
|
||||
|
||||
1. **Script succeeds** (on a feature branch): Parse JSON for FEATURE_DIR. Set `FEATURE_BRANCH = true`. Proceed to next step.
|
||||
2. **Script fails** (not on a feature branch): You MUST prompt for available features (Scan `specs/NNN-*/` to get available features). Use the **AskUserQuestion tool** to let the user select. **Do NOT guess or auto-select a change. Always let the user choose.**
|
||||
2. **Script fails** (not on a feature branch): You MUST prompt for available features (Scan `specs/*/` to get available features). Use the **AskUserQuestion tool** to let the user select. **Do NOT guess or auto-select a change. Always let the user choose.**
|
||||
|
||||
Derive absolute paths:
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ The standard SDD cycle for a new feature:
|
||||
|
||||
```text
|
||||
1. /speckit.specify "Feature description here"
|
||||
→ Creates specs/<NNN>-feature-name/spec.md
|
||||
→ Creates specs/<YYYYMMDD-HHMMSS>-feature-name/spec.md
|
||||
→ Auto-creates feature branch via git hook
|
||||
|
||||
2. /speckit.clarify
|
||||
@@ -85,11 +85,11 @@ This runs: specify → (review gate) → plan → (review gate) → tasks → im
|
||||
|
||||
## File Structure
|
||||
|
||||
Spec Kit produces files under `specs/<NNN>-feature-name/`:
|
||||
Spec Kit produces files under `specs/<YYYYMMDD-HHMMSS>-feature-name/`:
|
||||
|
||||
```
|
||||
specs/
|
||||
└── 001-feature-name/
|
||||
└── 20260513-160000-feature-name/
|
||||
├── spec.md # Feature specification (FRs, NFRs, SCs, user stories)
|
||||
├── plan.md # Implementation plan (architecture, phases)
|
||||
├── tasks.md # Dependency-ordered task list
|
||||
|
||||
@@ -30,7 +30,7 @@ Validate the implementation against its specification artifacts (`spec.md`, `pla
|
||||
Run `{SCRIPT}` from repo root.
|
||||
|
||||
1. **Script succeeds** (on a feature branch): Parse JSON for FEATURE_DIR. Set `FEATURE_BRANCH = true`. Proceed to next step.
|
||||
2. **Script fails** (not on a feature branch): You MUST prompt for available features (Scan `specs/NNN-*/` to get available features). Use the **AskUserQuestion tool** to let the user select. **Do NOT guess or auto-select a change. Always let the user choose.**
|
||||
2. **Script fails** (not on a feature branch): You MUST prompt for available features (Scan `specs/*/` to get available features). Use the **AskUserQuestion tool** to let the user select. **Do NOT guess or auto-select a change. Always let the user choose.**
|
||||
|
||||
Derive absolute paths:
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"feature_directory":"specs/006-kmp-project-structure"}
|
||||
{"feature_directory":"specs/20260513-160000-m3-expressive-adoption"}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"ai": "opencode",
|
||||
"branch_numbering": "sequential",
|
||||
"branch_numbering": "timestamp",
|
||||
"context_file": "AGENTS.md",
|
||||
"here": true,
|
||||
"integration": "opencode",
|
||||
|
||||
@@ -49,5 +49,5 @@ You are an expert Android/KMP engineer. Maintain architectural boundaries, use M
|
||||
<!-- SPECKIT START -->
|
||||
For additional context about technologies to be used, project structure,
|
||||
shell commands, and other important information, read the current plan
|
||||
at `specs/006-kmp-project-structure/plan.md`
|
||||
at `specs/20260513-160000-m3-expressive-adoption/plan.md`
|
||||
<!-- SPECKIT END -->
|
||||
|
||||
@@ -34,11 +34,13 @@ import androidx.compose.material3.AlertDialogDefaults
|
||||
import androidx.compose.material3.BasicAlertDialog
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuGroup
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MenuDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -723,69 +725,71 @@ private fun FdroidMainMapFilterDropdown(
|
||||
mapFilterState: MapFilterState,
|
||||
mapViewModel: MapViewModel,
|
||||
) {
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = onDismissRequest,
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.surface),
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = MeshtasticIcons.Favorite,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(text = stringResource(Res.string.only_favorites), modifier = Modifier.weight(1f))
|
||||
Checkbox(
|
||||
checked = mapFilterState.onlyFavorites,
|
||||
onCheckedChange = { mapViewModel.toggleOnlyFavorites() },
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = { mapViewModel.toggleOnlyFavorites() },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = MeshtasticIcons.PinDrop,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(text = stringResource(Res.string.show_waypoints), modifier = Modifier.weight(1f))
|
||||
Checkbox(
|
||||
checked = mapFilterState.showWaypoints,
|
||||
onCheckedChange = { mapViewModel.toggleShowWaypointsOnMap() },
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = { mapViewModel.toggleShowWaypointsOnMap() },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = MeshtasticIcons.Lens,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(text = stringResource(Res.string.show_precision_circle), modifier = Modifier.weight(1f))
|
||||
Checkbox(
|
||||
checked = mapFilterState.showPrecisionCircle,
|
||||
onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() },
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() },
|
||||
)
|
||||
HorizontalDivider()
|
||||
DropdownMenuGroup(shapes = MenuDefaults.groupShapes()) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = MeshtasticIcons.Favorite,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(text = stringResource(Res.string.only_favorites), modifier = Modifier.weight(1f))
|
||||
Checkbox(
|
||||
checked = mapFilterState.onlyFavorites,
|
||||
onCheckedChange = { mapViewModel.toggleOnlyFavorites() },
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = { mapViewModel.toggleOnlyFavorites() },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = MeshtasticIcons.PinDrop,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(text = stringResource(Res.string.show_waypoints), modifier = Modifier.weight(1f))
|
||||
Checkbox(
|
||||
checked = mapFilterState.showWaypoints,
|
||||
onCheckedChange = { mapViewModel.toggleShowWaypointsOnMap() },
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = { mapViewModel.toggleShowWaypointsOnMap() },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = MeshtasticIcons.Lens,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(text = stringResource(Res.string.show_precision_circle), modifier = Modifier.weight(1f))
|
||||
Checkbox(
|
||||
checked = mapFilterState.showPrecisionCircle,
|
||||
onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() },
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() },
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
val filterOptions = LastHeardFilter.entries
|
||||
val selectedIndex = filterOptions.indexOf(mapFilterState.lastHeardFilter)
|
||||
|
||||
@@ -14,16 +14,20 @@
|
||||
* 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:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package org.meshtastic.app.map.component
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuGroup
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MenuDefaults
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -52,55 +56,56 @@ import kotlin.math.roundToInt
|
||||
internal fun MapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit, mapViewModel: MapViewModel) {
|
||||
val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.only_favorites)) },
|
||||
onClick = { mapViewModel.toggleOnlyFavorites() },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = MeshtasticIcons.Favorite,
|
||||
contentDescription = stringResource(Res.string.only_favorites),
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
Checkbox(
|
||||
checked = mapFilterState.onlyFavorites,
|
||||
onCheckedChange = { mapViewModel.toggleOnlyFavorites() },
|
||||
)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.show_waypoints)) },
|
||||
onClick = { mapViewModel.toggleShowWaypointsOnMap() },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = MeshtasticIcons.PinDrop,
|
||||
contentDescription = stringResource(Res.string.show_waypoints),
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
Checkbox(
|
||||
checked = mapFilterState.showWaypoints,
|
||||
onCheckedChange = { mapViewModel.toggleShowWaypointsOnMap() },
|
||||
)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.show_precision_circle)) },
|
||||
onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = MeshtasticIcons.Lens,
|
||||
contentDescription = stringResource(Res.string.show_precision_circle),
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
Checkbox(
|
||||
checked = mapFilterState.showPrecisionCircle,
|
||||
onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() },
|
||||
)
|
||||
},
|
||||
)
|
||||
HorizontalDivider()
|
||||
DropdownMenuGroup(shapes = MenuDefaults.groupShapes()) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.only_favorites)) },
|
||||
onClick = { mapViewModel.toggleOnlyFavorites() },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = MeshtasticIcons.Favorite,
|
||||
contentDescription = stringResource(Res.string.only_favorites),
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
Checkbox(
|
||||
checked = mapFilterState.onlyFavorites,
|
||||
onCheckedChange = { mapViewModel.toggleOnlyFavorites() },
|
||||
)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.show_waypoints)) },
|
||||
onClick = { mapViewModel.toggleShowWaypointsOnMap() },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = MeshtasticIcons.PinDrop,
|
||||
contentDescription = stringResource(Res.string.show_waypoints),
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
Checkbox(
|
||||
checked = mapFilterState.showWaypoints,
|
||||
onCheckedChange = { mapViewModel.toggleShowWaypointsOnMap() },
|
||||
)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.show_precision_circle)) },
|
||||
onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = MeshtasticIcons.Lens,
|
||||
contentDescription = stringResource(Res.string.show_precision_circle),
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
Checkbox(
|
||||
checked = mapFilterState.showPrecisionCircle,
|
||||
onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
val filterOptions = LastHeardFilter.entries
|
||||
val selectedIndex = filterOptions.indexOf(mapFilterState.lastHeardFilter)
|
||||
|
||||
@@ -14,12 +14,16 @@
|
||||
* 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:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package org.meshtastic.app.map.component
|
||||
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuGroup
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MenuDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -58,38 +62,16 @@ internal fun MapTypeDropdown(
|
||||
)
|
||||
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
|
||||
googleMapTypes.forEach { (name, type) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(name) },
|
||||
onClick = {
|
||||
mapViewModel.setSelectedGoogleMapType(type)
|
||||
onDismissRequest() // Close menu
|
||||
},
|
||||
trailingIcon =
|
||||
if (selectedCustomUrl == null && selectedGoogleMapType == type) {
|
||||
{
|
||||
Icon(
|
||||
MeshtasticIcons.Check,
|
||||
contentDescription = stringResource(Res.string.selected_map_type),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (customTileProviders.isNotEmpty()) {
|
||||
HorizontalDivider()
|
||||
customTileProviders.forEach { config ->
|
||||
DropdownMenuGroup(shapes = MenuDefaults.groupShapes()) {
|
||||
googleMapTypes.forEach { (name, type) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(config.name) },
|
||||
text = { Text(name) },
|
||||
onClick = {
|
||||
mapViewModel.selectCustomTileProvider(config)
|
||||
onDismissRequest() // Close menu
|
||||
mapViewModel.setSelectedGoogleMapType(type)
|
||||
onDismissRequest()
|
||||
},
|
||||
trailingIcon =
|
||||
if (selectedCustomUrl == config.urlTemplate) {
|
||||
if (selectedCustomUrl == null && selectedGoogleMapType == type) {
|
||||
{
|
||||
Icon(
|
||||
MeshtasticIcons.Check,
|
||||
@@ -102,7 +84,31 @@ internal fun MapTypeDropdown(
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
|
||||
if (customTileProviders.isNotEmpty()) {
|
||||
DropdownMenuGroup(shapes = MenuDefaults.groupShapes()) {
|
||||
customTileProviders.forEach { config ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(config.name) },
|
||||
onClick = {
|
||||
mapViewModel.selectCustomTileProvider(config)
|
||||
onDismissRequest()
|
||||
},
|
||||
trailingIcon =
|
||||
if (selectedCustomUrl == config.urlTemplate) {
|
||||
{
|
||||
Icon(
|
||||
MeshtasticIcons.Check,
|
||||
contentDescription = stringResource(Res.string.selected_map_type),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.manage_custom_tile_sources)) },
|
||||
onClick = {
|
||||
|
||||
@@ -19,6 +19,7 @@ package org.meshtastic.core.prefs.emoji
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -42,6 +43,9 @@ class CustomEmojiPrefsImpl(
|
||||
override val customEmojiFrequency: StateFlow<String?> =
|
||||
dataStore.data.map { it[KEY_EMOJI_FREQ_PREF] }.stateIn(scope, SharingStarted.Eagerly, null)
|
||||
|
||||
override val preferredSkinToneIndex: StateFlow<Int> =
|
||||
dataStore.data.map { it[KEY_SKIN_TONE_PREF] ?: 0 }.stateIn(scope, SharingStarted.Eagerly, 0)
|
||||
|
||||
override fun setCustomEmojiFrequency(frequency: String?) {
|
||||
scope.launch {
|
||||
dataStore.edit { prefs ->
|
||||
@@ -54,8 +58,13 @@ class CustomEmojiPrefsImpl(
|
||||
}
|
||||
}
|
||||
|
||||
override fun setPreferredSkinToneIndex(index: Int) {
|
||||
scope.launch { dataStore.edit { prefs -> prefs[KEY_SKIN_TONE_PREF] = index } }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KEY_EMOJI_FREQ = "pref_key_custom_emoji_freq"
|
||||
val KEY_EMOJI_FREQ_PREF = stringPreferencesKey(KEY_EMOJI_FREQ)
|
||||
val KEY_SKIN_TONE_PREF = intPreferencesKey("pref_key_skin_tone")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,8 +65,11 @@ interface MeshLogPrefs {
|
||||
/** Reactive interface for emoji preferences. */
|
||||
interface CustomEmojiPrefs {
|
||||
val customEmojiFrequency: StateFlow<String?>
|
||||
val preferredSkinToneIndex: StateFlow<Int>
|
||||
|
||||
fun setCustomEmojiFrequency(frequency: String?)
|
||||
|
||||
fun setPreferredSkinToneIndex(index: Int)
|
||||
}
|
||||
|
||||
/** Reactive interface for general UI preferences. */
|
||||
|
||||
@@ -64,10 +64,15 @@ class FakeFilterPrefs : FilterPrefs {
|
||||
|
||||
class FakeCustomEmojiPrefs : CustomEmojiPrefs {
|
||||
override val customEmojiFrequency = MutableStateFlow<String?>(null)
|
||||
override val preferredSkinToneIndex = MutableStateFlow(0)
|
||||
|
||||
override fun setCustomEmojiFrequency(frequency: String?) {
|
||||
customEmojiFrequency.value = frequency
|
||||
}
|
||||
|
||||
override fun setPreferredSkinToneIndex(index: Int) {
|
||||
preferredSkinToneIndex.value = index
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("TooManyFunctions")
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
plugins {
|
||||
alias(libs.plugins.meshtastic.kmp.library)
|
||||
alias(libs.plugins.meshtastic.kmp.library.compose)
|
||||
alias(libs.plugins.meshtastic.kotlinx.serialization)
|
||||
id("meshtastic.kmp.jvm.android")
|
||||
id("meshtastic.koin")
|
||||
}
|
||||
@@ -44,6 +45,7 @@ kotlin {
|
||||
api(libs.compose.multiplatform.ui.tooling.preview)
|
||||
|
||||
implementation(libs.kermit)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.koin.compose.viewmodel)
|
||||
implementation(libs.qrcode.kotlin)
|
||||
implementation(libs.jetbrains.compose.material3.adaptive)
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
|
||||
@Composable
|
||||
actual fun rememberTimeTickWithLifecycle(): Long {
|
||||
val context = LocalContext.current
|
||||
var value by remember { mutableLongStateOf(nowMillis) }
|
||||
|
||||
DisposableEffect(context) {
|
||||
val receiver =
|
||||
object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
value = nowMillis
|
||||
}
|
||||
}
|
||||
|
||||
androidx.core.content.ContextCompat.registerReceiver(
|
||||
context,
|
||||
receiver,
|
||||
IntentFilter(Intent.ACTION_TIME_TICK),
|
||||
androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
|
||||
onDispose { context.unregisterReceiver(receiver) }
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -66,6 +67,7 @@ import org.meshtastic.core.ui.util.annotatedStringFromHtml
|
||||
* @param dismissable Whether the dialog can be dismissed by clicking outside or pressing back.
|
||||
*/
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
fun MeshtasticDialog(
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -137,7 +139,7 @@ fun MeshtasticDialog(
|
||||
text = titleText,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
style = MaterialTheme.typography.headlineSmallEmphasized,
|
||||
)
|
||||
},
|
||||
text = {
|
||||
@@ -167,7 +169,7 @@ fun MeshtasticDialog(
|
||||
}
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
|
||||
@Composable
|
||||
fun BottomSheetDialog(
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) = Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.fillMaxSize()
|
||||
.background(Color.Transparent)
|
||||
.clickable(
|
||||
onClick = onDismiss,
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 1f),
|
||||
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
|
||||
)
|
||||
.padding(16.dp),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@
|
||||
* 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:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -23,6 +25,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -54,7 +57,7 @@ fun ChannelItem(
|
||||
modifier = Modifier.weight(1f),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
color = fontColor,
|
||||
)
|
||||
content()
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@Composable
|
||||
fun ClickableTextField(
|
||||
label: StringResource,
|
||||
enabled: Boolean,
|
||||
trailingIcon: ImageVector,
|
||||
value: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
isError: Boolean = false,
|
||||
trailingIconContentDescription: String? = null,
|
||||
) {
|
||||
val source = remember { MutableInteractionSource() }
|
||||
val isPressed by source.collectIsPressedAsState()
|
||||
if (isPressed) onClick()
|
||||
|
||||
OutlinedTextField(
|
||||
value,
|
||||
onValueChange = {},
|
||||
enabled = enabled,
|
||||
readOnly = true,
|
||||
label = { Text(stringResource(label)) },
|
||||
trailingIcon = { Icon(trailingIcon, trailingIconContentDescription) },
|
||||
isError = isError,
|
||||
interactionSource = source,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -14,11 +14,12 @@
|
||||
* 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:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
@@ -29,6 +30,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.Clipboard
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
@@ -49,14 +51,14 @@ fun ListItem(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
supportingText: String? = null,
|
||||
textColor: Color = LocalContentColor.current,
|
||||
supportingTextColor: Color = LocalContentColor.current,
|
||||
textColor: Color = Color.Unspecified,
|
||||
supportingTextColor: Color = Color.Unspecified,
|
||||
copyable: Boolean = false,
|
||||
enabled: Boolean = true,
|
||||
leadingIcon: ImageVector? = null,
|
||||
leadingIconTint: Color = LocalContentColor.current,
|
||||
leadingIconTint: Color = Color.Unspecified,
|
||||
trailingIcon: ImageVector? = MeshtasticIcons.ChevronRight,
|
||||
trailingIconTint: Color = LocalContentColor.current,
|
||||
trailingIconTint: Color = Color.Unspecified,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val clipboard: Clipboard = LocalClipboard.current
|
||||
@@ -89,10 +91,10 @@ fun SwitchListItem(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
textColor: Color = LocalContentColor.current,
|
||||
textColor: Color = Color.Unspecified,
|
||||
enabled: Boolean = true,
|
||||
leadingIcon: ImageVector? = null,
|
||||
leadingIconTint: Color = LocalContentColor.current,
|
||||
leadingIconTint: Color = Color.Unspecified,
|
||||
) {
|
||||
BasicListItem(
|
||||
text = text,
|
||||
@@ -113,42 +115,57 @@ fun SwitchListItem(
|
||||
* This is a core component that should facilitate most list item use cases. Please carefully consider if modifying this
|
||||
* is really necessary before doing so.
|
||||
*
|
||||
* Uses the M3 Expressive interactive [ListItem] overload which provides built-in shape morphing on press/hover and
|
||||
* proper disabled styling.
|
||||
*
|
||||
* @see [LinkedCoordinatesItem] for example usage
|
||||
*/
|
||||
@Composable
|
||||
fun BasicListItem(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
textColor: Color = LocalContentColor.current,
|
||||
textColor: Color = Color.Unspecified,
|
||||
supportingText: String? = null,
|
||||
supportingTextColor: Color = LocalContentColor.current,
|
||||
supportingTextColor: Color = Color.Unspecified,
|
||||
enabled: Boolean = true,
|
||||
leadingIcon: ImageVector? = null,
|
||||
leadingIconTint: Color = LocalContentColor.current,
|
||||
leadingIconTint: Color = Color.Unspecified,
|
||||
trailingContent: @Composable (() -> Unit)? = null,
|
||||
onClick: (() -> Unit)? = null,
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
) {
|
||||
ListItem(
|
||||
modifier =
|
||||
if (onLongClick != null) {
|
||||
modifier.combinedClickable(enabled = enabled, onLongClick = onLongClick, onClick = onClick ?: {})
|
||||
} else if (onClick != null) {
|
||||
modifier.clickable(enabled = enabled, onClick = onClick)
|
||||
} else {
|
||||
modifier
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
headlineContent = { Text(text = text, color = textColor) },
|
||||
supportingContent = supportingText?.let { { Text(text = it, color = supportingTextColor) } },
|
||||
leadingContent = leadingIcon.icon(leadingIconTint),
|
||||
trailingContent = trailingContent,
|
||||
)
|
||||
if (onClick != null) {
|
||||
ListItem(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
onLongClick = onLongClick,
|
||||
shapes = ListItemDefaults.shapes(shape = RectangleShape),
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
leadingContent = leadingIcon.icon(leadingIconTint),
|
||||
trailingContent = trailingContent,
|
||||
supportingContent = supportingText?.let { { Text(text = it, color = supportingTextColor) } },
|
||||
content = { Text(text = text, color = textColor) },
|
||||
)
|
||||
} else {
|
||||
ListItem(
|
||||
modifier = modifier,
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
headlineContent = { Text(text = text, color = textColor) },
|
||||
supportingContent = supportingText?.let { { Text(text = it, color = supportingTextColor) } },
|
||||
leadingContent = leadingIcon.icon(leadingIconTint),
|
||||
trailingContent = trailingContent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ImageVector?.icon(tint: Color = LocalContentColor.current): @Composable (() -> Unit)? =
|
||||
this?.let { { Icon(imageVector = it, contentDescription = null, modifier = Modifier.size(24.dp), tint = tint) } }
|
||||
fun ImageVector?.icon(tint: Color = Color.Unspecified): @Composable (() -> Unit)? = this?.let {
|
||||
{
|
||||
val resolvedTint = if (tint == Color.Unspecified) LocalContentColor.current else tint
|
||||
Icon(imageVector = it, contentDescription = null, modifier = Modifier.size(24.dp), tint = resolvedTint)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
|
||||
@@ -74,7 +74,7 @@ fun MainAppBar(
|
||||
text = title,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
style = MaterialTheme.typography.titleLargeEmphasized,
|
||||
)
|
||||
},
|
||||
subtitle = {
|
||||
|
||||
@@ -16,13 +16,19 @@
|
||||
*/
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FloatingActionButtonMenu
|
||||
import androidx.compose.material3.FloatingActionButtonMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.PlainTooltip
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ToggleFloatingActionButton
|
||||
import androidx.compose.material3.ToggleFloatingActionButtonDefaults
|
||||
import androidx.compose.material3.TooltipAnchorPosition
|
||||
import androidx.compose.material3.TooltipBox
|
||||
import androidx.compose.material3.TooltipDefaults
|
||||
import androidx.compose.material3.rememberTooltipState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -32,7 +38,7 @@ import org.meshtastic.core.ui.icon.Close
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.OfflineShare
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MenuFAB(
|
||||
expanded: Boolean,
|
||||
@@ -46,15 +52,21 @@ fun MenuFAB(
|
||||
modifier = modifier.then(if (testTag != null) Modifier.testTag(testTag) else Modifier),
|
||||
expanded = expanded,
|
||||
button = {
|
||||
ToggleFloatingActionButton(
|
||||
checked = expanded,
|
||||
onCheckedChange = onExpandedChange,
|
||||
content = {
|
||||
val imageVector = if (expanded) MeshtasticIcons.Close else MeshtasticIcons.OfflineShare
|
||||
Icon(imageVector = imageVector, contentDescription = contentDescription)
|
||||
},
|
||||
containerColor = ToggleFloatingActionButtonDefaults.containerColor(),
|
||||
)
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
|
||||
tooltip = { contentDescription?.let { PlainTooltip { Text(it) } } },
|
||||
state = rememberTooltipState(),
|
||||
) {
|
||||
ToggleFloatingActionButton(
|
||||
checked = expanded,
|
||||
onCheckedChange = onExpandedChange,
|
||||
content = {
|
||||
val imageVector = if (expanded) MeshtasticIcons.Close else MeshtasticIcons.OfflineShare
|
||||
Icon(imageVector = imageVector, contentDescription = contentDescription)
|
||||
},
|
||||
containerColor = ToggleFloatingActionButtonDefaults.containerColor(),
|
||||
)
|
||||
}
|
||||
},
|
||||
horizontalAlignment = Alignment.End,
|
||||
) {
|
||||
|
||||
@@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme.colorScheme
|
||||
@@ -68,7 +69,7 @@ import org.meshtastic.core.ui.viewmodel.UIViewModel
|
||||
* This implementation uses the [MultiBackstack] state holder to manage independent histories for each tab, aligning
|
||||
* with Navigation 3 best practices for state preservation during tab switching.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun MeshtasticNavigationSuite(
|
||||
multiBackstack: MultiBackstack,
|
||||
|
||||
@@ -14,27 +14,26 @@
|
||||
* 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:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
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.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -63,7 +62,6 @@ fun RegularPreference(
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun RegularPreference(
|
||||
title: String,
|
||||
@@ -75,51 +73,30 @@ fun RegularPreference(
|
||||
trailingIcon: ImageVector? = null,
|
||||
dropdownMenu: @Composable () -> Unit = {},
|
||||
) {
|
||||
val color =
|
||||
if (enabled) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = enabled, onClick = onClick, role = Role.Button)
|
||||
.padding(all = 16.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
FlowRow(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color =
|
||||
if (enabled) {
|
||||
Color.Unspecified
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
},
|
||||
)
|
||||
|
||||
Text(text = subtitle, style = MaterialTheme.typography.bodyLarge, color = color)
|
||||
}
|
||||
if (trailingIcon != null) {
|
||||
Box {
|
||||
Icon(
|
||||
imageVector = trailingIcon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(start = 8.dp).wrapContentWidth(Alignment.End),
|
||||
tint = color,
|
||||
)
|
||||
dropdownMenu()
|
||||
ListItem(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
shapes = ListItemDefaults.shapes(shape = RectangleShape),
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
trailingContent = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = subtitle, style = MaterialTheme.typography.bodyLarge)
|
||||
if (trailingIcon != null) {
|
||||
Box {
|
||||
Icon(
|
||||
imageVector = trailingIcon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(start = 8.dp).wrapContentWidth(Alignment.End),
|
||||
)
|
||||
dropdownMenu()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (summary != null) {
|
||||
Text(text = summary, style = MaterialTheme.typography.bodyMedium, color = color)
|
||||
}
|
||||
}
|
||||
},
|
||||
supportingContent = summary?.let { { Text(text = it, style = MaterialTheme.typography.bodyMedium) } },
|
||||
content = { Text(text = title, style = MaterialTheme.typography.bodyLarge) },
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
*/
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -23,6 +26,7 @@ import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
@@ -46,6 +50,15 @@ fun <T> SliderPreference(
|
||||
val valueRange = 0f..(items.size - 1).toFloat()
|
||||
val steps = (items.size - 2).coerceAtLeast(0)
|
||||
|
||||
// Spring-animated thumb position for expressive feel
|
||||
val animatedValue by
|
||||
animateFloatAsState(
|
||||
targetValue = selectedIndex.coerceIn(valueRange),
|
||||
animationSpec =
|
||||
spring(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioMediumBouncy),
|
||||
label = "sliderSpring",
|
||||
)
|
||||
|
||||
ListItem(
|
||||
modifier = modifier,
|
||||
headlineContent = {
|
||||
@@ -60,7 +73,7 @@ fun <T> SliderPreference(
|
||||
Column {
|
||||
summary?.let { Text(text = it, modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) }
|
||||
Slider(
|
||||
value = selectedIndex.coerceIn(valueRange),
|
||||
value = animatedValue,
|
||||
onValueChange = {
|
||||
val index = it.roundToInt()
|
||||
if (index in items.indices) {
|
||||
|
||||
@@ -1,389 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.horizontalDrag
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||
import androidx.compose.ui.input.pointer.changedToUp
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.onClick
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.selected
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow.Companion.Ellipsis
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
private const val NO_OPTION_INDEX = -1
|
||||
|
||||
private val TRACK_PADDING = 2.dp
|
||||
|
||||
private val TRACK_COLOR = Color.LightGray.copy(alpha = .5f)
|
||||
|
||||
private val PRESSED_TRACK_PADDING = 1.dp
|
||||
|
||||
private val OPTION_PADDING = 5.dp
|
||||
|
||||
private const val PRESSED_UNSELECTED_ALPHA = .6f
|
||||
|
||||
private val BACKGROUND_SHAPE = RoundedCornerShape(8.dp)
|
||||
|
||||
/**
|
||||
* Provides the user with a set of options they can choose from.
|
||||
*
|
||||
* (Inspired by https://gist.github.com/zach-klippenstein/7ae8874db304f957d6bb91263e292117)
|
||||
*/
|
||||
@Composable
|
||||
fun <T : Any> SlidingSelector(
|
||||
options: List<T>,
|
||||
selectedOption: T,
|
||||
onOptionSelected: (T) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable (T) -> Unit,
|
||||
) {
|
||||
val state = remember { SelectorState() }
|
||||
state.optionCount = options.size
|
||||
state.selectedOption = options.indexOf(selectedOption)
|
||||
state.onOptionSelected = { onOptionSelected(options[it]) }
|
||||
|
||||
/* Animate between whole-number indices so we don't need to do pixel calculations. */
|
||||
val selectedIndexOffset by animateFloatAsState(state.selectedOption.toFloat(), label = "Selected Index Offset")
|
||||
|
||||
Layout(
|
||||
content = {
|
||||
SelectedIndicator(state)
|
||||
Dividers(state)
|
||||
Options(state, options, content)
|
||||
},
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.then(state.inputModifier)
|
||||
.background(TRACK_COLOR, BACKGROUND_SHAPE)
|
||||
.padding(TRACK_PADDING),
|
||||
) { measurables, constraints ->
|
||||
val (indicatorMeasurable, dividersMeasurable, optionsMeasurable) = measurables
|
||||
|
||||
/* Measure the options first so we know how tall to make the indicator. */
|
||||
val optionsPlaceable = optionsMeasurable.measure(constraints)
|
||||
state.updatePressedScale(optionsPlaceable.height, this)
|
||||
|
||||
/* Measure the indicator and dividers to be the right size. */
|
||||
val indicatorPlaceable =
|
||||
indicatorMeasurable.measure(
|
||||
Constraints.fixed(width = optionsPlaceable.width / options.size, height = optionsPlaceable.height),
|
||||
)
|
||||
|
||||
val dividersPlaceable =
|
||||
dividersMeasurable.measure(
|
||||
Constraints.fixed(width = optionsPlaceable.width, height = optionsPlaceable.height),
|
||||
)
|
||||
|
||||
layout(optionsPlaceable.width, optionsPlaceable.height) {
|
||||
val optionWidth = optionsPlaceable.width / options.size
|
||||
|
||||
/* Place the indicator first so that it's below the option labels. */
|
||||
indicatorPlaceable.placeRelative(x = (selectedIndexOffset * optionWidth).toInt(), y = 0)
|
||||
dividersPlaceable.placeRelative(IntOffset.Zero)
|
||||
optionsPlaceable.placeRelative(IntOffset.Zero)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Visual representation of the option the user may select. */
|
||||
@Composable
|
||||
fun OptionLabel(text: String) {
|
||||
Text(text, maxLines = 1, overflow = Ellipsis)
|
||||
}
|
||||
|
||||
/** Draws the selected indicator on the [SlidingSelector] track. */
|
||||
@Composable
|
||||
private fun SelectedIndicator(state: SelectorState) {
|
||||
Box(
|
||||
Modifier.then(
|
||||
state.optionScaleModifier(
|
||||
pressed = state.pressedOption == state.selectedOption,
|
||||
option = state.selectedOption,
|
||||
),
|
||||
)
|
||||
.shadow(4.dp, BACKGROUND_SHAPE)
|
||||
.background(MaterialTheme.colorScheme.background, BACKGROUND_SHAPE),
|
||||
)
|
||||
}
|
||||
|
||||
/** Draws dividers between [OptionLabel]s. */
|
||||
@Composable
|
||||
private fun Dividers(state: SelectorState) {
|
||||
/* Animate each divider independently. */
|
||||
val alphas =
|
||||
(0 until state.optionCount).map { i ->
|
||||
val selectionAdjacent = i == state.selectedOption || i - 1 == state.selectedOption
|
||||
animateFloatAsState(if (selectionAdjacent) 0f else 1f, label = "Dividers")
|
||||
}
|
||||
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
val optionWidth = size.width / state.optionCount
|
||||
val dividerPadding = TRACK_PADDING + PRESSED_TRACK_PADDING
|
||||
|
||||
alphas.forEachIndexed { i, alpha ->
|
||||
val x = i * optionWidth
|
||||
drawLine(
|
||||
Color.White,
|
||||
alpha = alpha.value,
|
||||
start = Offset(x, dividerPadding.toPx()),
|
||||
end = Offset(x, size.height - dividerPadding.toPx()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Draws the options available to the user. */
|
||||
@Composable
|
||||
private fun <T> Options(state: SelectorState, options: List<T>, content: @Composable (T) -> Unit) {
|
||||
CompositionLocalProvider(LocalTextStyle provides TextStyle(fontWeight = FontWeight.Medium)) {
|
||||
Row(horizontalArrangement = spacedBy(TRACK_PADDING), modifier = Modifier.fillMaxWidth().selectableGroup()) {
|
||||
options.forEachIndexed { i, timeFrame ->
|
||||
val isSelected = i == state.selectedOption
|
||||
val isPressed = i == state.pressedOption
|
||||
|
||||
/* Unselected presses are represented by fading. */
|
||||
val alpha by
|
||||
animateFloatAsState(
|
||||
if (!isSelected && isPressed) PRESSED_UNSELECTED_ALPHA else 1f,
|
||||
label = "Unselected",
|
||||
)
|
||||
|
||||
val semanticsModifier =
|
||||
Modifier.semantics(mergeDescendants = true) {
|
||||
selected = isSelected
|
||||
role = Role.Button
|
||||
onClick {
|
||||
state.onOptionSelected(i)
|
||||
true
|
||||
}
|
||||
stateDescription = if (isSelected) "Selected" else "Not selected"
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
/* Divide space evenly between all options. */
|
||||
.weight(1f)
|
||||
.then(semanticsModifier)
|
||||
.padding(OPTION_PADDING)
|
||||
/* Draw pressed indication when not selected. */
|
||||
.alpha(alpha)
|
||||
/* Selected presses are represented by scaling. */
|
||||
.then(state.optionScaleModifier(isPressed && isSelected, i))
|
||||
/* Center the option content. */
|
||||
.wrapContentWidth(),
|
||||
) {
|
||||
content(timeFrame)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Contains and handles the state necessary to present the [SlidingSelector] to the user. */
|
||||
private class SelectorState {
|
||||
var optionCount by mutableIntStateOf(0)
|
||||
var selectedOption by mutableIntStateOf(0)
|
||||
var onOptionSelected: (Int) -> Unit by mutableStateOf({})
|
||||
var pressedOption by mutableIntStateOf(NO_OPTION_INDEX)
|
||||
|
||||
/**
|
||||
* Scale factor that should be used to scale pressed option. When this scale is applied, exactly
|
||||
* [PRESSED_TRACK_PADDING] will be added around the element's usual size.
|
||||
*/
|
||||
var pressedSelectedScale by mutableFloatStateOf(1f)
|
||||
private set
|
||||
|
||||
/** Calculates the scale factor we need to use for pressed options to get the desired padding. */
|
||||
fun updatePressedScale(controlHeight: Int, density: Density) {
|
||||
with(density) {
|
||||
val pressedPadding = PRESSED_TRACK_PADDING * 2
|
||||
val pressedHeight = controlHeight - pressedPadding.toPx()
|
||||
pressedSelectedScale = pressedHeight / controlHeight
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a [Modifier] that will scale an element so that it gets [PRESSED_TRACK_PADDING] extra padding around it.
|
||||
* The scale will be animated.
|
||||
*
|
||||
* The scale is also performed around either the left or right edge of the element if the option is the first or
|
||||
* last option, respectively. In those cases, the scale will also be translated so that [PRESSED_TRACK_PADDING] will
|
||||
* be added on the left or right edge.
|
||||
*/
|
||||
fun optionScaleModifier(pressed: Boolean, option: Int): Modifier = Modifier.composed {
|
||||
val scale by animateFloatAsState(if (pressed) pressedSelectedScale else 1f, label = "Scale")
|
||||
val xOffset by animateDpAsState(if (pressed) PRESSED_TRACK_PADDING else 0.dp, label = "x Offset")
|
||||
|
||||
graphicsLayer {
|
||||
this.scaleX = scale
|
||||
this.scaleY = scale
|
||||
|
||||
/* Scales on the ends should gravitate to that edge. */
|
||||
this.transformOrigin =
|
||||
TransformOrigin(
|
||||
pivotFractionX =
|
||||
when (option) {
|
||||
0 -> 0f
|
||||
optionCount - 1 -> 1f
|
||||
else -> .5f
|
||||
},
|
||||
pivotFractionY = .5f,
|
||||
)
|
||||
|
||||
/* But should still move inwards to keep the pressed padding consistent with top and bottom. */
|
||||
this.translationX =
|
||||
when (option) {
|
||||
0 -> xOffset.toPx()
|
||||
optionCount - 1 -> -xOffset.toPx()
|
||||
else -> 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [Modifier] that will listen for touch gestures and update the selected and pressed properties of this state
|
||||
* appropriately.
|
||||
*/
|
||||
val inputModifier =
|
||||
Modifier.pointerInput(optionCount) {
|
||||
val optionWidth = size.width / optionCount
|
||||
|
||||
/* Helper to calculate which option an event occurred in. */
|
||||
fun optionIndex(change: PointerInputChange): Int =
|
||||
((change.position.x / size.width.toFloat()) * optionCount).toInt().coerceIn(0, optionCount - 1)
|
||||
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown()
|
||||
|
||||
pressedOption = optionIndex(down)
|
||||
val downOnSelected = pressedOption == selectedOption
|
||||
val optionBounds =
|
||||
Rect(
|
||||
left = pressedOption * optionWidth.toFloat(),
|
||||
right = (pressedOption + 1) * optionWidth.toFloat(),
|
||||
top = 0f,
|
||||
bottom = size.height.toFloat(),
|
||||
)
|
||||
|
||||
if (downOnSelected) {
|
||||
horizontalDrag(down.id) { change ->
|
||||
pressedOption = optionIndex(change)
|
||||
|
||||
if (pressedOption != selectedOption) {
|
||||
onOptionSelected(pressedOption)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
waitForUpOrCancellation(inBounds = optionBounds)
|
||||
/* Null means the gesture was cancelled (e.g. dragged out of bounds). */
|
||||
?.let { onOptionSelected(pressedOption) }
|
||||
}
|
||||
pressedOption = NO_OPTION_INDEX
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Works with bounds that may not be at 0,0. */
|
||||
@Suppress("ReturnCount")
|
||||
private suspend fun AwaitPointerEventScope.waitForUpOrCancellation(inBounds: Rect): PointerInputChange? {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Main)
|
||||
if (event.changes.all { it.changedToUp() }) {
|
||||
/* All pointers are up */
|
||||
return event.changes[0]
|
||||
}
|
||||
|
||||
if (event.changes.any { it.isConsumed || !inBounds.contains(it.position) }) {
|
||||
/* Canceled */
|
||||
return null
|
||||
}
|
||||
|
||||
val consumeCheck = awaitPointerEvent(PointerEventPass.Final)
|
||||
if (consumeCheck.changes.any { it.isConsumed }) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*@Preview
|
||||
@Composable
|
||||
fun SlidingSelectorPreview() {
|
||||
MaterialTheme {
|
||||
Surface {
|
||||
Column(Modifier.padding(8.dp)) {
|
||||
|
||||
var selectedOption by remember { mutableStateOf(TimeFrame.TWENTY_FOUR_HOURS) }
|
||||
SlidingSelector(
|
||||
TimeFrame.entries.toList(),
|
||||
selectedOption,
|
||||
onOptionSelected = { selectedOption = it }
|
||||
) {
|
||||
OptionLabel(stringResource(it.strRes))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}*/
|
||||
@@ -14,14 +14,16 @@
|
||||
* 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:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.CircularWavyProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.Switch
|
||||
@@ -29,6 +31,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
@@ -45,32 +48,19 @@ fun SwitchPreference(
|
||||
containerColor: Color? = null,
|
||||
loading: Boolean = false,
|
||||
) {
|
||||
val defaultColors = ListItemDefaults.colors()
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
val currentColors =
|
||||
if (enabled) {
|
||||
defaultColors
|
||||
} else {
|
||||
defaultColors.copy(
|
||||
headlineColor = defaultColors.headlineColor.copy(alpha = 0.5f),
|
||||
supportingTextColor = defaultColors.supportingTextColor.copy(alpha = 0.5f),
|
||||
)
|
||||
}
|
||||
.let { if (containerColor != null) it.copy(containerColor = containerColor) else it }
|
||||
val currentColors = ListItemDefaults.colors(containerColor = containerColor ?: Color.Unspecified)
|
||||
|
||||
ListItem(
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
modifier = padding?.let { Modifier.padding(it) } ?: modifier,
|
||||
enabled = enabled,
|
||||
shapes = ListItemDefaults.shapes(shape = RectangleShape),
|
||||
colors = currentColors,
|
||||
modifier =
|
||||
(padding?.let { Modifier.padding(it) } ?: modifier).toggleable(
|
||||
value = checked,
|
||||
enabled = enabled,
|
||||
onValueChange = onCheckedChange,
|
||||
),
|
||||
trailingContent = {
|
||||
AnimatedContent(targetState = loading) { loading ->
|
||||
if (loading) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||
CircularWavyProgressIndicator(modifier = Modifier.size(24.dp))
|
||||
} else {
|
||||
Switch(enabled = enabled, checked = checked, onCheckedChange = null)
|
||||
}
|
||||
@@ -81,7 +71,7 @@ fun SwitchPreference(
|
||||
Text(text = summary)
|
||||
}
|
||||
},
|
||||
headlineContent = { Text(text = title) },
|
||||
content = { Text(text = title) },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
/**
|
||||
* Remembers a time tick that updates every minute.
|
||||
*
|
||||
* @return The current time in milliseconds, updating every minute.
|
||||
*/
|
||||
@Composable expect fun rememberTimeTickWithLifecycle(): Long
|
||||
@@ -15,6 +15,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:Suppress("TooManyFunctions")
|
||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
package org.meshtastic.core.ui.emoji
|
||||
|
||||
@@ -31,7 +32,6 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -43,17 +43,22 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.PrimaryScrollableTabRow
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -67,18 +72,20 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import kotlinx.coroutines.delay
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.clear
|
||||
import org.meshtastic.core.resources.search_emoji
|
||||
import org.meshtastic.core.ui.component.BottomSheetDialog
|
||||
import org.meshtastic.core.ui.icon.Close
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.Search
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -89,6 +96,7 @@ private const val RECENTS_HEADER_KEY = "header_recents"
|
||||
private const val RECENTS_KEY_PREFIX = "recent_"
|
||||
private const val MAX_RECENTS = 30
|
||||
private const val DEFAULT_QUICK_REACTION_COUNT = 6
|
||||
private const val SEARCH_DEBOUNCE_MS = 150L
|
||||
|
||||
/** Default quick-reaction emoji used when the user has no recents. */
|
||||
private val DEFAULT_QUICK_REACTIONS = listOf("👍", "❤️", "😂", "😮", "😢", "🙏")
|
||||
@@ -116,26 +124,53 @@ fun EmojiPickerDialog(
|
||||
onConfirm: (String) -> Unit,
|
||||
) {
|
||||
val viewModel: EmojiPickerViewModel = koinViewModel()
|
||||
val isLoaded by viewModel.isLoaded.collectAsState()
|
||||
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||
var debouncedQuery by remember { mutableStateOf("") }
|
||||
var selectedCategoryIndex by rememberSaveable { mutableStateOf(0) }
|
||||
val preferredSkinToneIndex by viewModel.preferredSkinToneIndex.collectAsState()
|
||||
|
||||
// Debounce search input to avoid per-keystroke filtering of 1870 emojis
|
||||
LaunchedEffect(searchQuery) {
|
||||
if (searchQuery.isBlank()) {
|
||||
debouncedQuery = ""
|
||||
} else {
|
||||
delay(SEARCH_DEBOUNCE_MS)
|
||||
debouncedQuery = searchQuery
|
||||
}
|
||||
}
|
||||
|
||||
val recentEmojis by
|
||||
remember(viewModel.customEmojiFrequency) { derivedStateOf { parseRecents(viewModel.customEmojiFrequency) } }
|
||||
|
||||
BottomSheetDialog(onDismiss = onDismiss, modifier = Modifier.fillMaxHeight(fraction = .55f)) {
|
||||
EmojiPickerContent(
|
||||
searchQuery = searchQuery,
|
||||
onSearchQueryChange = { searchQuery = it },
|
||||
selectedCategoryIndex = selectedCategoryIndex,
|
||||
onCategorySelected = { selectedCategoryIndex = it },
|
||||
selectedEmojis = selectedEmojis,
|
||||
recentEmojis = recentEmojis,
|
||||
onEmojiSelected = { emoji ->
|
||||
recordSelection(emoji, viewModel)
|
||||
onDismiss()
|
||||
onConfirm(emoji)
|
||||
},
|
||||
)
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
) {
|
||||
if (isLoaded) {
|
||||
EmojiPickerContent(
|
||||
searchQuery = searchQuery,
|
||||
debouncedQuery = debouncedQuery,
|
||||
onSearchQueryChange = { searchQuery = it },
|
||||
selectedCategoryIndex = selectedCategoryIndex,
|
||||
onCategorySelected = { selectedCategoryIndex = it },
|
||||
selectedEmojis = selectedEmojis,
|
||||
recentEmojis = recentEmojis,
|
||||
categories = viewModel.categories,
|
||||
allEmojis = viewModel.allEmojis,
|
||||
preferredSkinToneIndex = preferredSkinToneIndex,
|
||||
onSkinToneSelect = { viewModel.setPreferredSkinTone(it) },
|
||||
onEmojiSelected = { emoji ->
|
||||
recordSelection(emoji, viewModel)
|
||||
onDismiss()
|
||||
onConfirm(emoji)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Box(modifier = Modifier.fillMaxWidth().height(200.dp), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,11 +206,16 @@ fun rememberQuickReactions(count: Int = DEFAULT_QUICK_REACTION_COUNT): List<Stri
|
||||
@Suppress("LongParameterList")
|
||||
private fun EmojiPickerContent(
|
||||
searchQuery: String,
|
||||
debouncedQuery: String,
|
||||
onSearchQueryChange: (String) -> Unit,
|
||||
selectedCategoryIndex: Int,
|
||||
onCategorySelected: (Int) -> Unit,
|
||||
selectedEmojis: Set<String>,
|
||||
recentEmojis: List<String>,
|
||||
categories: List<EmojiCategory>,
|
||||
allEmojis: List<Emoji>,
|
||||
preferredSkinToneIndex: Int,
|
||||
onSkinToneSelect: (Int) -> Unit,
|
||||
onEmojiSelected: (String) -> Unit,
|
||||
) {
|
||||
Column {
|
||||
@@ -186,15 +226,20 @@ private fun EmojiPickerContent(
|
||||
selectedIndex = selectedCategoryIndex,
|
||||
onCategorySelected = onCategorySelected,
|
||||
hasRecents = recentEmojis.isNotEmpty(),
|
||||
categories = categories,
|
||||
)
|
||||
}
|
||||
|
||||
EmojiGrid(
|
||||
searchQuery = searchQuery,
|
||||
searchQuery = debouncedQuery,
|
||||
selectedCategoryIndex = selectedCategoryIndex,
|
||||
onCategoryChanged = onCategorySelected,
|
||||
selectedEmojis = selectedEmojis,
|
||||
recentEmojis = recentEmojis,
|
||||
categories = categories,
|
||||
allEmojis = allEmojis,
|
||||
preferredSkinToneIndex = preferredSkinToneIndex,
|
||||
onSkinToneSelect = onSkinToneSelect,
|
||||
onEmojiSelected = onEmojiSelected,
|
||||
)
|
||||
}
|
||||
@@ -246,9 +291,14 @@ private fun SearchBar(query: String, onQueryChange: (String) -> Unit) {
|
||||
// ── Category Tabs ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun CategoryTabStrip(selectedIndex: Int, onCategorySelected: (Int) -> Unit, hasRecents: Boolean) {
|
||||
private fun CategoryTabStrip(
|
||||
selectedIndex: Int,
|
||||
onCategorySelected: (Int) -> Unit,
|
||||
hasRecents: Boolean,
|
||||
categories: List<EmojiCategory>,
|
||||
) {
|
||||
val tabOffset = if (hasRecents) 1 else 0
|
||||
val totalTabs = EmojiData.categories.size + tabOffset
|
||||
val totalTabs = categories.size + tabOffset
|
||||
|
||||
PrimaryScrollableTabRow(
|
||||
selectedTabIndex = selectedIndex,
|
||||
@@ -263,10 +313,7 @@ private fun CategoryTabStrip(selectedIndex: Int, onCategorySelected: (Int) -> Un
|
||||
selected = selectedIndex == index,
|
||||
onClick = { onCategorySelected(index) },
|
||||
text = {
|
||||
Text(
|
||||
text = if (isRecents) "\uD83D\uDD50" else EmojiData.categories[index - tabOffset].icon,
|
||||
fontSize = 18.sp,
|
||||
)
|
||||
Text(text = if (isRecents) "\uD83D\uDD50" else categories[index - tabOffset].icon, fontSize = 18.sp)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -283,13 +330,20 @@ private fun EmojiGrid(
|
||||
onCategoryChanged: (Int) -> Unit,
|
||||
selectedEmojis: Set<String>,
|
||||
recentEmojis: List<String>,
|
||||
categories: List<EmojiCategory>,
|
||||
allEmojis: List<Emoji>,
|
||||
preferredSkinToneIndex: Int,
|
||||
onSkinToneSelect: (Int) -> Unit,
|
||||
onEmojiSelected: (String) -> Unit,
|
||||
) {
|
||||
val gridState = rememberLazyGridState()
|
||||
val hasRecents = recentEmojis.isNotEmpty()
|
||||
val tabOffset = if (hasRecents) 1 else 0
|
||||
|
||||
val gridItems: List<GridItem> = remember(searchQuery, recentEmojis) { buildGridItems(searchQuery, recentEmojis) }
|
||||
val gridItems: List<GridItem> =
|
||||
remember(searchQuery, recentEmojis, categories, allEmojis) {
|
||||
buildGridItems(searchQuery, recentEmojis, categories, allEmojis)
|
||||
}
|
||||
var animationTargetIndex by remember { mutableStateOf<Int?>(null) }
|
||||
|
||||
// Scroll to category when tab changes
|
||||
@@ -302,7 +356,7 @@ private fun EmojiGrid(
|
||||
RECENTS_HEADER_KEY
|
||||
} else {
|
||||
val catIndex = selectedCategoryIndex - tabOffset
|
||||
if (catIndex in EmojiData.categories.indices) {
|
||||
if (catIndex in categories.indices) {
|
||||
CATEGORY_HEADER_KEY_PREFIX + catIndex
|
||||
} else {
|
||||
null
|
||||
@@ -363,6 +417,8 @@ private fun EmojiGrid(
|
||||
EmojiCellWithSkinTone(
|
||||
emoji = item.emoji,
|
||||
isSelected = selectedEmojis.contains(item.emoji.base),
|
||||
preferredSkinToneIndex = preferredSkinToneIndex,
|
||||
onSkinToneSelect = onSkinToneSelect,
|
||||
onSelect = onEmojiSelected,
|
||||
)
|
||||
}
|
||||
@@ -392,11 +448,15 @@ private sealed class GridItem(open val key: String) {
|
||||
}
|
||||
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
private fun buildGridItems(searchQuery: String, recentEmojis: List<String>): List<GridItem> = buildList {
|
||||
private fun buildGridItems(
|
||||
searchQuery: String,
|
||||
recentEmojis: List<String>,
|
||||
categories: List<EmojiCategory>,
|
||||
allEmojis: List<Emoji>,
|
||||
): List<GridItem> = buildList {
|
||||
if (searchQuery.isNotBlank()) {
|
||||
val query = searchQuery.lowercase()
|
||||
val results =
|
||||
EmojiData.all.filter { emoji -> emoji.keywords.any { it.contains(query) } || emoji.base.contains(query) }
|
||||
val results = rankSearchResults(query, allEmojis, recentEmojis)
|
||||
results.forEachIndexed { i, emoji -> add(GridItem.EmojiCell(emoji, "search_$i")) }
|
||||
} else {
|
||||
if (recentEmojis.isNotEmpty()) {
|
||||
@@ -405,7 +465,7 @@ private fun buildGridItems(searchQuery: String, recentEmojis: List<String>): Lis
|
||||
add(GridItem.EmojiCell(Emoji(emojiStr), "$RECENTS_KEY_PREFIX$i"))
|
||||
}
|
||||
}
|
||||
EmojiData.categories.forEachIndexed { catIndex, category ->
|
||||
categories.forEachIndexed { catIndex, category ->
|
||||
add(GridItem.Header(category.name, "$CATEGORY_HEADER_KEY_PREFIX$catIndex"))
|
||||
category.emojis.forEachIndexed { emojiIndex, emoji ->
|
||||
add(GridItem.EmojiCell(emoji, "cat_${catIndex}_$emojiIndex"))
|
||||
@@ -414,6 +474,47 @@ private fun buildGridItems(searchQuery: String, recentEmojis: List<String>): Lis
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ranks search results using prefix-weighted scoring (inspired by Signal's approach). Exact keyword matches score
|
||||
* highest, then prefix matches, then substring matches. Recently-used emoji matching the query get a boost.
|
||||
*/
|
||||
private fun rankSearchResults(query: String, allEmojis: List<Emoji>, recentEmojis: List<String>): List<Emoji> {
|
||||
val recentSet = recentEmojis.toSet()
|
||||
|
||||
data class ScoredEmoji(val emoji: Emoji, val score: Float)
|
||||
|
||||
return allEmojis
|
||||
.mapNotNull { emoji ->
|
||||
val score = scoreEmoji(emoji, query, recentSet)
|
||||
if (score > 0f) ScoredEmoji(emoji, score) else null
|
||||
}
|
||||
.sortedByDescending { it.score }
|
||||
.map { it.emoji }
|
||||
}
|
||||
|
||||
private const val SCORE_EXACT_MATCH = 10f
|
||||
private const val SCORE_PREFIX_MATCH = 4f
|
||||
private const val SCORE_SUBSTRING_MATCH = 1f
|
||||
private const val SCORE_BASE_CONTAINS = 0.5f
|
||||
private const val SCORE_RECENT_BOOST = 3f
|
||||
|
||||
private fun scoreEmoji(emoji: Emoji, query: String, recentSet: Set<String>): Float {
|
||||
var score = 0f
|
||||
|
||||
for (keyword in emoji.keywords) {
|
||||
when {
|
||||
keyword == query -> score += SCORE_EXACT_MATCH
|
||||
keyword.startsWith(query) -> score += SCORE_PREFIX_MATCH
|
||||
keyword.contains(query) -> score += SCORE_SUBSTRING_MATCH
|
||||
}
|
||||
}
|
||||
|
||||
if (emoji.base.contains(query)) score += SCORE_BASE_CONTAINS
|
||||
if (score > 0f && emoji.base in recentSet) score += SCORE_RECENT_BOOST
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
// ── Cell Components ────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
@@ -428,14 +529,23 @@ private fun SectionHeader(title: String) {
|
||||
|
||||
/**
|
||||
* An emoji grid cell that supports:
|
||||
* - **Tap** → select the emoji (with default skin tone)
|
||||
* - **Tap** → select the emoji with the user's preferred skin tone applied
|
||||
* - **Long-press** → if the emoji supports skin tones, show a popup with 6 Fitzpatrick variants
|
||||
* - **Selected highlight** → tinted background when the emoji is in [isSelected]
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun EmojiCellWithSkinTone(emoji: Emoji, isSelected: Boolean, onSelect: (String) -> Unit) {
|
||||
@Suppress("LongParameterList")
|
||||
private fun EmojiCellWithSkinTone(
|
||||
emoji: Emoji,
|
||||
isSelected: Boolean,
|
||||
preferredSkinToneIndex: Int,
|
||||
onSkinToneSelect: (Int) -> Unit,
|
||||
onSelect: (String) -> Unit,
|
||||
) {
|
||||
var showSkinTonePopup by rememberSaveable { mutableStateOf(false) }
|
||||
val preferredTone = SkinTone.entries.getOrElse(preferredSkinToneIndex) { SkinTone.DEFAULT }
|
||||
val displayEmoji = if (emoji.supportsSkinTone) emoji.withSkinTone(preferredTone) else emoji.base
|
||||
|
||||
Box {
|
||||
Box(
|
||||
@@ -450,7 +560,7 @@ private fun EmojiCellWithSkinTone(emoji: Emoji, isSelected: Boolean, onSelect: (
|
||||
},
|
||||
)
|
||||
.combinedClickable(
|
||||
onClick = { onSelect(emoji.base) },
|
||||
onClick = { onSelect(displayEmoji) },
|
||||
onLongClick =
|
||||
if (emoji.supportsSkinTone) {
|
||||
{ showSkinTonePopup = true }
|
||||
@@ -460,7 +570,7 @@ private fun EmojiCellWithSkinTone(emoji: Emoji, isSelected: Boolean, onSelect: (
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(text = emoji.base, fontSize = EMOJI_FONT_SIZE.sp, textAlign = TextAlign.Center)
|
||||
Text(text = displayEmoji, fontSize = EMOJI_FONT_SIZE.sp, textAlign = TextAlign.Center)
|
||||
// Small dot indicator for skin-tone-capable emoji
|
||||
if (emoji.supportsSkinTone) {
|
||||
Box(
|
||||
@@ -476,8 +586,9 @@ private fun EmojiCellWithSkinTone(emoji: Emoji, isSelected: Boolean, onSelect: (
|
||||
if (showSkinTonePopup) {
|
||||
SkinTonePopup(
|
||||
emoji = emoji,
|
||||
onSelect = { variant ->
|
||||
onSelect = { variant, toneIndex ->
|
||||
showSkinTonePopup = false
|
||||
onSkinToneSelect(toneIndex)
|
||||
onSelect(variant)
|
||||
},
|
||||
onDismiss = { showSkinTonePopup = false },
|
||||
@@ -489,7 +600,7 @@ private fun EmojiCellWithSkinTone(emoji: Emoji, isSelected: Boolean, onSelect: (
|
||||
// ── Skin Tone Popup ────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun SkinTonePopup(emoji: Emoji, onSelect: (String) -> Unit, onDismiss: () -> Unit) {
|
||||
private fun SkinTonePopup(emoji: Emoji, onSelect: (String, Int) -> Unit, onDismiss: () -> Unit) {
|
||||
Popup(alignment = Alignment.TopCenter, onDismissRequest = onDismiss) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
@@ -499,10 +610,11 @@ private fun SkinTonePopup(emoji: Emoji, onSelect: (String) -> Unit, onDismiss: (
|
||||
modifier = Modifier.widthIn(max = 280.dp),
|
||||
) {
|
||||
Row(modifier = Modifier.padding(6.dp), horizontalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
SkinTone.entries.forEach { tone ->
|
||||
SkinTone.entries.forEachIndexed { index, tone ->
|
||||
val variant = emoji.withSkinTone(tone)
|
||||
Box(
|
||||
modifier = Modifier.size(40.dp).clip(RoundedCornerShape(8.dp)).clickable { onSelect(variant) },
|
||||
modifier =
|
||||
Modifier.size(40.dp).clip(RoundedCornerShape(8.dp)).clickable { onSelect(variant, index) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(text = variant, fontSize = 22.sp)
|
||||
@@ -552,3 +664,89 @@ private fun recordSelection(emoji: String, viewModel: EmojiPickerViewModel) {
|
||||
viewModel.customEmojiFrequency =
|
||||
freq.entries.joinToString(SPLIT_CHAR) { "${it.key}$KEY_VALUE_DELIMITER${it.value}" }
|
||||
}
|
||||
|
||||
// ── Previews ───────────────────────────────────────────────────────────────────
|
||||
|
||||
private val PREVIEW_CATEGORIES =
|
||||
listOf(
|
||||
EmojiCategory(
|
||||
"Smileys & Emotion",
|
||||
"😀",
|
||||
listOf(
|
||||
Emoji("😀", listOf("grinning", "face", "smile")),
|
||||
Emoji("😃", listOf("smiley", "face", "happy")),
|
||||
Emoji("😄", listOf("smile", "happy", "joy")),
|
||||
Emoji("😁", listOf("grin", "happy")),
|
||||
Emoji("😆", listOf("laughing", "satisfied")),
|
||||
Emoji("😅", listOf("sweat", "smile", "hot")),
|
||||
Emoji("🤣", listOf("rofl", "laughing", "floor")),
|
||||
Emoji("😂", listOf("joy", "tears", "laugh")),
|
||||
Emoji("🙂", listOf("slightly", "smile")),
|
||||
Emoji("😉", listOf("wink", "face")),
|
||||
Emoji("😊", listOf("blush", "happy", "smile")),
|
||||
Emoji("😇", listOf("angel", "innocent", "halo")),
|
||||
),
|
||||
),
|
||||
EmojiCategory(
|
||||
"People & Body",
|
||||
"👋",
|
||||
listOf(
|
||||
Emoji("👋", listOf("wave", "hand", "hello"), supportsSkinTone = true),
|
||||
Emoji("🤚", listOf("raised", "back", "hand"), supportsSkinTone = true),
|
||||
Emoji("🖐️", listOf("hand", "splayed", "fingers"), supportsSkinTone = true),
|
||||
Emoji("✋", listOf("hand", "high five", "stop"), supportsSkinTone = true),
|
||||
Emoji("👍", listOf("thumbs up", "approve", "yes"), supportsSkinTone = true),
|
||||
Emoji("👎", listOf("thumbs down", "disapprove", "no"), supportsSkinTone = true),
|
||||
Emoji("👏", listOf("clap", "applause"), supportsSkinTone = true),
|
||||
Emoji("🙌", listOf("raised", "hands", "celebration"), supportsSkinTone = true),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@Suppress("UnusedPrivateMember", "PreviewPublic")
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
fun EmojiPickerContentPreview() {
|
||||
AppTheme {
|
||||
Surface {
|
||||
EmojiPickerContent(
|
||||
searchQuery = "",
|
||||
debouncedQuery = "",
|
||||
onSearchQueryChange = {},
|
||||
selectedCategoryIndex = 0,
|
||||
onCategorySelected = {},
|
||||
selectedEmojis = setOf("😀", "👍"),
|
||||
recentEmojis = listOf("😀", "❤️", "👍", "🔥", "😂", "🙏"),
|
||||
categories = PREVIEW_CATEGORIES,
|
||||
allEmojis = PREVIEW_CATEGORIES.flatMap { it.emojis },
|
||||
preferredSkinToneIndex = 0,
|
||||
onSkinToneSelect = {},
|
||||
onEmojiSelected = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UnusedPrivateMember", "PreviewPublic")
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
fun EmojiPickerSearchPreview() {
|
||||
AppTheme {
|
||||
Surface {
|
||||
EmojiPickerContent(
|
||||
searchQuery = "smile",
|
||||
debouncedQuery = "smile",
|
||||
onSearchQueryChange = {},
|
||||
selectedCategoryIndex = 0,
|
||||
onCategorySelected = {},
|
||||
selectedEmojis = emptySet(),
|
||||
recentEmojis = listOf("😀", "❤️", "👍"),
|
||||
categories = PREVIEW_CATEGORIES,
|
||||
allEmojis = PREVIEW_CATEGORIES.flatMap { it.emojis },
|
||||
preferredSkinToneIndex = 0,
|
||||
onSkinToneSelect = {},
|
||||
onEmojiSelected = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,11 +17,43 @@
|
||||
package org.meshtastic.core.ui.emoji
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.annotation.KoinViewModel
|
||||
import org.meshtastic.core.repository.CustomEmojiPrefs
|
||||
|
||||
@KoinViewModel
|
||||
class EmojiPickerViewModel(private val customEmojiPrefs: CustomEmojiPrefs) : ViewModel() {
|
||||
internal class EmojiPickerViewModel(
|
||||
private val customEmojiPrefs: CustomEmojiPrefs,
|
||||
private val emojiRepository: EmojiRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _isLoaded = MutableStateFlow(false)
|
||||
val isLoaded: StateFlow<Boolean> = _isLoaded
|
||||
|
||||
/** Emoji categories, available after [isLoaded] emits true. */
|
||||
val categories: List<EmojiCategory>
|
||||
get() = emojiRepository.categories
|
||||
|
||||
/** Flat list of all emojis, available after [isLoaded] emits true. */
|
||||
val allEmojis: List<Emoji>
|
||||
get() = emojiRepository.all
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
emojiRepository.preload()
|
||||
_isLoaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
/** User's preferred skin tone (persisted across sessions). */
|
||||
val preferredSkinToneIndex: StateFlow<Int> = customEmojiPrefs.preferredSkinToneIndex
|
||||
|
||||
fun setPreferredSkinTone(index: Int) {
|
||||
customEmojiPrefs.setPreferredSkinToneIndex(index)
|
||||
}
|
||||
|
||||
var customEmojiFrequency: String?
|
||||
get() = customEmojiPrefs.customEmojiFrequency.value
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ui.emoji
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import meshtasticandroid.core.ui.generated.resources.Res
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
/** A single emoji entry with optional skin-tone support and search keywords. */
|
||||
internal data class Emoji(
|
||||
val base: String,
|
||||
val keywords: List<String> = emptyList(),
|
||||
val supportsSkinTone: Boolean = false,
|
||||
)
|
||||
|
||||
/** A named category of emojis with an icon emoji for the tab. */
|
||||
internal data class EmojiCategory(val name: String, val icon: String, val emojis: List<Emoji>)
|
||||
|
||||
/** Unicode skin tone modifiers (Fitzpatrick scale). */
|
||||
internal enum class SkinTone(val modifier: String, val label: String, val preview: String) {
|
||||
DEFAULT("", "Default", "👋"),
|
||||
LIGHT("\uD83C\uDFFB", "Light", "👋🏻"),
|
||||
MEDIUM_LIGHT("\uD83C\uDFFC", "Medium-Light", "👋🏼"),
|
||||
MEDIUM("\uD83C\uDFFD", "Medium", "👋🏽"),
|
||||
MEDIUM_DARK("\uD83C\uDFFE", "Medium-Dark", "👋🏾"),
|
||||
DARK("\uD83C\uDFFF", "Dark", "👋🏿"),
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a skin tone modifier to a base emoji string. Only works correctly for single-codepoint emojis that support
|
||||
* skin tones.
|
||||
*/
|
||||
internal fun Emoji.withSkinTone(tone: SkinTone): String {
|
||||
if (!supportsSkinTone || tone == SkinTone.DEFAULT) return base
|
||||
val firstChar = base[0]
|
||||
val charCount = if (firstChar.isHighSurrogate() && base.length > 1) 2 else 1
|
||||
val baseChar = base.substring(0, charCount)
|
||||
val after = base.substring(charCount)
|
||||
return baseChar + tone.modifier + after
|
||||
}
|
||||
|
||||
// ── JSON models (compact wire format) ──────────────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
private data class EmojiJson(
|
||||
@SerialName("b") val base: String,
|
||||
@SerialName("k") val keywords: List<String> = emptyList(),
|
||||
@SerialName("s") val supportsSkinTone: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable private data class CategoryJson(val name: String, val icon: String, val emojis: List<EmojiJson>)
|
||||
|
||||
@Serializable private data class EmojiDataJson(val version: String, val categories: List<CategoryJson>)
|
||||
|
||||
// ── Repository ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Provides access to the emoji catalog loaded from a bundled JSON resource.
|
||||
*
|
||||
* Data is loaded lazily on first access and cached for the lifetime of the app. The JSON resource contains Unicode 16.0
|
||||
* emoji data with CLDR-based keywords.
|
||||
*/
|
||||
@Single
|
||||
internal class EmojiRepository {
|
||||
|
||||
private var cached: Pair<List<EmojiCategory>, List<Emoji>>? = null
|
||||
|
||||
val categories: List<EmojiCategory>
|
||||
get() = ensureLoaded().first
|
||||
|
||||
val all: List<Emoji>
|
||||
get() = ensureLoaded().second
|
||||
|
||||
suspend fun preload() {
|
||||
if (cached != null) return
|
||||
val bytes = Res.readBytes("files/emoji-data.json")
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val data = json.decodeFromString<EmojiDataJson>(bytes.decodeToString())
|
||||
val cats =
|
||||
data.categories.map { cat ->
|
||||
EmojiCategory(
|
||||
name = cat.name,
|
||||
icon = cat.icon,
|
||||
emojis =
|
||||
cat.emojis.map { e ->
|
||||
Emoji(base = e.base, keywords = e.keywords, supportsSkinTone = e.supportsSkinTone)
|
||||
},
|
||||
)
|
||||
}
|
||||
cached = cats to cats.flatMap { it.emojis }
|
||||
}
|
||||
|
||||
private fun ensureLoaded(): Pair<List<EmojiCategory>, List<Emoji>> =
|
||||
cached ?: error("EmojiRepository not loaded. Call preload() before accessing data.")
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:Suppress("MatchingDeclarationName")
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package org.meshtastic.core.ui.theme
|
||||
|
||||
@@ -127,12 +128,9 @@ fun AppTheme(
|
||||
null
|
||||
} ?: if (darkTheme) darkScheme else lightScheme
|
||||
|
||||
MaterialExpressiveTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = AppTypography,
|
||||
motionScheme = expressive(),
|
||||
content = content,
|
||||
)
|
||||
MaterialExpressiveTheme(colorScheme = colorScheme, typography = AppTypography, motionScheme = expressive()) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
const val MODE_DYNAMIC = 6969420
|
||||
|
||||
@@ -14,8 +14,40 @@
|
||||
* 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:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package org.meshtastic.core.ui.theme
|
||||
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
val AppTypography = Typography()
|
||||
/**
|
||||
* Complete expressive typescale for the Meshtastic application.
|
||||
*
|
||||
* `bodyLarge` and `bodyMedium` enforce a minimum of 16.sp per design standards §5. Emphasized variants (e.g.
|
||||
* `titleMediumEmphasized`, `bodyLargeEmphasized`) are auto-generated by [MaterialExpressiveTheme] based on these base
|
||||
* styles.
|
||||
*/
|
||||
val AppTypography =
|
||||
Typography(
|
||||
bodyLarge =
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp,
|
||||
),
|
||||
bodyMedium =
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 22.sp,
|
||||
letterSpacing = 0.25.sp,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -33,12 +33,15 @@ class EmojiPickerViewModelTest {
|
||||
|
||||
private lateinit var viewModel: EmojiPickerViewModel
|
||||
private val customEmojiPrefs: CustomEmojiPrefs = mock(MockMode.autofill)
|
||||
private val emojiRepository = EmojiRepository()
|
||||
private val frequencyFlow = MutableStateFlow<String?>(null)
|
||||
private val skinToneFlow = MutableStateFlow(0)
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
every { customEmojiPrefs.customEmojiFrequency } returns frequencyFlow
|
||||
viewModel = EmojiPickerViewModel(customEmojiPrefs)
|
||||
every { customEmojiPrefs.preferredSkinToneIndex } returns skinToneFlow
|
||||
viewModel = EmojiPickerViewModel(customEmojiPrefs, emojiRepository)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -16,10 +16,6 @@
|
||||
*/
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable actual fun rememberTimeTickWithLifecycle(): Long = 0L
|
||||
|
||||
internal actual fun <T : Enum<T>> enumEntriesOf(selectedItem: T): List<T> = emptyList()
|
||||
|
||||
internal actual fun Enum<*>.isDeprecatedEnumEntry(): Boolean = false
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
|
||||
/** JVM implementation — returns the current epoch millis (no lifecycle-based updates on Desktop). */
|
||||
@Composable actual fun rememberTimeTickWithLifecycle(): Long = nowMillis
|
||||
@@ -14,6 +14,8 @@
|
||||
* 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:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package org.meshtastic.feature.connections.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -22,7 +24,8 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.CircularWavyProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -66,7 +69,7 @@ fun ConnectingDeviceInfo(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(40.dp))
|
||||
CircularWavyProgressIndicator(modifier = Modifier.size(40.dp))
|
||||
|
||||
Column {
|
||||
Text(text = deviceName, style = MaterialTheme.typography.headlineSmall)
|
||||
|
||||
@@ -14,12 +14,15 @@
|
||||
* 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:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package org.meshtastic.feature.connections.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
@@ -62,6 +65,7 @@ fun ConnectionActionButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
shapes = ButtonDefaults.shapes(),
|
||||
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
||||
) {
|
||||
content()
|
||||
@@ -72,6 +76,7 @@ fun ConnectionActionButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
shapes = ButtonDefaults.shapes(),
|
||||
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
||||
) {
|
||||
content()
|
||||
@@ -82,6 +87,7 @@ fun ConnectionActionButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
shapes = ButtonDefaults.shapes(),
|
||||
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
||||
) {
|
||||
content()
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* 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:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package org.meshtastic.feature.connections.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -21,6 +23,7 @@ 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.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -95,7 +98,7 @@ fun CurrentlyConnectedInfo(
|
||||
NodeChip(node = node, onClick = { onNavigateToNodeDetails(it.num) })
|
||||
|
||||
Column(modifier = Modifier.weight(1f, fill = true)) {
|
||||
Text(text = node.user.long_name, style = MaterialTheme.typography.titleMedium)
|
||||
Text(text = node.user.long_name, style = MaterialTheme.typography.titleMediumEmphasized)
|
||||
|
||||
node.metadata
|
||||
?.firmware_version
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* 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:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package org.meshtastic.feature.connections.ui.components
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
@@ -24,7 +26,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.CircularWavyProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
@@ -156,7 +159,7 @@ fun DeviceListItem(
|
||||
}
|
||||
|
||||
if (connectionState is ConnectionState.Connecting) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(32.dp))
|
||||
CircularWavyProgressIndicator(modifier = Modifier.size(32.dp))
|
||||
} else {
|
||||
RadioButton(selected = connectionState is ConnectionState.Connected, onClick = null)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* 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:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package org.meshtastic.feature.connections.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -21,7 +23,8 @@ 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.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.LinearWavyProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -60,7 +63,7 @@ fun DeviceSectionHeader(
|
||||
trailing()
|
||||
}
|
||||
if (showProgress) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
LinearWavyProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* 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:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@file:Suppress("TooManyFunctions")
|
||||
|
||||
package org.meshtastic.feature.firmware
|
||||
@@ -39,7 +40,6 @@ import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.CircularWavyProgressIndicator
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -337,7 +337,7 @@ private fun FirmwareUpdateContent(
|
||||
|
||||
@Composable
|
||||
internal fun VerifyingState() {
|
||||
CircularProgressIndicator(modifier = Modifier.size(64.dp))
|
||||
CircularWavyProgressIndicator(modifier = Modifier.size(64.dp))
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(stringResource(Res.string.firmware_update_verifying), style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
@@ -352,7 +352,7 @@ internal fun VerifyingState() {
|
||||
|
||||
@Composable
|
||||
internal fun CheckingState() {
|
||||
CircularProgressIndicator(modifier = Modifier.size(64.dp))
|
||||
CircularWavyProgressIndicator(modifier = Modifier.size(64.dp))
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(stringResource(Res.string.firmware_update_checking), style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
@@ -763,7 +763,7 @@ private fun AwaitingFileSaveState(state: FirmwareUpdateState.AwaitingFileSave, o
|
||||
)
|
||||
}
|
||||
|
||||
CircularProgressIndicator(modifier = Modifier.size(64.dp))
|
||||
CircularWavyProgressIndicator(modifier = Modifier.size(64.dp))
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
stringResource(Res.string.firmware_update_save_dfu_file),
|
||||
|
||||
@@ -14,12 +14,14 @@
|
||||
* 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:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package org.meshtastic.feature.map.component
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.CircularWavyProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FloatingToolbarDefaults
|
||||
import androidx.compose.material3.HorizontalFloatingToolbar
|
||||
@@ -103,7 +105,7 @@ fun MapControlsOverlay(
|
||||
if (showRefresh) {
|
||||
if (isRefreshing) {
|
||||
Box(modifier = Modifier.padding(8.dp)) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
|
||||
CircularWavyProgressIndicator(modifier = Modifier.size(24.dp))
|
||||
}
|
||||
} else {
|
||||
MapButton(
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:Suppress("TooManyFunctions")
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package org.meshtastic.feature.messaging.component
|
||||
|
||||
@@ -36,13 +37,16 @@ import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuGroup
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MenuDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -389,13 +393,17 @@ private fun OverFlowMenu(
|
||||
) {
|
||||
if (expanded) {
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
|
||||
QuickChatToggleMenuItem(showQuickChat, onDismiss, onToggleQuickChat)
|
||||
QuickChatOptionsMenuItem(onDismiss, onNavigateToQuickChatOptions)
|
||||
if (filteredCount > 0 && !filteringDisabled) {
|
||||
FilteredMessagesMenuItem(showFiltered, filteredCount, onDismiss, onToggleShowFiltered)
|
||||
DropdownMenuGroup(shapes = MenuDefaults.groupShapes()) {
|
||||
QuickChatToggleMenuItem(showQuickChat, onDismiss, onToggleQuickChat)
|
||||
QuickChatOptionsMenuItem(onDismiss, onNavigateToQuickChatOptions)
|
||||
}
|
||||
DropdownMenuGroup(shapes = MenuDefaults.groupShapes()) {
|
||||
if (filteredCount > 0 && !filteringDisabled) {
|
||||
FilteredMessagesMenuItem(showFiltered, filteredCount, onDismiss, onToggleShowFiltered)
|
||||
}
|
||||
FilterToggleMenuItem(filteringDisabled, onDismiss, onToggleFilteringDisabled)
|
||||
FilterSettingsMenuItem(onDismiss, onNavigateToFilterSettings)
|
||||
}
|
||||
FilterToggleMenuItem(filteringDisabled, onDismiss, onToggleFilteringDisabled)
|
||||
FilterSettingsMenuItem(onDismiss, onNavigateToFilterSettings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
@@ -34,11 +33,14 @@ 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.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -68,7 +70,6 @@ import org.meshtastic.core.resources.message_status_queued
|
||||
import org.meshtastic.core.resources.message_status_unknown
|
||||
import org.meshtastic.core.resources.react
|
||||
import org.meshtastic.core.resources.you
|
||||
import org.meshtastic.core.ui.component.BottomSheetDialog
|
||||
import org.meshtastic.core.ui.component.Rssi
|
||||
import org.meshtastic.core.ui.component.Snr
|
||||
import org.meshtastic.core.ui.emoji.EmojiPickerDialog
|
||||
@@ -187,6 +188,7 @@ internal fun AddReactionButton(modifier: Modifier = Modifier, onSendReaction: (S
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Suppress("LongMethod", "CyclomaticComplexity", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
internal fun ReactionDialog(
|
||||
@@ -194,7 +196,10 @@ internal fun ReactionDialog(
|
||||
onDismiss: () -> Unit = {},
|
||||
myId: String? = null,
|
||||
onResend: (Reaction) -> Unit = {},
|
||||
) = BottomSheetDialog(onDismiss = onDismiss, modifier = Modifier.fillMaxHeight(fraction = .3f)) {
|
||||
) = ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
) {
|
||||
val groupedEmojis = reactions.groupBy { it.emoji }
|
||||
var selectedEmoji by remember { mutableStateOf<String?>(null) }
|
||||
val filteredReactions = selectedEmoji?.let { groupedEmojis[it] ?: emptyList() } ?: reactions
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* 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:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package org.meshtastic.feature.messaging.ui.contact
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
@@ -35,6 +37,7 @@ import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.AssistChipDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -141,7 +144,7 @@ private fun ContactHeader(
|
||||
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 8.dp).weight(1f),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
style = MaterialTheme.typography.bodyLargeEmphasized,
|
||||
fontWeight = FontWeight.Medium,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* 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:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package org.meshtastic.feature.messaging.ui.contact
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -27,8 +29,9 @@ import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.CircularWavyProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.RadioButton
|
||||
@@ -501,7 +504,7 @@ private fun ContactListViewPaged(
|
||||
val haptic = LocalHapticFeedback.current
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
if (contacts.loadState.refresh is LoadState.Loading && contacts.itemCount == 0) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
CircularWavyProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
} else {
|
||||
ContactListContentInternal(
|
||||
contacts = contacts,
|
||||
@@ -621,7 +624,7 @@ private fun LazyListScope.contactListAppendLoadingItem(contacts: LazyPagingItems
|
||||
if (contacts.loadState.append is LoadState.Loading) {
|
||||
item {
|
||||
Box(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
CircularWavyProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* 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:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package org.meshtastic.feature.node.component
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
@@ -23,7 +25,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.AssistChipDefaults
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.LinearWavyProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -168,7 +171,7 @@ private fun RemoteAdminListItem(
|
||||
)
|
||||
AnimatedVisibility(visible = isEnsuringSession) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp)) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
LinearWavyProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
androidx.compose.material3.Text(
|
||||
text = stringResource(Res.string.establishing_session),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* 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:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package org.meshtastic.feature.node.component
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
@@ -26,6 +28,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -99,7 +102,11 @@ fun CompassSheetContent(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(text = stringResource(Res.string.compass_title), style = MaterialTheme.typography.headlineSmall)
|
||||
Text(text = uiState.targetName, style = MaterialTheme.typography.titleMedium, color = uiState.targetColor)
|
||||
Text(
|
||||
text = uiState.targetName,
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
color = uiState.targetColor,
|
||||
)
|
||||
|
||||
CompassDial(
|
||||
heading = uiState.heading,
|
||||
|
||||
@@ -26,10 +26,10 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconToggleButton
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedIconToggleButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -136,7 +136,7 @@ private fun PrimaryActionsRow(node: Node, isLocal: Boolean, onAction: (NodeDetai
|
||||
}
|
||||
|
||||
if (!isLocal) {
|
||||
IconToggleButton(
|
||||
OutlinedIconToggleButton(
|
||||
checked = node.isFavorite,
|
||||
onCheckedChange = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Favorite(node))) },
|
||||
) {
|
||||
|
||||
@@ -14,13 +14,18 @@
|
||||
* 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:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package org.meshtastic.feature.node.component
|
||||
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuGroup
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MenuDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -59,12 +64,16 @@ fun NodeContextMenu(
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
|
||||
FavoriteMenuItem(node, onFavorite, onDismiss)
|
||||
IgnoreMenuItem(node, onIgnore, onDismiss)
|
||||
if (node.capabilities.canMuteNode) {
|
||||
MuteMenuItem(node, onMute, onDismiss)
|
||||
DropdownMenuGroup(shapes = MenuDefaults.groupShapes()) {
|
||||
FavoriteMenuItem(node, onFavorite, onDismiss)
|
||||
if (node.capabilities.canMuteNode) {
|
||||
MuteMenuItem(node, onMute, onDismiss)
|
||||
}
|
||||
}
|
||||
DropdownMenuGroup(shapes = MenuDefaults.groupShapes()) {
|
||||
IgnoreMenuItem(node, onIgnore, onDismiss)
|
||||
RemoveMenuItem(node, onRemove, onDismiss)
|
||||
}
|
||||
RemoveMenuItem(node, onRemove, onDismiss)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* 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:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package org.meshtastic.feature.node.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
@@ -31,8 +33,9 @@ import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuGroup
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -227,67 +230,69 @@ private fun NodeSortButton(
|
||||
onDismissRequest = { expanded = false },
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.background.copy(alpha = 1f)),
|
||||
) {
|
||||
DropdownMenuTitle(text = stringResource(Res.string.node_sort_title))
|
||||
DropdownMenuGroup(shapes = MenuDefaults.groupShapes()) {
|
||||
DropdownMenuTitle(text = stringResource(Res.string.node_sort_title))
|
||||
|
||||
NodeSortOption.entries.forEach { sort ->
|
||||
DropdownMenuRadio(
|
||||
text = stringResource(sort.stringRes),
|
||||
selected = sort == currentSortOption,
|
||||
onClick = { onSortSelect(sort) },
|
||||
)
|
||||
NodeSortOption.entries.forEach { sort ->
|
||||
DropdownMenuRadio(
|
||||
text = stringResource(sort.stringRes),
|
||||
selected = sort == currentSortOption,
|
||||
onClick = { onSortSelect(sort) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(MenuDefaults.DropdownMenuItemContentPadding))
|
||||
DropdownMenuGroup(shapes = MenuDefaults.groupShapes()) {
|
||||
DropdownMenuTitle(text = stringResource(Res.string.node_filter_title))
|
||||
|
||||
DropdownMenuTitle(text = stringResource(Res.string.node_filter_title))
|
||||
DropdownMenuCheck(
|
||||
text = stringResource(Res.string.node_filter_exclude_infrastructure),
|
||||
checked = toggles.excludeInfrastructure,
|
||||
onClick = toggles.onToggleExcludeInfrastructure,
|
||||
)
|
||||
|
||||
DropdownMenuCheck(
|
||||
text = stringResource(Res.string.node_filter_exclude_infrastructure),
|
||||
checked = toggles.excludeInfrastructure,
|
||||
onClick = toggles.onToggleExcludeInfrastructure,
|
||||
)
|
||||
DropdownMenuCheck(
|
||||
text = stringResource(Res.string.node_filter_include_unknown),
|
||||
checked = toggles.includeUnknown,
|
||||
onClick = toggles.onToggleIncludeUnknown,
|
||||
)
|
||||
|
||||
DropdownMenuCheck(
|
||||
text = stringResource(Res.string.node_filter_include_unknown),
|
||||
checked = toggles.includeUnknown,
|
||||
onClick = toggles.onToggleIncludeUnknown,
|
||||
)
|
||||
DropdownMenuCheck(
|
||||
text = stringResource(Res.string.node_filter_only_online),
|
||||
checked = toggles.onlyOnline,
|
||||
onClick = toggles.onToggleOnlyOnline,
|
||||
)
|
||||
|
||||
DropdownMenuCheck(
|
||||
text = stringResource(Res.string.node_filter_only_online),
|
||||
checked = toggles.onlyOnline,
|
||||
onClick = toggles.onToggleOnlyOnline,
|
||||
)
|
||||
DropdownMenuCheck(
|
||||
text = stringResource(Res.string.node_filter_only_direct),
|
||||
checked = toggles.onlyDirect,
|
||||
onClick = toggles.onToggleOnlyDirect,
|
||||
)
|
||||
|
||||
DropdownMenuCheck(
|
||||
text = stringResource(Res.string.node_filter_only_direct),
|
||||
checked = toggles.onlyDirect,
|
||||
onClick = toggles.onToggleOnlyDirect,
|
||||
)
|
||||
DropdownMenuCheck(
|
||||
text = stringResource(Res.string.node_filter_show_ignored),
|
||||
checked = toggles.showIgnored,
|
||||
onClick = toggles.onToggleShowIgnored,
|
||||
trailing =
|
||||
if (toggles.ignoredNodeCount > 0) {
|
||||
{
|
||||
Text(
|
||||
text = " (${toggles.ignoredNodeCount})",
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
|
||||
DropdownMenuCheck(
|
||||
text = stringResource(Res.string.node_filter_show_ignored),
|
||||
checked = toggles.showIgnored,
|
||||
onClick = toggles.onToggleShowIgnored,
|
||||
trailing =
|
||||
if (toggles.ignoredNodeCount > 0) {
|
||||
{
|
||||
Text(
|
||||
text = " (${toggles.ignoredNodeCount})",
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
|
||||
DropdownMenuCheck(
|
||||
text = stringResource(Res.string.node_filter_exclude_mqtt),
|
||||
checked = toggles.excludeMqtt,
|
||||
onClick = toggles.onToggleExcludeMqtt,
|
||||
)
|
||||
DropdownMenuCheck(
|
||||
text = stringResource(Res.string.node_filter_exclude_mqtt),
|
||||
checked = toggles.excludeMqtt,
|
||||
onClick = toggles.onToggleExcludeMqtt,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* 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:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package org.meshtastic.feature.node.component
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -25,6 +27,7 @@ import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -60,7 +63,7 @@ fun NotesSection(node: Node, onSaveNotes: (Int, String) -> Unit, modifier: Modif
|
||||
Column(modifier = Modifier.padding(20.dp)) {
|
||||
Text(
|
||||
text = stringResource(Res.string.notes),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* 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:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package org.meshtastic.feature.node.detail
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
@@ -24,7 +26,8 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.CircularWavyProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -73,7 +76,7 @@ fun NodeDetailContent(
|
||||
} else {
|
||||
val loadingDescription = stringResource(Res.string.loading)
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(modifier = Modifier.semantics { contentDescription = loadingDescription })
|
||||
CircularWavyProgressIndicator(modifier = Modifier.semantics { contentDescription = loadingDescription })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -363,11 +362,7 @@ private fun hasChannelData(voltage: Float?, current: Float?): Boolean = voltage
|
||||
@Composable
|
||||
private fun PowerChannelColumn(titleRes: StringResource, voltage: Float, current: Float) {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(titleRes),
|
||||
style = TextStyle(fontWeight = FontWeight.Bold),
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
Text(text = stringResource(titleRes), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold)
|
||||
MetricValueRow(color = PowerMetric.VOLTAGE.color, text = MetricFormatter.voltage(voltage))
|
||||
MetricValueRow(color = PowerMetric.CURRENT.color, text = MetricFormatter.current(current))
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* 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:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package org.meshtastic.feature.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -21,6 +23,7 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
@@ -112,7 +115,7 @@ private fun AboutHeader() {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)) {
|
||||
Text(
|
||||
text = stringResource(Res.string.open_source_libraries),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* 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:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package org.meshtastic.feature.settings.component
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -23,6 +25,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -43,7 +46,7 @@ fun ExpressiveSection(
|
||||
Text(
|
||||
text = title,
|
||||
modifier = Modifier.padding(horizontal = 16.dp).fillMaxWidth(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = titleColor,
|
||||
)
|
||||
|
||||
@@ -43,7 +43,6 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
@@ -129,7 +128,8 @@ fun DebugPresetFilters(
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = stringResource(Res.string.debug_filter_preset_title),
|
||||
style = TextStyle(fontWeight = FontWeight.Bold),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
)
|
||||
FlowRow(
|
||||
@@ -185,7 +185,8 @@ fun DebugFilterBar(
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = stringResource(Res.string.debug_filters),
|
||||
style = TextStyle(fontWeight = FontWeight.Bold),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Icon(
|
||||
imageVector =
|
||||
@@ -245,7 +246,8 @@ fun DebugActiveFilters(
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.debug_active_filters),
|
||||
style = TextStyle(fontWeight = FontWeight.Bold),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
TextButton(
|
||||
onClick = {
|
||||
|
||||
@@ -39,10 +39,8 @@ 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.text.TextStyle
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.debug_default_search
|
||||
@@ -73,7 +71,7 @@ fun DebugSearchNavigation(
|
||||
Text(
|
||||
text = "${searchState.currentMatchIndex + 1}/${searchState.allMatches.size}",
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
style = TextStyle(fontSize = 12.sp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
IconButton(onClick = onPreviousMatch, enabled = searchState.hasMatches, modifier = Modifier.size(32.dp)) {
|
||||
Icon(
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* 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:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package org.meshtastic.feature.settings.filter
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -26,6 +28,7 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -121,7 +124,7 @@ private fun FilterEnableCard(enabled: Boolean, onToggle: (Boolean) -> Unit) {
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(stringResource(Res.string.filter_enable), style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(Res.string.filter_enable), style = MaterialTheme.typography.titleMediumEmphasized)
|
||||
Text(
|
||||
stringResource(Res.string.filter_enable_summary),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -137,7 +140,7 @@ private fun FilterEnableCard(enabled: Boolean, onToggle: (Boolean) -> Unit) {
|
||||
private fun FilterWordsInputCard(newWord: String, onNewWordChange: (String) -> Unit, onAddWord: () -> Unit) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(stringResource(Res.string.filter_words), style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(Res.string.filter_words), style = MaterialTheme.typography.titleMediumEmphasized)
|
||||
Text(
|
||||
stringResource(Res.string.filter_words_summary),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* 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:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@file:Suppress("TooManyFunctions", "LongMethod")
|
||||
|
||||
package org.meshtastic.feature.wifiprovision.ui
|
||||
@@ -55,7 +56,7 @@ import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconToggleButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.LinearWavyProgressIndicator
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
@@ -200,7 +201,7 @@ fun WifiProvisionScreen(
|
||||
Column(modifier = Modifier.padding(padding).fillMaxSize().animateContentSize()) {
|
||||
// Indeterminate progress bar for active operations
|
||||
if (uiState.phase.isLoading) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
LinearWavyProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
} else {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.7 KiB |