feat: adopt Material 3 Expressive design system (M3-native APIs only) (#5479)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2026-05-18 20:10:47 -05:00
committed by GitHub
parent 72436e70bc
commit f5128798a8
161 changed files with 1864 additions and 3053 deletions

View File

@@ -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:

View File

@@ -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

View File

@@ -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:

View File

@@ -1 +1 @@
{"feature_directory":"specs/006-kmp-project-structure"}
{"feature_directory":"specs/20260513-160000-m3-expressive-adoption"}

View File

@@ -1,6 +1,6 @@
{
"ai": "opencode",
"branch_numbering": "sequential",
"branch_numbering": "timestamp",
"context_file": "AGENTS.md",
"here": true,
"integration": "opencode",

View File

@@ -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 -->

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 = {

View File

@@ -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")
}
}

View File

@@ -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. */

View File

@@ -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")

View File

@@ -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)

View File

@@ -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
}

View File

File diff suppressed because one or more lines are too long

View File

@@ -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),
)
}

View File

@@ -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,
)
}
}

View File

@@ -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()

View File

@@ -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,
)
}

View File

@@ -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

View File

@@ -74,7 +74,7 @@ fun MainAppBar(
text = title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleLarge,
style = MaterialTheme.typography.titleLargeEmphasized,
)
},
subtitle = {

View File

@@ -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,
) {

View File

@@ -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,

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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))
}
}
}
}
}*/

View File

@@ -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) },
)
}

View File

@@ -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

View File

File diff suppressed because it is too large Load Diff

View File

@@ -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 = {},
)
}
}
}

View File

@@ -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

View File

@@ -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.")
}

View File

@@ -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

View File

@@ -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,
),
)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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()

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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())
}
}
}

View File

@@ -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),

View 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(

View File

@@ -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)
}
}
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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))
}
}
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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))) },
) {

View File

@@ -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)
}
}

View File

@@ -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,
)
}
}
}

View File

@@ -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,
)

View File

@@ -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 })
}
}
}

View File

@@ -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))
}

View File

@@ -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(

View File

@@ -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,
)

View File

@@ -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 = {

View File

@@ -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(

View File

@@ -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,

View File

@@ -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))
}

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 63 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 63 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 59 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 57 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 57 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 72 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 72 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 65 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 65 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 57 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 57 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Some files were not shown because too many files have changed in this diff Show More