From 6e503f29a9f70c946bb8b2b1f921fa18121c61c9 Mon Sep 17 00:00:00 2001 From: Percy Wegmann Date: Thu, 28 Mar 2024 15:01:42 -0500 Subject: [PATCH] android/ui: implement design feedback Updates tailscale/corp#18202 Signed-off-by: Percy Wegmann --- android/build.gradle | 4 +- .../com/tailscale/ipn/ui/model/Permissions.kt | 11 +- .../com/tailscale/ipn/ui/model/TailCfg.kt | 50 ++- .../java/com/tailscale/ipn/ui/theme/Color.kt | 16 +- .../java/com/tailscale/ipn/ui/theme/Theme.kt | 219 +++++++++-- .../ipn/ui/util/ClickableOrGrayedOut.kt | 27 -- .../ipn/ui/util/ClipboardValueView.kt | 56 ++- .../ipn/ui/util/FeatureStateRepresentation.kt | 3 +- .../java/com/tailscale/ipn/ui/util/Lists.kt | 10 +- .../com/tailscale/ipn/ui/util/PeerHelper.kt | 46 +-- .../java/com/tailscale/ipn/ui/util/Styles.kt | 12 +- .../com/tailscale/ipn/ui/view/AboutView.kt | 24 +- .../java/com/tailscale/ipn/ui/view/Avatar.kt | 36 +- .../tailscale/ipn/ui/view/BugReportView.kt | 27 +- .../java/com/tailscale/ipn/ui/view/Buttons.kt | 29 +- .../tailscale/ipn/ui/view/ExitNodePicker.kt | 50 ++- .../ipn/ui/view/MDMSettingsDebugView.kt | 2 - .../com/tailscale/ipn/ui/view/MainView.kt | 369 +++++++++--------- .../ipn/ui/view/MullvadExitNodePicker.kt | 7 +- .../ipn/ui/view/MullvadExitNodePickerList.kt | 2 +- .../com/tailscale/ipn/ui/view/PeerDetails.kt | 136 ++++--- .../com/tailscale/ipn/ui/view/PeerView.kt | 11 +- .../tailscale/ipn/ui/view/PermissionsView.kt | 19 +- .../tailscale/ipn/ui/view/RunExitNodeView.kt | 9 +- .../com/tailscale/ipn/ui/view/SettingsView.kt | 70 ++-- .../com/tailscale/ipn/ui/view/SharedViews.kt | 40 +- .../com/tailscale/ipn/ui/view/TaildropView.kt | 2 - .../ipn/ui/view/TailnetLockSetupView.kt | 2 +- .../ipn/ui/view/TailscaleLogoView.kt | 4 +- .../com/tailscale/ipn/ui/view/TintedSwitch.kt | 14 +- .../ipn/ui/viewModel/DNSSettingsViewModel.kt | 18 +- .../ipn/ui/viewModel/MainViewModel.kt | 19 +- .../ipn/ui/viewModel/PeerDetailsViewModel.kt | 50 +-- .../ipn/ui/viewModel/SettingsViewModel.kt | 2 +- android/src/main/res/values/strings.xml | 143 ++++--- 35 files changed, 803 insertions(+), 736 deletions(-) delete mode 100644 android/src/main/java/com/tailscale/ipn/ui/util/ClickableOrGrayedOut.kt diff --git a/android/build.gradle b/android/build.gradle index 8724029..8a28900 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,6 +1,7 @@ buildscript { ext.kotlin_version = "1.9.22" ext.kotlin_compose_version = "1.5.10" + ext.accompanist_version = "0.34.0" repositories { google() @@ -88,7 +89,8 @@ dependencies { implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0' implementation 'androidx.activity:activity-compose:1.8.2' - implementation "com.google.accompanist:accompanist-permissions:0.34.0" + implementation "com.google.accompanist:accompanist-permissions:$accompanist_version" + implementation "com.google.accompanist:accompanist-systemuicontroller:$accompanist_version" // Navigation dependencies. def nav_version = "2.7.7" diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/Permissions.kt b/android/src/main/java/com/tailscale/ipn/ui/model/Permissions.kt index d4e6226..84901e4 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/Permissions.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/Permissions.kt @@ -25,7 +25,6 @@ object Permissions { Manifest.permission.WRITE_EXTERNAL_STORAGE, R.string.permission_write_external_storage, R.string.permission_write_external_storage_needed, - R.string.permission_write_external_storage_granted, )) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { @@ -33,16 +32,10 @@ object Permissions { Permission( Manifest.permission.POST_NOTIFICATIONS, R.string.permission_post_notifications, - R.string.permission_post_notifications_needed, - R.string.permission_post_notifications_granted)) + R.string.permission_post_notifications_needed)) } return result } } -data class Permission( - val name: String, - val title: Int, - val neededDescription: Int, - val grantedDescription: Int -) +data class Permission(val name: String, val title: Int, val description: Int) diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt b/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt index 827de6e..77add10 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt @@ -3,7 +3,17 @@ package com.tailscale.ipn.ui.model +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import com.tailscale.ipn.R +import com.tailscale.ipn.ui.theme.off +import com.tailscale.ipn.ui.theme.on +import com.tailscale.ipn.ui.util.ComposableStringFormatter +import com.tailscale.ipn.ui.util.DisplayAddress +import com.tailscale.ipn.ui.util.TimeUtil +import com.tailscale.ipn.ui.viewModel.PeerSettingInfo import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement class Tailcfg { @Serializable @@ -70,11 +80,14 @@ class Tailcfg { var LastSeen: Time? = null, var Online: Boolean? = null, var Capabilities: List? = null, + var CapMap: Map? = null, var ComputedName: String, var ComputedNameWithHost: String ) { val isAdmin: Boolean - get() = (Capabilities ?: emptyList()).contains("https://tailscale.com/cap/is-admin") + get() = + Capabilities?.contains("https://tailscale.com/cap/is-admin") == true || + CapMap?.contains("https://tailscale.com/cap/is-admin") == true // isExitNode reproduces the Go logic in local.go peerStatusFromNode val isExitNode: Boolean = @@ -82,6 +95,41 @@ class Tailcfg { val isMullvadNode: Boolean get() = Name.endsWith(".mullvad.ts.net.") + + val displayName: String + get() = ComputedName ?: "" + + fun connectedOrSelfNode(nm: Netmap.NetworkMap?) = + Online == true || StableID == nm?.SelfNode?.StableID + + fun connectedStrRes(nm: Netmap.NetworkMap?) = + if (connectedOrSelfNode(nm)) R.string.connected else R.string.not_connected + + @Composable + fun connectedColor(nm: Netmap.NetworkMap?) = + if (connectedOrSelfNode(nm)) MaterialTheme.colorScheme.on else MaterialTheme.colorScheme.off + + val nameWithoutTrailingDot = Name.trimEnd('.') + + val displayAddresses: List + get() { + var addresses = mutableListOf() + addresses.add(DisplayAddress(nameWithoutTrailingDot)) + Addresses?.let { addresses.addAll(it.map { addr -> DisplayAddress(addr) }) } + return addresses + } + + val info: List + get() { + val result = mutableListOf() + if (Hostinfo.OS?.isNotEmpty() == true) { + result.add( + PeerSettingInfo(R.string.os, ComposableStringFormatter(Hostinfo.OS!!)), + ) + } + result.add(PeerSettingInfo(R.string.key_expiry, TimeUtil().keyExpiryFromGoTime(KeyExpiry))) + return result + } } @Serializable diff --git a/android/src/main/java/com/tailscale/ipn/ui/theme/Color.kt b/android/src/main/java/com/tailscale/ipn/ui/theme/Color.kt index 18a2db3..156739c 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/theme/Color.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/theme/Color.kt @@ -5,19 +5,5 @@ package com.tailscale.ipn.ui.theme import androidx.compose.ui.graphics.Color -val ts_color_light_primary = Color(0xFF232222) -val ts_color_light_secondary = Color(0xFF706E6D) -val ts_color_light_background = Color(0xFFFFFFFF) -val ts_color_light_tintedBackground = Color(0xFFF7F5F4) +// TODO: replace references to these with references to material theme val ts_color_light_blue = Color(0xFF4B70CC) -val ts_color_light_green = Color(0xFF1EA672) - -var ts_color_dark_desctrutive_text = Color(0xFFFF0000) -var ts_color_light_desctrutive_text = Color(0xFFBB0000) - -val ts_color_dark_primary = Color(0xFFFAF9F8) -val ts_color_dark_secondary = Color(0xFFAFACAB) -val ts_color_dark_background = Color(0xFF232222) -val ts_color_dark_tintedBackground = Color(0xFF2E2D2D) -val ts_color_dark_blue = Color(0xFF4B70CC) -var ts_color_dark_green = Color(0xFF33C27F) diff --git a/android/src/main/java/com/tailscale/ipn/ui/theme/Theme.kt b/android/src/main/java/com/tailscale/ipn/ui/theme/Theme.kt index 23febee..b45957d 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/theme/Theme.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/theme/Theme.kt @@ -4,39 +4,204 @@ package com.tailscale.ipn.ui.theme import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ListItemColors +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.TopAppBarColors +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.Typography import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.sp +import com.google.accompanist.systemuicontroller.rememberSystemUiController + +@Composable +fun AppTheme(useDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) { + val colors = LightColors + + val typography = + Typography( + // titleMedium is styled to be slightly larger than bodyMedium for emphasis + titleMedium = + MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp, lineHeight = 26.sp), + // bodyMedium is styled to use same line height as titleMedium to ensure even vertical + // margins in list items. + bodyMedium = + MaterialTheme.typography.bodyMedium.copy(fontSize = 16.sp, lineHeight = 26.sp)) + + val systemUiController = rememberSystemUiController() + + DisposableEffect(systemUiController, useDarkTheme) { + systemUiController.setStatusBarColor(color = colors.surfaceContainer) + systemUiController.setNavigationBarColor(color = Color.Black) + onDispose {} + } + + MaterialTheme(colorScheme = colors, typography = typography, content = content) +} private val LightColors = lightColorScheme( - primary = ts_color_light_primary, - onPrimary = ts_color_light_background, - secondary = ts_color_light_secondary, - onSecondary = ts_color_light_background, - secondaryContainer = ts_color_light_tintedBackground, - surface = ts_color_light_background, + primary = Color(0xFF4B70CC), // blue-500 + onPrimary = Color(0xFFFFFFFF), // white + primaryContainer = Color(0xFFF0F5FF), // blue-0 + // primaryContainer = Color(0xFF6D94EC), // blue-400 + onPrimaryContainer = Color(0xFF3E5DB3), // blue-600 + // onPrimaryContainer = Color(0xFFFFFFFF), // white, + error = Color(0xFFB22C30), // red-500 + onError = Color(0xFFFFFFFF), // white + errorContainer = Color(0xFFFEF6F3), // red-0 + onErrorContainer = Color(0xFF930921), // red-600 + surfaceDim = Color(0xFFF7F5F4), // gray-100 + // surface = Color(0xFFF7F5F4), // gray-100 + surface = Color(0xFFFFFFFF), // white, + background = Color(0xFFF7F5F4), // gray-100 + surfaceBright = Color(0xFFFFFFFF), // white + surfaceContainerLowest = Color(0xFFFFFFFF), // white + surfaceContainerLow = Color(0xFFF7F5F4), // gray-100 + surfaceContainer = Color(0xFFF7F5F4), // gray-100 + surfaceContainerHigh = Color(0xFFF7F5F4), // gray-100 + surfaceContainerHighest = Color(0xFFF7F5F4), // gray-100 + onSurface = Color(0xFF232222), // gray-800 + onSurfaceVariant = Color(0xFF706E6D), // gray-500 + outline = Color(0xFFD9D6D5), // gray-300 + outlineVariant = Color(0xFFEDEBEA), // gray-200 + inverseSurface = Color(0xFF232222), // gray-800 + inverseOnSurface = Color(0xFFFFFFFF), // white + scrim = Color(0xFF000000), // black ) -private val DarkColors = - darkColorScheme( - primary = ts_color_dark_primary, - onPrimary = ts_color_dark_background, - secondary = ts_color_dark_secondary, - onSecondary = ts_color_dark_background, - secondaryContainer = ts_color_dark_tintedBackground, - surface = ts_color_dark_background, - ) +val ColorScheme.warning: Color + get() = Color(0xFFD97916) // yellow-300 -@Composable -fun AppTheme(useDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) { - val colors = - if (!useDarkTheme) { - LightColors - } else { - DarkColors - } - - MaterialTheme(colorScheme = colors, content = content) -} +val ColorScheme.onWarning: Color + get() = Color(0xFFFFFFFF) // white + +val ColorScheme.warningContainer: Color + get() = Color(0xFFFFFAEE) // orange-0 + +val ColorScheme.onWarningContainer: Color + get() = Color(0xFF7E1E22) // orange-600 + +val ColorScheme.success: Color + get() = Color(0xFF0A825D) // green-400 + +val ColorScheme.onSuccess: Color + get() = Color(0xFFFFFFFF) // white + +val ColorScheme.successContainer: Color + get() = Color(0xFFEFFEEC) // green-0 + +val ColorScheme.onSuccessContainer: Color + get() = Color(0xFF0E4B3B) // green-600 + +val ColorScheme.on: Color + get() = Color(0xFF1CA672) // green-300 + +val ColorScheme.off: Color + get() = Color(0xFFD9D6D5) // gray-300 + +val ColorScheme.link: Color + get() = onPrimaryContainer + +/** + * Main color scheme for list items, uses onPrimaryContainer color for leading and trailing icons. + */ +val ColorScheme.listItem: ListItemColors + @Composable + get() { + val default = ListItemDefaults.colors() + return ListItemColors( + containerColor = default.containerColor, + headlineColor = default.headlineColor, + leadingIconColor = MaterialTheme.colorScheme.onPrimaryContainer, + overlineColor = default.overlineColor, + supportingTextColor = default.supportingTextColor, + trailingIconColor = MaterialTheme.colorScheme.onPrimaryContainer, + disabledHeadlineColor = default.disabledHeadlineColor, + disabledLeadingIconColor = default.disabledLeadingIconColor, + disabledTrailingIconColor = default.disabledTrailingIconColor) + } + +/** Color scheme for disabled list items. */ +val ColorScheme.disabledListItem: ListItemColors + @Composable + get() { + val default = ListItemDefaults.colors() + return ListItemColors( + containerColor = default.containerColor, + headlineColor = MaterialTheme.colorScheme.disabled, + leadingIconColor = default.leadingIconColor, + overlineColor = default.overlineColor, + supportingTextColor = MaterialTheme.colorScheme.onSurfaceVariant, + trailingIconColor = default.trailingIconColor, + disabledHeadlineColor = default.disabledHeadlineColor, + disabledLeadingIconColor = default.disabledLeadingIconColor, + disabledTrailingIconColor = default.disabledTrailingIconColor) + } + +/** Color scheme for list items that should be styled as a surface container. */ +val ColorScheme.surfaceContainerListItem: ListItemColors + @Composable + get() { + val default = ListItemDefaults.colors() + return ListItemColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + headlineColor = MaterialTheme.colorScheme.onSurface, + leadingIconColor = MaterialTheme.colorScheme.onSurface, + overlineColor = MaterialTheme.colorScheme.onSurfaceVariant, + supportingTextColor = MaterialTheme.colorScheme.onSurfaceVariant, + trailingIconColor = MaterialTheme.colorScheme.onSurface, + disabledHeadlineColor = default.disabledHeadlineColor, + disabledLeadingIconColor = default.disabledLeadingIconColor, + disabledTrailingIconColor = default.disabledTrailingIconColor) + } + +/** Color scheme for list items that should be styled as a primary item. */ +val ColorScheme.primaryListItem: ListItemColors + @Composable + get() { + val default = ListItemDefaults.colors() + return ListItemColors( + containerColor = MaterialTheme.colorScheme.primary, + headlineColor = MaterialTheme.colorScheme.onPrimary, + leadingIconColor = MaterialTheme.colorScheme.onPrimary, + overlineColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f), + supportingTextColor = MaterialTheme.colorScheme.onPrimary, + trailingIconColor = MaterialTheme.colorScheme.onPrimary, + disabledHeadlineColor = default.disabledHeadlineColor, + disabledLeadingIconColor = default.disabledLeadingIconColor, + disabledTrailingIconColor = default.disabledTrailingIconColor) + } + +/** Main color scheme for top app bar, styles it as a surface container. */ +@OptIn(ExperimentalMaterial3Api::class) +val ColorScheme.topAppBar: TopAppBarColors + @Composable + get() = + TopAppBarDefaults.topAppBarColors() + .copy( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + navigationIconContentColor = MaterialTheme.colorScheme.onSurface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + ) + +val ColorScheme.secondaryButton: ButtonColors + @Composable + get() { + val defaults = ButtonDefaults.buttonColors() + return ButtonColors( + containerColor = Color(0xFF6D94EC), // blue-400 + contentColor = Color(0xFFFFFFFF), // white + disabledContainerColor = defaults.disabledContainerColor, + disabledContentColor = defaults.disabledContentColor) + } + +val ColorScheme.disabled: Color + get() = Color(0xFFAFACAB) // gray-400 diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/ClickableOrGrayedOut.kt b/android/src/main/java/com/tailscale/ipn/ui/util/ClickableOrGrayedOut.kt deleted file mode 100644 index 1bff49b..0000000 --- a/android/src/main/java/com/tailscale/ipn/ui/util/ClickableOrGrayedOut.kt +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package com.tailscale.ipn.ui.util - -import androidx.compose.foundation.clickable -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.semantics.Role - -/** - * Similar to Modifier.clickable, but if enabled == false, this adds a 75% alpha to make disabled - * items appear grayed out. - */ -@Composable -fun Modifier.clickableOrGrayedOut( - enabled: Boolean = true, - onClickLabel: String? = null, - role: Role? = null, - onClick: () -> Unit -) = - if (enabled) { - clickable(onClickLabel = onClickLabel, role = role, onClick = onClick) - } else { - alpha(0.75f) - } diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/ClipboardValueView.kt b/android/src/main/java/com/tailscale/ipn/ui/util/ClipboardValueView.kt index 9fac0c6..e12e3b2 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/ClipboardValueView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/ClipboardValueView.kt @@ -4,7 +4,6 @@ package com.tailscale.ipn.ui.util import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width @@ -22,7 +21,7 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.tailscale.ipn.R -import com.tailscale.ipn.ui.theme.ts_color_light_blue +import com.tailscale.ipn.ui.theme.listItem @Composable fun ClipboardValueView( @@ -32,33 +31,30 @@ fun ClipboardValueView( fontFamily: FontFamily = FontFamily.Monospace ) { val localClipboardManager = LocalClipboardManager.current - Row { - ListItem( - modifier = Modifier.clickable { localClipboardManager.setText(AnnotatedString(value)) }, - overlineContent = { title?.let { Text(it, style = MaterialTheme.typography.titleMedium) } }, - headlineContent = { + ListItem( + colors = MaterialTheme.colorScheme.listItem, + modifier = Modifier.clickable { localClipboardManager.setText(AnnotatedString(value)) }, + overlineContent = { title?.let { Text(it, style = MaterialTheme.typography.titleMedium) } }, + headlineContent = { + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + fontFamily = fontFamily, + maxLines = 2, + overflow = TextOverflow.Ellipsis) + }, + supportingContent = { + subtitle?.let { subtitle -> Text( - text = value, - style = MaterialTheme.typography.bodyMedium, - fontFamily = fontFamily, - maxLines = 2, - overflow = TextOverflow.Ellipsis) - }, - supportingContent = { - subtitle?.let { subtitle -> - Text( - subtitle, - modifier = Modifier.padding(top = 8.dp), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.secondary) - } - }, - trailingContent = { - Icon( - painterResource(R.drawable.clipboard), - stringResource(R.string.copy_to_clipboard), - modifier = Modifier.width(24.dp).height(24.dp), - tint = ts_color_light_blue) - }) - } + subtitle, + modifier = Modifier.padding(top = 8.dp), + style = MaterialTheme.typography.bodyMedium) + } + }, + trailingContent = { + Icon( + painterResource(R.drawable.clipboard), + stringResource(R.string.copy_to_clipboard), + modifier = Modifier.width(24.dp).height(24.dp)) + }) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/FeatureStateRepresentation.kt b/android/src/main/java/com/tailscale/ipn/ui/util/FeatureStateRepresentation.kt index 8481dd9..c6d772f 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/FeatureStateRepresentation.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/FeatureStateRepresentation.kt @@ -5,6 +5,7 @@ package com.tailscale.ipn.ui.util import androidx.annotation.DrawableRes import androidx.annotation.StringRes +import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color // FeatureStateRepresentation represents the status of a feature @@ -12,7 +13,7 @@ import androidx.compose.ui.graphics.Color // It is typically implemented as an enumeration. interface FeatureStateRepresentation { @get:DrawableRes val symbolDrawable: Int - val tint: Color + @get:Composable val tint: Color @get:StringRes val title: Int @get:StringRes val caption: Int } diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/Lists.kt b/android/src/main/java/com/tailscale/ipn/ui/util/Lists.kt index 6fb4281..4c76a4b 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/Lists.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/Lists.kt @@ -26,7 +26,7 @@ object Lists { @Composable fun ItemDivider() { - HorizontalDivider(color = MaterialTheme.colorScheme.secondaryContainer) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) } } @@ -35,7 +35,6 @@ inline fun LazyListScope.itemsWithDividers( items: List, noinline key: ((item: T) -> Any)? = null, forceLeading: Boolean = false, - forceTrailing: Boolean = false, crossinline contentType: (item: T) -> Any? = { _ -> null }, crossinline itemContent: @Composable LazyItemScope.(item: T) -> Unit ) = @@ -43,9 +42,7 @@ inline fun LazyListScope.itemsWithDividers( count = items.size, key = if (key != null) { index: Int -> key(items[index]) } else null, contentType = { index -> contentType(items[index]) }) { - if (forceLeading && it == 0 || - forceTrailing && it == items.size || - it > 0 && it < items.size) { + if (forceLeading && it == 0 || it > 0 && it < items.size) { Lists.ItemDivider() } itemContent(items[it]) @@ -55,7 +52,6 @@ inline fun LazyListScope.itemsWithDividers( items: Array, noinline key: ((item: T) -> Any)? = null, forceLeading: Boolean = false, - forceTrailing: Boolean = false, crossinline contentType: (item: T) -> Any? = { _ -> null }, crossinline itemContent: @Composable LazyItemScope.(item: T) -> Unit -) = itemsWithDividers(items.toList(), key, forceLeading, forceTrailing, contentType, itemContent) +) = itemsWithDividers(items.toList(), key, forceLeading, contentType, itemContent) diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt b/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt index 847192b..e65568d 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt @@ -6,37 +6,16 @@ package com.tailscale.ipn.ui.util import com.tailscale.ipn.ui.model.Netmap import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.model.UserID -import com.tailscale.ipn.ui.notifier.Notifier -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch data class PeerSet(val user: Tailcfg.UserProfile?, val peers: List) -typealias GroupedPeers = MutableMap> - -class PeerCategorizer(scope: CoroutineScope) { +class PeerCategorizer { var peerSets: List = emptyList() var lastSearchResult: List = emptyList() - var searchTerm: String = "" + var lastSearchTerm: String = "" - // Keep the peer sets current while the model is active - init { - scope.launch { - Notifier.netmap.collect { netmap -> - netmap?.let { - peerSets = regenerateGroupedPeers(netmap) - lastSearchResult = peerSets - } - ?: run { - peerSets = emptyList() - lastSearchResult = emptyList() - } - } - } - } - - private fun regenerateGroupedPeers(netmap: Netmap.NetworkMap): List { - val peers: List = netmap.Peers ?: return emptyList() + fun regenerateGroupedPeers(netmap: Netmap.NetworkMap) { + val peers: List = netmap.Peers ?: return val selfNode = netmap.SelfNode var grouped = mutableMapOf>() @@ -56,7 +35,7 @@ class PeerCategorizer(scope: CoroutineScope) { val me = netmap.currentUserProfile() - val peerSets = + peerSets = grouped .map { (userId, peers) -> val profile = netmap.userProfile(userId) @@ -66,11 +45,9 @@ class PeerCategorizer(scope: CoroutineScope) { if (it.user?.ID == me?.ID) { "" } else { - it.user?.DisplayName ?: "Unknown User" + it.user?.DisplayName?.lowercase() ?: "unknown user" } } - - return peerSets } fun groupedAndFilteredPeers(searchTerm: String = ""): List { @@ -78,14 +55,17 @@ class PeerCategorizer(scope: CoroutineScope) { return peerSets } - if (searchTerm == this.searchTerm) { + if (searchTerm == this.lastSearchTerm) { return lastSearchResult } // We can optimize out typing... If the search term starts with the last search term, we can // just search the last result - val setsToSearch = if (searchTerm.startsWith(this.searchTerm)) lastSearchResult else peerSets - this.searchTerm = searchTerm + val setsToSearch = + if (this.lastSearchTerm.isNotEmpty() && searchTerm.startsWith(this.lastSearchTerm)) + lastSearchResult + else peerSets + this.lastSearchTerm = searchTerm val matchingSets = setsToSearch @@ -107,7 +87,7 @@ class PeerCategorizer(scope: CoroutineScope) { } } .filterNotNull() - + lastSearchResult = matchingSets return matchingSets } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/Styles.kt b/android/src/main/java/com/tailscale/ipn/ui/util/Styles.kt index 8d75cb0..0df28ae 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/Styles.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/Styles.kt @@ -3,11 +3,8 @@ package com.tailscale.ipn.ui.util -import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxWidth -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.ui.Modifier import androidx.compose.ui.draw.clip @@ -15,12 +12,5 @@ import androidx.compose.ui.unit.dp @Composable fun settingsRowModifier(): Modifier { - return Modifier.clip(shape = RoundedCornerShape(8.dp)) - .background(color = MaterialTheme.colorScheme.secondaryContainer) - .fillMaxWidth() -} - -@Composable -fun defaultPaddingModifier(): Modifier { - return Modifier.padding(8.dp) + return Modifier.clip(shape = RoundedCornerShape(8.dp)).fillMaxWidth() } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/AboutView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/AboutView.kt index 9862cdf..92097d2 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/AboutView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/AboutView.kt @@ -47,6 +47,7 @@ fun AboutView(nav: BackNavigation) { .padding(15.dp), painter = painterResource(id = R.drawable.ic_tile), contentDescription = stringResource(R.string.app_icon_content_description)) + Column( verticalArrangement = Arrangement.spacedBy(space = 2.dp, alignment = Alignment.CenterVertically), @@ -54,28 +55,23 @@ fun AboutView(nav: BackNavigation) { Text( stringResource(R.string.about_view_title), fontWeight = FontWeight.SemiBold, - fontSize = MaterialTheme.typography.titleLarge.fontSize, - color = MaterialTheme.colorScheme.primary) + fontSize = MaterialTheme.typography.titleLarge.fontSize) Text( - text = BuildConfig.VERSION_NAME, + text = "${stringResource(R.string.version)} ${BuildConfig.VERSION_NAME}", fontWeight = MaterialTheme.typography.bodyMedium.fontWeight, - fontSize = MaterialTheme.typography.bodyMedium.fontSize, - color = MaterialTheme.colorScheme.secondary) - } - Column( - verticalArrangement = - Arrangement.spacedBy(space = 4.dp, alignment = Alignment.CenterVertically), - horizontalAlignment = Alignment.CenterHorizontally) { - OpenURLButton(stringResource(R.string.acknowledgements), Links.LICENSES_URL) - OpenURLButton(stringResource(R.string.privacy_policy), Links.PRIVACY_POLICY_URL) - OpenURLButton(stringResource(R.string.terms_of_service), Links.TERMS_URL) + fontSize = MaterialTheme.typography.bodyMedium.fontSize) } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + OpenURLButton(stringResource(R.string.acknowledgements), Links.LICENSES_URL) + OpenURLButton(stringResource(R.string.privacy_policy), Links.PRIVACY_POLICY_URL) + OpenURLButton(stringResource(R.string.terms_of_service), Links.TERMS_URL) + } + Text( stringResource(R.string.about_view_footnotes), fontWeight = FontWeight.Normal, fontSize = MaterialTheme.typography.labelMedium.fontSize, - color = MaterialTheme.colorScheme.tertiary, textAlign = TextAlign.Center) } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/Avatar.kt b/android/src/main/java/com/tailscale/ipn/ui/view/Avatar.kt index 0adf759..82c061d 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/Avatar.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/Avatar.kt @@ -3,15 +3,17 @@ package com.tailscale.ipn.ui.view -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.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Person +import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.Icon -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.draw.clip @@ -22,22 +24,20 @@ import com.tailscale.ipn.ui.model.IpnLocal @OptIn(ExperimentalCoilApi::class) @Composable -fun Avatar(profile: IpnLocal.LoginProfile?, size: Int = 50) { - Box( - contentAlignment = Alignment.Center, +fun Avatar(profile: IpnLocal.LoginProfile?, size: Int = 50, action: (() -> Unit)? = null) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(size.dp).clip(CircleShape)) { + var modifier = Modifier.size((size * .8f).dp) + action?.let { modifier = - Modifier.size(size.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.tertiaryContainer)) { - Icon( - imageVector = Icons.Default.Person, - contentDescription = null, - tint = MaterialTheme.colorScheme.onTertiaryContainer, - modifier = Modifier.size((size * .8f).dp)) + modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false), + onClick = action) + } + Icon(imageVector = Icons.Default.Person, contentDescription = null, modifier = modifier) - profile?.UserProfile?.ProfilePicURL?.let { url -> - AsyncImage( - model = url, modifier = Modifier.size((size * 1.2f).dp), contentDescription = null) - } - } + profile?.UserProfile?.ProfilePicURL?.let { url -> + AsyncImage(model = url, modifier = Modifier.size((size * 1.2f).dp), contentDescription = null) + } + } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/BugReportView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/BugReportView.kt index 27502e1..3be36b4 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/BugReportView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/BugReportView.kt @@ -26,9 +26,8 @@ import androidx.compose.ui.text.withStyle import androidx.lifecycle.viewmodel.compose.viewModel import com.tailscale.ipn.R import com.tailscale.ipn.ui.Links -import com.tailscale.ipn.ui.theme.ts_color_light_blue +import com.tailscale.ipn.ui.theme.link import com.tailscale.ipn.ui.util.ClipboardValueView -import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.viewModel.BugReportViewModel @Composable @@ -40,25 +39,16 @@ fun BugReportView(nav: BackNavigation, model: BugReportViewModel = viewModel()) Column(modifier = Modifier.padding(innerPadding).fillMaxWidth().fillMaxHeight()) { ListItem( headlineContent = { - ClickableText( - text = contactText(), - modifier = Modifier.fillMaxWidth(), - onClick = { handler.openUri(Links.SUPPORT_URL) }) + ClickableText(text = contactText(), onClick = { handler.openUri(Links.SUPPORT_URL) }) }) - Lists.SectionDivider() - ClipboardValueView(bugReportID, title = stringResource(R.string.bug_report_id)) - Lists.SectionDivider() - ListItem( headlineContent = { Text( text = stringResource(id = R.string.bug_report_id_desc), - modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Left, - color = MaterialTheme.colorScheme.secondary, style = MaterialTheme.typography.bodySmall) }) } @@ -68,20 +58,19 @@ fun BugReportView(nav: BackNavigation, model: BugReportViewModel = viewModel()) @Composable fun contactText(): AnnotatedString { val annotatedString = buildAnnotatedString { - withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) { - append(stringResource(id = R.string.bug_report_instructions_prefix)) - } + append(stringResource(id = R.string.bug_report_instructions_prefix)) pushStringAnnotation(tag = "reportLink", annotation = Links.SUPPORT_URL) withStyle( - style = SpanStyle(color = ts_color_light_blue, textDecoration = TextDecoration.Underline)) { + style = + SpanStyle( + color = MaterialTheme.colorScheme.link, + textDecoration = TextDecoration.Underline)) { append(stringResource(id = R.string.bug_report_instructions_linktext)) } pop() - withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) { - append(stringResource(id = R.string.bug_report_instructions_suffix)) - } + append(stringResource(id = R.string.bug_report_instructions_suffix)) } return annotatedString } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/Buttons.kt b/android/src/main/java/com/tailscale/ipn/ui/view/Buttons.kt index 271fa0b..1b80163 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/Buttons.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/Buttons.kt @@ -11,30 +11,23 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Clear import androidx.compose.material.icons.outlined.Close import androidx.compose.material3.Button -import androidx.compose.material3.ButtonColors -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp -import com.tailscale.ipn.ui.theme.ts_color_light_blue +import com.tailscale.ipn.ui.theme.link @Composable fun PrimaryActionButton(onClick: () -> Unit, content: @Composable RowScope.() -> Unit) { Button( onClick = onClick, - colors = - ButtonColors( - containerColor = ts_color_light_blue, - contentColor = Color.White, - disabledContainerColor = MaterialTheme.colorScheme.secondary, - disabledContentColor = MaterialTheme.colorScheme.onSecondary), contentPadding = PaddingValues(vertical = 12.dp), modifier = Modifier.fillMaxWidth(), content = content) @@ -44,13 +37,14 @@ fun PrimaryActionButton(onClick: () -> Unit, content: @Composable RowScope.() -> fun OpenURLButton(title: String, url: String) { val handler = LocalUriHandler.current - Button( - onClick = { handler.openUri(url) }, - content = { Text(title) }, - colors = - ButtonDefaults.buttonColors( - contentColor = MaterialTheme.colorScheme.secondary, - containerColor = MaterialTheme.colorScheme.secondaryContainer)) + TextButton(onClick = { handler.openUri(url) }) { + Text( + title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.link, + textDecoration = TextDecoration.Underline, + ) + } } @Composable @@ -60,7 +54,6 @@ fun ClearButton(onClick: () -> Unit) { } } - @Composable fun CloseButton() { val focusManager = LocalFocusManager.current diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt b/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt index e374da4..6d21c9e 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt @@ -3,7 +3,6 @@ package com.tailscale.ipn.ui.view -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -11,7 +10,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Check -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme @@ -23,16 +21,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.lifecycle.viewmodel.compose.viewModel import com.tailscale.ipn.R +import com.tailscale.ipn.ui.theme.disabledListItem +import com.tailscale.ipn.ui.theme.listItem import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.util.LoadingIndicator -import com.tailscale.ipn.ui.util.clickableOrGrayedOut import com.tailscale.ipn.ui.util.itemsWithDividers import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory import com.tailscale.ipn.ui.viewModel.selected -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun ExitNodePicker( nav: ExitNodePickerNav, @@ -47,15 +45,7 @@ fun ExitNodePicker( val anyActive = model.anyActive.collectAsState() LazyColumn(modifier = Modifier.padding(innerPadding)) { - stickyHeader { - RunAsExitNodeItem(nav = nav, viewModel = model) - Lists.ItemDivider() - SettingRow(model.allowLANAccessSetting) - } - - item(key = "none") { - Lists.SectionDivider() - + item(key = "header") { ExitNodeItem( model, ExitNodePickerViewModel.ExitNode( @@ -63,19 +53,26 @@ fun ExitNodePicker( online = true, selected = !anyActive.value, )) + Lists.ItemDivider() + RunAsExitNodeItem(nav = nav, viewModel = model) } - item { Lists.ItemDivider() } + item(key = "divider1") { Lists.SectionDivider() } itemsWithDividers(tailnetExitNodes, key = { it.id!! }) { node -> ExitNodeItem(model, node) } - item { Lists.SectionDivider() } - if (mullvadExitNodeCount > 0) { item(key = "mullvad") { + Lists.SectionDivider() MullvadItem(nav, mullvadExitNodeCount, mullvadExitNodesByCountryCode.selected) } } + + // TODO: make sure this actually works, and if not, leave it out for now + item(key = "allowLANAccess") { + Lists.SectionDivider() + SettingRow(model.allowLANAccessSetting) + } } } } @@ -87,16 +84,17 @@ fun ExitNodeItem( node: ExitNodePickerViewModel.ExitNode, ) { Box { - // TODO: add disabled styling + var modifier: Modifier = Modifier + if (node.online) { + modifier = modifier.clickable { viewModel.setExitNode(node) } + } ListItem( - modifier = - Modifier.clickableOrGrayedOut(enabled = node.online) { viewModel.setExitNode(node) }, + modifier = modifier, + colors = + if (node.online) MaterialTheme.colorScheme.listItem + else MaterialTheme.colorScheme.disabledListItem, headlineContent = { - Text( - node.city.ifEmpty { node.label }, - style = - if (node.online) MaterialTheme.typography.titleMedium - else MaterialTheme.typography.bodyMedium) + Text(node.city.ifEmpty { node.label }, style = MaterialTheme.typography.bodyMedium) }, supportingContent = { if (!node.online) @@ -120,7 +118,7 @@ fun MullvadItem(nav: ExitNodePickerNav, count: Int, selected: Boolean) { headlineContent = { Text( stringResource(R.string.mullvad_exit_nodes), - style = MaterialTheme.typography.titleMedium) + style = MaterialTheme.typography.bodyMedium) }, supportingContent = { Text( @@ -147,7 +145,7 @@ fun RunAsExitNodeItem(nav: ExitNodePickerNav, viewModel: ExitNodePickerViewModel stringResource(id = R.string.run_as_exit_node), style = MaterialTheme.typography.bodyMedium) }, - trailingContent = { + supportingContent = { if (isRunningExitNode) { Text(stringResource(R.string.enabled)) } else { diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MDMSettingsDebugView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MDMSettingsDebugView.kt index 64118bb..c3b91c9 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MDMSettingsDebugView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MDMSettingsDebugView.kt @@ -45,13 +45,11 @@ fun MDMSettingView(setting: MDMSetting<*>) { Text( setting.key, fontSize = MaterialTheme.typography.labelSmall.fontSize, - color = MaterialTheme.colorScheme.tertiary, fontFamily = FontFamily.Monospace) }, trailingContent = { Text( value.toString(), - color = MaterialTheme.colorScheme.secondary, fontFamily = FontFamily.Monospace, maxLines = 1, fontWeight = FontWeight.SemiBold) diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt index 79d7a7f..2e77210 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt @@ -3,6 +3,7 @@ package com.tailscale.ipn.ui.view +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -14,6 +15,8 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars @@ -28,6 +31,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SearchBar @@ -39,12 +43,15 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted @@ -55,15 +62,18 @@ import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.Permission import com.tailscale.ipn.ui.model.Permissions -import com.tailscale.ipn.ui.model.StableNodeID import com.tailscale.ipn.ui.model.Tailcfg -import com.tailscale.ipn.ui.theme.ts_color_light_green +import com.tailscale.ipn.ui.theme.disabled +import com.tailscale.ipn.ui.theme.listItem +import com.tailscale.ipn.ui.theme.primaryListItem +import com.tailscale.ipn.ui.theme.secondaryButton +import com.tailscale.ipn.ui.theme.surfaceContainerListItem +import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.util.LoadingIndicator import com.tailscale.ipn.ui.util.PeerSet import com.tailscale.ipn.ui.util.flag import com.tailscale.ipn.ui.util.itemsWithDividers import com.tailscale.ipn.ui.viewModel.MainViewModel -import kotlinx.coroutines.flow.StateFlow // Navigation actions for the MainView data class MainViewNavigation( @@ -82,51 +92,58 @@ fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewMode verticalArrangement = Arrangement.Center) { val state = viewModel.ipnState.collectAsState(initial = Ipn.State.NoState) val user = viewModel.loggedInUser.collectAsState(initial = null) + val stateVal = viewModel.stateRes.collectAsState(initial = R.string.placeholder) + val stateStr = stringResource(id = stateVal.value) + val username = viewModel.userName - Row( - modifier = - Modifier.fillMaxWidth() - .background(MaterialTheme.colorScheme.secondaryContainer) - .padding(horizontal = 16.dp) - .padding(top = 10.dp), - verticalAlignment = Alignment.CenterVertically) { + ListItem( + colors = MaterialTheme.colorScheme.surfaceContainerListItem, + leadingContent = { val isOn = viewModel.vpnToggleState.collectAsState(initial = false) - if (state.value != Ipn.State.NoState) { - TintedSwitch(onCheckedChange = { viewModel.toggleVpn() }, checked = isOn.value) - Spacer(Modifier.size(3.dp)) + TintedSwitch( + onCheckedChange = { viewModel.toggleVpn() }, + checked = isOn.value, + enabled = state.value != Ipn.State.NoState) + }, + headlineContent = { + if (username.isNotEmpty()) { + Text( + text = username, + style = MaterialTheme.typography.titleMedium.copy(lineHeight = 20.sp)) } - - StateDisplay(viewModel.stateRes, viewModel.userName) - - Box( - modifier = - Modifier.weight(1f).clickable { navigation.onNavigateToSettings() }, - contentAlignment = Alignment.CenterEnd) { - when (user.value) { - null -> SettingsButton(user.value) { navigation.onNavigateToSettings() } - else -> Avatar(profile = user.value, size = 36) - } - } - } + }, + supportingContent = { + if (username.isNotEmpty()) { + Text( + text = stateStr, + style = MaterialTheme.typography.bodyMedium.copy(lineHeight = 20.sp)) + } else { + Text( + text = stateStr, + style = MaterialTheme.typography.bodyMedium.copy(lineHeight = 20.sp)) + } + }, + trailingContent = { + Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { + when (user.value) { + null -> SettingsButton(user.value) { navigation.onNavigateToSettings() } + else -> + Avatar(profile = user.value, size = 36) { + navigation.onNavigateToSettings() + } + } + } + }) when (state.value) { Ipn.State.Running -> { PromptPermissionsIfNecessary(permissions = Permissions.all) - val selfPeerId = viewModel.selfPeerId.collectAsState(initial = "") - Row( - modifier = - Modifier.background(MaterialTheme.colorScheme.secondaryContainer) - .padding(top = 10.dp, bottom = 20.dp)) { - ExitNodeStatus( - navAction = navigation.onNavigateToExitNodes, viewModel = viewModel) - } + ExitNodeStatus(navAction = navigation.onNavigateToExitNodes, viewModel = viewModel) + PeerList( - searchTerm = viewModel.searchTerm, - state = viewModel.ipnState, - peers = viewModel.peers, - selfPeer = selfPeerId.value, + viewModel = viewModel, onNavigateToPeerDetails = navigation.onNavigateToPeerDetails, onSearch = { viewModel.searchPeers(it) }) } @@ -148,67 +165,55 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) { val exitNodeId = prefs.value?.ExitNodeID val peer = exitNodeId?.let { id -> netmap.value?.Peers?.find { it.StableID == id } } val location = peer?.Hostinfo?.Location - val name = peer?.Name + val name = peer?.ComputedName + val active = peer != null - Box( - modifier = - Modifier.padding(horizontal = 16.dp) - .clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp)) - .background(MaterialTheme.colorScheme.background) - .fillMaxWidth()) { - ListItem( - modifier = Modifier.clickable { navAction() }, - headlineContent = { - Text( - stringResource(R.string.exit_node), - style = MaterialTheme.typography.titleMedium, - ) - }, - supportingContent = { - Row(verticalAlignment = Alignment.CenterVertically) { + Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainer)) { + Box( + modifier = + Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 16.dp) + .clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp)) + .fillMaxWidth()) { + ListItem( + modifier = Modifier.clickable { navAction() }, + colors = + if (active) MaterialTheme.colorScheme.primaryListItem + else ListItemDefaults.colors(), + overlineContent = { Text( - text = - location?.let { "${it.CountryCode?.flag()} ${it.Country} - ${it.City}" } - ?: name - ?: stringResource(id = R.string.none), - style = MaterialTheme.typography.bodyLarge) - Icon( - Icons.Outlined.ArrowDropDown, - null, + stringResource(R.string.exit_node), + style = MaterialTheme.typography.bodySmall, ) - } - }, - trailingContent = { - if (peer != null) { - Button(onClick = { viewModel.disableExitNode() }) { - Text(stringResource(R.string.disable)) + }, + headlineContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = + location?.let { "${it.CountryCode?.flag()} ${it.Country} - ${it.City}" } + ?: name + ?: stringResource(id = R.string.none), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + Icon( + imageVector = Icons.Outlined.ArrowDropDown, + contentDescription = null, + tint = + if (active) MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f) + else MaterialTheme.colorScheme.onSurfaceVariant, + ) } - } - }) - } -} - -@Composable -fun StateDisplay(state: StateFlow, tailnet: String) { - val stateVal = state.collectAsState(initial = R.string.placeholder) - val stateStr = stringResource(id = stateVal.value) - - Column(modifier = Modifier.padding(7.dp)) { - when (tailnet.isEmpty()) { - false -> { - Text(text = tailnet, style = MaterialTheme.typography.titleMedium) - Text( - text = stateStr, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.secondary) - } - true -> { - Text( - text = stateStr, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary) - } - } + }, + trailingContent = { + if (peer != null) { + Button( + colors = MaterialTheme.colorScheme.secondaryButton, + onClick = { viewModel.disableExitNode() }) { + Text(stringResource(R.string.disable)) + } + } + }) + } } } @@ -227,10 +232,10 @@ fun SettingsButton(user: IpnLocal.LoginProfile?, action: () -> Unit) { @Composable fun StartingView() { Column( - modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.secondaryContainer), + modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) { - TailscaleLogoView(animated = true, Modifier.size(72.dp)) + TailscaleLogoView(animated = true, Modifier.size(40.dp)) } } @@ -242,81 +247,76 @@ fun ConnectView( loginAction: () -> Unit ) { Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = - Modifier.background(MaterialTheme.colorScheme.secondaryContainer).fillMaxWidth()) { - Column( - modifier = Modifier.padding(8.dp).fillMaxWidth(0.7f).fillMaxHeight(), - verticalArrangement = - Arrangement.spacedBy(8.dp, alignment = Alignment.CenterVertically), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - if (state != Ipn.State.NeedsLogin && user != null && !user.isEmpty()) { - Icon( - painter = painterResource(id = R.drawable.power), - contentDescription = null, - modifier = Modifier.size(48.dp), - tint = MaterialTheme.colorScheme.secondary) - Text( - text = stringResource(id = R.string.not_connected), - fontSize = MaterialTheme.typography.titleMedium.fontSize, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary, - textAlign = TextAlign.Center, - fontFamily = MaterialTheme.typography.titleMedium.fontFamily) - val tailnetName = user.NetworkProfile?.DomainName ?: "" - Text( - stringResource(id = R.string.connect_to_tailnet, tailnetName), - fontSize = MaterialTheme.typography.titleMedium.fontSize, - fontWeight = FontWeight.Normal, - color = MaterialTheme.colorScheme.secondary, - textAlign = TextAlign.Center, - ) - Spacer(modifier = Modifier.size(1.dp)) - PrimaryActionButton(onClick = connectAction) { - Text( - text = stringResource(id = R.string.connect), - fontSize = MaterialTheme.typography.titleMedium.fontSize) - } - } else { - TailscaleLogoView(modifier = Modifier.size(50.dp)) - Spacer(modifier = Modifier.size(1.dp)) - Text( - text = stringResource(id = R.string.welcome_to_tailscale), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - textAlign = TextAlign.Center) - Text( - stringResource(R.string.login_to_join_your_tailnet), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.secondary, - textAlign = TextAlign.Center) - Spacer(modifier = Modifier.size(1.dp)) - PrimaryActionButton(onClick = loginAction) { - Text( - text = stringResource(id = R.string.log_in), - fontSize = MaterialTheme.typography.titleMedium.fontSize) - } - } + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(8.dp).fillMaxWidth(0.7f).fillMaxHeight(), + verticalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (state != Ipn.State.NeedsLogin && user != null && !user.isEmpty()) { + Icon( + painter = painterResource(id = R.drawable.power), + contentDescription = null, + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colorScheme.disabled) + Text( + text = stringResource(id = R.string.not_connected), + fontSize = MaterialTheme.typography.titleMedium.fontSize, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + fontFamily = MaterialTheme.typography.titleMedium.fontFamily) + val tailnetName = user.NetworkProfile?.DomainName ?: "" + Text( + buildAnnotatedString { + append(stringResource(id = R.string.connect_to_tailnet_prefix)) + pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) + append(tailnetName) + pop() + append(stringResource(id = R.string.connect_to_tailnet_suffix)) + }, + fontSize = MaterialTheme.typography.titleMedium.fontSize, + fontWeight = FontWeight.Normal, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.size(1.dp)) + PrimaryActionButton(onClick = connectAction) { + Text( + text = stringResource(id = R.string.connect), + fontSize = MaterialTheme.typography.titleMedium.fontSize) + } + } else { + TailscaleLogoView(modifier = Modifier.size(50.dp)) + Spacer(modifier = Modifier.size(1.dp)) + Text( + text = stringResource(id = R.string.welcome_to_tailscale), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center) + Text( + stringResource(R.string.login_to_join_your_tailnet), + style = MaterialTheme.typography.titleSmall, + textAlign = TextAlign.Center) + Spacer(modifier = Modifier.size(1.dp)) + PrimaryActionButton(onClick = loginAction) { + Text( + text = stringResource(id = R.string.log_in), + fontSize = MaterialTheme.typography.titleMedium.fontSize) } } + } + } } } -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun PeerList( - searchTerm: StateFlow, - peers: StateFlow>, - state: StateFlow, - selfPeer: StableNodeID, + viewModel: MainViewModel, onNavigateToPeerDetails: (Tailcfg.Node) -> Unit, onSearch: (String) -> Unit ) { - val peerList = peers.collectAsState(initial = emptyList()) - val searchTermStr by searchTerm.collectAsState(initial = "") - val stateVal = state.collectAsState(initial = Ipn.State.NoState) + val peerList = viewModel.peers.collectAsState(initial = emptyList()) + val searchTermStr by viewModel.searchTerm.collectAsState(initial = "") + val netmap = viewModel.netmap.collectAsState() SearchBar( query = searchTermStr, @@ -333,15 +333,25 @@ fun PeerList( shadowElevation = 0.dp, colors = SearchBarDefaults.colors( - containerColor = Color.Transparent, dividerColor = Color.Transparent), + containerColor = MaterialTheme.colorScheme.surface, + dividerColor = MaterialTheme.colorScheme.outline), modifier = Modifier.fillMaxWidth()) { LazyColumn( - modifier = - Modifier.fillMaxSize().background(MaterialTheme.colorScheme.secondaryContainer), + modifier = Modifier.fillMaxSize(), ) { + var first = true peerList.value.forEach { peerSet -> - item { + if (!first) { + item(key = "spacer_${peerSet.user?.DisplayName}") { + Lists.ItemDivider() + Spacer(Modifier.height(24.dp)) + } + } + first = false + + stickyHeader { ListItem( + modifier = Modifier.heightIn(max = 48.dp), headlineContent = { Text( text = @@ -350,35 +360,28 @@ fun PeerList( fontWeight = FontWeight.SemiBold) }) } + itemsWithDividers(peerSet.peers, key = { it.StableID }) { peer -> ListItem( modifier = Modifier.clickable { onNavigateToPeerDetails(peer) }, + colors = MaterialTheme.colorScheme.listItem, headlineContent = { Row(verticalAlignment = Alignment.CenterVertically) { - // By definition, SelfPeer is online since we will not show the peer list - // unless you're connected. - val isSelfAndRunning = - (peer.StableID == selfPeer && stateVal.value == Ipn.State.Running) - val color: Color = - if ((peer.Online == true) || isSelfAndRunning) { - ts_color_light_green - } else { - Color.Gray - } Box( modifier = - Modifier.size(10.dp) + Modifier.padding(top = 2.dp) + .size(10.dp) .background( - color = color, shape = RoundedCornerShape(percent = 50))) {} - Spacer(modifier = Modifier.size(6.dp)) + color = peer.connectedColor(netmap.value), + shape = RoundedCornerShape(percent = 50))) {} + Spacer(modifier = Modifier.size(8.dp)) Text(text = peer.ComputedName, style = MaterialTheme.typography.titleMedium) } }, supportingContent = { Text( text = peer.Addresses?.first()?.split("/")?.first() ?: "", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.secondary) + style = MaterialTheme.typography.bodyMedium) }) } } @@ -393,7 +396,7 @@ fun PromptPermissionsIfNecessary(permissions: List) { val state = rememberPermissionState(permission.name) if (!state.status.isGranted && !state.status.shouldShowRationale) { // We don't have the permission and can ask for it - ErrorDialog(title = permission.title, message = permission.neededDescription) { + ErrorDialog(title = permission.title, message = permission.description) { state.launchPermissionRequest() } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MullvadExitNodePicker.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MullvadExitNodePicker.kt index 5e5784c..34e004f 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MullvadExitNodePicker.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MullvadExitNodePicker.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier @@ -37,7 +38,9 @@ fun MullvadExitNodePicker( LoadingIndicator.Wrap { Scaffold( topBar = { - Header(titleText = "${countryCode.flag()} ${any.country}", onBack = nav.onNavigateBack) + Header( + title = { Text("${countryCode.flag()} ${any.country}") }, + onBack = nav.onNavigateBack) }) { innerPadding -> LazyColumn(modifier = Modifier.padding(innerPadding)) { if (nodes.size > 1) { @@ -51,7 +54,7 @@ fun MullvadExitNodePicker( online = bestAvailableNode.online, selected = false, )) - Lists.ItemDivider() + Lists.SectionDivider() } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MullvadExitNodePickerList.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MullvadExitNodePickerList.kt index 63527bb..20e7f5c 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MullvadExitNodePickerList.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MullvadExitNodePickerList.kt @@ -70,7 +70,7 @@ fun MullvadExitNodePickerList( ) }, headlineContent = { - Text(first.country, style = MaterialTheme.typography.titleMedium) + Text(first.country, style = MaterialTheme.typography.bodyMedium) }, supportingContent = { Text( diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt b/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt index a3796b1..fb61cde 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt @@ -9,31 +9,39 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import com.tailscale.ipn.R +import com.tailscale.ipn.ui.theme.listItem import com.tailscale.ipn.ui.theme.ts_color_light_blue -import com.tailscale.ipn.ui.util.settingsRowModifier +import com.tailscale.ipn.ui.util.Lists +import com.tailscale.ipn.ui.util.itemsWithDividers import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModelFactory +@OptIn(ExperimentalMaterial3Api::class) @Composable fun PeerDetails( nav: BackNavigation, @@ -41,45 +49,59 @@ fun PeerDetails( model: PeerDetailsViewModel = viewModel(factory = PeerDetailsViewModelFactory(nodeId, LocalContext.current.filesDir)) ) { - Scaffold(topBar = { Header(title = R.string.peer_details, onBack = nav.onBack) }) { innerPadding - -> - Column( - modifier = - Modifier.fillMaxWidth() - .padding(innerPadding) - .padding(horizontal = 16.dp) - .padding(top = 22.dp), - ) { - Text( - text = model.nodeName, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.primary) - Row(verticalAlignment = Alignment.CenterVertically) { - Box( - modifier = - Modifier.size(8.dp) - .background( - color = model.connectedColor, shape = RoundedCornerShape(percent = 50))) {} - Spacer(modifier = Modifier.size(8.dp)) - Text( - text = stringResource(id = model.connectedStrRes), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary) - } - Column(modifier = Modifier.fillMaxHeight()) { - Text( - text = stringResource(id = R.string.addresses_section), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary) + model.netmap.collectAsState().value?.let { netmap -> + model.node.collectAsState().value?.let { node -> + Scaffold( + topBar = { + Header( + title = { + Column { + Text( + text = node.displayName, + style = MaterialTheme.typography.titleMedium.copy(lineHeight = 20.sp), + color = MaterialTheme.colorScheme.onSurface) + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = + Modifier.size(8.dp) + .background( + color = node.connectedColor(netmap), + shape = RoundedCornerShape(percent = 50))) {} + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = stringResource(id = node.connectedStrRes(netmap)), + style = MaterialTheme.typography.bodyMedium.copy(lineHeight = 20.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + }, + onBack = { nav.onBack() }) + }, + ) { innerPadding -> + LazyColumn( + modifier = Modifier.padding(innerPadding), + ) { + item(key = "tailscaleAddresses") { + Box( + modifier = + Modifier.fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surface, shape = RectangleShape)) { + Text( + modifier = Modifier.padding(start = 16.dp, top = 16.dp), + text = stringResource(R.string.tailscale_addresses), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } - Column(modifier = settingsRowModifier()) { - model.addresses.forEach { AddressRow(address = it.address, type = it.typeString) } - } + itemsWithDividers(node.displayAddresses, key = { it.address }) { + AddressRow(address = it.address, type = it.typeString) + } - Spacer(modifier = Modifier.size(16.dp)) + item(key = "infoDivider") { Lists.SectionDivider() } - Column(modifier = settingsRowModifier()) { - model.info.forEach { + itemsWithDividers(node.info, key = { "info_${it.titleRes}" }) { ValueRow(title = stringResource(id = it.titleRes), value = it.value.getString()) } } @@ -92,33 +114,21 @@ fun PeerDetails( fun AddressRow(address: String, type: String) { val localClipboardManager = LocalClipboardManager.current - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier.padding(horizontal = 8.dp, vertical = 8.dp) - .clickable(onClick = { localClipboardManager.setText(AnnotatedString(address)) })) { - Column { - Text(text = address) - Text( - text = type, - fontSize = MaterialTheme.typography.labelLarge.fontSize, - color = MaterialTheme.colorScheme.secondary) - } - Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { - Icon( - painter = painterResource(id = R.drawable.clipboard), - null, - tint = ts_color_light_blue) - } - } + ListItem( + modifier = Modifier.clickable { localClipboardManager.setText(AnnotatedString(address)) }, + colors = MaterialTheme.colorScheme.listItem, + headlineContent = { Text(text = address) }, + supportingContent = { Text(text = type) }, + trailingContent = { + // TODO: there is some overlap with other uses of clipboard, DRY + Icon(painter = painterResource(id = R.drawable.clipboard), null, tint = ts_color_light_blue) + }) } @Composable fun ValueRow(title: String, value: String) { - Row(modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp).fillMaxWidth()) { - Text(text = title) - Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { - Text(text = value, color = MaterialTheme.colorScheme.secondary) - } - } + ListItem( + colors = MaterialTheme.colorScheme.listItem, + headlineContent = { Text(text = title) }, + supportingContent = { Text(text = value) }) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/PeerView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/PeerView.kt index b363019..3d6ace1 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/PeerView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/PeerView.kt @@ -20,19 +20,20 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Tailcfg -import com.tailscale.ipn.ui.theme.ts_color_light_green +import com.tailscale.ipn.ui.theme.off +import com.tailscale.ipn.ui.theme.on @Composable fun PeerView( peer: Tailcfg.Node, selfPeer: String? = null, stateVal: Ipn.State? = null, - disabled: Boolean = false, subtitle: () -> String = { peer.Addresses?.first()?.split("/")?.first() ?: "" }, onClick: (Tailcfg.Node) -> Unit = {}, trailingContent: @Composable () -> Unit = {} ) { - val textColor = if (disabled) Color.Gray else MaterialTheme.colorScheme.primary + val disabled = !(peer.Online ?: false) + val textColor = if (disabled) MaterialTheme.colorScheme.onSurfaceVariant else Color.Unspecified ListItem( modifier = Modifier.clickable { onClick(peer) }, @@ -43,9 +44,9 @@ fun PeerView( val isSelfAndRunning = (peer.StableID == selfPeer && stateVal == Ipn.State.Running) val color: Color = if ((peer.Online == true) || isSelfAndRunning) { - ts_color_light_green + MaterialTheme.colorScheme.on } else { - Color.Gray + MaterialTheme.colorScheme.off } Box( modifier = diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/PermissionsView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/PermissionsView.kt index a002741..353f48d 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/PermissionsView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/PermissionsView.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.Icon @@ -30,7 +29,8 @@ import com.tailscale.ipn.ui.util.itemsWithDividers @OptIn(ExperimentalPermissionsApi::class) @Composable fun PermissionsView(nav: BackNavigation, openApplicationSettings: () -> Unit) { - Scaffold(topBar = { Header(title = R.string.permissions, onBack = nav.onBack) }) { innerPadding -> + Scaffold(topBar = { Header(titleRes = R.string.permissions, onBack = nav.onBack) }) { innerPadding + -> val permissions = Permissions.all val permissionStates = rememberMultiplePermissionsState(permissions = permissions.map { it.name }) @@ -54,20 +54,7 @@ fun PermissionsView(nav: BackNavigation, openApplicationSettings: () -> Unit) { headlineContent = { Text(stringResource(permission.title), style = MaterialTheme.typography.titleMedium) }, - supportingContent = { - Text( - stringResource( - if (state.status.isGranted) permission.grantedDescription - else permission.neededDescription)) - }, - trailingContent = { - if (!state.status.isGranted) { - Icon( - Icons.AutoMirrored.Outlined.KeyboardArrowRight, - modifier = Modifier.size(24.dp), - contentDescription = stringResource(R.string.more)) - } - }, + supportingContent = { Text(stringResource(permission.description)) }, ) } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/RunExitNodeView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/RunExitNodeView.kt index 7356e4c..6ce06a5 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/RunExitNodeView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/RunExitNodeView.kt @@ -45,8 +45,7 @@ fun RunExitNodeView( LoadingIndicator.Wrap { Column( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = - Arrangement.spacedBy(16.dp, alignment = Alignment.Top), + verticalArrangement = Arrangement.spacedBy(16.dp, alignment = Alignment.Top), modifier = Modifier.padding(innerPadding).padding(16.dp).fillMaxHeight()) { RunExitNodeGraphic() @@ -83,11 +82,7 @@ fun RunExitNodeView( fun RunExitNodeGraphic() { @Composable fun ArrowForward() { - Icon( - Icons.AutoMirrored.Outlined.ArrowForward, - "Arrow Forward", - tint = MaterialTheme.colorScheme.secondary, - modifier = Modifier.size(24.dp)) + Icon(Icons.AutoMirrored.Outlined.ArrowForward, "Arrow Forward", modifier = Modifier.size(24.dp)) } Row( diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt index a97f05c..fa9b610 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt @@ -6,8 +6,6 @@ package com.tailscale.ipn.ui.view import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.ClickableText import androidx.compose.material3.ListItem @@ -22,13 +20,14 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.tailscale.ipn.BuildConfig import com.tailscale.ipn.R import com.tailscale.ipn.ui.Links -import com.tailscale.ipn.ui.theme.ts_color_dark_desctrutive_text +import com.tailscale.ipn.ui.theme.link +import com.tailscale.ipn.ui.theme.listItem import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.viewModel.Setting import com.tailscale.ipn.ui.viewModel.SettingType @@ -47,8 +46,9 @@ fun SettingsView( val managedBy = viewModel.managedBy.collectAsState().value Scaffold( - topBar = { Header(title = R.string.settings_title, onBack = settingsNav.onBackPressed) }) { - innerPadding -> + topBar = { + Header(titleRes = R.string.settings_title, onBack = settingsNav.onBackPressed) + }) { innerPadding -> Column(modifier = Modifier.padding(innerPadding)) { UserView( profile = user, @@ -56,10 +56,10 @@ fun SettingsView( onClick = viewModel.navigation.onNavigateToUserSwitcher) if (isAdmin) { - Spacer(modifier = Modifier.height(4.dp)) AdminTextView { handler.openUri(Links.ADMIN_URL) } } + Lists.SectionDivider() SettingRow(viewModel.dns) Lists.ItemDivider() @@ -68,21 +68,22 @@ fun SettingsView( Lists.ItemDivider() SettingRow(viewModel.permissions) - Lists.ItemDivider() - SettingRow(viewModel.about) + managedBy?.let { + Lists.ItemDivider() + SettingRow(it) + } - Lists.ItemDivider() + Lists.SectionDivider() SettingRow(viewModel.bugReport) + Lists.ItemDivider() + SettingRow(viewModel.about) + + // TODO: put a heading for the debug section if (BuildConfig.DEBUG) { - Lists.ItemDivider() + Lists.SectionDivider() SettingRow(viewModel.mdmDebug) } - - managedBy?.let { - Lists.ItemDivider() - SettingRow(it) - } } } } @@ -105,13 +106,12 @@ private fun TextRow(setting: Setting) { val enabled = setting.enabled.collectAsState().value ListItem( modifier = Modifier.clickable { if (enabled) setting.onClick() }, + colors = MaterialTheme.colorScheme.listItem, headlineContent = { Text( setting.title ?: stringResource(setting.titleRes), style = MaterialTheme.typography.bodyMedium, - color = - if (setting.destructive) ts_color_dark_desctrutive_text - else MaterialTheme.colorScheme.primary) + color = if (setting.destructive) MaterialTheme.colorScheme.error else Color.Unspecified) }, ) } @@ -122,6 +122,7 @@ private fun SwitchRow(setting: Setting) { val swVal = setting.isOn?.collectAsState()?.value ?: false ListItem( modifier = Modifier.clickable { if (enabled) setting.onClick() }, + colors = MaterialTheme.colorScheme.listItem, headlineContent = { Text( setting.title ?: stringResource(setting.titleRes), @@ -137,34 +138,37 @@ private fun SwitchRow(setting: Setting) { private fun NavRow(setting: Setting) { ListItem( modifier = Modifier.clickable { setting.onClick() }, + colors = MaterialTheme.colorScheme.listItem, headlineContent = { Text( setting.title ?: stringResource(setting.titleRes), - style = MaterialTheme.typography.bodyMedium, - color = - if (setting.destructive) ts_color_dark_desctrutive_text - else MaterialTheme.colorScheme.primary) + style = MaterialTheme.typography.bodyMedium) }) } @Composable fun AdminTextView(onNavigateToAdminConsole: () -> Unit) { val adminStr = buildAnnotatedString { - withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) { + withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.onSurfaceVariant)) { append(stringResource(id = R.string.settings_admin_prefix)) } pushStringAnnotation(tag = "link", annotation = Links.ADMIN_URL) - withStyle(style = SpanStyle(color = Color.Blue)) { - append(stringResource(id = R.string.settings_admin_link)) - } + withStyle( + style = + SpanStyle( + color = MaterialTheme.colorScheme.link, + textDecoration = TextDecoration.Underline)) { + append(stringResource(id = R.string.settings_admin_link)) + } pop() } - Column(modifier = Modifier.padding(horizontal = 12.dp)) { - ClickableText( - text = adminStr, - style = MaterialTheme.typography.bodySmall, - onClick = { onNavigateToAdminConsole() }) - } + ListItem( + headlineContent = { + ClickableText( + text = adminStr, + style = MaterialTheme.typography.bodyMedium, + onClick = { onNavigateToAdminConsole() }) + }) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SharedViews.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SharedViews.kt index 8b24ad1..019b541 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SharedViews.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SharedViews.kt @@ -5,12 +5,15 @@ package com.tailscale.ipn.ui.view import androidx.annotation.StringRes import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -18,11 +21,12 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.tailscale.ipn.ui.theme.topAppBar import com.tailscale.ipn.ui.theme.ts_color_light_blue data class BackNavigation( @@ -34,29 +38,37 @@ data class BackNavigation( @OptIn(ExperimentalMaterial3Api::class) @Composable fun Header( - @StringRes title: Int = 0, - titleText: String? = null, + @StringRes titleRes: Int = 0, + title: (@Composable () -> Unit)? = null, actions: @Composable RowScope.() -> Unit = {}, onBack: (() -> Unit)? = null ) { TopAppBar( - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - titleContentColor = MaterialTheme.colorScheme.primary, - ), + title = { + title?.let { title() } + ?: Text( + stringResource(titleRes), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface) + }, + colors = MaterialTheme.colorScheme.topAppBar, actions = actions, - title = { Text(titleText ?: stringResource(title)) }, navigationIcon = { onBack?.let { BackArrow(action = it) } }, ) } @Composable fun BackArrow(action: () -> Unit) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - null, - modifier = Modifier.clickable { action() }.padding(start = 15.dp, end = 20.dp)) + Box(modifier = Modifier.padding(start = 8.dp, end = 8.dp)) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + null, + modifier = + Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false), + onClick = { action() })) + } } @Composable @@ -68,8 +80,6 @@ fun CheckedIndicator() { fun SimpleActivityIndicator(size: Int = 32) { CircularProgressIndicator( modifier = Modifier.width(size.dp), - color = ts_color_light_blue, - trackColor = MaterialTheme.colorScheme.secondary, ) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/TaildropView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/TaildropView.kt index 0facade..2c14ae1 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/TaildropView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/TaildropView.kt @@ -96,12 +96,10 @@ fun FileSharePeerList( false -> { LazyColumn { peers.forEach { peer -> - val disabled = !(peer.Online ?: false) item { PeerView( peer = peer, onClick = { onShare(peer) }, - disabled = disabled, subtitle = { peer.Hostinfo.OS ?: "" }, trailingContent = { stateViewGenerator(peer.StableID) }) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/TailnetLockSetupView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/TailnetLockSetupView.kt index e302854..94591e5 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/TailnetLockSetupView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/TailnetLockSetupView.kt @@ -59,7 +59,7 @@ fun TailnetLockSetupView( Icon( painter = painterResource(id = statusItem.icon), contentDescription = null, - tint = ts_color_light_blue) + tint = MaterialTheme.colorScheme.onSurfaceVariant) }, headlineContent = { Text(stringResource(statusItem.title)) }) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/TailscaleLogoView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/TailscaleLogoView.kt index b655670..967b8f3 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/TailscaleLogoView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/TailscaleLogoView.kt @@ -33,8 +33,8 @@ val logoDotsMatrix: DotsMatrix = @Composable fun TailscaleLogoView(animated: Boolean = false, modifier: Modifier) { - val primaryColor: Color = MaterialTheme.colorScheme.secondary - val secondaryColor: Color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.3f) + val primaryColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) + val secondaryColor: Color = primaryColor.copy(alpha = 0.1f) val currentDotsMatrix: StateFlow = MutableStateFlow(logoDotsMatrix) var currentDotsMatrixIndex = 0 diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/TintedSwitch.kt b/android/src/main/java/com/tailscale/ipn/ui/view/TintedSwitch.kt index 69f1ccb..e552e13 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/TintedSwitch.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/TintedSwitch.kt @@ -3,22 +3,10 @@ package com.tailscale.ipn.ui.view -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults import androidx.compose.runtime.Composable -import com.tailscale.ipn.ui.theme.ts_color_light_blue @Composable fun TintedSwitch(checked: Boolean, onCheckedChange: ((Boolean) -> Unit)?, enabled: Boolean = true) { - Switch( - checked = checked, - onCheckedChange = onCheckedChange, - enabled = enabled, - colors = - SwitchDefaults.colors( - checkedBorderColor = ts_color_light_blue, - checkedThumbColor = ts_color_light_blue, - checkedTrackColor = ts_color_light_blue.copy(alpha = 0.3f), - uncheckedTrackColor = MaterialTheme.colorScheme.secondaryContainer)) + Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/DNSSettingsViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/DNSSettingsViewModel.kt index 1da7513..1def574 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/DNSSettingsViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/DNSSettingsViewModel.kt @@ -5,6 +5,8 @@ package com.tailscale.ipn.ui.viewModel import android.util.Log import androidx.annotation.StringRes +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider @@ -14,10 +16,9 @@ import com.tailscale.ipn.ui.localapi.Client import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.notifier.Notifier -import com.tailscale.ipn.ui.theme.ts_color_light_desctrutive_text -import com.tailscale.ipn.ui.theme.ts_color_light_green +import com.tailscale.ipn.ui.theme.off +import com.tailscale.ipn.ui.theme.success import com.tailscale.ipn.ui.util.FeatureStateRepresentation -import com.tailscale.ipn.ui.util.LoadingIndicator import com.tailscale.ipn.ui.util.set import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -41,10 +42,7 @@ class DNSSettingsViewModel() : IpnViewModel() { R.string.use_ts_dns, type = SettingType.SWITCH, isOn = MutableStateFlow(Notifier.prefs.value?.CorpDNS), - onToggle = { - LoadingIndicator.start() - toggleCorpDNS { LoadingIndicator.stop() } - }) + onToggle = { toggleCorpDNS {} }) init { viewModelScope.launch { @@ -96,7 +94,7 @@ enum class DNSEnablementState : FeatureStateRepresentation { get() = R.string.tailscale_is_not_running_this_device_is_using_the_system_dns_resolver override val tint: Color - get() = Color.Gray + @Composable get() = MaterialTheme.colorScheme.off override val symbolDrawable: Int get() = R.drawable.xmark_circle @@ -109,7 +107,7 @@ enum class DNSEnablementState : FeatureStateRepresentation { @StringRes get() = R.string.this_device_is_using_tailscale_to_resolve_dns_names override val tint: Color - get() = ts_color_light_green + @Composable get() = MaterialTheme.colorScheme.success override val symbolDrawable: Int get() = R.drawable.check_circle @@ -122,7 +120,7 @@ enum class DNSEnablementState : FeatureStateRepresentation { @StringRes get() = R.string.this_device_is_using_the_system_dns_resolver override val tint: Color - get() = ts_color_light_desctrutive_text + @Composable get() = MaterialTheme.colorScheme.error override val symbolDrawable: Int get() = R.drawable.xmark_circle diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt index 4434dcd..11f5b0a 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt @@ -8,7 +8,6 @@ import com.tailscale.ipn.R import com.tailscale.ipn.ui.localapi.Client import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn.State -import com.tailscale.ipn.ui.model.StableNodeID import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.util.LoadingIndicator import com.tailscale.ipn.ui.util.PeerCategorizer @@ -38,10 +37,7 @@ class MainViewModel : IpnViewModel() { // The active search term for filtering peers val searchTerm: StateFlow = MutableStateFlow("") - // The peerID of the local node - val selfPeerId: StateFlow = MutableStateFlow("") - - private val peerCategorizer = PeerCategorizer(viewModelScope) + private val peerCategorizer = PeerCategorizer() val userName: String get() { @@ -57,16 +53,21 @@ class MainViewModel : IpnViewModel() { } viewModelScope.launch { - Notifier.netmap.collect { netmap -> - peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm.value)) - selfPeerId.set(netmap?.SelfNode?.StableID ?: "") + Notifier.netmap.collect { it -> + it?.let { netmap -> + peerCategorizer.regenerateGroupedPeers(netmap) + peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm.value)) + } } } + + viewModelScope.launch { + searchTerm.collect { term -> peers.set(peerCategorizer.groupedAndFilteredPeers(term)) } + } } fun searchPeers(searchTerm: String) { this.searchTerm.set(searchTerm) - viewModelScope.launch { peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm)) } } fun disableExitNode() { diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt index 372d887..a1ef0d1 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt @@ -3,18 +3,14 @@ package com.tailscale.ipn.ui.viewModel -import androidx.compose.ui.graphics.Color import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope -import com.tailscale.ipn.R -import com.tailscale.ipn.ui.model.Ipn +import com.tailscale.ipn.ui.model.Netmap import com.tailscale.ipn.ui.model.StableNodeID +import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.notifier.Notifier -import com.tailscale.ipn.ui.theme.ts_color_light_green import com.tailscale.ipn.ui.util.ComposableStringFormatter -import com.tailscale.ipn.ui.util.DisplayAddress -import com.tailscale.ipn.ui.util.TimeUtil import com.tailscale.ipn.ui.util.set import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -31,45 +27,15 @@ class PeerDetailsViewModelFactory(private val nodeId: StableNodeID, private val } class PeerDetailsViewModel(val nodeId: StableNodeID, val filesDir: File) : IpnViewModel() { - - // The peerID of the local node - val selfPeerId: StateFlow = MutableStateFlow("") - - var addresses: List = emptyList() - var info: List = emptyList() - - val nodeName: String - val connectedStrRes: Int - val connectedColor: Color + val netmap: StateFlow = MutableStateFlow(null) + val node: StateFlow = MutableStateFlow(null) init { viewModelScope.launch { - Notifier.netmap.collect { netmap -> selfPeerId.set(netmap?.SelfNode?.StableID ?: "") } + Notifier.netmap.collect { nm -> + netmap.set(nm) + nm?.getPeer(nodeId)?.let { peer -> node.set(peer) } + } } - - val peer = Notifier.netmap.value?.getPeer(nodeId) - - peer?.Addresses?.let { addresses = it.map { addr -> DisplayAddress(addr) } } - - peer?.Name?.let { addresses = listOf(DisplayAddress(it)) + addresses } - - peer?.let { p -> - info = - listOf( - PeerSettingInfo(R.string.os, ComposableStringFormatter(p.Hostinfo.OS ?: "")), - PeerSettingInfo(R.string.key_expiry, TimeUtil().keyExpiryFromGoTime(p.KeyExpiry))) - } - - nodeName = peer?.ComputedName ?: "" - - val stateVal = Notifier.state - val selfPeer = selfPeerId.value - - val isSelfAndRunning = - (peer != null && peer.StableID == selfPeer && stateVal.value == Ipn.State.Running) - connectedStrRes = - if (peer?.Online == true || isSelfAndRunning) R.string.connected else R.string.not_connected - connectedColor = - if (peer?.Online == true || isSelfAndRunning) ts_color_light_green else Color.Gray } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt index 73662fe..7a309e4 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt @@ -88,7 +88,7 @@ class SettingsViewModel(val navigation: SettingsNav) : IpnViewModel() { val about = Setting( - titleRes = R.string.about, + titleRes = R.string.about_tailscale, type = SettingType.NAV, onClick = { navigation.onNavigateToAbout() }, enabled = MutableStateFlow(true)) diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index c23fa46..ddf00d1 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -2,13 +2,13 @@ - Log In - Log Out + Log in + Log out None Connect - Unknown User + Unknown user Connected - Not Connected + Not connected %s More @@ -20,16 +20,17 @@ Tailscale Tailscale + Version Tailscale for Android Acknowledgements Privacy Policy Terms of Service WireGuard is a registered trademark of Jason A. Donenfeld.\n\n© 2024 Tailscale Inc. All rights reserved.\nTailscale is a registered trademark of Tailscale Inc. - The Tailscale App Icon - Managed By + The Tailscale app icon + Managed by - Report a Bug + Report a bug To report a bug,  contact our support team  and include the identifier below. @@ -38,37 +39,37 @@ Settings You can manage your account from the admin console.  - View admin console… - About - Bug Report + View admin console + About Tailscale + Bug report Use Tailscale DNS EXIT NODE Starting… - "Connect again to talk to the other devices in the %1$s tailnet." + "Connect again to talk to the other devices in the " + " tailnet." Welcome to Tailscale Log in to join your tailnet and connect your devices. - OS - Key Expiry - Tailscale Addresses + Key expiry + Tailscale addresses - Current MDM Settings - MDM Settings + Current MDM settings + MDM settings Managed by %1$s Your organization is managing Tailscale on this device. Some features might have been customized or hidden by your system administrator. %1$s is managing Tailscale on this device. Some features might have been customized or hidden by your system administrator. - Open Support + Open support - Loading… + Starting… -- - Login Required - Stopped + Login required + Not connected expired @@ -81,14 +82,14 @@ Accounts - Unable to logout at this time. Please try again. + Unable to logout at this time. Please try again. Error Accounts - Add Another Account - Add Account + Add another account… + Add account Reauthenticate - Unable to switch users. Please try again. - Unable to add a new profile. Please try again. + Unable to switch users. Please try again. + Unable to add a new profile. Please try again. Please enter a valid URL in the form https://server.com Invalid URL Add account using alternate server @@ -96,19 +97,19 @@ https://my.custom.server.com - Choose Exit Node - Mullvad Exit Nodes - Tailnet Exit Nodes + Choose exit node + Mullvad exit nodes + Tailnet exit nodes Mullvad VPN - Best Available - Run as Exit Node + Best available + Run as exit node Run this device as an exit node? - Other devices in your tailnet will be able to route their Internet traffic through this Android device. Make sure to approve this exit node in the admin console in order for other devices to see it. + Other devices in your tailnet will be able to route their internet traffic through this Android device. Make sure to approve this exit node in the admin console in order for other devices to see it. Caution: Running an exit node will severely impact battery life. On a metered data plan, significant cellular data charges may also apply. Always disable this feature when no longer needed. - Stop Running as Exit Node - Start Running as Exit Node - Now Running as Exit Node - Other devices in your tailnet can now route their Internet traffic through this Android device. Make sure to approve this exit node in the admin console in order for other devices to see it. + Stop running as exit node + Start running as exit node + Now running as exit node + Other devices in your tailnet can now route their internet traffic through this Android device. Make sure to approve this exit node in the admin console in order for other devices to see it. Enabled Disabled Disable @@ -122,38 +123,38 @@ This node has not been signed by another device. This node is trusted to change the Tailnet lock configuration. This node is not trusted to change the Tailnet lock configuration. - Copy to Clipboard - Node Key - Tailnet Lock Key + Copy to clipboard + Node key + Tailnet lock key Used to sign this node from another signing device in your tailnet. Used to authorize changes to the Tailnet lock configuration. - Bug Report ID - Learn more… + Bug report ID + Learn more - DNS Settings + DNS settings Using Tailscale DNS This device is using Tailscale to resolve DNS names. Resolvers - Search Domains - Not Running + Search somains + Not running Tailscale is not running. This device is using the system\'s DNS resolver. This device is using the system DNS resolver. - Not Using Tailscale DNS - Allow LAN Access + Not using Tailscale DNS + Allow LAN access exit nodes available cities available Prevents the user from disconnecting Tailscale. - Force Enabled Connection Toggle - Exit Node ID + Force enabled connection toggle + Exit node ID Forces the Tailscale client to always use the exit node with the given ID. - Managed By - Organization Name - Managed By - Caption - Managed By - URL + Managed by - organization name + Managed by - caption + Managed by - URL Shows a button to open support resources next to the organization name. Shows the given caption next to the organization name in the client. Shows the given organization name in the client. @@ -161,38 +162,36 @@ Required/Suggested Tailnet Custom control server URL Use this field to specify a custom coordination server URL, such as a Headscale instance. - Hidden Network Devices + Hidden network devices Hides the specified categories of network devices from the devices list in the client. - Allow LAN Access when using an exit node - Enable Posture Checking - Use Tailscale DNS Settings - Use Tailscale Subnets - Allow Incoming Connections - Exit Node Picker Visibility + Allow LAN access when using an exit node + Enable posture checking + Use Tailscale DNS settings + Use Tailscale subnets + Allow incoming connections + Exit node picker visibility Shows or hides the exit node picker in the main view of the app. Shows or hides the Tailnet lock configuration UI. Manage Tailnet lock visibility Shows or hides the UI to run the Android device as an exit node. - Run As Exit Node visibility + Run as exit node visibility Permissions - Permission Required + Permission required Storage - Please grant Tailscale the Storage permission in order to receive files with Taildrop. - Thank you for granting Tailscale the Storage permission. + We use storage in order to receive files with Taildrop. Notifications - Please grant Tailscale the Notifications permission in order to receive important status notifications. - Thank you for granting Tailscale the Notifications permission. + We use notifications to help you troubleshoot broken connections, or notify you before you need to reauthenticate to your network. Send via Tailscale - Unable to share files with this device. This device is not currently connected to the tailnet. + Unable to share files with this device. This device is not currently connected to the tailnet. No files to share - %1$s Files + %1$s files Connect to your tailnet to share files - My Devices + My devices There are no devices on your tailnet to share to Taildrop failed. Some files were not shared. Please try again. Sending @@ -200,15 +199,15 @@ - Logout Failed - Cannot Switch To User - Unable to Add Profile - Not Connected - Taildrop Failed + Logout failed + Cannot switch to user + Unable to add profile + Not connected + Taildrop failed Tailscale - Get Started + Get started Tailscale is a mesh VPN for securely connecting your devices. All connections are device-to-device, so we never see your data.\n\nWe collect and use your email address and name, as well as your device name, OS version, and IP address in order to help you to connect your devices and manage your settings. We log when you are connected to your network.\n