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