From e33dcf533cd28202dea75d59c86a946f2fd69473 Mon Sep 17 00:00:00 2001
From: beyond
Date: Sun, 30 Aug 2026 02:18:24 +0800
Subject: [PATCH] =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=99=A8=E5=88=97=E8=A1=A8?=
=?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../myapplication/data/AppDatabase.java | 11 +-
.../myapplication/data/ServerEntity.java | 11 +-
.../com/example/myapplication/MainApp.kt | 22 +-
.../example/myapplication/data/EmbyServer.kt | 85 ++++
.../myapplication/data/ServerRepository.kt | 41 ++
.../myapplication/navigation/AppNavHost.kt | 14 +-
.../myapplication/navigation/AppRoute.kt | 5 +-
.../navigation/NavigationTransitions.kt | 20 +-
.../myapplication/screens/HomeScreen.kt | 79 +---
.../myapplication/screens/LibraryScreen.kt | 426 ++++++++++++------
.../screens/SettingsChildScreens.kt | 45 +-
.../myapplication/screens/SettingsScreen.kt | 242 ++--------
.../example/myapplication/ui/AppBottomBar.kt | 64 ++-
.../example/myapplication/ui/LiquidGlass.kt | 10 +-
.../ui/liquidglass/LiquidBottomTabs.kt | 275 ++++++-----
app/src/main/res/values-en/strings.xml | 8 +
app/src/main/res/values/strings.xml | 8 +
.../data/EmbyServerAddressTest.kt | 56 +++
18 files changed, 877 insertions(+), 545 deletions(-)
create mode 100644 app/src/test/kotlin/com/example/myapplication/data/EmbyServerAddressTest.kt
diff --git a/app/src/main/java/com/example/myapplication/data/AppDatabase.java b/app/src/main/java/com/example/myapplication/data/AppDatabase.java
index 08bae06..b1c5cde 100644
--- a/app/src/main/java/com/example/myapplication/data/AppDatabase.java
+++ b/app/src/main/java/com/example/myapplication/data/AppDatabase.java
@@ -9,7 +9,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase;
@Database(
entities = {IconLibraryEntity.class, ServerEntity.class},
- version = 6,
+ version = 7,
exportSchema = false
)
public abstract class AppDatabase extends RoomDatabase {
@@ -83,6 +83,13 @@ public abstract class AppDatabase extends RoomDatabase {
}
};
+ private static final Migration MIGRATION_6_7 = new Migration(6, 7) {
+ @Override
+ public void migrate(SupportSQLiteDatabase db) {
+ db.execSQL("ALTER TABLE servers ADD COLUMN alternateAddresses TEXT");
+ }
+ };
+
public static AppDatabase getInstance(Context context) {
if (instance == null) {
synchronized (AppDatabase.class) {
@@ -91,7 +98,7 @@ public static AppDatabase getInstance(Context context) {
context.getApplicationContext(),
AppDatabase.class,
"emby_player.db"
- ).addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6).build();
+ ).addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7).build();
}
}
}
diff --git a/app/src/main/java/com/example/myapplication/data/ServerEntity.java b/app/src/main/java/com/example/myapplication/data/ServerEntity.java
index b4432e7..171221e 100644
--- a/app/src/main/java/com/example/myapplication/data/ServerEntity.java
+++ b/app/src/main/java/com/example/myapplication/data/ServerEntity.java
@@ -22,6 +22,7 @@ public class ServerEntity {
public String userId;
public String iconUrl;
public int isSelected;
+ public String alternateAddresses;
@Ignore
public ServerEntity(
@@ -36,7 +37,7 @@ public class ServerEntity {
String accessToken,
String userId
) {
- this(id, name, "https", host, basePath, port, username, password, note, accessToken, userId, "", 0);
+ this(id, name, "https", host, basePath, port, username, password, note, accessToken, userId, "", 0, "[]");
}
@Ignore
@@ -53,7 +54,7 @@ public class ServerEntity {
String userId,
String iconUrl
) {
- this(id, name, "https", host, basePath, port, username, password, note, accessToken, userId, iconUrl, 0);
+ this(id, name, "https", host, basePath, port, username, password, note, accessToken, userId, iconUrl, 0, "[]");
}
@Ignore
@@ -71,7 +72,7 @@ public class ServerEntity {
String iconUrl,
int isSelected
) {
- this(id, name, "https", host, basePath, port, username, password, note, accessToken, userId, iconUrl, isSelected);
+ this(id, name, "https", host, basePath, port, username, password, note, accessToken, userId, iconUrl, isSelected, "[]");
}
public ServerEntity(
@@ -87,7 +88,8 @@ public class ServerEntity {
String accessToken,
String userId,
String iconUrl,
- int isSelected
+ int isSelected,
+ String alternateAddresses
) {
this.id = id;
this.name = name;
@@ -102,5 +104,6 @@ public class ServerEntity {
this.userId = userId;
this.iconUrl = iconUrl == null ? "" : iconUrl;
this.isSelected = isSelected;
+ this.alternateAddresses = alternateAddresses == null ? "[]" : alternateAddresses;
}
}
diff --git a/app/src/main/kotlin/com/example/myapplication/MainApp.kt b/app/src/main/kotlin/com/example/myapplication/MainApp.kt
index 9b0214d..db7cab5 100644
--- a/app/src/main/kotlin/com/example/myapplication/MainApp.kt
+++ b/app/src/main/kotlin/com/example/myapplication/MainApp.kt
@@ -2,7 +2,9 @@ package com.example.myapplication
import android.app.Activity
import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExperimentalAnimationApi
+import androidx.compose.animation.ExitTransition
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
@@ -113,6 +115,14 @@ fun MainApp() {
}
val selectedTab = rootTab ?: lastRootTab
val showBottomBar = isRootRoute(currentRoute)
+ val isSettingsChild = currentRoute == AppRoute.CacheManagement.pattern ||
+ currentRoute == AppRoute.About.pattern
+ var lastDetailRoute by remember { mutableStateOf(null) }
+ LaunchedEffect(currentRoute, showBottomBar) {
+ if (!showBottomBar) lastDetailRoute = currentRoute
+ }
+ val returningFromSettingsChild = showBottomBar &&
+ (lastDetailRoute == AppRoute.CacheManagement.pattern || lastDetailRoute == AppRoute.About.pattern)
val glassBackdrop = rememberLumaLayerBackdrop()
val loginFailedMessage = stringResource(Res.string.error_login_failed)
val networkErrorMessage = stringResource(
@@ -223,8 +233,16 @@ fun MainApp() {
AnimatedVisibility(
visible = showBottomBar,
modifier = Modifier.align(Alignment.BottomCenter),
- enter = fadeIn(tween(180)) + slideInVertically(tween(180)) { it / 3 },
- exit = fadeOut(tween(180)) + slideOutVertically(tween(180)) { it / 3 },
+ enter = if (returningFromSettingsChild) {
+ EnterTransition.None
+ } else {
+ fadeIn(tween(180)) + slideInVertically(tween(180)) { it / 3 }
+ },
+ exit = if (isSettingsChild) {
+ ExitTransition.None
+ } else {
+ fadeOut(tween(180)) + slideOutVertically(tween(180)) { it / 3 }
+ },
) {
Box(
Modifier.fillMaxWidth().padding(bottom = 12.dp),
diff --git a/app/src/main/kotlin/com/example/myapplication/data/EmbyServer.kt b/app/src/main/kotlin/com/example/myapplication/data/EmbyServer.kt
index b26566e..3adc861 100644
--- a/app/src/main/kotlin/com/example/myapplication/data/EmbyServer.kt
+++ b/app/src/main/kotlin/com/example/myapplication/data/EmbyServer.kt
@@ -2,6 +2,7 @@ package com.example.myapplication.data
import androidx.annotation.StringRes
import com.example.myapplication.R
+import java.net.URI
import java.util.UUID
data class EmbyLibrary(
@@ -39,6 +40,7 @@ data class EmbyServer(
val iconUrl: String = "",
val authError: String = "",
val isSelected: Boolean = false,
+ val alternateAddresses: List = emptyList(),
) {
val fullUrl: String
get() {
@@ -71,3 +73,86 @@ data class EmbyServer(
fun generateId(): String = "server_${UUID.randomUUID()}"
}
}
+
+data class EmbyServerAddress(
+ val id: String = generateId(),
+ val name: String = "",
+ val protocol: String = "https",
+ val host: String = "",
+ val port: String = "",
+ val basePath: String = "",
+) {
+ val fullUrl: String
+ get() = buildServerUrl(protocol, host, port, basePath)
+
+ companion object {
+ fun generateId(): String = "address_${UUID.randomUUID()}"
+ }
+}
+
+data class ParsedServerAddress(
+ val protocol: String,
+ val host: String,
+ val port: String,
+ val basePath: String,
+)
+
+fun parseServerAddressInput(
+ input: String,
+ currentProtocol: String,
+ currentPort: String,
+ currentBasePath: String,
+): ParsedServerAddress {
+ val trimmed = input.trim()
+ if (trimmed.isEmpty()) {
+ return ParsedServerAddress(currentProtocol, "", currentPort, currentBasePath)
+ }
+
+ val schemeMatch = Regex("^(https?)://", RegexOption.IGNORE_CASE).find(trimmed)
+ val hasExplicitScheme = schemeMatch != null
+ val protocol = schemeMatch?.groupValues?.get(1)?.lowercase()
+ ?: currentProtocol.lowercase().takeIf { it == "http" } ?: "https"
+ val uriText = if (hasExplicitScheme) trimmed else "$protocol://$trimmed"
+ val uri = runCatching { URI(uriText) }.getOrNull()
+
+ var host = uri?.host.orEmpty()
+ val explicitPort = uri?.port?.takeIf { it >= 0 }?.toString()
+ var path = uri?.rawPath.orEmpty().trim('/')
+
+ if (host.isEmpty()) {
+ val withoutScheme = trimmed.replace(Regex("^(https?)://", RegexOption.IGNORE_CASE), "")
+ val authority = withoutScheme.substringBefore('/').substringBefore('?').substringBefore('#')
+ val portMatch = Regex("^(.+):(\\d+)$").matchEntire(authority)
+ host = portMatch?.groupValues?.get(1) ?: authority
+ if (path.isEmpty() && withoutScheme.contains('/')) {
+ path = withoutScheme.substringAfter('/').substringBefore('?').substringBefore('#').trim('/')
+ }
+ }
+
+ val port = when {
+ explicitPort != null -> explicitPort
+ hasExplicitScheme && protocol == "https" -> "443"
+ hasExplicitScheme -> ""
+ currentPort.isNotBlank() -> currentPort
+ protocol == "https" -> "443"
+ else -> ""
+ }
+ return ParsedServerAddress(
+ protocol = protocol,
+ host = host.trim().trimEnd('/'),
+ port = port,
+ basePath = path.ifBlank { currentBasePath.trim().trim('/') },
+ )
+}
+
+private fun buildServerUrl(protocol: String, host: String, port: String, basePath: String): String {
+ val effectiveProtocol = if (protocol.equals("http", ignoreCase = true)) "http" else "https"
+ val cleanHost = host.trim()
+ .replace(Regex("^(https?)://", RegexOption.IGNORE_CASE), "")
+ .substringBefore('/')
+ .trimEnd('/')
+ val base = if (port.isNotBlank()) "$effectiveProtocol://$cleanHost:$port" else "$effectiveProtocol://$cleanHost"
+ return listOf(base.trimEnd('/'), basePath.trim().trim('/'))
+ .filter { it.isNotBlank() }
+ .joinToString("/")
+}
diff --git a/app/src/main/kotlin/com/example/myapplication/data/ServerRepository.kt b/app/src/main/kotlin/com/example/myapplication/data/ServerRepository.kt
index 9b7149e..f06d134 100644
--- a/app/src/main/kotlin/com/example/myapplication/data/ServerRepository.kt
+++ b/app/src/main/kotlin/com/example/myapplication/data/ServerRepository.kt
@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONArray
+import org.json.JSONObject
object ServerRepository {
@@ -184,6 +185,7 @@ object ServerRepository {
server.userId,
server.iconUrl,
if (server.isSelected) 1 else 0,
+ encodeAlternateAddresses(server.alternateAddresses),
)
}
@@ -202,6 +204,45 @@ object ServerRepository {
userId = entity.userId ?: "",
iconUrl = entity.iconUrl ?: "",
isSelected = entity.isSelected == 1,
+ alternateAddresses = decodeAlternateAddresses(entity.alternateAddresses),
)
}
+
+ private fun encodeAlternateAddresses(addresses: List): String {
+ val array = JSONArray()
+ addresses.forEach { address ->
+ array.put(
+ JSONObject()
+ .put("id", address.id)
+ .put("name", address.name)
+ .put("protocol", address.protocol)
+ .put("host", address.host)
+ .put("port", address.port)
+ .put("basePath", address.basePath)
+ )
+ }
+ return array.toString()
+ }
+
+ private fun decodeAlternateAddresses(value: String?): List {
+ if (value.isNullOrBlank()) return emptyList()
+ return runCatching {
+ val array = JSONArray(value)
+ buildList {
+ for (index in 0 until array.length()) {
+ val item = array.getJSONObject(index)
+ add(
+ EmbyServerAddress(
+ id = item.optString("id").ifBlank { EmbyServerAddress.generateId() },
+ name = item.optString("name"),
+ protocol = item.optString("protocol", "https"),
+ host = item.optString("host"),
+ port = item.optString("port"),
+ basePath = item.optString("basePath"),
+ )
+ )
+ }
+ }
+ }.getOrDefault(emptyList())
+ }
}
diff --git a/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt b/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt
index cf53c60..8bc20ed 100644
--- a/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt
+++ b/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt
@@ -122,14 +122,14 @@ fun AppNavHost(
selectedServer = selectedServer,
onSelectServer = onSelectServer,
onDeleteServer = onDeleteServer,
- onUpdateServer = onUpdateServer,
+ onEditServer = { server -> navigateOnce(AppRoute.ServerEditor.create(server.id)) },
isGrid = isGrid,
onChangeIconTarget = changeIconTarget,
onDismissChangeIcon = onDismissChangeIcon,
onPickIcon = onPickIcon,
onRequestChangeIcon = onRequestChangeIcon,
onViewToggle = { isGrid = !isGrid },
- onAdd = { navigateOnce(AppRoute.AddServer.pattern) },
+ onAdd = { navigateOnce(AppRoute.ServerEditor.create()) },
onOpenIconLibraryManager = { navigateOnce(AppRoute.IconLibraryManager.pattern) },
)
}
@@ -229,10 +229,16 @@ fun AppNavHost(
AboutScreen(onBack = { navController.popBackStack() })
}
- composable(AppRoute.AddServer.pattern) {
+ composable(
+ route = AppRoute.ServerEditor.pattern,
+ arguments = listOf(navArgument("serverId") { type = NavType.StringType }),
+ ) { entry ->
+ val serverId = entry.arguments?.getString("serverId").routeArgumentOrNull()
+ val editingServer = serverId?.let { id -> servers.firstOrNull { it.id == id } }
AddServerScreen(
+ initial = editingServer,
onDismiss = { navController.popBackStack() },
- onConfirm = onAddServer,
+ onConfirm = if (serverId == null) onAddServer else onUpdateServer,
)
}
}
diff --git a/app/src/main/kotlin/com/example/myapplication/navigation/AppRoute.kt b/app/src/main/kotlin/com/example/myapplication/navigation/AppRoute.kt
index f34989f..977123b 100644
--- a/app/src/main/kotlin/com/example/myapplication/navigation/AppRoute.kt
+++ b/app/src/main/kotlin/com/example/myapplication/navigation/AppRoute.kt
@@ -22,7 +22,10 @@ sealed interface AppRoute {
data object IconLibraryManager : AppRoute { override val pattern = "library/icons" }
data object CacheManagement : AppRoute { override val pattern = "settings/cache" }
data object About : AppRoute { override val pattern = "settings/about" }
- data object AddServer : AppRoute { override val pattern = "library/add-server" }
+ data object ServerEditor : AppRoute {
+ override val pattern = "library/server/{serverId}"
+ fun create(serverId: String? = null) = "library/server/${(serverId ?: "-").encode()}"
+ }
}
private fun String.encode(): String = Uri.encode(this)
diff --git a/app/src/main/kotlin/com/example/myapplication/navigation/NavigationTransitions.kt b/app/src/main/kotlin/com/example/myapplication/navigation/NavigationTransitions.kt
index 6d1d167..41f5fa9 100644
--- a/app/src/main/kotlin/com/example/myapplication/navigation/NavigationTransitions.kt
+++ b/app/src/main/kotlin/com/example/myapplication/navigation/NavigationTransitions.kt
@@ -62,14 +62,26 @@ fun AnimatedContentTransitionScope.appExitTransition(): ExitT
}
}
-fun AnimatedContentTransitionScope.appPopEnterTransition(): EnterTransition =
- slideInHorizontally(
+fun AnimatedContentTransitionScope.appPopEnterTransition(): EnterTransition {
+ if (isReturningFromSettingsChild()) return EnterTransition.None
+ return slideInHorizontally(
animationSpec = tween(NavigationMotionTokens.detailPopDuration, easing = NavigationMotionTokens.popEasing),
) { width -> -(width * NavigationMotionTokens.detailParallax).toInt() } +
fadeIn(tween(220))
+}
-fun AnimatedContentTransitionScope.appPopExitTransition(): ExitTransition =
- slideOutHorizontally(
+fun AnimatedContentTransitionScope.appPopExitTransition(): ExitTransition {
+ if (isReturningFromSettingsChild()) return ExitTransition.None
+ return slideOutHorizontally(
animationSpec = tween(NavigationMotionTokens.detailPopDuration, easing = NavigationMotionTokens.popEasing),
) { width -> width } +
fadeOut(tween(220))
+}
+
+private fun AnimatedContentTransitionScope.isReturningFromSettingsChild(): Boolean {
+ if (targetState.destination.route != AppRoute.Settings.pattern) return false
+ return when (initialState.destination.route) {
+ AppRoute.CacheManagement.pattern, AppRoute.About.pattern -> true
+ else -> false
+ }
+}
diff --git a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt
index 31dbc2a..e513115 100644
--- a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt
+++ b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt
@@ -20,7 +20,6 @@ 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.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
@@ -622,62 +621,28 @@ private fun ResourceStatsDialog(
surfaceTint = dialogTint,
blurRadius = 22.dp,
) {
- Box(Modifier.fillMaxWidth()) {
- Column(
- modifier = Modifier.fillMaxWidth(),
- verticalArrangement = Arrangement.spacedBy(16.dp),
- ) {
- Text(
- text = stringResource(Res.string.resource_stats_title),
- modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
- color = GlassTokens.text,
- fontSize = 20.sp,
- fontWeight = FontWeight.Bold,
- textAlign = TextAlign.Center,
- )
- ResourceStatsGroup(
- title = stringResource(Res.string.resource_with_versions),
- labels = labels,
- values = stats.withVersions.run { listOf(movieCount, seriesCount, episodeCount) },
- )
- ResourceStatsGroup(
- title = stringResource(Res.string.resource_without_versions),
- labels = labels,
- values = stats.withoutVersions.run { listOf(movieCount, seriesCount, episodeCount) },
- )
- }
-
- val closeInteraction = remember { MutableInteractionSource() }
- Box(
- Modifier
- .align(Alignment.TopEnd)
- .offset(x = 10.dp, y = (-10).dp)
- .size(40.dp)
- .lumaGlass(
- backdrop = backdrop,
- radius = 20.dp,
- level = LumaGlassLevel.Interactive,
- tint = if (GlassTokens.isDark) {
- Color(0xFF334252).copy(alpha = 0.48f)
- } else {
- Color.White.copy(alpha = 0.36f)
- },
- shadowElevation = 4.dp,
- )
- .clickable(
- interactionSource = closeInteraction,
- indication = null,
- onClick = onDismiss,
- ),
- contentAlignment = Alignment.Center,
- ) {
- LumaIconView(
- icon = LumaIcon.Close,
- contentDescription = stringResource(Res.string.resource_close),
- tint = GlassTokens.text,
- modifier = Modifier.size(17.dp),
- )
- }
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Text(
+ text = stringResource(Res.string.resource_stats_title),
+ modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
+ color = GlassTokens.text,
+ fontSize = 20.sp,
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center,
+ )
+ ResourceStatsGroup(
+ title = stringResource(Res.string.resource_with_versions),
+ labels = labels,
+ values = stats.withVersions.run { listOf(movieCount, seriesCount, episodeCount) },
+ )
+ ResourceStatsGroup(
+ title = stringResource(Res.string.resource_without_versions),
+ labels = labels,
+ values = stats.withoutVersions.run { listOf(movieCount, seriesCount, episodeCount) },
+ )
}
}
}
diff --git a/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt
index 6f0d90e..034b512 100644
--- a/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt
+++ b/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt
@@ -60,6 +60,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
@@ -71,6 +72,7 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.window.Dialog
@@ -83,9 +85,11 @@ import androidx.compose.ui.res.stringResource
import com.example.myapplication.R as Res
import top.yukonga.miuix.kmp.basic.Text
import com.example.myapplication.data.EmbyServer
+import com.example.myapplication.data.EmbyServerAddress
import com.example.myapplication.data.IconItem
import com.example.myapplication.data.IconLibrary
import com.example.myapplication.data.IconLibraryRepository
+import com.example.myapplication.data.parseServerAddressInput
import com.example.myapplication.ui.GlassSurface
import com.example.myapplication.ui.GlassTokens
import com.example.myapplication.ui.LocalGlassBackdrop
@@ -173,7 +177,11 @@ private fun ServerCard(server: EmbyServer, tint: Color, onClick: () -> Unit, onC
role = Role.Button,
) { clicked = true; onClick() },
cornerRadius = 20.dp,
+ fill = Color.Transparent,
+ border = Color.Unspecified,
+ shadowElevation = 0.dp,
padding = PaddingValues(0.dp),
+ highlight = false,
) {
Row(modifier = Modifier.fillMaxWidth().background(pressBg).padding(14.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
@@ -241,7 +249,11 @@ private fun ServerGridCard(server: EmbyServer, tint: Color, onClick: () -> Unit,
role = Role.Button,
) { clicked = true; onClick() },
cornerRadius = 20.dp,
+ fill = Color.Transparent,
+ border = Color.Unspecified,
+ shadowElevation = 0.dp,
padding = PaddingValues(0.dp),
+ highlight = false,
) {
Column(modifier = Modifier.fillMaxWidth().background(pressBg).padding(14.dp), horizontalAlignment = Alignment.Start) {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -319,7 +331,7 @@ fun LibraryScreen(
selectedServer: EmbyServer? = null,
onSelectServer: (String) -> Unit = {},
onDeleteServer: (String) -> Unit = {},
- onUpdateServer: (EmbyServer, (String?) -> Unit) -> Unit = { _, _ -> },
+ onEditServer: (EmbyServer) -> Unit = {},
isGrid: Boolean = false,
onChangeIconTarget: EmbyServer? = null,
onDismissChangeIcon: () -> Unit = {},
@@ -330,7 +342,6 @@ fun LibraryScreen(
onOpenIconLibraryManager: () -> Unit = {},
showTopBar: Boolean = true,
) {
- var editingServer by remember { mutableStateOf(null) }
var popupServer by remember { mutableStateOf(null) }
var popupAnchor by remember { mutableStateOf(null) }
var popupVisible by remember { mutableStateOf(false) }
@@ -338,16 +349,6 @@ fun LibraryScreen(
var rootOrigin by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
val libraryBackdrop = rememberLumaLayerBackdrop()
- if (editingServer != null) {
- AddServerDialog(
- backdrop = libraryBackdrop,
- initial = editingServer,
- onDismiss = { editingServer = null },
- onConfirm = { updated, onResult ->
- onUpdateServer(updated, onResult)
- },
- )
- }
if (onChangeIconTarget != null) {
IconPickerDialog(
server = onChangeIconTarget,
@@ -384,10 +385,10 @@ fun LibraryScreen(
} else {
LazyVerticalGrid(
columns = if (isGrid) GridCells.Adaptive(minSize = 150.dp) else GridCells.Fixed(1),
- modifier = Modifier.fillMaxSize().statusBarsPadding().padding(horizontal = if (isGrid) 16.dp else 0.dp, vertical = 0.dp).padding(top = 78.dp),
+ modifier = Modifier.fillMaxSize().statusBarsPadding().padding(horizontal = 16.dp).padding(top = 78.dp),
contentPadding = PaddingValues(bottom = bottomBarContentPadding(86.dp).calculateBottomPadding()),
verticalArrangement = Arrangement.spacedBy(if (isGrid) 12.dp else 10.dp),
- horizontalArrangement = Arrangement.spacedBy(if (isGrid) 12.dp else 0.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
// Servers section
items(servers.size) { index ->
@@ -399,7 +400,7 @@ fun LibraryScreen(
onClick = { onSelectServer(server.id) },
onChangeIcon = { onRequestChangeIcon(server) },
onDelete = { onDeleteServer(server.id) },
- onEdit = { editingServer = server },
+ onEdit = { onEditServer(server) },
onMorePositioned = { coordinates ->
if (popupServer?.id == server.id) popupAnchor = coordinates.boundsInRoot()
},
@@ -415,7 +416,7 @@ fun LibraryScreen(
onClick = { onSelectServer(server.id) },
onChangeIcon = { onRequestChangeIcon(server) },
onDelete = { onDeleteServer(server.id) },
- onEdit = { editingServer = server },
+ onEdit = { onEditServer(server) },
onMorePositioned = { coordinates ->
if (popupServer?.id == server.id) popupAnchor = coordinates.boundsInRoot()
},
@@ -456,7 +457,7 @@ fun LibraryScreen(
GlassPopupItem(
text = stringResource(Res.string.server_edit),
icon = LumaIcon.Edit,
- onClick = { popupVisible = false; editingServer = currentPopupServer },
+ onClick = { popupVisible = false; onEditServer(currentPopupServer) },
)
GlassPopupItem(
text = stringResource(Res.string.change_icon_action),
@@ -593,7 +594,6 @@ internal fun IconLibraryManagerScreenContent(
item(span = { GridItemSpan(2) }) {
Box(
Modifier.fillMaxWidth()
- .lumaGlass(radius = LumaShapes.large, level = com.example.myapplication.ui.design.LumaGlassLevel.Regular)
.padding(vertical = 54.dp),
contentAlignment = Alignment.Center,
) {
@@ -662,7 +662,14 @@ internal fun IconLibraryManagerScreenContent(
private fun IconLibraryManagerCard(library: IconLibrary, onClick: () -> Unit) {
Box(
Modifier.fillMaxWidth()
- .lumaGlass(radius = LumaShapes.large, level = com.example.myapplication.ui.design.LumaGlassLevel.Regular)
+ .lumaGlass(
+ radius = LumaShapes.large,
+ level = com.example.myapplication.ui.design.LumaGlassLevel.Clear,
+ tint = Color.Transparent,
+ border = false,
+ shadowElevation = 0.dp,
+ highlight = false,
+ )
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
@@ -1276,6 +1283,7 @@ private fun IconPickerDialog(
@Composable
fun AddServerScreen(
+ initial: EmbyServer? = null,
onDismiss: () -> Unit,
onConfirm: (EmbyServer, (String?) -> Unit) -> Unit,
) {
@@ -1295,61 +1303,78 @@ fun AddServerScreen(
LumaIconView(LumaIcon.Back, contentDescription = stringResource(Res.string.back), tint = GlassTokens.text, modifier = Modifier.size(20.dp))
}
Spacer(Modifier.width(12.dp))
- Text(stringResource(Res.string.add_server), color = GlassTokens.text, fontSize = 22.sp, fontWeight = FontWeight.Bold)
+ Text(
+ stringResource(if (initial == null) Res.string.add_server else Res.string.edit_server),
+ color = GlassTokens.text,
+ fontSize = 22.sp,
+ fontWeight = FontWeight.Bold,
+ )
}
ServerFormContent(
- initial = null,
+ initial = initial,
onDismiss = onDismiss,
onConfirm = onConfirm,
- useGlass = false,
- showHeader = false,
modifier = Modifier.fillMaxWidth().weight(1f).verticalScroll(rememberScrollState())
.navigationBarsPadding().padding(horizontal = 18.dp, vertical = 8.dp),
)
}
}
-@Composable
-private fun AddServerDialog(
- backdrop: Backdrop? = null,
- initial: EmbyServer? = null,
- onDismiss: () -> Unit,
- onConfirm: (EmbyServer, (String?) -> Unit) -> Unit,
-) {
- GlassDialog(backdrop = backdrop, onDismissRequest = onDismiss) {
- ServerFormContent(
- initial = initial,
- onDismiss = onDismiss,
- onConfirm = onConfirm,
- useGlass = true,
- showHeader = true,
- modifier = Modifier.width(300.dp).verticalScroll(rememberScrollState()),
- )
- }
-}
+private data class AlternateAddressForm(
+ val id: String,
+ val name: TextFieldValue,
+ val host: TextFieldValue,
+ val port: TextFieldValue,
+ val basePath: TextFieldValue,
+ val useHttps: Boolean,
+)
+
+private fun String.toEndSelectedFieldValue(): TextFieldValue =
+ TextFieldValue(this, selection = TextRange(length))
+
+private fun EmbyServerAddress.toForm(): AlternateAddressForm = AlternateAddressForm(
+ id = id,
+ name = name.toEndSelectedFieldValue(),
+ host = host.toEndSelectedFieldValue(),
+ port = port.toEndSelectedFieldValue(),
+ basePath = basePath.toEndSelectedFieldValue(),
+ useHttps = protocol.equals("https", ignoreCase = true),
+)
@Composable
private fun ServerFormContent(
initial: EmbyServer?,
onDismiss: () -> Unit,
onConfirm: (EmbyServer, (String?) -> Unit) -> Unit,
- useGlass: Boolean,
- showHeader: Boolean,
modifier: Modifier = Modifier,
) {
- var name by remember { mutableStateOf(TextFieldValue(initial?.name ?: "")) }
- var host by remember { mutableStateOf(TextFieldValue(initial?.host ?: "")) }
- var port by remember { mutableStateOf(TextFieldValue(initial?.port ?: "")) }
- var basePath by remember { mutableStateOf(TextFieldValue(initial?.basePath ?: "")) }
- var username by remember { mutableStateOf(TextFieldValue(initial?.username ?: "")) }
- var password by remember { mutableStateOf(TextFieldValue(initial?.password ?: "")) }
- var note by remember { mutableStateOf(TextFieldValue(initial?.note ?: "")) }
+ val initialAddress = remember(initial?.id, initial?.host, initial?.protocol, initial?.port, initial?.basePath) {
+ initial?.let {
+ parseServerAddressInput(
+ input = it.host,
+ currentProtocol = it.protocol,
+ currentPort = it.port,
+ currentBasePath = it.basePath,
+ )
+ }
+ }
+ var name by remember(initial?.id) { mutableStateOf(TextFieldValue(initial?.name ?: "")) }
+ var host by remember(initial?.id) { mutableStateOf(TextFieldValue(initialAddress?.host ?: "")) }
+ var port by remember(initial?.id) { mutableStateOf(TextFieldValue(initialAddress?.port ?: "")) }
+ var basePath by remember(initial?.id) { mutableStateOf(TextFieldValue(initialAddress?.basePath ?: "")) }
+ var username by remember(initial?.id) { mutableStateOf(TextFieldValue(initial?.username ?: "")) }
+ var password by remember(initial?.id) { mutableStateOf(TextFieldValue(initial?.password ?: "")) }
+ var note by remember(initial?.id) { mutableStateOf(TextFieldValue(initial?.note ?: "")) }
+ var alternateAddresses by remember(initial?.id) {
+ mutableStateOf(initial?.alternateAddresses.orEmpty().map(EmbyServerAddress::toForm))
+ }
var isSaving by remember { mutableStateOf(false) }
var authError by remember { mutableStateOf("") }
var showPassword by remember { mutableStateOf(false) }
var showValidation by remember { mutableStateOf(false) }
- var useHttps by remember { mutableStateOf(initial?.protocol?.equals("https", ignoreCase = true) ?: true) }
+ var useHttps by remember(initial?.id) { mutableStateOf(initialAddress?.protocol?.equals("https", ignoreCase = true) ?: true) }
val justNowLabel = stringResource(Res.string.server_just_now)
+ val alternateIncompleteLabel = stringResource(Res.string.server_alternate_incomplete)
val hostFocusRequester = remember { FocusRequester() }
val portFocusRequester = remember { FocusRequester() }
val usernameFocusRequester = remember { FocusRequester() }
@@ -1357,22 +1382,65 @@ private fun ServerFormContent(
val portError = showValidation && port.text.trim().isEmpty()
val usernameError = showValidation && username.text.trim().isEmpty()
+ fun normalizePrimaryAddress() = parseServerAddressInput(
+ input = host.text,
+ currentProtocol = if (useHttps) "https" else "http",
+ currentPort = port.text,
+ currentBasePath = basePath.text,
+ ).also { parsed ->
+ host = parsed.host.toEndSelectedFieldValue()
+ port = parsed.port.toEndSelectedFieldValue()
+ basePath = parsed.basePath.toEndSelectedFieldValue()
+ useHttps = parsed.protocol == "https"
+ }
+
+ fun normalizedAlternateAddresses(): List = alternateAddresses.map { address ->
+ val parsed = parseServerAddressInput(
+ input = address.host.text,
+ currentProtocol = if (address.useHttps) "https" else "http",
+ currentPort = address.port.text,
+ currentBasePath = address.basePath.text,
+ )
+ address.copy(
+ host = parsed.host.toEndSelectedFieldValue(),
+ port = parsed.port.toEndSelectedFieldValue(),
+ basePath = parsed.basePath.toEndSelectedFieldValue(),
+ useHttps = parsed.protocol == "https",
+ )
+ }
+
fun submit() {
if (isSaving) return
authError = ""
showValidation = true
+ val primaryAddress = normalizePrimaryAddress()
+ val normalizedAlternates = normalizedAlternateAddresses()
+ alternateAddresses = normalizedAlternates
when {
- host.text.trim().isEmpty() -> runCatching { hostFocusRequester.requestFocus() }
- port.text.trim().isEmpty() -> runCatching { portFocusRequester.requestFocus() }
+ primaryAddress.host.isEmpty() -> runCatching { hostFocusRequester.requestFocus() }
+ primaryAddress.port.isEmpty() -> runCatching { portFocusRequester.requestFocus() }
username.text.trim().isEmpty() -> runCatching { usernameFocusRequester.requestFocus() }
+ normalizedAlternates.any { it.name.text.isBlank() || it.host.text.isBlank() || it.port.text.isBlank() } -> {
+ authError = alternateIncompleteLabel
+ }
else -> {
isSaving = true
val server = (initial ?: EmbyServer(id = EmbyServer.generateId())).copy(
- name = name.text.trim(), host = host.text.trim(), port = port.text.trim(),
- protocol = if (useHttps) "https" else "http",
- basePath = basePath.text.trim().trim('/'),
+ name = name.text.trim(), host = primaryAddress.host, port = primaryAddress.port,
+ protocol = primaryAddress.protocol,
+ basePath = primaryAddress.basePath,
username = username.text.trim(), password = password.text.trim(), note = note.text.trim(),
isOnline = initial?.isOnline ?: true, lastActive = initial?.lastActive ?: justNowLabel,
+ alternateAddresses = normalizedAlternates.map { address ->
+ EmbyServerAddress(
+ id = address.id,
+ name = address.name.text.trim(),
+ protocol = if (address.useHttps) "https" else "http",
+ host = address.host.text.trim(),
+ port = address.port.text.trim(),
+ basePath = address.basePath.text.trim().trim('/'),
+ )
+ },
)
onConfirm(server) { error ->
isSaving = false
@@ -1383,26 +1451,13 @@ private fun ServerFormContent(
}
Column(modifier, verticalArrangement = Arrangement.spacedBy(12.dp)) {
- if (showHeader) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Box(
- Modifier.size(36.dp).clip(RoundedCornerShape(10.dp))
- .background(Brush.linearGradient(listOf(Color(0xFF6EA8FF), Color(0xFF9B6BFF)))),
- contentAlignment = Alignment.Center,
- ) {
- LumaIconView(LumaIcon.Add, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp))
- }
- Spacer(Modifier.width(10.dp))
- Text(text = stringResource(if (initial == null) Res.string.add_server else Res.string.edit_server), fontWeight = FontWeight.Bold, fontSize = 18.sp, color = GlassTokens.text)
- }
- }
- ServerFormInput(useGlass = useGlass, label = stringResource(Res.string.server_name_label), value = name, placeholder = stringResource(Res.string.server_name_placeholder), onValueChange = { name = it })
+ ServerFormInput(label = stringResource(Res.string.server_name_label), value = name, placeholder = stringResource(Res.string.server_name_placeholder), onValueChange = { name = it })
ServerFormInput(
- useGlass = useGlass,
label = stringResource(Res.string.server_url_label),
value = host,
placeholder = "emby.example.com",
onValueChange = { host = it },
+ onFocusLost = { normalizePrimaryAddress() },
isError = hostError,
errorMessage = if (hostError) stringResource(Res.string.server_url_error) else null,
focusRequester = hostFocusRequester,
@@ -1416,11 +1471,16 @@ private fun ServerFormContent(
fontSize = 12.sp,
)
}
- ProtocolSwitch(checked = useHttps, onCheckedChange = { useHttps = it })
+ ProtocolSwitch(
+ checked = useHttps,
+ onCheckedChange = {
+ useHttps = it
+ if (it && port.text.isBlank()) port = "443".toEndSelectedFieldValue()
+ },
+ )
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
ServerFormInput(
- useGlass = useGlass,
label = stringResource(Res.string.server_path_label),
value = basePath,
placeholder = "/emby",
@@ -1428,7 +1488,6 @@ private fun ServerFormContent(
modifier = Modifier.weight(1f),
)
ServerFormInput(
- useGlass = useGlass,
label = stringResource(Res.string.server_port_label),
value = port,
placeholder = "8096",
@@ -1439,8 +1498,137 @@ private fun ServerFormContent(
focusRequester = portFocusRequester,
)
}
+ Text(
+ text = stringResource(Res.string.server_alternate_title),
+ color = GlassTokens.text,
+ fontSize = 16.sp,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.padding(top = 4.dp),
+ )
+ Text(
+ text = stringResource(Res.string.server_alternate_subtitle),
+ color = GlassTokens.subtext,
+ fontSize = 12.sp,
+ )
+ alternateAddresses.forEachIndexed { index, address ->
+ Column(
+ Modifier.fillMaxWidth()
+ .clip(RoundedCornerShape(LumaShapes.medium))
+ .border(0.8.dp, LumaColors.glassBorder, RoundedCornerShape(LumaShapes.medium))
+ .padding(12.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = stringResource(Res.string.server_alternate_item, index + 1),
+ color = GlassTokens.text,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.weight(1f),
+ )
+ Box(
+ Modifier.size(40.dp).clickable(
+ indication = null,
+ interactionSource = remember { MutableInteractionSource() },
+ ) {
+ alternateAddresses = alternateAddresses.filterNot { it.id == address.id }
+ },
+ contentAlignment = Alignment.Center,
+ ) {
+ LumaIconView(
+ LumaIcon.Delete,
+ contentDescription = stringResource(Res.string.server_alternate_delete),
+ tint = offlineRed,
+ modifier = Modifier.size(20.dp),
+ )
+ }
+ }
+ ServerFormInput(
+ label = stringResource(Res.string.server_alternate_name),
+ value = address.name,
+ placeholder = stringResource(Res.string.server_alternate_name_placeholder),
+ onValueChange = { value ->
+ alternateAddresses = alternateAddresses.map { if (it.id == address.id) it.copy(name = value) else it }
+ },
+ )
+ ServerFormInput(
+ label = stringResource(Res.string.server_url_label),
+ value = address.host,
+ placeholder = "backup.example.com",
+ onValueChange = { value ->
+ alternateAddresses = alternateAddresses.map { if (it.id == address.id) it.copy(host = value) else it }
+ },
+ onFocusLost = {
+ alternateAddresses = alternateAddresses.map {
+ if (it.id != address.id) return@map it
+ val parsed = parseServerAddressInput(
+ input = it.host.text,
+ currentProtocol = if (it.useHttps) "https" else "http",
+ currentPort = it.port.text,
+ currentBasePath = it.basePath.text,
+ )
+ it.copy(
+ host = parsed.host.toEndSelectedFieldValue(),
+ port = parsed.port.toEndSelectedFieldValue(),
+ basePath = parsed.basePath.toEndSelectedFieldValue(),
+ useHttps = parsed.protocol == "https",
+ )
+ }
+ },
+ )
+ Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ Column(Modifier.weight(1f)) {
+ Text(stringResource(Res.string.server_protocol), color = GlassTokens.text, fontSize = 15.sp, fontWeight = FontWeight.Medium)
+ Text(
+ stringResource(if (address.useHttps) Res.string.server_protocol_https_sub else Res.string.server_protocol_http_sub),
+ color = GlassTokens.subtext,
+ fontSize = 12.sp,
+ )
+ }
+ ProtocolSwitch(
+ checked = address.useHttps,
+ onCheckedChange = { checked ->
+ alternateAddresses = alternateAddresses.map {
+ if (it.id == address.id) {
+ it.copy(
+ useHttps = checked,
+ port = if (checked && it.port.text.isBlank()) "443".toEndSelectedFieldValue() else it.port,
+ )
+ } else it
+ }
+ },
+ )
+ }
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ ServerFormInput(
+ label = stringResource(Res.string.server_path_label),
+ value = address.basePath,
+ placeholder = "/emby",
+ onValueChange = { value ->
+ alternateAddresses = alternateAddresses.map { if (it.id == address.id) it.copy(basePath = value) else it }
+ },
+ modifier = Modifier.weight(1f),
+ )
+ ServerFormInput(
+ label = stringResource(Res.string.server_port_label),
+ value = address.port,
+ placeholder = "443",
+ onValueChange = { value ->
+ alternateAddresses = alternateAddresses.map { if (it.id == address.id) it.copy(port = value) else it }
+ },
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+ }
+ PlainButton(
+ text = stringResource(Res.string.server_alternate_add),
+ onClick = {
+ alternateAddresses = alternateAddresses + EmbyServerAddress(port = "443").toForm()
+ },
+ modifier = Modifier.fillMaxWidth(),
+ )
ServerFormInput(
- useGlass = useGlass,
label = stringResource(Res.string.server_username_label),
value = username,
placeholder = stringResource(Res.string.server_username_placeholder),
@@ -1450,7 +1638,6 @@ private fun ServerFormContent(
focusRequester = usernameFocusRequester,
)
ServerFormInput(
- useGlass = useGlass,
label = stringResource(Res.string.server_password_label),
value = password,
placeholder = stringResource(Res.string.server_password_placeholder),
@@ -1475,41 +1662,30 @@ private fun ServerFormContent(
}
},
)
- ServerFormInput(useGlass = useGlass, label = stringResource(Res.string.server_note_label), value = note, placeholder = stringResource(Res.string.server_note_placeholder), onValueChange = { note = it })
+ ServerFormInput(label = stringResource(Res.string.server_note_label), value = note, placeholder = stringResource(Res.string.server_note_placeholder), onValueChange = { note = it })
if (authError.isNotBlank()) {
Text(text = authError, color = offlineRed, fontSize = 13.sp, modifier = Modifier.padding(bottom = 4.dp))
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
- if (useGlass) {
- GlassButton(text = stringResource(Res.string.server_cancel), onClick = onDismiss, modifier = Modifier.weight(1f))
- GlassButton(
- text = if (isSaving) stringResource(Res.string.server_saving) else stringResource(Res.string.server_save),
- onClick = { submit() },
- modifier = Modifier.weight(1f),
- accent = true,
- enabled = !isSaving,
- )
- } else {
- PlainButton(text = stringResource(Res.string.server_cancel), onClick = onDismiss, modifier = Modifier.weight(1f))
- PlainButton(
- text = if (isSaving) stringResource(Res.string.server_saving) else stringResource(Res.string.server_save),
- onClick = { submit() },
- modifier = Modifier.weight(1f),
- accent = true,
- enabled = !isSaving,
- )
- }
+ PlainButton(text = stringResource(Res.string.server_cancel), onClick = onDismiss, modifier = Modifier.weight(1f))
+ PlainButton(
+ text = if (isSaving) stringResource(Res.string.server_saving) else stringResource(Res.string.server_save),
+ onClick = { submit() },
+ modifier = Modifier.weight(1f),
+ accent = true,
+ enabled = !isSaving,
+ )
}
}
}
@Composable
private fun ServerFormInput(
- useGlass: Boolean,
label: String,
value: TextFieldValue,
placeholder: String,
onValueChange: (TextFieldValue) -> Unit,
+ onFocusLost: () -> Unit = {},
modifier: Modifier = Modifier,
visualTransformation: VisualTransformation = VisualTransformation.None,
trailingContent: (@Composable () -> Unit)? = null,
@@ -1517,33 +1693,19 @@ private fun ServerFormInput(
errorMessage: String? = null,
focusRequester: FocusRequester? = null,
) {
- if (useGlass) {
- GlassInputField(
- label = label,
- value = value,
- placeholder = placeholder,
- onValueChange = onValueChange,
- modifier = modifier,
- visualTransformation = visualTransformation,
- trailingContent = trailingContent,
- isError = isError,
- errorMessage = errorMessage,
- focusRequester = focusRequester,
- )
- } else {
- PlainInputField(
- label = label,
- value = value,
- placeholder = placeholder,
- onValueChange = onValueChange,
- modifier = modifier,
- visualTransformation = visualTransformation,
- trailingContent = trailingContent,
- isError = isError,
- errorMessage = errorMessage,
- focusRequester = focusRequester,
- )
- }
+ PlainInputField(
+ label = label,
+ value = value,
+ placeholder = placeholder,
+ onValueChange = onValueChange,
+ onFocusLost = onFocusLost,
+ modifier = modifier,
+ visualTransformation = visualTransformation,
+ trailingContent = trailingContent,
+ isError = isError,
+ errorMessage = errorMessage,
+ focusRequester = focusRequester,
+ )
}
@Composable
@@ -1552,6 +1714,7 @@ private fun PlainInputField(
value: TextFieldValue,
placeholder: String,
onValueChange: (TextFieldValue) -> Unit,
+ onFocusLost: () -> Unit = {},
modifier: Modifier = Modifier,
visualTransformation: VisualTransformation = VisualTransformation.None,
trailingContent: (@Composable () -> Unit)? = null,
@@ -1561,23 +1724,30 @@ private fun PlainInputField(
) {
val shape = RoundedCornerShape(LumaShapes.small)
val fieldBackground = LumaColors.backgroundElevated
+ var hadFocus by remember { mutableStateOf(false) }
Column(modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(label, fontSize = 13.sp, color = GlassTokens.subtext, fontWeight = FontWeight.Medium)
Box(
- Modifier.fillMaxWidth().heightIn(min = 48.dp).clip(shape).background(fieldBackground)
+ Modifier.fillMaxWidth().height(48.dp).clip(shape).background(fieldBackground)
.border(0.8.dp, if (isError) GlassTokens.danger else LumaColors.glassBorder, shape)
- .padding(horizontal = 14.dp, vertical = 12.dp),
+ .padding(horizontal = 14.dp),
+ contentAlignment = Alignment.Center,
) {
BasicTextField(
value = value,
onValueChange = onValueChange,
singleLine = true,
textStyle = androidx.compose.ui.text.TextStyle(color = GlassTokens.text, fontSize = 15.sp),
- modifier = Modifier.fillMaxWidth().then(focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier),
+ modifier = Modifier.fillMaxWidth()
+ .then(focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier)
+ .onFocusChanged { focusState ->
+ if (hadFocus && !focusState.isFocused) onFocusLost()
+ hadFocus = focusState.isFocused
+ },
visualTransformation = visualTransformation,
decorationBox = { inner ->
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
- Box(Modifier.weight(1f)) {
+ Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) {
if (value.text.isEmpty()) Text(placeholder, color = GlassTokens.subtext2, fontSize = 15.sp)
inner()
}
diff --git a/app/src/main/kotlin/com/example/myapplication/screens/SettingsChildScreens.kt b/app/src/main/kotlin/com/example/myapplication/screens/SettingsChildScreens.kt
index ad76bc3..db17079 100644
--- a/app/src/main/kotlin/com/example/myapplication/screens/SettingsChildScreens.kt
+++ b/app/src/main/kotlin/com/example/myapplication/screens/SettingsChildScreens.kt
@@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -30,6 +29,7 @@ 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.unit.Dp
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
@@ -42,34 +42,25 @@ import com.example.myapplication.ui.GlassConfirmDialog
import com.example.myapplication.ui.GlassTokens
import com.example.myapplication.ui.LocalGlassBackdrop
import com.example.myapplication.ui.design.LumaColors
-import com.example.myapplication.ui.design.LumaGlassRuntime
-import com.example.myapplication.ui.design.rememberLumaLayerBackdrop
import com.example.myapplication.ui.design.LumaIcon
import com.example.myapplication.ui.design.LumaIconView
-import com.example.myapplication.ui.design.LumaGlassLevel
import com.example.myapplication.ui.design.LumaPageTopBar
-import com.example.myapplication.ui.design.lumaGlass
import com.example.myapplication.ui.design.LumaShapes
import com.example.myapplication.ui.design.LumaSpacing
import com.example.myapplication.ui.design.LumaTypography
import top.yukonga.miuix.kmp.basic.Text
-import com.kyant.backdrop.backdrops.layerBackdrop
@Composable
fun CacheManagementScreen(onBack: () -> Unit) {
var cacheSize by remember { mutableLongStateOf(IconLibraryRepository.getIconCacheSize()) }
var showClearCacheConfirm by remember { mutableStateOf(false) }
- val cacheBackdrop = rememberLumaLayerBackdrop()
Box(Modifier.fillMaxSize()) {
Box(
Modifier
.fillMaxSize()
- .background(LumaColors.background)
- .then(
- if (cacheBackdrop != null) Modifier.layerBackdrop(cacheBackdrop) else Modifier,
- ),
+ .background(LumaColors.background),
)
- CompositionLocalProvider(LocalGlassBackdrop provides cacheBackdrop) {
+ CompositionLocalProvider(LocalGlassBackdrop provides null) {
Column(Modifier.fillMaxSize()) {
LumaPageTopBar(
title = stringResource(R.string.cache_detail_title),
@@ -92,7 +83,7 @@ fun CacheManagementScreen(onBack: () -> Unit) {
Box(
Modifier
.fillMaxWidth()
- .lumaGlass(radius = LumaShapes.medium, level = LumaGlassLevel.Regular)
+ .settingsModuleSurface(radius = LumaShapes.medium)
.clickable { showClearCacheConfirm = true }
.padding(horizontal = 16.dp, vertical = 15.dp),
contentAlignment = Alignment.CenterStart,
@@ -113,7 +104,7 @@ fun CacheManagementScreen(onBack: () -> Unit) {
message = stringResource(R.string.confirm_clear_cache_message),
confirmText = stringResource(R.string.confirm_clear),
dismissText = stringResource(R.string.confirm_cancel),
- backdrop = cacheBackdrop,
+ backdrop = null,
onDismissRequest = { showClearCacheConfirm = false },
onConfirm = {
IconLibraryRepository.clearAllIconCache()
@@ -130,7 +121,7 @@ private fun CacheInfoCard(title: String, value: String, icon: LumaIcon) {
Row(
Modifier
.fillMaxWidth()
- .lumaGlass(radius = LumaShapes.medium, level = LumaGlassLevel.Regular)
+ .settingsModuleSurface(radius = LumaShapes.medium)
.padding(horizontal = 16.dp, vertical = 15.dp),
verticalAlignment = Alignment.CenterVertically,
) {
@@ -152,17 +143,13 @@ private fun CacheInfoCard(title: String, value: String, icon: LumaIcon) {
@Composable
fun AboutScreen(onBack: () -> Unit) {
- val aboutBackdrop = rememberLumaLayerBackdrop()
Box(Modifier.fillMaxSize()) {
Box(
Modifier
.fillMaxSize()
- .background(LumaColors.background)
- .then(
- if (aboutBackdrop != null) Modifier.layerBackdrop(aboutBackdrop) else Modifier,
- ),
+ .background(LumaColors.background),
)
- CompositionLocalProvider(LocalGlassBackdrop provides aboutBackdrop) {
+ CompositionLocalProvider(LocalGlassBackdrop provides null) {
Column(Modifier.fillMaxSize()) {
LumaPageTopBar(
title = stringResource(R.string.about),
@@ -176,7 +163,10 @@ fun AboutScreen(onBack: () -> Unit) {
) {
item {
Column(
- Modifier.fillMaxWidth().padding(vertical = LumaSpacing.sm),
+ Modifier
+ .fillMaxWidth()
+ .settingsModuleSurface(radius = LumaShapes.large)
+ .padding(horizontal = LumaSpacing.md, vertical = LumaSpacing.md),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
@@ -308,7 +298,7 @@ private fun AboutSectionCard(title: String, content: @Composable ColumnScope.()
Column(
Modifier
.fillMaxWidth()
- .lumaGlass(radius = LumaShapes.large, level = LumaGlassLevel.Regular)
+ .settingsModuleSurface(radius = LumaShapes.large)
.padding(vertical = LumaSpacing.xs),
) {
Text(
@@ -342,6 +332,15 @@ private fun AboutInfoRow(icon: LumaIcon, title: String, subtitle: String) {
}
}
+internal fun Modifier.settingsModuleSurface(radius: Dp): Modifier {
+ val shape = RoundedCornerShape(radius)
+ val fill = if (LumaColors.isDark) LumaColors.backgroundElevated else Color.White
+ val border = if (LumaColors.isDark) LumaColors.glassBorder else Color(0xFFE1E7EE)
+ return clip(shape)
+ .background(fill, shape)
+ .border(0.7.dp, border, shape)
+}
+
private fun formatCacheSize(bytes: Long): String = when {
bytes < 1024L -> "$bytes B"
bytes < 1024L * 1024L -> "%.1f KB".format(bytes / 1024f)
diff --git a/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt
index 57abddc..78533fc 100644
--- a/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt
+++ b/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt
@@ -1,11 +1,7 @@
package com.example.myapplication.screens
-import androidx.compose.animation.core.Animatable
-import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
-import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
-import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -17,26 +13,20 @@ 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.offset
import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
@@ -44,15 +34,11 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
-import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import androidx.compose.ui.zIndex
import com.example.myapplication.R
import com.example.myapplication.data.EmbyServer
import com.example.myapplication.ui.AppLanguage
@@ -66,28 +52,16 @@ import com.example.myapplication.ui.bottomBarContentPadding
import com.example.myapplication.ui.design.LumaColors
import com.example.myapplication.ui.design.LumaIcon
import com.example.myapplication.ui.design.LumaIconView
-import com.example.myapplication.ui.design.LumaGlassRuntime
import com.example.myapplication.ui.design.rememberLumaLayerBackdrop
-import com.example.myapplication.ui.design.LumaMotion
import com.example.myapplication.ui.design.LumaShapes
import com.example.myapplication.ui.design.LumaSpacing
import com.example.myapplication.ui.design.LumaSettingRow
import com.example.myapplication.ui.design.LumaSectionLabel
import com.example.myapplication.ui.design.LumaTypography
-import com.example.myapplication.ui.design.lumaGlass
-import com.example.myapplication.ui.design.lumaFallbackGlass
import com.kyant.backdrop.Backdrop
import com.kyant.backdrop.backdrops.layerBackdrop
-import com.kyant.backdrop.drawBackdrop
-import com.kyant.backdrop.effects.blur
-import com.kyant.backdrop.effects.colorControls
-import com.kyant.backdrop.effects.vibrancy
-import com.kyant.backdrop.highlight.Highlight
-import com.kyant.backdrop.shadow.Shadow
-import com.kyant.shapes.RoundedRectangle
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
-import kotlin.math.roundToInt
import top.yukonga.miuix.kmp.basic.Text
@Composable
@@ -117,7 +91,8 @@ fun SettingsScreen(
var settingsRootOrigin by remember { mutableStateOf(Offset.Zero) }
val context = LocalContext.current
val languageScope = rememberCoroutineScope()
- val languageBackdrop = rememberLumaLayerBackdrop()
+ val popupMounted = languageMenuMounted || themeMenuMounted || glassModeMenuMounted
+ val popupBackdrop = if (popupMounted) rememberLumaLayerBackdrop() else null
Box(
Modifier
@@ -128,11 +103,9 @@ fun SettingsScreen(
Modifier
.fillMaxSize()
.background(LumaColors.background)
- .then(
- if (languageBackdrop != null) Modifier.layerBackdrop(languageBackdrop) else Modifier,
- ),
+ .then(if (popupBackdrop != null) Modifier.layerBackdrop(popupBackdrop) else Modifier),
)
- CompositionLocalProvider(LocalGlassBackdrop provides languageBackdrop) {
+ CompositionLocalProvider(LocalGlassBackdrop provides popupBackdrop) {
Column(
Modifier
.fillMaxSize()
@@ -209,7 +182,7 @@ fun SettingsScreen(
LanguageMenuOverlay(
anchorBounds = anchor,
rootOrigin = settingsRootOrigin,
- backdrop = languageBackdrop,
+ backdrop = popupBackdrop,
visible = showLanguageDialog,
onDismiss = { showLanguageDialog = false },
onClosed = { languageMenuMounted = false },
@@ -227,7 +200,7 @@ fun SettingsScreen(
ThemeMenuOverlay(
anchorBounds = themeAnchor,
rootOrigin = settingsRootOrigin,
- backdrop = languageBackdrop,
+ backdrop = popupBackdrop,
visible = themeMenuVisible,
onDismiss = { themeMenuVisible = false },
onClosed = { themeMenuMounted = false },
@@ -242,7 +215,7 @@ fun SettingsScreen(
GlassModeMenuOverlay(
anchorBounds = glassModeAnchor,
rootOrigin = settingsRootOrigin,
- backdrop = languageBackdrop,
+ backdrop = popupBackdrop,
visible = glassModeMenuVisible,
onDismiss = { glassModeMenuVisible = false },
onClosed = { glassModeMenuMounted = false },
@@ -261,7 +234,7 @@ private fun SettingSectionCard(content: @Composable ColumnScope.() -> Unit) {
Column(
Modifier
.fillMaxWidth()
- .lumaGlass(radius = LumaShapes.large, level = com.example.myapplication.ui.design.LumaGlassLevel.Regular)
+ .settingsModuleSurface(radius = LumaShapes.large)
.padding(vertical = 4.dp),
) {
content()
@@ -304,7 +277,9 @@ private fun ThemeMenuOverlay(
onDismissRequest = onDismiss,
onClosed = onClosed,
width = 190.dp,
- height = 184.dp,
+ height = 152.dp,
+ containerColor = settingsPopupContainerColor(),
+ borderColor = settingsPopupBorderColor(),
) {
ThemeOption(
icon = LumaIcon.Image,
@@ -338,14 +313,16 @@ private fun ThemeOption(
Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
+ .padding(horizontal = 4.dp)
.clip(RoundedCornerShape(16.dp))
+ .background(if (selected) LumaColors.accentSoft else Color.Transparent)
.clickable(indication = null, interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }, onClick = onClick)
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
LumaIconView(icon, contentDescription = null, tint = if (selected) LumaColors.accent else LumaColors.textSecondary, modifier = Modifier.size(19.dp))
Spacer(Modifier.width(10.dp))
- Text(label, color = LumaColors.text, fontSize = 14.sp, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, modifier = Modifier.weight(1f))
+ Text(label, color = LumaColors.text, fontSize = LumaTypography.body, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, modifier = Modifier.weight(1f))
if (selected) {
LumaIconView(LumaIcon.Check, contentDescription = stringResource(R.string.selected), tint = LumaColors.accent, modifier = Modifier.size(18.dp))
}
@@ -370,7 +347,9 @@ private fun GlassModeMenuOverlay(
onDismissRequest = onDismiss,
onClosed = onClosed,
width = 190.dp,
- height = 128.dp,
+ height = 104.dp,
+ containerColor = settingsPopupContainerColor(),
+ borderColor = settingsPopupBorderColor(),
) {
ThemeOption(
icon = LumaIcon.Star,
@@ -397,162 +376,41 @@ private fun LanguageMenuOverlay(
onClosed: () -> Unit,
onLanguageSelected: (Int) -> Unit,
) {
- val density = LocalDensity.current
- val progress = remember { Animatable(0f) }
- var hasOpened by remember { mutableStateOf(false) }
-
- LaunchedEffect(visible) {
- if (visible) {
- hasOpened = true
- progress.animateTo(
- targetValue = 1f,
- animationSpec = tween(LumaMotion.menu, easing = LumaMotion.enter),
- )
- } else if (hasOpened) {
- progress.animateTo(
- targetValue = 0f,
- animationSpec = tween(LumaMotion.sheet, easing = LumaMotion.exit),
- )
- onClosed()
- }
- }
-
- val rawProgress = progress.value.coerceIn(0f, 1f)
- val widthProgress = 1f - (1f - rawProgress) * (1f - rawProgress) * (1f - rawProgress)
- val heightProgress = 1f - (1f - rawProgress) * (1f - rawProgress)
- val contentProgress = ((rawProgress - 0.38f) / 0.62f).coerceIn(0f, 1f)
- val initialSize = 44.dp
- val finalWidth = 184.dp
- val finalHeight = 112.dp
- val menuWidth = initialSize + (finalWidth - initialSize) * widthProgress
- val menuHeight = initialSize + (finalHeight - initialSize) * heightProgress
- val cornerRadius = 23.dp + (28.dp - 23.dp) * rawProgress
- val menuWidthPx = with(density) { menuWidth.roundToPx() }
- val menuHeightPx = with(density) { menuHeight.roundToPx() }
- val initialHalfPx = with(density) { initialSize.toPx() / 2f }
- val anchorCenterX = anchorBounds.center.x - rootOrigin.x
- val anchorCenterY = anchorBounds.center.y - rootOrigin.y
- val menuRight = anchorCenterX + initialHalfPx
- val menuLeft = (menuRight - menuWidthPx).roundToInt()
- val menuTop = (anchorCenterY - initialHalfPx).roundToInt()
- val menuRect = Rect(
- menuLeft.toFloat(),
- menuTop.toFloat(),
- (menuLeft + menuWidthPx).toFloat(),
- (menuTop + menuHeightPx).toFloat(),
- )
- val currentMenuRect by rememberUpdatedState(menuRect)
- val shape = RoundedCornerShape(cornerRadius)
- val surface = LumaColors.floatingTint
- val border = LumaColors.glassBorder
-
- Box(
- Modifier
- .fillMaxSize()
- .zIndex(10f),
+ LiquidGlassPopupOverlay(
+ anchorBounds = anchorBounds,
+ rootOrigin = rootOrigin,
+ backdrop = backdrop,
+ visible = visible,
+ onDismissRequest = onDismiss,
+ onClosed = onClosed,
+ width = 190.dp,
+ height = 104.dp,
+ containerColor = settingsPopupContainerColor(),
+ borderColor = settingsPopupBorderColor(),
) {
- Box(
- Modifier
- .fillMaxSize()
- .pointerInput(visible) {
- detectTapGestures { position ->
- if (!currentMenuRect.contains(position)) {
- onDismiss()
- }
- }
- },
+ ThemeOption(
+ icon = LumaIcon.Globe,
+ label = stringResource(R.string.lang_chinese),
+ selected = AppLanguage.language == 0,
+ onClick = { onLanguageSelected(0) },
)
-
- Box(
- Modifier
- .offset { IntOffset(menuLeft, menuTop) }
- .width(menuWidth)
- .height(menuHeight)
- .then(
- if (backdrop != null && LumaGlassRuntime.useNativeBackdrop) {
- Modifier.drawBackdrop(
- backdrop = backdrop,
- shape = { RoundedRectangle(cornerRadius) },
- effects = {
- colorControls(
- brightness = if (GlassTokens.isDark) 0f else 0.05f,
- saturation = 1.10f,
- )
- vibrancy()
- blur(if (GlassTokens.isDark) 10.dp.toPx() else 14.dp.toPx())
- },
- highlight = { Highlight.Plain },
- shadow = { Shadow(alpha = 0.16f) },
- onDrawSurface = { drawRect(surface) },
- )
- } else {
- Modifier.lumaFallbackGlass(shape, border = false)
- },
- )
- .border(0.7.dp, border, shape)
- .zIndex(1f),
- ) {
- Column(
- Modifier
- .fillMaxSize()
- .padding(horizontal = 8.dp, vertical = 8.dp)
- .alpha(contentProgress)
- .offset(y = 6.dp * (1f - contentProgress)),
- verticalArrangement = Arrangement.Center,
- ) {
- LanguageOption(
- label = stringResource(R.string.lang_chinese),
- selected = AppLanguage.language == 0,
- onClick = { onLanguageSelected(0) },
- )
- LanguageOption(
- label = stringResource(R.string.lang_english),
- selected = AppLanguage.language == 1,
- onClick = { onLanguageSelected(1) },
- )
- }
- }
- }
-}
-
-@Composable
-private fun LanguageOption(
- label: String,
- selected: Boolean,
- onClick: () -> Unit,
-) {
- Row(
- Modifier
- .fillMaxWidth()
- .heightIn(min = 48.dp)
- .clip(RoundedCornerShape(18.dp))
- .clickable(
- indication = null,
- interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() },
- onClick = onClick,
- )
- .padding(horizontal = 10.dp, vertical = 8.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Box(
- Modifier
- .size(20.dp)
- .clip(CircleShape)
- .background(if (selected) GlassTokens.accent else Color.Transparent)
- .border(1.dp, if (selected) GlassTokens.accent else LumaColors.textTertiary, CircleShape),
- contentAlignment = Alignment.Center,
- ) {
- if (selected) {
- LumaIconView(LumaIcon.Check, tint = Color.White, modifier = Modifier.size(14.dp), contentDescription = stringResource(R.string.selected))
- }
- }
- Spacer(Modifier.width(10.dp))
- Text(
- text = label,
- color = GlassTokens.text,
- fontSize = 15.sp,
- fontWeight = FontWeight.Medium,
- modifier = Modifier.weight(1f),
+ ThemeOption(
+ icon = LumaIcon.Globe,
+ label = stringResource(R.string.lang_english),
+ selected = AppLanguage.language == 1,
+ onClick = { onLanguageSelected(1) },
)
}
}
+
+private fun settingsPopupContainerColor(): Color = if (LumaColors.isDark) {
+ Color(0xFF111821).copy(alpha = 0.72f)
+} else {
+ Color(0xFFF4F7FA).copy(alpha = 0.68f)
+}
+
+private fun settingsPopupBorderColor(): Color = if (LumaColors.isDark) {
+ Color.White.copy(alpha = 0.24f)
+} else {
+ Color(0xFFC8D2DE)
+}
diff --git a/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt b/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt
index 61cc23a..bc29bca 100644
--- a/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt
+++ b/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt
@@ -20,10 +20,13 @@ import androidx.compose.runtime.remember
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -37,6 +40,9 @@ import com.example.myapplication.ui.design.LumaIconView
import com.example.myapplication.ui.design.LumaSpacing
import com.example.myapplication.ui.design.lumaFallbackGlass
import com.kyant.backdrop.Backdrop
+import com.kyant.backdrop.backdrops.layerBackdrop
+import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
+import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.lens
@@ -66,7 +72,9 @@ fun LiquidAppBottomBar(
Box(modifier = Modifier.weight(1f)) {
LiquidBottomTabs(
selectedTabIndex = { selectedTab.coerceIn(0, 3) },
- onTabSelected = onSelect,
+ onTabSelected = { index ->
+ if (selectedTab < 4) onSelect(index)
+ },
backdrop = backdrop,
tabsCount = 4,
showSelection = { selectedTab < 4 },
@@ -99,10 +107,16 @@ fun LiquidAppBottomBar(
val searchSelected = selectedTab == 4
val safeBackdrop = backdrop?.takeIf { LumaGlassRuntime.useNativeBackdrop }
+ val searchContentBackdrop = rememberLayerBackdrop()
val searchInteraction = remember { MutableInteractionSource() }
val searchPressed by searchInteraction.collectIsPressedAsState()
+ val searchPressProgress by animateFloatAsState(
+ targetValue = if (searchPressed) 1f else 0f,
+ animationSpec = spring(dampingRatio = 0.62f, stiffness = 420f),
+ label = "bottomSearchPressProgress",
+ )
val searchScale by animateFloatAsState(
- targetValue = if (searchPressed) 1.06f else 1f,
+ targetValue = if (searchPressed) 1.12f else 1f,
animationSpec = spring(dampingRatio = 0.72f, stiffness = 650f),
label = "bottomSearchPress",
)
@@ -124,6 +138,8 @@ fun LiquidAppBottomBar(
blur(8f.dp.toPx())
lens(24f.dp.toPx(), 24f.dp.toPx())
},
+ highlight = { Highlight.Default.copy(alpha = searchPressProgress) },
+ shadow = { Shadow(alpha = searchPressProgress) },
onDrawSurface = {
drawRect(
if (LumaColors.isDark) {
@@ -162,6 +178,24 @@ fun LiquidAppBottomBar(
.clickable(indication = null, interactionSource = searchInteraction) { onSelect(4) },
contentAlignment = Alignment.Center,
) {
+ if (safeBackdrop != null) {
+ Box(
+ Modifier
+ .clearAndSetSemantics {}
+ .alpha(0f)
+ .layerBackdrop(searchContentBackdrop)
+ .size(56.dp)
+ .graphicsLayer(colorFilter = ColorFilter.tint(active)),
+ contentAlignment = Alignment.Center,
+ ) {
+ LumaIconView(
+ icon = LumaIcon.Search,
+ contentDescription = null,
+ tint = active,
+ modifier = Modifier.size(27.dp),
+ )
+ }
+ }
if (searchSelected) {
Box(
Modifier
@@ -170,23 +204,35 @@ fun LiquidAppBottomBar(
.then(
if (safeBackdrop != null) {
Modifier.drawBackdrop(
- backdrop = safeBackdrop,
+ backdrop = rememberCombinedBackdrop(safeBackdrop, searchContentBackdrop),
shape = { Capsule() },
effects = {
lens(
- 10f.dp.toPx(),
- 14f.dp.toPx(),
+ 10f.dp.toPx() * searchPressProgress,
+ 14f.dp.toPx() * searchPressProgress,
chromaticAberration = true,
)
},
- highlight = { Highlight.Default.copy(alpha = 0.72f) },
- shadow = { Shadow(alpha = 0.56f) },
- innerShadow = { InnerShadow(radius = 8.dp, alpha = 0.52f) },
+ highlight = { Highlight.Default.copy(alpha = searchPressProgress) },
+ shadow = { Shadow(alpha = searchPressProgress) },
+ innerShadow = {
+ InnerShadow(
+ radius = 8.dp * searchPressProgress,
+ alpha = searchPressProgress,
+ )
+ },
+ layerBlock = {
+ val selectedScale = 1f + (78f / 56f - 1f) * searchPressProgress
+ scaleX = selectedScale
+ scaleY = selectedScale
+ },
onDrawSurface = {
drawRect(
if (LumaColors.isDark) Color.White.copy(alpha = 0.10f)
- else Color.Black.copy(alpha = 0.10f)
+ else Color.Black.copy(alpha = 0.10f),
+ alpha = 1f - searchPressProgress,
)
+ drawRect(Color.Black.copy(alpha = 0.03f * searchPressProgress))
},
)
} else {
diff --git a/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt b/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt
index 27284d8..29540fa 100644
--- a/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt
+++ b/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt
@@ -396,6 +396,8 @@ fun LiquidGlassPopupOverlay(
expandToRight: Boolean = false,
placeBelowAnchor: Boolean = false,
anchorGap: Dp = 6.dp,
+ containerColor: Color? = null,
+ borderColor: Color? = null,
content: @Composable ColumnScope.() -> Unit,
) {
val density = androidx.compose.ui.platform.LocalDensity.current
@@ -449,12 +451,12 @@ fun LiquidGlassPopupOverlay(
)
val currentMenuRect by androidx.compose.runtime.rememberUpdatedState(menuRect)
val shape = RoundedCornerShape(cornerRadius)
- val surface = if (GlassTokens.isDark) {
+ val surface = containerColor ?: if (GlassTokens.isDark) {
Color(0xFF14171D).copy(alpha = 0.42f)
} else {
Color.White.copy(alpha = 0.20f)
}
- val border = GlassTokens.glassBorder
+ val border = borderColor ?: GlassTokens.glassBorder
Box(modifier.fillMaxSize().zIndex(20f)) {
Box(
@@ -494,8 +496,10 @@ fun LiquidGlassPopupOverlay(
shadow = { Shadow(alpha = 0.18f) },
onDrawSurface = { drawRect(surface) },
)
- } else {
+ } else if (containerColor == null) {
Modifier.lumaFallbackGlass(shape, border = false)
+ } else {
+ Modifier.background(surface, shape)
}
)
.border(0.7.dp, border, shape)
diff --git a/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTabs.kt b/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTabs.kt
index 9570395..fb8251f 100644
--- a/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTabs.kt
+++ b/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTabs.kt
@@ -3,6 +3,7 @@ package com.example.myapplication.ui.liquidglass
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.EaseOut
import androidx.compose.animation.core.spring
+import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
@@ -11,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.border
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
@@ -20,22 +20,36 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
-import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.runtime.snapshotFlow
+import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastCoerceIn
import androidx.compose.ui.util.fastRoundToInt
+import androidx.compose.ui.util.lerp
+import com.example.myapplication.ui.AppTheme
+import com.example.myapplication.ui.GlassMode
+import com.example.myapplication.ui.design.LumaColors
+import com.example.myapplication.ui.design.LumaGlassRuntime
+import com.example.myapplication.ui.design.LumaSpacing
+import com.example.myapplication.ui.design.lumaFallbackGlass
import com.kyant.backdrop.Backdrop
+import com.kyant.backdrop.backdrops.layerBackdrop
+import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
+import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.lens
@@ -44,20 +58,15 @@ import com.kyant.backdrop.highlight.Highlight
import com.kyant.backdrop.shadow.InnerShadow
import com.kyant.backdrop.shadow.Shadow
import com.kyant.shapes.Capsule
-import com.example.myapplication.ui.design.LumaColors
-import com.example.myapplication.ui.design.LumaGlassRuntime
-import com.example.myapplication.ui.design.LumaSpacing
-import com.example.myapplication.ui.design.lumaFallbackGlass
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.sign
-/** true = the hidden accent-capture layer, false = the visible base layer */
+/** true = the hidden blue content-capture layer, false = the visible layer. */
val LocalLiquidBottomTabsGhostLayer = staticCompositionLocalOf { false }
-/** Local marker retained for the bottom-tab content layer. */
@Composable
fun LiquidBottomTabs(
selectedTabIndex: () -> Int,
@@ -66,21 +75,16 @@ fun LiquidBottomTabs(
tabsCount: Int,
showSelection: () -> Boolean = { true },
modifier: Modifier = Modifier,
- content: @Composable RowScope.() -> Unit
+ content: @Composable RowScope.() -> Unit,
) {
val isLightTheme = !LumaColors.isDark
- val containerColor = Color.Transparent
- val neutralTint = if (isLightTheme) {
+ val accentColor = if (isLightTheme) Color(0xFF0088FF) else Color(0xFF0091FF)
+ val containerColor = if (isLightTheme) {
Color(0xFFFAFAFA).copy(alpha = 0.40f)
} else {
Color(0xFF121212).copy(alpha = 0.40f)
}
- val selectedNeutralTint = if (isLightTheme) {
- Color.Black.copy(alpha = 0.10f)
- } else {
- Color.White.copy(alpha = 0.10f)
- }
- val liquidFallback = com.example.myapplication.ui.AppTheme.glassMode == com.example.myapplication.ui.GlassMode.Liquid
+ val liquidFallback = AppTheme.glassMode == GlassMode.Liquid
val fallbackContainerVeil = if (isLightTheme) {
if (liquidFallback) Color(0xFFD7E3F0).copy(alpha = 0.54f)
else Color(0xFFD4D9DF).copy(alpha = 0.78f)
@@ -93,17 +97,16 @@ fun LiquidBottomTabs(
} else {
Color(0xFF26313E).copy(alpha = 0.62f)
}
- val useBackdrop = LumaGlassRuntime.useNativeBackdrop && backdrop != null
+ val nativeBackdrop = backdrop?.takeIf { LumaGlassRuntime.useNativeBackdrop }
+ val selectionVisible = showSelection()
+ val tabsBackdrop = rememberLayerBackdrop()
+ val currentOnTabSelected by rememberUpdatedState(onTabSelected)
- BoxWithConstraints(
- modifier,
- contentAlignment = Alignment.CenterStart
- ) {
+ BoxWithConstraints(modifier, contentAlignment = Alignment.CenterStart) {
val density = LocalDensity.current
val tabWidth = with(density) {
(constraints.maxWidth.toFloat() - 8f.dp.toPx()) / tabsCount
}
-
val offsetAnimation = remember { Animatable(0f) }
val panelOffset by remember(density) {
derivedStateOf {
@@ -126,17 +129,14 @@ fun LiquidBottomTabs(
valueRange = 0f..(tabsCount - 1).toFloat(),
visibilityThreshold = 0.001f,
initialScale = 1f,
- pressedScale = 1.06f,
+ pressedScale = 78f / 56f,
onDragStarted = {},
onDragStopped = {
val targetIndex = targetValue.fastRoundToInt().fastCoerceIn(0, tabsCount - 1)
currentIndex = targetIndex
animateToValue(targetIndex.toFloat())
animationScope.launch {
- offsetAnimation.animateTo(
- 0f,
- spring(1f, 300f, 0.5f)
- )
+ offsetAnimation.animateTo(0f, spring(1f, 300f, 0.5f))
}
},
onDrag = { _, dragAmount ->
@@ -147,12 +147,13 @@ fun LiquidBottomTabs(
animationScope.launch {
offsetAnimation.snapTo(offsetAnimation.value + dragAmount.x)
}
- }
+ },
)
}
+
val externalIndex = selectedTabIndex().fastCoerceIn(0, tabsCount - 1)
- LaunchedEffect(externalIndex) {
- if (externalIndex != currentIndex) {
+ LaunchedEffect(externalIndex, selectionVisible) {
+ if (selectionVisible && externalIndex != currentIndex) {
currentIndex = externalIndex
dampedDragAnimation.animateToValue(externalIndex.toFloat())
}
@@ -162,11 +163,24 @@ fun LiquidBottomTabs(
.drop(1)
.collectLatest { index ->
dampedDragAnimation.animateToValue(index.toFloat())
- onTabSelected(index)
+ currentOnTabSelected(index)
}
}
- if (!useBackdrop) {
+ val interactiveHighlight = remember(animationScope, isLtr, tabWidth) {
+ InteractiveHighlight(
+ animationScope = animationScope,
+ position = { size, _ ->
+ Offset(
+ if (isLtr) (dampedDragAnimation.value + 0.5f) * tabWidth + panelOffset
+ else size.width - (dampedDragAnimation.value + 0.5f) * tabWidth + panelOffset,
+ size.height / 2f,
+ )
+ },
+ )
+ }
+
+ if (nativeBackdrop == null) {
Box(
Modifier
.graphicsLayer { translationX = panelOffset }
@@ -177,7 +191,7 @@ fun LiquidBottomTabs(
neutralVeil = fallbackContainerVeil,
),
)
- if (showSelection()) {
+ if (selectionVisible) {
Box(
Modifier
.padding(horizontal = 4.dp)
@@ -195,104 +209,133 @@ fun LiquidBottomTabs(
),
)
}
- }
-
- if (useBackdrop) {
- Box(
+ Row(
Modifier
.graphicsLayer { translationX = panelOffset }
+ .then(interactiveHighlight.modifier)
+ .then(interactiveHighlight.gestureModifier)
+ .then(dampedDragAnimation.modifier)
.clip(Capsule())
+ .height(64.dp)
+ .fillMaxWidth()
+ .padding(LumaSpacing.xxs),
+ verticalAlignment = Alignment.CenterVertically,
+ content = content,
+ )
+ } else {
+ Row(
+ Modifier
+ .graphicsLayer { translationX = panelOffset }
.drawBackdrop(
- backdrop = backdrop,
+ backdrop = nativeBackdrop,
shape = { Capsule() },
effects = {
vibrancy()
blur(8f.dp.toPx())
lens(24f.dp.toPx(), 24f.dp.toPx())
},
- onDrawSurface = {
- drawRect(containerColor)
- drawRect(neutralTint)
- },
- )
- .border(0.6.dp, LumaColors.glassBorder.copy(alpha = 0.72f), Capsule())
- .height(64f.dp)
- .fillMaxWidth()
- .padding(LumaSpacing.xxs)
- .clip(Capsule()),
- )
- }
-
- if (useBackdrop && showSelection()) {
- Box(
- Modifier
- .padding(horizontal = 4f.dp)
- .graphicsLayer {
- translationX =
- if (isLtr) dampedDragAnimation.value * tabWidth + panelOffset
- else size.width - (dampedDragAnimation.value + 1f) * tabWidth + panelOffset
- }
- .clip(Capsule())
- .drawBackdrop(
- backdrop = backdrop,
- shape = { Capsule() },
- effects = {
- lens(
- 10f.dp.toPx(),
- 14f.dp.toPx(),
- chromaticAberration = true,
- )
- },
- highlight = {
- val progress = dampedDragAnimation.pressProgress
- Highlight.Default.copy(alpha = 0.56f + progress * 0.44f)
- },
- shadow = {
- val progress = dampedDragAnimation.pressProgress
- Shadow(alpha = 0.36f + progress * 0.64f)
- },
- innerShadow = {
- val progress = dampedDragAnimation.pressProgress
- InnerShadow(
- radius = 8f.dp,
- alpha = 0.36f + progress * 0.64f,
- )
- },
layerBlock = {
- scaleX = dampedDragAnimation.scaleX
- scaleY = dampedDragAnimation.scaleY
- },
- onDrawSurface = {
val progress = dampedDragAnimation.pressProgress
- drawRect(selectedNeutralTint, alpha = 1f - progress)
- drawRect(Color.Black.copy(alpha = 0.03f * progress))
+ val scale = lerp(1f, 1f + 16f.dp.toPx() / size.width, progress)
+ scaleX = scale
+ scaleY = scale
},
+ onDrawSurface = { drawRect(containerColor) },
)
- .height(56f.dp)
- .fillMaxWidth(1f / tabsCount)
- .alpha(1f),
- )
- }
-
- Row(
- Modifier
- .graphicsLayer {
- translationX = panelOffset
- }
- .then(dampedDragAnimation.modifier)
- .clip(Capsule())
- .height(64f.dp)
- .fillMaxWidth()
- .padding(LumaSpacing.xxs)
- .clip(Capsule()),
- verticalAlignment = Alignment.CenterVertically,
- content = {
- CompositionLocalProvider(
- LocalLiquidBottomTabsGhostLayer provides false,
- ) {
+ .then(interactiveHighlight.modifier)
+ .then(interactiveHighlight.gestureModifier)
+ .then(dampedDragAnimation.modifier)
+ .border(0.6.dp, LumaColors.glassBorder.copy(alpha = 0.72f), Capsule())
+ .height(64.dp)
+ .fillMaxWidth()
+ .padding(4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ CompositionLocalProvider(LocalLiquidBottomTabsGhostLayer provides false) {
content()
}
}
- )
+
+ CompositionLocalProvider(LocalLiquidBottomTabsGhostLayer provides true) {
+ Row(
+ Modifier
+ .clearAndSetSemantics {}
+ .alpha(0f)
+ .layerBackdrop(tabsBackdrop)
+ .graphicsLayer { translationX = panelOffset }
+ .drawBackdrop(
+ backdrop = nativeBackdrop,
+ shape = { Capsule() },
+ effects = {
+ val progress = dampedDragAnimation.pressProgress
+ vibrancy()
+ blur(8f.dp.toPx())
+ lens(24f.dp.toPx() * progress, 24f.dp.toPx() * progress)
+ },
+ highlight = {
+ Highlight.Default.copy(alpha = dampedDragAnimation.pressProgress)
+ },
+ onDrawSurface = { drawRect(containerColor) },
+ )
+ .then(interactiveHighlight.modifier)
+ .height(56.dp)
+ .fillMaxWidth()
+ .padding(horizontal = 4.dp)
+ .graphicsLayer(colorFilter = ColorFilter.tint(accentColor)),
+ verticalAlignment = Alignment.CenterVertically,
+ content = content,
+ )
+ }
+
+ if (selectionVisible) {
+ Box(
+ Modifier
+ .padding(horizontal = 4.dp)
+ .graphicsLayer {
+ translationX =
+ if (isLtr) dampedDragAnimation.value * tabWidth + panelOffset
+ else size.width - (dampedDragAnimation.value + 1f) * tabWidth + panelOffset
+ }
+ .drawBackdrop(
+ backdrop = rememberCombinedBackdrop(nativeBackdrop, tabsBackdrop),
+ shape = { Capsule() },
+ effects = {
+ val progress = dampedDragAnimation.pressProgress
+ lens(
+ 10f.dp.toPx() * progress,
+ 14f.dp.toPx() * progress,
+ chromaticAberration = true,
+ )
+ },
+ highlight = {
+ Highlight.Default.copy(alpha = dampedDragAnimation.pressProgress)
+ },
+ shadow = { Shadow(alpha = dampedDragAnimation.pressProgress) },
+ innerShadow = {
+ val progress = dampedDragAnimation.pressProgress
+ InnerShadow(radius = 8.dp * progress, alpha = progress)
+ },
+ layerBlock = {
+ scaleX = dampedDragAnimation.scaleX
+ scaleY = dampedDragAnimation.scaleY
+ val velocity = dampedDragAnimation.velocity / 10f
+ scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.2f, 0.2f)
+ scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.2f, 0.2f)
+ },
+ onDrawSurface = {
+ val progress = dampedDragAnimation.pressProgress
+ drawRect(
+ if (isLightTheme) Color.Black.copy(alpha = 0.10f)
+ else Color.White.copy(alpha = 0.10f),
+ alpha = 1f - progress,
+ )
+ drawRect(Color.Black.copy(alpha = 0.03f * progress))
+ },
+ )
+ .height(56.dp)
+ .fillMaxWidth(1f / tabsCount),
+ )
+ }
+ }
}
}
diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml
index 69a2b2c..51cae5e 100644
--- a/app/src/main/res/values-en/strings.xml
+++ b/app/src/main/res/values-en/strings.xml
@@ -318,6 +318,14 @@
Show password
Note
Note
+ Alternate addresses
+ Add multiple alternate connection addresses
+ Alternate address %1$d
+ Name
+ For example: Remote access
+ Add alternate address
+ Delete alternate address
+ Complete the name, address, and port for each alternate address
Saving…
Save
Cancel
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 1762382..f28c373 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -318,6 +318,14 @@
显示密码
备注
备注
+ 备用地址
+ 可添加多个备用连接地址
+ 备用地址 %1$d
+ 名称
+ 例如:外网线路
+ 添加备用地址
+ 删除备用地址
+ 请完整填写每个备用地址的名称、地址和端口
保存中…
保存
取消
diff --git a/app/src/test/kotlin/com/example/myapplication/data/EmbyServerAddressTest.kt b/app/src/test/kotlin/com/example/myapplication/data/EmbyServerAddressTest.kt
new file mode 100644
index 0000000..29139c4
--- /dev/null
+++ b/app/src/test/kotlin/com/example/myapplication/data/EmbyServerAddressTest.kt
@@ -0,0 +1,56 @@
+package com.example.myapplication.data
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class EmbyServerAddressTest {
+
+ @Test
+ fun httpsUrlWithoutPortUses443AndExtractsPath() {
+ val parsed = parseServerAddressInput(
+ input = "https://emby.example.com/media/emby",
+ currentProtocol = "http",
+ currentPort = "",
+ currentBasePath = "",
+ )
+
+ assertEquals("https", parsed.protocol)
+ assertEquals("emby.example.com", parsed.host)
+ assertEquals("443", parsed.port)
+ assertEquals("media/emby", parsed.basePath)
+ }
+
+ @Test
+ fun explicitPortAndHttpProtocolArePreserved() {
+ val parsed = parseServerAddressInput(
+ input = "http://192.168.1.8:8096/emby",
+ currentProtocol = "https",
+ currentPort = "443",
+ currentBasePath = "",
+ )
+
+ assertEquals("http", parsed.protocol)
+ assertEquals("192.168.1.8", parsed.host)
+ assertEquals("8096", parsed.port)
+ assertEquals("emby", parsed.basePath)
+ }
+
+ @Test
+ fun bareHostUsesProtocolSwitchAndBuildsCompleteUrl() {
+ val parsed = parseServerAddressInput(
+ input = "emby.example.com/emby",
+ currentProtocol = "https",
+ currentPort = "",
+ currentBasePath = "",
+ )
+ val server = EmbyServer(
+ id = "test",
+ protocol = parsed.protocol,
+ host = parsed.host,
+ port = parsed.port,
+ basePath = parsed.basePath,
+ )
+
+ assertEquals("https://emby.example.com:443/emby", server.fullUrl)
+ }
+}