服务器列表优化
This commit is contained in:
1 parent
bbbebe38eb
commit
e33dcf533c
18 files changed
+877
-545
No files matched your search
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<String?>(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),
|
||||
|
||||
@@ -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<EmbyServerAddress> = 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("/")
|
||||
}
|
||||
@@ -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<EmbyServerAddress>): 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<EmbyServerAddress> {
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -62,14 +62,26 @@ fun AnimatedContentTransitionScope<NavBackStackEntry>.appExitTransition(): ExitT
|
||||
}
|
||||
}
|
||||
|
||||
fun AnimatedContentTransitionScope<NavBackStackEntry>.appPopEnterTransition(): EnterTransition =
|
||||
slideInHorizontally(
|
||||
fun AnimatedContentTransitionScope<NavBackStackEntry>.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<NavBackStackEntry>.appPopExitTransition(): ExitTransition =
|
||||
slideOutHorizontally(
|
||||
fun AnimatedContentTransitionScope<NavBackStackEntry>.appPopExitTransition(): ExitTransition {
|
||||
if (isReturningFromSettingsChild()) return ExitTransition.None
|
||||
return slideOutHorizontally(
|
||||
animationSpec = tween(NavigationMotionTokens.detailPopDuration, easing = NavigationMotionTokens.popEasing),
|
||||
) { width -> width } +
|
||||
fadeOut(tween(220))
|
||||
}
|
||||
|
||||
private fun AnimatedContentTransitionScope<NavBackStackEntry>.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
|
||||
}
|
||||
}
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<EmbyServer?>(null) }
|
||||
var popupServer by remember { mutableStateOf<EmbyServer?>(null) }
|
||||
var popupAnchor by remember { mutableStateOf<androidx.compose.ui.geometry.Rect?>(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<AlternateAddressForm> = 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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
+159
-116
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -318,6 +318,14 @@
|
||||
<string name="server_show_password">Show password</string>
|
||||
<string name="server_note_label">Note</string>
|
||||
<string name="server_note_placeholder">Note</string>
|
||||
<string name="server_alternate_title">Alternate addresses</string>
|
||||
<string name="server_alternate_subtitle">Add multiple alternate connection addresses</string>
|
||||
<string name="server_alternate_item">Alternate address %1$d</string>
|
||||
<string name="server_alternate_name">Name</string>
|
||||
<string name="server_alternate_name_placeholder">For example: Remote access</string>
|
||||
<string name="server_alternate_add">Add alternate address</string>
|
||||
<string name="server_alternate_delete">Delete alternate address</string>
|
||||
<string name="server_alternate_incomplete">Complete the name, address, and port for each alternate address</string>
|
||||
<string name="server_saving">Saving…</string>
|
||||
<string name="server_save">Save</string>
|
||||
<string name="server_cancel">Cancel</string>
|
||||
|
||||
@@ -318,6 +318,14 @@
|
||||
<string name="server_show_password">显示密码</string>
|
||||
<string name="server_note_label">备注</string>
|
||||
<string name="server_note_placeholder">备注</string>
|
||||
<string name="server_alternate_title">备用地址</string>
|
||||
<string name="server_alternate_subtitle">可添加多个备用连接地址</string>
|
||||
<string name="server_alternate_item">备用地址 %1$d</string>
|
||||
<string name="server_alternate_name">名称</string>
|
||||
<string name="server_alternate_name_placeholder">例如:外网线路</string>
|
||||
<string name="server_alternate_add">添加备用地址</string>
|
||||
<string name="server_alternate_delete">删除备用地址</string>
|
||||
<string name="server_alternate_incomplete">请完整填写每个备用地址的名称、地址和端口</string>
|
||||
<string name="server_saving">保存中…</string>
|
||||
<string name="server_save">保存</string>
|
||||
<string name="server_cancel">取消</string>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user