diff --git a/.gitignore b/.gitignore index a8b0d1d..7a88ddd 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,20 @@ google-services.json # Android Profiling *.hprof + +# Test screenshots and debug files +*_test.png +screenshot*.png +frame_*.png +ss_*.png +ui*.xml +ui_dump.xml +ui_capsule.xml +# Common debug screenshots +screen.png +screen2.png +current.png +home_screen.png +t1.png +t2.png +t3.png diff --git a/app/src/main/java/com/example/myapplication/data/AppDatabase.java b/app/src/main/java/com/example/myapplication/data/AppDatabase.java index 6d9d660..69be728 100644 --- a/app/src/main/java/com/example/myapplication/data/AppDatabase.java +++ b/app/src/main/java/com/example/myapplication/data/AppDatabase.java @@ -9,7 +9,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase; @Database( entities = {IconLibraryEntity.class, ServerEntity.class}, - version = 4, + version = 5, exportSchema = false ) public abstract class AppDatabase extends RoomDatabase { @@ -69,7 +69,14 @@ public abstract class AppDatabase extends RoomDatabase { } }; - public static AppDatabase getInstance(Context context) { + private static final Migration MIGRATION_4_5 = new Migration(4, 5) { + @Override + public void migrate(SupportSQLiteDatabase db) { + db.execSQL("ALTER TABLE servers ADD COLUMN isSelected INTEGER NOT NULL DEFAULT 0"); + } + }; + +public static AppDatabase getInstance(Context context) { if (instance == null) { synchronized (AppDatabase.class) { if (instance == null) { @@ -77,11 +84,10 @@ public abstract class AppDatabase extends RoomDatabase { context.getApplicationContext(), AppDatabase.class, "emby_player.db" - ).addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4).build(); + ).addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5).build(); } } } return instance; } } - diff --git a/app/src/main/java/com/example/myapplication/data/ServerDao.java b/app/src/main/java/com/example/myapplication/data/ServerDao.java index 07d3275..167d357 100644 --- a/app/src/main/java/com/example/myapplication/data/ServerDao.java +++ b/app/src/main/java/com/example/myapplication/data/ServerDao.java @@ -22,4 +22,10 @@ public interface ServerDao { void deleteLegacySampleServers(); @Query("DELETE FROM servers WHERE id = :serverId") void deleteServer(String serverId); -} + + @Query("UPDATE servers SET isSelected = 0") + void clearAllSelected(); + + @Query("UPDATE servers SET isSelected = 1 WHERE id = :serverId") + void setSelected(String serverId); +} \ No newline at end of file diff --git a/app/src/main/java/com/example/myapplication/data/ServerEntity.java b/app/src/main/java/com/example/myapplication/data/ServerEntity.java index bcf0c8d..41e4ec0 100644 --- a/app/src/main/java/com/example/myapplication/data/ServerEntity.java +++ b/app/src/main/java/com/example/myapplication/data/ServerEntity.java @@ -20,6 +20,7 @@ public class ServerEntity { public String accessToken; public String userId; public String iconUrl; + public int isSelected; @Ignore public ServerEntity( @@ -34,9 +35,10 @@ public class ServerEntity { String accessToken, String userId ) { - this(id, name, host, basePath, port, username, password, note, accessToken, userId, ""); + this(id, name, host, basePath, port, username, password, note, accessToken, userId, "", 0); } + @Ignore public ServerEntity( String id, String name, @@ -49,6 +51,23 @@ public class ServerEntity { String accessToken, String userId, String iconUrl + ) { + this(id, name, host, basePath, port, username, password, note, accessToken, userId, iconUrl, 0); + } + + public ServerEntity( + String id, + String name, + String host, + String basePath, + String port, + String username, + String password, + String note, + String accessToken, + String userId, + String iconUrl, + int isSelected ) { this.id = id; this.name = name; @@ -61,5 +80,6 @@ public class ServerEntity { this.accessToken = accessToken; this.userId = userId; this.iconUrl = iconUrl == null ? "" : iconUrl; + this.isSelected = isSelected; } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/example/myapplication/MainActivity.kt b/app/src/main/kotlin/com/example/myapplication/MainActivity.kt index 831c27e..87ab7c0 100644 --- a/app/src/main/kotlin/com/example/myapplication/MainActivity.kt +++ b/app/src/main/kotlin/com/example/myapplication/MainActivity.kt @@ -11,12 +11,19 @@ import com.example.myapplication.ui.AppLanguage class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { - installSplashScreen() + val splashScreen = installSplashScreen() + var dataReady = false + splashScreen.setKeepOnScreenCondition { !dataReady } + super.onCreate(savedInstanceState) enableEdgeToEdge() + + // Initialize on main thread (these are fast SharedPreferences/DB reads) AppLanguage.init(this) IconLibraryRepository.init(this) ServerRepository.init(this) + dataReady = true + setContent { MainApp() } diff --git a/app/src/main/kotlin/com/example/myapplication/MainApp.kt b/app/src/main/kotlin/com/example/myapplication/MainApp.kt index eba750a..487a82e 100644 --- a/app/src/main/kotlin/com/example/myapplication/MainApp.kt +++ b/app/src/main/kotlin/com/example/myapplication/MainApp.kt @@ -1,4 +1,4 @@ -package com.example.myapplication +package com.example.myapplication import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -13,6 +13,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeOut +import androidx.compose.animation.core.tween +import androidx.compose.ui.zIndex import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -91,7 +95,7 @@ fun MainApp() { val iconLibraries by IconLibraryRepository.libraries.collectAsState() val coroutineScope = rememberCoroutineScope() - val selectedServer = servers.find { it.id == selectedServerId } ?: servers.firstOrNull() + val selectedServer = servers.find { it.id == selectedServerId } // Check all servers' online status when app starts or server list changes LaunchedEffect(servers.size) { @@ -100,6 +104,18 @@ fun MainApp() { } } + // Pick the selected server from DB, or fallback to first; handle deletion + LaunchedEffect(servers) { + if (servers.isNotEmpty()) { + if (selectedServerId.isEmpty()) { + val dbSelected = servers.firstOrNull { it.isSelected } + selectedServerId = dbSelected?.id ?: servers.first().id + } else if (servers.none { it.id == selectedServerId }) { + selectedServerId = servers.first().id + } + } + } + val addIconLibrary: (String, String, List, Boolean) -> Unit = { name, url, icons, isDefault -> val library = IconLibrary( id = "lib_${System.currentTimeMillis()}", @@ -132,24 +148,17 @@ fun MainApp() { } } - LaunchedEffect(Unit) { - delay(2200) - showStartScreen = false - } - if (showStartScreen) { - Box(Modifier.fillMaxSize().background(Color.White)) { - Image( - painter = painterResource(Res.drawable.app_start), - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) + // Dismiss splash only after both: minimum display time AND correct server selected + LaunchedEffect(selectedServerId) { + if (selectedServerId.isNotEmpty()) { + delay(1200) + showStartScreen = false } } + AppTheme.systemIsDark = isSystemInDarkTheme() - if (!showStartScreen) { MiuixTheme { val hazeState = remember { HazeState() } val glassBackdrop = rememberLayerBackdrop() @@ -224,12 +233,18 @@ fun MainApp() { servers = servers, selected = selectedServer, onDetailVisibilityChanged = { homeDetailVisible = it }, + onSelectServer = { id -> + selectedServerId = id + selectedTab = 0 + coroutineScope.launch { ServerRepository.setSelectedServer(id) } + }, + hazeState = hazeState, ) 1 -> RecordsScreen(segment = recordsSegment, onSelect = { recordsSegment = it }) 2 -> LibraryScreen( servers = servers, selectedServer = selectedServer, - onSelectServer = { selectedServerId = it }, + onSelectServer = { id -> selectedServerId = id; selectedTab = 0; coroutineScope.launch { ServerRepository.setSelectedServer(id) } }, onAddServer = { server, onResult -> coroutineScope.launch { try { @@ -320,13 +335,26 @@ fun MainApp() { modifier = Modifier.align(Alignment.BottomCenter), ) + // Splash overlay — always on top, fades out after delay + AnimatedVisibility( + visible = showStartScreen, + exit = fadeOut(animationSpec = tween(400)), + modifier = Modifier.fillMaxSize().zIndex(10f), + ) { + Box(Modifier.fillMaxSize().background(Color.White)) { + Image( + painter = painterResource(Res.drawable.app_start), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } + } + } } } } - } } - - diff --git a/app/src/main/kotlin/com/example/myapplication/data/EmbyServer.kt b/app/src/main/kotlin/com/example/myapplication/data/EmbyServer.kt index 37a669d..6261aa3 100644 --- a/app/src/main/kotlin/com/example/myapplication/data/EmbyServer.kt +++ b/app/src/main/kotlin/com/example/myapplication/data/EmbyServer.kt @@ -38,6 +38,7 @@ data class EmbyServer( val lastActive: String = "", val iconUrl: String = "", val authError: String = "", + val isSelected: Boolean = false, ) { val fullUrl: String get() { @@ -72,4 +73,3 @@ data class EmbyServer( fun generateId(): String = "server_${UUID.randomUUID()}" } } - diff --git a/app/src/main/kotlin/com/example/myapplication/data/ServerRepository.kt b/app/src/main/kotlin/com/example/myapplication/data/ServerRepository.kt index fc976e2..2c28ee8 100644 --- a/app/src/main/kotlin/com/example/myapplication/data/ServerRepository.kt +++ b/app/src/main/kotlin/com/example/myapplication/data/ServerRepository.kt @@ -1,4 +1,4 @@ -package com.example.myapplication.data +package com.example.myapplication.data import android.content.Context import kotlinx.coroutines.CoroutineScope @@ -53,7 +53,6 @@ object ServerRepository { } fun setRuntimeIcon(serverId: String, iconUrl: String) { - // Update memory immediately for responsive UI... val updated = mutableListOf() var changed: EmbyServer? = null for (server in _servers.value) { @@ -65,7 +64,6 @@ object ServerRepository { } } _servers.value = updated - // ...and persist to Room so the icon survives app restarts changed?.let { srv -> repositoryScope.launch { database?.serverDao()?.insertServer(toEntity(srv)) @@ -82,16 +80,13 @@ object ServerRepository { suspend fun checkAllServersOnline() { val currentServers = _servers.value.toList() for (server in currentServers) { - // First verify server is reachable val reachable = EmbyRepository.checkOnline(server) if (!reachable) { updateServerStatus(server.id, false, "无法连接到服务器") continue } - // Then try to authenticate to verify credentials val authResult = EmbyRepository.authenticate(server) if (authResult.success) { - // Update token if it's different from stored one if (server.apiKey != authResult.accessToken) { val updated = server.copy( apiKey = authResult.accessToken, @@ -112,6 +107,14 @@ object ServerRepository { } } + suspend fun setSelectedServer(serverId: String) { + withContext(Dispatchers.IO) { + database?.serverDao()?.clearAllSelected() + database?.serverDao()?.setSelected(serverId) + } + reload() + } + private suspend fun reload(): List { val previousServers = _servers.value.toList() val entities = withContext(Dispatchers.IO) { @@ -119,7 +122,6 @@ object ServerRepository { } val result = mutableListOf() for (entity in entities) { - // Preserve runtime-only fields (isOnline, lastActive) from previous state val prev = previousServers.find { it.id == entity.id } result.add( toModel(entity).copy( @@ -161,12 +163,12 @@ object ServerRepository { } } - private suspend fun removeLegacySampleServers() { withContext(Dispatchers.IO) { database?.serverDao()?.deleteLegacySampleServers() } } + private fun toEntity(server: EmbyServer): ServerEntity { return ServerEntity( server.id, @@ -180,6 +182,7 @@ object ServerRepository { server.apiKey, server.userId, server.iconUrl, + if (server.isSelected) 1 else 0, ) } @@ -196,9 +199,7 @@ object ServerRepository { apiKey = entity.accessToken ?: "", userId = entity.userId ?: "", iconUrl = entity.iconUrl ?: "", + isSelected = entity.isSelected == 1, ) } -} - - - +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt index 26fccac..7684e04 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt @@ -92,6 +92,25 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.delay import top.yukonga.miuix.kmp.basic.Text import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.text.TextStyle +import com.example.myapplication.ui.AppTheme +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.HazeStyle +import dev.chrisbanes.haze.HazeTint +import androidx.compose.runtime.mutableIntStateOf /* ---- Adaptive content colors for the immersive home background ---- */ val LocalHomeContentColor = staticCompositionLocalOf { Color.Unspecified } @@ -592,6 +611,8 @@ fun HomeScreen( servers: List = emptyList(), selected: EmbyServer? = null, onDetailVisibilityChanged: (Boolean) -> Unit = {}, + onSelectServer: (String) -> Unit = {}, + hazeState: HazeState? = null, ) { val activeServer = remember(servers, selected) { selected ?: servers.firstOrNull { it.apiKey.isNotBlank() } } @@ -610,11 +631,13 @@ fun HomeScreen( val activeHeroItem = heroItems.getOrNull(heroPagerState.currentPage) val immersiveBackground = rememberPosterImmersiveBackground(heroImageUrl(activeHeroItem, activeServer?.fullUrl.orEmpty())) - LaunchedEffect(activeServer?.id, activeServer?.apiKey) { + var lastLoadedServerId by remember { mutableStateOf("") } + + LaunchedEffect(activeServer?.id) { if (activeServer == null || activeServer.apiKey.isBlank()) return@LaunchedEffect + if (activeServer.id == lastLoadedServerId) return@LaunchedEffect loading = true try { - // Fetch everything concurrently; commit state only when all data is ready val libsDeferred = async { EmbyRepository.getLibraries(activeServer) } val latestDeferred = async { EmbyRepository.getLatestItems(activeServer, limit = 10) } val resumeDeferred = async { EmbyRepository.getResumeItems(activeServer, limit = 10) } @@ -628,15 +651,14 @@ fun HomeScreen( HomeUiState.selectedLibrary = libs.firstOrNull() } - // First screen renders immediately; per-library sections load on demand when scrolled into view libraries = libs latestItems = latest resumeItems = resume + lastLoadedServerId = activeServer.id } finally { loading = false } } - val surfaceIsDark = immersiveBackground.isDark val adaptiveText = if (surfaceIsDark) Color(0xFFF4F7FB) else Color(0xFF121417) val adaptiveSecondary = if (surfaceIsDark) Color.White.copy(alpha = 0.74f) else Color.Black.copy(alpha = 0.64f) @@ -728,13 +750,15 @@ fun HomeScreen( } } - // Overlay top bar: selected library name + matching icon, floats over poster HomeOverlayTopBar( name = activeServer?.name ?: "", iconUrl = activeServer?.iconUrl ?: "", + servers = servers, + activeServerId = activeServer?.id ?: "", + onSelectServer = onSelectServer, + hazeState = hazeState, modifier = Modifier.align(Alignment.TopCenter), ) - detailItem?.let { item -> activeServer?.let { server -> MediaDetailScreen( @@ -748,71 +772,130 @@ fun HomeScreen( } } -/* ---- Overlay top bar showing selected server (live name + custom icon) ---- */ +/* ---- Overlay top bar: liquid-glass adaptive capsule with server dropdown ---- */ @Composable private fun HomeOverlayTopBar( name: String, iconUrl: String, + servers: List, + activeServerId: String, + onSelectServer: (String) -> Unit, + hazeState: HazeState? = null, modifier: Modifier = Modifier, ) { val title = name.ifBlank { "个人媒体中心" } - Row( + val dark = AppTheme.isDark + val capsuleShape = RoundedCornerShape(22.dp) + var showDropdown by remember { mutableStateOf(false) } + BackHandler(enabled = showDropdown) { showDropdown = false } + + Box( modifier.fillMaxWidth() .statusBarsPadding() .padding(horizontal = 16.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, ) { - Row( - Modifier.widthIn(min = 168.dp) - .clip(RoundedCornerShape(22.dp)) - .background( - Brush.verticalGradient( - listOf( - Color.White.copy(alpha = 0.42f), - Color.White.copy(alpha = 0.28f), - ) + Column { + /* ---- Capsule button ---- */ + Box( + Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { showDropdown = !showDropdown } + .shadow(elevation = 14.dp, shape = capsuleShape, ambientColor = GlassTokens.glassShadow, spotColor = GlassTokens.glassShadow) + .clip(capsuleShape) + .background(GlassTokens.glassFill) + .border(1.dp, Color.White.copy(alpha = if (dark) 0.45f else 0.75f), capsuleShape) + ) { + /* Specular highlight */ + Box(Modifier.matchParentSize().background(Brush.verticalGradient(0f to Color.White.copy(alpha = if (dark) 0.18f else 0.70f), 0.06f to Color.White.copy(alpha = if (dark) 0.08f else 0.38f), 0.18f to Color.Transparent, 0.82f to Color.Transparent, 1f to Color.White.copy(alpha = if (dark) 0.06f else 0.18f)))) + + val animatedRotation by animateFloatAsState(targetValue = if (showDropdown) 180f else 0f, animationSpec = tween(durationMillis = 300), label = "chevron") + + Row(Modifier.padding(horizontal = 14.dp, vertical = 7.dp), verticalAlignment = Alignment.CenterVertically) { + /* Liquid-glass icon circle */ + Box( + modifier = Modifier.size(28.dp) + .shadow(elevation = 6.dp, shape = CircleShape, ambientColor = GlassTokens.glassShadow, spotColor = GlassTokens.glassShadow) + .clip(CircleShape).background(GlassTokens.glassFillStrong) + .border(1.dp, Color.White.copy(alpha = if (dark) 0.50f else 0.80f), CircleShape), + contentAlignment = Alignment.Center, + ) { + Box(Modifier.matchParentSize().background(Brush.verticalGradient(0f to Color.White.copy(alpha = if (dark) 0.22f else 0.72f), 0.15f to Color.Transparent, 0.85f to Color.Transparent, 1f to Color.White.copy(alpha = if (dark) 0.08f else 0.22f)))) + if (iconUrl.isNotBlank()) { + AsyncImage(model = ImageRequest.Builder(LocalContext.current).data(iconUrl).crossfade(true).build(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.size(20.dp).clip(CircleShape)) + } else { + Icon(imageVector = Icons.Outlined.Storage, contentDescription = null, tint = GlassTokens.text, modifier = Modifier.size(14.dp)) + } + } + + Spacer(Modifier.width(8.dp)) + + /* Server name - rainbow gradient */ + GradientText( + text = title, + colors = if (dark) listOf(Color(0xFFFF6B6B), Color(0xFFFF8E53), Color(0xFFFFC947), Color(0xFF4ECDC4), Color(0xFF45B7D1), Color(0xFF96E6A1)) + else listOf(Color(0xFF1A1A2E), Color(0xFF16213E), Color(0xFF0F3460), Color(0xFF533483), Color(0xFFE94560), Color(0xFF0F3460)), + fontSize = 15.sp, fontWeight = FontWeight.Bold, ) - ) - .border(0.8.dp, Color.White.copy(alpha = 0.60f), RoundedCornerShape(22.dp)) - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (iconUrl.isNotBlank()) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current).data(iconUrl).crossfade(true).build(), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.size(22.dp).clip(RoundedCornerShape(6.dp)), - ) - } else { - Icon(imageVector = Icons.Outlined.Storage, contentDescription = null, tint = Color.White, modifier = Modifier.size(19.dp)) + + Spacer(Modifier.width(4.dp)) + + Icon(imageVector = Icons.Filled.ArrowDropDown, contentDescription = null, tint = GlassTokens.subtext, modifier = Modifier.size(18.dp).rotate(animatedRotation)) + } + } + + /* ---- Server dropdown list ---- */ + AnimatedVisibility( + visible = showDropdown, + enter = expandVertically(animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow)) + fadeIn(animationSpec = tween(200)), + exit = shrinkVertically(animationSpec = tween(150)) + fadeOut(animationSpec = tween(100)), + ) { + Spacer(Modifier.height(12.dp)) + Column( + Modifier.fillMaxWidth(0.5f).widthIn(min = 240.dp, max = 300.dp) + .clip(RoundedCornerShape(22.dp)) + .then(if (hazeState != null) Modifier.hazeEffect(state = hazeState, style = HazeStyle(blurRadius = 60.dp, noiseFactor = 0.03f, tint = HazeTint(Color.Black.copy(alpha = if (dark) 0.88f else 0.85f)))) else Modifier) + .background(Brush.verticalGradient(listOf(Color.White.copy(alpha = if (dark) 0.10f else 0.50f), Color.White.copy(alpha = if (dark) 0.06f else 0.35f), Color(0xFFCBD0DA).copy(alpha = if (dark) 0.10f else 0.38f)))) + .border(1.dp, Color.White.copy(alpha = if (dark) 0.28f else 0.50f), RoundedCornerShape(22.dp)) + .padding(vertical = 6.dp) + ) { + servers.forEach { server -> + ServerDropdownItem(server = server, isActive = server.id == activeServerId, dark = dark, onClick = { onSelectServer(server.id); showDropdown = false }) + } + } } - Spacer(Modifier.width(7.dp)) - Text( - text = title, - color = Color.White, - fontSize = 15.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = LocalTextStyle.current.copy( - shadow = Shadow(color = Color.Black.copy(alpha = 0.45f), offset = Offset(0f, 1f), blurRadius = 6f), - ), - ) } - - } } +/* ---- Server dropdown item (capsule-shaped) ---- */ +@Composable +private fun ServerDropdownItem(server: EmbyServer, isActive: Boolean, dark: Boolean, onClick: () -> Unit) { + Row( + Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + .clip(RoundedCornerShape(16.dp)) + .then(if (isActive) Modifier.background(Brush.verticalGradient(listOf(GlassTokens.accent.copy(alpha = if (dark) 0.20f else 0.14f), GlassTokens.accent.copy(alpha = if (dark) 0.10f else 0.06f)))) else Modifier.background(Brush.verticalGradient(listOf(Color.White.copy(alpha = if (dark) 0.08f else 0.30f), Color.White.copy(alpha = if (dark) 0.04f else 0.14f))))) + .border(0.6.dp, if (isActive) Color.White.copy(alpha = if (dark) 0.20f else 0.40f) else Color.White.copy(alpha = if (dark) 0.10f else 0.20f), RoundedCornerShape(16.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(modifier = Modifier.size(24.dp).clip(CircleShape).background(GlassTokens.glassFillStrong).border(0.6.dp, Color.White.copy(alpha = if (dark) 0.35f else 0.55f), CircleShape), contentAlignment = Alignment.Center) { + if (server.iconUrl.isNotBlank()) { + AsyncImage(model = ImageRequest.Builder(LocalContext.current).data(server.iconUrl).crossfade(true).build(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.size(18.dp).clip(CircleShape)) + } else { + Icon(imageVector = Icons.Outlined.Storage, contentDescription = null, tint = GlassTokens.subtext, modifier = Modifier.size(13.dp)) + } + } + Spacer(Modifier.width(10.dp)) + Text(text = server.name, color = if (isActive) GlassTokens.accent else GlassTokens.text, fontSize = 14.sp, fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + Box(modifier = Modifier.size(6.dp).clip(CircleShape).background(if (server.isOnline) Color(0xFF34C759) else Color(0xFFFF3B30))) + } +} - - - - - - - - - - +/* ---- Rainbow gradient text ---- */ +@Composable +private fun GradientText(text: String, colors: List, fontSize: androidx.compose.ui.unit.TextUnit = 15.sp, fontWeight: FontWeight = FontWeight.Bold) { + Text(text = text, fontSize = fontSize, fontWeight = fontWeight, maxLines = 1, overflow = TextOverflow.Ellipsis, style = TextStyle(brush = Brush.linearGradient(colors))) +} \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index a1bc890..74a9148 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -8,6 +8,7 @@