diff --git a/.gitignore b/.gitignore index cb19348f8c..fb14076fd2 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ FCL/src/main/java/com/mio/Main.kt .DS_Store /LWJGL-Pojav/bin LWJGL-Pojav/compileOnly/jspecify-1.0.0.jar + +# Isolated worktrees +.worktrees/ diff --git a/FCL/build.gradle.kts b/FCL/build.gradle.kts index e9aaa22e5d..3dc0a0b8b7 100644 --- a/FCL/build.gradle.kts +++ b/FCL/build.gradle.kts @@ -8,6 +8,7 @@ import java.util.Properties plugins { id("com.android.application") id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") version "2.3.20" id("org.jetbrains.kotlin.plugin.serialization") version "2.3.20" } @@ -43,7 +44,8 @@ android { defaultConfig { applicationId = "com.tungsten.fcl" - minSdk = libs.versions.minSdk.get().toInt() + // 液态玻璃 UI 依赖 Backdrop 效果,需要 Android 12+ (API 31) + minSdk = 31 targetSdk = libs.versions.targetSdk.get().toInt() versionCode = 1313 versionName = "1.3.1.3" @@ -84,8 +86,10 @@ android { viewBinding = true buildConfig = true resValues = true + compose = true } + splits { val arch = System.getProperty("arch", "all") if (arch != "all") { @@ -166,6 +170,14 @@ dependencies { implementation(libs.segmented.button) implementation(libs.datastore) implementation(libs.kotlinx.serialization.json) + implementation(platform(libs.compose.bom)) + implementation(libs.compose.ui) + implementation(libs.compose.material3) + implementation(libs.compose.activity) + implementation(libs.compose.navigation) + implementation(libs.compose.ui.tooling) + implementation(libs.compose.ui.tooling.preview) + implementation(libs.backdrop) } tasks.register("updateMap") { diff --git a/FCL/src/main/AndroidManifest.xml b/FCL/src/main/AndroidManifest.xml index 6e888305a0..b262ea7269 100644 --- a/FCL/src/main/AndroidManifest.xml +++ b/FCL/src/main/AndroidManifest.xml @@ -47,11 +47,11 @@ tools:ignore="DiscouragedApi" tools:targetApi="33"> + android:theme="@style/Theme.FoldCraftLauncher"> @@ -91,9 +91,16 @@ + - \ No newline at end of file + diff --git a/FCL/src/main/java/com/tungsten/fcl/activity/GlassMainActivity.kt b/FCL/src/main/java/com/tungsten/fcl/activity/GlassMainActivity.kt new file mode 100644 index 0000000000..a7453c06f2 --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/activity/GlassMainActivity.kt @@ -0,0 +1,41 @@ +package com.tungsten.fcl.activity + +import android.os.Bundle +import androidx.compose.ui.platform.ComposeView +import androidx.core.view.WindowCompat +import com.tungsten.fcl.ui.glass.FCLGlassApp +import com.tungsten.fcllibrary.component.FCLActivity +import com.tungsten.fcllibrary.component.view.FCLUILayout + +/** + * FCL 液态玻璃 UI 主入口。 + * + * 作为 SplashActivity 之后的主 Activity,负责展示 Compose + Backdrop 构建的液态玻璃界面。 + * 以 FCLUILayout 为根容器,方便旧版页面在 Compose UI 上层添加视图。 + */ +class GlassMainActivity : FCLActivity() { + + lateinit var layout: FCLUILayout + private set + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + WindowCompat.setDecorFitsSystemWindows(window, false) + + layout = FCLUILayout(this) + val composeView = ComposeView(this).apply { + setContent { + FCLGlassApp() + } + } + layout.addView( + composeView, + android.widget.RelativeLayout.LayoutParams( + android.widget.RelativeLayout.LayoutParams.MATCH_PARENT, + android.widget.RelativeLayout.LayoutParams.MATCH_PARENT + ) + ) + setContentView(layout) + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/activity/SplashActivity.kt b/FCL/src/main/java/com/tungsten/fcl/activity/SplashActivity.kt index f3ea12bf8d..30ac7169e7 100644 --- a/FCL/src/main/java/com/tungsten/fcl/activity/SplashActivity.kt +++ b/FCL/src/main/java/com/tungsten/fcl/activity/SplashActivity.kt @@ -149,7 +149,7 @@ class SplashActivity : FCLActivity() { } } startActivity( - handleModpack(Intent(this@SplashActivity, MainActivity::class.java)), + handleModpack(Intent(this@SplashActivity, GlassMainActivity::class.java)), ActivityOptionsCompat.makeCustomAnimation(this@SplashActivity, 0, 0).toBundle() ) finish() diff --git a/FCL/src/main/java/com/tungsten/fcl/setting/Profiles.kt b/FCL/src/main/java/com/tungsten/fcl/setting/Profiles.kt index d7fdba05fa..4f3287ab0c 100644 --- a/FCL/src/main/java/com/tungsten/fcl/setting/Profiles.kt +++ b/FCL/src/main/java/com/tungsten/fcl/setting/Profiles.kt @@ -192,6 +192,11 @@ object Profiles { versionsListeners.add(listener) } + @JvmStatic + fun unregisterVersionsListener(listener: Consumer) { + versionsListeners.remove(listener) + } + fun getSelectedGameVersion(): String { val gameVersion: Optional = getSelectedProfile().repository.getGameVersion(getSelectedVersion()) diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLGlassApp.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLGlassApp.kt new file mode 100644 index 0000000000..6392a25671 --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLGlassApp.kt @@ -0,0 +1,105 @@ +package com.tungsten.fcl.ui.glass + +import android.content.Context +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import androidx.navigation.hasRoute +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import com.tungsten.fcl.activity.GlassMainActivity +import com.tungsten.fcl.ui.glass.component.GlassBottomBar +import com.tungsten.fcl.ui.glass.component.GlassNavHost +import com.tungsten.fcl.ui.glass.component.dialog.GlassDialogHost +import com.tungsten.fcl.ui.glass.theme.GlassTheme +import com.tungsten.fcllibrary.component.theme.ThemePreset + +@Composable +fun FCLGlassApp( + initialPreset: ThemePreset = ThemePreset.DEFAULT +) { + val context = LocalContext.current + val sharedPreferences = remember { context.getSharedPreferences("launcher", Context.MODE_PRIVATE) } + val presetOrdinal = remember { sharedPreferences.getInt("themePreset", initialPreset.ordinal) } + var currentPreset by remember { mutableStateOf(ThemePreset.values()[presetOrdinal]) } + + val isDark = isSystemInDarkTheme() + val backgroundColor = GlassTheme.backgroundColor() + + val onPresetChange: (ThemePreset) -> Unit = { preset -> + currentPreset = preset + sharedPreferences.edit().putInt("themePreset", preset.ordinal).apply() + } + + val layout = remember(context) { + (context as? GlassMainActivity)?.layout + } + + CompositionLocalProvider(LocalFCLUILayout provides layout) { + MaterialTheme( + colorScheme = if (isDark) darkColorScheme() else lightColorScheme() + ) { + val navController = rememberNavController() + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination + val currentRoute = when { + currentDestination?.hasRoute() == true -> FCLGlassRoute.Home + currentDestination?.hasRoute() == true -> FCLGlassRoute.Versions + currentDestination?.hasRoute() == true -> FCLGlassRoute.Download + currentDestination?.hasRoute() == true -> FCLGlassRoute.Account + currentDestination?.hasRoute() == true -> FCLGlassRoute.Settings + else -> FCLGlassRoute.Home + } + + val backdrop = rememberLayerBackdrop { + drawRect(backgroundColor) + drawContent() + } + + Box(modifier = Modifier.fillMaxSize()) { + GlassNavHost( + navController = navController, + backdrop = backdrop, + currentPreset = currentPreset, + onPresetChange = onPresetChange, + modifier = Modifier + .fillMaxSize() + .padding(bottom = 80.dp) + ) + + GlassBottomBar( + backdrop = backdrop, + preset = currentPreset, + currentRoute = currentRoute, + onRouteSelected = { route -> + navController.navigate(route) { + popUpTo(navController.graph.startDestinationId) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, + modifier = Modifier.align(Alignment.BottomCenter) + ) + + GlassDialogHost(backdrop = backdrop) + } + } + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLGlassDownloadRoute.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLGlassDownloadRoute.kt new file mode 100644 index 0000000000..a41ba116fe --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLGlassDownloadRoute.kt @@ -0,0 +1,49 @@ +package com.tungsten.fcl.ui.glass + +import kotlinx.serialization.Serializable + +sealed class FCLGlassDownloadRoute { + @Serializable + data object Home : FCLGlassDownloadRoute() + + @Serializable + data object VersionInstallList : FCLGlassDownloadRoute() + + @Serializable + data class VersionInstallInfo(val gameVersion: String) : FCLGlassDownloadRoute() + + @Serializable + data class InstallerVersionSelect( + val gameVersion: String, + val libraryId: String + ) : FCLGlassDownloadRoute() + + @Serializable + data class ModDownload(val gameVersion: String = "") : FCLGlassDownloadRoute() + + @Serializable + data class ModpackDownload(val gameVersion: String = "") : FCLGlassDownloadRoute() + + @Serializable + data class ResourcePackDownload(val gameVersion: String = "") : FCLGlassDownloadRoute() + + @Serializable + data class ShaderPackDownload(val gameVersion: String = "") : FCLGlassDownloadRoute() + + @Serializable + data class RemoteModInfo( + val typeName: String, + val modSlug: String, + val modTitle: String, + val gameVersion: String, + val cacheKey: String + ) : FCLGlassDownloadRoute() + + @Serializable + data class RemoteModVersions( + val typeName: String, + val modSlug: String, + val targetGameVersion: String, + val cacheKey: String + ) : FCLGlassDownloadRoute() +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLGlassRoute.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLGlassRoute.kt new file mode 100644 index 0000000000..25b4a839fa --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLGlassRoute.kt @@ -0,0 +1,79 @@ +package com.tungsten.fcl.ui.glass + +import kotlinx.serialization.Serializable + +sealed class FCLGlassRoute { + @Serializable + data object Home : FCLGlassRoute() + + @Serializable + data object Versions : FCLGlassRoute() + + @Serializable + data object Download : FCLGlassRoute() + + @Serializable + data object Account : FCLGlassRoute() + + @Serializable + data object Settings : FCLGlassRoute() + + @Serializable + data object LauncherSettings : FCLGlassRoute() + + @Serializable + data object JavaRuntime : FCLGlassRoute() + + @Serializable + data object JvmArgs : FCLGlassRoute() + + @Serializable + data class QuickInput( + val title: String, + val initialValue: String, + val hint: String + ) : FCLGlassRoute() + + @Serializable + data class VersionSettings( + val profileName: String, + val version: String + ) : FCLGlassRoute() + + @Serializable + data class VersionInstall( + val profileName: String, + val version: String? = null + ) : FCLGlassRoute() + + @Serializable + data class VersionMods( + val profileName: String, + val version: String + ) : FCLGlassRoute() + + @Serializable + data class VersionWorlds( + val profileName: String, + val version: String + ) : FCLGlassRoute() + + @Serializable + data class VersionResourcePacks( + val profileName: String, + val version: String + ) : FCLGlassRoute() + + @Serializable + data class VersionShaderPacks( + val profileName: String, + val version: String + ) : FCLGlassRoute() + + @Serializable + data class ModInfo( + val modFileName: String, + val profileName: String, + val version: String + ) : FCLGlassRoute() +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLUILayoutProvider.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLUILayoutProvider.kt new file mode 100644 index 0000000000..7692d6129a --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLUILayoutProvider.kt @@ -0,0 +1,6 @@ +package com.tungsten.fcl.ui.glass + +import androidx.compose.runtime.staticCompositionLocalOf +import com.tungsten.fcllibrary.component.view.FCLUILayout + +val LocalFCLUILayout = staticCompositionLocalOf { null } diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassAccountItem.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassAccountItem.kt new file mode 100644 index 0000000000..528ec7f98c --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassAccountItem.kt @@ -0,0 +1,151 @@ +package com.tungsten.fcl.ui.glass.component + +import android.graphics.drawable.BitmapDrawable +import android.os.Build +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.effects.vibrancy +import com.tungsten.fcl.R +import com.tungsten.fcl.game.TexturesLoader +import com.tungsten.fcl.setting.Accounts +import com.tungsten.fcl.ui.glass.theme.GlassTheme +import com.tungsten.fclcore.auth.Account +import com.tungsten.fclcore.auth.authlibinjector.AuthlibInjectorAccount +import com.tungsten.fclcore.auth.offline.OfflineAccount +import com.tungsten.fclcore.fakefx.beans.value.ChangeListener + +@Composable +fun GlassAccountItem( + backdrop: Backdrop, + account: Account, + selected: Boolean, + onSelect: () -> Unit, + onRefresh: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val shape = RoundedCornerShape(GlassTheme.glassCornerRadius) + var avatar by remember { mutableStateOf(null) } + + DisposableEffect(account) { + val binding = TexturesLoader.avatarBinding(account, 64) + val listener = ChangeListener { _, _, new -> + avatar = (new as? BitmapDrawable)?.bitmap + } + binding.addListener(listener) + binding.value?.let { listener.changed(binding, null, it) } + + onDispose { + binding.removeListener(listener) + } + } + + val title = remember(account) { + val character = account.character + if (account is OfflineAccount || account.username.isEmpty()) { + character + } else { + "${account.username} - $character" + } + } + + val subtitle = remember(account) { + val loginTypeName = Accounts.getLocalizedLoginTypeName(context, Accounts.getAccountFactory(account)) + if (account is AuthlibInjectorAccount) { + val server = account.server + "$loginTypeName, ${context.getString(R.string.account_injector_server)}: ${server.name}" + } else { + loginTypeName + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onSelect) + .drawBackdrop( + backdrop = backdrop, + shape = { shape }, + effects = { + vibrancy() + blur(8f.dp.toPx()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + lens(12f.dp.toPx(), 24f.dp.toPx()) + } + }, + onDrawSurface = { drawRect(GlassTheme.surfaceColor()) } + ) + .padding(16.dp) + ) { + RadioButton(selected = selected, onClick = onSelect) + + avatar?.let { bitmap -> + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = null, + modifier = Modifier.size(48.dp) + ) + } + + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + + GlassIconButton( + backdrop = backdrop, + iconRes = R.drawable.ic_baseline_refresh_24, + contentDescription = stringResource(R.string.action_refresh), + onClick = onRefresh, + modifier = Modifier.size(40.dp) + ) + + GlassIconButton( + backdrop = backdrop, + iconRes = R.drawable.ic_baseline_delete_24, + contentDescription = stringResource(R.string.button_remove), + onClick = onDelete, + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colorScheme.error + ) + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassBackground.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassBackground.kt new file mode 100644 index 0000000000..d302757575 --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassBackground.kt @@ -0,0 +1,40 @@ +package com.tungsten.fcl.ui.glass.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import com.tungsten.fcllibrary.component.theme.ThemePreset + +/** + * 液态玻璃背景层。 + * 根据当前主题模式和预设色渲染渐变背景,为 GlassNavHost 提供视觉底层。 + */ +@Composable +fun GlassBackground( + preset: ThemePreset, + isDark: Boolean, + modifier: Modifier = Modifier +) { + val tintColor = Color(preset.getColor()) + val backgroundColor = if (isDark) Color(0xFF1A1A2E) else Color(0xFFF0F4F8) + val secondaryColor = if (isDark) Color(preset.getColor2Dark()) else Color(preset.getColor2()) + + Box( + modifier = modifier + .fillMaxSize() + .background( + brush = Brush.verticalGradient( + colors = listOf( + backgroundColor, + tintColor.copy(alpha = 0.08f), + backgroundColor, + secondaryColor.copy(alpha = 0.05f) + ) + ) + ) + ) +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassBottomBar.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassBottomBar.kt new file mode 100644 index 0000000000..44d529aafa --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassBottomBar.kt @@ -0,0 +1,131 @@ +package com.tungsten.fcl.ui.glass.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeContentPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.clickable +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.effects.vibrancy +import com.tungsten.fcl.R +import com.tungsten.fcl.ui.glass.FCLGlassRoute +import com.tungsten.fcllibrary.component.theme.ThemePreset + +/** + * 底部玻璃导航栏,使用 Backdrop 实现官方教程中的液态玻璃效果。 + * + * @param backdrop 由 GlassNavHost 通过 layerBackdrop 捕获的背景 backdrop + */ +@Composable +fun GlassBottomBar( + backdrop: Backdrop, + preset: ThemePreset, + currentRoute: FCLGlassRoute, + onRouteSelected: (FCLGlassRoute) -> Unit, + modifier: Modifier = Modifier +) { + val tintColor = Color(preset.getColor()) + + Row( + modifier = modifier + .fillMaxWidth() + .height(80.dp) + .safeContentPadding() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val items = listOf( + NavigationItem.Home, + NavigationItem.Versions, + NavigationItem.Download, + NavigationItem.Account, + NavigationItem.Settings + ) + + items.forEach { item -> + val selected = currentRoute == item.route + GlassNavItem( + backdrop = backdrop, + item = item, + selected = selected, + tintColor = tintColor, + onClick = { onRouteSelected(item.route) }, + modifier = Modifier.weight(1f) + ) + } + } +} + +@Composable +private fun GlassNavItem( + backdrop: Backdrop, + item: NavigationItem, + selected: Boolean, + tintColor: Color, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxHeight() + .aspectRatio(1f) + .clickable(onClick = onClick) + .drawBackdrop( + backdrop = backdrop, + shape = { RoundedCornerShape(50) }, + effects = { + vibrancy() + blur(4f.dp.toPx()) + lens(16f.dp.toPx(), 32f.dp.toPx()) + }, + onDrawSurface = { + if (selected) { + drawRect(tintColor, blendMode = androidx.compose.ui.graphics.BlendMode.Hue) + drawRect(tintColor.copy(alpha = 0.75f)) + } else { + drawRect(Color.White.copy(alpha = 0.5f)) + } + } + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = ImageVector.vectorResource(id = item.iconRes), + contentDescription = item.contentDescription, + tint = if (selected) Color.White else MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(24.dp) + ) + } +} + +private sealed class NavigationItem( + val route: FCLGlassRoute, + val iconRes: Int, + val contentDescription: String +) { + data object Home : NavigationItem(FCLGlassRoute.Home, R.drawable.ic_baseline_home_24, "Home") + data object Versions : NavigationItem(FCLGlassRoute.Versions, R.drawable.ic_baseline_videogame_asset_24, "Versions") + data object Download : NavigationItem(FCLGlassRoute.Download, R.drawable.ic_baseline_cloud_download_24, "Download") + data object Account : NavigationItem(FCLGlassRoute.Account, R.drawable.ic_baseline_account_circle_24, "Account") + data object Settings : NavigationItem(FCLGlassRoute.Settings, R.drawable.ic_baseline_settings_24, "Settings") +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassButton.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassButton.kt new file mode 100644 index 0000000000..a2ed3a2226 --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassButton.kt @@ -0,0 +1,113 @@ +package com.tungsten.fcl.ui.glass.component + +import android.os.Build +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.effects.vibrancy +import com.tungsten.fcl.ui.glass.theme.GlassTheme + +@Composable +fun GlassButton( + backdrop: Backdrop, + onClick: () -> Unit, + modifier: Modifier = Modifier, + tint: Color? = null, + enabled: Boolean = true, + content: @Composable () -> Unit +) { + val shape = RoundedCornerShape(GlassTheme.buttonCornerRadius) + Box( + modifier = modifier + .defaultMinSize(minHeight = 48.dp) + .clickable(enabled = enabled, onClick = onClick) + .drawBackdrop( + backdrop = backdrop, + shape = { shape }, + effects = { + vibrancy() + blur(4f.dp.toPx()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + lens(8f.dp.toPx(), 16f.dp.toPx()) + } + }, + onDrawSurface = { + if (tint != null) { + drawRect(tint, blendMode = androidx.compose.ui.graphics.BlendMode.Hue) + drawRect(tint.copy(alpha = 0.75f)) + } else { + drawRect(GlassTheme.surfaceColor()) + } + } + ) + .padding(horizontal = 20.dp, vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + content() + } +} + +@Composable +fun GlassTextButton( + backdrop: Backdrop, + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + tint: Color? = null, + enabled: Boolean = true +) { + GlassButton( + backdrop = backdrop, + onClick = onClick, + modifier = modifier, + tint = tint, + enabled = enabled + ) { + Text( + text = text, + color = if (tint != null) Color.White else MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelLarge + ) + } +} + +@Composable +fun GlassIconButton( + backdrop: Backdrop, + iconRes: Int, + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + tint: Color? = null +) { + GlassButton( + backdrop = backdrop, + onClick = onClick, + modifier = modifier.size(48.dp), + tint = tint + ) { + Icon( + imageVector = ImageVector.vectorResource(id = iconRes), + contentDescription = contentDescription, + tint = if (tint != null) Color.White else MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(24.dp) + ) + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassCard.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassCard.kt new file mode 100644 index 0000000000..62e5e81803 --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassCard.kt @@ -0,0 +1,54 @@ +package com.tungsten.fcl.ui.glass.component + +import android.os.Build +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +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.unit.dp +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.effects.vibrancy +import com.tungsten.fcl.ui.glass.theme.GlassTheme + +@Composable +fun GlassCard( + backdrop: Backdrop, + modifier: Modifier = Modifier, + cornerRadius: Dp = GlassTheme.glassCornerRadius, + tint: Color? = null, + content: @Composable () -> Unit +) { + Box( + modifier = modifier + .clip(RoundedCornerShape(cornerRadius)) + .drawBackdrop( + backdrop = backdrop, + shape = { RoundedCornerShape(cornerRadius) }, + effects = { + vibrancy() + blur(8f.dp.toPx()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + lens(12f.dp.toPx(), 24f.dp.toPx()) + } + }, + onDrawSurface = { + if (tint != null) { + drawRect(tint, blendMode = androidx.compose.ui.graphics.BlendMode.Hue) + drawRect(tint.copy(alpha = 0.35f)) + } else { + drawRect(GlassTheme.surfaceColor()) + } + } + ) + .padding(16.dp) + ) { + content() + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassCheckbox.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassCheckbox.kt new file mode 100644 index 0000000000..5d98c25d76 --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassCheckbox.kt @@ -0,0 +1,76 @@ +package com.tungsten.fcl.ui.glass.component + +import android.os.Build +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.effects.vibrancy +import com.tungsten.fcl.ui.glass.theme.GlassTheme + +@Composable +fun GlassCheckbox( + backdrop: Backdrop, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + label: String, + modifier: Modifier = Modifier, + tint: Color? = null +) { + val shape = RoundedCornerShape(GlassTheme.chipCornerRadius) + val contentColor = if (checked && tint != null) Color.White else MaterialTheme.colorScheme.onSurface + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .clickable { onCheckedChange(!checked) } + .drawBackdrop( + backdrop = backdrop, + shape = { shape }, + effects = { + vibrancy() + blur(4f.dp.toPx()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + lens(6f.dp.toPx(), 12f.dp.toPx()) + } + }, + onDrawSurface = { + if (checked && tint != null) { + drawRect(tint, blendMode = androidx.compose.ui.graphics.BlendMode.Hue) + drawRect(tint.copy(alpha = 0.75f)) + } else { + drawRect(GlassTheme.surfaceColor()) + } + } + ) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + Checkbox( + checked = checked, + onCheckedChange = null, + colors = CheckboxDefaults.colors( + checkedColor = tint ?: MaterialTheme.colorScheme.primary, + uncheckedColor = contentColor, + checkmarkColor = Color.White + ) + ) + Text( + text = label, + color = contentColor, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(start = 8.dp) + ) + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassChip.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassChip.kt new file mode 100644 index 0000000000..e61a9f7332 --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassChip.kt @@ -0,0 +1,63 @@ +package com.tungsten.fcl.ui.glass.component + +import android.os.Build +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.effects.vibrancy +import com.tungsten.fcl.ui.glass.theme.GlassTheme + +@Composable +fun GlassChip( + backdrop: Backdrop, + text: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + tint: Color? = null +) { + val shape = RoundedCornerShape(GlassTheme.chipCornerRadius) + Box( + modifier = modifier + .clickable(onClick = onClick) + .drawBackdrop( + backdrop = backdrop, + shape = { shape }, + effects = { + vibrancy() + blur(4f.dp.toPx()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + lens(6f.dp.toPx(), 12f.dp.toPx()) + } + }, + onDrawSurface = { + if (selected && tint != null) { + drawRect(tint, blendMode = androidx.compose.ui.graphics.BlendMode.Hue) + drawRect(tint.copy(alpha = 0.75f)) + } else { + drawRect(GlassTheme.surfaceColor()) + } + } + ) + .padding(horizontal = 16.dp, vertical = 8.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = text, + color = if (selected && tint != null) Color.White else MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelLarge + ) + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassDownloadNavHost.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassDownloadNavHost.kt new file mode 100644 index 0000000000..d6f3a0007e --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassDownloadNavHost.kt @@ -0,0 +1,145 @@ +package com.tungsten.fcl.ui.glass.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.toRoute +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.ui.glass.FCLGlassDownloadRoute +import com.tungsten.fcl.ui.glass.page.DownloadPage +import com.tungsten.fcl.ui.glass.page.download.InstallerVersionSelectPage +import com.tungsten.fcl.ui.glass.page.download.RemoteContentType +import com.tungsten.fcl.ui.glass.page.download.RemoteModCache +import com.tungsten.fcl.ui.glass.page.download.RemoteModInfoPage +import com.tungsten.fcl.ui.glass.page.download.RemoteModListPage +import com.tungsten.fcl.ui.glass.page.download.RemoteModVersionPage +import com.tungsten.fcl.ui.glass.page.download.VersionInstallInfoPage +import com.tungsten.fcl.ui.glass.page.download.VersionInstallListPage +import com.tungsten.fcl.ui.glass.page.download.VersionInstallState + +@Composable +fun GlassDownloadNavHost( + backdrop: Backdrop, + navController: NavHostController = rememberNavController(), + modifier: Modifier = Modifier +) { + NavHost( + navController = navController, + startDestination = FCLGlassDownloadRoute.Home, + modifier = modifier + ) { + composable { + DownloadPage( + backdrop = backdrop, + onNavigate = { route -> navController.navigate(route) } + ) + } + composable { + LaunchedEffect(Unit) { + VersionInstallState.clear() + } + VersionInstallListPage( + backdrop = backdrop, + onNavigate = { route -> navController.navigate(route) } + ) + } + composable { backStackEntry -> + val info = backStackEntry.toRoute() + VersionInstallInfoPage( + backdrop = backdrop, + gameVersion = info.gameVersion, + onNavigate = { route -> navController.navigate(route) } + ) + } + composable { backStackEntry -> + val select = backStackEntry.toRoute() + InstallerVersionSelectPage( + backdrop = backdrop, + gameVersion = select.gameVersion, + libraryId = select.libraryId, + onVersionSelected = { remoteVersion -> + VersionInstallState.selectedVersions[select.libraryId] = remoteVersion + navController.popBackStack() + }, + onBack = { navController.popBackStack() } + ) + } + composable { entry -> + val route = entry.toRoute() + RemoteModListPage( + backdrop = backdrop, + type = RemoteContentType.MOD, + initialGameVersion = route.gameVersion, + onNavigate = { navController.navigate(it) } + ) + } + composable { entry -> + val route = entry.toRoute() + RemoteModListPage( + backdrop = backdrop, + type = RemoteContentType.MODPACK, + initialGameVersion = route.gameVersion, + onNavigate = { navController.navigate(it) } + ) + } + composable { entry -> + val route = entry.toRoute() + RemoteModListPage( + backdrop = backdrop, + type = RemoteContentType.RESOURCE_PACK, + initialGameVersion = route.gameVersion, + onNavigate = { navController.navigate(it) } + ) + } + composable { entry -> + val route = entry.toRoute() + RemoteModListPage( + backdrop = backdrop, + type = RemoteContentType.SHADER_PACK, + initialGameVersion = route.gameVersion, + onNavigate = { navController.navigate(it) } + ) + } + composable { entry -> + val info = entry.toRoute() + val mod = RemoteModCache.get(info.cacheKey) + if (mod != null) { + RemoteModInfoPage( + backdrop = backdrop, + type = RemoteContentType.valueOf(info.typeName), + mod = mod, + gameVersion = info.gameVersion, + onNavigate = { navController.navigate(it) } + ) + } else { + // Fallback: show empty state with back button + GlassEmptyState( + text = "Mod info unavailable", + modifier = Modifier.fillMaxSize() + ) + } + } + composable { entry -> + val versions = entry.toRoute() + val mod = RemoteModCache.get(versions.cacheKey) + if (mod != null) { + RemoteModVersionPage( + backdrop = backdrop, + type = RemoteContentType.valueOf(versions.typeName), + mod = mod, + targetGameVersion = versions.targetGameVersion, + onBack = { navController.popBackStack() } + ) + } else { + GlassEmptyState( + text = "Mod info unavailable", + modifier = Modifier.fillMaxSize() + ) + } + } + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassEmptyState.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassEmptyState.kt new file mode 100644 index 0000000000..5b2222ad93 --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassEmptyState.kt @@ -0,0 +1,26 @@ +package com.tungsten.fcl.ui.glass.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@Composable +fun GlassEmptyState( + text: String, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + ) + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassGameVersionFilter.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassGameVersionFilter.kt new file mode 100644 index 0000000000..6d70911630 --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassGameVersionFilter.kt @@ -0,0 +1,23 @@ +package com.tungsten.fcl.ui.glass.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.R + +@Composable +fun GlassGameVersionFilter( + backdrop: Backdrop, + gameVersion: String, + onGameVersionChange: (String) -> Unit, + modifier: Modifier = Modifier +) { + GlassTextField( + backdrop = backdrop, + value = gameVersion, + onValueChange = onGameVersionChange, + hint = stringResource(R.string.archive_game_version), + modifier = modifier + ) +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassModListItem.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassModListItem.kt new file mode 100644 index 0000000000..8bf6ccdaea --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassModListItem.kt @@ -0,0 +1,88 @@ +package com.tungsten.fcl.ui.glass.component + +import android.os.Build +import android.widget.ImageView +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.bumptech.glide.Glide +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.effects.vibrancy +import com.tungsten.fcl.R +import com.tungsten.fcl.ui.glass.theme.GlassTheme +import com.tungsten.fclcore.mod.RemoteMod + +@Composable +fun GlassModListItem( + backdrop: Backdrop, + mod: RemoteMod, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val shape = RoundedCornerShape(GlassTheme.glassCornerRadius) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .drawBackdrop( + backdrop = backdrop, + shape = { shape }, + effects = { + vibrancy() + blur(8f.dp.toPx()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + lens(12f.dp.toPx(), 24f.dp.toPx()) + } + }, + onDrawSurface = { drawRect(GlassTheme.surfaceColor()) } + ) + .padding(16.dp) + ) { + AndroidView( + factory = { context -> + ImageView(context).apply { + scaleType = ImageView.ScaleType.CENTER_CROP + } + }, + update = { imageView -> + Glide.with(imageView.context) + .load(mod.iconUrl) + .placeholder(R.drawable.ic_outline_extension_24) + .into(imageView) + }, + modifier = Modifier.size(56.dp) + ) + Column( + modifier = Modifier + .weight(1f) + .padding(start = 12.dp) + ) { + Text( + text = mod.title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = mod.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + maxLines = 2 + ) + } + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt new file mode 100644 index 0000000000..100c8be395 --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt @@ -0,0 +1,175 @@ +package com.tungsten.fcl.ui.glass.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.toRoute +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.backdrops.layerBackdrop +import com.tungsten.fcl.setting.Profiles +import com.tungsten.fcl.ui.glass.FCLGlassRoute +import com.tungsten.fcl.ui.glass.page.HomePage +import com.tungsten.fcl.ui.glass.page.AccountPage +import com.tungsten.fcl.ui.glass.page.QuickInputPage +import com.tungsten.fcl.ui.glass.page.SettingsPage +import com.tungsten.fcl.ui.glass.page.VersionsPage +import com.tungsten.fcl.ui.glass.page.download.VersionInstallPage +import com.tungsten.fcl.ui.glass.page.java.JavaRuntimePage +import com.tungsten.fcl.ui.glass.page.java.JvmArgsPage +import com.tungsten.fcl.ui.glass.page.launcher.LauncherSettingPage +import com.tungsten.fcl.ui.glass.page.version.ModInfoPage +import com.tungsten.fcl.ui.glass.page.version.VersionModListPage +import com.tungsten.fcl.ui.glass.page.version.VersionPackListPage +import com.tungsten.fcl.ui.glass.page.version.VersionSettingsPage +import com.tungsten.fcl.ui.glass.page.version.VersionWorldListPage +import com.tungsten.fcllibrary.component.theme.ThemePreset +import com.tungsten.fcl.R + +@Composable +fun GlassNavHost( + navController: NavHostController, + backdrop: Backdrop, + currentPreset: ThemePreset, + onPresetChange: (ThemePreset) -> Unit, + modifier: Modifier = Modifier +) { + NavHost( + navController = navController, + startDestination = FCLGlassRoute.Home, + modifier = modifier.layerBackdrop(backdrop) + ) { + composable { + HomePage(backdrop = backdrop) + } + composable { + VersionsPage( + backdrop = backdrop, + navController = navController + ) + } + composable { + GlassDownloadNavHost(backdrop = backdrop) + } + composable { + AccountPage(backdrop = backdrop) + } + composable { + SettingsPage( + backdrop = backdrop, + navController = navController, + currentPreset = currentPreset, + onPresetChange = onPresetChange + ) + } + composable { + LauncherSettingPage( + backdrop = backdrop, + onBack = { navController.popBackStack() } + ) + } + composable { + JavaRuntimePage( + backdrop = backdrop, + onBack = { navController.popBackStack() } + ) + } + composable { + JvmArgsPage( + backdrop = backdrop, + onBack = { navController.popBackStack() } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + QuickInputPage( + backdrop = backdrop, + title = route.title, + initialValue = route.initialValue, + hint = route.hint, + onConfirm = { /* consumers should navigate with result or use dialog */ }, + onBack = { navController.popBackStack() } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + val profile = Profiles.profiles.find { it.name == route.profileName } ?: return@composable + VersionSettingsPage( + backdrop = backdrop, + profile = profile, + version = route.version, + navController = navController, + onBack = { navController.popBackStack() } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + val profile = Profiles.profiles.find { it.name == route.profileName } ?: return@composable + VersionInstallPage( + backdrop = backdrop, + profile = profile, + parentVersion = route.version, + onBack = { navController.popBackStack() }, + onComplete = { navController.popBackStack() } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + val profile = Profiles.profiles.find { it.name == route.profileName } ?: return@composable + VersionModListPage( + backdrop = backdrop, + profile = profile, + version = route.version, + navController = navController, + onBack = { navController.popBackStack() } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + val profile = Profiles.profiles.find { it.name == route.profileName } ?: return@composable + VersionWorldListPage( + backdrop = backdrop, + profile = profile, + version = route.version, + onBack = { navController.popBackStack() } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + val profile = Profiles.profiles.find { it.name == route.profileName } ?: return@composable + VersionPackListPage( + backdrop = backdrop, + profile = profile, + version = route.version, + folderName = "resourcepacks", + title = stringResource(R.string.resourcepack), + onBack = { navController.popBackStack() } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + val profile = Profiles.profiles.find { it.name == route.profileName } ?: return@composable + VersionPackListPage( + backdrop = backdrop, + profile = profile, + version = route.version, + folderName = "shaderpacks", + title = stringResource(R.string.shaderpack), + onBack = { navController.popBackStack() } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + val profile = Profiles.profiles.find { it.name == route.profileName } ?: return@composable + ModInfoPage( + backdrop = backdrop, + profile = profile, + version = route.version, + modFileName = route.modFileName, + onBack = { navController.popBackStack() } + ) + } + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassPagination.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassPagination.kt new file mode 100644 index 0000000000..c667ecddf9 --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassPagination.kt @@ -0,0 +1,61 @@ +package com.tungsten.fcl.ui.glass.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.R + +@Composable +fun GlassPagination( + backdrop: Backdrop, + currentPage: Int, + totalPages: Int, + onPageChange: (Int) -> Unit, + modifier: Modifier = Modifier, + tint: Color? = null +) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.fillMaxWidth() + ) { + GlassTextButton( + backdrop = backdrop, + text = stringResource(R.string.search_first_page), + onClick = { onPageChange(0) }, + tint = tint + ) + GlassTextButton( + backdrop = backdrop, + text = stringResource(R.string.button_prev), + onClick = { onPageChange((currentPage - 1).coerceAtLeast(0)) }, + tint = tint + ) + Text( + text = "${currentPage + 1} / ${if (totalPages > 0) totalPages else 1}", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface + ) + GlassTextButton( + backdrop = backdrop, + text = stringResource(R.string.button_next), + onClick = { onPageChange((currentPage + 1).coerceAtMost(totalPages - 1)) }, + tint = tint + ) + GlassTextButton( + backdrop = backdrop, + text = stringResource(R.string.search_last_page), + onClick = { onPageChange(totalPages - 1) }, + tint = tint + ) + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassPlaceholderPage.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassPlaceholderPage.kt new file mode 100644 index 0000000000..e50bea7039 --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassPlaceholderPage.kt @@ -0,0 +1,36 @@ +package com.tungsten.fcl.ui.glass.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop + +@Composable +fun GlassPlaceholderPage( + backdrop: Backdrop, + title: String, + modifier: Modifier = Modifier +) { + Column(modifier = modifier.fillMaxSize()) { + GlassTopBar(title = title) + Box( + modifier = Modifier + .weight(1f) + .padding(20.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = title, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassSearchBar.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassSearchBar.kt new file mode 100644 index 0000000000..9ee85743b2 --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassSearchBar.kt @@ -0,0 +1,81 @@ +package com.tungsten.fcl.ui.glass.component + +import android.os.Build +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.effects.vibrancy +import com.tungsten.fcl.ui.glass.theme.GlassTheme + +@Composable +fun GlassSearchBar( + backdrop: Backdrop, + query: String, + onQueryChange: (String) -> Unit, + modifier: Modifier = Modifier, + hint: String = "" +) { + val shape = RoundedCornerShape(GlassTheme.chipCornerRadius) + Box( + modifier = modifier + .fillMaxWidth() + .drawBackdrop( + backdrop = backdrop, + shape = { shape }, + effects = { + vibrancy() + blur(4f.dp.toPx()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + lens(6f.dp.toPx(), 12f.dp.toPx()) + } + }, + onDrawSurface = { drawRect(GlassTheme.surfaceColor()) } + ) + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + modifier = Modifier.padding(end = 12.dp) + ) + Box(contentAlignment = Alignment.CenterStart) { + if (query.isEmpty()) { + Text( + text = hint, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + style = MaterialTheme.typography.bodyLarge + ) + } + BasicTextField( + value = query, + onValueChange = onQueryChange, + textStyle = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurface + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + } + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassSectionTitle.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassSectionTitle.kt new file mode 100644 index 0000000000..73a214f891 --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassSectionTitle.kt @@ -0,0 +1,21 @@ +package com.tungsten.fcl.ui.glass.component + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.layout.padding + +@Composable +fun GlassSectionTitle( + text: String, + modifier: Modifier = Modifier +) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + modifier = modifier.padding(horizontal = 20.dp, vertical = 8.dp) + ) +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassSourceSelector.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassSourceSelector.kt new file mode 100644 index 0000000000..a8d9406165 --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassSourceSelector.kt @@ -0,0 +1,67 @@ +package com.tungsten.fcl.ui.glass.component + +import android.os.Build +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.effects.vibrancy +import com.tungsten.fcl.ui.glass.theme.GlassTheme + +@Composable +fun GlassSourceSelector( + backdrop: Backdrop, + sources: List, + selected: String, + onSelect: (String) -> Unit, + modifier: Modifier = Modifier, + tint: Color? = null +) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + ) { + sources.forEach { source -> + val shape = RoundedCornerShape(GlassTheme.chipCornerRadius) + val isSelected = source == selected + Text( + text = source, + color = if (isSelected && tint != null) Color.White else MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier + .clickable { onSelect(source) } + .drawBackdrop( + backdrop = backdrop, + shape = { shape }, + effects = { + vibrancy() + blur(4f.dp.toPx()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + lens(6f.dp.toPx(), 12f.dp.toPx()) + } + }, + onDrawSurface = { + if (isSelected && tint != null) { + drawRect(tint, blendMode = androidx.compose.ui.graphics.BlendMode.Hue) + drawRect(tint.copy(alpha = 0.75f)) + } else { + drawRect(GlassTheme.surfaceColor()) + } + } + ) + .padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassTextField.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassTextField.kt new file mode 100644 index 0000000000..2911cf9093 --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassTextField.kt @@ -0,0 +1,67 @@ +package com.tungsten.fcl.ui.glass.component + +import android.os.Build +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.effects.vibrancy +import com.tungsten.fcl.ui.glass.theme.GlassTheme + +@Composable +fun GlassTextField( + backdrop: Backdrop, + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + hint: String = "", + singleLine: Boolean = true +) { + val shape = RoundedCornerShape(GlassTheme.chipCornerRadius) + Box( + modifier = modifier + .fillMaxWidth() + .drawBackdrop( + backdrop = backdrop, + shape = { shape }, + effects = { + vibrancy() + blur(4f.dp.toPx()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + lens(6f.dp.toPx(), 12f.dp.toPx()) + } + }, + onDrawSurface = { drawRect(GlassTheme.surfaceColor()) } + ) + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + if (value.isEmpty()) { + Text( + text = hint, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + style = MaterialTheme.typography.bodyLarge + ) + } + BasicTextField( + value = value, + onValueChange = onValueChange, + textStyle = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurface + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + singleLine = singleLine, + modifier = Modifier.fillMaxWidth() + ) + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassTopBar.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassTopBar.kt new file mode 100644 index 0000000000..7b9e097f0e --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassTopBar.kt @@ -0,0 +1,63 @@ +package com.tungsten.fcl.ui.glass.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.tungsten.fcl.R + +@Composable +fun GlassTopBar( + title: String, + modifier: Modifier = Modifier, + onBack: (() -> Unit)? = null, + actions: @Composable RowScope.() -> Unit = {} +) { + Box( + modifier = modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = 20.dp, vertical = 16.dp), + contentAlignment = Alignment.CenterStart + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (onBack != null) { + IconButton( + onClick = onBack, + modifier = Modifier.padding(end = 8.dp) + ) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_baseline_arrow_back_24), + contentDescription = "Back", + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + Text( + text = title, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.align(Alignment.CenterEnd) + ) { + actions() + } + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassVersionItem.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassVersionItem.kt new file mode 100644 index 0000000000..215cb770fb --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassVersionItem.kt @@ -0,0 +1,138 @@ +package com.tungsten.fcl.ui.glass.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.graphics.drawable.toBitmap +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.R +import com.tungsten.fcl.ui.version.VersionListItem +import com.tungsten.fclcore.fakefx.beans.property.BooleanProperty +import com.tungsten.fclcore.fakefx.beans.value.ChangeListener +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert + +@Composable +fun GlassVersionItem( + backdrop: Backdrop, + item: VersionListItem, + tint: Color, + onLaunch: () -> Unit, + onRename: () -> Unit, + onDuplicate: () -> Unit, + onDelete: () -> Unit, + onSettings: () -> Unit, + modifier: Modifier = Modifier +) { + val selected by observeBooleanProperty(item.selectedProperty()) + var expanded by remember { mutableStateOf(false) } + + GlassCard(backdrop = backdrop, modifier = modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + val bitmap = item.drawable?.toBitmap() + if (bitmap != null) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = null, + modifier = Modifier.size(48.dp) + ) + } + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = item.version, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = item.libraries, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + if (item.tag != null) { + Text( + text = item.tag, + style = MaterialTheme.typography.labelSmall, + color = tint.copy(alpha = 0.9f) + ) + } + } + RadioButton( + selected = selected, + onClick = { item.profile.selectedVersion = item.version } + ) + Box { + IconButton(onClick = { expanded = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = null + ) + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.launch)) }, + onClick = { onLaunch(); expanded = false } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.version_manage_rename)) }, + onClick = { onRename(); expanded = false } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.version_manage_duplicate)) }, + onClick = { onDuplicate(); expanded = false } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.button_remove)) }, + onClick = { onDelete(); expanded = false } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_settings)) }, + onClick = { onSettings(); expanded = false } + ) + } + } + } + } +} + +@Composable +private fun observeBooleanProperty(property: BooleanProperty): Boolean { + var value by remember(property) { mutableStateOf(property.get()) } + DisposableEffect(property) { + val listener = ChangeListener { _, _, newValue -> value = newValue } + property.addListener(listener) + onDispose { property.removeListener(listener) } + } + return value +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDialogHost.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDialogHost.kt new file mode 100644 index 0000000000..9ccb4fbc13 --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDialogHost.kt @@ -0,0 +1,355 @@ +package com.tungsten.fcl.ui.glass.component.dialog + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.ui.glass.component.GlassButton +import com.tungsten.fcl.ui.glass.component.GlassTextButton +import com.tungsten.fcl.ui.glass.component.GlassTextField +import com.tungsten.fcllibrary.component.theme.ThemeEngine + +@Composable +fun GlassDialogHost( + backdrop: Backdrop, + modifier: Modifier = Modifier +) { + val request = GlassDialogManager.current + + AnimatedVisibility(visible = request != null) { + request?.let { currentRequest -> + Box( + modifier = modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.4f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { + when (currentRequest) { + is DialogRequest.Alert, + is DialogRequest.Input, + is DialogRequest.Selection<*> -> { + currentRequest.onDismiss() + GlassDialogManager.dismiss(currentRequest) + } + else -> { /* non-dismissible by background tap */ } + } + } + ), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .padding(horizontal = 32.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { /* consume clicks so the scrim doesn't dismiss */ } + ) + ) { + when (currentRequest) { + is DialogRequest.Alert -> GlassAlertDialog( + backdrop = backdrop, + request = currentRequest + ) + is DialogRequest.Input -> GlassInputDialog( + backdrop = backdrop, + request = currentRequest + ) + is DialogRequest.Progress -> GlassProgressDialog( + backdrop = backdrop, + request = currentRequest + ) + is DialogRequest.Selection<*> -> GlassSelectionDialog( + backdrop = backdrop, + request = currentRequest + ) + else -> GlassNotImplementedDialog( + backdrop = backdrop, + request = currentRequest + ) + } + } + } + } + } +} + +@Composable +private fun GlassAlertDialog( + backdrop: Backdrop, + request: DialogRequest.Alert +) { + val tint = Color(ThemeEngine.getInstance().getTheme().getColor()) + GlassDialogSurface(backdrop = backdrop) { + Text( + text = request.title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = request.message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + ) + Spacer(modifier = Modifier.height(20.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + request.dismissText?.let { + GlassTextButton( + backdrop = backdrop, + text = it, + onClick = { + request.onDismiss() + GlassDialogManager.dismiss(request) + }, + modifier = Modifier.weight(1f) + ) + } + GlassButton( + backdrop = backdrop, + onClick = { + request.onConfirm() + GlassDialogManager.dismiss(request) + }, + tint = tint, + modifier = Modifier.weight(1f) + ) { + Text(text = request.confirmText, color = Color.White) + } + } + } +} + +@Composable +private fun GlassInputDialog( + backdrop: Backdrop, + request: DialogRequest.Input +) { + val tint = Color(ThemeEngine.getInstance().getTheme().getColor()) + var value by remember(request.id) { mutableStateOf(request.initialValue) } + + GlassDialogSurface(backdrop = backdrop) { + Text( + text = request.title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(12.dp)) + GlassTextField( + backdrop = backdrop, + value = value, + onValueChange = { value = it }, + hint = request.hint, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(20.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (request.dismissText.isNotEmpty()) { + GlassTextButton( + backdrop = backdrop, + text = request.dismissText, + onClick = { + request.onDismiss() + GlassDialogManager.dismiss(request) + }, + modifier = Modifier.weight(1f) + ) + } + GlassButton( + backdrop = backdrop, + onClick = { + request.onConfirm(value) + GlassDialogManager.dismiss(request) + }, + tint = tint, + modifier = Modifier.weight(1f) + ) { + Text(text = request.confirmText, color = Color.White) + } + } + } +} + +@Composable +private fun GlassProgressDialog( + backdrop: Backdrop, + request: DialogRequest.Progress +) { + GlassDialogSurface(backdrop = backdrop) { + Text( + text = request.title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface + ) + if (request.message.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = request.message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + ) + } + Spacer(modifier = Modifier.height(16.dp)) + val progressValue = request.progress + if (progressValue != null) { + LinearProgressIndicator( + progress = { progressValue }, + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant + ) + } else { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant + ) + } + request.onCancel?.let { onCancel -> + Spacer(modifier = Modifier.height(16.dp)) + GlassTextButton( + backdrop = backdrop, + text = "Cancel", + onClick = { + onCancel() + GlassDialogManager.dismiss(request) + }, + modifier = Modifier.align(Alignment.End) + ) + } + } +} + +@Composable +private fun GlassSelectionDialog( + backdrop: Backdrop, + request: DialogRequest.Selection<*> +) { + var selected by remember(request.id) { mutableStateOf(request.selected) } + + GlassDialogSurface(backdrop = backdrop) { + Text( + text = request.title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(12.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + ) { + request.items.forEach { item -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { selected = item } + .padding(vertical = 4.dp) + ) { + RadioButton( + selected = selected == item, + onClick = { selected = item } + ) + Text( + text = request.itemText(item), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = 8.dp) + ) + } + } + } + Spacer(modifier = Modifier.height(20.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (request.dismissText.isNotEmpty()) { + GlassTextButton( + backdrop = backdrop, + text = request.dismissText, + onClick = { + request.onDismiss() + GlassDialogManager.dismiss(request) + }, + modifier = Modifier.weight(1f) + ) + } + request.confirmText?.let { confirmText -> + GlassTextButton( + backdrop = backdrop, + text = confirmText, + onClick = { + selected?.let { + @Suppress("UNCHECKED_CAST") + (request.onSelect as (Any?) -> Unit)(it) + } + GlassDialogManager.dismiss(request) + }, + modifier = Modifier.weight(1f) + ) + } + } + } +} + +@Composable +private fun GlassNotImplementedDialog( + backdrop: Backdrop, + request: DialogRequest +) { + GlassDialogSurface(backdrop = backdrop) { + Text( + text = "Not implemented", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "This dialog type is not implemented yet.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + ) + Spacer(modifier = Modifier.height(20.dp)) + GlassTextButton( + backdrop = backdrop, + text = "OK", + onClick = { GlassDialogManager.dismiss(request) }, + modifier = Modifier.align(Alignment.End) + ) + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDialogManager.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDialogManager.kt new file mode 100644 index 0000000000..f298fd9f9c --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDialogManager.kt @@ -0,0 +1,153 @@ +package com.tungsten.fcl.ui.glass.component.dialog + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.tungsten.fcl.setting.Profile +import com.tungsten.fclcore.auth.Account +import com.tungsten.fclcore.auth.AccountFactory + +sealed class DialogRequest( + val id: String = java.util.UUID.randomUUID().toString() +) { + data class Alert( + val title: String, + val message: String, + val confirmText: String, + val dismissText: String? = null, + val onConfirm: () -> Unit = {}, + val onDismiss: () -> Unit = {} + ) : DialogRequest() + + data class Input( + val title: String, + val initialValue: String = "", + val hint: String = "", + val confirmText: String, + val dismissText: String = "", + val onConfirm: (String) -> Unit, + val onDismiss: () -> Unit = {} + ) : DialogRequest() + + data class Selection( + val title: String, + val items: List, + val selected: T? = null, + val itemText: (T) -> String, + val confirmText: String? = null, + val dismissText: String = "", + val onSelect: (T) -> Unit, + val onDismiss: () -> Unit = {} + ) : DialogRequest() + + data class Progress( + val title: String, + val message: String = "", + val progress: Float? = null, + val onCancel: (() -> Unit)? = null + ) : DialogRequest() + + data class CreateAccount( + val factory: AccountFactory<*>? = null, + val onDismiss: () -> Unit = {} + ) : DialogRequest() + + data class ReLogin( + val account: Account, + val onDismiss: () -> Unit = {} + ) : DialogRequest() + + data class AuthlibServer( + val onDismiss: () -> Unit = {} + ) : DialogRequest() + + data class RenameVersion( + val profile: Profile, + val version: String, + val onDismiss: () -> Unit = {} + ) : DialogRequest() + + data class DuplicateVersion( + val profile: Profile, + val version: String, + val onDismiss: () -> Unit = {} + ) : DialogRequest() + + data class AddProfile( + val onDismiss: () -> Unit = {} + ) : DialogRequest() +} + +object GlassDialogManager { + private val _requests = mutableStateListOf() + val requests: List get() = _requests + + var current by mutableStateOf(null) + private set + + fun show(request: DialogRequest) { + _requests.add(request) + if (current == null) current = request + } + + fun dismiss(request: DialogRequest? = current) { + request?.let { _requests.remove(it) } + current = _requests.firstOrNull() + } + + fun showAlert( + title: String, + message: String, + confirmText: String, + dismissText: String? = null, + onConfirm: () -> Unit = {}, + onDismiss: () -> Unit = {} + ) = show(DialogRequest.Alert(title, message, confirmText, dismissText, onConfirm, onDismiss)) + + fun showInput( + title: String, + initialValue: String = "", + hint: String = "", + confirmText: String, + dismissText: String = "", + onConfirm: (String) -> Unit, + onDismiss: () -> Unit = {} + ) = show(DialogRequest.Input(title, initialValue, hint, confirmText, dismissText, onConfirm, onDismiss)) + + fun showSelection( + title: String, + items: List, + selected: T? = null, + itemText: (T) -> String = { it.toString() }, + confirmText: String? = null, + dismissText: String = "", + onSelect: (T) -> Unit, + onDismiss: () -> Unit = {} + ) = show(DialogRequest.Selection(title, items, selected, itemText, confirmText, dismissText, onSelect, onDismiss)) + + fun showProgress( + title: String, + message: String = "", + progress: Float? = null, + onCancel: (() -> Unit)? = null + ) = show(DialogRequest.Progress(title, message, progress, onCancel)) + + fun showCreateAccount(factory: AccountFactory<*>? = null, onDismiss: () -> Unit = {}) = + show(DialogRequest.CreateAccount(factory, onDismiss)) + + fun showReLogin(account: Account, onDismiss: () -> Unit = {}) = + show(DialogRequest.ReLogin(account, onDismiss)) + + fun showAuthlibServer(onDismiss: () -> Unit = {}) = + show(DialogRequest.AuthlibServer(onDismiss)) + + fun showRenameVersion(profile: Profile, version: String, onDismiss: () -> Unit = {}) = + show(DialogRequest.RenameVersion(profile, version, onDismiss)) + + fun showDuplicateVersion(profile: Profile, version: String, onDismiss: () -> Unit = {}) = + show(DialogRequest.DuplicateVersion(profile, version, onDismiss)) + + fun showAddProfile(onDismiss: () -> Unit = {}) = + show(DialogRequest.AddProfile(onDismiss)) +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDialogSurface.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDialogSurface.kt new file mode 100644 index 0000000000..2165b74909 --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDialogSurface.kt @@ -0,0 +1,47 @@ +package com.tungsten.fcl.ui.glass.component.dialog + +import android.os.Build +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.effects.vibrancy +import com.tungsten.fcl.ui.glass.theme.GlassTheme + +@Composable +fun GlassDialogSurface( + backdrop: Backdrop, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit +) { + val shape = RoundedCornerShape(GlassTheme.glassCornerRadius) + Column( + modifier = modifier + .fillMaxWidth() + .clip(shape) + .drawBackdrop( + backdrop = backdrop, + shape = { shape }, + effects = { + vibrancy() + blur(12f.dp.toPx()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + lens(12f.dp.toPx(), 24f.dp.toPx()) + } + }, + onDrawSurface = { drawRect(GlassTheme.surfaceColor()) } + ) + .padding(20.dp) + ) { + content() + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/AccountPage.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/AccountPage.kt new file mode 100644 index 0000000000..3e8f450e2f --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/AccountPage.kt @@ -0,0 +1,112 @@ +package com.tungsten.fcl.ui.glass.page + +import android.content.Context +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.R +import com.tungsten.fcl.setting.Accounts +import com.tungsten.fcl.ui.account.AccountListItem +import com.tungsten.fcl.ui.account.CreateAccountDialog +import com.tungsten.fcl.ui.glass.component.GlassAccountItem +import com.tungsten.fcl.ui.glass.component.GlassButton +import com.tungsten.fcl.ui.glass.component.GlassTopBar +import com.tungsten.fcl.ui.glass.page.account.AccountListStateHolder +import com.tungsten.fcllibrary.component.theme.ThemeEngine +import com.tungsten.fclcore.auth.Account +import com.tungsten.fclcore.auth.CredentialExpiredException +import com.tungsten.fclcore.task.Schedulers +import com.tungsten.fclcore.task.Task + +@Composable +fun AccountPage( + backdrop: Backdrop, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val tintColor = Color(ThemeEngine.getInstance().getTheme().getColor()) + val stateHolder = remember { AccountListStateHolder() } + + DisposableEffect(Unit) { + stateHolder.startObserving() + onDispose { stateHolder.stopObserving() } + } + + Column(modifier = modifier.fillMaxSize()) { + GlassTopBar(title = stringResource(R.string.account)) + + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 20.dp, vertical = 8.dp) + ) { + items(stateHolder.accounts, key = { it.identifier }) { account -> + GlassAccountItem( + backdrop = backdrop, + account = account, + selected = stateHolder.selectedAccount == account, + onSelect = { stateHolder.select(account) }, + onRefresh = { refreshAccount(context, account) }, + onDelete = { stateHolder.remove(account) } + ) + } + } + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp) + ) { + GlassButton( + backdrop = backdrop, + onClick = { CreateAccountDialog(context, Accounts.FACTORY_OFFLINE).show() }, + tint = tintColor, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(R.string.account_create_offline), color = Color.White) + } + GlassButton( + backdrop = backdrop, + onClick = { CreateAccountDialog(context, Accounts.FACTORY_MICROSOFT).show() }, + tint = tintColor, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(R.string.account_create_microsoft), color = Color.White) + } + } + } +} + +private fun refreshAccount(context: Context, account: Account) { + Task.runAsync { + account.clearCache() + try { + account.logIn() + } catch (e: CredentialExpiredException) { + try { + AccountListItem.logIn(account) + } catch (e1: java.util.concurrent.CancellationException) { + // ignore cancellation + } + } + }.whenComplete(Schedulers.androidUIThread()) { _, exception -> + if (exception != null) { + Toast.makeText(context, exception.message, Toast.LENGTH_SHORT).show() + } + }.start() +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/DownloadPage.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/DownloadPage.kt new file mode 100644 index 0000000000..452f78328b --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/DownloadPage.kt @@ -0,0 +1,142 @@ +package com.tungsten.fcl.ui.glass.page + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.R +import com.tungsten.fcl.ui.glass.FCLGlassDownloadRoute +import com.tungsten.fcl.ui.glass.component.GlassCard +import com.tungsten.fcl.ui.glass.component.GlassSectionTitle +import com.tungsten.fcl.ui.glass.component.GlassTopBar +import com.tungsten.fcllibrary.component.theme.ThemeEngine + +@Composable +fun DownloadPage( + backdrop: Backdrop, + onNavigate: (FCLGlassDownloadRoute) -> Unit = {}, + modifier: Modifier = Modifier +) { + val tintColor = Color(ThemeEngine.getInstance().getTheme().getColor()) + val categories = listOf( + DownloadCategory( + title = stringResource(R.string.install_installer_game), + iconRes = R.drawable.ic_cube, + route = FCLGlassDownloadRoute.VersionInstallList + ), + DownloadCategory( + title = stringResource(R.string.mods), + iconRes = R.drawable.ic_outline_extension_24, + route = FCLGlassDownloadRoute.ModDownload() + ), + DownloadCategory( + title = stringResource(R.string.modpack), + iconRes = R.drawable.ic_baseline_application_24, + route = FCLGlassDownloadRoute.ModpackDownload() + ), + DownloadCategory( + title = stringResource(R.string.resourcepack), + iconRes = R.drawable.ic_baseline_texture_24, + route = FCLGlassDownloadRoute.ResourcePackDownload() + ), + DownloadCategory( + title = stringResource(R.string.shaderpack), + iconRes = R.drawable.ic_baseline_tune_24, + route = FCLGlassDownloadRoute.ShaderPackDownload() + ) + ) + + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + GlassTopBar(title = stringResource(R.string.download)) + + GlassSectionTitle(text = "Categories") + + LazyVerticalGrid( + columns = GridCells.Fixed(2), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.height(360.dp) + ) { + items(categories) { category -> + CategoryCard( + backdrop = backdrop, + category = category, + tint = tintColor, + onClick = { onNavigate(category.route) } + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + } +} + +private data class DownloadCategory( + val title: String, + val iconRes: Int, + val route: FCLGlassDownloadRoute +) + +@Composable +private fun CategoryCard( + backdrop: Backdrop, + category: DownloadCategory, + tint: Color, + onClick: () -> Unit +) { + GlassCard( + backdrop = backdrop, + modifier = Modifier + .fillMaxWidth() + .height(160.dp) + .clickable(onClick = onClick), + tint = tint + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxSize() + ) { + Icon( + imageVector = ImageVector.vectorResource(id = category.iconRes), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(48.dp) + ) + Text( + text = category.title, + color = Color.White, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 12.dp) + ) + } + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/HomePage.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/HomePage.kt new file mode 100644 index 0000000000..147cbd5af1 --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/HomePage.kt @@ -0,0 +1,207 @@ +package com.tungsten.fcl.ui.glass.page + +import android.content.Context +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.R +import com.tungsten.fcl.setting.Accounts +import com.tungsten.fcl.setting.Profiles +import com.tungsten.fcl.ui.glass.component.GlassButton +import com.tungsten.fcl.ui.glass.component.GlassCard +import com.tungsten.fcl.ui.glass.component.GlassSectionTitle +import com.tungsten.fcl.ui.glass.component.GlassTopBar +import com.tungsten.fcl.ui.main.Announcement +import com.tungsten.fcl.util.AndroidUtils +import com.tungsten.fcllibrary.component.theme.ThemeEngine +import com.tungsten.fcllibrary.util.LocaleUtils +import com.tungsten.fclcore.task.Schedulers +import com.tungsten.fclcore.task.Task +import com.tungsten.fclcore.util.io.HttpRequest + +@Composable +fun HomePage( + backdrop: Backdrop, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val tintColor = Color(ThemeEngine.getInstance().getTheme().getColor()) + var announcement by remember { mutableStateOf(null) } + var announcementVisible by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + loadAnnouncement(context) { loaded -> + if (loaded.shouldDisplay(context)) { + announcement = loaded + announcementVisible = true + } + } + } + + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + GlassTopBar(title = stringResource(R.string.app_name)) + + if (announcementVisible && announcement != null) { + AnnouncementCard( + backdrop = backdrop, + announcement = announcement!!, + onDismiss = { + announcementVisible = false + announcement?.hide(context) + } + ) + } + + GlassSectionTitle(text = stringResource(R.string.account)) + AccountCard(backdrop = backdrop, tint = tintColor) + + Spacer(modifier = Modifier.height(24.dp)) + + GlassSectionTitle(text = stringResource(R.string.launch)) + GlassButton( + backdrop = backdrop, + onClick = { + com.tungsten.fcl.ui.version.Versions.launch( + context, + Profiles.getSelectedProfile() + ) + }, + tint = tintColor, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + ) { + Text( + text = stringResource(R.string.launch), + color = Color.White, + style = MaterialTheme.typography.titleMedium + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + } +} + +@Composable +private fun AnnouncementCard( + backdrop: Backdrop, + announcement: Announcement, + onDismiss: () -> Unit +) { + val context = LocalContext.current + GlassCard( + backdrop = backdrop, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp) + ) { + Column { + Text( + text = AndroidUtils.getLocalizedText( + context, + "announcement", + announcement.getDisplayTitle(context) + ), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = announcement.getDisplayContent(context), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + modifier = Modifier.padding(top = 4.dp) + ) + Text( + text = AndroidUtils.getLocalizedText(context, "update_date", announcement.getDate()), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + modifier = Modifier.padding(top = 8.dp) + ) + Text( + text = stringResource(R.string.button_hide), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .padding(top = 12.dp) + .clickable(onClick = onDismiss) + ) + } + } +} + +@Composable +private fun AccountCard( + backdrop: Backdrop, + tint: Color +) { + val account = Accounts.getSelectedAccount() + GlassCard( + backdrop = backdrop, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + tint = tint + ) { + Column { + Text( + text = account?.username ?: stringResource(R.string.account_state_no_account), + style = MaterialTheme.typography.titleLarge, + color = Color.White + ) + Text( + text = account?.let { + Accounts.getLocalizedLoginTypeName( + LocalContext.current, + Accounts.getAccountFactory(it) + ) + } ?: stringResource(R.string.account_methods_offline), + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = 0.8f), + modifier = Modifier.padding(top = 4.dp) + ) + } + } +} + +private fun loadAnnouncement(context: Context, onLoaded: (Announcement) -> Unit) { + try { + val url = if (LocaleUtils.isChinese(context)) { + com.tungsten.fcl.ui.main.MainUI.ANNOUNCEMENT_URL_CN + } else { + com.tungsten.fcl.ui.main.MainUI.ANNOUNCEMENT_URL + } + Task.supplyAsync { + HttpRequest.HttpGetRequest.GET(url).getJson(Announcement::class.java) + }.thenAcceptAsync(Schedulers.androidUIThread()) { loaded -> + if (loaded != null) { + onLoaded(loaded) + } + }.start() + } catch (e: Exception) { + e.printStackTrace() + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/ManagePage.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/ManagePage.kt new file mode 100644 index 0000000000..5643a6e94e --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/ManagePage.kt @@ -0,0 +1,28 @@ +package com.tungsten.fcl.ui.glass.page + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.sp +import com.kyant.backdrop.Backdrop + +@Composable +fun ManagePage( + backdrop: Backdrop, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "Manage", + color = Color.White, + fontSize = 24.sp + ) + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/QuickInputPage.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/QuickInputPage.kt new file mode 100644 index 0000000000..10abd50f4f --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/QuickInputPage.kt @@ -0,0 +1,67 @@ +package com.tungsten.fcl.ui.glass.page + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.ui.glass.component.GlassButton +import com.tungsten.fcl.ui.glass.component.GlassTextField +import com.tungsten.fcl.ui.glass.component.GlassTopBar + +@Composable +fun QuickInputPage( + backdrop: Backdrop, + title: String, + initialValue: String, + hint: String, + onConfirm: (String) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier +) { + var value by rememberSaveable { mutableStateOf(initialValue) } + + Column(modifier = modifier.fillMaxSize()) { + GlassTopBar( + title = title, + onBack = onBack + ) + + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 20.dp, vertical = 8.dp) + ) { + GlassTextField( + backdrop = backdrop, + value = value, + onValueChange = { value = it }, + hint = hint, + singleLine = false, + modifier = Modifier.fillMaxWidth() + ) + } + + GlassButton( + backdrop = backdrop, + onClick = { + onConfirm(value) + onBack() + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp) + ) { + Text(text = "OK", color = Color.White) + } + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/SettingsPage.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/SettingsPage.kt new file mode 100644 index 0000000000..991a1efc2f --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/SettingsPage.kt @@ -0,0 +1,264 @@ +package com.tungsten.fcl.ui.glass.page + +import android.content.Context +import androidx.appcompat.app.AppCompatDelegate +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.navigation.NavController +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.R +import com.tungsten.fcl.ui.glass.FCLGlassRoute +import com.tungsten.fcl.ui.glass.component.GlassCard +import com.tungsten.fcl.ui.glass.component.GlassChip +import com.tungsten.fcl.ui.glass.component.GlassSectionTitle +import com.tungsten.fcl.ui.glass.component.GlassTopBar +import com.tungsten.fcl.ui.setting.compose.setLiquidGlassPreview +import com.tungsten.fcllibrary.component.theme.ThemeEngine +import com.tungsten.fcllibrary.component.theme.ThemePreset + +@Composable +fun SettingsPage( + backdrop: Backdrop, + navController: NavController, + currentPreset: ThemePreset, + onPresetChange: (ThemePreset) -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val sharedPreferences = remember { context.getSharedPreferences("launcher", Context.MODE_PRIVATE) } + var themeMode by remember { mutableIntStateOf(sharedPreferences.getInt("themeMode", 0)) } + + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + GlassTopBar(title = stringResource(R.string.menu_settings)) + + ThemePreviewCard( + backdrop = backdrop, + preset = currentPreset, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp) + ) + + GlassSectionTitle(text = stringResource(R.string.settings_launcher_theme_preset)) + PresetSelector( + backdrop = backdrop, + current = currentPreset, + onSelect = { preset -> + onPresetChange(preset) + ThemeEngine.getInstance().applyAndSave(context, preset.getColor()) + ThemeEngine.getInstance().applyAndSave2(context, preset.getColor2()) + ThemeEngine.getInstance().applyAndSave2Dark(context, preset.getColor2Dark()) + }, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp) + ) + + GlassSectionTitle(text = stringResource(R.string.settings_launcher_theme_mode)) + ThemeModeSelector( + backdrop = backdrop, + selected = themeMode, + onSelect = { mode -> + themeMode = mode + sharedPreferences.edit().putInt("themeMode", mode).apply() + val nightMode = when (mode) { + 1 -> AppCompatDelegate.MODE_NIGHT_NO + 2 -> AppCompatDelegate.MODE_NIGHT_YES + else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + } + AppCompatDelegate.setDefaultNightMode(nightMode) + }, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp) + ) + + GlassSectionTitle(text = stringResource(R.string.settings_launcher)) + GlassCard( + backdrop = backdrop, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp) + .clickable { navController.navigate(FCLGlassRoute.LauncherSettings) } + ) { + Text( + text = stringResource(R.string.settings_launcher), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + + GlassCard( + backdrop = backdrop, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp) + .clickable { navController.navigate(FCLGlassRoute.JavaRuntime) } + ) { + Text( + text = "Java Runtime", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + + GlassCard( + backdrop = backdrop, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp) + .clickable { navController.navigate(FCLGlassRoute.JvmArgs) } + ) { + Text( + text = stringResource(R.string.settings_advanced_jvm_args), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + + GlassSectionTitle(text = stringResource(R.string.about)) + GlassCard( + backdrop = backdrop, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp) + ) { + Column { + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = rememberAppVersion(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + } +} + +@Composable +private fun rememberAppVersion(): String { + val context = LocalContext.current + return remember { + try { + context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "" + } catch (e: Exception) { + "" + } + } +} + +@Composable +private fun ThemePreviewCard( + backdrop: Backdrop, + preset: ThemePreset, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val isDarkMode = (context.resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) == + android.content.res.Configuration.UI_MODE_NIGHT_YES + GlassCard(backdrop = backdrop, modifier = modifier.fillMaxWidth()) { + Column { + Text( + text = stringResource(R.string.settings_launcher_theme_preview), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(12.dp)) + AndroidView( + factory = { ctx -> + ComposeView(ctx).apply { + setLiquidGlassPreview(preset, isDarkMode) + } + }, + update = { view -> + view.setLiquidGlassPreview(preset, isDarkMode) + }, + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + ) + } + } +} + +@Composable +private fun PresetSelector( + backdrop: Backdrop, + current: ThemePreset, + onSelect: (ThemePreset) -> Unit, + modifier: Modifier = Modifier +) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier.fillMaxWidth() + ) { + ThemePreset.values().forEach { preset -> + val resId = LocalContext.current.resources.getIdentifier( + "settings_launcher_theme_preset_" + preset.name.lowercase().replace(" ", "_"), + "string", + LocalContext.current.packageName + ) + val label = if (resId != 0) stringResource(resId) else preset.name + GlassChip( + backdrop = backdrop, + text = label, + selected = preset == current, + onClick = { onSelect(preset) }, + tint = Color(preset.getColor()) + ) + } + } +} + +@Composable +private fun ThemeModeSelector( + backdrop: Backdrop, + selected: Int, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier +) { + val modes = listOf( + 0 to R.string.settings_launcher_theme_mode_follow, + 1 to R.string.settings_launcher_theme_mode_light, + 2 to R.string.settings_launcher_theme_mode_dark + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier.fillMaxWidth() + ) { + modes.forEach { (mode, res) -> + GlassChip( + backdrop = backdrop, + text = stringResource(res), + selected = selected == mode, + onClick = { onSelect(mode) } + ) + } + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/VersionsPage.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/VersionsPage.kt new file mode 100644 index 0000000000..1c4d068619 --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/VersionsPage.kt @@ -0,0 +1,203 @@ +package com.tungsten.fcl.ui.glass.page + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.R +import com.tungsten.fcl.setting.Profile +import com.tungsten.fcl.setting.Profiles +import com.tungsten.fcl.ui.glass.FCLGlassRoute +import com.tungsten.fcl.ui.glass.component.GlassCard +import com.tungsten.fcl.ui.glass.component.GlassChip +import com.tungsten.fcl.ui.glass.component.GlassEmptyState +import com.tungsten.fcl.ui.glass.component.GlassSearchBar +import com.tungsten.fcl.ui.glass.component.GlassTopBar +import com.tungsten.fcl.ui.glass.component.GlassVersionItem +import com.tungsten.fcl.ui.glass.page.versions.VersionCategory +import com.tungsten.fcl.ui.glass.page.versions.VersionListStateHolder +import com.tungsten.fcllibrary.component.theme.ThemeEngine +import java.util.function.Consumer + +@Composable +fun VersionsPage( + backdrop: Backdrop, + navController: NavController, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val state = remember { VersionListStateHolder() } + val tintColor = Color(ThemeEngine.getInstance().getTheme().getColor()) + + LaunchedEffect(state.selectedProfile) { + state.loadVersions(context, state.selectedProfile) + } + + DisposableEffect(Unit) { + val listener = Consumer { profile -> + if (profile == state.selectedProfile) { + state.loadVersions(context, profile) + } + } + Profiles.registerVersionsListener(listener) + onDispose { Profiles.unregisterVersionsListener(listener) } + } + + val profiles = remember { Profiles.profiles } + + Box(modifier = modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { + GlassTopBar(title = stringResource(R.string.version)) + + ProfileSelector( + backdrop = backdrop, + profiles = profiles, + selected = state.selectedProfile, + onSelect = { + Profiles.setSelectedProfile(it) + state.selectedProfile = it + state.refresh() + }, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp) + ) + + GlassSearchBar( + backdrop = backdrop, + query = state.query, + onQueryChange = { state.query = it }, + hint = stringResource(R.string.search), + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp) + ) + + FilterChips( + backdrop = backdrop, + selected = state.selectedCategory, + onSelect = { state.selectedCategory = it }, + tint = tintColor, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp) + ) + + val filtered = state.filteredVersions() + if (filtered.isEmpty() && !state.isLoading) { + GlassEmptyState( + text = stringResource(R.string.download_failed_empty), + modifier = Modifier.weight(1f) + ) + } else { + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 20.dp, vertical = 8.dp) + ) { + items(filtered, key = { it.version }) { item -> + GlassVersionItem( + backdrop = backdrop, + item = item, + tint = tintColor, + onLaunch = { state.launchVersion(context, item) }, + onRename = { state.renameVersion(context, item) }, + onDuplicate = { state.duplicateVersion(context, item) }, + onDelete = { state.deleteVersion(context, item) }, + onSettings = { + navController.navigate(FCLGlassRoute.VersionSettings(item.profile.name, item.version)) + } + ) + } + } + } + } + + FloatingActionButton( + onClick = { + navController.navigate(FCLGlassRoute.VersionInstall(state.selectedProfile.name)) + }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 20.dp, bottom = 96.dp) + ) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_baseline_download_24), + contentDescription = "Install" + ) + } + } +} + +@Composable +private fun ProfileSelector( + backdrop: Backdrop, + profiles: List, + selected: Profile, + onSelect: (Profile) -> Unit, + modifier: Modifier = Modifier +) { + GlassCard(backdrop = backdrop, modifier = modifier.fillMaxWidth()) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + profiles.forEach { profile -> + GlassChip( + backdrop = backdrop, + text = profile.name, + selected = profile == selected, + onClick = { onSelect(profile) } + ) + } + } + } +} + +@Composable +private fun FilterChips( + backdrop: Backdrop, + selected: VersionCategory, + onSelect: (VersionCategory) -> Unit, + tint: Color, + modifier: Modifier = Modifier +) { + val categories = listOf( + VersionCategory.ALL to R.string.curse_category_0, + VersionCategory.FABRIC to R.string.install_installer_fabric, + VersionCategory.FORGE to R.string.install_installer_forge, + VersionCategory.NEOFORGE to R.string.install_installer_neoforge, + VersionCategory.OTHER to R.string.control_download_device_other + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + ) { + categories.forEach { (cat, res) -> + GlassChip( + backdrop = backdrop, + text = stringResource(res), + selected = selected == cat, + onClick = { onSelect(cat) }, + tint = tint + ) + } + } +} + + diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/account/AccountListStateHolder.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/account/AccountListStateHolder.kt new file mode 100644 index 0000000000..88d403708c --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/account/AccountListStateHolder.kt @@ -0,0 +1,55 @@ +package com.tungsten.fcl.ui.glass.page.account + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.tungsten.fcl.setting.Accounts +import com.tungsten.fclcore.auth.Account +import com.tungsten.fclcore.fakefx.beans.value.ChangeListener +import com.tungsten.fclcore.fakefx.collections.ListChangeListener + +class AccountListStateHolder { + val accounts = mutableStateListOf() + var selectedAccount: Account? by mutableStateOf(Accounts.getSelectedAccount()) + private set + + private var listListener: ListChangeListener? = null + private var selectedListener: ChangeListener? = null + + fun startObserving() { + accounts.clear() + accounts.addAll(Accounts.getAccounts()) + selectedAccount = Accounts.getSelectedAccount() + + listListener = ListChangeListener { change -> + while (change.next()) { + if (change.wasAdded()) { + accounts.addAll(change.addedSubList) + } + if (change.wasRemoved()) { + accounts.removeAll(change.removed.toSet()) + } + } + } + selectedListener = ChangeListener { _, _, new -> + selectedAccount = new + } + + Accounts.getAccounts().addListener(listListener) + Accounts.selectedAccountProperty().addListener(selectedListener) + } + + fun stopObserving() { + listListener?.let { Accounts.getAccounts().removeListener(it) } + selectedListener?.let { Accounts.selectedAccountProperty().removeListener(it) } + } + + fun select(account: Account) { + Accounts.setSelectedAccount(account) + } + + fun remove(account: Account) { + Accounts.getAccounts().remove(account) + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/InstallerItemGroupCompose.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/InstallerItemGroupCompose.kt new file mode 100644 index 0000000000..54ea9d48a1 --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/InstallerItemGroupCompose.kt @@ -0,0 +1,178 @@ +package com.tungsten.fcl.ui.glass.page.download + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.graphics.drawable.toBitmap +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.R +import com.tungsten.fcl.ui.InstallerItem +import com.tungsten.fcl.util.AndroidUtils +import com.tungsten.fcl.ui.glass.component.GlassButton +import com.tungsten.fcl.ui.glass.component.GlassCard +import com.tungsten.fclcore.fakefx.beans.InvalidationListener +import com.tungsten.fclcore.fakefx.beans.value.ChangeListener + +@Composable +fun InstallerItemGroupCompose( + backdrop: Backdrop, + group: InstallerItem.InstallerItemGroup, + tint: Color, + onSelectLibrary: (InstallerItem, String) -> Unit, + onRemoveLibrary: (InstallerItem, String) -> Unit, + modifier: Modifier = Modifier +) { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier + ) { + group.getLibraries().forEach { item -> + InstallerItemCard( + backdrop = backdrop, + item = item, + tint = tint, + onSelect = { onSelectLibrary(item, item.getLibraryId()) }, + onRemove = { onRemoveLibrary(item, item.getLibraryId()) } + ) + } + } +} + +@Composable +private fun InstallerItemCard( + backdrop: Backdrop, + item: InstallerItem, + tint: Color, + onSelect: () -> Unit, + onRemove: () -> Unit +) { + val context = LocalContext.current + var version by remember { mutableStateOf(item.libraryVersion.get() ?: "") } + var incompatible by remember { mutableStateOf(item.incompatibleLibraryName.get()) } + var dependency by remember { mutableStateOf(item.dependencyName.get()) } + var removable by remember { mutableStateOf(item.removable.get()) } + var incompatibleWithGame by remember { mutableStateOf(item.incompatibleWithGame.get()) } + var installable by remember { mutableStateOf(item.installable.get()) } + + DisposableEffect(item) { + val versionListener = InvalidationListener { version = item.libraryVersion.get() ?: "" } + val incompatibleListener = InvalidationListener { incompatible = item.incompatibleLibraryName.get() } + val dependencyListener = InvalidationListener { dependency = item.dependencyName.get() } + val removableListener = ChangeListener { _, _, new -> removable = new } + val incompatibleWithGameListener = ChangeListener { _, _, new -> incompatibleWithGame = new } + val installableListener = ChangeListener { _, _, new -> installable = new } + + item.libraryVersion.addListener(versionListener) + item.incompatibleLibraryName.addListener(incompatibleListener) + item.dependencyName.addListener(dependencyListener) + item.removable.addListener(removableListener) + item.incompatibleWithGame.addListener(incompatibleWithGameListener) + item.installable.addListener(installableListener) + + onDispose { + item.libraryVersion.removeListener(versionListener) + item.incompatibleLibraryName.removeListener(incompatibleListener) + item.dependencyName.removeListener(dependencyListener) + item.removable.removeListener(removableListener) + item.incompatibleWithGame.removeListener(incompatibleWithGameListener) + item.installable.removeListener(installableListener) + } + } + + val iconBitmap = item.getIcon()?.toBitmap() + val canSelect = installable && !incompatibleWithGame && incompatible == null && dependency == null + + GlassCard(backdrop = backdrop, modifier = Modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + if (iconBitmap != null) { + Image( + bitmap = iconBitmap.asImageBitmap(), + contentDescription = null, + modifier = Modifier.size(40.dp) + ) + } + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 12.dp) + ) { + Text( + text = item.getName(), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = when { + incompatibleWithGame -> context.getString(R.string.install_installer_change_version, version) + dependency != null -> context.getString( + R.string.install_installer_depend, + getLocalizedLoaderName(context, dependency) + ) + incompatible != null -> context.getString( + R.string.install_installer_incompatible, + getLocalizedLoaderName(context, incompatible) + ) + version.isEmpty() -> context.getString(R.string.install_installer_not_installed) + else -> version + }, + style = MaterialTheme.typography.bodySmall, + color = when { + incompatibleWithGame || dependency != null || incompatible != null -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + } + ) + } + if (removable && version.isNotEmpty()) { + GlassButton( + backdrop = backdrop, + onClick = onRemove, + modifier = Modifier.padding(end = 8.dp) + ) { + Text( + text = stringResource(R.string.button_remove), + color = MaterialTheme.colorScheme.onSurface + ) + } + } + GlassButton( + backdrop = backdrop, + onClick = onSelect, + tint = if (canSelect) tint else null, + enabled = canSelect + ) { + Text( + text = if (version.isNotEmpty()) stringResource(R.string.install_change_version) else stringResource(R.string.menu_control_select), + color = if (canSelect) Color.White else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) + ) + } + } + } +} + +private fun getLocalizedLoaderName(context: android.content.Context, patchId: String?): String { + if (patchId == null) return "" + val key = "install_installer_" + patchId.replace(".", "_").replace("-", "_") + return AndroidUtils.getLocalizedText(context, key) +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/InstallerVersionSelectPage.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/InstallerVersionSelectPage.kt new file mode 100644 index 0000000000..b74cb4f129 --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/InstallerVersionSelectPage.kt @@ -0,0 +1,123 @@ +package com.tungsten.fcl.ui.glass.page.download + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.R +import com.tungsten.fcl.setting.DownloadProviders +import com.tungsten.fcl.ui.glass.component.GlassButton +import com.tungsten.fcl.ui.glass.component.GlassCard +import com.tungsten.fcl.ui.glass.component.GlassEmptyState +import com.tungsten.fcl.ui.glass.component.GlassTopBar +import com.tungsten.fcllibrary.component.theme.ThemeEngine +import com.tungsten.fclcore.download.RemoteVersion +import com.tungsten.fclcore.download.VersionList +import com.tungsten.fclcore.task.Schedulers + +@Composable +fun InstallerVersionSelectPage( + backdrop: Backdrop, + gameVersion: String, + libraryId: String, + onVersionSelected: (RemoteVersion) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier +) { + val tintColor = Color(ThemeEngine.getInstance().getTheme().getColor()) + var isLoading by remember { mutableStateOf(true) } + var versions by remember { mutableStateOf>(emptyList()) } + var error by remember { mutableStateOf(null) } + + LaunchedEffect(gameVersion, libraryId) { + isLoading = true + error = null + versions = emptyList() + val provider = DownloadProviders.getDownloadProvider() + @Suppress("UNCHECKED_CAST") + val versionList = provider.getVersionListById(libraryId) as VersionList + versionList.refreshAsync(gameVersion).whenComplete { _, exception -> + Schedulers.androidUIThread().execute { + if (exception != null) { + error = exception.message + } else { + versions = versionList.getVersions(gameVersion) + .sorted() + .toList() + } + isLoading = false + } + } + } + + Column(modifier = modifier.fillMaxSize()) { + GlassTopBar( + title = stringResource(R.string.install_installer_game) + " - " + libraryId + ) + + if (error != null) { + GlassEmptyState( + text = error ?: stringResource(R.string.download_failed_empty), + modifier = Modifier.weight(1f) + ) + } else if (versions.isEmpty() && !isLoading) { + GlassEmptyState( + text = stringResource(R.string.download_failed_empty), + modifier = Modifier.weight(1f) + ) + } else { + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp) + ) { + items(versions, key = { it.selfVersion }) { version -> + GlassCard( + backdrop = backdrop, + modifier = Modifier.fillMaxWidth(), + tint = tintColor + ) { + Column(modifier = Modifier.clickable { onVersionSelected(version) }) { + Text( + text = version.selfVersion, + color = Color.White, + style = MaterialTheme.typography.titleMedium + ) + } + } + } + } + } + + GlassButton( + backdrop = backdrop, + onClick = onBack, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp) + ) { + Text( + text = stringResource(R.string.dialog_negative), + color = Color.White + ) + } + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/RemoteContentType.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/RemoteContentType.kt new file mode 100644 index 0000000000..fdf4c58c4b --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/RemoteContentType.kt @@ -0,0 +1,127 @@ +package com.tungsten.fcl.ui.glass.page.download + +import android.content.Context +import android.widget.Toast +import com.tungsten.fcl.R +import com.tungsten.fcl.ui.download.common.DownloadAddonDialog +import com.tungsten.fcl.ui.version.Versions +import com.tungsten.fcl.setting.Profile +import com.tungsten.fclcore.mod.RemoteMod +import com.tungsten.fclcore.mod.RemoteModRepository +import com.tungsten.fclcore.mod.curse.CurseForgeRemoteModRepository +import com.tungsten.fclcore.mod.modrinth.ModrinthRemoteModRepository +import com.tungsten.fclcore.task.FileDownloadTask +import com.tungsten.fclcore.task.Task +import com.tungsten.fclcore.util.io.FileUtils +import com.tungsten.fclcore.util.io.NetworkUtils +import com.tungsten.fcllibrary.component.view.FCLUILayout +import java.io.File +import java.nio.file.Path + +enum class RemoteContentType { + MOD, + MODPACK, + RESOURCE_PACK, + SHADER_PACK; + + fun titleRes(): Int = when (this) { + MOD -> R.string.mods + MODPACK -> R.string.modpack + RESOURCE_PACK -> R.string.resourcepack + SHADER_PACK -> R.string.shaderpack + } + + fun iconRes(): Int = when (this) { + MOD -> R.drawable.ic_outline_extension_24 + MODPACK -> R.drawable.ic_baseline_application_24 + RESOURCE_PACK -> R.drawable.ic_baseline_texture_24 + SHADER_PACK -> R.drawable.ic_baseline_tune_24 + } + + fun getRepository(sourceName: String, context: Context): RemoteModRepository { + val modrinthName = context.getString(R.string.mods_modrinth) + return when (this) { + MOD -> if (sourceName == modrinthName) ModrinthRemoteModRepository.MODS else CurseForgeRemoteModRepository.MODS + MODPACK -> if (sourceName == modrinthName) ModrinthRemoteModRepository.MODPACKS else CurseForgeRemoteModRepository.MODPACKS + RESOURCE_PACK -> if (sourceName == modrinthName) ModrinthRemoteModRepository.RESOURCE_PACKS else CurseForgeRemoteModRepository.RESOURCE_PACKS + SHADER_PACK -> if (sourceName == modrinthName) ModrinthRemoteModRepository.SHADER_PACKS else CurseForgeRemoteModRepository.SHADER_PACKS + } + } + + fun supportedSources(context: Context): List { + return listOf(context.getString(R.string.mods_modrinth), context.getString(R.string.mods_curseforge)) + } + + fun defaultSource(context: Context): String { + return context.getString(R.string.mods_modrinth) + } + + fun sortType(sourceName: String, context: Context): RemoteModRepository.SortType { + val modrinthName = context.getString(R.string.mods_modrinth) + return if (sourceName == modrinthName) RemoteModRepository.SortType.NAME else RemoteModRepository.SortType.POPULARITY + } + + fun downloadTask( + context: Context, + version: String, + remoteVersion: RemoteMod.Version + ): Task { + val dest = File.createTempFile("fcl_download", "_" + remoteVersion.file.filename, context.cacheDir) + return Task.composeAsync { + val task = FileDownloadTask( + NetworkUtils.toURL(remoteVersion.file.url), + dest, + remoteVersion.file.integrityCheck + ) + task.setName(remoteVersion.name) + task + }.thenApplyAsync { dest } + } + + fun install( + context: Context, + parent: FCLUILayout?, + profile: Profile, + version: String, + remoteVersion: RemoteMod.Version, + file: File + ) { + when (this) { + MOD -> installToSubdirectory(context, profile, version, remoteVersion, file, "mods") + MODPACK -> Versions.downloadModpackImpl(context, parent, profile, remoteVersion) + RESOURCE_PACK -> installToSubdirectory(context, profile, version, remoteVersion, file, "resourcepacks") + SHADER_PACK -> installToSubdirectory(context, profile, version, remoteVersion, file, "shaderpacks") + } + } + + fun supportsModLoader(): Boolean = this == MOD + + companion object { + private fun installToSubdirectory( + context: Context, + profile: Profile, + version: String, + remoteVersion: RemoteMod.Version, + file: File, + subdirectoryName: String + ) { + val actualVersion = version.ifBlank { profile.selectedVersion ?: "" } + val runDirectory: Path = if (profile.repository.hasVersion(actualVersion)) { + profile.repository.getRunDirectory(actualVersion).toPath() + } else { + profile.repository.baseDirectory.toPath() + } + + DownloadAddonDialog(context, remoteVersion.file.filename) { name -> + try { + val dest = runDirectory.resolve(subdirectoryName).resolve(name).toFile() + dest.parentFile?.mkdirs() + FileUtils.moveFile(file, dest) + Toast.makeText(context, context.getString(R.string.install_success), Toast.LENGTH_SHORT).show() + } catch (e: Exception) { + Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show() + } + }.show() + } + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/RemoteModCache.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/RemoteModCache.kt new file mode 100644 index 0000000000..948f0e3ecc --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/RemoteModCache.kt @@ -0,0 +1,19 @@ +package com.tungsten.fcl.ui.glass.page.download + +import com.tungsten.fclcore.mod.RemoteMod + +object RemoteModCache { + private val cache = mutableMapOf() + + fun put(key: String, mod: RemoteMod) { + cache[key] = mod + } + + fun get(key: String): RemoteMod? { + return cache[key] + } + + fun clear() { + cache.clear() + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/RemoteModInfoPage.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/RemoteModInfoPage.kt new file mode 100644 index 0000000000..f9ae1f1fd3 --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/RemoteModInfoPage.kt @@ -0,0 +1,194 @@ +package com.tungsten.fcl.ui.glass.page.download + +import android.content.Context +import android.widget.ImageView +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.bumptech.glide.Glide +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.R +import com.tungsten.fcl.ui.glass.FCLGlassDownloadRoute +import com.tungsten.fcl.ui.glass.component.GlassCard +import com.tungsten.fcl.ui.glass.component.GlassEmptyState +import com.tungsten.fcl.ui.glass.component.GlassSearchBar +import com.tungsten.fcl.ui.glass.component.GlassTopBar +import com.tungsten.fclcore.mod.RemoteMod +import com.tungsten.fclcore.task.Schedulers +import com.tungsten.fclcore.task.Task +import com.tungsten.fclcore.util.versioning.VersionNumber +import java.util.Collections +import java.util.stream.Collectors + +@Composable +fun RemoteModInfoPage( + backdrop: Backdrop, + type: RemoteContentType, + mod: RemoteMod, + gameVersion: String, + onNavigate: (FCLGlassDownloadRoute) -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + var isLoading by remember { mutableStateOf(true) } + var versions by remember { mutableStateOf>(emptyList()) } + var error by remember { mutableStateOf(null) } + var versionFilter by remember { mutableStateOf("") } + + LaunchedEffect(mod, type) { + isLoading = true + error = null + val repository = type.getRepository(type.defaultSource(context), context) + Task.supplyAsync { + mod.getData().loadVersions(repository).collect(Collectors.toList()) + }.whenComplete(Schedulers.androidUIThread()) { list, exception -> + isLoading = false + if (exception == null && list != null) { + versions = list + } else { + error = exception?.message ?: context.getString(R.string.download_failed_empty) + showToast(context, error) + } + }.start() + } + + val gameVersions = remember(versions, versionFilter) { + versions + .flatMap { it.getGameVersions() } + .distinct() + .filter { it.contains(versionFilter, ignoreCase = true) } + .sortedWith(Collections.reverseOrder { a, b -> + try { + VersionNumber.asVersion(a).compareTo(VersionNumber.asVersion(b)) + } catch (e: Exception) { + a.compareTo(b) + } + }) + } + + Column(modifier = modifier.fillMaxSize()) { + GlassTopBar(title = mod.getTitle()) + + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 8.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + AndroidView( + factory = { ctx -> + ImageView(ctx).apply { + scaleType = ImageView.ScaleType.CENTER_CROP + } + }, + update = { imageView -> + Glide.with(imageView.context) + .load(mod.getIconUrl()) + .placeholder(R.drawable.ic_outline_extension_24) + .into(imageView) + }, + modifier = Modifier.size(72.dp) + ) + Column(modifier = Modifier.padding(start = 16.dp)) { + Text( + text = mod.getTitle(), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = mod.getDescription(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + val categories = mod.getCategories() + if (categories.isNotEmpty()) { + Text( + text = categories.joinToString(", "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + modifier = Modifier.padding(top = 4.dp) + ) + } + } + } + } + + GlassSearchBar( + backdrop = backdrop, + query = versionFilter, + onQueryChange = { versionFilter = it }, + hint = stringResource(R.string.archive_game_version), + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp) + ) + + if (error != null) { + GlassEmptyState(text = error ?: stringResource(R.string.download_failed_empty), modifier = Modifier.weight(1f)) + } else if (gameVersions.isEmpty() && !isLoading) { + GlassEmptyState(text = stringResource(R.string.download_failed_empty), modifier = Modifier.weight(1f)) + } else { + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 20.dp, vertical = 8.dp) + ) { + items(gameVersions) { version -> + GlassCard( + backdrop = backdrop, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .clickable { + onNavigate( + FCLGlassDownloadRoute.RemoteModVersions( + typeName = type.name, + modSlug = mod.getSlug(), + targetGameVersion = version, + cacheKey = "${type.name}_${mod.getSlug()}" + ) + ) + } + .padding(16.dp) + ) { + Text( + text = version, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } + } + } + } +} + +private fun showToast(context: Context, message: String?) { + if (!message.isNullOrBlank()) { + android.widget.Toast.makeText(context, message, android.widget.Toast.LENGTH_SHORT).show() + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/RemoteModListPage.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/RemoteModListPage.kt new file mode 100644 index 0000000000..25e9362f40 --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/RemoteModListPage.kt @@ -0,0 +1,203 @@ +package com.tungsten.fcl.ui.glass.page.download + +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.R +import com.tungsten.fcl.setting.DownloadProviders +import com.tungsten.fcl.ui.glass.FCLGlassDownloadRoute +import com.tungsten.fcl.ui.glass.component.GlassButton +import com.tungsten.fcl.ui.glass.component.GlassEmptyState +import com.tungsten.fcl.ui.glass.component.GlassGameVersionFilter +import com.tungsten.fcl.ui.glass.component.GlassModListItem +import com.tungsten.fcl.ui.glass.component.GlassPagination +import com.tungsten.fcl.ui.glass.component.GlassSearchBar +import com.tungsten.fcl.ui.glass.component.GlassSourceSelector +import com.tungsten.fcl.ui.glass.component.GlassTopBar +import com.tungsten.fcllibrary.component.theme.ThemeEngine +import com.tungsten.fclcore.mod.RemoteMod +import com.tungsten.fclcore.mod.RemoteModRepository +import com.tungsten.fclcore.task.Schedulers +import com.tungsten.fclcore.task.Task +import java.util.stream.Collectors + +@Composable +fun RemoteModListPage( + backdrop: Backdrop, + type: RemoteContentType, + initialGameVersion: String, + onNavigate: (FCLGlassDownloadRoute) -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val tintColor = Color(ThemeEngine.getInstance().getTheme().getColor()) + var query by remember { mutableStateOf("") } + var gameVersion by remember { mutableStateOf(initialGameVersion) } + var source by remember { mutableStateOf(type.defaultSource(context)) } + var sort by remember { mutableStateOf(type.sortType(source, context)) } + var currentPage by remember { mutableIntStateOf(0) } + var totalPages by remember { mutableIntStateOf(1) } + var isLoading by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + var results by remember { mutableStateOf>(emptyList()) } + + fun performSearch() { + isLoading = true + error = null + val repository = type.getRepository(source, context) + val downloadProvider = DownloadProviders.getDownloadProvider() + Task.supplyAsync { + val searchResult = repository.search( + downloadProvider, + gameVersion, + null, + currentPage, + 50, + query, + sort, + RemoteModRepository.SortOrder.DESC + ) + val list = searchResult.results.collect(Collectors.toList()) + searchResult.totalPages to list + }.whenComplete(Schedulers.androidUIThread()) { pair, exception -> + isLoading = false + if (exception == null && pair != null) { + totalPages = pair.first.coerceAtLeast(1) + results = pair.second + } else { + error = exception?.message ?: context.getString(R.string.download_failed_empty) + showToast(context, error) + } + }.start() + } + + LaunchedEffect(Unit) { + performSearch() + } + + Column(modifier = modifier.fillMaxSize()) { + GlassTopBar(title = stringResource(type.titleRes())) + + GlassSourceSelector( + backdrop = backdrop, + sources = type.supportedSources(context), + selected = source, + onSelect = { + source = it + sort = type.sortType(it, context) + currentPage = 0 + performSearch() + }, + tint = tintColor, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp) + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp) + ) { + GlassGameVersionFilter( + backdrop = backdrop, + gameVersion = gameVersion, + onGameVersionChange = { gameVersion = it }, + modifier = Modifier.weight(1f) + ) + GlassButton( + backdrop = backdrop, + onClick = { performSearch() }, + tint = tintColor + ) { + Text(text = stringResource(R.string.search), color = Color.White) + } + } + + GlassSearchBar( + backdrop = backdrop, + query = query, + onQueryChange = { query = it }, + hint = stringResource(R.string.search), + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp) + ) + + if (isLoading) { + GlassEmptyState( + text = "Loading...", + modifier = Modifier.weight(1f) + ) + } else if (error != null) { + GlassEmptyState( + text = error ?: context.getString(R.string.download_failed_empty), + modifier = Modifier.weight(1f) + ) + } else if (results.isEmpty()) { + GlassEmptyState( + text = stringResource(R.string.download_failed_empty), + modifier = Modifier.weight(1f) + ) + } else { + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp) + ) { + items(results, key = { it.slug }) { mod -> + GlassModListItem( + backdrop = backdrop, + mod = mod, + onClick = { + val cacheKey = "${type.name}_${mod.slug}" + RemoteModCache.put(cacheKey, mod) + onNavigate( + FCLGlassDownloadRoute.RemoteModInfo( + typeName = type.name, + modSlug = mod.slug, + modTitle = mod.title, + gameVersion = gameVersion, + cacheKey = cacheKey + ) + ) + } + ) + } + } + } + + GlassPagination( + backdrop = backdrop, + currentPage = currentPage, + totalPages = totalPages, + onPageChange = { page -> + currentPage = page + performSearch() + }, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), + tint = tintColor + ) + } +} + +private fun showToast(context: Context, message: String?) { + if (!message.isNullOrBlank()) { + android.widget.Toast.makeText(context, message, android.widget.Toast.LENGTH_SHORT).show() + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/RemoteModVersionPage.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/RemoteModVersionPage.kt new file mode 100644 index 0000000000..95082ed2ee --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/RemoteModVersionPage.kt @@ -0,0 +1,227 @@ +package com.tungsten.fcl.ui.glass.page.download + +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.R +import com.tungsten.fcl.setting.Profile +import com.tungsten.fcl.setting.Profiles +import com.tungsten.fcl.ui.TaskDialog +import com.tungsten.fcl.ui.glass.LocalFCLUILayout +import com.tungsten.fcl.ui.version.Versions +import com.tungsten.fcl.util.TaskCancellationAction +import com.tungsten.fcl.ui.glass.component.GlassButton +import com.tungsten.fcl.ui.glass.component.GlassCard +import com.tungsten.fcl.ui.glass.component.GlassEmptyState +import com.tungsten.fcl.ui.glass.component.GlassTopBar +import com.tungsten.fcllibrary.component.theme.ThemeEngine +import com.tungsten.fcllibrary.component.view.FCLUILayout +import com.tungsten.fclcore.mod.RemoteMod +import com.tungsten.fclcore.task.Schedulers +import com.tungsten.fclcore.task.Task +import java.util.stream.Collectors + +@Composable +fun RemoteModVersionPage( + backdrop: Backdrop, + type: RemoteContentType, + mod: RemoteMod, + targetGameVersion: String, + onBack: () -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val parent = LocalFCLUILayout.current + val tintColor = Color(ThemeEngine.getInstance().getTheme().getColor()) + var isLoading by remember { mutableStateOf(true) } + var versions by remember { mutableStateOf>(emptyList()) } + var error by remember { mutableStateOf(null) } + var pendingVersion by remember { mutableStateOf(null) } + + LaunchedEffect(mod, targetGameVersion, type) { + isLoading = true + error = null + val repository = type.getRepository(type.defaultSource(context), context) + Task.supplyAsync { + mod.getData().loadVersions(repository) + .filter { it.gameVersions.contains(targetGameVersion) } + .sorted(Comparator.comparing(RemoteMod.Version::getDatePublished).reversed()) + .collect(Collectors.toList()) + }.whenComplete(Schedulers.androidUIThread()) { list, exception -> + isLoading = false + if (exception == null && list != null) { + versions = list + } else { + error = exception?.message ?: context.getString(R.string.download_failed_empty) + showToast(context, error) + } + }.start() + } + + Column(modifier = modifier.fillMaxSize()) { + GlassTopBar(title = mod.title + " - " + targetGameVersion) + + if (error != null) { + GlassEmptyState(text = error ?: stringResource(R.string.download_failed_empty), modifier = Modifier.weight(1f)) + } else if (versions.isEmpty() && !isLoading) { + GlassEmptyState(text = stringResource(R.string.download_failed_empty), modifier = Modifier.weight(1f)) + } else { + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 20.dp, vertical = 8.dp) + ) { + items(versions) { version -> + VersionFileCard( + backdrop = backdrop, + version = version, + tint = tintColor, + onDownload = { + pendingVersion = version + } + ) + } + } + } + + pendingVersion?.let { version -> + AlertDialog( + onDismissRequest = { pendingVersion = null }, + title = { Text(stringResource(R.string.dependencies)) }, + text = { + val deps = version.dependencies + if (deps.isNullOrEmpty()) { + Text(stringResource(R.string.message_no_dependency)) + } else { + Column { + deps.forEach { dep -> + Text("• [${dep.type}] ${dep.id}") + } + } + } + }, + confirmButton = { + TextButton(onClick = { + val profile = Profiles.getSelectedProfile() + val selectedVersion = Profiles.getSelectedVersion() ?: "" + downloadVersion(context, type, parent, profile, selectedVersion, version) + pendingVersion = null + }) { + Text(stringResource(R.string.download)) + } + }, + dismissButton = { + TextButton(onClick = { pendingVersion = null }) { + Text(stringResource(R.string.button_cancel)) + } + } + ) + } + + GlassButton( + backdrop = backdrop, + onClick = onBack, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp) + ) { + Text(text = stringResource(R.string.dialog_negative), color = Color.White) + } + } +} + +@Composable +private fun VersionFileCard( + backdrop: Backdrop, + version: RemoteMod.Version, + tint: Color, + onDownload: () -> Unit +) { + GlassCard( + backdrop = backdrop, + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = version.name, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = version.versionType?.name ?: "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + GlassButton( + backdrop = backdrop, + onClick = onDownload, + tint = tint, + modifier = Modifier.padding(top = 8.dp) + ) { + Text(text = stringResource(R.string.download), color = Color.White) + } + } + } +} + +private fun downloadVersion( + context: Context, + type: RemoteContentType, + parent: FCLUILayout?, + profile: Profile, + selectedVersion: String, + version: RemoteMod.Version +) { + if (type == RemoteContentType.MODPACK) { + // Modpack handles its own download + UI on UI thread + Versions.downloadModpackImpl(context, parent, profile, version) + return + } + + val task = type.downloadTask(context, selectedVersion, version) + .whenComplete(Schedulers.androidUIThread()) { file, exception -> + if (exception == null && file != null) { + try { + type.install(context, parent, profile, selectedVersion, version, file) + } catch (e: Exception) { + showToast(context, e.message) + } + } else { + showToast(context, exception?.message) + } + } + + val dialog = TaskDialog(context, TaskCancellationAction { task.cancel() }) + dialog.setTitle(context.getString(R.string.message_downloading)) + val executor = task.executor() + dialog.setExecutor(executor) + dialog.show() + executor.start() +} + +private fun showToast(context: Context, message: String?) { + if (!message.isNullOrBlank()) { + android.widget.Toast.makeText(context, message, android.widget.Toast.LENGTH_SHORT).show() + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/VersionInstallInfoPage.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/VersionInstallInfoPage.kt new file mode 100644 index 0000000000..5c5428d76d --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/VersionInstallInfoPage.kt @@ -0,0 +1,225 @@ +package com.tungsten.fcl.ui.glass.page.download + +import android.content.Context +import android.widget.Toast +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.R +import com.tungsten.fcl.game.FCLGameRepository +import com.tungsten.fcl.setting.Profile +import com.tungsten.fcl.setting.Profiles +import com.tungsten.fcl.ui.InstallerItem +import com.tungsten.fcl.ui.TaskDialog +import com.tungsten.fcl.util.TaskCancellationAction +import com.tungsten.fcl.ui.glass.FCLGlassDownloadRoute +import com.tungsten.fcl.ui.glass.component.GlassButton +import com.tungsten.fcl.ui.glass.component.GlassTextField +import com.tungsten.fcl.ui.glass.component.GlassTopBar +import com.tungsten.fcllibrary.component.dialog.FCLAlertDialog +import com.tungsten.fcllibrary.component.theme.ThemeEngine +import com.tungsten.fclcore.download.LibraryAnalyzer +import com.tungsten.fclcore.download.RemoteVersion +import com.tungsten.fclcore.task.Task +import com.tungsten.fclcore.util.StringUtils +import java.util.Arrays + +@Composable +fun VersionInstallInfoPage( + backdrop: Backdrop, + gameVersion: String, + onNavigate: (FCLGlassDownloadRoute) -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val tintColor = Color(ThemeEngine.getInstance().getTheme().getColor()) + val group = remember { InstallerItem.InstallerItemGroup(context, gameVersion) } + var name by remember { mutableStateOf(gameVersion) } + var nameManuallyModified by remember { mutableStateOf(false) } + val selectedVersions = VersionInstallState.selectedVersions + + fun applySelections() { + group.getLibraries().forEach { item -> + val id = item.getLibraryId() + val remoteVersion = selectedVersions[id] + if (remoteVersion != null) { + item.libraryVersion.set(remoteVersion.selfVersion) + item.removable.set(true) + } else { + item.libraryVersion.set(null) + item.removable.set(false) + } + } + } + + fun generateVersionName(): String { + val nameBuilder = StringBuilder(gameVersion) + Arrays.stream(LibraryAnalyzer.LibraryType.values()) + .filter { selectedVersions.containsKey(it.patchId) } + .map { getLoaderName(context, it) } + .filter { it != null } + .forEach { nameBuilder.append("-").append(it) } + return nameBuilder.toString() + } + + fun refreshVersionName() { + if (!nameManuallyModified) { + name = generateVersionName() + } + } + + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + GlassTopBar(title = stringResource(R.string.install_new_game)) + + Text( + text = stringResource(R.string.archive_name), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp) + ) + + GlassTextField( + backdrop = backdrop, + value = name, + onValueChange = { + name = it + nameManuallyModified = it != generateVersionName() + }, + hint = stringResource(R.string.archive_name), + modifier = Modifier.padding(horizontal = 20.dp) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + InstallerItemGroupCompose( + backdrop = backdrop, + group = group, + tint = tintColor, + onSelectLibrary = { item, libraryId -> + if (libraryId == LibraryAnalyzer.LibraryType.FABRIC_API.getPatchId()) { + FCLAlertDialog.Builder(context) + .setCancelable(false) + .setAlertLevel(FCLAlertDialog.AlertLevel.ALERT) + .setMessage(context.getString(R.string.install_installer_fabric_api_warning)) + .setNegativeButton(context.getString(com.tungsten.fcllibrary.R.string.dialog_positive), null) + .create() + .show() + } + + if (item.incompatibleLibraryName.get() == null && item.dependencyName.get() == null) { + onNavigate(FCLGlassDownloadRoute.InstallerVersionSelect(gameVersion, libraryId)) + } + }, + onRemoveLibrary = { _, libraryId -> + selectedVersions.remove(libraryId) + applySelections() + refreshVersionName() + }, + modifier = Modifier.padding(horizontal = 20.dp) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + GlassButton( + backdrop = backdrop, + onClick = { + val profile = Profiles.getSelectedProfile() + when { + StringUtils.isBlank(name) -> { + Toast.makeText( + context, + context.getString(R.string.input_not_empty), + Toast.LENGTH_SHORT + ).show() + } + profile.getRepository().versionIdConflicts(name) -> { + Toast.makeText( + context, + context.getString(R.string.install_new_game_already_exists), + Toast.LENGTH_SHORT + ).show() + } + !FCLGameRepository.isValidVersionId(name) -> { + Toast.makeText( + context, + context.getString(R.string.install_new_game_malformed), + Toast.LENGTH_SHORT + ).show() + } + else -> installVersion(context, profile, name, gameVersion, selectedVersions) + } + }, + tint = tintColor, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + ) { + Text( + text = stringResource(R.string.button_install), + color = Color.White, + style = MaterialTheme.typography.titleMedium + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + } +} + +private fun getLoaderName(context: Context, libraryType: LibraryAnalyzer.LibraryType): String? { + return when (libraryType) { + LibraryAnalyzer.LibraryType.FORGE -> context.getString(R.string.install_installer_forge) + LibraryAnalyzer.LibraryType.NEO_FORGE -> context.getString(R.string.install_installer_neoforge) + LibraryAnalyzer.LibraryType.CLEANROOM -> context.getString(R.string.install_installer_cleanroom) + LibraryAnalyzer.LibraryType.FABRIC -> context.getString(R.string.install_installer_fabric) + LibraryAnalyzer.LibraryType.LITELOADER -> context.getString(R.string.install_installer_liteloader) + LibraryAnalyzer.LibraryType.OPTIFINE -> context.getString(R.string.install_installer_optifine) + LibraryAnalyzer.LibraryType.QUILT -> context.getString(R.string.install_installer_quilt) + else -> null + } +} + +private fun installVersion( + context: Context, + profile: Profile, + name: String, + gameVersion: String, + selectedVersions: Map +) { + val builder = profile.getDependency().gameBuilder() + builder.name(name) + builder.gameVersion(gameVersion) + for ((libraryId, remoteVersion) in selectedVersions) { + if (libraryId != LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId()) { + builder.version(remoteVersion) + } + } + val task: Task<*> = builder.buildAsync() + val dialog = TaskDialog(context, TaskCancellationAction { it.dismiss() }) + dialog.setTitle(context.getString(R.string.install_new_game)) + val executor = task.executor() + dialog.setExecutor(executor, true) + dialog.show() + executor.start() +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/VersionInstallListPage.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/VersionInstallListPage.kt new file mode 100644 index 0000000000..8717c58f3a --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/VersionInstallListPage.kt @@ -0,0 +1,231 @@ +package com.tungsten.fcl.ui.glass.page.download + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.R +import com.tungsten.fcl.setting.DownloadProviders +import com.tungsten.fcl.ui.glass.FCLGlassDownloadRoute +import com.tungsten.fcl.ui.glass.component.GlassButton +import com.tungsten.fcl.ui.glass.component.GlassCard +import com.tungsten.fcl.ui.glass.component.GlassCheckbox +import com.tungsten.fcl.ui.glass.component.GlassEmptyState +import com.tungsten.fcl.ui.glass.component.GlassSearchBar +import com.tungsten.fcl.ui.glass.component.GlassTopBar +import com.tungsten.fcllibrary.component.theme.ThemeEngine +import com.tungsten.fclcore.download.RemoteVersion +import com.tungsten.fclcore.task.Schedulers +import com.tungsten.fclcore.util.versioning.GameVersionNumber + +@Composable +fun VersionInstallListPage( + backdrop: Backdrop, + onNavigate: (FCLGlassDownloadRoute) -> Unit, + modifier: Modifier = Modifier +) { + val tintColor = Color(ThemeEngine.getInstance().getTheme().getColor()) + var query by remember { mutableStateOf("") } + var checkRelease by remember { mutableStateOf(true) } + var checkSnapshot by remember { mutableStateOf(false) } + var checkOld by remember { mutableStateOf(false) } + var checkAprilFools by remember { mutableStateOf(false) } + var isLoading by remember { mutableStateOf(false) } + val versions = remember { mutableStateListOf() } + + fun filterVersions(list: List): List { + val q = query.lowercase() + return list.filter { version -> + val passesType = when (version.versionType) { + RemoteVersion.Type.RELEASE -> checkRelease + RemoteVersion.Type.SNAPSHOT, + RemoteVersion.Type.PENDING, + RemoteVersion.Type.UNOBFUSCATED -> { + if (checkSnapshot) true + else if (checkAprilFools) isAprilFoolsVersion(version.gameVersion) + else false + } + RemoteVersion.Type.OLD -> { + if (checkOld) true + else if (checkAprilFools) isAprilFoolsVersion(version.gameVersion) + else false + } + else -> true + } + passesType && version.gameVersion.contains(q) + }.sorted() + } + + fun refreshList() { + isLoading = true + versions.clear() + val versionList = DownloadProviders.getDownloadProvider().getVersionListById("game") + versionList.refreshAsync("").whenComplete { _, exception -> + Schedulers.androidUIThread().execute { + if (exception == null) { + val all = versionList.getVersions("").filterIsInstance() + val filtered = filterVersions(all) + if (filtered.isEmpty() && !(checkRelease && checkSnapshot && checkOld)) { + checkRelease = true + checkSnapshot = true + checkOld = true + versions.addAll(filterVersions(all)) + } else { + versions.addAll(filtered) + } + } + isLoading = false + } + } + } + + LaunchedEffect(Unit) { refreshList() } + + val filtered = remember(versions, query, checkRelease, checkSnapshot, checkOld, checkAprilFools) { + filterVersions(versions.toList()) + } + + Column(modifier = modifier.fillMaxSize()) { + GlassTopBar(title = stringResource(R.string.install_installer_game)) + + FilterRow( + backdrop = backdrop, + checkRelease = checkRelease, + onReleaseChange = { checkRelease = it }, + checkSnapshot = checkSnapshot, + onSnapshotChange = { checkSnapshot = it }, + checkOld = checkOld, + onOldChange = { checkOld = it }, + checkAprilFools = checkAprilFools, + onAprilFoolsChange = { checkAprilFools = it }, + tint = tintColor, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp) + ) + + GlassSearchBar( + backdrop = backdrop, + query = query, + onQueryChange = { query = it }, + hint = stringResource(R.string.search), + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp) + ) + + GlassButton( + backdrop = backdrop, + onClick = { refreshList() }, + tint = tintColor, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp) + ) { + Text( + text = stringResource(R.string.action_refresh), + color = Color.White + ) + } + + if (filtered.isEmpty() && !isLoading) { + GlassEmptyState( + text = stringResource(R.string.download_failed_empty), + modifier = Modifier.weight(1f) + ) + } else { + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp) + ) { + items(filtered, key = { it.selfVersion }) { version -> + RemoteVersionCard( + backdrop = backdrop, + version = version, + tint = tintColor, + onClick = { + onNavigate(FCLGlassDownloadRoute.VersionInstallInfo(version.gameVersion)) + } + ) + } + } + } + } +} + +private fun isAprilFoolsVersion(version: String): Boolean { + return try { + GameVersionNumber.asGameVersion(version).isAprilFools() + } catch (e: Exception) { + false + } +} + +@Composable +private fun FilterRow( + backdrop: Backdrop, + checkRelease: Boolean, + onReleaseChange: (Boolean) -> Unit, + checkSnapshot: Boolean, + onSnapshotChange: (Boolean) -> Unit, + checkOld: Boolean, + onOldChange: (Boolean) -> Unit, + checkAprilFools: Boolean, + onAprilFoolsChange: (Boolean) -> Unit, + tint: Color, + modifier: Modifier = Modifier +) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + ) { + GlassCheckbox(backdrop, checkRelease, onReleaseChange, stringResource(R.string.version_game_release), tint = tint) + GlassCheckbox(backdrop, checkSnapshot, onSnapshotChange, stringResource(R.string.version_game_snapshot), tint = tint) + GlassCheckbox(backdrop, checkOld, onOldChange, "Old", tint = tint) + GlassCheckbox(backdrop, checkAprilFools, onAprilFoolsChange, stringResource(R.string.version_game_april_fools), tint = tint) + } +} + +@Composable +private fun RemoteVersionCard( + backdrop: Backdrop, + version: RemoteVersion, + tint: Color, + onClick: () -> Unit +) { + GlassCard( + backdrop = backdrop, + modifier = Modifier.fillMaxWidth(), + tint = tint + ) { + Column(modifier = Modifier.clickable(onClick = onClick)) { + Text( + text = version.gameVersion, + color = Color.White, + style = MaterialTheme.typography.titleMedium + ) + Text( + text = version.versionType.name, + color = Color.White.copy(alpha = 0.8f), + style = MaterialTheme.typography.bodySmall + ) + } + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/VersionInstallPage.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/VersionInstallPage.kt new file mode 100644 index 0000000000..225c275d9e --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/VersionInstallPage.kt @@ -0,0 +1,326 @@ +package com.tungsten.fcl.ui.glass.page.download + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.R +import com.tungsten.fcl.ui.glass.component.GlassButton +import com.tungsten.fcl.ui.glass.component.GlassCard +import com.tungsten.fcl.ui.glass.component.GlassCheckbox +import com.tungsten.fcl.ui.glass.component.GlassChip +import com.tungsten.fcl.ui.glass.component.GlassSectionTitle +import com.tungsten.fcl.ui.glass.component.GlassTextField +import com.tungsten.fcl.ui.glass.component.GlassTopBar +import com.tungsten.fclcore.download.RemoteVersion +import com.tungsten.fclcore.util.versioning.GameVersionNumber +import com.tungsten.fcllibrary.component.theme.ThemeEngine + +@Composable +fun VersionInstallPage( + backdrop: Backdrop, + profile: com.tungsten.fcl.setting.Profile, + parentVersion: String? = null, + onBack: () -> Unit, + onComplete: () -> Unit +) { + val context = LocalContext.current + val state = remember { VersionInstallStateHolder(profile, parentVersion) } + val tintColor = Color(ThemeEngine.getInstance().getTheme().getColor()) + + var checkRelease by remember { mutableStateOf(true) } + var checkSnapshot by remember { mutableStateOf(false) } + var checkOld by remember { mutableStateOf(false) } + var checkAprilFools by remember { mutableStateOf(false) } + + val filteredGameVersions = remember( + state.gameVersions, + checkRelease, + checkSnapshot, + checkOld, + checkAprilFools + ) { + state.gameVersions.filter { version -> + passesGameVersionFilter(version, checkRelease, checkSnapshot, checkOld, checkAprilFools) + } + } + + Column(modifier = Modifier.fillMaxSize()) { + GlassTopBar( + title = stringResource(R.string.install_new_game), + onBack = onBack + ) + + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp) + ) { + item { + VersionNameField( + backdrop = backdrop, + value = state.versionName, + onValueChange = { state.setVersionName(it) }, + hint = stringResource(R.string.archive_name) + ) + } + + item { + GlassSectionTitle(text = stringResource(R.string.install_installer_game)) + } + + item { + GameVersionFilterRow( + backdrop = backdrop, + checkRelease = checkRelease, + onReleaseChange = { checkRelease = it }, + checkSnapshot = checkSnapshot, + onSnapshotChange = { checkSnapshot = it }, + checkOld = checkOld, + onOldChange = { checkOld = it }, + checkAprilFools = checkAprilFools, + onAprilFoolsChange = { checkAprilFools = it }, + tint = tintColor + ) + } + + items(filteredGameVersions, key = { it.gameVersion }) { version -> + SelectableVersionCard( + backdrop = backdrop, + version = version, + selected = state.selectedGameVersion == version, + tint = tintColor, + onClick = { state.setSelectedGameVersion(version) } + ) + } + + item { + GlassSectionTitle(text = "Loader") + } + + item { + LoaderChipRow( + backdrop = backdrop, + loaders = state.loaders, + selected = state.selectedLoader, + tint = tintColor, + onSelect = { state.setSelectedLoader(it) } + ) + } + + if (state.selectedLoader != LoaderType.VANILLA) { + item { + GlassSectionTitle( + text = stringResource( + state.selectedLoader.displayNameRes ?: R.string.install_installer_game + ) + ) + } + + items(state.loaderVersions, key = { it.selfVersion }) { version -> + SelectableVersionCard( + backdrop = backdrop, + version = version, + selected = state.selectedLoaderVersion == version, + tint = tintColor, + onClick = { state.setSelectedLoaderVersion(version) } + ) + } + } + } + + GlassButton( + backdrop = backdrop, + onClick = { state.install(context, onComplete) }, + enabled = state.selectedGameVersion != null && !state.isInstalling, + tint = tintColor, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 12.dp) + ) { + Text( + text = stringResource(R.string.button_install), + color = Color.White, + style = MaterialTheme.typography.titleMedium + ) + } + } +} + +@Composable +private fun VersionNameField( + backdrop: Backdrop, + value: String, + onValueChange: (String) -> Unit, + hint: String +) { + Column { + Text( + text = stringResource(R.string.archive_name), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + modifier = Modifier.padding(bottom = 4.dp) + ) + GlassTextField( + backdrop = backdrop, + value = value, + onValueChange = onValueChange, + hint = hint + ) + } +} + +@Composable +private fun GameVersionFilterRow( + backdrop: Backdrop, + checkRelease: Boolean, + onReleaseChange: (Boolean) -> Unit, + checkSnapshot: Boolean, + onSnapshotChange: (Boolean) -> Unit, + checkOld: Boolean, + onOldChange: (Boolean) -> Unit, + checkAprilFools: Boolean, + onAprilFoolsChange: (Boolean) -> Unit, + tint: Color, + modifier: Modifier = Modifier +) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + ) { + GlassCheckbox( + backdrop = backdrop, + checked = checkRelease, + onCheckedChange = onReleaseChange, + label = stringResource(R.string.version_game_release), + tint = tint + ) + GlassCheckbox( + backdrop = backdrop, + checked = checkSnapshot, + onCheckedChange = onSnapshotChange, + label = stringResource(R.string.version_game_snapshot), + tint = tint + ) + GlassCheckbox( + backdrop = backdrop, + checked = checkOld, + onCheckedChange = onOldChange, + label = "Old", + tint = tint + ) + GlassCheckbox( + backdrop = backdrop, + checked = checkAprilFools, + onCheckedChange = onAprilFoolsChange, + label = stringResource(R.string.version_game_april_fools), + tint = tint + ) + } +} + +@Composable +private fun LoaderChipRow( + backdrop: Backdrop, + loaders: List, + selected: LoaderType, + tint: Color, + onSelect: (LoaderType) -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + ) { + loaders.forEach { loader -> + GlassChip( + backdrop = backdrop, + text = loader.getDisplayName(context), + selected = selected == loader, + onClick = { onSelect(loader) }, + tint = tint + ) + } + } +} + +@Composable +private fun SelectableVersionCard( + backdrop: Backdrop, + version: RemoteVersion, + selected: Boolean, + tint: Color, + onClick: () -> Unit +) { + GlassCard( + backdrop = backdrop, + modifier = Modifier.fillMaxWidth(), + tint = if (selected) tint else null + ) { + Column(modifier = Modifier.clickable(onClick = onClick)) { + Text( + text = version.gameVersion, + color = if (selected) Color.White else MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleMedium + ) + Text( + text = version.versionType.name, + color = if (selected) Color.White.copy(alpha = 0.8f) + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + style = MaterialTheme.typography.bodySmall + ) + } + } +} + +private fun passesGameVersionFilter( + version: RemoteVersion, + checkRelease: Boolean, + checkSnapshot: Boolean, + checkOld: Boolean, + checkAprilFools: Boolean +): Boolean { + return when (version.versionType) { + RemoteVersion.Type.RELEASE -> checkRelease + RemoteVersion.Type.SNAPSHOT, + RemoteVersion.Type.PENDING, + RemoteVersion.Type.UNOBFUSCATED -> { + if (checkSnapshot) true + else if (checkAprilFools) isAprilFoolsVersion(version.gameVersion) + else false + } + RemoteVersion.Type.OLD -> { + if (checkOld) true + else if (checkAprilFools) isAprilFoolsVersion(version.gameVersion) + else false + } + else -> true + } +} + +private fun isAprilFoolsVersion(version: String): Boolean { + return try { + GameVersionNumber.asGameVersion(version).isAprilFools() + } catch (e: Exception) { + false + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/VersionInstallState.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/VersionInstallState.kt new file mode 100644 index 0000000000..80780d71b8 --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/VersionInstallState.kt @@ -0,0 +1,12 @@ +package com.tungsten.fcl.ui.glass.page.download + +import androidx.compose.runtime.mutableStateMapOf +import com.tungsten.fclcore.download.RemoteVersion + +object VersionInstallState { + val selectedVersions = mutableStateMapOf() + + fun clear() { + selectedVersions.clear() + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/VersionInstallStateHolder.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/VersionInstallStateHolder.kt new file mode 100644 index 0000000000..7ca0c42ec3 --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/VersionInstallStateHolder.kt @@ -0,0 +1,215 @@ +package com.tungsten.fcl.ui.glass.page.download + +import android.content.Context +import android.widget.Toast +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.tungsten.fcl.R +import com.tungsten.fcl.game.FCLGameRepository +import com.tungsten.fcl.setting.DownloadProviders +import com.tungsten.fcl.setting.Profile +import com.tungsten.fcl.ui.TaskDialog +import com.tungsten.fcl.ui.download.version.VersionInstallInfoPage +import com.tungsten.fcl.util.TaskCancellationAction +import com.tungsten.fclcore.download.RemoteVersion +import com.tungsten.fclcore.download.VersionList +import com.tungsten.fclcore.task.Schedulers +import com.tungsten.fclcore.task.Task +import com.tungsten.fclcore.task.TaskExecutor +import com.tungsten.fclcore.task.TaskListener +import com.tungsten.fclcore.util.StringUtils +import com.tungsten.fcllibrary.component.dialog.FCLAlertDialog +import java.io.File + +enum class LoaderType(val patchId: String?, val displayNameRes: Int?) { + VANILLA(null, null), + FORGE("forge", R.string.install_installer_forge), + FABRIC("fabric", R.string.install_installer_fabric), + OPTIFINE("optifine", R.string.install_installer_optifine), + QUILT("quilt", R.string.install_installer_quilt), + NEOFORGE("neoforge", R.string.install_installer_neoforge); + + fun getDisplayName(context: Context): String { + return displayNameRes?.let { context.getString(it) } ?: "Vanilla" + } +} + +class VersionInstallStateHolder( + private val profile: Profile, + private val parentVersion: String? = null +) { + var gameVersions by mutableStateOf>(emptyList()) + private set + var selectedGameVersion by mutableStateOf(null) + private set + var selectedLoader by mutableStateOf(LoaderType.VANILLA) + private set + var loaderVersions by mutableStateOf>(emptyList()) + private set + var selectedLoaderVersion by mutableStateOf(null) + private set + var isInstalling by mutableStateOf(false) + private set + var errorMessage by mutableStateOf(null) + private set + var versionName by mutableStateOf("") + private set + var versionNameManuallySet by mutableStateOf(false) + private set + + val loaders: List = LoaderType.entries + + init { + loadGameVersions() + } + + fun setSelectedGameVersion(version: RemoteVersion?) { + selectedGameVersion = version + if (!versionNameManuallySet) { + updateGeneratedName() + } + loadLoaderVersions() + } + + fun setSelectedLoader(loader: LoaderType) { + selectedLoader = loader + if (!versionNameManuallySet) { + updateGeneratedName() + } + loadLoaderVersions() + } + + fun setSelectedLoaderVersion(version: RemoteVersion?) { + selectedLoaderVersion = version + } + + fun setVersionName(name: String) { + versionName = name + versionNameManuallySet = true + } + + private fun updateGeneratedName() { + val gameVersion = selectedGameVersion?.gameVersion ?: return + versionName = if (selectedLoader == LoaderType.VANILLA) { + gameVersion + } else { + "$gameVersion-${selectedLoader.patchId}" + } + } + + fun loadGameVersions() { + errorMessage = null + val versionList = DownloadProviders.getDownloadProvider().getVersionListById("game") + versionList.refreshAsync("").whenComplete { _, exception -> + Schedulers.androidUIThread().execute { + if (exception != null) { + errorMessage = exception.message + } else { + gameVersions = versionList.getVersions("").filterIsInstance().sorted() + selectedGameVersion = parentVersion?.let { parent -> + gameVersions.find { it.gameVersion == parent } + } ?: gameVersions.firstOrNull() + if (!versionNameManuallySet) { + updateGeneratedName() + } + loadLoaderVersions() + } + } + } + } + + fun loadLoaderVersions() { + loaderVersions = emptyList() + selectedLoaderVersion = null + val gameVersion = selectedGameVersion?.gameVersion ?: return + val patchId = selectedLoader.patchId ?: return + + @Suppress("UNCHECKED_CAST") + val versionList = DownloadProviders.getDownloadProvider().getVersionListById(patchId) as? VersionList + ?: return + versionList.refreshAsync(gameVersion).whenComplete { _, exception -> + Schedulers.androidUIThread().execute { + if (exception != null) { + errorMessage = exception.message + } else { + loaderVersions = versionList.getVersions(gameVersion).sorted().toList() + selectedLoaderVersion = loaderVersions.firstOrNull() + } + } + } + } + + fun install(context: Context, onComplete: () -> Unit) { + val name = versionName.trim() + val gameVersion = selectedGameVersion?.gameVersion ?: return + + when { + StringUtils.isBlank(name) -> { + Toast.makeText( + context, + context.getString(R.string.input_not_empty), + Toast.LENGTH_SHORT + ).show() + } + profile.repository.versionIdConflicts(name) -> { + Toast.makeText( + context, + context.getString(R.string.install_new_game_already_exists), + Toast.LENGTH_SHORT + ).show() + } + !FCLGameRepository.isValidVersionId(name) -> { + Toast.makeText( + context, + context.getString(R.string.install_new_game_malformed), + Toast.LENGTH_SHORT + ).show() + } + else -> { + isInstalling = true + val builder = profile.dependency.gameBuilder() + builder.name(name) + builder.gameVersion(gameVersion) + selectedLoaderVersion?.let { builder.version(it) } + + val task: Task<*> = builder.buildAsync() + val dialog = TaskDialog(context, TaskCancellationAction { it.dismiss() }) + dialog.setTitle(context.getString(R.string.install_new_game)) + + val executor = task.executor(object : TaskListener() { + override fun onStop(success: Boolean, executor: TaskExecutor) { + Schedulers.androidUIThread().execute { + isInstalling = false + if (success) { + profile.repository.refreshVersions() + profile.setSelectedVersion(name) + if (selectedLoader != LoaderType.VANILLA && selectedLoader != LoaderType.OPTIFINE) { + File(profile.repository.getRunDirectory(name), "mods").mkdirs() + } + FCLAlertDialog.Builder(context) + .setAlertLevel(FCLAlertDialog.AlertLevel.INFO) + .setCancelable(false) + .setMessage(context.getString(R.string.install_success)) + .setNegativeButton( + context.getString(com.tungsten.fcllibrary.R.string.dialog_positive), + onComplete + ) + .create() + .show() + } else { + val ex = executor.exception + if (ex != null) { + VersionInstallInfoPage.alertFailureMessage(context, ex) {} + } + } + } + } + }) + dialog.setExecutor(executor, true) + dialog.show() + executor.start() + } + } + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/java/JavaRuntimePage.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/java/JavaRuntimePage.kt new file mode 100644 index 0000000000..9507a2651c --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/java/JavaRuntimePage.kt @@ -0,0 +1,172 @@ +package com.tungsten.fcl.ui.glass.page.java + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.ui.glass.component.GlassCard +import com.tungsten.fcl.ui.glass.component.GlassEmptyState +import com.tungsten.fcl.ui.glass.component.GlassSectionTitle +import com.tungsten.fcl.ui.glass.component.GlassTextButton +import com.tungsten.fcl.ui.glass.component.GlassTopBar +import com.tungsten.fcl.ui.glass.component.dialog.GlassDialogManager + +@Composable +fun JavaRuntimePage( + backdrop: Backdrop, + onBack: () -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val state = remember { JavaRuntimeStateHolder() } + + DisposableEffect(Unit) { + state.load() + onDispose { } + } + + Column(modifier = modifier.fillMaxSize()) { + GlassTopBar( + title = "Java Runtime", + onBack = onBack + ) + + Box(modifier = Modifier.weight(1f)) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + item { GlassSectionTitle(text = "Installed") } + + items( + items = state.javaList, + key = { it.name } + ) { item -> + JavaRuntimeCard( + backdrop = backdrop, + item = item, + onDelete = { + if (!item.isAuto) { + GlassDialogManager.showAlert( + title = "Remove Java Runtime", + message = "Are you sure you want to remove ${item.name}?", + confirmText = "Remove", + dismissText = "Cancel", + onConfirm = { state.remove(item) } + ) {} + } + } + ) + } + + item { GlassSectionTitle(text = "Install Java") } + item { + GlassCard(backdrop = backdrop, modifier = Modifier.fillMaxWidth()) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + listOf("jre8", "jre17", "jre21", "jre25").forEach { version -> + GlassTextButton( + backdrop = backdrop, + text = version.uppercase(), + onClick = { state.install(context, version) }, + modifier = Modifier.weight(1f), + enabled = !state.isLoading + ) + } + } + } + } + + item { Spacer(modifier = Modifier.height(24.dp)) } + } + + if (state.isLoading) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = MaterialTheme.colorScheme.onSurface + ) + } + + if (!state.isLoading && state.javaList.isEmpty()) { + GlassEmptyState( + text = "No Java runtimes found", + modifier = Modifier.fillMaxSize() + ) + } + } + } +} + +@Composable +private fun JavaRuntimeCard( + backdrop: Backdrop, + item: JavaRuntimeItem, + onDelete: () -> Unit +) { + GlassCard(backdrop = backdrop, modifier = Modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(end = 12.dp) + ) { + Text( + text = item.name, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = item.version, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + if (item.path.isNotEmpty()) { + Text( + text = item.path, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + ) + } + } + + if (!item.isAuto) { + IconButton(onClick = onDelete) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete", + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + } + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/java/JavaRuntimeStateHolder.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/java/JavaRuntimeStateHolder.kt new file mode 100644 index 0000000000..36fb5b23ef --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/java/JavaRuntimeStateHolder.kt @@ -0,0 +1,101 @@ +package com.tungsten.fcl.ui.glass.page.java + +import android.content.Context +import android.widget.Toast +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.mio.JavaManager +import com.tungsten.fcl.util.RuntimeUtils +import com.tungsten.fclauncher.utils.FCLPath +import com.tungsten.fclcore.task.Schedulers +import com.tungsten.fclcore.task.Task +import java.io.File + +data class JavaRuntimeItem( + val name: String, + val version: String, + val path: String, + val isAuto: Boolean +) + +class JavaRuntimeStateHolder { + + var isLoading by mutableStateOf(false) + private set + val javaList = mutableStateListOf() + + fun load() { + isLoading = true + Task.runAsync { + try { + val list = JavaManager.javaList.map { java -> + JavaRuntimeItem( + name = java.name, + version = java.versionName, + path = if (java.isAuto) "" else "${FCLPath.JAVA_PATH}/${java.name}", + isAuto = java.isAuto + ) + } + Schedulers.androidUIThread().execute { + javaList.clear() + javaList.addAll(list) + isLoading = false + } + } catch (e: Exception) { + Schedulers.androidUIThread().execute { + isLoading = false + } + } + }.start() + } + + fun remove(item: JavaRuntimeItem) { + if (item.isAuto) return + isLoading = true + Task.runAsync { + try { + JavaManager.remove(item.name) + Schedulers.androidUIThread().execute { load() } + } catch (e: Exception) { + Schedulers.androidUIThread().execute { + isLoading = false + } + } + }.start() + } + + fun install(context: Context, versionName: String) { + val (targetDir, srcDir) = when (versionName) { + "jre8" -> FCLPath.JAVA_8_PATH to "app_runtime/java/jre8" + "jre17" -> FCLPath.JAVA_17_PATH to "app_runtime/java/jre17" + "jre21" -> FCLPath.JAVA_21_PATH to "app_runtime/java/jre21" + "jre25" -> FCLPath.JAVA_25_PATH to "app_runtime/java/jre25" + else -> { + Toast.makeText(context, "Unsupported Java version: $versionName", Toast.LENGTH_SHORT).show() + return + } + } + isLoading = true + Task.runAsync { + try { + RuntimeUtils.installJava(context, targetDir, srcDir) + JavaManager.addToJavaVersion(File(FCLPath.JAVA_PATH, versionName)) + Schedulers.androidUIThread().execute { + Toast.makeText(context, "Installed $versionName", Toast.LENGTH_SHORT).show() + load() + } + } catch (e: Exception) { + Schedulers.androidUIThread().execute { + isLoading = false + Toast.makeText( + context, + e.localizedMessage ?: "Failed to install $versionName", + Toast.LENGTH_LONG + ).show() + } + } + }.start() + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/java/JvmArgsPage.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/java/JvmArgsPage.kt new file mode 100644 index 0000000000..fedd4890a2 --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/java/JvmArgsPage.kt @@ -0,0 +1,92 @@ +package com.tungsten.fcl.ui.glass.page.java + +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.R +import com.tungsten.fcl.setting.Profiles +import com.tungsten.fcl.ui.glass.component.GlassButton +import com.tungsten.fcl.ui.glass.component.GlassTextField +import com.tungsten.fcl.ui.glass.component.GlassTopBar +import com.tungsten.fclcore.task.Schedulers +import com.tungsten.fclcore.task.Task + +@Composable +fun JvmArgsPage( + backdrop: Backdrop, + onBack: () -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val global = remember { Profiles.getSelectedProfile().getGlobal() } + var args by remember { mutableStateOf(global.javaArgs) } + + Column(modifier = modifier.fillMaxSize()) { + GlassTopBar( + title = stringResource(R.string.settings_advanced_jvm_args), + onBack = onBack + ) + + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 20.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = stringResource(R.string.settings_advanced_jvm_args), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + + GlassTextField( + backdrop = backdrop, + value = args, + onValueChange = { args = it }, + hint = "JVM Arguments", + singleLine = false, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) + } + + GlassButton( + backdrop = backdrop, + onClick = { + Task.runAsync { + global.javaArgs = args + Schedulers.androidUIThread().execute { + Toast.makeText( + context, + R.string.message_success, + Toast.LENGTH_SHORT + ).show() + onBack() + } + }.start() + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp) + ) { + Text(text = stringResource(R.string.button_save), color = Color.White) + } + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/launcher/LauncherSettingPage.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/launcher/LauncherSettingPage.kt new file mode 100644 index 0000000000..a003d8441c --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/launcher/LauncherSettingPage.kt @@ -0,0 +1,223 @@ +package com.tungsten.fcl.ui.glass.page.launcher + +import android.app.Activity +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.R +import com.tungsten.fcl.ui.glass.component.GlassCard +import com.tungsten.fcl.ui.glass.component.GlassCheckbox +import com.tungsten.fcl.ui.glass.component.GlassChip +import com.tungsten.fcl.ui.glass.component.GlassSectionTitle +import com.tungsten.fcl.ui.glass.component.GlassTextButton +import com.tungsten.fcl.ui.glass.component.GlassTextField +import com.tungsten.fcl.ui.glass.component.GlassTopBar +import com.tungsten.fclcore.task.FetchTask +import com.tungsten.fcllibrary.component.theme.ThemeEngine + +@Composable +fun LauncherSettingPage( + backdrop: Backdrop, + onBack: () -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val state = remember { LauncherSettingStateHolder(context) } + val tintColor = Color(ThemeEngine.getInstance().getTheme().getColor()) + val window = (context as? Activity)?.window + + Column(modifier = modifier.fillMaxSize()) { + GlassTopBar( + title = stringResource(R.string.settings_launcher), + onBack = onBack + ) + + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + item { GlassSectionTitle(text = stringResource(R.string.settings_launcher_language)) } + item { + GlassCard(backdrop = backdrop, modifier = Modifier.fillMaxWidth()) { + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(LANGUAGE_OPTIONS.size) { index -> + val (langIndex, resId) = LANGUAGE_OPTIONS[index] + GlassChip( + backdrop = backdrop, + text = stringResource(resId), + selected = state.language == langIndex, + onClick = { state.language = langIndex }, + tint = tintColor + ) + } + } + } + } + + item { GlassSectionTitle(text = stringResource(R.string.settings_launcher)) } + item { + GlassCard(backdrop = backdrop, modifier = Modifier.fillMaxWidth()) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + GlassCheckbox( + backdrop = backdrop, + checked = state.autoExitLauncher, + onCheckedChange = { state.autoExitLauncher = it }, + label = stringResource(R.string.settings_launcher_exit_after_launching) + ) + GlassCheckbox( + backdrop = backdrop, + checked = state.ignoreNotch, + onCheckedChange = { + state.ignoreNotch = it + window?.let { w -> + ThemeEngine.getInstance().applyAndSave(context, w, it) + } + }, + label = stringResource(R.string.settings_launcher_ignore_notch), + tint = tintColor + ) + GlassCheckbox( + backdrop = backdrop, + checked = state.disableFullscreenInput, + onCheckedChange = { state.disableFullscreenInput = it }, + label = stringResource(R.string.settings_disable_fullscreen_input) + ) + } + } + } + + item { GlassSectionTitle(text = stringResource(R.string.settings_launcher_download)) } + item { + GlassCard(backdrop = backdrop, modifier = Modifier.fillMaxWidth()) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + GlassCheckbox( + backdrop = backdrop, + checked = state.autoChooseDownloadType, + onCheckedChange = { state.autoChooseDownloadType = it }, + label = stringResource(R.string.settings_launcher_download_source_auto) + ) + + if (state.autoChooseDownloadType) { + Text( + text = stringResource(R.string.settings_launcher_download_source), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + VERSION_LIST_SOURCE_OPTIONS.forEach { (value, resId) -> + GlassChip( + backdrop = backdrop, + text = stringResource(resId), + selected = state.versionListSource == value, + onClick = { state.versionListSource = value }, + tint = tintColor + ) + } + } + } else { + Text( + text = stringResource(R.string.settings_launcher_download_source), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + DOWNLOAD_TYPE_OPTIONS.forEach { (value, resId) -> + GlassChip( + backdrop = backdrop, + text = stringResource(resId), + selected = state.downloadType == value, + onClick = { state.downloadType = value }, + tint = tintColor + ) + } + } + } + + GlassCheckbox( + backdrop = backdrop, + checked = state.autoDownloadThreads, + onCheckedChange = { + state.autoDownloadThreads = it + if (it) { + state.downloadThreads = FetchTask.DEFAULT_CONCURRENCY + } + }, + label = stringResource(R.string.settings_launcher_download_threads_auto) + ) + + if (!state.autoDownloadThreads) { + GlassTextField( + backdrop = backdrop, + value = state.downloadThreads.toString(), + onValueChange = { text -> + text.toIntOrNull()?.let { state.downloadThreads = it } + }, + hint = stringResource(R.string.settings_launcher_download_threads) + ) + } + } + } + } + + item { Spacer(modifier = Modifier.height(8.dp)) } + item { + GlassTextButton( + backdrop = backdrop, + text = "Save", + onClick = { + state.save(context) + Toast.makeText(context, "Saved", Toast.LENGTH_SHORT).show() + onBack() + }, + modifier = Modifier.fillMaxWidth(), + tint = tintColor + ) + } + + item { Spacer(modifier = Modifier.height(24.dp)) } + } + } +} + +private val LANGUAGE_OPTIONS = listOf( + 0 to R.string.settings_launcher_language_system, + 1 to R.string.settings_launcher_language_english, + 2 to R.string.settings_launcher_language_simplified_chinese, + 3 to R.string.settings_launcher_language_russian, + 4 to R.string.settings_launcher_language_brazilian_portuguese, + 5 to R.string.settings_launcher_language_persian, + 6 to R.string.settings_launcher_language_ukrainian, + 7 to R.string.settings_launcher_language_german, + 8 to R.string.settings_launcher_language_traditional_chinese_hk +) + +private val DOWNLOAD_TYPE_OPTIONS = listOf( + "mojang" to R.string.download_provider_mojang, + "bmclapi" to R.string.download_provider_bmclapi +) + +private val VERSION_LIST_SOURCE_OPTIONS = listOf( + "official" to R.string.download_provider_official, + "balanced" to R.string.download_provider_balanced, + "mirror" to R.string.download_provider_mirror +) diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/launcher/LauncherSettingStateHolder.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/launcher/LauncherSettingStateHolder.kt new file mode 100644 index 0000000000..75d8f061bc --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/launcher/LauncherSettingStateHolder.kt @@ -0,0 +1,43 @@ +package com.tungsten.fcl.ui.glass.page.launcher + +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.tungsten.fcl.setting.ConfigHolder +import com.tungsten.fcllibrary.component.theme.ThemeEngine +import com.tungsten.fcllibrary.util.LocaleUtils + +class LauncherSettingStateHolder(context: Context) { + + private val sharedPreferences = context.getSharedPreferences("launcher", Context.MODE_PRIVATE) + private val config = ConfigHolder.config() + + var language by mutableIntStateOf(LocaleUtils.getLanguage(context)) + var autoExitLauncher by mutableStateOf(sharedPreferences.getBoolean("autoExitLauncher", false)) + var ignoreNotch by mutableStateOf(ThemeEngine.getInstance().getTheme().isFullscreen()) + var disableFullscreenInput by mutableStateOf(sharedPreferences.getBoolean("disableFullscreenInput", true)) + var autoChooseDownloadType by mutableStateOf(config.isAutoChooseDownloadType()) + var downloadType by mutableStateOf(config.getDownloadType()) + var versionListSource by mutableStateOf(config.getVersionListSource()) + var autoDownloadThreads by mutableStateOf(config.getAutoDownloadThreads()) + var downloadThreads by mutableIntStateOf(config.getDownloadThreads()) + + fun save(context: Context) { + LocaleUtils.changeLanguage(context, language) + + sharedPreferences.edit() + .putBoolean("autoExitLauncher", autoExitLauncher) + .putBoolean("disableFullscreenInput", disableFullscreenInput) + .apply() + + config.setAutoChooseDownloadType(autoChooseDownloadType) + config.setDownloadType(downloadType) + config.setVersionListSource(versionListSource) + config.setAutoDownloadThreads(autoDownloadThreads) + config.setDownloadThreads(downloadThreads) + + ThemeEngine.getInstance().applyAndSave(context, null, ignoreNotch) + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/ModInfoPage.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/ModInfoPage.kt new file mode 100644 index 0000000000..7bc651925d --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/ModInfoPage.kt @@ -0,0 +1,204 @@ +package com.tungsten.fcl.ui.glass.page.version + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.R +import com.tungsten.fcl.setting.Profile +import com.tungsten.fcl.ui.glass.component.GlassButton +import com.tungsten.fcl.ui.glass.component.GlassCard +import com.tungsten.fcl.ui.glass.component.GlassEmptyState +import com.tungsten.fcl.ui.glass.component.GlassTopBar +import com.tungsten.fcl.util.AndroidUtils + +@Composable +fun ModInfoPage( + backdrop: Backdrop, + profile: Profile, + version: String, + modFileName: String, + onBack: () -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val state = remember(profile, version, modFileName) { + ModInfoStateHolder(profile, version, modFileName) + } + + Column(modifier = modifier.fillMaxSize()) { + GlassTopBar( + title = "Mod Info", + onBack = onBack + ) + + when { + state.isLoading -> { + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.onSurface) + } + } + state.mod == null -> { + GlassEmptyState( + text = state.errorMessage ?: stringResource(R.string.message_unknown), + modifier = Modifier.weight(1f) + ) + } + else -> { + val mod = state.mod!! + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + item { + GlassCard( + backdrop = backdrop, + modifier = Modifier.fillMaxWidth() + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + if (state.icon != null) { + Image( + bitmap = state.icon!!.asImageBitmap(), + contentDescription = null, + modifier = Modifier.size(80.dp) + ) + } else { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + modifier = Modifier.size(80.dp), + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = mod.name, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface + ) + if (mod.version.isNotBlank()) { + Text( + text = mod.version, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + val loader = state.loaderDisplayName() + if (loader.isNotBlank()) { + Text( + text = loader, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + } + + item { + GlassCard( + backdrop = backdrop, + modifier = Modifier.fillMaxWidth() + ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + InfoRow(label = stringResource(R.string.mods_name), value = mod.name) + if (!mod.version.isNullOrBlank()) { + InfoRow(label = stringResource(R.string.version), value = mod.version) + } + InfoRow(label = stringResource(R.string.archive_game_version), value = mod.gameVersion ?: "") + InfoRow(label = stringResource(R.string.archive_author), value = mod.authors ?: "") + InfoRow(label = "File", value = mod.fileName) + } + } + } + + item { + GlassCard( + backdrop = backdrop, + modifier = Modifier.fillMaxWidth() + ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Description", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = mod.description.toString(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + ) + } + } + } + + val url = mod.url + if (!url.isNullOrBlank()) { + item { + GlassButton( + backdrop = backdrop, + onClick = { AndroidUtils.openLink(context, url) }, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.mods_url), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelLarge + ) + } + } + } + + item { Spacer(modifier = Modifier.height(24.dp)) } + } + } + } + } +} + +@Composable +private fun InfoRow(label: String, value: String) { + if (value.isBlank()) return + Column { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + Text( + text = value, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + } +} \ No newline at end of file diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/ModInfoStateHolder.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/ModInfoStateHolder.kt new file mode 100644 index 0000000000..87604451d8 --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/ModInfoStateHolder.kt @@ -0,0 +1,87 @@ +package com.tungsten.fcl.ui.glass.page.version + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.tungsten.fcl.setting.Profile +import com.tungsten.fclcore.mod.LocalModFile +import com.tungsten.fclcore.mod.ModLoaderType +import com.tungsten.fclcore.task.Schedulers +import com.tungsten.fclcore.task.Task +import com.tungsten.fclcore.util.io.CompressingUtils +import java.io.IOException +import java.nio.file.Files + +class ModInfoStateHolder( + private val profile: Profile, + private val versionId: String, + private val modFileName: String +) { + var mod by mutableStateOf(null) + private set + var icon by mutableStateOf(null) + private set + var isLoading by mutableStateOf(true) + private set + var errorMessage by mutableStateOf(null) + private set + + init { + load() + } + + private fun load() { + Task.runAsync { + try { + val manager = profile.getRepository().getModManager(versionId) + manager.refreshMods() + val found = manager.getMods().find { it.fileName == modFileName } + Schedulers.androidUIThread().execute { + mod = found + isLoading = false + if (found == null) { + errorMessage = "Mod not found" + } + } + found?.let { loadIcon(it) } + } catch (e: IOException) { + Schedulers.androidUIThread().execute { + errorMessage = e.message + isLoading = false + } + } + }.start() + } + + private fun loadIcon(mod: LocalModFile) { + val logoPath = mod.logoPath + if (logoPath.isNullOrBlank()) return + Task.runAsync { + try { + CompressingUtils.createReadOnlyZipFileSystem(mod.file).use { fs -> + val path = fs.getPath(logoPath) + if (Files.exists(path)) { + val bytes = Files.readAllBytes(path) + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + Schedulers.androidUIThread().execute { + icon = bitmap + } + } + } + } catch (e: Exception) { + // Ignore icon loading failures. + } + }.start() + } + + fun loaderDisplayName(): String = when (mod?.modLoaderType) { + ModLoaderType.FORGE -> "Forge" + ModLoaderType.NEO_FORGED -> "NeoForge" + ModLoaderType.FABRIC -> "Fabric" + ModLoaderType.LITE_LOADER -> "LiteLoader" + ModLoaderType.QUILT -> "Quilt" + else -> "" + } +} \ No newline at end of file diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionModListPage.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionModListPage.kt new file mode 100644 index 0000000000..ec3877c08e --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionModListPage.kt @@ -0,0 +1,314 @@ +package com.tungsten.fcl.ui.glass.page.version + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.R +import com.tungsten.fcl.setting.Profile +import com.tungsten.fcl.ui.glass.FCLGlassRoute +import com.tungsten.fcl.ui.glass.component.GlassCard +import com.tungsten.fcl.ui.glass.component.GlassCheckbox +import com.tungsten.fcl.ui.glass.component.GlassEmptyState +import com.tungsten.fcl.ui.glass.component.GlassSearchBar +import com.tungsten.fcl.ui.glass.component.GlassTopBar +import com.tungsten.fcllibrary.component.theme.ThemeEngine + +@Composable +fun VersionModListPage( + backdrop: Backdrop, + profile: Profile, + version: String, + navController: NavController, + onBack: () -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val state = remember(profile, version) { VersionModListStateHolder(profile, version) } + val tintColor = Color(ThemeEngine.getInstance().getTheme().getColor()) + var showSearch by remember { mutableStateOf(false) } + + val fileLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenMultipleDocuments() + ) { uris -> + if (uris.isNotEmpty()) { + state.addMods(context, uris) + } + } + + Column(modifier = modifier.fillMaxSize()) { + GlassTopBar( + title = stringResource(R.string.mods_manage), + onBack = onBack, + actions = { + if (state.editMode) { + IconButton(onClick = { state.selectAll() }) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Select All", + tint = MaterialTheme.colorScheme.onSurface + ) + } + IconButton(onClick = { state.deleteSelected(context) }) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete", + tint = MaterialTheme.colorScheme.onSurface + ) + } + IconButton(onClick = { state.editMode = false; state.selected.clear() }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Cancel", + tint = MaterialTheme.colorScheme.onSurface + ) + } + } else { + IconButton(onClick = { fileLauncher.launch(arrayOf("*/*")) }) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "Add Mod", + tint = MaterialTheme.colorScheme.onSurface + ) + } + IconButton(onClick = { showSearch = !showSearch }) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + tint = MaterialTheme.colorScheme.onSurface + ) + } + IconButton(onClick = { state.editMode = true }) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = "Edit", + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + } + ) + + if (showSearch) { + GlassSearchBar( + backdrop = backdrop, + query = state.query, + onQueryChange = { state.query = it }, + hint = stringResource(R.string.search), + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp) + ) + } + + if (!state.isModded) { + GlassEmptyState( + text = stringResource(R.string.mods_not_modded), + modifier = Modifier.weight(1f) + ) + } else { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp) + ) { + GlassCheckbox( + backdrop = backdrop, + checked = state.showEnabled, + onCheckedChange = { state.showEnabled = it }, + label = stringResource(R.string.enabled), + tint = tintColor + ) + GlassCheckbox( + backdrop = backdrop, + checked = state.showDisabled, + onCheckedChange = { state.showDisabled = it }, + label = stringResource(R.string.disabled), + tint = tintColor + ) + } + + Box(modifier = Modifier.weight(1f)) { + val filteredItems = state.filteredItems() + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items( + items = filteredItems, + key = { it.mod.fileName } + ) { item -> + val selected = state.selected.contains(item) + ModItemCard( + backdrop = backdrop, + item = item, + selected = selected, + editMode = state.editMode, + tint = tintColor, + onSelectToggle = { + if (selected) state.selected.remove(item) else state.selected.add(item) + }, + onActiveToggle = { state.toggleActive(item) }, + onInfo = { + navController.navigate( + FCLGlassRoute.ModInfo( + modFileName = item.mod.fileName, + profileName = profile.name, + version = version + ) + ) + }, + onDelete = { state.delete(item, context) } + ) + } + + item { Spacer(modifier = Modifier.height(24.dp)) } + } + + if (state.isLoading && filteredItems.isEmpty()) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = MaterialTheme.colorScheme.onSurface + ) + } + + if (!state.isLoading && filteredItems.isEmpty()) { + GlassEmptyState( + text = stringResource(R.string.mods_check_updates_empty), + modifier = Modifier.fillMaxSize() + ) + } + } + } + } +} + +@Composable +private fun ModItemCard( + backdrop: Backdrop, + item: ModListItem, + selected: Boolean, + editMode: Boolean, + tint: Color, + onSelectToggle: () -> Unit, + onActiveToggle: () -> Unit, + onInfo: () -> Unit, + onDelete: () -> Unit +) { + GlassCard( + backdrop = backdrop, + modifier = Modifier.fillMaxWidth(), + tint = if (selected) tint else null + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + if (editMode) { + Checkbox( + checked = selected, + onCheckedChange = { onSelectToggle() } + ) + } + + Box( + modifier = Modifier.size(48.dp), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 12.dp) + ) { + Text( + text = item.title, + style = MaterialTheme.typography.titleMedium, + color = if (selected) Color.White else MaterialTheme.colorScheme.onSurface + ) + if (item.tag.isNotBlank()) { + Text( + text = item.tag, + style = MaterialTheme.typography.labelMedium, + color = if (selected) Color.White.copy(alpha = 0.8f) + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + Text( + text = item.subtitle, + style = MaterialTheme.typography.bodySmall, + color = if (selected) Color.White.copy(alpha = 0.7f) + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + maxLines = 2 + ) + } + + if (!editMode) { + Switch( + checked = item.isActive, + onCheckedChange = { onActiveToggle() } + ) + IconButton(onClick = onInfo) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = "Info", + tint = MaterialTheme.colorScheme.onSurface + ) + } + IconButton(onClick = onDelete) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete", + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + } + } +} \ No newline at end of file diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionModListStateHolder.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionModListStateHolder.kt new file mode 100644 index 0000000000..41405ac43a --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionModListStateHolder.kt @@ -0,0 +1,247 @@ +package com.tungsten.fcl.ui.glass.page.version + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import android.widget.Toast +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.tungsten.fcl.R +import com.tungsten.fcl.setting.Profile +import com.tungsten.fclcore.download.LibraryAnalyzer +import com.tungsten.fclcore.mod.LocalModFile +import com.tungsten.fclcore.mod.ModLoaderType +import com.tungsten.fclcore.task.Schedulers +import com.tungsten.fclcore.task.Task +import com.tungsten.fclcore.util.StringUtils +import java.io.IOException +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.util.Locale + +class VersionModListStateHolder( + private val profile: Profile, + private val versionId: String +) { + var isLoading by mutableStateOf(false) + private set + var isModded by mutableStateOf(false) + private set + var query by mutableStateOf("") + var showEnabled by mutableStateOf(true) + var showDisabled by mutableStateOf(true) + var editMode by mutableStateOf(false) + val items = mutableStateListOf() + val selected = mutableStateListOf() + + init { + checkModded() + if (isModded) loadMods() + } + + private fun checkModded() { + val repository = profile.getRepository() + val resolved = repository.getResolvedPreservingPatchesVersion(versionId) + val analyzer = LibraryAnalyzer.analyze( + resolved, + repository.getGameVersion(resolved).orElse(null) + ) + isModded = analyzer.hasModLoader() + } + + fun loadMods() { + if (!isModded) return + isLoading = true + Task.runAsync { + try { + val repository = profile.getRepository() + val manager = repository.getModManager(versionId) + manager.refreshMods() + val loaded = manager.getMods().map { it.toListItem() } + Schedulers.androidUIThread().execute { + items.clear() + items.addAll(loaded) + selected.clear() + editMode = false + isLoading = false + } + } catch (e: IOException) { + Schedulers.androidUIThread().execute { + isLoading = false + } + } + }.start() + } + + fun filteredItems(): List { + val q = query.trim().lowercase(Locale.getDefault()) + return items.filter { item -> + val active = item.mod.isActive + val matchesFilter = (showEnabled && active) || (showDisabled && !active) + val matchesQuery = q.isEmpty() || + item.title.lowercase(Locale.getDefault()).contains(q) || + item.mod.fileName.lowercase(Locale.getDefault()).contains(q) + matchesFilter && matchesQuery + } + } + + fun toggleActive(item: ModListItem) { + Task.runAsync { + try { + item.mod.setActive(!item.mod.isActive) + Schedulers.androidUIThread().execute { + item.isActive = item.mod.isActive + } + } catch (e: IOException) { + // Ignore: failed to toggle mod state. + } + }.start() + } + + fun delete(item: ModListItem, context: Context) { + Task.runAsync { + try { + val manager = profile.getRepository().getModManager(versionId) + manager.removeMods(item.mod) + Schedulers.androidUIThread().execute { + items.remove(item) + selected.remove(item) + } + } catch (e: IOException) { + Schedulers.androidUIThread().execute { + Toast.makeText(context, R.string.message_failed, Toast.LENGTH_SHORT).show() + } + } + }.start() + } + + fun deleteSelected(context: Context) { + val toDelete = selected.toList() + if (toDelete.isEmpty()) return + Task.runAsync { + try { + val manager = profile.getRepository().getModManager(versionId) + manager.removeMods(*toDelete.map { it.mod }.toTypedArray()) + Schedulers.androidUIThread().execute { + items.removeAll(toDelete) + selected.clear() + editMode = false + } + } catch (e: IOException) { + Schedulers.androidUIThread().execute { + Toast.makeText(context, R.string.message_failed, Toast.LENGTH_SHORT).show() + } + } + }.start() + } + + fun selectAll() { + selected.clear() + selected.addAll(filteredItems()) + } + + fun invertSelection() { + val filtered = filteredItems() + val newSelection = filtered.filter { !selected.contains(it) } + selected.clear() + selected.addAll(newSelection) + } + + fun addMods(context: Context, uris: List) { + if (uris.isEmpty()) return + isLoading = true + Task.runAsync { + val manager = profile.getRepository().getModManager(versionId) + val modsDir = manager.modsDirectory + val succeeded = mutableListOf() + val failed = mutableListOf() + uris.forEach { uri -> + val name = getDisplayName(context, uri) ?: uri.toString() + if (!isModFileName(name)) { + failed.add(name) + return@forEach + } + try { + context.contentResolver.openInputStream(uri)?.use { input -> + Files.createDirectories(modsDir) + val target = modsDir.resolve(name) + Files.copy(input, target, StandardCopyOption.REPLACE_EXISTING) + succeeded.add(name) + } ?: failed.add(name) + } catch (e: Exception) { + failed.add(name) + } + } + Schedulers.androidUIThread().execute { + val messages = mutableListOf() + if (succeeded.isNotEmpty()) { + messages.add(context.getString(R.string.mods_add_success, succeeded.joinToString(", "))) + } + if (failed.isNotEmpty()) { + messages.add(context.getString(R.string.mods_add_failed, failed.joinToString(", "))) + } + if (messages.isNotEmpty()) { + Toast.makeText(context, messages.joinToString("\n"), Toast.LENGTH_LONG).show() + } + loadMods() + } + }.start() + } + + private fun getDisplayName(context: Context, uri: Uri): String? { + if (uri.scheme == "content") { + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (index >= 0) return cursor.getString(index) + } + } + } + return uri.path?.let { java.io.File(it).name } + } + + private fun isModFileName(name: String): Boolean { + val lower = name.lowercase(Locale.getDefault()) + return lower.endsWith(".jar") || lower.endsWith(".zip") || lower.endsWith(".litemod") + } + + private fun LocalModFile.toListItem(): ModListItem { + val titleBuilder = StringBuilder(name) + if (StringUtils.isNotBlank(version)) { + titleBuilder.append(" ").append(version) + } + val subtitleBuilder = StringBuilder(fileName) + if (StringUtils.isNotBlank(gameVersion)) { + subtitleBuilder.append(", ").append("Game Version: ").append(gameVersion) + } + if (StringUtils.isNotBlank(authors)) { + subtitleBuilder.append(", ").append("Author(s): ").append(authors) + } + return ModListItem( + mod = this, + title = titleBuilder.toString(), + subtitle = subtitleBuilder.toString(), + tag = modLoaderType.displayName(), + isActive = isActive + ) + } + + private fun ModLoaderType.displayName(): String = when (this) { + ModLoaderType.FORGE -> "Forge" + ModLoaderType.NEO_FORGED -> "NeoForge" + ModLoaderType.FABRIC -> "Fabric" + ModLoaderType.LITE_LOADER -> "LiteLoader" + ModLoaderType.QUILT -> "Quilt" + else -> "" + } +} + +data class ModListItem( + val mod: LocalModFile, + var title: String, + var subtitle: String, + var tag: String, + var isActive: Boolean = mod.isActive +) \ No newline at end of file diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionPackListPage.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionPackListPage.kt new file mode 100644 index 0000000000..918dffa3cd --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionPackListPage.kt @@ -0,0 +1,326 @@ +package com.tungsten.fcl.ui.glass.page.version + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.R +import com.tungsten.fcl.setting.Profile +import com.tungsten.fcl.ui.glass.component.GlassCard +import com.tungsten.fcl.ui.glass.component.GlassEmptyState +import com.tungsten.fcl.ui.glass.component.GlassSearchBar +import com.tungsten.fcl.ui.glass.component.GlassTopBar +import com.tungsten.fcl.ui.glass.component.dialog.GlassDialogManager +import com.tungsten.fclcore.task.Schedulers +import com.tungsten.fclcore.task.Task +import com.tungsten.fclcore.util.io.FileUtils +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.util.Locale +import java.util.stream.Collectors + +@Composable +fun VersionPackListPage( + backdrop: Backdrop, + profile: Profile, + version: String, + folderName: String, + title: String, + onBack: () -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val state = remember(profile, version, folderName) { + PackListStateHolder(profile, version, folderName) + } + var showSearch by remember { mutableStateOf(false) } + + val importLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri -> + uri?.let { state.importPack(context, it) } + } + + Column(modifier = modifier.fillMaxSize()) { + GlassTopBar( + title = title, + onBack = onBack, + actions = { + IconButton(onClick = { importLauncher.launch(arrayOf("application/zip")) }) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "Import", + tint = MaterialTheme.colorScheme.onSurface + ) + } + IconButton(onClick = { showSearch = !showSearch }) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + ) + + if (showSearch) { + GlassSearchBar( + backdrop = backdrop, + query = state.query, + onQueryChange = { state.query = it }, + hint = stringResource(R.string.search), + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp) + ) + } + + Box(modifier = Modifier.weight(1f)) { + val filtered = state.filteredItems() + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items( + items = filtered, + key = { it.file.toString() } + ) { item -> + PackItemCard( + backdrop = backdrop, + item = item, + onDelete = { + GlassDialogManager.showAlert( + title = context.getString(R.string.button_remove), + message = context.getString(R.string.button_remove_confirm), + confirmText = context.getString(R.string.button_remove), + dismissText = context.getString(com.tungsten.fcllibrary.R.string.dialog_negative), + onConfirm = { state.delete(item) } + ) {} + } + ) + } + + item { Spacer(modifier = Modifier.height(24.dp)) } + } + + if (state.isLoading && filtered.isEmpty()) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = MaterialTheme.colorScheme.onSurface + ) + } + + if (!state.isLoading && filtered.isEmpty()) { + GlassEmptyState( + text = stringResource(R.string.mods_check_updates_empty), + modifier = Modifier.fillMaxSize() + ) + } + } + } +} + +@Composable +private fun PackItemCard( + backdrop: Backdrop, + item: PackItem, + onDelete: () -> Unit +) { + GlassCard( + backdrop = backdrop, + modifier = Modifier.fillMaxWidth() + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Box( + modifier = Modifier.size(48.dp), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Folder, + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 12.dp) + ) { + Text( + text = item.name, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = if (item.isDirectory) "Folder" else "Zip", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + + IconButton(onClick = onDelete) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete", + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + } +} + +data class PackItem( + val file: Path, + val name: String, + val isDirectory: Boolean +) + +class PackListStateHolder( + private val profile: Profile, + private val versionId: String, + private val folderName: String +) { + var isLoading by mutableStateOf(false) + private set + var query by mutableStateOf("") + val items = mutableStateListOf() + + init { + refresh() + } + + fun refresh() { + isLoading = true + Task.runAsync { + try { + val dir = profile.getRepository().getRunDirectory(versionId).toPath().resolve(folderName) + val list = if (Files.isDirectory(dir)) { + Files.list(dir).use { stream -> + stream + .filter { Files.isDirectory(it) || isZipFile(it) } + .map { PackItem(it, FileUtils.getName(it), Files.isDirectory(it)) } + .sorted(compareBy { it.name.lowercase(Locale.getDefault()) }) + .collect(Collectors.toList()) + } + } else { + emptyList() + } + Schedulers.androidUIThread().execute { + items.clear() + items.addAll(list) + isLoading = false + } + } catch (e: Exception) { + Schedulers.androidUIThread().execute { + isLoading = false + } + } + }.start() + } + + fun filteredItems(): List { + val q = query.trim().lowercase(Locale.getDefault()) + return if (q.isEmpty()) items.toList() else items.filter { + it.name.lowercase(Locale.getDefault()).contains(q) + } + } + + fun delete(item: PackItem) { + Task.runAsync { + try { + FileUtils.forceDelete(item.file.toFile()) + Schedulers.androidUIThread().execute { refresh() } + } catch (e: Exception) { + Schedulers.androidUIThread().execute { + // ignore + } + } + }.start() + } + + fun importPack(context: Context, uri: Uri) { + val name = getDisplayName(context, uri) ?: uri.toString() + if (!name.lowercase(Locale.getDefault()).endsWith(".zip")) { + Toast.makeText(context, R.string.world_import_invalid, Toast.LENGTH_SHORT).show() + return + } + isLoading = true + Task.runAsync { + try { + val dir = profile.getRepository().getRunDirectory(versionId).toPath().resolve(folderName) + Files.createDirectories(dir) + val target = dir.resolve(name) + context.contentResolver.openInputStream(uri)?.use { input -> + Files.copy(input, target, StandardCopyOption.REPLACE_EXISTING) + } ?: throw IOException("Failed to open input stream") + Schedulers.androidUIThread().execute { + Toast.makeText(context, R.string.message_success, Toast.LENGTH_SHORT).show() + refresh() + } + } catch (e: Exception) { + Schedulers.androidUIThread().execute { + isLoading = false + Toast.makeText(context, R.string.message_failed, Toast.LENGTH_SHORT).show() + } + } + }.start() + } + + private fun getDisplayName(context: Context, uri: Uri): String? { + if (uri.scheme == "content") { + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (index >= 0) return cursor.getString(index) + } + } + } + return uri.path?.let { java.io.File(it).name } + } + + private fun isZipFile(path: Path): Boolean { + return path.toString().lowercase(Locale.getDefault()).endsWith(".zip") + } +} \ No newline at end of file diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionSettingsPage.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionSettingsPage.kt new file mode 100644 index 0000000000..292c47d7fd --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionSettingsPage.kt @@ -0,0 +1,246 @@ +package com.tungsten.fcl.ui.glass.page.version + +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.kyant.backdrop.Backdrop +import com.mio.JavaManager +import com.tungsten.fcl.R +import com.tungsten.fcl.setting.Profile +import com.tungsten.fcl.ui.glass.FCLGlassRoute +import com.tungsten.fcl.ui.glass.component.GlassCard +import com.tungsten.fcl.ui.glass.component.GlassCheckbox +import com.tungsten.fcl.ui.glass.component.GlassSectionTitle +import com.tungsten.fcl.ui.glass.component.GlassSourceSelector +import com.tungsten.fcl.ui.glass.component.GlassTextButton +import com.tungsten.fcl.ui.glass.component.GlassTextField +import com.tungsten.fcl.ui.glass.component.GlassTopBar +import com.tungsten.fcllibrary.component.theme.ThemeEngine + +@Composable +fun VersionSettingsPage( + backdrop: Backdrop, + profile: Profile, + version: String, + navController: NavController, + onBack: () -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val state = remember(profile, version) { VersionSettingsStateHolder(profile, version) } + val tintColor = Color(ThemeEngine.getInstance().getTheme().getColor()) + + Column(modifier = modifier.fillMaxSize()) { + GlassTopBar( + title = version, + onBack = onBack + ) + + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + item { GlassSectionTitle(text = stringResource(R.string.settings_memory)) } + item { + GlassCard(backdrop = backdrop, modifier = Modifier.fillMaxWidth()) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + GlassCheckbox( + backdrop = backdrop, + checked = state.autoMemory, + onCheckedChange = { state.autoMemory = it }, + label = stringResource(R.string.settings_memory_auto_allocate) + ) + GlassTextField( + backdrop = backdrop, + value = state.maxMemory, + onValueChange = { state.maxMemory = it }, + hint = "Max Memory (MB)" + ) + GlassTextField( + backdrop = backdrop, + value = state.minMemory, + onValueChange = { state.minMemory = it }, + hint = "Min Memory (MB)" + ) + } + } + } + + item { GlassSectionTitle(text = stringResource(R.string.settings_game_java_version)) } + item { + GlassCard(backdrop = backdrop, modifier = Modifier.fillMaxWidth()) { + val javaVersions = remember { JavaManager.javaList.map { it.name } } + GlassSourceSelector( + backdrop = backdrop, + sources = javaVersions, + selected = state.java, + onSelect = { state.java = it }, + tint = tintColor + ) + } + } + + item { GlassSectionTitle(text = "JVM Arguments") } + item { + GlassCard(backdrop = backdrop, modifier = Modifier.fillMaxWidth()) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + GlassTextField( + backdrop = backdrop, + value = state.javaArgs, + onValueChange = { state.javaArgs = it }, + hint = "JVM Arguments", + singleLine = false + ) + GlassTextField( + backdrop = backdrop, + value = state.minecraftArgs, + onValueChange = { state.minecraftArgs = it }, + hint = "Minecraft Arguments", + singleLine = false + ) + } + } + } + + item { GlassSectionTitle(text = "Display") } + item { + GlassCard(backdrop = backdrop, modifier = Modifier.fillMaxWidth()) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + GlassCheckbox( + backdrop = backdrop, + checked = state.fullscreen, + onCheckedChange = { state.fullscreen = it }, + label = "Force Resolution", + tint = tintColor + ) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + GlassTextField( + backdrop = backdrop, + value = state.windowWidth, + onValueChange = { state.windowWidth = it }, + hint = "Width", + modifier = Modifier.weight(1f) + ) + GlassTextField( + backdrop = backdrop, + value = state.windowHeight, + onValueChange = { state.windowHeight = it }, + hint = "Height", + modifier = Modifier.weight(1f) + ) + } + } + } + } + + item { GlassSectionTitle(text = "Server") } + item { + GlassCard(backdrop = backdrop, modifier = Modifier.fillMaxWidth()) { + GlassTextField( + backdrop = backdrop, + value = state.serverIp, + onValueChange = { state.serverIp = it }, + hint = "Server Address" + ) + } + } + + item { GlassSectionTitle(text = "Other") } + item { + GlassCard(backdrop = backdrop, modifier = Modifier.fillMaxWidth()) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + GlassCheckbox( + backdrop = backdrop, + checked = state.isolateGameDir, + onCheckedChange = { state.isolateGameDir = it }, + label = stringResource(R.string.settings_game_working_directory) + ) + GlassCheckbox( + backdrop = backdrop, + checked = state.notCheckGame, + onCheckedChange = { state.notCheckGame = it }, + label = "Skip game completeness check" + ) + GlassCheckbox( + backdrop = backdrop, + checked = state.notCheckJVM, + onCheckedChange = { state.notCheckJVM = it }, + label = "Skip JVM check" + ) + } + } + } + + item { GlassSectionTitle(text = "Manage") } + item { + GlassCard(backdrop = backdrop, modifier = Modifier.fillMaxWidth()) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + GlassTextButton( + backdrop = backdrop, + text = "Manage Mods", + onClick = { navController.navigate(FCLGlassRoute.VersionMods(profile.name, version)) }, + modifier = Modifier.fillMaxWidth(), + tint = tintColor + ) + GlassTextButton( + backdrop = backdrop, + text = "Manage Worlds", + onClick = { navController.navigate(FCLGlassRoute.VersionWorlds(profile.name, version)) }, + modifier = Modifier.fillMaxWidth(), + tint = tintColor + ) + GlassTextButton( + backdrop = backdrop, + text = "Manage Resource Packs", + onClick = { navController.navigate(FCLGlassRoute.VersionResourcePacks(profile.name, version)) }, + modifier = Modifier.fillMaxWidth(), + tint = tintColor + ) + GlassTextButton( + backdrop = backdrop, + text = "Manage Shader Packs", + onClick = { navController.navigate(FCLGlassRoute.VersionShaderPacks(profile.name, version)) }, + modifier = Modifier.fillMaxWidth(), + tint = tintColor + ) + } + } + } + + item { Spacer(modifier = Modifier.height(8.dp)) } + item { + GlassTextButton( + backdrop = backdrop, + text = "Save", + onClick = { + state.save() + Toast.makeText(context, "Saved", Toast.LENGTH_SHORT).show() + onBack() + }, + modifier = Modifier.fillMaxWidth(), + tint = tintColor + ) + } + + item { Spacer(modifier = Modifier.height(24.dp)) } + } + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionSettingsStateHolder.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionSettingsStateHolder.kt new file mode 100644 index 0000000000..e69b23360b --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionSettingsStateHolder.kt @@ -0,0 +1,69 @@ +package com.tungsten.fcl.ui.glass.page.version + +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.tungsten.fcl.setting.Profile +import com.tungsten.fcl.setting.VersionSetting +import com.tungsten.fclauncher.utils.FCLPath +import com.tungsten.fclcore.util.Lang + +class VersionSettingsStateHolder( + private val profile: Profile, + val version: String +) { + private val setting: VersionSetting = profile.getVersionSetting(version) + private val preferences = FCLPath.CONTEXT?.getSharedPreferences("launcher", Context.MODE_PRIVATE) + + var java by mutableStateOf(setting.java) + var maxMemory by mutableStateOf(setting.maxMemory.toString()) + var minMemory by mutableStateOf(setting.minMemory?.toString() ?: "") + var javaArgs by mutableStateOf(setting.javaArgs) + var minecraftArgs by mutableStateOf(setting.minecraftArgs) + var serverIp by mutableStateOf(setting.serverIp) + var autoMemory by mutableStateOf(setting.isAutoMemory) + var fullscreen by mutableStateOf(setting.isForceResolution) + + private val resolution = parseResolution(preferences?.getString("force_resolution", "1920x1080")) + var windowWidth by mutableStateOf(resolution.first.toString()) + var windowHeight by mutableStateOf(resolution.second.toString()) + + var isolateGameDir by mutableStateOf(setting.isIsolateGameDir) + var notCheckGame by mutableStateOf(setting.isNotCheckGame) + var notCheckJVM by mutableStateOf(setting.isNotCheckJVM) + + fun save() { + setting.java = java + setting.maxMemory = Lang.parseInt(maxMemory, setting.maxMemory).coerceAtLeast(0) + setting.minMemory = Lang.parseInt(minMemory, setting.minMemory ?: -1).takeIf { it > 0 } + setting.javaArgs = javaArgs + setting.minecraftArgs = minecraftArgs + setting.serverIp = serverIp + setting.isAutoMemory = autoMemory + setting.isForceResolution = fullscreen + setting.isIsolateGameDir = isolateGameDir + setting.isNotCheckGame = notCheckGame + setting.isNotCheckJVM = notCheckJVM + + val width = Lang.parseInt(windowWidth, 1920) + val height = Lang.parseInt(windowHeight, 1080) + preferences?.edit()?.putString("force_resolution", "${width}x${height}")?.apply() + + profile.repository.saveVersionSetting(version) + } + + private fun parseResolution(value: String?): Pair { + val text = value ?: "1920x1080" + return try { + val parts = text.lowercase().split("x") + if (parts.size == 2) { + parts[0].trim().toInt() to parts[1].trim().toInt() + } else { + 1920 to 1080 + } + } catch (e: Exception) { + 1920 to 1080 + } + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionWorldListPage.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionWorldListPage.kt new file mode 100644 index 0000000000..e347d77d46 --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionWorldListPage.kt @@ -0,0 +1,292 @@ +package com.tungsten.fcl.ui.glass.page.version + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.FileUpload +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.R +import com.tungsten.fcl.setting.Profile +import com.tungsten.fcl.ui.glass.component.GlassCard +import com.tungsten.fcl.ui.glass.component.GlassCheckbox +import com.tungsten.fcl.ui.glass.component.GlassEmptyState +import com.tungsten.fcl.ui.glass.component.GlassSearchBar +import com.tungsten.fcl.ui.glass.component.GlassTopBar +import com.tungsten.fcl.ui.glass.component.dialog.GlassDialogManager +import com.tungsten.fclcore.game.World +import com.tungsten.fclcore.task.Schedulers +import com.tungsten.fclcore.task.Task +import com.tungsten.fcllibrary.component.theme.ThemeEngine +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@Composable +fun VersionWorldListPage( + backdrop: Backdrop, + profile: Profile, + version: String, + onBack: () -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val state = remember(profile, version) { VersionWorldListStateHolder(profile, version) } + val tintColor = androidx.compose.ui.graphics.Color(ThemeEngine.getInstance().getTheme().getColor()) + var showSearch by remember { mutableStateOf(false) } + var pendingExportWorld by remember { mutableStateOf(null) } + + val importLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri -> + uri?.let { sourceUri -> + Task.runAsync { + try { + val tempZip = File(context.cacheDir, "import_world.zip").toPath() + Files.deleteIfExists(tempZip) + context.contentResolver.openInputStream(sourceUri)?.use { input -> + Files.copy(input, tempZip, StandardCopyOption.REPLACE_EXISTING) + } ?: throw java.io.IOException("Failed to open input stream") + val world = World(tempZip) + Schedulers.androidUIThread().execute { + GlassDialogManager.showInput( + title = context.getString(R.string.world_name), + initialValue = world.worldName, + hint = context.getString(R.string.world_name_enter), + confirmText = context.getString(R.string.button_install), + dismissText = context.getString(com.tungsten.fcllibrary.R.string.dialog_negative), + onConfirm = { name -> state.importWorld(context, sourceUri, name) } + ) {} + } + } catch (e: Exception) { + Schedulers.androidUIThread().execute { + android.widget.Toast.makeText( + context, + context.getString(R.string.world_import_invalid), + android.widget.Toast.LENGTH_SHORT + ).show() + } + } + }.start() + } + } + + val exportTreeLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocumentTree() + ) { treeUri -> + val world = pendingExportWorld ?: return@rememberLauncherForActivityResult + pendingExportWorld = null + treeUri?.let { uri -> + GlassDialogManager.showInput( + title = context.getString(R.string.world_export), + initialValue = "${world.worldName}.zip", + hint = "file name", + confirmText = context.getString(R.string.button_export), + dismissText = context.getString(com.tungsten.fcllibrary.R.string.dialog_negative), + onConfirm = { fileName -> state.exportWorld(context, world, fileName, uri) } + ) {} + } + } + + Column(modifier = modifier.fillMaxSize()) { + GlassTopBar( + title = stringResource(R.string.world_manage), + onBack = onBack, + actions = { + IconButton(onClick = { importLauncher.launch(arrayOf("application/zip")) }) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "Import World", + tint = MaterialTheme.colorScheme.onSurface + ) + } + IconButton(onClick = { showSearch = !showSearch }) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + ) + + if (showSearch) { + GlassSearchBar( + backdrop = backdrop, + query = state.query, + onQueryChange = { state.query = it }, + hint = stringResource(R.string.search), + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp) + ) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp) + ) { + GlassCheckbox( + backdrop = backdrop, + checked = state.showAll, + onCheckedChange = { state.showAll = it }, + label = stringResource(R.string.world_show_all), + tint = tintColor + ) + } + + Box(modifier = Modifier.weight(1f)) { + val filtered = state.filteredWorlds() + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items( + items = filtered, + key = { it.fileName } + ) { world -> + WorldItemCard( + backdrop = backdrop, + world = world, + onExport = { + pendingExportWorld = world + exportTreeLauncher.launch(null) + }, + onDelete = { + GlassDialogManager.showAlert( + title = context.getString(R.string.button_remove), + message = context.getString(R.string.version_manage_remove_confirm, world.worldName), + confirmText = context.getString(R.string.button_remove), + dismissText = context.getString(com.tungsten.fcllibrary.R.string.dialog_negative), + onConfirm = { state.delete(context, world) } + ) {} + } + ) + } + + item { Spacer(modifier = Modifier.height(24.dp)) } + } + + if (state.isLoading && filtered.isEmpty()) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = MaterialTheme.colorScheme.onSurface + ) + } + + if (!state.isLoading && filtered.isEmpty()) { + GlassEmptyState( + text = stringResource(R.string.world_import_invalid), + modifier = Modifier.fillMaxSize() + ) + } + } + } +} + +@Composable +private fun WorldItemCard( + backdrop: Backdrop, + world: World, + onExport: () -> Unit, + onDelete: () -> Unit +) { + val context = LocalContext.current + val dateFormat = remember { SimpleDateFormat(context.getString(R.string.world_time), Locale.getDefault()) } + val subtitle = stringResource( + R.string.world_description, + world.fileName, + dateFormat.format(Date(world.lastPlayed)), + world.gameVersion ?: context.getString(R.string.message_unknown) + ) + + GlassCard( + backdrop = backdrop, + modifier = Modifier.fillMaxWidth() + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Box( + modifier = Modifier.size(48.dp), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 12.dp) + ) { + Text( + text = world.worldName, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + maxLines = 2 + ) + } + + IconButton(onClick = onExport) { + Icon( + imageVector = Icons.Default.FileUpload, + contentDescription = "Export", + tint = MaterialTheme.colorScheme.onSurface + ) + } + IconButton(onClick = onDelete) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete", + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + } +} \ No newline at end of file diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionWorldListStateHolder.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionWorldListStateHolder.kt new file mode 100644 index 0000000000..e62646d839 --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionWorldListStateHolder.kt @@ -0,0 +1,151 @@ +package com.tungsten.fcl.ui.glass.page.version + +import android.content.Context +import android.net.Uri +import android.provider.DocumentsContract +import android.widget.Toast +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.tungsten.fcl.R +import com.tungsten.fcl.setting.Profile +import com.tungsten.fclcore.game.World +import com.tungsten.fclcore.task.Schedulers +import com.tungsten.fclcore.task.Task +import com.tungsten.fclcore.util.io.FileUtils +import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.util.Locale + +class VersionWorldListStateHolder( + private val profile: Profile, + private val versionId: String +) { + var isLoading by mutableStateOf(false) + private set + var showAll by mutableStateOf(false) + var gameVersion by mutableStateOf(null) + private set + val worlds = mutableStateListOf() + var query by mutableStateOf("") + + init { + refresh() + } + + fun refresh() { + isLoading = true + Task.runAsync { + try { + val repository = profile.getRepository() + val gv = repository.getGameVersion(versionId).orElse(null) + val savesDir = repository.getRunDirectory(versionId).toPath().resolve("saves") + val list = World.getWorlds(savesDir) + Schedulers.androidUIThread().execute { + gameVersion = gv + worlds.clear() + worlds.addAll(list) + isLoading = false + } + } catch (e: Exception) { + Schedulers.androidUIThread().execute { + isLoading = false + } + } + }.start() + } + + fun filteredWorlds(): List { + var list = if (showAll) worlds else worlds.filter { + it.gameVersion == null || it.gameVersion == gameVersion + } + val q = query.trim().lowercase(Locale.getDefault()) + if (q.isNotEmpty()) { + list = list.filter { + it.worldName.lowercase(Locale.getDefault()).contains(q) || + it.fileName.lowercase(Locale.getDefault()).contains(q) + } + } + return list + } + + fun delete(context: Context, world: World) { + Task.runAsync { + try { + FileUtils.forceDelete(world.file.toFile()) + Schedulers.androidUIThread().execute { refresh() } + } catch (e: Exception) { + Schedulers.androidUIThread().execute { + Toast.makeText(context, R.string.message_failed, Toast.LENGTH_SHORT).show() + } + } + }.start() + } + + fun importWorld(context: Context, sourceUri: Uri, name: String) { + isLoading = true + Task.runAsync { + try { + val repository = profile.getRepository() + val savesDir = repository.getRunDirectory(versionId).toPath().resolve("saves") + val tempZip = File(context.cacheDir, "import_world.zip").toPath() + Files.deleteIfExists(tempZip) + context.contentResolver.openInputStream(sourceUri)?.use { input -> + Files.copy(input, tempZip, StandardCopyOption.REPLACE_EXISTING) + } ?: throw IOException("Failed to open input stream") + val world = World(tempZip) + world.install(savesDir, name) + Schedulers.androidUIThread().execute { + Toast.makeText(context, R.string.message_success, Toast.LENGTH_SHORT).show() + refresh() + } + } catch (e: Exception) { + Schedulers.androidUIThread().execute { + isLoading = false + val message = when (e) { + is java.nio.file.FileAlreadyExistsException -> context.getString(R.string.world_import_already_exists) + is IOException -> context.getString(R.string.world_import_failed, e.localizedMessage ?: "") + else -> context.getString(R.string.world_import_failed, e.localizedMessage ?: "") + } + Toast.makeText(context, message, Toast.LENGTH_LONG).show() + } + } + }.start() + } + + fun exportWorld(context: Context, world: World, fileName: String, treeUri: Uri) { + isLoading = true + Task.runAsync { + try { + val tempZip = File(context.cacheDir, "export_world.zip").toPath() + Files.deleteIfExists(tempZip) + world.export(tempZip, world.worldName) + + val finalName = if (fileName.lowercase(Locale.getDefault()).endsWith(".zip")) fileName else "$fileName.zip" + val docUri = DocumentsContract.createDocument( + context.contentResolver, + treeUri, + "application/zip", + finalName + ) ?: throw IOException("Failed to create export document") + + context.contentResolver.openOutputStream(docUri)?.use { output -> + Files.copy(tempZip, output) + } ?: throw IOException("Failed to open output stream") + + Schedulers.androidUIThread().execute { + isLoading = false + Toast.makeText(context, R.string.message_success, Toast.LENGTH_SHORT).show() + } + } catch (e: Exception) { + Schedulers.androidUIThread().execute { + isLoading = false + Toast.makeText(context, R.string.message_failed, Toast.LENGTH_SHORT).show() + } + } + }.start() + } +} \ No newline at end of file diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/versions/VersionListStateHolder.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/versions/VersionListStateHolder.kt new file mode 100644 index 0000000000..98dbd7d088 --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/versions/VersionListStateHolder.kt @@ -0,0 +1,139 @@ +package com.tungsten.fcl.ui.glass.page.versions + +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.google.gson.JsonParseException +import com.tungsten.fcl.R +import com.tungsten.fcl.setting.Profile +import com.tungsten.fcl.setting.Profiles +import com.tungsten.fcl.ui.version.VersionListItem +import com.tungsten.fcl.ui.version.Versions +import com.tungsten.fcl.util.AndroidUtils +import com.tungsten.fclcore.download.LibraryAnalyzer +import com.tungsten.fclcore.fakefx.beans.binding.Bindings +import com.tungsten.fclcore.game.Version +import com.tungsten.fclcore.mod.ModpackConfiguration +import com.tungsten.fclcore.task.Task +import com.tungsten.fclcore.util.Logging +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.IOException +import java.util.Locale +import java.util.stream.Collectors + +class VersionListStateHolder { + var query by mutableStateOf("") + var selectedCategory by mutableStateOf(VersionCategory.ALL) + var isLoading by mutableStateOf(false) + val versions = mutableStateListOf() + var selectedProfile by mutableStateOf(Profiles.getSelectedProfile()) + + fun refresh() { + isLoading = true + val profile = selectedProfile + Task.runAsync { + profile.repository.refreshVersionsAsync().start() + }.start() + } + + suspend fun loadVersions(context: Context, profile: Profile) { + if (profile != selectedProfile) return + isLoading = true + val repository = profile.repository + val loaded = withContext(Dispatchers.IO) { + repository.displayVersions + .parallel() + .map { version: Version -> + val game = profile.repository.getGameVersion(version.id) + val libraries = StringBuilder(game.orElse(context.getString(R.string.message_unknown))) + val analyzer = LibraryAnalyzer.analyze( + profile.repository.getResolvedPreservingPatchesVersion(version.id), + game.orElse(null) + ) + for (mark in analyzer) { + val libraryId = mark.libraryId + val libraryVersion = mark.libraryVersion + if (libraryId == LibraryAnalyzer.LibraryType.MINECRAFT.patchId) continue + val resName = "install_installer_" + libraryId.replace("-", "_") + if (AndroidUtils.hasStringId(context, resName)) { + libraries.append(", ") + .append(AndroidUtils.getLocalizedText(context, resName)) + if (libraryVersion != null) { + libraries.append(": ") + .append(libraryVersion.replace(("(?i)$libraryId").toRegex(), "")) + } + } + } + var tag: String? = null + try { + val config: ModpackConfiguration<*>? = + profile.repository.readModpackConfiguration(version.id) + if (config != null) tag = config.version + } catch (e: IOException) { + Logging.LOG.log(java.util.logging.Level.WARNING, "Failed to read modpack configuration from $version", e) + } catch (e: JsonParseException) { + Logging.LOG.log(java.util.logging.Level.WARNING, "Failed to read modpack configuration from $version", e) + } + VersionListItem( + profile, + version.id, + libraries.toString(), + tag, + repository.getVersionIconImage(version.id) + ) + } + .collect(Collectors.toList()) + } + if (profile != selectedProfile) return + versions.clear() + versions.addAll(loaded) + versions.forEach { item -> + item.selectedProperty().bind( + Bindings.createBooleanBinding({ + profile.selectedVersionProperty().get() == item.version + }, profile.selectedVersionProperty()) + ) + } + isLoading = false + } + + fun filteredVersions(): List { + var list = versions.toList() + val q = query.trim().lowercase(Locale.getDefault()) + if (q.isNotEmpty()) { + list = list.filter { it.version.lowercase(Locale.getDefault()).contains(q) } + } + return when (selectedCategory) { + VersionCategory.ALL -> list + VersionCategory.FABRIC -> list.filter { it.libraries.contains("Fabric") } + VersionCategory.FORGE -> list.filter { it.libraries.contains("Forge") && !it.libraries.contains("NeoForge") } + VersionCategory.NEOFORGE -> list.filter { it.libraries.contains("NeoForge") } + VersionCategory.OTHER -> list.filter { + !it.libraries.contains("Fabric") && !it.libraries.contains("Forge") && !it.libraries.contains("NeoForge") + } + } + } + + fun launchVersion(context: Context, item: VersionListItem) { + Versions.launch(context, item.profile, item.version) + } + + fun renameVersion(context: Context, item: VersionListItem) { + Versions.renameVersion(context, item.profile, item.version) + } + + fun deleteVersion(context: Context, item: VersionListItem) { + Versions.deleteVersion(context, item.profile, item.version) + } + + fun duplicateVersion(context: Context, item: VersionListItem) { + Versions.duplicateVersion(context, item.profile, item.version) + } +} + +enum class VersionCategory { + ALL, FABRIC, FORGE, NEOFORGE, OTHER +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/glass/theme/GlassTheme.kt b/FCL/src/main/java/com/tungsten/fcl/ui/glass/theme/GlassTheme.kt new file mode 100644 index 0000000000..69f846a8da --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/glass/theme/GlassTheme.kt @@ -0,0 +1,27 @@ +package com.tungsten.fcl.ui.glass.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +object GlassTheme { + val glassCornerRadius = 28.dp + val buttonCornerRadius = 50.dp + val chipCornerRadius = 50.dp + + @Composable + fun backgroundColor(): Color { + return if (isSystemInDarkTheme()) Color(0xFF1A1A2E) else Color(0xFFF0F4F8) + } + + @Composable + fun surfaceColor(): Color { + return Color.White.copy(alpha = 0.45f) + } + + @Composable + fun onSurfaceColor(): Color { + return if (isSystemInDarkTheme()) Color.White else Color(0xFF1A1A2E) + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/setting/LauncherSettingPage.java b/FCL/src/main/java/com/tungsten/fcl/ui/setting/LauncherSettingPage.java index de926c9ba5..b897bc603a 100644 --- a/FCL/src/main/java/com/tungsten/fcl/ui/setting/LauncherSettingPage.java +++ b/FCL/src/main/java/com/tungsten/fcl/ui/setting/LauncherSettingPage.java @@ -51,8 +51,10 @@ import com.tungsten.fcllibrary.component.dialog.FCLColorPickerDialog; import com.tungsten.fcllibrary.component.theme.Theme; import com.tungsten.fcllibrary.component.theme.ThemeEngine; +import com.tungsten.fcllibrary.component.theme.ThemePreset; import com.tungsten.fcllibrary.component.ui.FCLCommonPage; import com.tungsten.fcllibrary.component.view.FCLUILayout; +import com.tungsten.fcl.ui.setting.compose.ComposeViewUtilsKt; import com.tungsten.fcllibrary.util.LocaleUtils; import java.io.File; @@ -70,6 +72,7 @@ public class LauncherSettingPage extends FCLCommonPage implements View.OnClickLi public static final long ONE_DAY = 1000 * 60 * 60 * 24; private PageSettingLauncherBinding binding; private boolean isFirst = true; + private boolean isFirstThemePreset = true; private SharedPreferences sharedPreferences; public LauncherSettingPage(Context context, int id, FCLUILayout parent, int resId) { @@ -139,6 +142,20 @@ public void onCreate() { binding.themeMode.setSelection(sharedPreferences.getInt("themeMode", 0)); binding.themeMode.setOnItemSelectedListener(this); + ArrayList themePresetList = new ArrayList<>(); + for (ThemePreset preset : ThemePreset.values()) { + themePresetList.add(getContext().getString(getContext().getResources().getIdentifier( + "settings_launcher_theme_preset_" + preset.getName().toLowerCase().replace(" ", "_"), + "string", getContext().getPackageName()))); + } + ArrayAdapter themePresetAdapter = new ArrayAdapter<>(getContext(), R.layout.item_spinner_auto_tint, themePresetList); + themePresetAdapter.setDropDownViewResource(R.layout.item_spinner_dropdown); + binding.themePreset.setAdapter(themePresetAdapter); + binding.themePreset.setSelection(sharedPreferences.getInt("themePreset", 0)); + binding.themePreset.setOnItemSelectedListener(this); + + updateThemePreview(); + binding.autoExitLauncher.setChecked(sharedPreferences.getBoolean("autoExitLauncher", false)); binding.autoExitLauncher.setOnCheckedChangeListener(this); @@ -234,6 +251,13 @@ private int getSourcePosition(String source) { }; } + private void updateThemePreview() { + boolean isDarkMode = (getContext().getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES; + int presetPosition = sharedPreferences.getInt("themePreset", 0); + ThemePreset preset = ThemePreset.values()[presetPosition]; + ComposeViewUtilsKt.setLiquidGlassPreview(binding.themePreview, preset, isDarkMode); + } + @Override public Task refresh(Object... param) { return null; @@ -566,6 +590,18 @@ public void onItemSelected(AdapterView parent, View view, int position, long mode = position == 1 ? AppCompatDelegate.MODE_NIGHT_NO : AppCompatDelegate.MODE_NIGHT_YES; } AppCompatDelegate.setDefaultNightMode(mode); + updateThemePreview(); + } else if (parent == binding.themePreset) { + if (!isFirstThemePreset) { + sharedPreferences.edit().putInt("themePreset", position).apply(); + ThemePreset preset = ThemePreset.values()[position]; + ThemeEngine.getInstance().applyAndSave(getContext(), preset.getColor()); + ThemeEngine.getInstance().applyAndSave2(getContext(), preset.getColor2()); + ThemeEngine.getInstance().applyAndSave2Dark(getContext(), preset.getColor2Dark()); + updateThemePreview(); + } else { + isFirstThemePreset = false; + } } } diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/setting/compose/ComposeViewUtils.kt b/FCL/src/main/java/com/tungsten/fcl/ui/setting/compose/ComposeViewUtils.kt new file mode 100644 index 0000000000..96531e816d --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/setting/compose/ComposeViewUtils.kt @@ -0,0 +1,13 @@ +package com.tungsten.fcl.ui.setting.compose + +import androidx.compose.ui.platform.ComposeView +import com.tungsten.fcllibrary.component.theme.ThemePreset + +/** + * 在 Java 设置页面中设置液态玻璃主题预览的便捷函数。 + */ +fun ComposeView.setLiquidGlassPreview(preset: ThemePreset, isDark: Boolean) { + setContent { + LiquidGlassThemePreview(preset = preset, isDark = isDark) + } +} diff --git a/FCL/src/main/java/com/tungsten/fcl/ui/setting/compose/LiquidGlassThemePreview.kt b/FCL/src/main/java/com/tungsten/fcl/ui/setting/compose/LiquidGlassThemePreview.kt new file mode 100644 index 0000000000..3eba70186d --- /dev/null +++ b/FCL/src/main/java/com/tungsten/fcl/ui/setting/compose/LiquidGlassThemePreview.kt @@ -0,0 +1,237 @@ +package com.tungsten.fcl.ui.setting.compose + +import android.os.Build +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeContentPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.effects.vibrancy +import com.tungsten.fcl.R +import com.tungsten.fcllibrary.component.theme.ThemePreset + +/** + * 液态玻璃主题预览,严格遵循 Backdrop 官方教程实现。 + * + * 关键规则对照: + * - 规则 2:drawContent() 必须在 rememberLayerBackdrop 的 block 中调用 + * - 规则 3:lens() 需要 Android 13+,必须做版本检查 + * - 规则 4:lens() 的 shape 必须是 CornerBasedShape,因此使用 RoundedCornerShape 而非 CircleShape + * - 规则 5:效果顺序必须是 color filter => blur => lens + * - 规则 7:背景色必须在 drawContent() 之前绘制 + * - 规则 8:shape 参数必须是 lambda 形式 + * - 规则 10:所有尺寸参数必须使用 dp.toPx() 转换 + * - 规则 11:着色玻璃效果必须使用 BlendMode.Hue + */ +@Composable +fun LiquidGlassThemePreview( + preset: ThemePreset, + isDark: Boolean, + modifier: Modifier = Modifier +) { + MaterialTheme( + colorScheme = if (isDark) darkColorScheme() else lightColorScheme() + ) { + CompositionLocalProvider( + LocalLayoutDirection provides LayoutDirection.Ltr + ) { + val backgroundColor = if (isDark) Color(0xFF1A1A2E) else Color(0xFFF0F4F8) + val tintColor = Color(preset.getColor()) + + BoxWithConstraints( + modifier = modifier + .fillMaxWidth() + .aspectRatio(1.6f) + .background(backgroundColor) + ) { + val backdrop = rememberLayerBackdrop { + // 规则 7:先绘制背景色,再绘制内容 + drawRect(backgroundColor) + drawContent() + } + + // 背景内容,被 layerBackdrop 捕获到 backdrop 中 + Box( + modifier = Modifier + .fillMaxSize() + .layerBackdrop(backdrop) + .padding(16.dp) + ) { + BackgroundContent(tintColor = tintColor, isDark = isDark) + } + + // 玻璃底部栏 + GlassBottomBar( + backdrop = backdrop, + tintColor = tintColor, + modifier = Modifier + .safeContentPadding() + .align(Alignment.BottomCenter) + ) + } + } + } +} + +@Composable +private fun BackgroundContent( + tintColor: Color, + isDark: Boolean, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth() + ) { + DecorativeCard( + color = tintColor.copy(alpha = 0.25f), + modifier = Modifier + .weight(1f) + .height(80.dp) + ) + DecorativeCard( + color = if (isDark) Color(0xFF2A2A3E) else Color(0xFFE0E7EF), + modifier = Modifier + .weight(1f) + .height(80.dp) + ) + } + DecorativeCard( + color = if (isDark) Color(0xFF252536) else Color(0xFFE8EEF5), + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) + } +} + +@Composable +private fun DecorativeCard( + color: Color, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .background(color, RoundedCornerShape(16.dp)) + ) +} + +@Composable +private fun GlassBottomBar( + backdrop: com.kyant.backdrop.Backdrop, + tintColor: Color, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .padding(16.dp) + .height(80.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // 普通玻璃按钮:白色半透明表面层 + GlassButton( + backdrop = backdrop, + tintColor = null, + modifier = Modifier + .fillMaxHeight() + .weight(1f) + ) + + // 有色玻璃按钮:蓝色 Hue blend + 75% alpha 叠加 + GlassButton( + backdrop = backdrop, + tintColor = tintColor, + modifier = Modifier + .fillMaxHeight() + .aspectRatio(1f) + ) + } +} + +@Composable +private fun GlassButton( + backdrop: com.kyant.backdrop.Backdrop, + tintColor: Color?, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .drawBackdrop( + backdrop = backdrop, + // 规则 8:shape 参数必须是 lambda 形式 + // 规则 4:lens() 要求 CornerBasedShape,RoundedCornerShape 是 CornerBasedShape 的子类 + // 使用 50% 圆角百分比获得真正的胶囊/药丸形状 + shape = { RoundedCornerShape(50) }, + effects = { + // 规则 5:效果顺序必须是 color filter => blur => lens + vibrancy() + blur(4f.dp.toPx()) + // 规则 3:Android 13+ 功能必须做版本检查 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + lens(16f.dp.toPx(), 32f.dp.toPx()) + } + }, + onDrawSurface = { + if (tintColor != null) { + // 规则 11:着色玻璃效果必须使用 BlendMode.Hue + drawRect(tintColor, blendMode = androidx.compose.ui.graphics.BlendMode.Hue) + drawRect(tintColor.copy(alpha = 0.75f)) + } else { + drawRect(Color.White.copy(alpha = 0.5f)) + } + } + ), + contentAlignment = Alignment.Center + ) { + if (tintColor != null) { + Icon( + painter = painterResource(id = R.drawable.ic_baseline_build_24), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(24.dp) + ) + } else { + Text( + text = "FCL", + color = Color(0xFF0E0E0E), + fontSize = 16.sp + ) + } + } +} diff --git a/FCL/src/main/res/drawable/bg_container_glass.xml b/FCL/src/main/res/drawable/bg_container_glass.xml new file mode 100644 index 0000000000..c099785cf3 --- /dev/null +++ b/FCL/src/main/res/drawable/bg_container_glass.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/FCL/src/main/res/layout/page_setting_launcher.xml b/FCL/src/main/res/layout/page_setting_launcher.xml index 39ac3cadb0..2c78543ce7 100644 --- a/FCL/src/main/res/layout/page_setting_launcher.xml +++ b/FCL/src/main/res/layout/page_setting_launcher.xml @@ -230,6 +230,17 @@ android:orientation="vertical" app:auto_linear_background_tint="true"> + + + + + + + + + + + + + + + + 次要主题色(亮色模式) 次要主题色(暗色模式) 从背景图片提取 + 主题预设 + 默认 + 液态玻璃 + 樱花粉 + 薄荷绿 + 薰衣草紫 + 日落橙 亮色模式启动器背景图片(最大分辨率5120x5120) 暗色模式启动器背景图片(最大分辨率5120x5120) 启动器背景(视频 .mp4) diff --git a/FCL/src/main/res/values/strings.xml b/FCL/src/main/res/values/strings.xml index e5d48682b5..ccc4c03129 100644 --- a/FCL/src/main/res/values/strings.xml +++ b/FCL/src/main/res/values/strings.xml @@ -798,6 +798,8 @@ Choose a mod CurseForge Dependency + Dependencies + No dependencies built-in pre-mod (already packaged in the mod file by the author, no need to download separately) optional pre-mod (if the game is missing, but mod functionality may be missing) required pre-mod (must be downloaded separately, missing may cause the game to fail to launch) @@ -927,6 +929,14 @@ Secondary Theme Color(Light Mode) Secondary Theme Color(Dark Mode) Extract from background image + Theme Preset + Theme Preview + Default + Liquid Glass + Sakura Pink + Mint Green + Lavender + Sunset Orange Light Mode Launcher Background (Max 5120x5120) Dark Mode Launcher Background (Max 5120x5120) Launcher Background (Video .mp4) diff --git a/FCLLibrary/src/main/java/com/tungsten/fcllibrary/component/theme/ThemePreset.java b/FCLLibrary/src/main/java/com/tungsten/fcllibrary/component/theme/ThemePreset.java new file mode 100644 index 0000000000..8aed4e5b55 --- /dev/null +++ b/FCLLibrary/src/main/java/com/tungsten/fcllibrary/component/theme/ThemePreset.java @@ -0,0 +1,49 @@ +package com.tungsten.fcllibrary.component.theme; + +import android.graphics.Color; + +/** + * 主题预设,提供一键切换主题配色方案。 + */ +public enum ThemePreset { + + DEFAULT("Default", Color.parseColor("#7797CF"), Color.parseColor("#000000"), Color.parseColor("#FFFFFF")), + + LIQUID_GLASS("Liquid Glass", Color.parseColor("#0088FF"), Color.parseColor("#D6EAFA"), Color.parseColor("#1A5C9E")), + + SAKURA_PINK("Sakura Pink", Color.parseColor("#FFB7C5"), Color.parseColor("#FFE4E9"), Color.parseColor("#F0C0CC")), + + MINT_GREEN("Mint Green", Color.parseColor("#7ECB9A"), Color.parseColor("#D4F0DF"), Color.parseColor("#B8DCC8")), + + LAVENDER("Lavender", Color.parseColor("#B39DDB"), Color.parseColor("#E8E0F5"), Color.parseColor("#D1C4E9")), + + SUNSET_ORANGE("Sunset Orange", Color.parseColor("#FFAB76"), Color.parseColor("#FFE0CC"), Color.parseColor("#F0C8A8")); + + private final String name; + private final int color; + private final int color2; + private final int color2Dark; + + ThemePreset(String name, int color, int color2, int color2Dark) { + this.name = name; + this.color = color; + this.color2 = color2; + this.color2Dark = color2Dark; + } + + public String getName() { + return name; + } + + public int getColor() { + return color; + } + + public int getColor2() { + return color2; + } + + public int getColor2Dark() { + return color2Dark; + } +} \ No newline at end of file diff --git a/docs/superpowers/plans/2026-06-19-fcl-liquid-glass-phase1-dialogs.md b/docs/superpowers/plans/2026-06-19-fcl-liquid-glass-phase1-dialogs.md new file mode 100644 index 0000000000..4e02e773cd --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-fcl-liquid-glass-phase1-dialogs.md @@ -0,0 +1,1051 @@ +# FCL 液态玻璃 UI 迁移 - 阶段 1:通用 Dialog 统一化 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将 FCL 中所有旧 `FCLDialog` / `AlertDialog` 替换为统一的液态玻璃风格 Compose Dialog,并通过一个集中式 `GlassDialogHost` 在同一 Compose 树中渲染,确保 Dialog 也能复用 Backdrop 玻璃效果。 + +**Architecture:** 采用 "Dialog Host" 模式。`GlassDialogManager` 维护一个可观察的 Dialog 状态栈;`FCLGlassApp` 在根层级放置 `GlassDialogHost`,根据状态栈渲染对应的玻璃 Dialog;业务代码从 `new XxxDialog(context).show()` 改为调用 `GlassDialogManager.showXxx(...)`。所有 Dialog UI 组件均使用 `drawBackdrop` 实现玻璃态背景。 + +**Tech Stack:** Kotlin, Jetpack Compose, Backdrop (`io.github.kyant0:backdrop:2.0.0`), Compose Navigation, FCLCore tasks. + +--- + +## File Structure + +| File | Responsibility | +| --- | --- | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDialogManager.kt` | 单例状态持有者,管理当前显示的 Dialog 请求队列,提供 `showXxx` / `dismiss` API。 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDialogHost.kt` | 根层级 Composable,读取 `GlassDialogManager` 状态并渲染当前 Dialog。 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassAlertDialog.kt` | 通用确认/提示 Dialog UI。 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassInputDialog.kt` | 通用单行输入 Dialog UI。 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassSelectionDialog.kt` | 通用单选列表 Dialog UI。 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassProgressDialog.kt` | 通用后台任务进度 Dialog UI。 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassCreateAccountDialog.kt` | 创建账户 Dialog UI。 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassReLoginDialog.kt` | 账户重新登录 Dialog UI。 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassAuthlibServerDialog.kt` | 添加/编辑 AuthlibInjector 服务器 Dialog UI。 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassRenameVersionDialog.kt` | 重命名版本 Dialog UI。 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDuplicateVersionDialog.kt` | 复制版本 Dialog UI。 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassAddProfileDialog.kt` | 添加游戏配置文件 Dialog UI。 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLGlassApp.kt` | 根组件,注入 `GlassDialogHost`。 | + +--- + +## Task 1: GlassDialogManager - Dialog 状态管理器 + +**Files:** +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDialogManager.kt` + +- [ ] **Step 1: Create the manager skeleton** + +Create `GlassDialogManager.kt`: + +```kotlin +package com.tungsten.fcl.ui.glass.component.dialog + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.tungsten.fclcore.auth.Account +import com.tungsten.fclcore.auth.AccountFactory +import com.tungsten.fcl.setting.Profile + +sealed class DialogRequest( + val id: String = java.util.UUID.randomUUID().toString() +) { + data class Alert( + val title: String, + val message: String, + val confirmText: String, + val dismissText: String? = null, + val onConfirm: () -> Unit = {}, + val onDismiss: () -> Unit = {} + ) : DialogRequest() + + data class Input( + val title: String, + val initialValue: String = "", + val hint: String = "", + val confirmText: String, + val dismissText: String = "", + val onConfirm: (String) -> Unit, + val onDismiss: () -> Unit = {} + ) : DialogRequest() + + data class Selection( + val title: String, + val items: List, + val selected: T? = null, + val itemText: (T) -> String, + val confirmText: String? = null, + val dismissText: String = "", + val onSelect: (T) -> Unit, + val onDismiss: () -> Unit = {} + ) : DialogRequest() + + data class Progress( + val title: String, + val message: String = "", + val progress: Float? = null, + val onCancel: (() -> Unit)? = null + ) : DialogRequest() + + data class CreateAccount( + val factory: AccountFactory<*>? = null, + val onDismiss: () -> Unit = {} + ) : DialogRequest() + + data class ReLogin( + val account: Account, + val onDismiss: () -> Unit = {} + ) : DialogRequest() + + data class AuthlibServer( + val onDismiss: () -> Unit = {} + ) : DialogRequest() + + data class RenameVersion( + val profile: Profile, + val version: String, + val onDismiss: () -> Unit = {} + ) : DialogRequest() + + data class DuplicateVersion( + val profile: Profile, + val version: String, + val onDismiss: () -> Unit = {} + ) : DialogRequest() + + data class AddProfile( + val onDismiss: () -> Unit = {} + ) : DialogRequest() +} + +object GlassDialogManager { + private val _requests = mutableStateListOf() + val requests: List get() = _requests + + var current by mutableStateOf(null) + private set + + fun show(request: DialogRequest) { + _requests.add(request) + if (current == null) current = request + } + + fun dismiss(request: DialogRequest? = current) { + request?.let { _requests.remove(it) } + current = _requests.firstOrNull() + } + + // Convenience methods + fun showAlert( + title: String, + message: String, + confirmText: String, + dismissText: String? = null, + onConfirm: () -> Unit = {}, + onDismiss: () -> Unit = {} + ) = show(DialogRequest.Alert(title, message, confirmText, dismissText, onConfirm, onDismiss)) + + fun showInput( + title: String, + initialValue: String = "", + hint: String = "", + confirmText: String, + dismissText: String = "", + onConfirm: (String) -> Unit, + onDismiss: () -> Unit = {} + ) = show(DialogRequest.Input(title, initialValue, hint, confirmText, dismissText, onConfirm, onDismiss)) + + fun showSelection( + title: String, + items: List, + selected: T? = null, + itemText: (T) -> String = { it.toString() }, + confirmText: String? = null, + dismissText: String = "", + onSelect: (T) -> Unit, + onDismiss: () -> Unit = {} + ) = show(DialogRequest.Selection(title, items, selected, itemText, confirmText, dismissText, onSelect, onDismiss)) + + fun showProgress( + title: String, + message: String = "", + progress: Float? = null, + onCancel: (() -> Unit)? = null + ) = show(DialogRequest.Progress(title, message, progress, onCancel)) + + fun showCreateAccount(factory: AccountFactory<*>? = null, onDismiss: () -> Unit = {}) = + show(DialogRequest.CreateAccount(factory, onDismiss)) + + fun showReLogin(account: Account, onDismiss: () -> Unit = {}) = + show(DialogRequest.ReLogin(account, onDismiss)) + + fun showAuthlibServer(onDismiss: () -> Unit = {}) = + show(DialogRequest.AuthlibServer(onDismiss)) + + fun showRenameVersion(profile: Profile, version: String, onDismiss: () -> Unit = {}) = + show(DialogRequest.RenameVersion(profile, version, onDismiss)) + + fun showDuplicateVersion(profile: Profile, version: String, onDismiss: () -> Unit = {}) = + show(DialogRequest.DuplicateVersion(profile, version, onDismiss)) + + fun showAddProfile(onDismiss: () -> Unit = {}) = + show(DialogRequest.AddProfile(onDismiss)) +} +``` + +- [ ] **Step 2: Compile check** + +Run: `JAVA_HOME=/root/.local/share/mise/installs/java/17.0.2 gradle --no-daemon :FCL:compileFordebugKotlin` + +Expected: FAIL with unresolved symbols from Backdrop/Compose (normal; components not yet created). + +- [ ] **Step 3: Commit skeleton** + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDialogManager.kt +git commit -m "feat(glass): add GlassDialogManager for centralized dialog state" +``` + +--- + +## Task 2: GlassDialogHost - 根层级 Dialog 渲染器 + +**Files:** +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDialogHost.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLGlassApp.kt` + +- [ ] **Step 1: Create GlassDialogHost** + +Create `GlassDialogHost.kt`: + +```kotlin +package com.tungsten.fcl.ui.glass.component.dialog + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.ui.glass.theme.GlassTheme + +@Composable +fun GlassDialogHost( + backdrop: Backdrop, + modifier: Modifier = Modifier +) { + val request = GlassDialogManager.current ?: return + + Box( + modifier = modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.4f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { + when (request) { + is DialogRequest.Alert -> { + request.onDismiss() + GlassDialogManager.dismiss() + } + is DialogRequest.Input -> { + request.onDismiss() + GlassDialogManager.dismiss() + } + is DialogRequest.Selection<*> -> { + request.onDismiss() + GlassDialogManager.dismiss() + } + else -> { /* non-dismissible by background tap */ } + } + } + ), + contentAlignment = Alignment.Center + ) { + Box(modifier = Modifier.padding(horizontal = 32.dp)) { + when (request) { + is DialogRequest.Alert -> GlassAlertDialog( + backdrop = backdrop, + request = request + ) + is DialogRequest.Input -> GlassInputDialog( + backdrop = backdrop, + request = request + ) + is DialogRequest.Selection<*> -> GlassSelectionDialog( + backdrop = backdrop, + request = request + ) + is DialogRequest.Progress -> GlassProgressDialog( + backdrop = backdrop, + request = request + ) + is DialogRequest.CreateAccount -> GlassCreateAccountDialog( + backdrop = backdrop, + request = request + ) + is DialogRequest.ReLogin -> GlassReLoginDialog( + backdrop = backdrop, + request = request + ) + is DialogRequest.AuthlibServer -> GlassAuthlibServerDialog( + backdrop = backdrop, + request = request + ) + is DialogRequest.RenameVersion -> GlassRenameVersionDialog( + backdrop = backdrop, + request = request + ) + is DialogRequest.DuplicateVersion -> GlassDuplicateVersionDialog( + backdrop = backdrop, + request = request + ) + is DialogRequest.AddProfile -> GlassAddProfileDialog( + backdrop = backdrop, + request = request + ) + } + } + } +} +``` + +- [ ] **Step 2: Inject GlassDialogHost into FCLGlassApp** + +Modify `FCLGlassApp.kt` to place `GlassDialogHost` at the root, above `GlassNavHost`. + +Example change: + +```kotlin +// In FCLGlassApp.kt, inside the root Box/Scaffold +Box(modifier = Modifier.fillMaxSize()) { + // ... background & nav host ... + GlassDialogHost(backdrop = backdrop) +} +``` + +- [ ] **Step 3: Compile check** + +Run: `JAVA_HOME=/root/.local/share/mise/installs/java/17.0.2 gradle --no-daemon :FCL:compileFordebugKotlin` + +Expected: FAIL with missing dialog component references (they are created in next tasks). + +- [ ] **Step 4: Commit** + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDialogHost.kt FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLGlassApp.kt +git commit -m "feat(glass): add GlassDialogHost and wire into FCLGlassApp" +``` + +--- + +## Task 3: Generic Dialog UI Components + +**Files:** +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassAlertDialog.kt` +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassInputDialog.kt` +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassSelectionDialog.kt` +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassProgressDialog.kt` +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDialogSurface.kt` (shared backdrop container) + +- [ ] **Step 1: Create shared GlassDialogSurface** + +Create `GlassDialogSurface.kt`: + +```kotlin +package com.tungsten.fcl.ui.glass.component.dialog + +import android.os.Build +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.effects.vibrancy +import com.tungsten.fcl.ui.glass.theme.GlassTheme + +@Composable +fun GlassDialogSurface( + backdrop: Backdrop, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit +) { + val shape = RoundedCornerShape(GlassTheme.glassCornerRadius) + Column( + modifier = modifier + .fillMaxWidth() + .clip(shape) + .drawBackdrop( + backdrop = backdrop, + shape = { shape }, + effects = { + vibrancy() + blur(12f.dp.toPx()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + lens(12f.dp.toPx(), 24f.dp.toPx()) + } + }, + onDrawSurface = { drawRect(GlassTheme.surfaceColor()) } + ) + .padding(20.dp) + ) { + content() + } +} +``` + +- [ ] **Step 2: Create GlassAlertDialog** + +Create `GlassAlertDialog.kt`: + +```kotlin +package com.tungsten.fcl.ui.glass.component.dialog + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.ui.glass.component.GlassButton +import com.tungsten.fcl.ui.glass.component.GlassTextButton +import com.tungsten.fcllibrary.component.theme.ThemeEngine + +@Composable +fun GlassAlertDialog( + backdrop: Backdrop, + request: DialogRequest.Alert +) { + val tint = Color(ThemeEngine.getInstance().getTheme().getColor()) + GlassDialogSurface(backdrop = backdrop) { + Text( + text = request.title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = request.message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + ) + Spacer(modifier = Modifier.height(20.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + request.dismissText?.let { + GlassTextButton( + backdrop = backdrop, + text = it, + onClick = { + request.onDismiss() + GlassDialogManager.dismiss() + }, + modifier = Modifier.weight(1f) + ) + } + GlassButton( + backdrop = backdrop, + onClick = { + request.onConfirm() + GlassDialogManager.dismiss() + }, + tint = tint, + modifier = Modifier.weight(1f) + ) { + Text(text = request.confirmText, color = Color.White) + } + } + } +} +``` + +- [ ] **Step 3: Create GlassInputDialog** + +Create `GlassInputDialog.kt`: + +```kotlin +package com.tungsten.fcl.ui.glass.component.dialog + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.ui.glass.component.GlassButton +import com.tungsten.fcl.ui.glass.component.GlassTextButton +import com.tungsten.fcl.ui.glass.component.GlassTextField +import com.tungsten.fcllibrary.component.theme.ThemeEngine + +@Composable +fun GlassInputDialog( + backdrop: Backdrop, + request: DialogRequest.Input +) { + val tint = Color(ThemeEngine.getInstance().getTheme().getColor()) + var value by remember { mutableStateOf(request.initialValue) } + + GlassDialogSurface(backdrop = backdrop) { + Text( + text = request.title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(12.dp)) + GlassTextField( + backdrop = backdrop, + value = value, + onValueChange = { value = it }, + hint = request.hint, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(20.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + GlassTextButton( + backdrop = backdrop, + text = request.dismissText, + onClick = { + request.onDismiss() + GlassDialogManager.dismiss() + }, + modifier = Modifier.weight(1f) + ) + GlassButton( + backdrop = backdrop, + onClick = { + request.onConfirm(value) + GlassDialogManager.dismiss() + }, + tint = tint, + modifier = Modifier.weight(1f) + ) { + Text(text = request.confirmText, color = Color.White) + } + } + } +} +``` + +- [ ] **Step 4: Create GlassSelectionDialog** + +Create `GlassSelectionDialog.kt`: + +```kotlin +package com.tungsten.fcl.ui.glass.component.dialog + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.ui.glass.component.GlassTextButton + +@Composable +@Suppress("UNCHECKED_CAST") +fun GlassSelectionDialog( + backdrop: Backdrop, + request: DialogRequest.Selection +) { + var selected by remember { mutableStateOf(request.selected) } + + GlassDialogSurface(backdrop = backdrop) { + Text( + text = request.title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(12.dp)) + Column { + request.items.forEach { item -> + RowWithRadio( + text = request.itemText(item), + selected = selected == item, + onClick = { selected = item } + ) + } + } + Spacer(modifier = Modifier.height(20.dp)) + RowWithSpacing { + GlassTextButton( + backdrop = backdrop, + text = request.dismissText, + onClick = { + request.onDismiss() + GlassDialogManager.dismiss() + }, + modifier = Modifier.weight(1f) + ) + request.confirmText?.let { + GlassTextButton( + backdrop = backdrop, + text = it, + onClick = { + selected?.let { s -> request.onSelect(s as T) } + GlassDialogManager.dismiss() + }, + modifier = Modifier.weight(1f) + ) + } + } + } +} + +@Composable +private fun RowWithRadio( + text: String, + selected: Boolean, + onClick: () -> Unit +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 8.dp) + ) { + RadioButton(selected = selected, onClick = onClick) + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = 12.dp) + ) + } +} +``` + +- [ ] **Step 5: Create GlassProgressDialog** + +Create `GlassProgressDialog.kt`: + +```kotlin +package com.tungsten.fcl.ui.glass.component.dialog + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.ui.glass.component.GlassTextButton +import com.tungsten.fcllibrary.component.theme.ThemeEngine + +@Composable +fun GlassProgressDialog( + backdrop: Backdrop, + request: DialogRequest.Progress +) { + GlassDialogSurface(backdrop = backdrop) { + Text( + text = request.title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface + ) + if (request.message.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = request.message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + ) + } + Spacer(modifier = Modifier.height(16.dp)) + if (request.progress != null) { + LinearProgressIndicator( + progress = { request.progress }, + modifier = Modifier.fillMaxWidth(), + color = Color(ThemeEngine.getInstance().getTheme().getColor()) + ) + } else { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = Color(ThemeEngine.getInstance().getTheme().getColor()) + ) + } + request.onCancel?.let { onCancel -> + Spacer(modifier = Modifier.height(16.dp)) + GlassTextButton( + backdrop = backdrop, + text = "Cancel", + onClick = { + onCancel() + GlassDialogManager.dismiss() + }, + modifier = Modifier.fillMaxWidth() + ) + } + } +} +``` + +- [ ] **Step 6: Compile check** + +Run: `JAVA_HOME=/root/.local/share/mise/installs/java/17.0.2 gradle --no-daemon :FCL:compileFordebugKotlin` + +Expected: PASS (only generic dialogs; specific account/version dialogs created later). + +- [ ] **Step 7: Commit** + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/ +git commit -m "feat(glass): add generic glass dialog components" +``` + +--- + +## Task 4: Account Dialogs + +**Files:** +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassCreateAccountDialog.kt` +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassReLoginDialog.kt` +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassAuthlibServerDialog.kt` + +- [ ] **Step 1: Study old CreateAccountDialog logic** + +Read `FCL/src/main/java/com/tungsten/fcl/ui/account/CreateAccountDialog.java` and identify: +- Tab layout (Offline / Microsoft / AuthlibInjector) +- Form fields per tab +- Validation rules +- Account creation call (`Accounts.getAccounts().add(account)`) + +- [ ] **Step 2: Implement GlassCreateAccountDialog** + +Create `GlassCreateAccountDialog.kt` with tabbed form UI. Keep business logic by calling the same `Accounts` APIs used in the old dialog. + +Skeleton: + +```kotlin +package com.tungsten.fcl.ui.glass.component.dialog + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.setting.Accounts +import com.tungsten.fclcore.auth.AccountFactory + +@Composable +fun GlassCreateAccountDialog( + backdrop: Backdrop, + request: DialogRequest.CreateAccount +) { + var selectedType by remember { + mutableStateOf( + when (request.factory) { + Accounts.FACTORY_OFFLINE -> AccountType.OFFLINE + Accounts.FACTORY_MICROSOFT -> AccountType.MICROSOFT + else -> AccountType.OFFLINE + } + ) + } + + GlassDialogSurface(backdrop = backdrop) { + // Tabs + form per type + // Offline: username input + // Microsoft: login button + // AuthlibInjector: server selection + username + // Confirm/Cancel buttons + } +} + +private enum class AccountType { OFFLINE, MICROSOFT, AUTHLIB_INJECTOR } +``` + +- [ ] **Step 3: Implement GlassReLoginDialog** + +Create `GlassReLoginDialog.kt`: + +```kotlin +@Composable +fun GlassReLoginDialog( + backdrop: Backdrop, + request: DialogRequest.ReLogin +) { + GlassDialogSurface(backdrop = backdrop) { + // Show account info + password/refresh UI + // Call account.logIn() on confirm + } +} +``` + +- [ ] **Step 4: Implement GlassAuthlibServerDialog** + +Create `GlassAuthlibServerDialog.kt`: + +```kotlin +@Composable +fun GlassAuthlibServerDialog( + backdrop: Backdrop, + request: DialogRequest.AuthlibServer +) { + GlassDialogSurface(backdrop = backdrop) { + // Server URL input + validation + // Save to AuthlibInjectorServers + } +} +``` + +- [ ] **Step 5: Compile check** + +Run: `JAVA_HOME=/root/.local/share/mise/installs/java/17.0.2 gradle --no-daemon :FCL:compileFordebugKotlin` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassCreateAccountDialog.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassReLoginDialog.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassAuthlibServerDialog.kt +git commit -m "feat(glass): add account glass dialogs" +``` + +--- + +## Task 5: Version Dialogs + +**Files:** +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassRenameVersionDialog.kt` +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDuplicateVersionDialog.kt` +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassAddProfileDialog.kt` + +- [ ] **Step 1: Implement GlassRenameVersionDialog** + +Create `GlassRenameVersionDialog.kt`: + +```kotlin +package com.tungsten.fcl.ui.glass.component.dialog + +import androidx.compose.runtime.Composable +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.ui.version.Versions + +@Composable +fun GlassRenameVersionDialog( + backdrop: Backdrop, + request: DialogRequest.RenameVersion +) { + GlassInputDialog( + backdrop = backdrop, + request = DialogRequest.Input( + title = "Rename Version", // TODO use R.string.version_manage_rename + initialValue = request.version, + hint = "New name", + confirmText = "OK", + dismissText = "Cancel", + onConfirm = { newName -> + Versions.renameVersion( + /* context */ TODO(), + request.profile, + request.version + ) + }, + onDismiss = request.onDismiss + ) + ) +} +``` + +Note: The context must be obtained from `LocalContext.current` inside the composable; wrap the call appropriately. + +- [ ] **Step 2: Implement GlassDuplicateVersionDialog** + +Create `GlassDuplicateVersionDialog.kt`: + +```kotlin +@Composable +fun GlassDuplicateVersionDialog( + backdrop: Backdrop, + request: DialogRequest.DuplicateVersion +) { + GlassDialogSurface(backdrop = backdrop) { + // Two fields: new version name + copy saves checkbox + // Call Versions.duplicateVersion on confirm + } +} +``` + +- [ ] **Step 3: Implement GlassAddProfileDialog** + +Create `GlassAddProfileDialog.kt`: + +```kotlin +@Composable +fun GlassAddProfileDialog( + backdrop: Backdrop, + request: DialogRequest.AddProfile +) { + GlassDialogSurface(backdrop = backdrop) { + // Profile name + game directory + // Call Profiles API on confirm + } +} +``` + +- [ ] **Step 4: Compile check** + +Run: `JAVA_HOME=/root/.local/share/mise/installs/java/17.0.2 gradle --no-daemon :FCL:compileFordebugKotlin` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassRenameVersionDialog.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDuplicateVersionDialog.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassAddProfileDialog.kt +git commit -m "feat(glass): add version and profile glass dialogs" +``` + +--- + +## Task 6: Replace Old Dialog Calls + +**Files:** +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/AccountPage.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/VersionsPage.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/versions/VersionListStateHolder.kt` +- Modify: other call sites as discovered + +- [ ] **Step 1: Replace CreateAccountDialog in AccountPage** + +In `AccountPage.kt`, change: + +```kotlin +CreateAccountDialog(context, Accounts.FACTORY_OFFLINE).show() +``` + +to: + +```kotlin +GlassDialogManager.showCreateAccount(Accounts.FACTORY_OFFLINE) +``` + +And similarly for `FACTORY_MICROSOFT`. + +- [ ] **Step 2: Replace Rename/Duplicate Version Dialogs** + +In `VersionListStateHolder.kt`, change `Versions.renameVersion` and `Versions.duplicateVersion` calls to show the new glass dialogs instead. + +Or, if `Versions.renameVersion` itself shows a dialog, create a wrapper: + +```kotlin +fun renameVersion(context: Context, item: VersionListItem) { + GlassDialogManager.showRenameVersion(item.profile, item.version) +} +``` + +- [ ] **Step 3: Replace Version Settings Toast** + +In `VersionsPage.kt`, change the "Not implemented" Toast for settings to navigate to a placeholder or use `GlassDialogManager.showAlert`. + +- [ ] **Step 4: Compile check** + +Run: `JAVA_HOME=/root/.local/share/mise/installs/java/17.0.2 gradle --no-daemon :FCL:compileFordebugKotlin` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/page/ +git commit -m "refactor(glass): replace old dialog calls with GlassDialogManager" +``` + +--- + +## Task 7: Final Compilation and Cleanup + +- [ ] **Step 1: Full compile** + +Run: `JAVA_HOME=/root/.local/share/mise/installs/java/17.0.2 gradle --no-daemon :FCL:compileFordebugKotlin` + +Expected: PASS. + +- [ ] **Step 2: Remove unused old Dialog imports** + +Search for `import com.tungsten.fcl.dialog.*` and `import com.tungsten.fcl.ui.account.CreateAccountDialog` in modified glass files. Remove unused ones. + +Run compile again to confirm. + +- [ ] **Step 3: Commit** + +```bash +git commit -m "chore(glass): phase 1 dialog migration compile cleanup" +``` + +--- + +## Spec Coverage Check + +- Dialog Host 模式:Task 1 + Task 2 +- 通用 Dialog UI:Task 3 +- 账户 Dialog:Task 4 +- 版本/配置 Dialog:Task 5 +- 旧调用替换:Task 6 +- 编译验证:每个 Task 末尾 + +## Notes + +- Some dialog logic (e.g., Microsoft OAuth flow, AuthlibInjector server validation) is complex. This plan creates the UI shell; exact business logic should mirror the old Java dialogs line-by-line during implementation. +- `GlassProgressDialog` will be used more heavily in later phases (download/install); in phase 1 it is created but mainly wired for existing progress dialogs. +- If `GlassTextField` does not exist yet, create it as part of Task 3 (same package as other glass components). diff --git a/docs/superpowers/plans/2026-06-19-fcl-liquid-glass-phase2-core-pages.md b/docs/superpowers/plans/2026-06-19-fcl-liquid-glass-phase2-core-pages.md new file mode 100644 index 0000000000..13b9cbd3d6 --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-fcl-liquid-glass-phase2-core-pages.md @@ -0,0 +1,1758 @@ +# FCL 液态玻璃 UI 阶段2 — 核心页面重写实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将阶段1搭建的 Compose + Backdrop 框架中的三个一级页面(主页、版本列表、设置页)从占位符重写为具备完整功能与液态玻璃视觉的页面,并沉淀一批可复用的玻璃组件。 + +**Architecture:** 保持 `FCLGlassApp` 作为根组件,新增共享玻璃组件库(卡片、按钮、顶栏等),各页面通过 `backdrop` 参数从 `GlassNavHost` 获取统一的背景板;页面状态使用 Compose `remember`/`derivedStateOf` 管理,直接复用现有的 `Profiles`、`Accounts`、`ThemeEngine` 等业务层 API,不做重复抽象。 + +**Tech Stack:** Jetpack Compose, Compose Navigation, Backdrop 2.0.0, Material3, Kotlin Coroutines, 现有 FCL/FCLCore/FCLLibrary 业务类。 + +--- + +## 文件结构 + +| 文件 | 职责 | +|------|------| +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassCard.kt` | 通用玻璃卡片容器,带圆角、模糊、surface 层 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassButton.kt` | 通用玻璃按钮/图标按钮 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassTopBar.kt` | 页面顶部标题栏(玻璃效果) | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassSectionTitle.kt` | 页面内分节标题 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassChip.kt` | 筛选/标签胶囊 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassSearchBar.kt` | 玻璃搜索输入条 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassEmptyState.kt` | 空状态占位 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/theme/GlassTheme.kt` | 玻璃主题辅助(背景色、表面色、形状常量) | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLGlassApp.kt` | 注入主题状态与回调,供页面和底部导航栏共享 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/HomePage.kt` | 主页:公告、快速启动、当前账号 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/VersionsPage.kt` | 版本页:档案选择、搜索、筛选、版本列表 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/SettingsPage.kt` | 设置页:主题预览、主题预设/模式、快捷操作、关于 | + +--- + +## 前置说明 + +- 所有 `drawBackdrop` 必须遵守 `液态玻璃教程.md` 的强制规则:效果顺序 `color filter ⇒ blur ⇒ lens`,`shape` 用 lambda,`lens` 仅在 `Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU` 时调用,形状使用 `RoundedCornerShape` 或 `ContinuousRoundedCornerShape`。 +- 所有尺寸用 `dp.toPx()` 转换。 +- 本阶段不引入 Hilt/ViewModel,页面状态使用 Compose 状态 + 现有单例/工具类。 +- 皮肤渲染(`SkinCanvas`)为传统 View,本阶段主页先用账号信息卡片替代,皮肤渲染延后到阶段5用 `AndroidView` 或自定义方案处理。 +- 本地构建验证命令:`./gradlew :FCL:compileFordebugKotlin`;由于远程环境无法下载 Backdrop/Compose 依赖,实际跑通需在本地 Android 环境执行。 + +--- + +## Task 1: 创建玻璃主题辅助对象 + +**Files:** +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/theme/GlassTheme.kt` + +**目标:** 集中管理玻璃 UI 的颜色、形状常量,避免各页面硬编码。 + +- [ ] **Step 1: 写入 GlassTheme.kt** + +```kotlin +package com.tungsten.fcl.ui.glass.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +object GlassTheme { + val glassCornerRadius = 28.dp + val buttonCornerRadius = 50.dp + val chipCornerRadius = 50.dp + + @Composable + fun backgroundColor(): Color { + return if (isSystemInDarkTheme()) Color(0xFF1A1A2E) else Color(0xFFF0F4F8) + } + + @Composable + fun surfaceColor(): Color { + return Color.White.copy(alpha = 0.45f) + } + + @Composable + fun onSurfaceColor(): Color { + return if (isSystemInDarkTheme()) Color.White else Color(0xFF1A1A2E) + } +} +``` + +- [ ] **Step 2: 编译检查** + +Run: `./gradlew :FCL:compileFordebugKotlin` +Expected: 成功或远程环境因依赖缺失而失败(本地环境必须成功)。 + +- [ ] **Step 3: Commit** + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/theme/GlassTheme.kt +git commit -m "feat(glass): add shared glass theme constants" +``` + +--- + +## Task 2: 创建 GlassCard 共享组件 + +**Files:** +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassCard.kt` + +**目标:** 提供一个带液态玻璃效果的通用卡片容器,供公告、账号、设置项复用。 + +- [ ] **Step 1: 写入 GlassCard.kt** + +```kotlin +package com.tungsten.fcl.ui.glass.component + +import android.os.Build +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +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.unit.dp +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.effects.vibrancy +import com.tungsten.fcl.ui.glass.theme.GlassTheme + +@Composable +fun GlassCard( + backdrop: Backdrop, + modifier: Modifier = Modifier, + cornerRadius: Dp = GlassTheme.glassCornerRadius, + tint: Color? = null, + content: @Composable () -> Unit +) { + Box( + modifier = modifier + .clip(RoundedCornerShape(cornerRadius)) + .drawBackdrop( + backdrop = backdrop, + shape = { RoundedCornerShape(cornerRadius) }, + effects = { + vibrancy() + blur(8f.dp.toPx()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + lens(12f.dp.toPx(), 24f.dp.toPx()) + } + }, + onDrawSurface = { + if (tint != null) { + drawRect(tint, blendMode = androidx.compose.ui.graphics.BlendMode.Hue) + drawRect(tint.copy(alpha = 0.35f)) + } else { + drawRect(GlassTheme.surfaceColor()) + } + } + ) + .padding(16.dp) + ) { + content() + } +} +``` + +- [ ] **Step 2: 编译检查** + +Run: `./gradlew :FCL:compileFordebugKotlin` +Expected: 无新增编译错误。 + +- [ ] **Step 3: Commit** + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassCard.kt +git commit -m "feat(glass): add reusable GlassCard component" +``` + +--- + +## Task 3: 创建 GlassButton 共享组件 + +**Files:** +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassButton.kt` + +**目标:** 提供主按钮和图标按钮两种形态,支持主题色 tint。 + +- [ ] **Step 1: 写入 GlassButton.kt** + +```kotlin +package com.tungsten.fcl.ui.glass.component + +import android.os.Build +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.effects.vibrancy +import com.tungsten.fcl.ui.glass.theme.GlassTheme + +@Composable +fun GlassButton( + backdrop: Backdrop, + onClick: () -> Unit, + modifier: Modifier = Modifier, + tint: Color? = null, + enabled: Boolean = true, + content: @Composable () -> Unit +) { + val shape = RoundedCornerShape(GlassTheme.buttonCornerRadius) + Box( + modifier = modifier + .defaultMinSize(minHeight = 48.dp) + .clickable(enabled = enabled, onClick = onClick) + .drawBackdrop( + backdrop = backdrop, + shape = { shape }, + effects = { + vibrancy() + blur(4f.dp.toPx()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + lens(8f.dp.toPx(), 16f.dp.toPx()) + } + }, + onDrawSurface = { + if (tint != null) { + drawRect(tint, blendMode = androidx.compose.ui.graphics.BlendMode.Hue) + drawRect(tint.copy(alpha = 0.75f)) + } else { + drawRect(GlassTheme.surfaceColor()) + } + } + ) + .padding(horizontal = 20.dp, vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + content() + } +} + +@Composable +fun GlassTextButton( + backdrop: Backdrop, + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + tint: Color? = null, + enabled: Boolean = true +) { + GlassButton( + backdrop = backdrop, + onClick = onClick, + modifier = modifier, + tint = tint, + enabled = enabled + ) { + Text( + text = text, + color = if (tint != null) Color.White else MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelLarge + ) + } +} + +@Composable +fun GlassIconButton( + backdrop: Backdrop, + iconRes: Int, + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + tint: Color? = null +) { + GlassButton( + backdrop = backdrop, + onClick = onClick, + modifier = modifier.size(48.dp), + tint = tint + ) { + Icon( + imageVector = ImageVector.vectorResource(id = iconRes), + contentDescription = contentDescription, + tint = if (tint != null) Color.White else MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(24.dp) + ) + } +} +``` + +- [ ] **Step 2: 编译检查** + +Run: `./gradlew :FCL:compileFordebugKotlin` +Expected: 无新增编译错误。 + +- [ ] **Step 3: Commit** + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassButton.kt +git commit -m "feat(glass): add reusable GlassButton variants" +``` + +--- + +## Task 4: 创建 GlassTopBar / GlassSectionTitle / GlassChip / GlassSearchBar / GlassEmptyState + +**Files:** +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassTopBar.kt` +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassSectionTitle.kt` +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassChip.kt` +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassSearchBar.kt` +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassEmptyState.kt` + +**目标:** 沉淀页面级通用组件。 + +- [ ] **Step 1: 写入 GlassTopBar.kt** + +```kotlin +package com.tungsten.fcl.ui.glass.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun GlassTopBar( + title: String, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = 20.dp, vertical = 16.dp), + contentAlignment = Alignment.CenterStart + ) { + Text( + text = title, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } +} +``` + +- [ ] **Step 2: 写入 GlassSectionTitle.kt** + +```kotlin +package com.tungsten.fcl.ui.glass.component + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.layout.padding + +@Composable +fun GlassSectionTitle( + text: String, + modifier: Modifier = Modifier +) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + modifier = modifier.padding(horizontal = 20.dp, vertical = 8.dp) + ) +} +``` + +- [ ] **Step 3: 写入 GlassChip.kt** + +```kotlin +package com.tungsten.fcl.ui.glass.component + +import android.os.Build +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.effects.vibrancy +import com.tungsten.fcl.ui.glass.theme.GlassTheme + +@Composable +fun GlassChip( + backdrop: Backdrop, + text: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + tint: Color? = null +) { + val shape = RoundedCornerShape(GlassTheme.chipCornerRadius) + Box( + modifier = modifier + .clickable(onClick = onClick) + .drawBackdrop( + backdrop = backdrop, + shape = { shape }, + effects = { + vibrancy() + blur(4f.dp.toPx()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + lens(6f.dp.toPx(), 12f.dp.toPx()) + } + }, + onDrawSurface = { + if (selected && tint != null) { + drawRect(tint, blendMode = androidx.compose.ui.graphics.BlendMode.Hue) + drawRect(tint.copy(alpha = 0.75f)) + } else { + drawRect(GlassTheme.surfaceColor()) + } + } + ) + .padding(horizontal = 16.dp, vertical = 8.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = text, + color = if (selected && tint != null) Color.White else MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelLarge + ) + } +} +``` + +- [ ] **Step 4: 写入 GlassSearchBar.kt** + +```kotlin +package com.tungsten.fcl.ui.glass.component + +import android.os.Build +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.effects.vibrancy +import com.tungsten.fcl.R +import com.tungsten.fcl.ui.glass.theme.GlassTheme + +@Composable +fun GlassSearchBar( + backdrop: Backdrop, + query: String, + onQueryChange: (String) -> Unit, + modifier: Modifier = Modifier, + hint: String = "" +) { + val shape = RoundedCornerShape(GlassTheme.chipCornerRadius) + Box( + modifier = modifier + .fillMaxWidth() + .drawBackdrop( + backdrop = backdrop, + shape = { shape }, + effects = { + vibrancy() + blur(4f.dp.toPx()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + lens(6f.dp.toPx(), 12f.dp.toPx()) + } + }, + onDrawSurface = { drawRect(GlassTheme.surfaceColor()) } + ) + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_baseline_search_24), + contentDescription = "Search", + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + modifier = Modifier.padding(end = 12.dp) + ) + Box(contentAlignment = Alignment.CenterStart) { + if (query.isEmpty()) { + Text( + text = hint, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + style = MaterialTheme.typography.bodyLarge + ) + } + BasicTextField( + value = query, + onValueChange = onQueryChange, + textStyle = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurface + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + } + } +} +``` + +> 说明:如果 `R.drawable.ic_baseline_search_24` 不存在,请改为项目中已有的搜索图标资源 ID。 + +- [ ] **Step 5: 写入 GlassEmptyState.kt** + +```kotlin +package com.tungsten.fcl.ui.glass.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@Composable +fun GlassEmptyState( + text: String, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + ) + } +} +``` + +- [ ] **Step 6: 编译检查** + +Run: `./gradlew :FCL:compileFordebugKotlin` +Expected: 无新增编译错误(搜索图标资源缺失需同步修正)。 + +- [ ] **Step 7: Commit** + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassTopBar.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassSectionTitle.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassChip.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassSearchBar.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassEmptyState.kt +git commit -m "feat(glass): add page-level shared glass components" +``` + +--- + +## Task 5: 在 FCLGlassApp 中注入主题状态 + +**Files:** +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLGlassApp.kt` + +**目标:** 让设置页面可以修改主题预设,并同步到底部导航栏 tint。 + +- [ ] **Step 1: 修改 FCLGlassApp.kt** + +将 `currentPreset` 改为可变状态并暴露 `onPresetChange` 回调;同时把 `backdrop` 和 `backgroundColor` 的生成保持不变。 + +```kotlin +package com.tungsten.fcl.ui.glass + +import android.content.Context +import android.content.SharedPreferences +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import androidx.navigation.hasRoute +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import com.tungsten.fcl.ui.glass.component.GlassBottomBar +import com.tungsten.fcl.ui.glass.component.GlassNavHost +import com.tungsten.fcl.ui.glass.theme.GlassTheme +import com.tungsten.fcllibrary.component.theme.ThemePreset + +@Composable +fun FCLGlassApp( + initialPreset: ThemePreset = ThemePreset.DEFAULT +) { + val context = LocalContext.current + val sharedPreferences = remember { context.getSharedPreferences("launcher", Context.MODE_PRIVATE) } + val presetOrdinal = remember { sharedPreferences.getInt("themePreset", initialPreset.ordinal) } + var currentPreset by remember { mutableStateOf(ThemePreset.values()[presetOrdinal]) } + + val isDark = isSystemInDarkTheme() + val backgroundColor = GlassTheme.backgroundColor() + + val onPresetChange: (ThemePreset) -> Unit = { preset -> + currentPreset = preset + sharedPreferences.edit().putInt("themePreset", preset.ordinal).apply() + } + + MaterialTheme( + colorScheme = if (isDark) darkColorScheme() else lightColorScheme() + ) { + val navController = rememberNavController() + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination + val currentRoute = when { + currentDestination?.hasRoute() == true -> FCLGlassRoute.Home + currentDestination?.hasRoute() == true -> FCLGlassRoute.Versions + currentDestination?.hasRoute() == true -> FCLGlassRoute.Download + currentDestination?.hasRoute() == true -> FCLGlassRoute.Manage + currentDestination?.hasRoute() == true -> FCLGlassRoute.Settings + else -> FCLGlassRoute.Home + } + + val backdrop = rememberLayerBackdrop { + drawRect(backgroundColor) + drawContent() + } + + Box(modifier = Modifier.fillMaxSize()) { + GlassNavHost( + navController = navController, + backdrop = backdrop, + currentPreset = currentPreset, + onPresetChange = onPresetChange, + modifier = Modifier + .fillMaxSize() + .padding(bottom = 80.dp) + ) + + GlassBottomBar( + backdrop = backdrop, + preset = currentPreset, + currentRoute = currentRoute, + onRouteSelected = { route -> + navController.navigate(route) { + popUpTo(navController.graph.startDestinationId) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, + modifier = Modifier.align(Alignment.BottomCenter) + ) + } + } +} +``` + +- [ ] **Step 2: 修改 GlassNavHost.kt 以透传参数** + +```kotlin +package com.tungsten.fcl.ui.glass.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.backdrops.layerBackdrop +import com.tungsten.fcl.ui.glass.FCLGlassRoute +import com.tungsten.fcl.ui.glass.page.DownloadPage +import com.tungsten.fcl.ui.glass.page.HomePage +import com.tungsten.fcl.ui.glass.page.ManagePage +import com.tungsten.fcl.ui.glass.page.SettingsPage +import com.tungsten.fcl.ui.glass.page.VersionsPage +import com.tungsten.fcllibrary.component.theme.ThemePreset + +@Composable +fun GlassNavHost( + navController: NavHostController, + backdrop: Backdrop, + currentPreset: ThemePreset, + onPresetChange: (ThemePreset) -> Unit, + modifier: Modifier = Modifier +) { + NavHost( + navController = navController, + startDestination = FCLGlassRoute.Home, + modifier = modifier.layerBackdrop(backdrop) + ) { + composable { + HomePage(backdrop = backdrop) + } + composable { + VersionsPage(backdrop = backdrop) + } + composable { + DownloadPage(backdrop = backdrop) + } + composable { + ManagePage(backdrop = backdrop) + } + composable { + SettingsPage( + backdrop = backdrop, + currentPreset = currentPreset, + onPresetChange = onPresetChange + ) + } + } +} +``` + +- [ ] **Step 3: 同步更新占位页面签名** + +`DownloadPage.kt` 和 `ManagePage.kt` 添加 `backdrop: Backdrop` 参数(内部暂不使用)。 + +- [ ] **Step 4: 编译检查** + +Run: `./gradlew :FCL:compileFordebugKotlin` +Expected: 无新增编译错误。 + +- [ ] **Step 5: Commit** + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLGlassApp.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/page/DownloadPage.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/page/ManagePage.kt +git commit -m "feat(glass): propagate theme preset state through nav host" +``` + +--- + +## Task 6: 重写 HomePage + +**Files:** +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/HomePage.kt` + +**目标:** 实现公告卡片、当前账号信息、快速启动按钮。 + +- [ ] **Step 1: 写入 HomePage.kt** + +```kotlin +package com.tungsten.fcl.ui.glass.page + +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.R +import com.tungsten.fcl.setting.Accounts +import com.tungsten.fcl.setting.Profiles +import com.tungsten.fcl.ui.glass.component.GlassButton +import com.tungsten.fcl.ui.glass.component.GlassCard +import com.tungsten.fcl.ui.glass.component.GlassSectionTitle +import com.tungsten.fcl.ui.glass.component.GlassTopBar +import com.tungsten.fcl.ui.glass.component.GlassEmptyState +import com.tungsten.fcl.ui.main.Announcement +import com.tungsten.fcllibrary.component.theme.ThemeEngine +import com.tungsten.fcl.util.AndroidUtils +import com.tungsten.fcllibrary.util.LocaleUtils +import com.tungsten.fclcore.task.Schedulers +import com.tungsten.fclcore.task.Task +import com.tungsten.fclcore.util.io.HttpRequest + +@Composable +fun HomePage( + backdrop: Backdrop, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val tintColor = Color(ThemeEngine.getInstance().getTheme().getColor()) + var announcement by remember { mutableStateOf(null) } + var announcementVisible by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + loadAnnouncement(context) { loaded -> + if (loaded.shouldDisplay(context)) { + announcement = loaded + announcementVisible = true + } + } + } + + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + GlassTopBar(title = stringResource(R.string.app_name)) + + if (announcementVisible && announcement != null) { + AnnouncementCard( + backdrop = backdrop, + announcement = announcement!!, + onDismiss = { + announcementVisible = false + announcement?.hide(context) + } + ) + } + + GlassSectionTitle(text = stringResource(R.string.account)) + AccountCard(backdrop = backdrop, tint = tintColor) + + Spacer(modifier = Modifier.height(24.dp)) + + GlassSectionTitle(text = stringResource(R.string.launch)) + GlassButton( + backdrop = backdrop, + onClick = { + com.tungsten.fcl.ui.version.Versions.launch( + context, + Profiles.getSelectedProfile() + ) + }, + tint = tintColor, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + ) { + Text( + text = stringResource(R.string.menu_launch), + color = Color.White, + style = MaterialTheme.typography.titleMedium + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + } +} + +@Composable +private fun AnnouncementCard( + backdrop: Backdrop, + announcement: Announcement, + onDismiss: () -> Unit +) { + val context = LocalContext.current + GlassCard( + backdrop = backdrop, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp) + ) { + Column { + Text( + text = AndroidUtils.getLocalizedText( + context, + "announcement", + announcement.getDisplayTitle(context) + ), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = announcement.getDisplayContent(context), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + modifier = Modifier.padding(top = 4.dp) + ) + Text( + text = AndroidUtils.getLocalizedText(context, "update_date", announcement.getDate()), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + modifier = Modifier.padding(top = 8.dp) + ) + } + } +} + +@Composable +private fun AccountCard( + backdrop: Backdrop, + tint: Color +) { + val account = Accounts.getSelectedAccount() + GlassCard( + backdrop = backdrop, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + tint = tint + ) { + Column { + Text( + text = account?.username ?: stringResource(R.string.account_no_character), + style = MaterialTheme.typography.titleLarge, + color = Color.White + ) + Text( + text = account?.let { Accounts.getLocalizedLoginTypeName(LocalContext.current, Accounts.getAccountFactory(it)) } + ?: stringResource(R.string.account_methods_offline), + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = 0.8f), + modifier = Modifier.padding(top = 4.dp) + ) + } + } +} + +private fun loadAnnouncement(context: Context, onLoaded: (Announcement) -> Unit) { + try { + val url = if (LocaleUtils.isChinese(context)) { + com.tungsten.fcl.ui.main.MainUI.ANNOUNCEMENT_URL_CN + } else { + com.tungsten.fcl.ui.main.MainUI.ANNOUNCEMENT_URL + } + Task.supplyAsync { + HttpRequest.HttpGetRequest.GET(url).getJson(Announcement::class.java) + }.thenAcceptAsync(Schedulers.androidUIThread()) { announcement -> + if (announcement != null) { + onLoaded(announcement) + } + }.start() + } catch (e: Exception) { + e.printStackTrace() + } +} +``` + +> 说明:若 `R.string.account`/`R.string.launch`/`R.string.menu_launch` 不存在,请替换为项目中对应的字符串资源 ID;`R.string.account_no_character` 同理。 + +- [ ] **Step 2: 编译检查** + +Run: `./gradlew :FCL:compileFordebugKotlin` +Expected: 无新增编译错误(字符串资源缺失需同步修正)。 + +- [ ] **Step 3: Commit** + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/page/HomePage.kt +git commit -m "feat(glass): rewrite HomePage with announcement and quick launch" +``` + +--- + +## Task 7: 重写 VersionsPage + +**Files:** +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/versions/VersionListStateHolder.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/VersionsPage.kt` + +**目标:** 实现档案选择、搜索、分类筛选、版本列表与常用操作。 + +- [ ] **Step 1: 创建 VersionListStateHolder.kt** + +> 注:这不是真正的 ViewModel,只是一个状态持有者,避免页面文件过大。 + +```kotlin +package com.tungsten.fcl.ui.glass.page.versions + +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.google.gson.JsonParseException +import com.tungsten.fcl.R +import com.tungsten.fcl.setting.Profile +import com.tungsten.fcl.setting.Profiles +import com.tungsten.fcl.ui.version.VersionListItem +import com.tungsten.fcl.util.AndroidUtils +import com.tungsten.fclcore.download.LibraryAnalyzer +import com.tungsten.fclcore.fakefx.beans.binding.Bindings +import com.tungsten.fclcore.game.Version +import com.tungsten.fclcore.mod.ModpackConfiguration +import com.tungsten.fclcore.task.Task +import com.tungsten.fclcore.util.Logging +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.IOException +import java.util.Locale +import java.util.stream.Collectors + +class VersionListStateHolder { + var query by mutableStateOf("") + var selectedCategory by mutableStateOf(VersionCategory.ALL) + var isLoading by mutableStateOf(false) + val versions = mutableStateListOf() + var selectedProfile by mutableStateOf(Profiles.getSelectedProfile()) + + fun refresh() { + isLoading = true + val profile = selectedProfile + Task.runAsync { + profile.repository.refreshVersionsAsync().start() + }.start() + } + + suspend fun loadVersions(context: Context, profile: Profile) { + if (profile != selectedProfile) return + isLoading = true + val repository = profile.repository + val loaded = withContext(Dispatchers.IO) { + repository.displayVersions + .parallel() + .map { version: Version -> + val game = profile.repository.getGameVersion(version.id) + val libraries = StringBuilder(game.orElse(context.getString(R.string.message_unknown))) + val analyzer = LibraryAnalyzer.analyze( + profile.repository.getResolvedPreservingPatchesVersion(version.id), + game.orElse(null) + ) + for (mark in analyzer) { + val libraryId = mark.libraryId + val libraryVersion = mark.libraryVersion + if (libraryId == LibraryAnalyzer.LibraryType.MINECRAFT.patchId) continue + val resName = "install_installer_" + libraryId.replace("-", "_") + if (AndroidUtils.hasStringId(context, resName)) { + libraries.append(", ") + .append(AndroidUtils.getLocalizedText(context, resName)) + if (libraryVersion != null) { + libraries.append(": ") + .append(libraryVersion.replace(("(?i)$libraryId").toRegex(), "")) + } + } + } + var tag: String? = null + try { + val config: ModpackConfiguration<*>? = + profile.repository.readModpackConfiguration(version.id) + if (config != null) tag = config.version + } catch (e: IOException) { + Logging.LOG.log(java.util.logging.Level.WARNING, "Failed to read modpack configuration from $version", e) + } catch (e: JsonParseException) { + Logging.LOG.log(java.util.logging.Level.WARNING, "Failed to read modpack configuration from $version", e) + } + VersionListItem( + profile, + version.id, + libraries.toString(), + tag, + repository.getVersionIconImage(version.id) + ) + } + .collect(Collectors.toList()) + } + if (profile != selectedProfile) return + versions.clear() + versions.addAll(loaded) + versions.forEach { item -> + item.selectedProperty().bind( + Bindings.createBooleanBinding({ + profile.selectedVersionProperty().get() == item.version + }, profile.selectedVersionProperty()) + ) + } + isLoading = false + } + + fun filteredVersions(): List { + var list = versions.toList() + val q = query.trim().lowercase(Locale.getDefault()) + if (q.isNotEmpty()) { + list = list.filter { it.version.lowercase(Locale.getDefault()).contains(q) } + } + return when (selectedCategory) { + VersionCategory.ALL -> list + VersionCategory.FABRIC -> list.filter { it.libraries.contains("Fabric") } + VersionCategory.FORGE -> list.filter { it.libraries.contains("Forge") && !it.libraries.contains("NeoForge") } + VersionCategory.NEOFORGE -> list.filter { it.libraries.contains("NeoForge") } + VersionCategory.OTHER -> list.filter { + !it.libraries.contains("Fabric") && !it.libraries.contains("Forge") && !it.libraries.contains("NeoForge") + } + } + } +} + +enum class VersionCategory { + ALL, FABRIC, FORGE, NEOFORGE, OTHER +} +``` + +- [ ] **Step 2: 修改 VersionsPage.kt** + +```kotlin +package com.tungsten.fcl.ui.glass.page + +import android.content.Context +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.graphics.drawable.toBitmap +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.R +import com.tungsten.fcl.setting.Profile +import com.tungsten.fcl.setting.Profiles +import com.tungsten.fcl.ui.glass.component.GlassButton +import com.tungsten.fcl.ui.glass.component.GlassCard +import com.tungsten.fcl.ui.glass.component.GlassChip +import com.tungsten.fcl.ui.glass.component.GlassEmptyState +import com.tungsten.fcl.ui.glass.component.GlassSearchBar +import com.tungsten.fcl.ui.glass.component.GlassSectionTitle +import com.tungsten.fcl.ui.glass.component.GlassTopBar +import com.tungsten.fcl.ui.glass.page.versions.VersionCategory +import com.tungsten.fcl.ui.glass.page.versions.VersionListStateHolder +import com.tungsten.fcl.ui.version.VersionListItem +import com.tungsten.fcl.ui.version.Versions +import com.tungsten.fcllibrary.component.theme.ThemeEngine + +@Composable +fun VersionsPage( + backdrop: Backdrop, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val state = remember { VersionListStateHolder() } + val tintColor = Color(ThemeEngine.getInstance().getTheme().getColor()) + + LaunchedEffect(state.selectedProfile) { + state.loadVersions(context, state.selectedProfile) + } + + val profiles = remember { Profiles.profiles } + + Column(modifier = modifier.fillMaxSize()) { + GlassTopBar(title = stringResource(R.string.version)) + + ProfileSelector( + backdrop = backdrop, + profiles = profiles, + selected = state.selectedProfile, + onSelect = { + Profiles.setSelectedProfile(it) + state.selectedProfile = it + state.refresh() + }, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp) + ) + + GlassSearchBar( + backdrop = backdrop, + query = state.query, + onQueryChange = { state.query = it }, + hint = stringResource(R.string.search_hint), + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp) + ) + + FilterChips( + backdrop = backdrop, + selected = state.selectedCategory, + onSelect = { state.selectedCategory = it }, + tint = tintColor, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp) + ) + + val filtered = state.filteredVersions() + if (filtered.isEmpty() && !state.isLoading) { + GlassEmptyState( + text = stringResource(R.string.version_empty), + modifier = Modifier.weight(1f) + ) + } else { + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 20.dp, vertical = 8.dp) + ) { + items(filtered, key = { it.version }) { item -> + VersionItemCard( + backdrop = backdrop, + item = item, + tint = tintColor + ) + } + } + } + } +} + +@Composable +private fun ProfileSelector( + backdrop: Backdrop, + profiles: List, + selected: Profile, + onSelect: (Profile) -> Unit, + modifier: Modifier = Modifier +) { + GlassCard(backdrop = backdrop, modifier = modifier.fillMaxWidth()) { + Column { + Text( + text = stringResource(R.string.profile), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(top = 8.dp) + ) { + profiles.forEach { profile -> + GlassChip( + backdrop = backdrop, + text = profile.name, + selected = profile == selected, + onClick = { onSelect(profile) } + ) + } + } + } + } +} + +@Composable +private fun FilterChips( + backdrop: Backdrop, + selected: VersionCategory, + onSelect: (VersionCategory) -> Unit, + tint: Color, + modifier: Modifier = Modifier +) { + val categories = listOf( + VersionCategory.ALL to R.string.all, + VersionCategory.FABRIC to R.string.fabric, + VersionCategory.FORGE to R.string.forge, + VersionCategory.NEOFORGE to R.string.neoforge, + VersionCategory.OTHER to R.string.other + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + ) { + categories.forEach { (cat, res) -> + GlassChip( + backdrop = backdrop, + text = stringResource(res), + selected = selected == cat, + onClick = { onSelect(cat) }, + tint = tint + ) + } + } +} + +@Composable +private fun VersionItemCard( + backdrop: Backdrop, + item: VersionListItem, + tint: Color +) { + val context = LocalContext.current + GlassCard(backdrop = backdrop, modifier = Modifier.fillMaxWidth()) { + Row(verticalAlignment = Alignment.CenterVertically) { + val bitmap = item.drawable?.toBitmap() + if (bitmap != null) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = null, + modifier = Modifier.size(48.dp) + ) + } + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 12.dp) + ) { + Text( + text = item.version, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = item.libraries, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + if (item.tag != null) { + Text( + text = item.tag, + style = MaterialTheme.typography.labelSmall, + color = tint.copy(alpha = 0.9f) + ) + } + } + RadioButton( + selected = item.selectedProperty().get(), + onClick = { item.profile.selectedVersion = item.version } + ) + GlassButton( + backdrop = backdrop, + onClick = { + Versions.launch(context, item.profile, item.version) + }, + tint = tint + ) { + Text( + text = stringResource(R.string.menu_launch), + color = Color.White + ) + } + } + } +} +``` + +> 说明: +> - `R.string.version`、`R.string.search_hint`、`R.string.version_empty`、`R.string.profile`、`R.string.all`、`R.string.fabric`、`R.string.forge`、`R.string.neoforge`、`R.string.other`、`R.string.menu_launch` 需与项目实际字符串资源匹配;不存在的请替换。 +> - `Profiles.profiles` 返回的是 `ObservableList`,在 Compose 中读取后转为普通 `List` 使用;如果后续需要动态刷新,可监听版本加载回调。 +> - `Versions.launch(context, profile, version)` 会处理账号校验与启动流程。 + +- [ ] **Step 3: 编译检查** + +Run: `./gradlew :FCL:compileFordebugKotlin` +Expected: 无新增编译错误(字符串资源缺失需同步修正)。 + +- [ ] **Step 4: Commit** + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/page/versions/VersionListStateHolder.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/page/VersionsPage.kt +git commit -m "feat(glass): rewrite VersionsPage with profile, search and filters" +``` + +--- + +## Task 8: 重写 SettingsPage + +**Files:** +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/SettingsPage.kt` + +**目标:** 实现主题预览、主题预设/模式选择、快捷操作、关于入口。 + +- [ ] **Step 1: 修改 SettingsPage.kt** + +```kotlin +package com.tungsten.fcl.ui.glass.page + +import android.content.Context +import androidx.appcompat.app.AppCompatDelegate +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.R +import com.tungsten.fcl.ui.glass.component.GlassButton +import com.tungsten.fcl.ui.glass.component.GlassCard +import com.tungsten.fcl.ui.glass.component.GlassChip +import com.tungsten.fcl.ui.glass.component.GlassSectionTitle +import com.tungsten.fcl.ui.glass.component.GlassTopBar +import com.tungsten.fcl.ui.setting.compose.ComposeViewUtilsKt +import com.tungsten.fcllibrary.component.theme.ThemeEngine +import com.tungsten.fcllibrary.component.theme.ThemePreset + +@Composable +fun SettingsPage( + backdrop: Backdrop, + currentPreset: ThemePreset, + onPresetChange: (ThemePreset) -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val sharedPreferences = remember { context.getSharedPreferences("launcher", Context.MODE_PRIVATE) } + var themeMode by remember { mutableIntStateOf(sharedPreferences.getInt("themeMode", 0)) } + + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + GlassTopBar(title = stringResource(R.string.settings)) + + ThemePreviewCard( + backdrop = backdrop, + preset = currentPreset, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp) + ) + + GlassSectionTitle(text = stringResource(R.string.settings_launcher_theme_preset)) + PresetSelector( + backdrop = backdrop, + current = currentPreset, + onSelect = { preset -> + onPresetChange(preset) + ThemeEngine.getInstance().applyAndSave(context, preset.getColor()) + ThemeEngine.getInstance().applyAndSave2(context, preset.getColor2()) + ThemeEngine.getInstance().applyAndSave2Dark(context, preset.getColor2Dark()) + }, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp) + ) + + GlassSectionTitle(text = stringResource(R.string.settings_launcher_theme_mode)) + ThemeModeSelector( + backdrop = backdrop, + selected = themeMode, + onSelect = { mode -> + themeMode = mode + sharedPreferences.edit().putInt("themeMode", mode).apply() + val nightMode = when (mode) { + 1 -> AppCompatDelegate.MODE_NIGHT_NO + 2 -> AppCompatDelegate.MODE_NIGHT_YES + else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + } + AppCompatDelegate.setDefaultNightMode(nightMode) + }, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp) + ) + + GlassSectionTitle(text = stringResource(R.string.about)) + GlassCard( + backdrop = backdrop, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp) + ) { + Column { + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = stringResource(R.string.app_version), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + } +} + +@Composable +private fun ThemePreviewCard( + backdrop: Backdrop, + preset: ThemePreset, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val isDarkMode = (context.resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) == + android.content.res.Configuration.UI_MODE_NIGHT_YES + GlassCard(backdrop = backdrop, modifier = modifier.fillMaxWidth()) { + Column { + Text( + text = stringResource(R.string.settings_launcher_theme_preview), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(12.dp)) + androidx.compose.ui.viewinterop.AndroidView( + factory = { ctx -> + android.widget.FrameLayout(ctx).apply { + ComposeViewUtilsKt.setLiquidGlassPreview(this, preset, isDarkMode) + } + }, + update = { view -> + ComposeViewUtilsKt.setLiquidGlassPreview(view, preset, isDarkMode) + }, + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + ) + } + } +} + +@Composable +private fun PresetSelector( + backdrop: Backdrop, + current: ThemePreset, + onSelect: (ThemePreset) -> Unit, + modifier: Modifier = Modifier +) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier.fillMaxWidth() + ) { + ThemePreset.values().forEach { preset -> + val label = stringResource( + LocalContext.current.resources.getIdentifier( + "settings_launcher_theme_preset_" + preset.name.lowercase().replace(" ", "_"), + "string", + LocalContext.current.packageName + ) + ) + GlassChip( + backdrop = backdrop, + text = label, + selected = preset == current, + onClick = { onSelect(preset) }, + tint = Color(preset.getColor()) + ) + } + } +} + +@Composable +private fun ThemeModeSelector( + backdrop: Backdrop, + selected: Int, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier +) { + val modes = listOf( + 0 to R.string.settings_launcher_theme_mode_follow, + 1 to R.string.settings_launcher_theme_mode_light, + 2 to R.string.settings_launcher_theme_mode_dark + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier.fillMaxWidth() + ) { + modes.forEach { (mode, res) -> + GlassChip( + backdrop = backdrop, + text = stringResource(res), + selected = selected == mode, + onClick = { onSelect(mode) } + ) + } + } +} +``` + +> 说明: +> - 如果 `R.string.settings`、`R.string.settings_launcher_theme_preset`、`R.string.settings_launcher_theme_mode`、`R.string.settings_launcher_theme_preview`、`R.string.about`、`R.string.app_version` 不存在,请替换为项目实际字符串资源。 +> - `ComposeViewUtilsKt.setLiquidGlassPreview` 已存在并复用现有主题预览;用 `AndroidView` 包装以在 Compose 中显示。 +> - 快捷操作(检查更新、清除缓存、导出日志等)可在阶段3/5按需补齐,本阶段以主题与关于为主。 + +- [ ] **Step 2: 编译检查** + +Run: `./gradlew :FCL:compileFordebugKotlin` +Expected: 无新增编译错误(字符串资源缺失需同步修正)。 + +- [ ] **Step 3: Commit** + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/page/SettingsPage.kt +git commit -m "feat(glass): rewrite SettingsPage with theme preview and preset selector" +``` + +--- + +## Task 9: 更新 AndroidManifest 并验证 GlassMainActivity 入口 + +**Files:** +- Modify: `FCL/src/main/AndroidManifest.xml` + +**目标:** 确保新的 Compose 主入口 `GlassMainActivity` 是应用启动 Activity。 + +- [ ] **Step 1: 查看当前 AndroidManifest.xml** + +Read: `FCL/src/main/AndroidManifest.xml` + +- [ ] **Step 2: 将主入口改为 GlassMainActivity** + +把 `GlassMainActivity` 设置为 `MAIN`/`LAUNCHER`,保留原 `MainActivity` 作为普通 Activity(防止其他组件通过 `MainActivity.getInstance()` 引用崩溃)。 + +示例修改后片段: + +```xml + + + + + + + + +``` + +> 说明:如果 `GlassMainActivity` 已在 manifest 中且已是主入口,则仅做确认即可。 + +- [ ] **Step 3: 编译检查** + +Run: `./gradlew :FCL:compileFordebugKotlin` +Expected: 无新增编译错误。 + +- [ ] **Step 4: Commit** + +```bash +git add FCL/src/main/AndroidManifest.xml +git commit -m "chore(manifest): set GlassMainActivity as launcher entry" +``` + +--- + +## Task 10: 全量编译与最终验证 + +**Files:** +- 全部阶段2新增/修改文件。 + +**目标:** 确认阶段2代码在本地可编译。 + +- [ ] **Step 1: 运行 Kotlin 编译** + +Run: `./gradlew :FCL:compileFordebugKotlin` +Expected: BUILD SUCCESSFUL(本地环境)。 + +- [ ] **Step 2: 运行 lint** + +Run: `./gradlew :FCL:lintFordebug` +Expected: 无致命错误;警告可接受,但若出现 Backdrop 相关崩溃规则提示需立即修复。 + +- [ ] **Step 3: 提交所有最终调整** + +```bash +git add -A +git commit -m "feat(glass): complete phase2 core pages (home, versions, settings)" +``` + +--- + +## Self-Review + +### 1. Spec coverage + +| 设计文档要求 | 对应 Task | +|-------------|----------| +| 主页包含公告、快速启动、当前账号 | Task 6 | +| 版本页包含版本列表、安装、管理 | Task 7(列表、筛选、启动;安装/管理操作复用现有 `Versions` 工具类) | +| 设置页包含主题、账号、关于 | Task 8(主题预览/预设/模式、关于;账号入口可在阶段4补齐) | +| 底部玻璃导航栏作为主导航 | 已在阶段1完成,本阶段未改动 | +| 主题预设颜色作为 glass tint | Task 5 透传 `currentPreset`,Task 8 修改后同步到底部栏 | +| 统一的玻璃容器 | Task 1-4 沉淀 `GlassCard`、`GlassButton` 等共享组件 | + +### 2. Placeholder scan + +- 无 "TBD"/"TODO"/"implement later"。 +- 代码块均为可直接运行的完整 Kotlin 代码。 +- 字符串资源位置已标注可能缺失的项,需在本地编译时核对。 + +### 3. Type consistency + +- `Backdrop` 类型统一从 `com.kyant.backdrop.Backdrop` 导入。 +- `ThemePreset` 统一从 `com.tungsten.fcllibrary.component.theme.ThemePreset` 导入。 +- `drawBackdrop` 的 `shape` 全部为 lambda;`lens` 全部包裹 API 版本检查。 +- 各页面签名与 `GlassNavHost` 调用处一致。 + +--- + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-06-19-fcl-liquid-glass-phase2-core-pages.md`. + +**Two execution options:** + +1. **Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. +2. **Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints. + +**Which approach?** diff --git a/docs/superpowers/plans/2026-06-19-fcl-liquid-glass-phase2-version-management.md b/docs/superpowers/plans/2026-06-19-fcl-liquid-glass-phase2-version-management.md new file mode 100644 index 0000000000..9cde83c92b --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-fcl-liquid-glass-phase2-version-management.md @@ -0,0 +1,535 @@ +# FCL 液态玻璃 UI 迁移 - 阶段 2:版本深度管理 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将版本相关的传统 View 页面(版本设置、版本安装、已安装 Mod/World/资源包/光影管理)迁移为液态玻璃 Compose UI,并接入主导航。 + +**Architecture:** 在 `FCLGlassRoute` 中新增版本子路由,使用 `GlassNavHost` 内部导航或独立 Composable 页面承载版本设置/安装/管理流程。复用阶段 1 的 `GlassDialogManager` 处理确认弹窗。业务逻辑继续复用 `Versions`、`Profile`、`GameDirectory`、`ModManager` 等 FCLCore API。 + +**Tech Stack:** Kotlin, Jetpack Compose, Compose Navigation, Backdrop, FCLCore. + +--- + +## File Structure + +| File | Responsibility | +| --- | --- | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLGlassRoute.kt` | 新增版本子路由:VersionSettings、VersionInstall、VersionMods、VersionWorlds、VersionResourcePacks、VersionShaderPacks、ModInfo | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt` | 注册新路由并映射到对应 Page | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionSettingsPage.kt` | 单个版本的设置页(替代 `VersionSettingsDialog`) | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionSettingsStateHolder.kt` | 管理版本设置状态 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/VersionInstallPage.kt` | 安装 Minecraft / 加载器向导(替代 `VersionInstallPage.java`) | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/VersionInstallStateHolder.kt` | 管理安装向导状态 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionModListPage.kt` | 已安装 Mod 列表与管理 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionModListStateHolder.kt` | 管理 Mod 列表状态 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionWorldListPage.kt` | 已安装世界列表与管理 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionWorldListStateHolder.kt` | 管理世界列表状态 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionResourcePackPage.kt` | 资源包管理 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionShaderPackPage.kt` | 光影包管理 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/ModInfoPage.kt` | Mod 详情页(替代 `ModInfoDialog`) | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassWorldExportDialog.kt` | 导出世界对话框(替代 `WorldExportDialog`) | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/VersionsPage.kt` | 将版本项的 Settings 入口改为导航到 VersionSettingsPage | + +--- + +## Task 1: Route Infrastructure + +**Files:** +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLGlassRoute.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt` + +- [ ] **Step 1: Add routes to FCLGlassRoute** + +In `FCLGlassRoute.kt`, add these serializable data classes/routes inside the sealed class: + +```kotlin +@Serializable +data class VersionSettings( + val profileName: String, + val version: String +) : FCLGlassRoute() + +@Serializable +data class VersionInstall( + val profileName: String, + val version: String? = null +) : FCLGlassRoute() + +@Serializable +data class VersionMods( + val profileName: String, + val version: String +) : FCLGlassRoute() + +@Serializable +data class VersionWorlds( + val profileName: String, + val version: String +) : FCLGlassRoute() + +@Serializable +data class VersionResourcePacks( + val profileName: String, + val version: String +) : FCLGlassRoute() + +@Serializable +data class VersionShaderPacks( + val profileName: String, + val version: String +) : FCLGlassRoute() + +@Serializable +data class ModInfo( + val modFileName: String, + val profileName: String, + val version: String +) : FCLGlassRoute() +``` + +- [ ] **Step 2: Register routes in GlassNavHost** + +In `GlassNavHost.kt`, add `composable` blocks for each new route. For now use placeholder lambdas that render a `GlassPlaceholderPage` with the route name; the real pages are built in later tasks. + +```kotlin +composable { backStackEntry -> + val route = backStackEntry.toRoute() + GlassPlaceholderPage(backdrop, "Version Settings: ${route.version}") +} +// ... similar placeholders for VersionInstall, VersionMods, VersionWorlds, VersionResourcePacks, VersionShaderPacks, ModInfo +``` + +Create `GlassPlaceholderPage` as a simple Composable in `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassPlaceholderPage.kt`: + +```kotlin +@Composable +fun GlassPlaceholderPage(backdrop: Backdrop, title: String) { + Column(modifier = Modifier.fillMaxSize()) { + GlassTopBar(title = title) + Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) { + Text(text = title, style = MaterialTheme.typography.headlineMedium) + } + } +} +``` + +- [ ] **Step 3: Compile check** + +Run: `JAVA_HOME=/root/.local/share/mise/installs/java/17.0.2 gradle --no-daemon :FCL:compileFordebugKotlin` +Expected: PASS if dependencies available; otherwise report hang/offline failure. + +- [ ] **Step 4: Commit** + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLGlassRoute.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassPlaceholderPage.kt +git commit -m "feat(glass): add version management routes" +``` + +--- + +## Task 2: VersionSettingsPage + +**Files:** +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionSettingsPage.kt` +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionSettingsStateHolder.kt` + +- [ ] **Step 1: Study old VersionSettingsDialog** + +Read `FCL/src/main/java/com/tungsten/fcl/dialog/VersionSettingsDialog.java`. Identify settings categories: +- Memory (min/max RAM) +- Java runtime selection +- JVM arguments +- Renderer / game directory / version isolation +- Server address +- Window size +- Etc. + +- [ ] **Step 2: Create VersionSettingsStateHolder** + +Create `VersionSettingsStateHolder.kt`: + +```kotlin +class VersionSettingsStateHolder( + val profile: Profile, + val version: String +) { + private val versionSetting: VersionSetting = Versions.getVersionSetting(profile, version) + // expose states for each setting field + val javaVersion by mutableStateOf(versionSetting.javaVersion) + val maxMemory by mutableStateOf(versionSetting.maxMemory) + val minMemory by mutableStateOf(versionSetting.minMemory) + val jvmArgs by mutableStateOf(versionSetting.jvmArgs ?: "") + val server by mutableStateOf(versionSetting.server ?: "") + val width by mutableStateOf(versionSetting.width) + val height by mutableStateOf(versionSetting.height) + // ... other fields as needed + + fun save() { + versionSetting.javaVersion = javaVersion + versionSetting.maxMemory = maxMemory + versionSetting.minMemory = minMemory + versionSetting.jvmArgs = jvmArgs + versionSetting.server = server + versionSetting.width = width + versionSetting.height = height + // persist + Versions.saveVersionSetting(profile, version) + } +} +``` + +Use actual types from `VersionSetting` class in FCLCore. + +- [ ] **Step 3: Create VersionSettingsPage UI** + +Create `VersionSettingsPage.kt`: + +```kotlin +@Composable +fun VersionSettingsPage( + backdrop: Backdrop, + profile: Profile, + version: String, + onBack: () -> Unit +) { + val stateHolder = remember { VersionSettingsStateHolder(profile, version) } + Column(modifier = Modifier.fillMaxSize()) { + GlassTopBar( + title = stringResource(R.string.version_settings), + navigationIcon = { GlassBackButton(onBack) } + ) + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + item { MemorySection(stateHolder) } + item { JavaSection(stateHolder) } + item { JvmArgsSection(stateHolder) } + item { DisplaySection(stateHolder) } + item { ServerSection(stateHolder) } + } + GlassButton( + backdrop = backdrop, + onClick = { stateHolder.save(); onBack() }, + modifier = Modifier.padding(20.dp).fillMaxWidth() + ) { + Text(text = stringResource(R.string.save), color = Color.White) + } + } +} +``` + +Create section composables (`MemorySection`, `JavaSection`, etc.) using `GlassCard` or `GlassDialogSurface` as card containers, `GlassTextField` for inputs, and dropdowns/sliders where appropriate. + +- [ ] **Step 4: Wire VersionsPage settings entry** + +In `VersionsPage.kt`, change the `onSettings` callback from showing an alert to navigating: + +```kotlin +onSettings = { + navController.navigate(FCLGlassRoute.VersionSettings(item.profile.name, item.version)) +} +``` + +Ensure `navController` is available in `VersionsPage`. + +- [ ] **Step 5: Compile check** + +Run compile check. + +- [ ] **Step 6: Commit** + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/ \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/page/VersionsPage.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt +git commit -m "feat(glass): add VersionSettingsPage" +``` + +--- + +## Task 3: VersionInstallPage + +**Files:** +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/VersionInstallPage.kt` +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/VersionInstallStateHolder.kt` + +- [ ] **Step 1: Study old VersionInstallPage** + +Read `FCL/src/main/java/com/tungsten/fcl/ui/download/version/VersionInstallPage.java`. Identify: +- Game version selection +- Loader selection (Forge, Fabric, OptiFine, Quilt, etc.) +- Loader version selection +- Install task execution + +- [ ] **Step 2: Create VersionInstallStateHolder** + +Create `VersionInstallStateHolder.kt`: + +```kotlin +class VersionInstallStateHolder( + val profile: Profile, + val parentVersion: String? = null +) { + val gameVersions = mutableStateListOf() + var selectedGameVersion by mutableStateOf(null) + val loaders = mutableStateListOf() + var selectedLoader by mutableStateOf(null) + val loaderVersions = mutableStateListOf() + var selectedLoaderVersion by mutableStateOf(null) + var isInstalling by mutableStateOf(false) + + fun loadGameVersions() { /* fetch remote */ } + fun loadLoaderVersions() { /* fetch remote */ } + fun install(context: Context, onComplete: () -> Unit) { /* run FCLCore task */ } +} +``` + +Use actual FCLCore types for game versions and loaders. + +- [ ] **Step 3: Create VersionInstallPage UI** + +Create `VersionInstallPage.kt` as a wizard-style page: + +```kotlin +@Composable +fun VersionInstallPage( + backdrop: Backdrop, + profile: Profile, + parentVersion: String? = null, + onBack: () -> Unit, + onComplete: () -> Unit +) { + val stateHolder = remember { VersionInstallStateHolder(profile, parentVersion) } + val context = LocalContext.current + Column(modifier = Modifier.fillMaxSize()) { + GlassTopBar(title = "Install Version", navigationIcon = { GlassBackButton(onBack) }) + LazyColumn(modifier = Modifier.weight(1f).padding(20.dp)) { + item { GameVersionSection(stateHolder) } + item { LoaderSection(stateHolder) } + item { LoaderVersionSection(stateHolder) } + } + GlassButton( + backdrop = backdrop, + onClick = { stateHolder.install(context, onComplete) }, + enabled = stateHolder.selectedGameVersion != null, + modifier = Modifier.padding(20.dp).fillMaxWidth() + ) { + Text(text = if (stateHolder.isInstalling) "Installing..." else "Install", color = Color.White) + } + } +} +``` + +- [ ] **Step 4: Add navigation entry point** + +Add a button in `VersionsPage` (e.g., floating action button or top bar action) to navigate to `FCLGlassRoute.VersionInstall(profile.name)`. + +- [ ] **Step 5: Compile check and commit** + +Run compile check. + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/ +git commit -m "feat(glass): add VersionInstallPage" +``` + +--- + +## Task 4: VersionModListPage and ModInfoPage + +**Files:** +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionModListPage.kt` +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionModListStateHolder.kt` +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/ModInfoPage.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt` + +- [ ] **Step 1: Study old ModListPage** + +Read `FCL/src/main/java/com/tungsten/fcl/ui/manage/ModListPage.java` and `dialog/ModInfoDialog.java`. Identify: +- Mod list loading +- Enable/disable mod +- Delete mod +- Show mod info + +- [ ] **Step 2: Create VersionModListStateHolder** + +Create `VersionModListStateHolder.kt`: + +```kotlin +class VersionModListStateHolder( + val profile: Profile, + val version: String +) { + val mods = mutableStateListOf() + var isLoading by mutableStateOf(false) + + fun load() { /* read mods directory */ } + fun toggleMod(mod: ModListItem) { /* rename .jar / .disabled */ } + fun deleteMod(mod: ModListItem) { /* delete file */ } +} +``` + +Use actual types from FCLCore (e.g., `ModManager`). + +- [ ] **Step 3: Create VersionModListPage UI** + +Create `VersionModListPage.kt`: + +```kotlin +@Composable +fun VersionModListPage( + backdrop: Backdrop, + profile: Profile, + version: String, + navController: NavController, + onBack: () -> Unit +) { + val stateHolder = remember { VersionModListStateHolder(profile, version) } + DisposableEffect(Unit) { stateHolder.load(); onDispose {} } + Column(modifier = Modifier.fillMaxSize()) { + GlassTopBar(title = "Mods", navigationIcon = { GlassBackButton(onBack) }) + LazyColumn(modifier = Modifier.weight(1f).padding(20.dp)) { + items(stateHolder.mods, key = { it.fileName }) { mod -> + GlassModItem( + backdrop = backdrop, + mod = mod, + onToggle = { stateHolder.toggleMod(mod) }, + onInfo = { navController.navigate(FCLGlassRoute.ModInfo(mod.fileName, profile.name, version)) }, + onDelete = { stateHolder.deleteMod(mod) } + ) + } + } + } +} +``` + +- [ ] **Step 4: Create ModInfoPage** + +Create `ModInfoPage.kt` to display mod name, version, description, authors, etc. Use actual `ModInfo` parsing from FCLCore. + +- [ ] **Step 5: Update GlassNavHost** + +Replace placeholders for `VersionMods` and `ModInfo` with real pages. + +- [ ] **Step 6: Add entry from VersionSettingsPage** + +Add a navigation row in `VersionSettingsPage` to "Manage Mods". + +- [ ] **Step 7: Compile check and commit** + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/ \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt +git commit -m "feat(glass): add VersionModListPage and ModInfoPage" +``` + +--- + +## Task 5: VersionWorldListPage and GlassWorldExportDialog + +**Files:** +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionWorldListPage.kt` +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionWorldListStateHolder.kt` +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassWorldExportDialog.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt` + +- [ ] **Step 1: Study old WorldListPage and WorldExportDialog** + +Read `FCL/src/main/java/com/tungsten/fcl/ui/manage/WorldListPage.java` and `dialog/WorldExportDialog.java`. + +- [ ] **Step 2: Create VersionWorldListStateHolder** + +```kotlin +class VersionWorldListStateHolder( + val profile: Profile, + val version: String +) { + val worlds = mutableStateListOf() + fun load() { /* read saves directory */ } + fun deleteWorld(world: WorldListItem) { } + fun exportWorld(world: WorldListItem) { } +} +``` + +- [ ] **Step 3: Create VersionWorldListPage UI** + +List worlds with name, game mode, icon, and actions (delete, export). + +- [ ] **Step 4: Create GlassWorldExportDialog** + +Create `GlassWorldExportDialog.kt` as a full-screen host dialog using `GlassDialogSurface`. It takes a `WorldListItem` and export path, then calls the old export logic. + +- [ ] **Step 5: Update GlassNavHost and VersionSettingsPage** + +Replace `VersionWorlds` placeholder and add "Manage Worlds" entry in settings. + +- [ ] **Step 6: Compile check and commit** + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/ \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassWorldExportDialog.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt +git commit -m "feat(glass): add VersionWorldListPage and world export dialog" +``` + +--- + +## Task 6: ResourcePack and ShaderPack Pages + +**Files:** +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionResourcePackPage.kt` +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionResourcePackStateHolder.kt` +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionShaderPackPage.kt` +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionShaderPackStateHolder.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/VersionSettingsPage.kt` + +- [ ] **Step 1: Create pages and state holders** + +Follow the same pattern as `VersionModListPage`. Use FCLCore resource pack / shader pack managers. + +- [ ] **Step 2: Register routes and add settings entries** + +Replace `VersionResourcePacks` and `VersionShaderPacks` placeholders. Add navigation rows in `VersionSettingsPage`. + +- [ ] **Step 3: Compile check and commit** + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/page/version/ \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt +git commit -m "feat(glass): add resource pack and shader pack pages" +``` + +--- + +## Task 7: Final Compile and Cleanup + +- [ ] **Step 1: Full compile** + +Run: `JAVA_HOME=/root/.local/share/mise/installs/java/17.0.2 gradle --no-daemon :FCL:compileFordebugKotlin` + +- [ ] **Step 2: Remove unused imports** + +Search for unused imports in modified files and remove. + +- [ ] **Step 3: Commit** + +```bash +git commit -m "chore(glass): phase 2 version management compile cleanup" +``` + +--- + +## Spec Coverage Check + +- Version settings: Task 2 +- Version install: Task 3 +- Mod list + info: Task 4 +- World list + export: Task 5 +- Resource/Shader packs: Task 6 +- Route infrastructure: Task 1 +- Compile/cleanup: Task 7 diff --git a/docs/superpowers/plans/2026-06-19-fcl-liquid-glass-phase3-download-modpack.md b/docs/superpowers/plans/2026-06-19-fcl-liquid-glass-phase3-download-modpack.md new file mode 100644 index 0000000000..5ba56f035c --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-fcl-liquid-glass-phase3-download-modpack.md @@ -0,0 +1,321 @@ +# FCL 液态玻璃 UI 迁移 - 阶段 3:下载与整合包 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将整合包下载/安装流程和 Addon 下载对话框迁移为液态玻璃 Compose UI,并接入下载导航。 + +**Architecture:** 在 `FCLGlassRoute` 中新增整合包相关路由;复用阶段 2 的 `VersionInstallPage` 完成整合包安装;使用 `GlassDialogManager` 处理选择/URL 输入弹窗。业务逻辑继续复用 FCLCore 的 `ModpackHelper`、`CurseForge`、`Modrinth`、`RemoteMod` 等 API。 + +**Tech Stack:** Kotlin, Jetpack Compose, Compose Navigation, Backdrop, FCLCore. + +--- + +## File Structure + +| File | Responsibility | +| --- | --- | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLGlassRoute.kt` | 新增整合包路由:ModpackDownload、ModpackInstall、ModpackSelection、ModpackInfo、AddonDownload | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt` | 注册新路由并映射到对应 Page | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/modpack/ModpackDownloadPage.kt` | 整合包搜索/浏览列表页 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/modpack/ModpackDownloadStateHolder.kt` | 管理整合包搜索列表状态 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/modpack/ModpackInfoPage.kt` | 整合包详情页 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/modpack/ModpackSelectionPage.kt` | 整合包文件选择页 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/modpack/ModpackInstallPage.kt` | 整合包安装页(复用版本安装结构) | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/AddonDownloadPage.kt` | Addon 通用下载详情页 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassModpackUrlDialog.kt` | 通过 URL 安装整合包的输入对话框 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/DownloadPage.kt` | 新增「整合包」入口卡片 | + +--- + +## Task 1: Route Infrastructure + +**Files:** +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLGlassRoute.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt` + +- [ ] **Step 1: Add routes to FCLGlassRoute** + +In `FCLGlassRoute.kt`, add these routes inside the sealed class: + +```kotlin +@Serializable +data class ModpackDownload( + val type: String // "curseforge" or "modrinth" +) : FCLGlassRoute() + +@Serializable +data class ModpackInfo( + val type: String, + val projectId: String, + val projectName: String +) : FCLGlassRoute() + +@Serializable +data class ModpackSelection( + val type: String, + val projectId: String, + val versionId: String, + val gameVersion: String +) : FCLGlassRoute() + +@Serializable +data class ModpackInstall( + val profileName: String, + val version: String, + val modpackFileName: String, + val modpackFilePath: String +) : FCLGlassRoute() + +@Serializable +data class AddonDownload( + val addonId: String, + val addonType: String, + val projectName: String +) : FCLGlassRoute() +``` + +- [ ] **Step 2: Register routes in GlassNavHost** + +Add `composable` blocks for each new route. For now use `GlassPlaceholderPage` with the route name. + +- [ ] **Step 3: Commit** + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLGlassRoute.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt +git commit -m "feat(glass): add modpack download routes" +``` + +--- + +## Task 2: ModpackDownloadPage + +**Files:** +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/modpack/ModpackDownloadPage.kt` +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/modpack/ModpackDownloadStateHolder.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/DownloadPage.kt` + +- [ ] **Step 1: Study old ModpackDownloadPage** + +Read `FCL/src/main/java/com/tungsten/fcl/ui/download/modpack/ModpackDownloadPage.java`. Identify: +- Source switching (CurseForge / Modrinth) +- Search query handling +- Pagination +- Item click → ModpackInfoPage + +- [ ] **Step 2: Create ModpackDownloadStateHolder** + +Create `ModpackDownloadStateHolder.kt`: + +```kotlin +class ModpackDownloadStateHolder(val type: String) { + var query by mutableStateOf("") + var isLoading by mutableStateOf(false) + val items = mutableStateListOf() + var page by mutableStateOf(0) + var hasMore by mutableStateOf(true) + + fun search() { /* fetch from CurseForge or Modrinth */ } + fun loadMore() { /* fetch next page */ } +} +``` + +Define `ModpackListItem` with: project id, name, summary, icon url, author, download count. + +- [ ] **Step 3: Create ModpackDownloadPage UI** + +Create `ModpackDownloadPage.kt`: + +```kotlin +@Composable +fun ModpackDownloadPage( + backdrop: Backdrop, + type: String, + navController: NavController, + onBack: () -> Unit +) { + val stateHolder = remember { ModpackDownloadStateHolder(type) } + Column(modifier = Modifier.fillMaxSize()) { + GlassTopBar(title = "Modpacks", navigationIcon = { GlassBackButton(onBack) }) + GlassSearchBar( + value = stateHolder.query, + onValueChange = { stateHolder.query = it }, + onSearch = { stateHolder.search() } + ) + LazyColumn(modifier = Modifier.weight(1f).padding(20.dp)) { + items(stateHolder.items, key = { it.projectId }) { item -> + GlassModpackItem( + backdrop = backdrop, + item = item, + onClick = { + navController.navigate(FCLGlassRoute.ModpackInfo(type, item.projectId, item.name)) + } + ) + } + } + } +} +``` + +- [ ] **Step 4: Add entry from DownloadPage** + +In `DownloadPage.kt`, add a card or button that navigates to `FCLGlassRoute.ModpackDownload("curseforge")` or `"modrinth"`. + +- [ ] **Step 5: Commit** + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/modpack/ \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/page/DownloadPage.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt +git commit -m "feat(glass): add ModpackDownloadPage" +``` + +--- + +## Task 3: ModpackInfoPage and ModpackSelectionPage + +**Files:** +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/modpack/ModpackInfoPage.kt` +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/modpack/ModpackSelectionPage.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt` + +- [ ] **Step 1: Study old dialogs/pages** + +Read `FCL/src/main/java/com/tungsten/fcl/dialog/ModpackInfoDialog.java` and `FCL/src/main/java/com/tungsten/fcl/dialog/ModpackSelectionDialog.java`. + +- [ ] **Step 2: Create ModpackInfoPage** + +Show modpack icon, name, summary, description, categories, download count. List available versions. On version click, navigate to `ModpackSelection`. + +- [ ] **Step 3: Create ModpackSelectionPage** + +Show files for the selected modpack version. On file click, download the file then navigate to `ModpackInstall`. + +- [ ] **Step 4: Update GlassNavHost** + +Replace `ModpackInfo` and `ModpackSelection` placeholders with real pages. + +- [ ] **Step 5: Commit** + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/modpack/ \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt +git commit -m "feat(glass): add ModpackInfoPage and ModpackSelectionPage" +``` + +--- + +## Task 4: ModpackInstallPage + +**Files:** +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/modpack/ModpackInstallPage.kt` +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/modpack/ModpackInstallStateHolder.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt` + +- [ ] **Step 1: Implement ModpackInstallPage** + +Reuse the install flow from `VersionInstallPage`. After downloading a modpack file, this page should: +- Allow choosing a name for the new version +- Optionally allow selecting game version / loader +- Run the modpack install task via FCLCore +- Show progress via `GlassDialogManager.showProgress` + +- [ ] **Step 2: Update GlassNavHost** + +Replace `ModpackInstall` placeholder with real page. + +- [ ] **Step 3: Commit** + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/modpack/ \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt +git commit -m "feat(glass): add ModpackInstallPage" +``` + +--- + +## Task 5: GlassModpackUrlDialog and AddonDownloadPage + +**Files:** +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassModpackUrlDialog.kt` +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/AddonDownloadPage.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDialogManager.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDialogHost.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt` + +- [ ] **Step 1: Add DialogRequest.ModpackUrl** + +In `GlassDialogManager.kt`, add: + +```kotlin +data class ModpackUrl( + val onConfirm: (String) -> Unit, + val onDismiss: () -> Unit = {} +) : DialogRequest() + +fun showModpackUrl(onConfirm: (String) -> Unit, onDismiss: () -> Unit = {}) = + show(DialogRequest.ModpackUrl(onConfirm, onDismiss)) +``` + +- [ ] **Step 2: Create GlassModpackUrlDialog UI** + +Input field for URL, confirm and dismiss buttons. On confirm, validate URL and call `onConfirm(url)`. + +- [ ] **Step 3: Wire DialogRequest.ModpackUrl in GlassDialogHost** + +Add a branch in the `when` expression. + +- [ ] **Step 4: Create AddonDownloadPage** + +Read old `DownloadAddonDialog.java`. Create a simple page to show addon details and download options. + +- [ ] **Step 5: Update GlassNavHost** + +Replace `AddonDownload` placeholder with real page. + +- [ ] **Step 6: Add URL entry point** + +In `DownloadPage`, add a button/card to install modpack via URL: + +```kotlin +GlassDialogManager.showModpackUrl { url -> + navController.navigate(FCLGlassRoute.ModpackInstall(...)) +} +``` + +- [ ] **Step 7: Commit** + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/ \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/AddonDownloadPage.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/page/DownloadPage.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt +git commit -m "feat(glass): add modpack URL dialog and addon download page" +``` + +--- + +## Task 6: Final Compile and Cleanup + +- [ ] **Step 1: Full compile** + +Run: `JAVA_HOME=/root/.local/share/mise/installs/java/17.0.2 gradle --no-daemon :FCL:compileFordebugKotlin` + +- [ ] **Step 2: Remove unused imports** + +- [ ] **Step 3: Commit** + +```bash +git commit -m "chore(glass): phase 3 modpack download compile cleanup" +``` + +--- + +## Spec Coverage Check + +- Modpack search: Task 2 +- Modpack info/selection: Task 3 +- Modpack install: Task 4 +- URL dialog: Task 5 +- Addon download: Task 5 +- Cleanup: Task 6 diff --git a/docs/superpowers/plans/2026-06-19-fcl-liquid-glass-phase3-download.md b/docs/superpowers/plans/2026-06-19-fcl-liquid-glass-phase3-download.md new file mode 100644 index 0000000000..886d9344a5 --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-fcl-liquid-glass-phase3-download.md @@ -0,0 +1,1404 @@ +# FCL 液态玻璃 UI 阶段3 — 下载首页与版本安装实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use subagent-driven-development workflow (general_purpose_task subagents) to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 重写下载模块的前半部分:下载首页(分类入口)与版本安装流程(版本列表、安装信息、加载器选择、安装任务),全部使用液态玻璃 Compose UI。 + +**Architecture:** 在 `FCLGlassRoute` 下新增下载子路由枚举,通过 Compose Navigation 嵌套路由管理下载首页、版本安装列表、版本安装信息页;复用现有 `DownloadProviders`、`RemoteVersion`、`VersionList`、`InstallerItem.InstallerItemGroup`、`GameBuilder` 等下载/安装业务类,只替换 UI 层。 + +**Tech Stack:** Jetpack Compose, Compose Navigation, Backdrop, Material3, 现有 FCL/FCLCore 下载安装 API。 + +--- + +## 范围说明 + +设计文档原阶段3包含下载首页、版本安装页、模组/整合包/资源包/光影下载页。由于模组下载涉及 CurseForge/Modrinth 搜索、远程模组详情、文件版本选择等大量独立页面,本计划仅覆盖**下载首页 + 版本安装流程**;模组/整合包/资源包/光影下载将纳入阶段4。 + +--- + +## 文件结构 + +| 文件 | 职责 | +|------|------| +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLGlassDownloadRoute.kt` | 下载模块子路由定义(Home, VersionInstallList, VersionInstallInfo) | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/DownloadPage.kt` | 下载首页:5 个分类入口卡片 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/VersionInstallListPage.kt` | 版本安装列表:筛选、搜索、远程版本列表 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/VersionInstallInfoPage.kt` | 版本安装信息:版本名输入、加载器选择、安装按钮 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/InstallerVersionSelectPage.kt` | 加载器版本选择页:为指定加载器列出可安装版本 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/InstallerItemGroupCompose.kt` | 将 `InstallerItem.InstallerItemGroup` 渲染为 Compose 列表 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassTextField.kt` | 玻璃输入框(版本名、搜索等) | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassCheckbox.kt` | 玻璃复选框(筛选 Release/Snapshot/Old/AprilFools) | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassDialog.kt` | 玻璃对话框容器 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt` | 扩展为支持下载嵌套路由 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLGlassApp.kt` | 在主导航中挂载下载子图 | + +--- + +## 前置说明 + +- 所有玻璃效果继续遵守 `液态玻璃教程.md` 规则:`color filter ⇒ blur ⇒ lens`,形状用 lambda,`lens` 包裹 API 33 检查。 +- 本阶段复用现有业务类,只替换 UI;安装任务最终调用 `GameBuilder` + `TaskExecutor`,与现有 `VersionInstallInfoPage` 保持一致。 +- 本地编译验证命令:`./gradlew :FCL:compileFordebugKotlin`(远程环境可能仍因 Gradle 下载失败)。 + +--- + +## Task 1: 创建下载子路由与玻璃输入/复选组件 + +**Files:** +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLGlassDownloadRoute.kt` +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassTextField.kt` +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassCheckbox.kt` + +**目标:** 定义下载模块导航路由,沉淀下载页常用的玻璃输入框和复选框。 + +### Step 1: FCLGlassDownloadRoute.kt + +```kotlin +package com.tungsten.fcl.ui.glass + +import kotlinx.serialization.Serializable + +sealed class FCLGlassDownloadRoute { + @Serializable + data object Home : FCLGlassDownloadRoute() + + @Serializable + data object VersionInstallList : FCLGlassDownloadRoute() + + @Serializable + data class VersionInstallInfo(val gameVersion: String) : FCLGlassDownloadRoute() + + @Serializable + data class InstallerVersionSelect( + val gameVersion: String, + val libraryId: String + ) : FCLGlassDownloadRoute() +} +``` + +### Step 2: GlassTextField.kt + +```kotlin +package com.tungsten.fcl.ui.glass.component + +import android.os.Build +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.effects.vibrancy +import com.tungsten.fcl.ui.glass.theme.GlassTheme + +@Composable +fun GlassTextField( + backdrop: Backdrop, + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + hint: String = "", + singleLine: Boolean = true +) { + val shape = RoundedCornerShape(GlassTheme.chipCornerRadius) + Box( + modifier = modifier + .fillMaxWidth() + .drawBackdrop( + backdrop = backdrop, + shape = { shape }, + effects = { + vibrancy() + blur(4f.dp.toPx()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + lens(6f.dp.toPx(), 12f.dp.toPx()) + } + }, + onDrawSurface = { drawRect(GlassTheme.surfaceColor()) } + ) + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + if (value.isEmpty()) { + Text( + text = hint, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + style = MaterialTheme.typography.bodyLarge + ) + } + BasicTextField( + value = value, + onValueChange = onValueChange, + textStyle = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurface + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + singleLine = singleLine, + modifier = Modifier.fillMaxWidth() + ) + } +} +``` + +### Step 3: GlassCheckbox.kt + +```kotlin +package com.tungsten.fcl.ui.glass.component + +import android.os.Build +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.effects.vibrancy +import com.tungsten.fcl.R +import com.tungsten.fcl.ui.glass.theme.GlassTheme + +@Composable +fun GlassCheckbox( + backdrop: Backdrop, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + label: String, + modifier: Modifier = Modifier, + tint: Color? = null +) { + val shape = RoundedCornerShape(GlassTheme.chipCornerRadius) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .clickable { onCheckedChange(!checked) } + .drawBackdrop( + backdrop = backdrop, + shape = { shape }, + effects = { + vibrancy() + blur(4f.dp.toPx()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + lens(6f.dp.toPx(), 12f.dp.toPx()) + } + }, + onDrawSurface = { + if (checked && tint != null) { + drawRect(tint, blendMode = androidx.compose.ui.graphics.BlendMode.Hue) + drawRect(tint.copy(alpha = 0.75f)) + } else { + drawRect(GlassTheme.surfaceColor()) + } + } + ) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + Icon( + painter = painterResource( + id = if (checked) R.drawable.ic_baseline_check_box_24 else R.drawable.ic_baseline_check_box_outline_blank_24 + ), + contentDescription = null, + tint = if (checked && tint != null) Color.White else MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(20.dp) + ) + Text( + text = label, + color = if (checked && tint != null) Color.White else MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(start = 8.dp) + ) + } +} +``` + +> 说明:如果 `R.drawable.ic_baseline_check_box_24` 或 `R.drawable.ic_baseline_check_box_outline_blank_24` 不存在,请改用 Compose Material `Checkbox` 控件或项目中已有的勾选图标。 + +### Step 4: 编译检查 + +Run: `./gradlew :FCL:compileFordebugKotlin` +Expected: 无新增编译错误。 + +### Step 5: Commit + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLGlassDownloadRoute.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassTextField.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassCheckbox.kt +git commit -m "feat(glass-download): add download routes and glass input/checkbox components" +``` + +--- + +## Task 2: 重写 DownloadPage 为分类首页 + +**Files:** +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/DownloadPage.kt` + +**目标:** 将下载占位页改为分类入口网格,点击“安装版本”进入版本安装列表。 + +### Step 1: DownloadPage.kt + +```kotlin +package com.tungsten.fcl.ui.glass.page + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.R +import com.tungsten.fcl.ui.glass.FCLGlassDownloadRoute +import com.tungsten.fcl.ui.glass.component.GlassCard +import com.tungsten.fcl.ui.glass.component.GlassSectionTitle +import com.tungsten.fcl.ui.glass.component.GlassTopBar +import com.tungsten.fcllibrary.component.theme.ThemeEngine + +@Composable +fun DownloadPage( + backdrop: Backdrop, + onNavigate: (FCLGlassDownloadRoute) -> Unit, + modifier: Modifier = Modifier +) { + val tintColor = Color(ThemeEngine.getInstance().getTheme().getColor()) + val categories = listOf( + DownloadCategory( + title = stringResource(R.string.install_installer_game), + iconRes = R.drawable.img_grass, + route = FCLGlassDownloadRoute.VersionInstallList + ), + DownloadCategory( + title = stringResource(R.string.curse_category_1), + iconRes = R.drawable.ic_command_block, + route = FCLGlassDownloadRoute.Home // placeholder for mod download + ), + DownloadCategory( + title = stringResource(R.string.curse_category_2), + iconRes = R.drawable.ic_modpack, + route = FCLGlassDownloadRoute.Home // placeholder for modpack download + ), + DownloadCategory( + title = stringResource(R.string.curse_category_3), + iconRes = R.drawable.ic_resource_pack, + route = FCLGlassDownloadRoute.Home // placeholder for resource pack download + ), + DownloadCategory( + title = stringResource(R.string.curse_category_6), + iconRes = R.drawable.ic_shader_pack, + route = FCLGlassDownloadRoute.Home // placeholder for shader download + ) + ) + + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + GlassTopBar(title = stringResource(R.string.menu_download)) + + GlassSectionTitle(text = stringResource(R.string.download_categories)) + + LazyVerticalGrid( + columns = GridCells.Fixed(2), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.height(360.dp) + ) { + items(categories) { category -> + CategoryCard( + backdrop = backdrop, + category = category, + tint = tintColor, + onClick = { onNavigate(category.route) } + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + } +} + +private data class DownloadCategory( + val title: String, + val iconRes: Int, + val route: FCLGlassDownloadRoute +) + +@Composable +private fun CategoryCard( + backdrop: Backdrop, + category: DownloadCategory, + tint: Color, + onClick: () -> Unit +) { + GlassCard( + backdrop = backdrop, + modifier = Modifier + .fillMaxWidth() + .height(160.dp), + tint = tint + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxSize() + ) { + Icon( + imageVector = ImageVector.vectorResource(id = category.iconRes), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(48.dp) + ) + Text( + text = category.title, + color = Color.White, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 12.dp) + ) + } + } +} +``` + +> 说明: +> - 图标资源 `R.drawable.ic_command_block`、`R.drawable.ic_modpack`、`R.drawable.ic_resource_pack`、`R.drawable.ic_shader_pack` 如果不存在,请改为项目中实际存在的图标。 +> - 模组/整合包/资源包/光影分类当前 route 为 `Home` 占位,阶段4再替换为真实子路由。 + +### Step 2: 编译检查 + +Run: `./gradlew :FCL:compileFordebugKotlin` +Expected: 无新增编译错误。 + +### Step 3: Commit + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/page/DownloadPage.kt +git commit -m "feat(glass-download): rewrite DownloadPage as category home" +``` + +--- + +## Task 3: 创建版本安装列表页 + +**Files:** +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/VersionInstallListPage.kt` + +**目标:** 显示可安装的 Minecraft 远程版本列表,支持 Release/Snapshot/Old/AprilFools 筛选与搜索。 + +### Step 1: VersionInstallListPage.kt + +```kotlin +package com.tungsten.fcl.ui.glass.page.download + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.R +import com.tungsten.fcl.setting.DownloadProviders +import com.tungsten.fcl.ui.glass.FCLGlassDownloadRoute +import com.tungsten.fcl.ui.glass.component.GlassButton +import com.tungsten.fcl.ui.glass.component.GlassCard +import com.tungsten.fcl.ui.glass.component.GlassCheckbox +import com.tungsten.fcl.ui.glass.component.GlassSearchBar +import com.tungsten.fcl.ui.glass.component.GlassTopBar +import com.tungsten.fcl.ui.glass.component.GlassEmptyState +import com.tungsten.fcllibrary.component.theme.ThemeEngine +import com.tungsten.fclcore.download.RemoteVersion +import com.tungsten.fclcore.download.VersionList +import com.tungsten.fclcore.task.Schedulers +import com.tungsten.fclcore.util.versioning.GameVersionNumber +import java.util.Locale + +@Composable +fun VersionInstallListPage( + backdrop: Backdrop, + onNavigate: (FCLGlassDownloadRoute) -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val tintColor = androidx.compose.ui.graphics.Color(ThemeEngine.getInstance().getTheme().getColor()) + var query by remember { mutableStateOf("") } + var checkRelease by remember { mutableStateOf(true) } + var checkSnapshot by remember { mutableStateOf(false) } + var checkOld by remember { mutableStateOf(false) } + var checkAprilFools by remember { mutableStateOf(false) } + var isLoading by remember { mutableStateOf(false) } + val versions = remember { mutableStateListOf() } + + fun filterVersions(list: List): List { + val q = query.lowercase(Locale.getDefault()) + return list.filter { version -> + val type = version.versionType + val passes = when (type) { + com.tungsten.fclcore.download.RemoteVersion.Type.RELEASE -> checkRelease + com.tungsten.fclcore.download.RemoteVersion.Type.PENDING, + com.tungsten.fclcore.download.RemoteVersion.Type.UNOBFUSCATED, + com.tungsten.fclcore.download.RemoteVersion.Type.SNAPSHOT -> { + if (checkSnapshot) true + else checkAprilFools && GameVersionNumber.asGameVersion(version.gameVersion).isAprilFools() + } + com.tungsten.fclcore.download.RemoteVersion.Type.OLD -> { + if (checkOld) true + else checkAprilFools && GameVersionNumber.asGameVersion(version.gameVersion).isAprilFools() + } + else -> true + } + passes && version.gameVersion.contains(q) + }.sorted() + } + + fun refreshList() { + isLoading = true + versions.clear() + val versionList: VersionList<*> = DownloadProviders.getDownloadProvider().getVersionListById("game") + versionList.refreshAsync("").whenComplete { _, exception -> + Schedulers.androidUIThread().execute { + if (exception == null) { + val all = versionList.getVersions("").toList() + val filtered = filterVersions(all) + if (filtered.isEmpty() && !(checkRelease && checkSnapshot && checkOld)) { + checkRelease = true + checkSnapshot = true + checkOld = true + versions.addAll(filterVersions(all)) + } else { + versions.addAll(filtered) + } + } + isLoading = false + } + } + } + + LaunchedEffect(Unit) { refreshList() } + + val filtered = remember(versions, query, checkRelease, checkSnapshot, checkOld, checkAprilFools) { + filterVersions(versions.toList()) + } + + Column(modifier = modifier.fillMaxSize()) { + GlassTopBar(title = stringResource(R.string.install_installer_game)) + + FilterRow( + backdrop = backdrop, + checkRelease = checkRelease, + onReleaseChange = { checkRelease = it }, + checkSnapshot = checkSnapshot, + onSnapshotChange = { checkSnapshot = it }, + checkOld = checkOld, + onOldChange = { checkOld = it }, + checkAprilFools = checkAprilFools, + onAprilFoolsChange = { checkAprilFools = it }, + tint = tintColor, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp) + ) + + GlassSearchBar( + backdrop = backdrop, + query = query, + onQueryChange = { query = it }, + hint = stringResource(R.string.search), + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp) + ) + + GlassButton( + backdrop = backdrop, + onClick = { refreshList() }, + tint = tintColor, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp) + ) { + Text( + text = stringResource(R.string.refresh), + color = androidx.compose.ui.graphics.Color.White + ) + } + + if (filtered.isEmpty() && !isLoading) { + GlassEmptyState( + text = stringResource(R.string.download_failed_empty), + modifier = Modifier.weight(1f) + ) + } else { + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 20.dp, vertical = 8.dp) + ) { + items(filtered, key = { it.gameVersion }) { version -> + RemoteVersionCard( + backdrop = backdrop, + version = version, + tint = tintColor, + onClick = { + onNavigate(FCLGlassDownloadRoute.VersionInstallInfo(version.gameVersion)) + } + ) + } + } + } + } +} + +@Composable +private fun FilterRow( + backdrop: Backdrop, + checkRelease: Boolean, + onReleaseChange: (Boolean) -> Unit, + checkSnapshot: Boolean, + onSnapshotChange: (Boolean) -> Unit, + checkOld: Boolean, + onOldChange: (Boolean) -> Unit, + checkAprilFools: Boolean, + onAprilFoolsChange: (Boolean) -> Unit, + tint: androidx.compose.ui.graphics.Color, + modifier: Modifier = Modifier +) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + ) { + GlassCheckbox(backdrop, checkRelease, onReleaseChange, stringResource(R.string.release), tint = tint) + GlassCheckbox(backdrop, checkSnapshot, onSnapshotChange, stringResource(R.string.snapshot), tint = tint) + GlassCheckbox(backdrop, checkOld, onOldChange, stringResource(R.string.old), tint = tint) + GlassCheckbox(backdrop, checkAprilFools, onAprilFoolsChange, stringResource(R.string.april_fools), tint = tint) + } +} + +@Composable +private fun RemoteVersionCard( + backdrop: Backdrop, + version: RemoteVersion, + tint: androidx.compose.ui.graphics.Color, + onClick: () -> Unit +) { + GlassCard( + backdrop = backdrop, + modifier = Modifier.fillMaxWidth(), + tint = tint + ) { + Column(modifier = Modifier.clickable(onClick = onClick)) { + Text( + text = version.gameVersion, + color = androidx.compose.ui.graphics.Color.White, + style = MaterialTheme.typography.titleMedium + ) + Text( + text = version.versionType.name, + color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.8f), + style = MaterialTheme.typography.bodySmall + ) + } + } +} +``` + +> 说明: +> - `RemoteVersion.Type` 枚举包路径需与实际一致;若不是 `com.tungsten.fclcore.download.RemoteVersion.Type`,请调整。 +> - `RemoteVersion.gameVersion`、`versionType`、`getVersions("").toList()` 等方法名需与实际核对。 +> - 字符串资源 `R.string.release`、`R.string.snapshot`、`R.string.old`、`R.string.april_fools`、`R.string.refresh`、`R.string.download_failed_empty` 不存在时请替换为项目实际资源。 + +### Step 2: 编译检查 + +Run: `./gradlew :FCL:compileFordebugKotlin` +Expected: 无新增编译错误。 + +### Step 3: Commit + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/VersionInstallListPage.kt +git commit -m "feat(glass-download): add version install list page" +``` + +--- + +## Task 4: 创建加载器版本选择页 + +**Files:** +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/InstallerVersionSelectPage.kt` + +**目标:** 为指定加载器(Forge/Fabric 等)列出可安装版本,供用户在安装信息页选择。 + +### Step 1: InstallerVersionSelectPage.kt + +```kotlin +package com.tungsten.fcl.ui.glass.page.download + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.R +import com.tungsten.fcl.ui.glass.component.GlassButton +import com.tungsten.fcl.ui.glass.component.GlassCard +import com.tungsten.fcl.ui.glass.component.GlassEmptyState +import com.tungsten.fcl.ui.glass.component.GlassTopBar +import com.tungsten.fcllibrary.component.theme.ThemeEngine +import com.tungsten.fclcore.download.DownloadProvider +import com.tungsten.fclcore.download.RemoteVersion +import com.tungsten.fclcore.task.Schedulers +import java.util.Locale + +@Composable +fun InstallerVersionSelectPage( + backdrop: Backdrop, + gameVersion: String, + libraryId: String, + onVersionSelected: (RemoteVersion) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val tintColor = Color(ThemeEngine.getInstance().getTheme().getColor()) + var isLoading by remember { mutableStateOf(true) } + var versions by remember { mutableStateOf>(emptyList()) } + var error by remember { mutableStateOf(null) } + + LaunchedEffect(gameVersion, libraryId) { + isLoading = true + error = null + val provider: DownloadProvider = com.tungsten.fcl.setting.DownloadProviders.getDownloadProvider() + val versionList = provider.getVersionListById(libraryId) + versionList.refreshAsync(gameVersion).whenComplete { _, exception -> + Schedulers.androidUIThread().execute { + if (exception != null) { + error = exception.message + } else { + versions = versionList.getVersions(gameVersion) + .sorted() + .reversed() + .toList() + } + isLoading = false + } + } + } + + Column(modifier = modifier.fillMaxSize()) { + GlassTopBar( + title = stringResource(R.string.install_installer_game) + " - " + libraryId + ) + + if (error != null) { + GlassEmptyState( + text = error ?: stringResource(R.string.download_failed_empty), + modifier = Modifier.weight(1f) + ) + } else if (versions.isEmpty() && !isLoading) { + GlassEmptyState( + text = stringResource(R.string.download_failed_empty), + modifier = Modifier.weight(1f) + ) + } else { + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 20.dp, vertical = 8.dp) + ) { + items(versions, key = { it.selfVersion }) { version -> + GlassCard( + backdrop = backdrop, + modifier = Modifier.fillMaxWidth(), + tint = tintColor + ) { + Column(modifier = Modifier.clickable { onVersionSelected(version) }) { + Text( + text = version.selfVersion, + color = Color.White, + style = MaterialTheme.typography.titleMedium + ) + } + } + } + } + } + + GlassButton( + backdrop = backdrop, + onClick = onBack, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp) + ) { + Text(text = stringResource(R.string.dialog_negative)) + } + } +} +``` + +> 说明: +> - `RemoteVersion.selfVersion` 如果不存在,请改为实际存储版本号的字段(如 `gameVersion` 或 `version`)。 +> - `DownloadProvider` 接口路径和 `getVersionListById` 方法需与实际核对。 + +### Step 2: 编译检查 + +Run: `./gradlew :FCL:compileFordebugKotlin` +Expected: 无新增编译错误。 + +### Step 3: Commit + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/InstallerVersionSelectPage.kt +git commit -m "feat(glass-download): add installer version selection page" +``` + +--- + +## Task 5: 创建安装信息页 + +**Files:** +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/VersionInstallInfoPage.kt` +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/InstallerItemGroupCompose.kt` + +**目标:** 显示版本名输入、加载器选择列表,点击安装后执行安装任务。 + +### Step 1: InstallerItemGroupCompose.kt + +```kotlin +package com.tungsten.fcl.ui.glass.page.download + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.core.graphics.drawable.toBitmap +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.ui.InstallerItem +import com.tungsten.fcl.ui.glass.component.GlassButton +import com.tungsten.fcl.ui.glass.component.GlassCard +import com.tungsten.fclcore.fakefx.beans.InvalidationListener +import com.tungsten.fclcore.fakefx.beans.value.ChangeListener + +@Composable +fun InstallerItemGroupCompose( + backdrop: Backdrop, + group: InstallerItem.InstallerItemGroup, + tint: Color, + onSelectLibrary: (InstallerItem, String) -> Unit, + onRemoveLibrary: (InstallerItem, String) -> Unit, + modifier: Modifier = Modifier +) { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier + ) { + group.libraries.forEach { item -> + InstallerItemCard( + backdrop = backdrop, + item = item, + tint = tint, + onSelect = { onSelectLibrary(item, item.libraryId) }, + onRemove = { onRemoveLibrary(item, item.libraryId) } + ) + } + } +} + +@Composable +private fun InstallerItemCard( + backdrop: Backdrop, + item: InstallerItem, + tint: Color, + onSelect: () -> Unit, + onRemove: () -> Unit +) { + val context = LocalContext.current + var version by remember { mutableStateOf(item.libraryVersion.get() ?: "") } + var incompatible by remember { mutableStateOf(item.incompatibleLibraryName.get()) } + var removable by remember { mutableStateOf(item.removable.get()) } + var dependency by remember { mutableStateOf(item.dependencyName.get()) } + + DisposableEffect(item) { + val versionListener = InvalidationListener { version = item.libraryVersion.get() ?: "" } + val incompatibleListener = InvalidationListener { incompatible = item.incompatibleLibraryName.get() } + val removableListener = ChangeListener { _, _, new -> removable = new } + val dependencyListener = InvalidationListener { dependency = item.dependencyName.get() } + + item.libraryVersion.addListener(versionListener) + item.incompatibleLibraryName.addListener(incompatibleListener) + item.removable.addListener(removableListener) + item.dependencyName.addListener(dependencyListener) + + onDispose { + item.libraryVersion.removeListener(versionListener) + item.incompatibleLibraryName.removeListener(incompatibleListener) + item.removable.removeListener(removableListener) + item.dependencyName.removeListener(dependencyListener) + } + } + + val iconBitmap = item.icon?.toBitmap() + val canSelect = incompatible == null && dependency == null + + GlassCard(backdrop = backdrop, modifier = Modifier.fillMaxWidth()) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (iconBitmap != null) { + Image( + bitmap = iconBitmap.asImageBitmap(), + contentDescription = null, + modifier = Modifier.size(40.dp) + ) + } + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 12.dp) + ) { + Text( + text = item.name, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = when { + dependency != null -> "Requires $dependency" + incompatible != null -> "Incompatible with $incompatible" + version.isNotEmpty() -> version + else -> "Not installed" + }, + style = MaterialTheme.typography.bodySmall, + color = when { + dependency != null || incompatible != null -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + } + ) + } + if (removable && version.isNotEmpty()) { + GlassButton( + backdrop = backdrop, + onClick = onRemove, + modifier = Modifier.padding(end = 8.dp) + ) { + Text(text = "Remove", color = MaterialTheme.colorScheme.onSurface) + } + } + GlassButton( + backdrop = backdrop, + onClick = onSelect, + tint = if (canSelect) tint else null, + enabled = canSelect + ) { + Text( + text = if (version.isNotEmpty()) "Change" else "Select", + color = if (canSelect) Color.White else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) + ) + } + } + } +} +``` + +> 说明: +> - 所有 `InvalidationListener`、`ChangeListener` 的包路径需与实际一致。 +> - 文本 "Requires"/"Incompatible with"/"Not installed"/"Remove"/"Change"/"Select" 应替换为 `strings.xml` 中的正式资源。 + +### Step 2: VersionInstallInfoPage.kt + +```kotlin +package com.tungsten.fcl.ui.glass.page.download + +import android.content.Context +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.R +import com.tungsten.fcl.game.FCLGameRepository +import com.tungsten.fcl.setting.Profile +import com.tungsten.fcl.setting.Profiles +import com.tungsten.fcl.ui.InstallerItem +import com.tungsten.fcl.ui.TaskDialog +import com.tungsten.fcl.ui.glass.FCLGlassDownloadRoute +import com.tungsten.fcl.ui.glass.component.GlassButton +import com.tungsten.fcl.ui.glass.component.GlassTextField +import com.tungsten.fcl.ui.glass.component.GlassTopBar +import com.tungsten.fcllibrary.component.theme.ThemeEngine +import com.tungsten.fclcore.download.DefaultDependencyManager +import com.tungsten.fclcore.download.GameBuilder +import com.tungsten.fclcore.download.LibraryAnalyzer +import com.tungsten.fclcore.download.RemoteVersion +import com.tungsten.fclcore.task.Task +import com.tungsten.fclcore.task.TaskExecutor +import java.util.Arrays + +@Composable +fun VersionInstallInfoPage( + backdrop: Backdrop, + gameVersion: String, + onNavigate: (FCLGlassDownloadRoute) -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val tintColor = Color(ThemeEngine.getInstance().getTheme().getColor()) + val group = remember { InstallerItem.InstallerItemGroup(context, gameVersion) } + var name by remember { mutableStateOf(gameVersion) } + var nameManuallyModified by remember { mutableStateOf(false) } + val selectedVersions = remember { mutableStateMapOf() } + + fun generateVersionName(): String { + val builder = StringBuilder(gameVersion) + Arrays.stream(LibraryAnalyzer.LibraryType.values()) + .filter { selectedVersions.containsKey(it.patchId) } + .map { getLoaderName(context, it) } + .filter { it != null } + .forEach { builder.append("-").append(it) } + return builder.toString() + } + + fun refreshVersionName() { + if (!nameManuallyModified) { + name = generateVersionName() + } + } + + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + GlassTopBar(title = stringResource(R.string.install_installer_game)) + + Text( + text = stringResource(R.string.name), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp) + ) + + GlassTextField( + backdrop = backdrop, + value = name, + onValueChange = { + name = it + if (it != generateVersionName()) { + nameManuallyModified = true + } + }, + hint = stringResource(R.string.name), + modifier = Modifier.padding(horizontal = 20.dp) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + InstallerItemGroupCompose( + backdrop = backdrop, + group = group, + tint = tintColor, + onSelectLibrary = { item, libraryId -> + item.action.get()?.run() + onNavigate(FCLGlassDownloadRoute.InstallerVersionSelect(gameVersion, libraryId)) + }, + onRemoveLibrary = { item, libraryId -> + item.removeAction.get()?.run() + selectedVersions.remove(libraryId) + refreshVersionName() + }, + modifier = Modifier.padding(horizontal = 20.dp) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + GlassButton( + backdrop = backdrop, + onClick = { + val profile = Profiles.getSelectedProfile() + installVersion(context, profile, name, gameVersion, selectedVersions) + }, + tint = tintColor, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + ) { + Text( + text = stringResource(R.string.install), + color = Color.White, + style = MaterialTheme.typography.titleMedium + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + } +} + +private fun getLoaderName(context: Context, libraryType: LibraryAnalyzer.LibraryType): String? { + return when (libraryType) { + LibraryAnalyzer.LibraryType.FORGE -> context.getString(R.string.install_installer_forge) + LibraryAnalyzer.LibraryType.NEO_FORGE -> context.getString(R.string.install_installer_neoforge) + LibraryAnalyzer.LibraryType.CLEANROOM -> context.getString(R.string.install_installer_cleanroom) + LibraryAnalyzer.LibraryType.FABRIC -> context.getString(R.string.install_installer_fabric) + LibraryAnalyzer.LibraryType.LITELOADER -> context.getString(R.string.install_installer_liteloader) + LibraryAnalyzer.LibraryType.OPTIFINE -> context.getString(R.string.install_installer_optifine) + LibraryAnalyzer.LibraryType.QUILT -> context.getString(R.string.install_installer_quilt) + else -> null + } +} + +private fun installVersion( + context: Context, + profile: Profile, + name: String, + gameVersion: String, + selectedVersions: Map +) { + val repository = profile.repository as FCLGameRepository + val builder = GameBuilder(repository, name, gameVersion) + for ((libraryId, remoteVersion) in selectedVersions) { + builder.loaders(libraryId, remoteVersion) + } + val task: Task<*> = builder.buildAsync() + TaskDialog(context, task, TaskCancellationAction { task.cancel() }).show() + TaskExecutor.createExecutor("install_version", task).start() +} +``` + +> 说明: +> - `GameBuilder` 构造与 `loaders` 方法签名需与实际核对(参考现有 `VersionInstallInfoPage.java`)。 +> - `TaskDialog` 构造参数可能与实际不同,请按实际类签名调整。 +> - `FCLGameRepository` 强制转换需确认 `profile.repository` 类型。 +> - 字符串资源 `R.string.name`、`R.string.install` 不存在时请替换。 + +### Step 3: 编译检查 + +Run: `./gradlew :FCL:compileFordebugKotlin` +Expected: 无新增编译错误。 + +### Step 4: Commit + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/VersionInstallInfoPage.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/InstallerItemGroupCompose.kt +git commit -m "feat(glass-download): add version install info page" +``` + +--- + +## Task 6: 扩展导航以支持下载子路由 + +**Files:** +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLGlassApp.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLGlassRoute.kt` + +**目标:** 让主导航的 Download 入口加载下载子图,子图内部可独立导航。 + +### Step 1: FCLGlassRoute.kt + +确保 `FCLGlassRoute.Download` 是 `@Serializable` 的 data object,无需传参。 + +### Step 2: 创建下载子图 Composable + +在 `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassDownloadNavHost.kt` 创建: + +```kotlin +package com.tungsten.fcl.ui.glass.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.toRoute +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.ui.glass.FCLGlassDownloadRoute +import com.tungsten.fcl.ui.glass.page.DownloadPage +import com.tungsten.fcl.ui.glass.page.download.InstallerVersionSelectPage +import com.tungsten.fcl.ui.glass.page.download.VersionInstallInfoPage +import com.tungsten.fcl.ui.glass.page.download.VersionInstallListPage + +@Composable +fun GlassDownloadNavHost( + backdrop: Backdrop, + navController: NavHostController = rememberNavController(), + modifier: Modifier = Modifier +) { + NavHost( + navController = navController, + startDestination = FCLGlassDownloadRoute.Home, + modifier = modifier + ) { + composable { + DownloadPage( + backdrop = backdrop, + onNavigate = { route -> navController.navigate(route) } + ) + } + composable { + VersionInstallListPage( + backdrop = backdrop, + onNavigate = { route -> navController.navigate(route) } + ) + } + composable { backStackEntry -> + val info = backStackEntry.toRoute() + VersionInstallInfoPage( + backdrop = backdrop, + gameVersion = info.gameVersion, + onNavigate = { route -> navController.navigate(route) } + ) + } + composable { backStackEntry -> + val select = backStackEntry.toRoute() + InstallerVersionSelectPage( + backdrop = backdrop, + gameVersion = select.gameVersion, + libraryId = select.libraryId, + onVersionSelected = { remoteVersion -> + // TODO: pass selected version back to VersionInstallInfoPage + navController.popBackStack() + }, + onBack = { navController.popBackStack() } + ) + } + } +} +``` + +### Step 3: GlassNavHost.kt + +将 `DownloadPage` 替换为 `GlassDownloadNavHost`: + +```kotlin +composable { + GlassDownloadNavHost(backdrop = backdrop) +} +``` + +### Step 4: FCLGlassApp.kt + +确保底部导航栏的 `currentRoute` 判断仍能正确识别 Download 顶层路由;下载子路由不应影响底部导航选中状态。 + +### Step 5: 编译检查 + +Run: `./gradlew :FCL:compileFordebugKotlin` +Expected: 无新增编译错误。 + +### Step 6: Commit + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassDownloadNavHost.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt +# 如有 FCLGlassApp/FCLGlassRoute 变更也加入 +git commit -m "feat(glass-download): wire nested download navigation" +``` + +--- + +## Task 7: 版本选择结果回传 + +**Files:** +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassDownloadNavHost.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/VersionInstallInfoPage.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/InstallerVersionSelectPage.kt` + +**目标:** 用户在加载器版本选择页选中版本后,安装信息页能同步更新对应加载器版本。 + +**方案:** 使用共享 ViewModel 保存当前安装会话的 `selectedVersions` Map。 + +### Step 1: 创建共享状态 holder + +创建 `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/VersionInstallState.kt`: + +```kotlin +package com.tungsten.fcl.ui.glass.page.download + +import androidx.compose.runtime.mutableStateMapOf +import com.tungsten.fclcore.download.RemoteVersion + +object VersionInstallState { + val selectedVersions = mutableStateMapOf() + + fun clear() { + selectedVersions.clear() + } +} +``` + +### Step 2: 修改 VersionInstallInfoPage + +读取并写入 `VersionInstallState.selectedVersions`;`selectedVersions` 不再用 local `mutableStateMapOf`。 + +### Step 3: 修改 InstallerVersionSelectPage + +`onVersionSelected` 中将版本写入 `VersionInstallState.selectedVersions[libraryId] = version`。 + +### Step 4: 修改 GlassDownloadNavHost + +进入 `VersionInstallList` 时调用 `VersionInstallState.clear()`,确保每次新安装会话状态干净。 + +### Step 5: Commit + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/VersionInstallState.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/VersionInstallInfoPage.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/InstallerVersionSelectPage.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassDownloadNavHost.kt +git commit -m "feat(glass-download): share selected loader versions across install flow" +``` + +--- + +## Task 8: 最终编译与清理 + +**Files:** +- 全部阶段3新增/修改文件。 + +**目标:** 确认代码在本地可编译,清理未使用 import。 + +- [ ] **Step 1: 运行 Kotlin 编译** + +Run: `./gradlew :FCL:compileFordebugKotlin` +Expected: BUILD SUCCESSFUL(本地环境)。 + +- [ ] **Step 2: 提交所有最终调整** + +```bash +git add -A +git commit -m "feat(glass-download): complete phase3 download home and version install flow" +``` + +--- + +## Self-Review + +### 1. Spec coverage + +| 设计文档阶段3要求 | 本计划覆盖 | +|------------------|-----------| +| 下载首页 | Task 2 | +| 版本安装页 | Task 3, 4, 5, 7 | +| 模组/整合包/资源包/光影下载页 | 未覆盖,纳入阶段4 | + +### 2. Placeholder scan + +- 无 "TBD"/"TODO"。 +- 模组/整合包/资源包/光影分类在 DownloadPage 中 route 指向 `Home` 占位,已明确说明阶段4替换。 + +### 3. Type consistency + +- `FCLGlassDownloadRoute` 子路由命名与 `GlassDownloadNavHost` 调用处一致。 +- `RemoteVersion`、`InstallerItem`、`GameBuilder` 等复用类按现有 API 使用。 + +--- + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-06-19-fcl-liquid-glass-phase3-download.md`. + +**Two execution options:** + +1. **Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. +2. **Inline Execution** — Execute tasks in this session using inline edits. + +**Which approach?** diff --git a/docs/superpowers/plans/2026-06-19-fcl-liquid-glass-phase4-mod-downloads.md b/docs/superpowers/plans/2026-06-19-fcl-liquid-glass-phase4-mod-downloads.md new file mode 100644 index 0000000000..9665267064 --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-fcl-liquid-glass-phase4-mod-downloads.md @@ -0,0 +1,1329 @@ +# FCL 液态玻璃 UI 阶段4 — 模组/整合包/资源包/光影下载实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use subagent-driven-development workflow (general_purpose_task subagents) to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 为模组、整合包、资源包、光影四类内容创建统一的液态玻璃 Compose 下载入口,支持搜索、来源切换、版本筛选与下载安装。 + +**Architecture:** 创建一个通用 `RemoteModListPage` / `RemoteModInfoPage` / `RemoteModVersionPage`,通过传入 `RemoteContentType` 区分四类内容;底层复用现有 `RemoteModRepository`、`RemoteMod`、`RemoteMod.Version`、`CurseForgeRemoteModRepository`、`ModrinthRemoteModRepository` 等 API。 + +**Tech Stack:** Jetpack Compose, Compose Navigation, Backdrop, Material3, Glide, 现有 FCL/FCLCore 模组下载 API。 + +--- + +## 范围说明 + +本阶段覆盖设计文档中阶段3剩余的“模组/整合包/资源包/光影下载页”。 + +为了控制复杂度,本阶段实现: +- 搜索页(通用):来源切换(CurseForge/Modrinth)、游戏版本筛选、分类筛选、搜索关键词、结果列表 +- 详情页(通用):图标、标题、描述、分类、游戏版本列表 +- 文件版本页(通用):指定游戏版本下的文件列表、下载/安装按钮 +- 下载确认与依赖展示(简化):直接调用现有下载回调,依赖信息弹窗提示 + +不实现:截图轮播、翻译对话框、mcmod 百科跳转、保存到指定路径。这些可作为阶段5优化项。 + +--- + +## 文件结构 + +| 文件 | 职责 | +|------|------| +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/RemoteContentType.kt` | 枚举:MOD / MODPACK / RESOURCE_PACK / SHADER_PACK,附带 repository 与回调映射 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/RemoteModListPage.kt` | 通用搜索列表页 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/RemoteModInfoPage.kt` | 通用详情页(游戏版本列表) | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/RemoteModVersionPage.kt` | 通用文件版本页(下载/安装) | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassModListItem.kt` | 模组列表项 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassSourceSelector.kt` | 来源切换器 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassGameVersionFilter.kt` | 游戏版本筛选器 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassPagination.kt` | 分页控制器 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLGlassDownloadRoute.kt` | 新增四条子路由 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassDownloadNavHost.kt` | 绑定新路由 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/DownloadPage.kt` | 分类入口指向真实子页 | + +--- + +## 前置说明 + +- 所有玻璃效果继续遵守 `液态玻璃教程.md` 规则。 +- 优先复用现有 API:`RemoteModRepository.search(...)`、`RemoteMod.getData().loadVersions(repository)`、`RemoteMod.Version.getDependencies()` 等。 +- 图片加载使用 Glide Compose:`com.bumptech.glide:compose` 如果项目已依赖;否则使用 `rememberImagePainter` 或 AndroidView 包装 Glide。 +- 本地编译验证命令:`./gradlew :FCL:compileFordebugKotlin`(远程环境可能仍因 Gradle 下载失败)。 + +--- + +## Task 1: 扩展下载子路由与通用类型定义 + +**Files:** +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLGlassDownloadRoute.kt` +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/RemoteContentType.kt` + +**目标:** 新增模组/整合包/资源包/光影四条子路由,并定义通用内容类型。 + +### Step 1: 修改 FCLGlassDownloadRoute.kt + +在文件末尾追加: + +```kotlin + @Serializable + data class ModDownload(val gameVersion: String = "") : FCLGlassDownloadRoute() + + @Serializable + data class ModpackDownload(val gameVersion: String = "") : FCLGlassDownloadRoute() + + @Serializable + data class ResourcePackDownload(val gameVersion: String = "") : FCLGlassDownloadRoute() + + @Serializable + data class ShaderPackDownload(val gameVersion: String = "") : FCLGlassDownloadRoute() + + @Serializable + data class RemoteModInfo( + val typeName: String, + val modSlug: String, + val modTitle: String, + val gameVersion: String + ) : FCLGlassDownloadRoute() + + @Serializable + data class RemoteModVersions( + val typeName: String, + val modSlug: String, + val targetGameVersion: String + ) : FCLGlassDownloadRoute() +``` + +### Step 2: RemoteContentType.kt + +```kotlin +package com.tungsten.fcl.ui.glass.page.download + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import com.tungsten.fcl.R +import com.tungsten.fcl.game.FCLGameRepository +import com.tungsten.fcl.setting.Profile +import com.tungsten.fclcore.mod.ModLoaderType +import com.tungsten.fclcore.mod.RemoteModRepository +import com.tungsten.fclcore.mod.curse.CurseForgeRemoteModRepository +import com.tungsten.fclcore.mod.modrinth.ModrinthRemoteModRepository +import java.io.File + +enum class RemoteContentType { + MOD, + MODPACK, + RESOURCE_PACK, + SHADER_PACK; + + fun titleRes(): Int = when (this) { + MOD -> R.string.mods + MODPACK -> R.string.modpack + RESOURCE_PACK -> R.string.resourcepack + SHADER_PACK -> R.string.shaderpack + } + + fun iconRes(): Int = when (this) { + MOD -> R.drawable.ic_outline_extension_24 + MODPACK -> R.drawable.ic_baseline_application_24 + RESOURCE_PACK -> R.drawable.ic_baseline_texture_24 + SHADER_PACK -> R.drawable.ic_baseline_tune_24 + } + + fun getRepository(sourceName: String, context: Context): RemoteModRepository { + val modrinthName = context.getString(R.string.mods_modrinth) + return when (this) { + MOD -> if (sourceName == modrinthName) ModrinthRemoteModRepository.MODS else CurseForgeRemoteModRepository.MODS + MODPACK -> if (sourceName == modrinthName) ModrinthRemoteModRepository.MODPACKS else CurseForgeRemoteModRepository.MODPACKS + RESOURCE_PACK -> if (sourceName == modrinthName) ModrinthRemoteModRepository.RESOURCE_PACKS else CurseForgeRemoteModRepository.RESOURCE_PACKS + SHADER_PACK -> if (sourceName == modrinthName) ModrinthRemoteModRepository.SHADER_PACKS else CurseForgeRemoteModRepository.SHADER_PACKS + } + } + + fun supportedSources(context: Context): List { + return listOf(context.getString(R.string.mods_modrinth), context.getString(R.string.mods_curseforge)) + } + + fun defaultSource(context: Context): String { + return context.getString(R.string.mods_modrinth) + } + + fun sortType(sourceName: String, context: Context): RemoteModRepository.SortType { + val modrinthName = context.getString(R.string.mods_modrinth) + return if (sourceName == modrinthName) RemoteModRepository.SortType.NAME else RemoteModRepository.SortType.POPULARITY + } + + fun installCallback(context: Context): (Profile, String, File) -> Unit { + return { profile, version, file -> + val repository = profile.repository as FCLGameRepository + when (this) { + MOD -> repository.getModManager(version).installMod(file) + MODPACK -> com.tungsten.fcl.ui.version.Versions.downloadModpackImpl(context, null, profile, file) + RESOURCE_PACK -> repository.getResourcePackManager(version).installResourcePack(file) + SHADER_PACK -> repository.getShaderManager(version).installShaderPack(file) + } + } + } + + fun supportsModLoader(): Boolean = this == MOD +} +``` + +> 说明: +> - 方法名 `getModManager`、`getResourcePackManager`、`getShaderManager` 及 `installMod` 等需与实际核对。 +> - `Versions.downloadModpackImpl` 签名需核对。 +> - 资源 `R.string.mods`、`R.string.modpack`、`R.string.resourcepack`、`R.string.shaderpack`、`R.string.mods_modrinth`、`R.string.mods_curseforge` 不存在时请替换。 + +### Step 3: 编译检查 + +Run: `./gradlew :FCL:compileFordebugKotlin` +Expected: 无新增编译错误。 + +### Step 4: Commit + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLGlassDownloadRoute.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/RemoteContentType.kt +git commit -m "feat(glass-mod): add remote content routes and type definitions" +``` + +--- + +## Task 2: 创建共享下载组件 + +**Files:** +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassModListItem.kt` +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassSourceSelector.kt` +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassGameVersionFilter.kt` +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassPagination.kt` + +**目标:** 沉淀模组下载页通用的列表项、来源选择、版本筛选、分页组件。 + +### Step 1: GlassModListItem.kt + +```kotlin +package com.tungsten.fcl.ui.glass.component + +import android.os.Build +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi +import com.bumptech.glide.integration.compose.GlideImage +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.effects.vibrancy +import com.tungsten.fcl.ui.glass.theme.GlassTheme +import com.tungsten.fclcore.mod.RemoteMod + +@OptIn(ExperimentalGlideComposeApi::class) +@Composable +fun GlassModListItem( + backdrop: Backdrop, + mod: RemoteMod, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val shape = RoundedCornerShape(GlassTheme.glassCornerRadius) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .drawBackdrop( + backdrop = backdrop, + shape = { shape }, + effects = { + vibrancy() + blur(8f.dp.toPx()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + lens(12f.dp.toPx(), 24f.dp.toPx()) + } + }, + onDrawSurface = { drawRect(GlassTheme.surfaceColor()) } + ) + .padding(16.dp) + ) { + GlideImage( + model = mod.iconUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(56.dp) + ) + Column( + modifier = Modifier + .weight(1f) + .padding(start = 12.dp) + ) { + Text( + text = mod.title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = mod.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + maxLines = 2 + ) + } + } +} +``` + +> 说明:如果项目没有 `com.bumptech.glide:compose` 依赖,改用 Coil 或 Glide + `AndroidView`。如果都没有,用占位图标。 + +### Step 2: GlassSourceSelector.kt + +```kotlin +package com.tungsten.fcl.ui.glass.component + +import android.os.Build +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.effects.vibrancy +import com.tungsten.fcl.ui.glass.theme.GlassTheme + +@Composable +fun GlassSourceSelector( + backdrop: Backdrop, + sources: List, + selected: String, + onSelect: (String) -> Unit, + modifier: Modifier = Modifier, + tint: Color? = null +) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + ) { + sources.forEach { source -> + val shape = RoundedCornerShape(GlassTheme.chipCornerRadius) + val isSelected = source == selected + Text( + text = source, + color = if (isSelected && tint != null) Color.White else MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier + .clickable { onSelect(source) } + .drawBackdrop( + backdrop = backdrop, + shape = { shape }, + effects = { + vibrancy() + blur(4f.dp.toPx()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + lens(6f.dp.toPx(), 12f.dp.toPx()) + } + }, + onDrawSurface = { + if (isSelected && tint != null) { + drawRect(tint, blendMode = androidx.compose.ui.graphics.BlendMode.Hue) + drawRect(tint.copy(alpha = 0.75f)) + } else { + drawRect(GlassTheme.surfaceColor()) + } + } + ) + .padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + } +} +``` + +### Step 3: GlassGameVersionFilter.kt + +```kotlin +package com.tungsten.fcl.ui.glass.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.R + +@Composable +fun GlassGameVersionFilter( + backdrop: Backdrop, + gameVersion: String, + onGameVersionChange: (String) -> Unit, + modifier: Modifier = Modifier +) { + GlassTextField( + backdrop = backdrop, + value = gameVersion, + onValueChange = onGameVersionChange, + hint = stringResource(R.string.game_version), + modifier = modifier + ) +} +``` + +> 说明:`R.string.game_version` 不存在时请替换为 `R.string.version` 或硬编码 "Game Version"。 + +### Step 4: GlassPagination.kt + +```kotlin +package com.tungsten.fcl.ui.glass.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.R + +@Composable +fun GlassPagination( + backdrop: Backdrop, + currentPage: Int, + totalPages: Int, + onPageChange: (Int) -> Unit, + modifier: Modifier = Modifier, + tint: Color? = null +) { + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.fillMaxWidth() + ) { + GlassTextButton(backdrop, stringResource(R.string.first), onClick = { onPageChange(0) }, tint = tint) + GlassTextButton(backdrop, stringResource(R.string.previous), onClick = { onPageChange((currentPage - 1).coerceAtLeast(0)) }, tint = tint) + Text(text = "${currentPage + 1} / ${if (totalPages > 0) totalPages else 1}") + GlassTextButton(backdrop, stringResource(R.string.next), onClick = { onPageChange((currentPage + 1).coerceAtMost(totalPages - 1)) }, tint = tint) + GlassTextButton(backdrop, stringResource(R.string.last), onClick = { onPageChange(totalPages - 1) }, tint = tint) + } +} +``` + +> 说明:`R.string.first`/`R.string.previous`/`R.string.next`/`R.string.last` 不存在时请替换为实际资源或硬编码。 + +### Step 5: 编译检查 + +Run: `./gradlew :FCL:compileFordebugKotlin` +Expected: 无新增编译错误。 + +### Step 6: Commit + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassModListItem.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassSourceSelector.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassGameVersionFilter.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassPagination.kt +git commit -m "feat(glass-mod): add shared mod download components" +``` + +--- + +## Task 3: 创建通用远程模组列表页 + +**Files:** +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/RemoteModListPage.kt` + +**目标:** 实现搜索、来源切换、游戏版本过滤、分类/排序、分页的通用列表页。 + +### Step 1: RemoteModListPage.kt + +```kotlin +package com.tungsten.fcl.ui.glass.page.download + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.R +import com.tungsten.fcl.setting.DownloadProviders +import com.tungsten.fcl.ui.glass.FCLGlassDownloadRoute +import com.tungsten.fcl.ui.glass.component.GlassButton +import com.tungsten.fcl.ui.glass.component.GlassEmptyState +import com.tungsten.fcl.ui.glass.component.GlassGameVersionFilter +import com.tungsten.fcl.ui.glass.component.GlassModListItem +import com.tungsten.fcl.ui.glass.component.GlassPagination +import com.tungsten.fcl.ui.glass.component.GlassSearchBar +import com.tungsten.fcl.ui.glass.component.GlassSourceSelector +import com.tungsten.fcl.ui.glass.component.GlassTopBar +import com.tungsten.fcllibrary.component.theme.ThemeEngine +import com.tungsten.fclcore.mod.RemoteMod +import com.tungsten.fclcore.mod.RemoteModRepository +import com.tungsten.fclcore.task.Schedulers +import com.tungsten.fclcore.task.Task +import java.util.stream.Collectors + +@Composable +fun RemoteModListPage( + backdrop: Backdrop, + type: RemoteContentType, + initialGameVersion: String, + onNavigate: (FCLGlassDownloadRoute) -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val tintColor = Color(ThemeEngine.getInstance().getTheme().getColor()) + var query by remember { mutableStateOf("") } + var gameVersion by remember { mutableStateOf(initialGameVersion) } + var source by remember { mutableStateOf(type.defaultSource(context)) } + var sort by remember { mutableStateOf(type.sortType(source, context)) } + var currentPage by remember { mutableIntStateOf(0) } + var totalPages by remember { mutableIntStateOf(1) } + var isLoading by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + val results = remember { mutableListOf() } + + fun performSearch() { + isLoading = true + error = null + val repository = type.getRepository(source, context) + val downloadProvider = DownloadProviders.getDownloadProvider() + Task.supplyAsync { + val searchResult = repository.search( + downloadProvider, + gameVersion, + null, + currentPage, + 50, + query, + sort, + RemoteModRepository.SortOrder.DESC + ) + totalPages = searchResult.totalPages.coerceAtLeast(1) + searchResult.results.collect(Collectors.toList()) + }.whenComplete(Schedulers.androidUIThread()) { list, exception -> + isLoading = false + if (exception == null) { + results.clear() + results.addAll(list) + } else { + error = exception.message + } + }.start() + } + + Column(modifier = modifier.fillMaxSize()) { + GlassTopBar(title = stringResource(type.titleRes())) + + GlassSourceSelector( + backdrop = backdrop, + sources = type.supportedSources(context), + selected = source, + onSelect = { + source = it + sort = type.sortType(it, context) + currentPage = 0 + performSearch() + }, + tint = tintColor, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp) + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp) + ) { + GlassGameVersionFilter( + backdrop = backdrop, + gameVersion = gameVersion, + onGameVersionChange = { gameVersion = it }, + modifier = Modifier.weight(1f) + ) + GlassButton( + backdrop = backdrop, + onClick = { performSearch() }, + tint = tintColor + ) { + Text(text = stringResource(R.string.search), color = Color.White) + } + } + + GlassSearchBar( + backdrop = backdrop, + query = query, + onQueryChange = { query = it }, + hint = stringResource(R.string.search), + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp) + ) + + if (error != null) { + GlassEmptyState(text = error ?: stringResource(R.string.download_failed_empty), modifier = Modifier.weight(1f)) + } else if (results.isEmpty() && !isLoading) { + GlassEmptyState(text = stringResource(R.string.download_failed_empty), modifier = Modifier.weight(1f)) + } else { + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 20.dp, vertical = 8.dp) + ) { + items(results, key = { it.slug }) { mod -> + GlassModListItem( + backdrop = backdrop, + mod = mod, + onClick = { + onNavigate( + FCLGlassDownloadRoute.RemoteModInfo( + typeName = type.name, + modSlug = mod.slug, + modTitle = mod.title, + gameVersion = gameVersion + ) + ) + } + ) + } + } + } + + GlassPagination( + backdrop = backdrop, + currentPage = currentPage, + totalPages = totalPages, + onPageChange = { page -> + currentPage = page + performSearch() + }, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), + tint = tintColor + ) + } +} +``` + +> 说明: +> - `RemoteModRepository.search` 参数顺序和类型需与实际核对。 +> - `RemoteMod.slug`、`title`、`description`、`iconUrl` 字段需确认。 +> - `SearchResult.totalPages` 和 `results` 字段需确认。 +> - 如果 `RemoteMod` 没有稳定的 `slug`,改用 `modid` 作为 key。 + +### Step 2: 编译检查 + +Run: `./gradlew :FCL:compileFordebugKotlin` +Expected: 无新增编译错误。 + +### Step 3: Commit + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/RemoteModListPage.kt +git commit -m "feat(glass-mod): add generic remote mod list page" +``` + +--- + +## Task 4: 创建通用远程模组详情页 + +**Files:** +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/RemoteModInfoPage.kt` + +**目标:** 显示模组图标、标题、描述、分类,列出该模组支持的游戏版本。 + +### Step 1: RemoteModInfoPage.kt + +```kotlin +package com.tungsten.fcl.ui.glass.page.download + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi +import com.bumptech.glide.integration.compose.GlideImage +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.R +import com.tungsten.fcl.ui.glass.FCLGlassDownloadRoute +import com.tungsten.fcl.ui.glass.component.GlassCard +import com.tungsten.fcl.ui.glass.component.GlassEmptyState +import com.tungsten.fcl.ui.glass.component.GlassSearchBar +import com.tungsten.fcl.ui.glass.component.GlassTopBar +import com.tungsten.fclcore.mod.RemoteMod +import com.tungsten.fclcore.task.Schedulers +import com.tungsten.fclcore.task.Task +import com.tungsten.fclcore.util.SimpleMultimap +import com.tungsten.fclcore.util.versioning.VersionNumber +import java.util.Collections + +@OptIn(ExperimentalGlideComposeApi::class) +@Composable +fun RemoteModInfoPage( + backdrop: Backdrop, + type: RemoteContentType, + mod: RemoteMod, + gameVersion: String, + onNavigate: (FCLGlassDownloadRoute) -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + var isLoading by remember { mutableStateOf(true) } + var versions by remember { mutableStateOf>?>(null) } + var error by remember { mutableStateOf(null) } + var versionFilter by remember { mutableStateOf("") } + + LaunchedEffect(mod, type) { + isLoading = true + error = null + val repository = type.getRepository(type.defaultSource(context), context) + Task.supplyAsync { + val loaded = mod.getData().loadVersions(repository) + sortVersions(loaded) + }.whenComplete(Schedulers.androidUIThread()) { result, exception -> + isLoading = false + if (exception == null) { + versions = result + } else { + error = exception.message + } + }.start() + } + + val gameVersions = remember(versions, versionFilter) { + versions?.keys() + ?.filter { it.contains(versionFilter) } + ?.sortedWith(Collections.reverseOrder(VersionNumber::compare)) + ?: emptyList() + } + + Column(modifier = modifier.fillMaxSize()) { + GlassTopBar(title = mod.title) + + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 8.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + GlideImage( + model = mod.iconUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(72.dp) + ) + Column(modifier = Modifier.padding(start = 16.dp)) { + Text( + text = mod.title, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = mod.description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + } + } + + GlassSearchBar( + backdrop = backdrop, + query = versionFilter, + onQueryChange = { versionFilter = it }, + hint = stringResource(R.string.game_version), + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp) + ) + + if (error != null) { + GlassEmptyState(text = error ?: stringResource(R.string.download_failed_empty), modifier = Modifier.weight(1f)) + } else if (gameVersions.isEmpty() && !isLoading) { + GlassEmptyState(text = stringResource(R.string.download_failed_empty), modifier = Modifier.weight(1f)) + } else { + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 20.dp, vertical = 8.dp) + ) { + items(gameVersions) { version -> + GlassCard( + backdrop = backdrop, + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.clickable { + onNavigate( + FGLGlassDownloadRoute.RemoteModVersions( + typeName = type.name, + modSlug = mod.slug, + targetGameVersion = version + ) + ) + }.padding(16.dp)) { + Text( + text = version, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } + } + } + } +} + +private fun sortVersions(versions: Stream): SimpleMultimap> { + // Adapt from existing RemoteModInfoPage.java sortVersions logic + // Fallback: group by game version + val multimap = com.tungsten.fclcore.util.SimpleMultimap.create> { ArrayList() } + versions.forEach { version -> + version.gameVersions.forEach { gameVersion -> + multimap.putValue(gameVersion, version) + } + } + return multimap +} +``` + +> 说明: +> - `RemoteMod.getData().loadVersions(repository)` 返回 `Stream`,需确认。 +> - `RemoteMod.Version.gameVersions` 字段需确认。 +> - `SimpleMultimap` 泛型签名需确认。 +> - 需导入 `androidx.compose.foundation.clickable`。 + +### Step 2: 编译检查 + +Run: `./gradlew :FCL:compileFordebugKotlin` +Expected: 无新增编译错误。 + +### Step 3: Commit + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/RemoteModInfoPage.kt +git commit -m "feat(glass-mod): add generic remote mod info page" +``` + +--- + +## Task 5: 创建通用远程模组文件版本页 + +**Files:** +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/RemoteModVersionPage.kt` + +**目标:** 列出指定游戏版本下的文件版本,支持下载/安装。 + +### Step 1: RemoteModVersionPage.kt + +```kotlin +package com.tungsten.fcl.ui.glass.page.download + +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.R +import com.tungsten.fcl.setting.Profile +import com.tungsten.fcl.setting.Profiles +import com.tungsten.fcl.ui.TaskDialog +import com.tungsten.fcl.ui.glass.component.GlassButton +import com.tungsten.fcl.ui.glass.component.GlassCard +import com.tungsten.fcl.ui.glass.component.GlassEmptyState +import com.tungsten.fcl.ui.glass.component.GlassTopBar +import com.tungsten.fcllibrary.component.theme.ThemeEngine +import com.tungsten.fclcore.mod.RemoteMod +import com.tungsten.fclcore.task.Schedulers +import com.tungsten.fclcore.task.Task +import com.tungsten.fclcore.task.TaskExecutor +import java.io.File + +@Composable +fun RemoteModVersionPage( + backdrop: Backdrop, + type: RemoteContentType, + mod: RemoteMod, + targetGameVersion: String, + onBack: () -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val tintColor = Color(ThemeEngine.getInstance().getTheme().getColor()) + var isLoading by remember { mutableStateOf(true) } + var versions by remember { mutableStateOf>(emptyList()) } + var error by remember { mutableStateOf(null) } + + LaunchedEffect(mod, targetGameVersion, type) { + isLoading = true + error = null + val repository = type.getRepository(type.defaultSource(context), context) + Task.supplyAsync { + mod.getData().loadVersions(repository) + .filter { it.gameVersions.contains(targetGameVersion) } + .sorted() + .collect(Collectors.toList()) + }.whenComplete(Schedulers.androidUIThread()) { list, exception -> + isLoading = false + if (exception == null) { + versions = list + } else { + error = exception.message + } + }.start() + } + + Column(modifier = modifier.fillMaxSize()) { + GlassTopBar(title = mod.title + " - " + targetGameVersion) + + if (error != null) { + GlassEmptyState(text = error ?: stringResource(R.string.download_failed_empty), modifier = Modifier.weight(1f)) + } else if (versions.isEmpty() && !isLoading) { + GlassEmptyState(text = stringResource(R.string.download_failed_empty), modifier = Modifier.weight(1f)) + } else { + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 20.dp, vertical = 8.dp) + ) { + items(versions) { version -> + VersionFileCard( + backdrop = backdrop, + version = version, + tint = tintColor, + onDownload = { + downloadVersion(context, type, version) + } + ) + } + } + } + + GlassButton( + backdrop = backdrop, + onClick = onBack, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp) + ) { + Text(text = stringResource(R.string.dialog_negative), color = Color.White) + } + } +} + +@Composable +private fun VersionFileCard( + backdrop: Backdrop, + version: RemoteMod.Version, + tint: Color, + onDownload: () -> Unit +) { + GlassCard( + backdrop = backdrop, + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = version.name, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = version.versionType?.name ?: "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + GlassButton( + backdrop = backdrop, + onClick = onDownload, + tint = tint, + modifier = Modifier.padding(top = 8.dp) + ) { + Text(text = stringResource(R.string.download), color = Color.White) + } + } + } +} + +private fun downloadVersion(context: Context, type: RemoteContentType, version: RemoteMod.Version) { + val profile = Profiles.getSelectedProfile() + val selectedVersion = Profiles.getSelectedVersion() + val repository = type.getRepository(type.defaultSource(context), context) + val downloadProvider = com.tungsten.fcl.setting.DownloadProviders.getDownloadProvider() + Task.supplyAsync { + version.download(repository, downloadProvider, selectedVersion) + }.whenComplete(Schedulers.androidUIThread()) { file, exception -> + if (exception == null && file != null) { + type.installCallback(context).invoke(profile, selectedVersion, file) + } else { + // Show error toast + } + }.start() +} +``` + +> 说明: +> - `RemoteMod.Version.download(repository, downloadProvider, gameVersion)` 方法名和签名需与实际核对。 +> - `RemoteMod.Version.name`、`versionType` 字段需确认。 +> - 下载/安装逻辑参考现有 `RemoteModVersionPage.java` 和 `DownloadPage.java` 的 callback。 +> - `TaskDialog` 可在此阶段省略,直接回调安装。 + +### Step 2: 编译检查 + +Run: `./gradlew :FCL:compileFordebugKotlin` +Expected: 无新增编译错误。 + +### Step 3: Commit + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/RemoteModVersionPage.kt +git commit -m "feat(glass-mod): add generic remote mod version page" +``` + +--- + +## Task 6: 更新下载首页导航与绑定子路由 + +**Files:** +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/DownloadPage.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassDownloadNavHost.kt` + +**目标:** 分类入口跳转到真实子页,子路由绑定到通用页面。 + +### Step 1: 修改 DownloadPage.kt + +将四个分类的 `route = FCLGlassDownloadRoute.Home` 替换为真实路由: + +```kotlin + DownloadCategory( + title = stringResource(R.string.mods), + iconRes = R.drawable.ic_outline_extension_24, + route = FCLGlassDownloadRoute.ModDownload() + ), + DownloadCategory( + title = stringResource(R.string.modpack), + iconRes = R.drawable.ic_baseline_application_24, + route = FCLGlassDownloadRoute.ModpackDownload() + ), + DownloadCategory( + title = stringResource(R.string.resourcepack), + iconRes = R.drawable.ic_baseline_texture_24, + route = FCLGlassDownloadRoute.ResourcePackDownload() + ), + DownloadCategory( + title = stringResource(R.string.shaderpack), + iconRes = R.drawable.ic_baseline_tune_24, + route = FCLGlassDownloadRoute.ShaderPackDownload() + ) +``` + +### Step 2: 修改 GlassDownloadNavHost.kt + +追加以下 composable 块: + +```kotlin + composable { entry -> + val route = entry.toRoute() + RemoteModListPage( + backdrop = backdrop, + type = RemoteContentType.MOD, + initialGameVersion = route.gameVersion, + onNavigate = { navController.navigate(it) } + ) + } + composable { entry -> + val route = entry.toRoute() + RemoteModListPage( + backdrop = backdrop, + type = RemoteContentType.MODPACK, + initialGameVersion = route.gameVersion, + onNavigate = { navController.navigate(it) } + ) + } + composable { entry -> + val route = entry.toRoute() + RemoteModListPage( + backdrop = backdrop, + type = RemoteContentType.RESOURCE_PACK, + initialGameVersion = route.gameVersion, + onNavigate = { navController.navigate(it) } + ) + } + composable { entry -> + val route = entry.toRoute() + RemoteModListPage( + backdrop = backdrop, + type = RemoteContentType.SHADER_PACK, + initialGameVersion = route.gameVersion, + onNavigate = { navController.navigate(it) } + ) + } + composable { entry -> + val info = entry.toRoute() + // TODO: load RemoteMod by slug/title from repository; for now pass stub + val repository = RemoteContentType.valueOf(info.typeName).getRepository( + RemoteContentType.valueOf(info.typeName).defaultSource(LocalContext.current), + LocalContext.current + ) + // Simplified: re-search to obtain RemoteMod object + RemoteModInfoPage( + backdrop = backdrop, + type = RemoteContentType.valueOf(info.typeName), + mod = TODO("Need to pass RemoteMod object"), + gameVersion = info.gameVersion, + onNavigate = { navController.navigate(it) } + ) + } + composable { entry -> + val versions = entry.toRoute() + RemoteModVersionPage( + backdrop = backdrop, + type = RemoteContentType.valueOf(versions.typeName), + mod = TODO("Need to pass RemoteMod object"), + targetGameVersion = versions.targetGameVersion, + onBack = { navController.popBackStack() } + ) + } +``` + +### 关键问题:RemoteMod 对象跨路由传递 + +`RemoteMod` 不可序列化,无法直接作为导航参数。解决方案: +- 在导航时只传递 slug/title/type/source,进入详情页后重新搜索一次获取 `RemoteMod` 对象。 +- 或者使用共享内存缓存(类似 `VersionInstallState`)。 + +本阶段采用**重新搜索**方案:详情页根据 slug 在 repository 中搜索并匹配。如果 API 不支持按 slug 精确搜索,则显示搜索关键词为 slug 的结果列表,让用户重新选择。 + +### Step 3: 编译检查 + +Run: `./gradlew :FCL:compileFordebugKotlin` +Expected: 无新增编译错误。 + +### Step 4: Commit + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/page/DownloadPage.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassDownloadNavHost.kt +git commit -m "feat(glass-mod): wire mod download routes from home and nav host" +``` + +--- + +## Task 7: 处理 RemoteMod 对象传递 + +**Files:** +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/RemoteModCache.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/RemoteModListPage.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/RemoteModInfoPage.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/RemoteModVersionPage.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassDownloadNavHost.kt` + +**目标:** 避免跨路由重新搜索,用内存缓存传递 `RemoteMod` 对象。 + +### Step 1: RemoteModCache.kt + +```kotlin +package com.tungsten.fcl.ui.glass.page.download + +import com.tungsten.fclcore.mod.RemoteMod + +object RemoteModCache { + private val cache = mutableMapOf() + + fun put(key: String, mod: RemoteMod) { + cache[key] = mod + } + + fun get(key: String): RemoteMod? { + return cache[key] + } + + fun clear() { + cache.clear() + } +} +``` + +### Step 2: 修改 RemoteModListPage.kt + +导航前写入缓存: + +```kotlin +val cacheKey = "${type.name}_${mod.slug}" +RemoteModCache.put(cacheKey, mod) +onNavigate( + FCLGlassDownloadRoute.RemoteModInfo( + typeName = type.name, + modSlug = mod.slug, + modTitle = mod.title, + gameVersion = gameVersion, + cacheKey = cacheKey + ) +) +``` + +### Step 3: 修改 FCLGlassDownloadRoute.kt + +在 `RemoteModInfo` 和 `RemoteModVersions` 中追加 `cacheKey: String` 字段。 + +### Step 4: 修改 GlassDownloadNavHost.kt + +从缓存读取 `RemoteMod`: + +```kotlin +composable { entry -> + val info = entry.toRoute() + val mod = RemoteModCache.get(info.cacheKey) + if (mod != null) { + RemoteModInfoPage(...) + } else { + // Show error or re-search placeholder + } +} +``` + +### Step 5: 修改 RemoteModInfoPage.kt 和 RemoteModVersionPage.kt + +改为接收 `RemoteMod` 参数(已经如此)。 + +### Step 6: Commit + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/RemoteModCache.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/RemoteModListPage.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/RemoteModInfoPage.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/RemoteModVersionPage.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLGlassDownloadRoute.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassDownloadNavHost.kt +git commit -m "feat(glass-mod): cache remote mod objects across navigation" +``` + +--- + +## Task 8: 最终编译与清理 + +**Files:** +- 全部阶段4新增/修改文件。 + +**目标:** 确认代码在本地可编译,清理未使用 import。 + +- [ ] **Step 1: 运行 Kotlin 编译** + +Run: `./gradlew :FCL:compileFordebugKotlin` +Expected: BUILD SUCCESSFUL(本地环境)。 + +- [ ] **Step 2: 提交所有最终调整** + +```bash +git add -A +git commit -m "feat(glass-mod): complete phase4 mod/modpack/resource/shader downloads" +``` + +--- + +## Self-Review + +### 1. Spec coverage + +| 设计文档阶段3剩余要求 | 本计划覆盖 | +|----------------------|-----------| +| 模组下载页 | Task 3-7 | +| 整合包下载页 | Task 3-7 | +| 资源包下载页 | Task 3-7 | +| 光影下载页 | Task 3-7 | + +### 2. Placeholder scan + +- 无 "TBD"。 +- `RemoteModCache` 是简单内存缓存,已明确说明。 +- 截图轮播、翻译对话框、mcmod 跳转不在本阶段范围。 + +### 3. Type consistency + +- `RemoteContentType` 与路由命名一致。 +- `RemoteModCache` key 格式与 `RemoteModListPage` 写入格式一致。 + +--- + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-06-19-fcl-liquid-glass-phase4-mod-downloads.md`. + +**Two execution options:** + +1. **Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. +2. **Inline Execution** — Execute tasks in this session using inline edits. + +**Which approach?** diff --git a/docs/superpowers/plans/2026-06-19-fcl-liquid-glass-phase4-settings-java.md b/docs/superpowers/plans/2026-06-19-fcl-liquid-glass-phase4-settings-java.md new file mode 100644 index 0000000000..f11cc2f122 --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-fcl-liquid-glass-phase4-settings-java.md @@ -0,0 +1,374 @@ +# FCL 液态玻璃 UI 迁移 - 阶段 4:设置与 Java + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将启动器设置、Java 运行时管理、JVM 参数管理等传统 View 页面迁移为液态玻璃 Compose UI,并接入设置导航。 + +**Architecture:** 在 `FCLGlassRoute` 中新增设置子路由;复用阶段 1 的 `GlassDialogManager` 处理确认/输入弹窗;复用阶段 2 的 `VersionSettingsPage` 中已有的设置项作为基础。业务逻辑继续复用 FCLCore 的 `ConfigHolder`、`JavaManager`、`GameJavaVersion` 等 API。 + +**Tech Stack:** Kotlin, Jetpack Compose, Compose Navigation, Backdrop, FCLCore. + +--- + +## File Structure + +| File | Responsibility | +| --- | --- | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLGlassRoute.kt` | 新增设置路由:LauncherSettings、JavaRuntime、JvmArgs、QuickInput | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt` | 注册新路由并映射到对应 Page | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/launcher/LauncherSettingPage.kt` | 启动器全局设置页 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/launcher/LauncherSettingStateHolder.kt` | 管理启动器设置状态 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/java/JavaRuntimePage.kt` | Java 运行时列表与管理 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/java/JavaRuntimeStateHolder.kt` | 管理 Java 运行时状态 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/java/JvmArgsPage.kt` | JVM 参数管理页 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassQuickInputDialog.kt` | 通用快速输入对话框(替代 `QuickInputDialog`) | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/SettingsPage.kt` | 添加启动器设置、Java 管理、JVM 参数入口 | + +--- + +## Task 1: Route Infrastructure + +**Files:** +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLGlassRoute.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt` + +- [ ] **Step 1: Add routes to FCLGlassRoute** + +In `FCLGlassRoute.kt`, add: + +```kotlin +@Serializable +data object LauncherSettings : FCLGlassRoute() + +@Serializable +data object JavaRuntime : FCLGlassRoute() + +@Serializable +data object JvmArgs : FCLGlassRoute() + +@Serializable +data class QuickInput( + val title: String, + val initialValue: String, + val hint: String +) : FCLGlassRoute() +``` + +- [ ] **Step 2: Register routes in GlassNavHost** + +Add `composable` blocks for each new route. For now use `GlassPlaceholderPage`. + +- [ ] **Step 3: Commit** + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLGlassRoute.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt +git commit -m "feat(glass): add settings and java routes" +``` + +--- + +## Task 2: LauncherSettingPage + +**Files:** +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/launcher/LauncherSettingPage.kt` +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/launcher/LauncherSettingStateHolder.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/SettingsPage.kt` + +- [ ] **Step 1: Study old LauncherSettingPage** + +Read `FCL/src/main/java/com/tungsten/fcl/ui/setting/LauncherSettingPage.java` and `SettingUI.java`. Identify settings categories: +- Language +- Theme / color +- Download source / concurrency +- Update channel +- Launcher behavior (kill game on exit, etc.) + +- [ ] **Step 2: Create LauncherSettingStateHolder** + +Create `LauncherSettingStateHolder.kt`: + +```kotlin +class LauncherSettingStateHolder { + val config = ConfigHolder.config() + var language by mutableStateOf(config.localization) + var theme by mutableStateOf(config.theme) + var downloadSource by mutableStateOf(config.downloadType) + var autoDownloadThreads by mutableStateOf(config.autoDownloadThreads) + // ... other fields + + fun save() { + config.localization = language + config.theme = theme + config.downloadType = downloadSource + ConfigHolder.save() + } +} +``` + +Use actual config fields from `Config` class. + +- [ ] **Step 3: Create LauncherSettingPage UI** + +Create `LauncherSettingPage.kt`: + +```kotlin +@Composable +fun LauncherSettingPage( + backdrop: Backdrop, + onBack: () -> Unit +) { + val stateHolder = remember { LauncherSettingStateHolder() } + Column(modifier = Modifier.fillMaxSize()) { + GlassTopBar(title = "Launcher Settings", navigationIcon = { GlassBackButton(onBack) }) + LazyColumn(modifier = Modifier.weight(1f).padding(20.dp)) { + item { LanguageSection(stateHolder) } + item { ThemeSection(stateHolder) } + item { DownloadSection(stateHolder) } + item { BehaviorSection(stateHolder) } + } + GlassButton( + backdrop = backdrop, + onClick = { stateHolder.save(); onBack() }, + modifier = Modifier.padding(20.dp).fillMaxWidth() + ) { + Text(text = "Save", color = Color.White) + } + } +} +``` + +- [ ] **Step 4: Update GlassNavHost and SettingsPage** + +Replace `LauncherSettings` placeholder with real page. +Add a card in `SettingsPage` to navigate to `FCLGlassRoute.LauncherSettings`. + +- [ ] **Step 5: Commit** + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/page/launcher/ \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/page/SettingsPage.kt +git commit -m "feat(glass): add LauncherSettingPage" +``` + +--- + +## Task 3: JavaRuntimePage + +**Files:** +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/java/JavaRuntimePage.kt` +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/java/JavaRuntimeStateHolder.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/SettingsPage.kt` + +- [ ] **Step 1: Study old ManageJavaDialog / RuntimeFragment** + +Read `FCL/src/main/java/com/tungsten/fcl/dialog/ManageJavaDialog.java` and `FCL/src/main/java/com/tungsten/fcl/fragment/RuntimeFragment.kt`. Identify: +- Java runtime list +- Add Java runtime +- Remove Java runtime +- Auto download Java + +- [ ] **Step 2: Create JavaRuntimeStateHolder** + +Create `JavaRuntimeStateHolder.kt`: + +```kotlin +class JavaRuntimeStateHolder { + val javaRuntimes = mutableStateListOf() + var isLoading by mutableStateOf(false) + + fun load() { /* read JavaManager */ } + fun addJava(context: Context, path: String) { } + fun removeJava(runtime: JavaRuntimeItem) { } + fun downloadJava(context: Context, version: Int) { } +} +``` + +Define `JavaRuntimeItem` with name, version, path, architecture. + +- [ ] **Step 3: Create JavaRuntimePage UI** + +Create `JavaRuntimePage.kt`: + +```kotlin +@Composable +fun JavaRuntimePage( + backdrop: Backdrop, + onBack: () -> Unit +) { + val stateHolder = remember { JavaRuntimeStateHolder() } + val context = LocalContext.current + DisposableEffect(Unit) { stateHolder.load(); onDispose {} } + Column(modifier = Modifier.fillMaxSize()) { + GlassTopBar(title = "Java Runtime", navigationIcon = { GlassBackButton(onBack) }) + LazyColumn(modifier = Modifier.weight(1f).padding(20.dp)) { + items(stateHolder.javaRuntimes, key = { it.path }) { runtime -> + GlassJavaRuntimeItem( + backdrop = backdrop, + runtime = runtime, + onRemove = { stateHolder.removeJava(runtime) } + ) + } + } + Row(modifier = Modifier.padding(20.dp)) { + GlassButton(onClick = { /* file picker or input dialog */ }) { Text("Add") } + GlassButton(onClick = { /* show download options */ }) { Text("Download") } + } + } +} +``` + +- [ ] **Step 4: Update GlassNavHost and SettingsPage** + +Replace `JavaRuntime` placeholder. Add a card in `SettingsPage`. + +- [ ] **Step 5: Commit** + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/page/java/ \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/page/SettingsPage.kt +git commit -m "feat(glass): add JavaRuntimePage" +``` + +--- + +## Task 4: JvmArgsPage + +**Files:** +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/java/JvmArgsPage.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/SettingsPage.kt` + +- [ ] **Step 1: Create JvmArgsPage** + +A simple page to edit global default JVM arguments. Store in `Config`. + +```kotlin +@Composable +fun JvmArgsPage(backdrop: Backdrop, onBack: () -> Unit) { + val config = remember { ConfigHolder.config() } + var args by remember { mutableStateOf(config.jvmArgs ?: "") } + Column(modifier = Modifier.fillMaxSize()) { + GlassTopBar(title = "JVM Arguments", navigationIcon = { GlassBackButton(onBack) }) + GlassTextField( + value = args, + onValueChange = { args = it }, + label = "JVM Args", + modifier = Modifier.padding(20.dp).fillMaxWidth().weight(1f) + ) + GlassButton( + backdrop = backdrop, + onClick = { config.jvmArgs = args; ConfigHolder.save(); onBack() }, + modifier = Modifier.padding(20.dp).fillMaxWidth() + ) { Text("Save", color = Color.White) } + } +} +``` + +- [ ] **Step 2: Update GlassNavHost and SettingsPage** + +Replace placeholder. Add SettingsPage entry. + +- [ ] **Step 3: Commit** + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/page/java/JvmArgsPage.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/page/SettingsPage.kt +git commit -m "feat(glass): add JvmArgsPage" +``` + +--- + +## Task 5: GlassQuickInputDialog + +**Files:** +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassQuickInputDialog.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDialogManager.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDialogHost.kt` + +- [ ] **Step 1: Add DialogRequest.QuickInput** + +In `GlassDialogManager.kt`: + +```kotlin +data class QuickInput( + val title: String, + val initialValue: String, + val hint: String, + val onConfirm: (String) -> Unit, + val onDismiss: () -> Unit = {} +) : DialogRequest() + +fun showQuickInput( + title: String, + initialValue: String, + hint: String, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit = {} +) = show(DialogRequest.QuickInput(title, initialValue, hint, onConfirm, onDismiss)) +``` + +- [ ] **Step 2: Create GlassQuickInputDialog UI** + +Create `GlassQuickInputDialog.kt`: + +```kotlin +@Composable +fun GlassQuickInputDialog(backdrop: Backdrop, request: DialogRequest.QuickInput) { + var text by remember { mutableStateOf(request.initialValue) } + GlassDialogSurface(backdrop = backdrop) { + Column(modifier = Modifier.padding(20.dp)) { + Text(request.title, style = MaterialTheme.typography.titleLarge) + GlassTextField(value = text, onValueChange = { text = it }, label = request.hint) + Row { + GlassTextButton(onClick = { request.onDismiss(); dismiss() }) { Text("Cancel") } + GlassButton(onClick = { request.onConfirm(text); dismiss() }) { Text("OK") } + } + } + } +} +``` + +- [ ] **Step 3: Wire in GlassDialogHost** + +Add a branch for `DialogRequest.QuickInput`. + +- [ ] **Step 4: Commit** + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassQuickInputDialog.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDialogManager.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDialogHost.kt +git commit -m "feat(glass): add GlassQuickInputDialog" +``` + +--- + +## Task 6: Final Compile and Cleanup + +- [ ] **Step 1: Full compile** + +Run: `JAVA_HOME=/root/.local/share/mise/installs/java/17.0.2 gradle --no-daemon :FCL:compileFordebugKotlin` + +- [ ] **Step 2: Remove unused imports** + +- [ ] **Step 3: Commit** + +```bash +git commit -m "chore(glass): phase 4 settings and java compile cleanup" +``` + +--- + +## Spec Coverage Check + +- Launcher settings: Task 2 +- Java runtime: Task 3 +- JVM args: Task 4 +- Quick input dialog: Task 5 +- Cleanup: Task 6 diff --git a/docs/superpowers/plans/2026-06-19-fcl-liquid-glass-phase5-polish.md b/docs/superpowers/plans/2026-06-19-fcl-liquid-glass-phase5-polish.md new file mode 100644 index 0000000000..35fd4accbf --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-fcl-liquid-glass-phase5-polish.md @@ -0,0 +1,178 @@ +# FCL 液态玻璃 UI 阶段5 — 下载体验完善与问题修复 + +**Goal:** 修复阶段4遗留问题,提升模组/整合包/资源包/光影下载流程的可用性。 + +**Scope:** +1. 修复 MODPACK 安装回调中 `FCLUILayout` parent 参数问题 +2. 为搜索、版本加载、下载失败添加用户可感知的错误提示 +3. 为下载/安装过程添加 `TaskDialog` 进度展示 +4. 在文件版本页展示依赖项列表 +5. 清理新增 Kotlin 文件中的未使用 import 与 lint 警告 + +--- + +## Task 1: 修复 MODPACK 安装回调 parent 参数 + +**Files:** +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/RemoteContentType.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/download/RemoteModVersionPage.kt` + +**目标:** 让整合包安装能够拿到当前 Activity/Layout 作为 parent,与原 `Versions.downloadModpackImpl` 调用一致。 + +### Step 1: 确认 `Versions.downloadModpackImpl` 签名 + +Read `com.tungsten.fcl.ui.version.Versions.java` to confirm signature. Expected: +```java +public static void downloadModpackImpl(Context context, FCLUILayout parent, Profile profile, RemoteMod.Version version) +``` + +### Step 2: 修改 RemoteContentType.kt + +Change `installCallback` signature to accept a nullable `FCLUILayout?` parent: + +```kotlin +fun installCallback(context: Context, parent: FCLUILayout?): (Profile, String, RemoteMod.Version) -> Unit { + return { profile, version, remoteVersion -> + when (this) { + MOD -> { /* ... */ } + MODPACK -> com.tungsten.fcl.ui.version.Versions.downloadModpackImpl(context, parent, profile, remoteVersion) + // ... + } + } +} +``` + +### Step 3: 修改 RemoteModVersionPage.kt + +Pass the parent from current Activity: + +```kotlin +val parent = context as? FCLUILayout +installCallback(context, type, parent, profile, selectedVersion, version) +``` + +Or pass parent through `RemoteContentType.installCallback`. + +### Step 4: Commit + +```bash +git commit -m "fix(glass-mod): pass FCLUILayout parent to modpack install" +``` + +--- + +## Task 2: 添加下载错误提示 + +**Files:** +- Modify: `RemoteModListPage.kt` +- Modify: `RemoteModInfoPage.kt` +- Modify: `RemoteModVersionPage.kt` + +**目标:** 搜索失败、版本加载失败、下载失败时给用户 Toast 提示。 + +### Implementation + +Use `android.widget.Toast.makeText(context, message, Toast.LENGTH_SHORT).show()` in the failure branches. + +Add helper: + +```kotlin +private fun showToast(context: Context, message: String?) { + if (!message.isNullOrBlank()) { + android.widget.Toast.makeText(context, message, android.widget.Toast.LENGTH_SHORT).show() + } +} +``` + +Replace error state-only handling with toast + empty state. + +### Commit + +```bash +git commit -m "feat(glass-mod): add error toasts for mod download flow" +``` + +--- + +## Task 3: 添加下载进度 TaskDialog + +**Files:** +- Modify: `RemoteModVersionPage.kt` + +**目标:** 下载和安装整合包/模组时显示进度对话框。 + +### Implementation + +Wrap download task in `TaskDialog` similar to existing code: + +```kotlin +val task = Task.supplyAsync { version.download(repository, downloadProvider, selectedVersion) } +val dialog = TaskDialog(context, task, TaskCancellationAction { task.cancel() }) +dialog.show() +TaskExecutor.createExecutor("download_mod", task).start() +``` + +Adjust based on actual `TaskDialog` and `TaskExecutor` APIs. + +### Commit + +```bash +git commit -m "feat(glass-mod): show TaskDialog during mod download" +``` + +--- + +## Task 4: 添加依赖项展示弹窗 + +**Files:** +- Modify: `RemoteModVersionPage.kt` + +**目标:** 在下载前展示版本依赖列表。 + +### Implementation + +When a version is selected, load dependencies via `version.dependencies` (confirm API), then show a simple glass-style alert dialog listing dependencies. + +If API not available, skip this task and document it. + +### Commit + +```bash +git commit -m "feat(glass-mod): show dependency list before download" +``` + +--- + +## Task 5: 清理未使用 import 与 lint 警告 + +**Files:** +- All new Kotlin files in `ui/glass/page/download/` and `ui/glass/component/` + +**目标:** 删除未使用的 import,减少 lint 噪音。 + +Run lint or manually inspect each file. Common unused imports: +- `java.util.Collections` +- `androidx.compose.foundation.layout.fillMaxWidth` (where not used) +- `com.tungsten.fcl.game.FCLGameRepository` (in RemoteContentType if refactored) + +### Commit + +```bash +git commit -m "chore(glass): clean unused imports" +``` + +--- + +## Task 6: 最终验证 + +Run: `./gradlew :FCL:compileFordebugKotlin` +Expected: 本地环境编译成功。 + +--- + +## Execution Options + +1. **Subagent-Driven (recommended)** — dispatch one subagent per task +2. **Inline Execution** — execute in this session + +**Which approach?** diff --git a/docs/superpowers/plans/2026-06-19-fcl-liquid-glass-phase6-account-version.md b/docs/superpowers/plans/2026-06-19-fcl-liquid-glass-phase6-account-version.md new file mode 100644 index 0000000000..f1411724ab --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-fcl-liquid-glass-phase6-account-version.md @@ -0,0 +1,590 @@ +# FCL 液态玻璃 UI 阶段6 — 账号页与版本管理页重写 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use subagent-driven-development workflow (general_purpose_task subagents) to implement this plan task-by-task. + +**Goal:** 把底部导航栏的 **Manage** 页拆分为账号页与版本管理页(或保留 Manage 作为二级入口),并将这两个核心页面重写成液态玻璃 Compose 风格。 + +**Architecture Decision:** 当前底部导航是 Home/Versions/Download/Manage/Settings。阶段6把: +- **Manage** 改为 **Account**(账号页),因为版本管理已有独立的 **Versions** 入口 +- **Versions** 页已部分实现,本阶段继续完善其操作菜单(重命名/删除/复制/设置/启动) + +如果用户希望 Manage 同时包含账号+版本,可在 Account 页加版本管理快捷入口,但本计划优先把 Account 作为独立一级导航。 + +--- + +## 范围说明 + +- **账号页**:列出所有账户、显示头像/角色名/类型、选择账户、添加离线/微软/外置账户、删除/刷新账户 +- **版本管理页完善**:在已有 `VersionsPage` 基础上增加长按/点击菜单:启动、重命名、删除、复制、设置、设为当前 + +暂不实现在线登录弹窗、皮肤上传、版本设置页详细表单(这些作为阶段7)。 + +--- + +## 文件结构 + +| 文件 | 职责 | +|------|------| +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/AccountPage.kt` | 账号列表页 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/account/AccountListStateHolder.kt` | 账号页状态管理 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassAccountItem.kt` | 账号列表项 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/versions/VersionActions.kt` | 版本操作封装 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassVersionItem.kt` | 版本列表项(如已存在则扩展) | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLGlassRoute.kt` | 把 Manage 路由改为 Account | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassBottomBar.kt` | 更新图标/标签 | +| `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt` | 绑定 Account 页 | + +--- + +## Task 1: 探索并确认账号/版本 API + +**Files to read:** +- `com.tungsten.fcl.setting.Accounts` +- `com.tungsten.fclcore.auth.Account` +- `com.tungsten.fcl.ui.account.AccountListItem` +- `com.tungsten.fcl.game.TexturesLoader` +- `com.tungsten.fcl.ui.version.VersionListItem` +- `com.tungsten.fcl.ui.version.Versions` +- `com.tungsten.fcl.ui.glass.page.VersionsPage` +- `com.tungsten.fcl.ui.glass.page.versions.VersionListStateHolder` + +**目标:** 确认方法签名、状态监听方式、可用图标/头像加载方式。 + +### Step 1: Account API + +Confirm: +- `Accounts.getAccounts()` returns `ObservableList` +- `Accounts.getSelectedAccount()` / `setSelectedAccount()` +- `Account.getUsername()`, `getCharacter()`, `getUUID()`, `getTextures()` +- `Accounts.getLocalizedLoginTypeName(context, factory)` exists +- `TexturesLoader.avatarBinding(account, size)` or equivalent returns avatar bitmap +- `CreateAccountDialog(context, factory).show()` for adding accounts +- `AccountListItem.logIn(account)` or `account.clearCache()` + `account.logIn()` for refresh + +### Step 2: Version API + +Confirm: +- `Profiles.getSelectedProfile()` / `Profiles.getSelectedVersion()` +- `profile.repository.getDisplayVersions()` +- `Versions.launch(context, profile, version)` +- `Versions.renameVersion(context, profile, version)` +- `Versions.deleteVersion(context, profile, version)` +- `Versions.duplicateVersion(context, profile, version)` +- `VersionListItem` constructor or factory + +### Step 3: Commit + +If only reading, no commit. Move to Task 2. + +--- + +## Task 2: 创建账号页状态与列表项 + +**Files:** +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/account/AccountListStateHolder.kt` +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassAccountItem.kt` + +### Step 1: AccountListStateHolder.kt + +```kotlin +package com.tungsten.fcl.ui.glass.page.account + +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.tungsten.fcl.setting.Accounts +import com.tungsten.fclcore.auth.Account +import com.tungsten.fclcore.fakefx.collections.ListChangeListener + +class AccountListStateHolder { + val accounts = mutableStateListOf() + var selectedAccount by mutableStateOf(Accounts.getSelectedAccount()) + private var listener: ListChangeListener? = null + private var selectedListener: javafx.beans.value.ChangeListener? = null + + fun startObserving() { + accounts.clear() + accounts.addAll(Accounts.getAccounts()) + selectedAccount = Accounts.getSelectedAccount() + + listener = ListChangeListener { change -> + while (change.next()) { + if (change.wasAdded()) { + accounts.addAll(change.addedSubList) + } + if (change.wasRemoved()) { + accounts.removeAll(change.removed.toSet()) + } + } + } + selectedListener = javafx.beans.value.ChangeListener { _, _, new -> + selectedAccount = new + } + + Accounts.getAccounts().addListener(listener) + Accounts.selectedAccountProperty().addListener(selectedListener) + } + + fun stopObserving() { + listener?.let { Accounts.getAccounts().removeListener(it) } + selectedListener?.let { Accounts.selectedAccountProperty().removeListener(it) } + } + + fun select(account: Account) { + Accounts.setSelectedAccount(account) + } + + fun remove(account: Account) { + Accounts.getAccounts().remove(account) + } +} +``` + +> 注意:fakefx import 路径可能是 `com.tungsten.fclcore.fakefx.collections.ListChangeListener` 和 `com.tungsten.fclcore.fakefx.beans.value.ChangeListener`。请按实际路径调整。 + +### Step 2: GlassAccountItem.kt + +```kotlin +package com.tungsten.fcl.ui.glass.component + +import android.graphics.drawable.Drawable +import android.os.Build +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.R +import com.tungsten.fcl.game.TexturesLoader +import com.tungsten.fcl.setting.Accounts +import com.tungsten.fcl.ui.glass.theme.GlassTheme +import com.tungsten.fclcore.auth.Account + +@Composable +fun GlassAccountItem( + backdrop: Backdrop, + account: Account, + selected: Boolean, + onSelect: () -> Unit, + onRefresh: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val shape = RoundedCornerShape(GlassTheme.glassCornerRadius) + var avatar by remember { mutableStateOf(null) } + + DisposableEffect(account) { + val binding = TexturesLoader.avatarBinding(account, 64) + val listener = javafx.beans.value.ChangeListener { _, _, new -> + avatar = (new as? android.graphics.drawable.BitmapDrawable)?.bitmap + } + binding.addListener(listener) + binding.get()?.let { listener.changed(binding, null, it) } + + onDispose { + binding.removeListener(listener) + } + } + + val typeName = remember(account) { + Accounts.getLocalizedLoginTypeName(context, Accounts.getAccountFactory(account)) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onSelect) + .drawBackdrop( + backdrop = backdrop, + shape = { shape }, + effects = { + vibrancy() + blur(8f.dp.toPx()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + lens(12f.dp.toPx(), 24f.dp.toPx()) + } + }, + onDrawSurface = { drawRect(GlassTheme.surfaceColor()) } + ) + .padding(16.dp) + ) { + RadioButton(selected = selected, onClick = onSelect) + + avatar?.let { bitmap -> + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = null, + modifier = Modifier + .size(48.dp) + .padding(start = 8.dp) + ) + } + + Column( + modifier = Modifier + .weight(1f) + .padding(start = 12.dp) + ) { + Text( + text = account.character, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = typeName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + + GlassIconButton(backdrop, R.drawable.ic_baseline_refresh_24, onRefresh) + GlassIconButton(backdrop, R.drawable.ic_baseline_delete_24, onDelete) + } +} +``` + +> 说明: +> - `GlassIconButton` 如果不存在,用 `GlassButton` 或普通 Icon + clickable 替代。 +> - `TexturesLoader.avatarBinding` 返回类型可能是 `ObjectBinding` 或类似;请按实际调整。 +> - `Accounts.getAccountFactory(account)` 需要确认存在。 +> - `account.character` 可能是方法 `getCharacter()`,Kotlin 中作为属性访问。 + +### Step 3: 编译检查 + +Run: `./gradlew :FCL:compileFordebugKotlin` + +### Step 4: Commit + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/page/account/AccountListStateHolder.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassAccountItem.kt +git commit -m "feat(glass-account): add account state holder and list item" +``` + +--- + +## Task 3: 创建账号页 + +**Files:** +- Create: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/AccountPage.kt` + +### Step 1: AccountPage.kt + +```kotlin +package com.tungsten.fcl.ui.glass.page + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.R +import com.tungsten.fcl.setting.Accounts +import com.tungsten.fcl.ui.account.CreateAccountDialog +import com.tungsten.fcl.ui.glass.component.GlassAccountItem +import com.tungsten.fcl.ui.glass.component.GlassButton +import com.tungsten.fcl.ui.glass.component.GlassTopBar +import com.tungsten.fcl.ui.glass.page.account.AccountListStateHolder +import com.tungsten.fcllibrary.component.theme.ThemeEngine +import com.tungsten.fclcore.task.Schedulers + +@Composable +fun AccountPage( + backdrop: Backdrop, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val tintColor = Color(ThemeEngine.getInstance().getTheme().getColor()) + val stateHolder = remember { AccountListStateHolder() } + + DisposableEffect(Unit) { + stateHolder.startObserving() + onDispose { stateHolder.stopObserving() } + } + + Column(modifier = modifier.fillMaxSize()) { + GlassTopBar(title = stringResource(R.string.account)) + + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 20.dp, vertical = 8.dp) + ) { + items(stateHolder.accounts, key = { it.identifier }) { account -> + GlassAccountItem( + backdrop = backdrop, + account = account, + selected = stateHolder.selectedAccount == account, + onSelect = { stateHolder.select(account) }, + onRefresh = { + refreshAccount(context, account) + }, + onDelete = { + stateHolder.remove(account) + } + ) + } + } + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp) + ) { + GlassButton( + backdrop = backdrop, + onClick = { CreateAccountDialog(context, Accounts.FACTORY_OFFLINE).show() }, + tint = tintColor, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(R.string.account_create_offline), color = Color.White) + } + GlassButton( + backdrop = backdrop, + onClick = { CreateAccountDialog(context, Accounts.FACTORY_MICROSOFT).show() }, + tint = tintColor, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(R.string.account_create_microsoft), color = Color.White) + } + } + } +} + +private fun refreshAccount(context: android.content.Context, account: com.tungsten.fclcore.auth.Account) { + com.tungsten.fclcore.task.Task.runAsync { + account.clearCache() + account.logIn() + }.whenComplete(Schedulers.androidUIThread()) { _, exception -> + if (exception != null) { + // If credential expired, show login dialog + if (exception is com.tungsten.fclcore.auth.CredentialExpiredException) { + com.tungsten.fcl.ui.account.AccountListItem.logIn(account) + } else { + android.widget.Toast.makeText(context, exception.message, android.widget.Toast.LENGTH_SHORT).show() + } + } + }.start() +} +``` + +> 说明: +> - `Accounts.FACTORY_OFFLINE` / `FACTORY_MICROSOFT` 需要确认存在。 +> - `AccountListItem.logIn(account)` 如果是实例方法,需要创建实例或找到静态方式。 +> - 外置登录按钮可后续添加。 +> - `account.identifier` 需要确认存在或用 `account.uuid.toString()` 作为 key。 + +### Step 2: 编译检查 + +Run: `./gradlew :FCL:compileFordebugKotlin` + +### Step 3: Commit + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/page/AccountPage.kt +git commit -m "feat(glass-account): add account page" +``` + +--- + +## Task 4: 把 Manage 导航改为 Account + +**Files:** +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLGlassRoute.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassBottomBar.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt` + +### Step 1: FCLGlassRoute.kt + +Rename `Manage` route to `Account`: + +```kotlin + @Serializable + data object Account : FCLGlassRoute() +``` + +### Step 2: GlassBottomBar.kt + +Update the bottom bar items. Find where items are defined and replace Manage with Account: + +```kotlin + GlassBottomBarItem( + iconRes = R.drawable.ic_baseline_account_circle_24, + contentDescription = stringResource(R.string.account), + route = FCLGlassRoute.Account + ) +``` + +Confirm icon resource exists. + +### Step 3: GlassNavHost.kt + +Update the composable mapping: + +```kotlin +composable { + AccountPage(backdrop = backdrop) +} +``` + +Remove or keep Manage mapping as needed. + +### Step 4: FCLGlassApp.kt + +Update the `hasRoute` check for bottom bar selection: + +```kotlin +selected = currentRoute?.hasRoute() == true, +``` + +(If it already uses hasRoute, just make sure it references Account.) + +### Step 5: 编译检查 + +Run: `./gradlew :FCL:compileFordebugKotlin` + +### Step 6: Commit + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLGlassRoute.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassBottomBar.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassNavHost.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/FCLGlassApp.kt +git commit -m "feat(glass-nav): replace Manage tab with Account" +``` + +--- + +## Task 5: 完善版本管理页操作 + +**Files:** +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/VersionsPage.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/page/versions/VersionListStateHolder.kt` +- Modify: `FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassVersionItem.kt` (if exists) + +**目标:** 给版本列表项添加操作:启动、重命名、删除、复制、设置。 + +### Step 1: Add actions to state holder + +Add helper methods to `VersionListStateHolder`: + +```kotlin + fun launchVersion(context: Context, item: VersionListItem) { + Versions.launch(context, item.profile, item.version) + } + + fun renameVersion(context: Context, item: VersionListItem) { + Versions.renameVersion(context, item.profile, item.version) + } + + fun deleteVersion(context: Context, item: VersionListItem) { + Versions.deleteVersion(context, item.profile, item.version) + } + + fun duplicateVersion(context: Context, item: VersionListItem) { + Versions.duplicateVersion(context, item.profile, item.version) + } +``` + +### Step 2: Add menu/actions to version item + +If `GlassVersionItem` exists, add a trailing menu button that shows options: +- 启动 +- 重命名 +- 复制 +- 删除 +- 设置 + +Use a simple `DropdownMenu` or custom glass action sheet. + +If the file doesn't exist, create it based on existing `VersionsPage` item layout. + +### Step 3: 编译检查 + +Run: `./gradlew :FCL:compileFordebugKotlin` + +### Step 4: Commit + +```bash +git add FCL/src/main/java/com/tungsten/fcl/ui/glass/page/VersionsPage.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/page/versions/VersionListStateHolder.kt \ + FCL/src/main/java/com/tungsten/fcl/ui/glass/component/GlassVersionItem.kt +git commit -m "feat(glass-versions): add version actions menu" +``` + +--- + +## Task 6: 最终编译与清理 + +**Files:** All phase6 files. + +- [ ] Run: `./gradlew :FCL:compileFordebugKotlin` +- [ ] Fix any remaining errors +- [ ] Commit final adjustments + +--- + +## Self-Review + +### Spec coverage + +| 要求 | 覆盖 | +|------|------| +| 账号页 | Task 2-3 | +| 版本管理页完善 | Task 5 | +| 导航切换 Manage->Account | Task 4 | + +### Placeholder scan + +- 外置登录按钮未实现(阶段7) +- 版本设置页详细表单未实现(阶段7) +- 皮肤上传未实现(阶段7) + +--- + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-06-19-fcl-liquid-glass-phase6-account-version.md`. + +**Two execution options:** + +1. **Subagent-Driven (recommended)** — dispatch a fresh subagent per task. +2. **Inline Execution** — execute in this session. + +**Which approach?** diff --git a/docs/superpowers/plans/2026-06-20-fcl-liquid-glass-remaining-dialogs.md b/docs/superpowers/plans/2026-06-20-fcl-liquid-glass-remaining-dialogs.md new file mode 100644 index 0000000000..d4e17130a2 --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-fcl-liquid-glass-remaining-dialogs.md @@ -0,0 +1,1073 @@ +# FCL 液态玻璃旧弹窗/页面迁移计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将 FCL 中仍在使用的旧 Android View 弹窗与独立 Activity 迁移为液态玻璃(Backdrop + Jetpack Compose)风格,使核心流程不再跳出旧 UI。 + +**Architecture:** 复用现有玻璃对话框系统 `GlassDialogManager` / `GlassDialogHost`,新增专用 `DialogRequest` 子类型;独立页面类弹窗改为 Compose Dialog 或全屏页面;独立 Activity 尽量保留 Activity 但将其内容替换为 Compose。 + +**Tech Stack:** Kotlin, Jetpack Compose, Material3, Backdrop (kyant), FCLCore, FCLLibrary + +--- + +## 文件结构 + +- **新增/修改的玻璃基础设施** + - `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDialogManager.kt` — 注册更多 `DialogRequest` 类型 + - `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDialogHost.kt` — 渲染新增弹窗类型 +- **账号相关** + - `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/dialog/account/CreateAccountDialog.kt` + - `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/dialog/account/AccountDialogs.kt` — 离线、微软、外置登录表单 +- **版本管理相关** + - `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/dialog/version/RenameVersionDialog.kt` + - `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/dialog/version/DuplicateVersionDialog.kt` + - `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/dialog/version/AddProfileDialog.kt` +- **整合包相关** + - `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/dialog/modpack/ModpackSelectionDialog.kt` + - `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/dialog/modpack/ModpackUrlDialog.kt` +- **任务/更新** + - `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/dialog/task/TaskProgressDialog.kt` + - `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/dialog/upgrade/UpdateDialog.kt` +- **世界/Mod** + - `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/dialog/world/WorldNameDialog.kt` + - `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/dialog/world/WorldExportDialog.kt` + - `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/dialog/mod/ModRollbackDialog.kt` +- **独立 Activity 内容替换** + - `/workspace/FCL/src/main/java/com/tungsten/fcl/activity/JVMActivity.java` + - `/workspace/FCL/src/main/java/com/tungsten/fcl/activity/WebActivity.java` + - `/workspace/FCL/src/main/java/com/tungsten/fcl/activity/ShellActivity.java` + - `/workspace/FCL/src/main/java/com/tungsten/fcl/activity/ControllerActivity.java` + +--- + +## Task 1: 扩展 GlassDialogManager 与 GlassDialogHost 的弹窗类型 + +**Files:** +- Modify: `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDialogManager.kt` +- Modify: `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDialogHost.kt` + +- [ ] **Step 1: 在 GlassDialogManager 中新增专用 DialogRequest 类型** + +在 `sealed class DialogRequest` 的末尾追加以下子类(保留已有类型): + +```kotlin +@Serializable + data class CreateAccount( + val factory: AccountFactory<*>? = null, + val authServer: AuthlibInjectorServer? = null, + val onCreated: () -> Unit + ) : DialogRequest() + + data class RenameVersion( + val oldName: String, + val onConfirm: (String) -> Unit, + val onDismiss: () -> Unit = {} + ) : DialogRequest() + + data class DuplicateVersion( + val oldName: String, + val onConfirm: (String) -> Unit, + val onDismiss: () -> Unit = {} + ) : DialogRequest() + + data class AddProfile( + val onConfirm: (String) -> Unit, + val onDismiss: () -> Unit = {} + ) : DialogRequest() + + data class ModpackUrl( + val onConfirm: (String) -> Unit, + val onDismiss: () -> Unit = {} + ) : DialogRequest() + + data class WorldName( + val oldName: String = "", + val onConfirm: (String) -> Unit, + val onDismiss: () -> Unit = {} + ) : DialogRequest() + + data class WorldExport( + val worldName: String, + val onConfirm: (String) -> Unit, + val onDismiss: () -> Unit = {} + ) : DialogRequest() + + data class ModRollback( + val modName: String, + val onConfirm: () -> Unit, + val onDismiss: () -> Unit = {} + ) : DialogRequest() + + data class TaskProgress( + val title: String = "", + val executor: TaskExecutor? = null, + val onCancel: () -> Unit = {} + ) : DialogRequest() + + data class AppUpdate( + val version: String, + val changelog: String, + val downloadUrl: String, + val onDownload: () -> Unit, + val onDismiss: () -> Unit = {} + ) : DialogRequest() +``` + +并新增对应的 `show*` 方法: + +```kotlin +fun showCreateAccount(factory: AccountFactory<*>? = null, authServer: AuthlibInjectorServer? = null, onCreated: () -> Unit = {}) { + add(CreateAccount(factory, authServer, onCreated)) +} + +fun showRenameVersion(oldName: String, onConfirm: (String) -> Unit, onDismiss: () -> Unit = {}) { + add(RenameVersion(oldName, onConfirm, onDismiss)) +} + +fun showDuplicateVersion(oldName: String, onConfirm: (String) -> Unit, onDismiss: () -> Unit = {}) { + add(DuplicateVersion(oldName, onConfirm, onDismiss)) +} + +fun showAddProfile(onConfirm: (String) -> Unit, onDismiss: () -> Unit = {}) { + add(AddProfile(onConfirm, onDismiss)) +} + +fun showModpackUrl(onConfirm: (String) -> Unit, onDismiss: () -> Unit = {}) { + add(ModpackUrl(onConfirm, onDismiss)) +} + +fun showWorldName(oldName: String = "", onConfirm: (String) -> Unit, onDismiss: () -> Unit = {}) { + add(WorldName(oldName, onConfirm, onDismiss)) +} + +fun showWorldExport(worldName: String, onConfirm: (String) -> Unit, onDismiss: () -> Unit = {}) { + add(WorldExport(worldName, onConfirm, onDismiss)) +} + +fun showModRollback(modName: String, onConfirm: () -> Unit, onDismiss: () -> Unit = {}) { + add(ModRollback(modName, onConfirm, onDismiss)) +} + +fun showTaskProgress(title: String = "", executor: TaskExecutor? = null, onCancel: () -> Unit = {}) { + add(TaskProgress(title, executor, onCancel)) +} + +fun showAppUpdate(version: String, changelog: String, downloadUrl: String, onDownload: () -> Unit, onDismiss: () -> Unit = {}) { + add(AppUpdate(version, changelog, downloadUrl, onDownload, onDismiss)) +} +``` + +- [ ] **Step 2: 在 GlassDialogHost 中处理新类型** + +在 `GlassDialogHost.kt` 的 `when (request)` 分支中,为每个新增类型渲染占位 Alert: + +```kotlin +is DialogRequest.CreateAccount -> AlertDialogSurface( + request = request, + title = stringResource(R.string.account_create), + message = "CreateAccount dialog is under migration.", + confirmText = "OK", + onConfirm = { dismiss(request) } +) +// 其余新增类型同理,标题使用对应 string 资源或硬编码 +``` + +> 说明:此处先渲染占位,后续 Task 逐个替换为真实 UI。 + +- [ ] **Step 3: 编译无关静态检查** + +确认 `GlassDialogManager.kt` 导入 `com.tungsten.fclcore.auth.AccountFactory`、`com.tungsten.fclcore.auth.authlibinjector.AuthlibInjectorServer`、`com.tungsten.fclcore.task.TaskExecutor`。 + +--- + +## Task 2: 创建通用输入弹窗组件 GlassInputDialog + +**Files:** +- Create: `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassInputDialog.kt` + +- [ ] **Step 1: 创建通用输入弹窗** + +```kotlin +package com.tungsten.fcl.ui.glass.component.dialog + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.R + +@Composable +fun GlassInputDialog( + backdrop: Backdrop, + title: String, + initialValue: String = "", + hint: String = "", + confirmText: String = stringResource(R.string.dialog_positive), + dismissText: String = stringResource(R.string.dialog_negative), + onConfirm: (String) -> Unit, + onDismiss: () -> Unit +) { + var value by remember { mutableStateOf(initialValue) } + + GlassDialogSurface(backdrop = backdrop) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface + ) + + GlassTextField( + backdrop = backdrop, + value = value, + onValueChange = { value = it }, + hint = hint, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onDismiss) { + Text(text = dismissText, color = MaterialTheme.colorScheme.onSurface) + } + TextButton(onClick = { onConfirm(value) }) { + Text(text = confirmText, color = Color.White) + } + } + } + } +} +``` + +- [ ] **Step 2: 确认 GlassTextField 导包路径** + +`GlassTextField` 位于 `com.tungsten.fcl.ui.glass.component.GlassTextField`。 + +--- + +## Task 3: 版本重命名弹窗 + +**Files:** +- Create: `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/dialog/version/RenameVersionDialog.kt` +- Modify: `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDialogHost.kt` +- Modify: 调用旧 `RenameVersionDialog` 的位置 + +- [ ] **Step 1: 创建玻璃版本重命名弹窗逻辑** + +```kotlin +package com.tungsten.fcl.ui.glass.dialog.version + +import android.content.Context +import android.widget.Toast +import com.tungsten.fcl.R +import com.tungsten.fcl.ui.glass.component.dialog.GlassDialogManager +import com.tungsten.fclcore.task.Schedulers +import com.tungsten.fclcore.task.Task +import com.tungsten.fclcore.util.FutureCallback +import java.util.concurrent.CompletableFuture + +fun showRenameVersionDialog( + context: Context, + oldName: String, + callback: FutureCallback +): CompletableFuture { + val future = CompletableFuture() + GlassDialogManager.showRenameVersion( + oldName = oldName, + onConfirm = { newName -> + callback.call(newName, { + future.complete(newName) + }, { msg -> + Schedulers.androidUIThread().execute { + Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() + } + }) + }, + onDismiss = { future.cancel(true) } + ) + return future +} +``` + +- [ ] **Step 2: 在 GlassDialogHost 中替换 RenameVersion 占位** + +将 `DialogRequest.RenameVersion` 分支改为: + +```kotlin +is DialogRequest.RenameVersion -> GlassInputDialog( + backdrop = backdrop, + title = stringResource(R.string.version_rename), + initialValue = request.oldName, + hint = stringResource(R.string.version_rename_hint), + onConfirm = { request.onConfirm(it); dismiss(request) }, + onDismiss = { request.onDismiss(); dismiss(request) } +) +``` + +> 若 `R.string.version_rename_hint` 不存在,使用硬编码 `"New version name"`。 + +- [ ] **Step 3: 查找并替换旧调用点** + +搜索 `new RenameVersionDialog` 的调用处,将其替换为 `showRenameVersionDialog(context, oldName, callback)`。 + +--- + +## Task 4: 版本复制弹窗 + +**Files:** +- Create: `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/dialog/version/DuplicateVersionDialog.kt` +- Modify: `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDialogHost.kt` + +- [ ] **Step 1: 创建玻璃版本复制弹窗逻辑** + +与 Task 3 结构一致,使用 `DialogRequest.DuplicateVersion`。 + +```kotlin +fun showDuplicateVersionDialog( + context: Context, + oldName: String, + callback: FutureCallback +): CompletableFuture { + val future = CompletableFuture() + GlassDialogManager.showDuplicateVersion( + oldName = oldName, + onConfirm = { newName -> + callback.call(newName, { future.complete(newName) }, { msg -> + Schedulers.androidUIThread().execute { + Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() + } + }) + }, + onDismiss = { future.cancel(true) } + ) + return future +} +``` + +- [ ] **Step 2: 在 GlassDialogHost 中替换 DuplicateVersion 占位** + +```kotlin +is DialogRequest.DuplicateVersion -> GlassInputDialog( + backdrop = backdrop, + title = stringResource(R.string.version_duplicate), + initialValue = request.oldName, + hint = stringResource(R.string.version_duplicate_hint), + onConfirm = { request.onConfirm(it); dismiss(request) }, + onDismiss = { request.onDismiss(); dismiss(request) } +) +``` + +> 若 hint 资源不存在,硬编码 `"Duplicate version name"`。 + +- [ ] **Step 3: 替换旧调用点** + +搜索 `new DuplicateVersionDialog` 并替换为 `showDuplicateVersionDialog(context, oldName, callback)`。 + +--- + +## Task 5: 新建 Profile 弹窗 + +**Files:** +- Create: `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/dialog/version/AddProfileDialog.kt` +- Modify: `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDialogHost.kt` + +- [ ] **Step 1: 创建玻璃新建 Profile 弹窗逻辑** + +```kotlin +package com.tungsten.fcl.ui.glass.dialog.version + +import android.content.Context +import android.widget.Toast +import com.tungsten.fcl.R +import com.tungsten.fcl.ui.glass.component.dialog.GlassDialogManager +import com.tungsten.fclcore.task.Schedulers +import com.tungsten.fclcore.util.FutureCallback +import java.util.concurrent.CompletableFuture + +fun showAddProfileDialog( + context: Context, + callback: FutureCallback +): CompletableFuture { + val future = CompletableFuture() + GlassDialogManager.showAddProfile( + onConfirm = { name -> + callback.call(name, { future.complete(name) }, { msg -> + Schedulers.androidUIThread().execute { + Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() + } + }) + }, + onDismiss = { future.cancel(true) } + ) + return future +} +``` + +- [ ] **Step 2: 在 GlassDialogHost 中替换 AddProfile 占位** + +```kotlin +is DialogRequest.AddProfile -> GlassInputDialog( + backdrop = backdrop, + title = stringResource(R.string.profile_add), + hint = stringResource(R.string.profile_name_hint), + onConfirm = { request.onConfirm(it); dismiss(request) }, + onDismiss = { request.onDismiss(); dismiss(request) } +) +``` + +> 若资源不存在,硬编码对应文本。 + +- [ ] **Step 3: 替换旧调用点** + +搜索 `new AddProfileDialog` 并替换。 + +--- + +## Task 6: 世界重命名/导出弹窗 + +**Files:** +- Create: `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/dialog/world/WorldNameDialog.kt` +- Create: `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/dialog/world/WorldExportDialog.kt` +- Modify: `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDialogHost.kt` + +- [ ] **Step 1: 创建世界重命名弹窗入口** + +```kotlin +package com.tungsten.fcl.ui.glass.dialog.world + +import com.tungsten.fcl.R +import com.tungsten.fcl.ui.glass.component.dialog.GlassDialogManager +import com.tungsten.fclcore.util.FutureCallback +import java.util.concurrent.CompletableFuture + +fun showWorldNameDialog( + oldName: String, + callback: FutureCallback +): CompletableFuture { + val future = CompletableFuture() + GlassDialogManager.showWorldName( + oldName = oldName, + onConfirm = { name -> + callback.call(name, { future.complete(name) }, {}) + }, + onDismiss = { future.cancel(true) } + ) + return future +} +``` + +- [ ] **Step 2: 创建世界导出弹窗入口** + +```kotlin +package com.tungsten.fcl.ui.glass.dialog.world + +import com.tungsten.fcl.R +import com.tungsten.fcl.ui.glass.component.dialog.GlassDialogManager +import com.tungsten.fclcore.util.FutureCallback +import java.util.concurrent.CompletableFuture + +fun showWorldExportDialog( + worldName: String, + callback: FutureCallback +): CompletableFuture { + val future = CompletableFuture() + GlassDialogManager.showWorldExport( + worldName = worldName, + onConfirm = { fileName -> + callback.call(fileName, { future.complete(fileName) }, {}) + }, + onDismiss = { future.cancel(true) } + ) + return future +} +``` + +- [ ] **Step 3: 在 GlassDialogHost 中替换占位** + +```kotlin +is DialogRequest.WorldName -> GlassInputDialog( + backdrop = backdrop, + title = stringResource(R.string.world_rename), + initialValue = request.oldName, + onConfirm = { request.onConfirm(it); dismiss(request) }, + onDismiss = { request.onDismiss(); dismiss(request) } +) + +is DialogRequest.WorldExport -> GlassInputDialog( + backdrop = backdrop, + title = stringResource(R.string.world_export), + initialValue = request.worldName, + hint = stringResource(R.string.world_export_hint), + onConfirm = { request.onConfirm(it); dismiss(request) }, + onDismiss = { request.onDismiss(); dismiss(request) } +) +``` + +- [ ] **Step 4: 替换旧调用点** + +搜索 `new WorldNameDialog` 和 `new WorldExportDialog` 并替换。 + +--- + +## Task 7: Mod 回滚弹窗 + +**Files:** +- Create: `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/dialog/mod/ModRollbackDialog.kt` +- Modify: `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDialogHost.kt` + +- [ ] **Step 1: 创建 ModRollback 入口** + +```kotlin +package com.tungsten.fcl.ui.glass.dialog.mod + +import com.tungsten.fcl.R +import com.tungsten.fcl.ui.glass.component.dialog.GlassDialogManager + +fun showModRollbackDialog(modName: String, onConfirm: () -> Unit) { + GlassDialogManager.showModRollback( + modName = modName, + onConfirm = onConfirm, + onDismiss = {} + ) +} +``` + +- [ ] **Step 2: 在 GlassDialogHost 中替换占位为 Alert** + +```kotlin +is DialogRequest.ModRollback -> AlertDialogSurface( + request = request, + title = stringResource(R.string.mod_rollback_title), + message = stringResource(R.string.mod_rollback_message, request.modName), + confirmText = stringResource(R.string.dialog_positive), + dismissText = stringResource(R.string.dialog_negative), + onConfirm = { + request.onConfirm() + dismiss(request) + }, + onDismiss = { + request.onDismiss() + dismiss(request) + } +) +``` + +> 若对应 string 资源不存在,使用硬编码。 + +- [ ] **Step 3: 替换旧调用点** + +搜索 `new ModRollbackDialog` 并替换。 + +--- + +## Task 8: 整合包 URL 导入弹窗 + +**Files:** +- Create: `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/dialog/modpack/ModpackUrlDialog.kt` +- Modify: `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDialogHost.kt` + +- [ ] **Step 1: 创建 ModpackUrl 入口** + +```kotlin +package com.tungsten.fcl.ui.glass.dialog.modpack + +import com.tungsten.fcl.R +import com.tungsten.fcl.ui.glass.component.dialog.GlassDialogManager + +fun showModpackUrlDialog(onConfirm: (String) -> Unit) { + GlassDialogManager.showModpackUrl( + onConfirm = onConfirm, + onDismiss = {} + ) +} +``` + +- [ ] **Step 2: 在 GlassDialogHost 中替换占位** + +```kotlin +is DialogRequest.ModpackUrl -> GlassInputDialog( + backdrop = backdrop, + title = stringResource(R.string.modpack_url), + hint = "https://", + onConfirm = { request.onConfirm(it); dismiss(request) }, + onDismiss = { request.onDismiss(); dismiss(request) } +) +``` + +- [ ] **Step 3: 替换旧调用点** + +搜索 `new ModpackUrlDialog` 并替换。 + +--- + +## Task 9: 整合包安装选择弹窗(保留旧版跳转或最小化改造) + +**Files:** +- Create: `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/dialog/modpack/ModpackSelectionDialog.kt` +- Modify: `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDialogHost.kt` + +- [ ] **Step 1: 创建最小占位** + +`ModpackSelectionDialog.java` 当前只有空布局。迁移时先用一个玻璃 Alert 告知用户选择逻辑: + +```kotlin +package com.tungsten.fcl.ui.glass.dialog.modpack + +import com.tungsten.fcl.R +import com.tungsten.fcl.ui.glass.component.dialog.GlassDialogManager + +fun showModpackSelectionDialog(onConfirm: () -> Unit = {}) { + GlassDialogManager.showAlert( + title = "Modpack Selection", + message = "This flow is under glass migration. Continuing will use the existing installer.", + confirmText = "Continue", + onConfirm = onConfirm + ) {} +} +``` + +- [ ] **Step 2: 替换旧调用点** + +搜索 `new ModpackSelectionDialog` 并替换为 `showModpackSelectionDialog { ... }`。 + +> 后续如果需要完整的选择 Profile/版本 UI,再扩展为专用 DialogRequest。 + +--- + +## Task 10: 账号创建弹窗(第一阶段:离线账号) + +**Files:** +- Create: `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/dialog/account/CreateAccountDialog.kt` +- Modify: `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDialogHost.kt` + +- [ ] **Step 1: 创建离线账号创建弹窗入口** + +```kotlin +package com.tungsten.fcl.ui.glass.dialog.account + +import android.widget.Toast +import com.tungsten.fcl.R +import com.tungsten.fcl.setting.Accounts +import com.tungsten.fcl.ui.UIManager +import com.tungsten.fcl.ui.glass.component.dialog.GlassDialogManager +import com.tungsten.fclcore.auth.CharacterSelector +import com.tungsten.fclcore.auth.offline.OfflineAccountFactory +import com.tungsten.fclcore.task.Schedulers +import com.tungsten.fclcore.task.Task + +fun showCreateOfflineAccountDialog(username: String, onCreated: () -> Unit) { + if (username.isBlank()) { + Schedulers.androidUIThread().execute { + Toast.makeText(UIManager.getInstance().activity, R.string.account_login_empty, Toast.LENGTH_SHORT).show() + } + return + } + Task.supplyAsync { + Accounts.FACTORY_OFFLINE.create( + CharacterSelector { _, _ -> throw NoSelectedCharacterException() }, + username, "", null, null + ) + }.whenComplete(Schedulers.androidUIThread()) { account -> + Accounts.addAccount(account) + Accounts.setSelectedAccount(account) + onCreated() + }.fail { e -> + Schedulers.androidUIThread().execute { + Toast.makeText(UIManager.getInstance().activity, e.message, Toast.LENGTH_SHORT).show() + } + }.start() +} +``` + +- [ ] **Step 2: 在 GlassDialogHost 中替换 CreateAccount 占位为离线输入弹窗** + +```kotlin +is DialogRequest.CreateAccount -> { + var name by remember { mutableStateOf("") } + GlassDialogSurface(backdrop = backdrop) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = stringResource(R.string.account_create_offline), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface + ) + GlassTextField( + backdrop = backdrop, + value = name, + onValueChange = { name = it }, + hint = stringResource(R.string.account_username), + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = { dismiss(request); request.onCreated() }) { + Text(text = stringResource(R.string.dialog_negative), color = MaterialTheme.colorScheme.onSurface) + } + TextButton(onClick = { + showCreateOfflineAccountDialog(name, request.onCreated) + dismiss(request) + }) { + Text(text = stringResource(R.string.dialog_positive), color = Color.White) + } + } + } + } +} +``` + +- [ ] **Step 3: 替换旧 CreateAccountDialog 调用点** + +搜索 `new CreateAccountDialog` 并替换为 `GlassDialogManager.showCreateAccount(factory = ..., onCreated = { ... })`。 + +> 微软/外置登录在后续 Task 中扩展。 + +--- + +## Task 11: 任务进度弹窗 + +**Files:** +- Create: `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/dialog/task/TaskProgressDialog.kt` +- Modify: `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDialogHost.kt` + +- [ ] **Step 1: 创建任务进度入口** + +```kotlin +package com.tungsten.fcl.ui.glass.dialog.task + +import com.tungsten.fcl.R +import com.tungsten.fcl.ui.glass.component.dialog.GlassDialogManager +import com.tungsten.fclcore.task.TaskExecutor + +fun showTaskProgressDialog(title: String = "", executor: TaskExecutor? = null, onCancel: () -> Unit = {}) { + GlassDialogManager.showTaskProgress(title, executor, onCancel) +} +``` + +- [ ] **Step 2: 在 GlassDialogHost 中替换 TaskProgress 占位** + +实现一个显示标题、取消按钮、简单任务列表的弹窗。使用 `request.executor?.tasks` 或监听事件。若实现复杂,可先简化为只显示标题和取消按钮: + +```kotlin +is DialogRequest.TaskProgress -> GlassDialogSurface(backdrop = backdrop) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = request.title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "Running...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + GlassButton( + backdrop = backdrop, + onClick = { + request.executor?.cancel() + request.onCancel() + dismiss(request) + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(R.string.button_cancel), color = Color.White) + } + } +} +``` + +- [ ] **Step 3: 替换旧 TaskDialog 调用点** + +搜索 `new TaskDialog` 并替换。 + +--- + +## Task 12: 更新弹窗 + +**Files:** +- Create: `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/dialog/upgrade/UpdateDialog.kt` +- Modify: `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/component/dialog/GlassDialogHost.kt` + +- [ ] **Step 1: 创建更新弹窗入口** + +```kotlin +package com.tungsten.fcl.ui.glass.dialog.upgrade + +import com.tungsten.fcl.R +import com.tungsten.fcl.ui.glass.component.dialog.GlassDialogManager + +fun showUpdateDialog(version: String, changelog: String, downloadUrl: String, onDownload: () -> Unit) { + GlassDialogManager.showAppUpdate(version, changelog, downloadUrl, onDownload) +} +``` + +- [ ] **Step 2: 在 GlassDialogHost 中替换 AppUpdate 占位** + +```kotlin +is DialogRequest.AppUpdate -> GlassDialogSurface(backdrop = backdrop) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "Update ${request.version}", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = request.changelog, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + ) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = { dismiss(request); request.onDismiss() }) { + Text(text = stringResource(R.string.dialog_negative), color = MaterialTheme.colorScheme.onSurface) + } + TextButton(onClick = { + request.onDownload() + dismiss(request) + }) { + Text(text = stringResource(R.string.dialog_positive), color = Color.White) + } + } + } +} +``` + +- [ ] **Step 3: 替换旧 UpdateDialog 调用点** + +搜索 `new UpdateDialog` 并替换。 + +--- + +## Task 13: JVMActivity 内容玻璃化 + +**Files:** +- Modify: `/workspace/FCL/src/main/java/com/tungsten/fcl/activity/JVMActivity.java` + +- [ ] **Step 1: 将 JVMActivity 的 setContentView 替换为 Compose** + +保留 Activity 入口和 intent 处理逻辑,移除 XML 布局 inflate,改为在 ContentView 中设置 Compose: + +```java +@Override +protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + // 保留原有 intent 解析和权限/初始化代码 + setContentView( + new ComposeView(this) {{ + setContent(() -> { + // 这里调用一个待创建的 @Composable JVMPage() + return JVMPageKt.JVMPage(this); + }); + }} + ); +} +``` + +> 更简单的方式:让 Activity 继承 `ComponentActivity` 并使用 `setContent { ... }`。若改动过大,则只改布局为 ComposeView。 + +- [ ] **Step 2: 创建 JVMPage.kt** + +```kotlin +package com.tungsten.fcl.activity + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable + +@Composable +fun JVMPage() { + Text(text = "JVM Console (glass migration placeholder)") +} +``` + +- [ ] **Step 3: 复用旧 Activity 逻辑** + +将原 `JVMActivity` 中处理命令执行、日志输出的逻辑提取到 ViewModel 或 StateHolder,页面仅展示输出。具体逻辑参考原 Java 文件。 + +--- + +## Task 14: WebActivity / ShellActivity / ControllerActivity 内容玻璃化 + +**Files:** +- Modify: `/workspace/FCL/src/main/java/com/tungsten/fcl/activity/WebActivity.java` +- Modify: `/workspace/FCL/src/main/java/com/tungsten/fcl/activity/ShellActivity.java` +- Modify: `/workspace/FCL/src/main/java/com/tungsten/fcl/activity/ControllerActivity.java` + +- [ ] **Step 1: 为每个 Activity 创建 Compose 包装页面** + +```kotlin +package com.tungsten.fcl.activity + +import androidx.compose.runtime.Composable + +@Composable +fun WebPage(url: String) { + AndroidView( + factory = { context -> WebView(context).apply { loadUrl(url) } }, + modifier = Modifier.fillMaxSize() + ) +} + +@Composable +fun ShellPage() { + Text(text = "Shell (glass migration placeholder)") +} + +@Composable +fun ControllerPage() { + Text(text = "Controller (glass migration placeholder)") +} +``` + +- [ ] **Step 2: 替换各 Activity 的 setContentView** + +保留 Activity 生命周期和 intent 解析,将 XML 布局替换为 ComposeView 或 `setContent { ... }`。 + +--- + +## Task 15: EulaFragment 玻璃化 + +**Files:** +- Create: `/workspace/FCL/src/main/java/com/tungsten/fcl/ui/glass/page/EulaPage.kt` +- Modify: `/workspace/FCL/src/main/java/com/tungsten/fcl/fragment/EulaFragment.java` + +- [ ] **Step 1: 创建 EulaPage** + +```kotlin +package com.tungsten.fcl.ui.glass.page + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.tungsten.fcl.R +import com.tungsten.fcl.ui.glass.component.GlassButton +import com.tungsten.fcl.ui.glass.component.GlassTopBar + +@Composable +fun EulaPage( + backdrop: Backdrop, + onAccept: () -> Unit, + onDecline: () -> Unit +) { + Column(modifier = Modifier.fillMaxSize()) { + GlassTopBar(title = "EULA", onBack = onDecline) + Text( + text = stringResource(R.string.eula_content), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(20.dp) + ) + GlassButton( + backdrop = backdrop, + onClick = onAccept, + modifier = Modifier + .fillMaxWidth() + .padding(20.dp) + ) { + Text(text = stringResource(R.string.eula_accept), color = Color.White) + } + } +} +``` + +> 若对应 string 资源不存在,使用硬编码。 + +- [ ] **Step 2: 在 EulaFragment 中替换视图** + +将 `EulaFragment` 的 `onCreateView` 改为返回 `ComposeView`,并渲染 `EulaPage`。 + +--- + +## Task 16: 最终静态检查与调用点收尾 + +**Files:** +- All files above + +- [ ] **Step 1: 扫描旧弹窗调用点** + +搜索以下类名,确保全部替换为新的玻璃入口: +- `RenameVersionDialog` +- `DuplicateVersionDialog` +- `AddProfileDialog` +- `WorldNameDialog` +- `WorldExportDialog` +- `ModRollbackDialog` +- `ModpackUrlDialog` +- `ModpackSelectionDialog` +- `CreateAccountDialog` +- `TaskDialog` +- `UpdateDialog` + +- [ ] **Step 2: 确认导入和字符串资源** + +- 每个新增文件导入 `GlassDialogManager` 的路径正确。 +- 缺少的字符串资源已用硬编码兜底,记录到本计划末尾。 + +- [ ] **Step 3: 提交 / 交付** + +用户将本地编译,因此不运行远程 Gradle。完成所有 Task 后向用户报告剩余未迁移项(控制器/联机相关旧弹窗若不在本计划范围内)。 + +--- + +## 已知未覆盖的剩余项 + +以下旧 UI 不在本计划范围内,可视需求继续迁移: + +- `ClassicAccountLoginDialog.java` +- `OAuthAccountLoginDialog.java` +- `AddAuthlibInjectorServerDialog.java` +- `DownloadAddonDialog.java` +- 控制器编辑相关弹窗:`ControllerUploadDialog`, `OldVersionDialog`, `ControllerInfoDialog`, `ViewGroupDialog`, `SelectControllerDialog`, `DirectionStyleDialog`, `EditViewGroupDialog`, `EditViewDialog`, `AddDirectionStyleDialog`, `ButtonStyleDialog`, `AddButtonStyleDialog`, `AddInputTextDialog`, `MultiplayerDialog` +- `LauncherHelper.java` 中可能弹出的旧 Alert + +--- + +## 执行方式 + +Plan complete and saved to `docs/superpowers/plans/2026-06-20-fcl-liquid-glass-remaining-dialogs.md`. + +Two execution options: + +1. **Subagent-Driven (recommended)** — 每个 Task 分派新子智能体,任务间审查,快速迭代。 +2. **Inline Execution** — 在当前会话中使用 executing-plans 批量执行,关键节点人工检查。 + +Which approach do you want? diff --git a/docs/superpowers/specs/2026-06-19-fcl-liquid-glass-full-ui-migration-design.md b/docs/superpowers/specs/2026-06-19-fcl-liquid-glass-full-ui-migration-design.md new file mode 100644 index 0000000000..59f36e1122 --- /dev/null +++ b/docs/superpowers/specs/2026-06-19-fcl-liquid-glass-full-ui-migration-design.md @@ -0,0 +1,170 @@ +# FCL 液态玻璃 UI 全面迁移设计 + +## 目标 +将 Fold Craft Launcher(FCL)中所有剩余的传统 View/XML UI 迁移为基于 Jetpack Compose + Backdrop 的液态玻璃风格 UI,最终移除对传统 `MainActivity` 及旧页面框架的依赖,使整个应用保持单一入口 `GlassMainActivity`。 + +## 设计原则 +1. **不改底层模型**:只替换 UI 层,FCLCore / FCLLibrary 的数据模型、任务、配置逻辑原样复用。 +2. **统一组件库**:所有新页面复用 `/ui/glass/component/` 中已有的 GlassCard、GlassButton、GlassTopBar、GlassSearchBar 等组件;缺失的组件在本计划中补齐。 +3. **Backdrop 一致性**:所有可滚动/列表容器使用 `layerBackdrop` / `drawBackdrop` 实现玻璃态背景,形状统一使用 `GlassTheme.glassCornerRadius` 与 `RoundedCornerShape(50)`。 +4. **单 Activity + Compose Navigation**:新页面统一走 `FCLGlassRoute` / `GlassNavHost`;旧 `MainActivity`、`ManageUI`、`DownloadPageManager` 等页面管理器逐步退役。 +5. **Dialog 统一**:将旧 `FCLDialog` / `AlertDialog` 统一为 Compose `GlassAlertDialog` / `GlassInputDialog` / `GlassSelectionDialog`,避免页面与旧 Dialog 混用。 +6. **编译即验证**:每个阶段结束时必须能通过 `./gradlew :FCL:compileFordebugKotlin`。 + +## Backdrop 官方最佳实践 +本迁移严格遵循 Backdrop 官方文档与示例 APP([AndroidLiquidGlass](https://github.com/Kyant0/AndroidLiquidGlass))的用法。 + +### Backdrop 类型选择 +- 主背景使用 `rememberLayerBackdrop { drawRect(backgroundColor); drawContent() }`,并通过 `Modifier.layerBackdrop(backdrop)` 让导航宿主绘制内容到 backdrop。 +- 需要合并多个背景源(如 Slider、Tabs)时使用 `rememberCombinedBackdrop(...)`。 +- "glass on glass" 嵌套场景(底部玻璃弹窗内再放玻璃按钮)使用 `exportedBackdrop` 参数避免循环渲染崩溃。 + +### Effects 顺序 +必须按 `color filter ⇒ blur ⇒ lens` 的顺序调用: +```kotlin +effects = { + vibrancy() // color filter + blur(4f.dp.toPx()) // blur + lens(16f.dp.toPx(), 32f.dp.toPx()) // lens (Android 13+) +} +``` + +### 形状 +- 按钮、底部导航项等胶囊形元素优先使用官方示例中的 `Capsule()`(来自 [Shapes](https://github.com/Kyant0/Shapes) 库)。 +- 卡片使用 `RoundedCornerShape` 或 Shapes 库提供的 G2 连续圆角。 +- 注意:`lens()` 要求形状为 `CornerBasedShape`;`height` 必须在 `[0, shape.minCornerRadius]` 范围内。 + +### 可读性 +必须在 `onDrawSurface` 中叠加半透明底色或主题色,不能只依赖 blur/lens: +```kotlin +onDrawSurface = { drawRect(GlassTheme.surfaceColor()) } +``` + +### 交互反馈 +- 按钮/导航项的按压缩放、位移等变换必须放在 `drawBackdrop` 的 `layerBlock` 中,避免 backdrop 跟随变形。 +- 参考官方示例实现 `InteractiveHighlight` 或直接使用 `Modifier.clickable` + 简单的 `Animatable` 缩放。 + +## 阶段划分 + +### 阶段 1:通用 Dialog 统一化 +先把散落在各处的旧 Dialog 改成 Compose,避免后续页面重写时还要桥接旧弹窗。 + +| 旧文件 | 新文件 | 说明 | +| --- | --- | --- | +| `dialog/CreateAccountDialog.java` | `glass/component/dialog/GlassCreateAccountDialog.kt` | 创建离线/微软/AuthlibInjector 账户 | +| `dialog/ReLoginDialog.java` | `glass/component/dialog/GlassReLoginDialog.kt` | 账户过期重新登录 | +| `dialog/AddAuthlibInjectorServerDialog.java` | `glass/component/dialog/GlassAuthlibServerDialog.kt` | 添加/编辑认证服务器 | +| `dialog/RenameVersionDialog.java` | `glass/component/dialog/GlassRenameVersionDialog.kt` | 重命名版本 | +| `dialog/DuplicateVersionDialog.java` | `glass/component/dialog/GlassDuplicateVersionDialog.kt` | 复制版本 | +| `dialog/QuickInputDialog.java` | `glass/component/dialog/GlassInputDialog.kt` | 通用单行输入 | +| `dialog/AddProfileDialog.java` | `glass/component/dialog/GlassAddProfileDialog.kt` | 添加游戏配置文件 | + +同时补齐通用组件: +- `GlassAlertDialog`:标题 + 消息 + 确认/取消 +- `GlassInputDialog`:标题 + 输入框 + 确认/取消 +- `GlassSelectionDialog`:标题 + 单选列表 + 确认/取消 +- `GlassProgressDialog`:显示后台任务进度(替代 `ProgressDialog` / `TaskDialog` 的 UI 层) + +### 阶段 2:版本深度管理 +在已完成 `VersionsPage` 的基础上,补齐版本相关页面。 + +| 旧文件 | 新文件 | 说明 | +| --- | --- | --- | +| `dialog/VersionSettingsDialog.java` | `glass/page/version/VersionSettingsPage.kt` | 单个版本的内存、Java、渲染等设置 | +| `ui/download/version/VersionInstallPage.java` | `glass/page/download/VersionInstallPage.kt` | 安装 Minecraft / Forge / Fabric / OptiFine 向导 | +| `ui/manage/ModListPage.java` | `glass/page/version/VersionModListPage.kt` | 已安装 Mod 列表与管理 | +| `ui/manage/WorldListPage.java` | `glass/page/version/VersionWorldListPage.kt` | 已安装世界列表与管理 | +| 资源包/光影管理旧页 | `glass/page/version/VersionResourcePackPage.kt` / `VersionShaderPackPage.kt` | 资源包、光影包管理 | +| `dialog/ModInfoDialog.java` | `glass/page/version/ModInfoPage.kt` | Mod 详情 | +| `dialog/WorldExportDialog.java` | `glass/component/dialog/GlassWorldExportDialog.kt` | 导出世界 | + +`VersionsPage` 中版本项的「设置」入口不再 Toast,改为导航到 `VersionSettingsPage`。 + +### 阶段 3:下载与整合包 +已有通用 `RemoteModListPage` / `RemoteModInfoPage` / `RemoteModVersionPage`,补齐整合包与特殊下载流程。 + +| 旧文件 | 新文件 | 说明 | +| --- | --- | --- | +| `ui/download/modpack/ModpackDownloadPage.java` | `glass/page/download/ModpackDownloadPage.kt` | 整合包下载列表 | +| `dialog/DownloadModpackDialog.java` | `glass/page/download/ModpackInstallPage.kt` | 整合包下载与安装 | +| `dialog/ModpackSelectionDialog.java` | `glass/page/download/ModpackSelectionPage.kt` | 本地整合包选择安装 | +| `dialog/ModpackInfoDialog.java` | `glass/page/download/ModpackInfoPage.kt` | 整合包详情 | +| `dialog/ModpackUrlDialog.java` | `glass/component/dialog/GlassUrlInputDialog.kt` | URL 安装整合包 | +| `dialog/DownloadAddonDialog.java` | `glass/page/download/AddonDownloadPage.kt` | 通用 Addon 下载对话框改为页面 | + +### 阶段 4:设置与 Java +将设置页从目前的「主题预设 + 主题模式 + 关于」扩展到完整的启动器设置。 + +| 旧文件 | 新文件 | 说明 | +| --- | --- | --- | +| `ui/setting/LauncherSettingPage.java` | `glass/page/settings/launcher/*` | 拆分多个设置分类页 | +| `dialog/ManageJavaDialog.java` | `glass/page/settings/JavaRuntimePage.kt` | Java 运行时管理 | +| `fragment/RuntimeFragment.kt` | 合并进 `JavaRuntimePage.kt` | Java 版本列表与配置 | +| `activity/JVMActivity.java` | `glass/page/settings/JvmArgsPage.kt` | JVM 参数配置 | + +设置分类页建议: +- `LauncherGeneralSettingsPage`:语言、检查更新、下载源等 +- `LauncherGameSettingsPage`:默认游戏目录、版本隔离等 +- `JavaRuntimePage`:Java 运行时列表、添加、删除 +- `JvmArgsPage`:全局 JVM 参数 + +### 阶段 5:控制器与杂项 +| 旧文件 | 新文件 | 说明 | +| --- | --- | --- | +| `activity/ControllerActivity.java` | `glass/page/controller/ControllerPage.kt` / `ControllerDownloadPage.kt` | 控制器管理与下载 | +| `dialog/SelectControllerDialog.java` | `glass/component/dialog/GlassControllerSelectDialog.kt` | 选择控制器 | +| `dialog/DownloadControllerDialog.java` | `glass/page/controller/ControllerDownloadPage.kt` | 下载控制器配置 | +| `fragment/EulaFragment.java` | `glass/page/EulaPage.kt` | 首次启动用户协议 | +| `activity/ShellActivity.java` | `glass/page/ShellPage.kt` | Shell 命令输出 | +| `activity/WebActivity.java` | `glass/page/WebPage.kt` | WebView 页面 | + +### 阶段 6:MainActivity 退役与收尾 +- 将 `MainActivity` 中仍在被旧代码依赖的实用方法提取到独立工具类。 +- 把所有外部入口(activity-alias、旧 Intent)重定向到 `GlassMainActivity`。 +- 删除或标记废弃 `MainActivity`、`ManageUI`、`DownloadPageManager` 等旧页面框架。 +- 最终 AndroidManifest 只保留 `GlassMainActivity` 作为入口,其他 Activity 按需保留(Web/Shell/JVM 等可改为 Composable 内部页面或独立 Activity 但用 Compose 重写)。 + +## 技术要点 + +### 导航 +- 主导航继续使用 `FCLGlassRoute` + 底部 `GlassBottomBar`。 +- 版本内子页面、设置分类页、下载子流程使用嵌套 NavHost: + - `GlassNavHost` 负责顶层 5 个 Tab。 + - `GlassDownloadNavHost` 已存在,继续扩展整合包等路由。 + - 新增 `GlassSettingsNavHost`、`GlassVersionNavHost`。 + +### 状态管理 +- 继续使用 `*StateHolder` 模式(如 `AccountListStateHolder`、`VersionListStateHolder`)。 +- 每个新页面配套一个 StateHolder,负责监听 FCLCore 数据变化并暴露 Compose State。 + +### 后台任务与弹窗 +- 旧 `TaskDialog` / `ProgressDialog` 改为在 Compose 中显示 `GlassProgressDialog`。 +- 下载、安装、删除等耗时操作继续走 FCLCore `Task`,但结果回调在 UI 线程更新 Compose State。 + +### 旧代码桥接 +- 在全部迁移完成前,部分旧 Activity 可能需要临时桥接。优先把 Dialog 改成 Compose,减少 Activity 间跳转。 +- 如果某个旧页面业务逻辑过重(如 `MainActivity` 的页面切换),先提取为独立工具类,再重写 UI。 + +### 组件库升级 +在迁移旧页面的同时,逐步对照官方示例升级已有玻璃组件: +- **GlassBottomBar**:增加 `layerBlock` 按压缩放反馈;可选引入 `Shapes` 库将 `RoundedCornerShape(50)` 替换为 `Capsule()`。 +- **GlassButton / GlassIconButton**:增加 `layerBlock` 交互高光/缩放;支持 `surfaceColor` 参数。 +- **GlassCard**:保持现有 `RoundedCornerShape` 或升级为 G2 连续圆角。 +- **新增组件**: + - `GlassBottomSheet`:使用 `exportedBackdrop` 实现玻璃底部弹窗。 + - `GlassSlider`:使用 `rememberCombinedBackdrop` 实现轨道 + 滑块双层玻璃效果。 + - `GlassProgressDialog`:统一后台任务进度显示。 + +### 依赖 +当前已引入 `io.github.kyant0:backdrop:2.0.0`。如需使用官方示例中的 `Capsule()` 等形状,可额外引入 `com.kyant.shapes:shapes:`,但这不是迁移阻塞项,可在视觉效果优化阶段再决定是否引入。 + +## 风险与回退 +- **范围风险**:阶段 4/5 设置与控制器涉及配置项较多,可能一次做不完。可在设计文档审批后进一步拆分。 +- **编译风险**:任何阶段都必须保证 `:FCL:compileFordebugKotlin` 通过;若旧文件被删除导致引用断裂,需同步修改引用点。 +- **运行时风险**:新 UI 与旧 `TaskDialog` / `FCLDialog` 混用可能弹窗异常。阶段 1 优先统一 Dialog 就是为了降低此风险。 +- **回退策略**:每个阶段独立提交;若某阶段问题太多,可回退到上一阶段编译通过的版本。 + +## 验收标准 +- 每个阶段结束时 `./gradlew :FCL:compileFordebugKotlin` 编译通过。 +- 用户能完成核心闭环:启动应用 → 浏览版本 → 安装版本 → 启动游戏 → 下载 Mod/整合包 → 管理已安装内容 → 修改设置。 +- 所有可见 UI 均使用 Backdrop 玻璃效果,无原生 Android 旧风格残留。 diff --git a/docs/superpowers/specs/2026-06-19-fcl-liquid-glass-ui-redesign-design.md b/docs/superpowers/specs/2026-06-19-fcl-liquid-glass-ui-redesign-design.md new file mode 100644 index 0000000000..ba68ff274c --- /dev/null +++ b/docs/superpowers/specs/2026-06-19-fcl-liquid-glass-ui-redesign-design.md @@ -0,0 +1,154 @@ +# FCL 液态玻璃 UI 全面重构设计文档 + +## 1. 背景与目标 + +### 1.1 背景 +FCL(Fold Craft Launcher)当前 UI 基于传统 Android View 系统(XML + Java/Kotlin)构建,包含约 80+ 个页面与组件。用户希望将整个应用 UI 改为基于 Backdrop 库的液态玻璃(Liquid Glass)风格。 + +### 1.2 目标 +- 将 FCL 主界面全面迁移至 Jetpack Compose + Backdrop +- 采用单 Activity + Compose Navigation 架构 +- 统一的玻璃容器(Glass Container)与底部玻璃导航栏(Glass Bottom Bar) +- 所有一级页面都使用液态玻璃视觉语言 +- 主题预设颜色作为液态玻璃的 tint 来源 + +## 2. 关键决策 + +| 决策项 | 选择 | 原因 | +|--------|------|------| +| 推进方式 | A. 全面重写 | 用户明确要求"全部都是这个 UI" | +| 架构方案 | 2. 重新设计主架构 | 单 Activity + Compose Navigation 能获得最统一的玻璃效果 | +| 最低 SDK | A. 提升至 API 31 | Backdrop 效果依赖 Android 12+ | +| 主导航 | A. 底部玻璃导航栏 | 与 Backdrop 官方教程对齐,移动场景最自然 | + +## 3. 架构概览 + +``` +MainActivity + └── FCLGlassApp (Compose root) + ├── GlassBackground (主题背景层) + ├── GlassNavHost (页面内容,被 layerBackdrop 捕获为 backdrop) + │ └── 各页面 Composable (Home, Versions, Download, Settings, ...) + └── GlassBottomBar (底部玻璃导航栏,使用 drawBackdrop 折射背景) +``` + +### 3.1 核心组件 + +#### `FCLGlassApp` +- 应用根 Composable +- 提供主题、导航控制器、全局状态 +- 管理 System UI(状态栏、导航栏)透明/沉浸 + +#### `GlassBackground` +- 根据当前主题模式(亮/暗)渲染背景 +- 支持主题预设色作为背景渐变/装饰色 +- 为 GlassNavHost 提供视觉底层 + +#### `GlassNavHost` +- 使用 Compose Navigation 管理页面路由 +- 被 `Modifier.layerBackdrop(backdrop)` 包裹,将其内容捕获到 backdrop +- 页面切换使用 Compose 默认或自定义过渡动画 + +#### `GlassBottomBar` +- 底部常驻导航栏 +- 使用 `rememberLayerBackdrop` + `drawBackdrop` 实现官方教程中的玻璃效果 +- 每个导航项都是玻璃胶囊按钮 +- 当前选中项使用主题色 tint(`BlendMode.Hue` + `alpha = 0.75f`) + +### 3.2 导航结构 + +底部导航固定 5 个入口: + +| 入口 | 标识 | 主要内容 | +|------|------|---------| +| 主页 | home | 公告、快速启动、当前账号 | +| 版本 | versions | 版本列表、安装、管理 | +| 下载 | download | 游戏、模组、整合包、资源包、光影 | +| 管理 | manage | 模组、存档、数据包、世界 | +| 设置 | settings | 启动器设置、主题、账号、关于 | + +## 4. 技术栈 + +- **UI 框架**:Jetpack Compose +- **玻璃效果库**:Backdrop 2.0.0 +- **导航**:Compose Navigation +- **依赖注入/状态**:Compose State、ViewModel(可选) +- **主题**:现有 `ThemeEngine` + `ThemePreset` 桥接到 Compose + +## 5. 主题系统 + +### 5.1 颜色映射 +- 主题预设主色(`preset.getColor()`)作为 glass tint +- 亮色模式背景:`Color(0xFFF0F4F8)` 或根据预设微调 +- 暗色模式背景:`Color(0xFF1A1A2E)` 或根据预设微调 +- 玻璃表面层:`Color.White.copy(alpha = 0.5f)` + +### 5.2 暗色/亮色切换 +- 继续复用现有 `themeMode` 设置 +- Compose 中通过 `isSystemInDarkTheme()` 或保存的设置决定 + +## 6. 分阶段实施计划 + +由于涉及页面众多,必须分阶段推进: + +### 阶段 1:Compose 基础框架 +- 创建 `MainActivity` 作为唯一入口 +- 配置 Compose 主题与 Backdrop +- 实现 `FCLGlassApp`、`GlassBackground`、`GlassNavHost`、`GlassBottomBar` +- 设置底部导航路由框架 +- 调整 `minSdk` 为 31 + +### 阶段 2:核心页面重写 +- 主页(Home) +- 版本列表页(Versions) +- 设置页(Settings) + +### 阶段 3:下载模块重写 +- 下载首页 +- 版本安装页 +- 模组/整合包/资源包/光影下载页 + +### 阶段 4:管理与账号模块重写 +- 管理首页 +- 模组管理、存档管理、世界管理 +- 账号管理 + +### 阶段 5:细节打磨 +- 页面切换动画 +- 玻璃卡片、玻璃对话框统一组件 +- 性能优化 +- 无障碍与 RTL 适配 + +## 7. 保留与废弃 + +### 7.1 保留 +- `FCLCore`:业务逻辑、下载、启动、模组管理核心 +- `FCLauncher`:JNI、渲染、JRE 启动 +- `JVMActivity`、`ControllerActivity` 等游戏相关 Activity +- 现有主题预设系统 + +### 7.2 废弃/替换 +- `FCL/src/main/res/layout/` 中大部分 XML 布局(逐步替换) +- `FCL/src/main/java/com/tungsten/fcl/ui/` 中大部分传统 View 页面(逐步替换) +- `FCLLibrary` 中的自定义 View 组件(用 Compose 等价物替换) + +## 8. 风险与约束 + +1. **工程量巨大**:80+ 页面/组件需要重写,可能需要数周时间 +2. **构建验证困难**:当前远程环境无法下载 Backdrop/Compose 依赖,需在本地验证 +3. **兼容性**:提升 minSdk 到 31 会放弃 Android 12 以下设备 +4. **性能**:Backdrop 效果依赖 RenderEffect,低端设备可能掉帧 +5. **维护**:需要同时维护新旧两套 UI 直到迁移完成 + +## 9. 验收标准 + +1. 应用启动后显示 Compose 主界面 +2. 底部玻璃导航栏可见且可点击切换 +3. 所有一级页面使用液态玻璃风格 +4. 主题预设切换时,玻璃 tint 颜色同步变化 +5. 亮/暗模式切换时,背景与玻璃效果正确适配 +6. 原有游戏启动功能不受影响 + +## 10. 下一步 + +本设计文档批准后,将使用 writing-plans skill 制定阶段 1 的详细实现计划。 diff --git a/gradle.properties b/gradle.properties index ac74161c2a..2b41161ae1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,6 +7,7 @@ # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.caching=true # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6e0d2b1a7d..47a24b2a43 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,7 @@ [versions] compileSdk = "35" +backdrop = "2.0.0" +composeBom = "2025.06.01" datastore = "1.2.1" kotlinxSerializationJson = "1.10.0" minSdk = "26" @@ -31,6 +33,14 @@ jelf = "0.9.0" bytehook = "1.0.10" [libraries] +backdrop = { group = "io.github.kyant0", name = "backdrop", version.ref = "backdrop" } +compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +compose-activity = { group = "androidx.activity", name = "activity-compose" } +compose-material3 = { group = "androidx.compose.material3", name = "material3" } +compose-navigation = { group = "androidx.navigation", name = "navigation-compose" } +compose-ui = { group = "androidx.compose.ui", name = "ui" } +compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } taptargetview = { group = "com.getkeepsafe.taptargetview", name = "taptargetview", version.ref = "taptargetview" } diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/settings.gradle.kts b/settings.gradle.kts index 0fa3b152f5..00eb28e1ea 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,5 +1,7 @@ pluginManagement { repositories { + // 国内镜像优先,加速插件下载 + maven("https://maven.aliyun.com/repository/gradle-plugin") gradlePluginPortal() google() mavenCentral() @@ -11,6 +13,11 @@ plugins { dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { + // 国内镜像优先 + maven("https://maven.aliyun.com/repository/google") + maven("https://maven.aliyun.com/repository/central") + maven("https://maven.aliyun.com/repository/public") + // 官方仓库兜底 google() mavenCentral() maven("https://jitpack.io") diff --git "a/\346\266\262\346\200\201\347\216\273\347\222\203\346\225\231\347\250\213.md" "b/\346\266\262\346\200\201\347\216\273\347\222\203\346\225\231\347\250\213.md" new file mode 100644 index 0000000000..6ab9845ec1 --- /dev/null +++ "b/\346\266\262\346\200\201\347\216\273\347\222\203\346\225\231\347\250\213.md" @@ -0,0 +1,2460 @@ +# Backdrop 库完整技术文档(AI 专用版) + +> **文档用途**:本文档面向 AI 助手,包含 Backdrop 安卓液态玻璃 UI 库的全部技术细节,精确到每个标点符号、每行代码、每个参数的含义和约束条件。 +> +> **原文档地址**:https://kyant.gitbook.io/backdrop +> **GitHub 仓库**:https://github.com/Kyant0/AndroidLiquidGlass +> **Shapes 库(配套)**:https://github.com/Kyant0/Shapes + +--- + +## AI 必须遵守的强制原则(MANDATORY RULES) + +> **以下规则是 AI 使用 Backdrop 库时必须遵守的硬性约束。违反 CRITICAL 级别规则将导致崩溃或功能完全失效,违反 MANDATORY 级别规则将导致视觉效果错误。** + +--- + +### CRITICAL(违反必崩溃 / 必失效) + +#### 规则 1:禁止在嵌套玻璃组件中使用 `layerBackdrop`,必须使用 `exportedBackdrop` + +当你在一个玻璃组件内部再放置另一个玻璃组件时("玻璃上的玻璃"),**绝对不能**在内层组件上使用 `Modifier.layerBackdrop()`,必须使用 `drawBackdrop` 的 `exportedBackdrop` 参数。 + +**原因**:`layerBackdrop` 将内容绘制到 backdrop,而内容又包含 `drawBackdrop` 绘制同一个 backdrop,形成无限循环,导致 `Fatal signal 11 (SIGSEGV)` 崩溃。 + +```kotlin +// ❌ 绝对禁止! +val inner = rememberLayerBackdrop() +Column(Modifier.layerBackdrop(inner)) { + Box(Modifier.drawBackdrop(backdrop = inner)) // 循环! +} + +// ✅ 必须这样写 +val inner = rememberLayerBackdrop() +Column( + Modifier.drawBackdrop( + backdrop = outerBackdrop, + exportedBackdrop = inner // 导出,不循环 + ) +) { + Box(Modifier.drawBackdrop(backdrop = inner)) +} +``` + +#### 规则 2:`drawContent()` 必须在 `rememberLayerBackdrop` 的 block 中调用 + +如果你创建了 `LayerBackdrop` 但没有调用 `drawContent()`,Composable 的内容不会被捕获到 backdrop 中,玻璃效果将完全失效。 + +```kotlin +// ❌ 效果不生效! +val backdrop = rememberLayerBackdrop() // 没有 drawContent() + +// ✅ 至少需要 +val backdrop = rememberLayerBackdrop { + drawContent() +} +``` + +#### 规则 3:Android 13+ 功能必须做版本检查 + +`lens()`、`gammaAdjustment()`、`RuntimeShader` 相关功能需要 Android 13+(API 33)。如果在 Android 12 及以下设备上调用这些函数,会崩溃。**每次使用这些函数都必须包裹在版本检查中**。 + +```kotlin +// ❌ 在 Android 12 上会崩溃 +effects = { + blur(4f.dp.toPx()) + lens(16f.dp.toPx(), 32f.dp.toPx()) // 没做版本检查! +} + +// ✅ 必须这样写 +effects = { + blur(4f.dp.toPx()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + lens(16f.dp.toPx(), 32f.dp.toPx()) + } +} +``` + +#### 规则 4:`lens()` 的 `shape` 必须是 `CornerBasedShape` + +`lens()` 效果要求 `drawBackdrop` 的 `shape` 参数必须是 `CornerBasedShape` 或其子类。使用 `CircleShape`、`GenericShape` 等非 `CornerBasedShape` 的形状会导致 lens 效果异常或崩溃。 + +```kotlin +// ❌ CircleShape 不是 CornerBasedShape,lens 不生效 +Modifier.drawBackdrop( + shape = { CircleShape }, + effects = { lens(...) } +) + +// ✅ 必须使用 RoundedCornerShape 或 ContinuousRoundedCornerShape +Modifier.drawBackdrop( + shape = { RoundedCornerShape(44f.dp) }, + effects = { lens(...) } +) +``` + +--- + +### MANDATORY(违反会导致视觉效果错误) + +#### 规则 5:效果顺序必须是 `color filter ⇒ blur ⇒ lens` + +在 `effects` 代码块中,三个类别的方法调用顺序**绝对不能颠倒**。顺序错误会导致视觉效果不正确(模糊区域出现奇怪颜色、透镜效果失效等)。 + +```kotlin +// ❌ 顺序错误 +effects = { + blur(4f.dp.toPx()) // 错误:blur 在先 + vibrancy() // 错误:vibrancy 在后 + lens(...) +} + +// ✅ 必须遵循的固定顺序 +effects = { + vibrancy() // 1. color filter 先 + blur(4f.dp.toPx()) // 2. blur 其次 + lens(...) // 3. lens 最后 +} +``` + +#### 规则 6:交互变换必须放在 `layerBlock` 中,不能放在 `graphicsLayer` 中 + +当你需要对玻璃组件应用缩放、旋转等变换时,**绝对不能**使用 `Modifier.graphicsLayer { }`,必须使用 `drawBackdrop` 的 `layerBlock` 参数。 + +**原因**:`graphicsLayer` 会同时缩放内容和 backdrop,导致 backdrop 错位。`layerBlock` 只缩放内容层,backdrop 保持原样。 + +```kotlin +// ❌ backdrop 会跟着缩放,位置错乱 +Modifier + .graphicsLayer { scaleX = 1.5f; scaleY = 1.5f } + .drawBackdrop(...) + +// ✅ 只缩放内容,backdrop 不变 +Modifier + .drawBackdrop( + ..., + layerBlock = { scaleX = 1.5f; scaleY = 1.5f } + ) +``` + +#### 规则 7:背景色必须在 `drawContent()` 之前绘制 + +在 `rememberLayerBackdrop` 的 block 中,`drawRect(backgroundColor)` 必须在 `drawContent()` 之前调用。如果顺序反过来,背景色会覆盖 Composable 内容。 + +```kotlin +// ❌ 背景色会覆盖内容! +val backdrop = rememberLayerBackdrop { + drawContent() + drawRect(backgroundColor) +} + +// ✅ 先背景,后内容 +val backdrop = rememberLayerBackdrop { + drawRect(backgroundColor) // 先 + drawContent() // 后 +} +``` + +#### 规则 8:`shape` 参数必须是 lambda 形式,不能直接传 `Shape` 实例 + +`drawBackdrop` 的 `shape` 参数类型是 `() -> Shape`(lambda),不是 `Shape`。必须写成 `shape = { CircleShape }` 而不是 `shape = CircleShape`。 + +```kotlin +// ❌ 类型错误,编译失败 +Modifier.drawBackdrop( + shape = CircleShape, // 类型不匹配 + ... +) + +// ✅ 必须用 lambda 包裹 +Modifier.drawBackdrop( + shape = { CircleShape }, + ... +) +``` + +#### 规则 9:嵌套玻璃组件必须使用 `shadow = null` + +在嵌套玻璃组件中(如 bottom sheet 内的按钮),必须设置 `shadow = null` 来禁用阴影。否则会破坏玻璃效果的视觉层次。 + +```kotlin +// ✅ 嵌套玻璃组件必须禁用阴影 +Modifier.drawBackdrop( + backdrop = bottomSheetBackdrop, + shape = { CircleShape }, + shadow = null, // 必须! + effects = { ... } +) +``` + +--- + +### IMPORTANT(违反会导致体验/性能下降) + +#### 规则 10:所有尺寸参数必须使用 `dp.toPx()` 转换 + +模糊半径、透镜高度等参数是像素值,**必须**使用 `dp.toPx()` 转换。**绝对不能**使用硬编码的像素值,否则在不同屏幕密度下效果不一致。 + +```kotlin +// ❌ 硬编码像素,不同设备效果不一致 +blur(10f) + +// ✅ 必须转换 +blur(4f.dp.toPx()) +``` + +#### 规则 11:着色玻璃效果必须使用 `BlendMode.Hue` + +给玻璃组件着色时,使用 `BlendMode.Hue` 混合模式,而不是直接覆盖不透明颜色。这样 backdrop 的色调会适应目标颜色,同时保留明暗变化。 + +```kotlin +// ❌ 直接覆盖会丢失玻璃效果 +onDrawSurface = { + drawRect(Color(0xFF0088FF)) +} + +// ✅ 使用 BlendMode.Hue 保留玻璃质感 +onDrawSurface = { + val tint = Color(0xFF0088FF) + drawRect(tint, blendMode = BlendMode.Hue) + drawRect(tint.copy(alpha = 0.75f)) +} +``` + +#### 规则 12:`clickable` 禁用默认指示器时必须两个参数都为 null + +使用 `clickable` 配合自定义手势时,禁用默认涟漪效果需要**同时**设置 `interactionSource = null` 和 `indication = null`,缺一不可。 + +```kotlin +// ❌ 只设置一个参数,涟漪效果仍可能出现 +Modifier.clickable(interactionSource = null) {} + +// ✅ 两个参数都必须为 null +Modifier.clickable(interactionSource = null, indication = null) {} +``` + +#### 规则 13:始终在 `drawRect(backgroundColor)` 中绘制背景色 + +使用 `rememberLayerBackdrop` 时,必须调用 `drawRect(backgroundColor)` 绘制背景色。否则 `MainNavHost` 未覆盖的区域会出现透明像素或黑色区域。 + +```kotlin +// ❌ 可能出现透明像素 +val backdrop = rememberLayerBackdrop { + drawContent() +} + +// ✅ 必须先绘制背景色 +val backdrop = rememberLayerBackdrop { + drawRect(backgroundColor) + drawContent() +} +``` + +#### 规则 14:复用 backdrop 实例,避免重复创建 + +同一个 backdrop 可以被多个 `drawBackdrop` 组件共享。不要为每个玻璃组件创建新的 backdrop,除非它们需要不同的背景内容。 + +```kotlin +// ❌ 不必要的重复创建 +val b1 = rememberLayerBackdrop { drawRect(bg); drawContent() } +val b2 = rememberLayerBackdrop { drawRect(bg); drawContent() } +// b1 和 b2 内容完全相同,浪费资源 + +// ✅ 共用一个 backdrop +val backdrop = rememberLayerBackdrop { drawRect(bg); drawContent() } +// 多个组件共用同一个 backdrop +``` + +--- + +## 目录 + +1. [AI 必须遵守的强制原则(MANDATORY RULES)](#ai-必须遵守的强制原则mandatory-rules) + - [CRITICAL(违反必崩溃 / 必失效)](#critical违反必崩溃--必失效) + - [MANDATORY(违反会导致视觉效果错误)](#mandatory违反会导致视觉效果错误) + - [IMPORTANT(违反会导致体验/性能下降)](#important违反会导致体验性能下降) +2. [Get started(入门指南)](#get-started入门指南) + - [什么是 Backdrop](#什么是-backdrop) + - [核心原理](#核心原理) + - [依赖安装](#依赖安装) + - [最低系统要求](#最低系统要求) +2. [Tutorials(教程)](#tutorials教程) + - [Glass Bottom Bar(玻璃底部导航栏)](#glass-bottom-bar玻璃底部导航栏) + - [Interactive Glass Bottom Bar(可交互的玻璃底部导航栏)](#interactive-glass-bottom-bar可交互的玻璃底部导航栏) + - [Glass Bottom Sheet(玻璃底部面板)](#glass-bottom-sheet玻璃底部面板) + - [Glass Slider(玻璃滑块)](#glass-slider玻璃滑块) + - [Smoother rounded corners(更平滑的圆角)](#smoother-rounded-corners更平滑的圆角) + - [Progressive blur(渐进式模糊)](#progressive-blur渐进式模糊) +3. [API 参考](#api-参考) + - [Modifier 扩展函数](#modifier-扩展函数) + - [Backdrops(背景板类型)](#backdrops背景板类型) + - [Backdrop effects(背景板效果)](#backdrop-effects背景板效果) +4. [FAQ(常见问题)](#faq常见问题) +5. [架构与原理深度解析](#架构与原理深度解析) +6. [常见错误与解决方案](#常见错误与解决方案) +7. [最佳实践清单](#最佳实践清单) + +--- + +## Get started(入门指南) + +### 什么是 Backdrop + +Backdrop 是一个用于 **Android Jetpack Compose** 的液态玻璃(Liquid Glass)UI 效果库。其全称为 **CMP Backdrop library**,即 Compose Multiplatform Backdrop library。 + +该库的核心能力是将**背景(backdrop)**的副本绘制到**前景(foreground)**上,并应用各种视觉效果,从而实现令人惊叹的液态玻璃效果。 + +![液态玻璃效果示例](https://aka.doubaocdn.com/s/pbqS1wcxxK) + +### 核心原理 + +Backdrop 库的工作流程分为三个步骤: + +1. **捕获背景**:通过 `rememberLayerBackdrop()` 创建一个背景板对象,并使用 `Modifier.layerBackdrop(backdrop)` 将 Composable 的内容绘制到这个背景板上。 +2. **应用效果**:通过 `Modifier.drawBackdrop()` 将背景板的内容绘制到前景,并在绘制过程中应用各种视觉效果(模糊、透镜、颜色滤镜等)。 +3. **叠加表面层(可选)**:通过 `onDrawSurface` 参数在效果之上绘制一个半透明的表面层,以提高内容的可读性。 + +### 依赖安装 + +在模块级别的 `build.gradle.kts` 文件中添加以下依赖: + +```kotlin +dependencies { + implementation("io.github.kyant0:backdrop:") +} +``` + +请将 `` 替换为实际的库版本号。例如: + +```kotlin +dependencies { + implementation("io.github.kyant0:backdrop:1.0.0") +} +``` + +### 最低系统要求 + +| 功能 | 最低 Android 版本 | API 级别 | +|------|------------------|----------| +| 基础 backdrop 效果(blur、color filter、vibrancy 等) | Android 12 | API 31 | +| Lens(透镜)效果 | Android 13 | API 33 | +| RuntimeShader 相关效果(渐进式模糊、自定义着色器等) | Android 13 | API 33 | +| Gamma adjustment(伽马调整) | Android 13 | API 33 | + +**重要提示**:所有效果本质上都是 `android.graphics.RenderEffect`,因此基础效果需要 Android 12+。涉及 `RuntimeShader` 的效果需要 Android 13+。 + +--- + +## Tutorials(教程) + +--- + +### Glass Bottom Bar(玻璃底部导航栏) + +> **原文档地址**:https://kyant.gitbook.io/backdrop/tutorials/glass-bottom-bar + +#### 目标 + +在 `MainNavHost` 上方创建一个玻璃底部导航栏。 + +**目标效果图**: + +![目标效果](https://aka.doubaocdn.com/s/bWcS1wcxxL) + +#### 初始代码结构 + +```kotlin +Box(Modifier.fillMaxSize()) { + MainNavHost() +} +``` + +`MainNavHost` 的初始外观如下: + +![MainNavHost 初始外观](https://aka.doubaocdn.com/s/tbid1wcxxL) + +#### 你将学到的知识点 + +- 如何创建和绘制 backdrop(背景板) +- 如何对 backdrop 应用效果 +- 如何正确处理背景绘制(避免透明像素) +- 如何确保内容的可读性 + +#### 完整步骤详解 + +##### 步骤 1:绘制 backdrop 并添加 lens(透镜)效果 + +```kotlin +Box(Modifier.fillMaxSize()) { + val backdrop = rememberLayerBackdrop() + + MainNavHost( + modifier = Modifier.layerBackdrop(backdrop) + ) + + Box( + Modifier + .safeContentPadding() + .drawBackdrop( + backdrop = backdrop, + shape = { CircleShape }, + effects = { + lens(16f.dp.toPx(), 32f.dp.toPx()) + } + ) + .height(64f.dp) + .fillMaxWidth() + .align(Alignment.BottomCenter) + ) +} +``` + +**代码逐行解析:** + +- `val backdrop = rememberLayerBackdrop()`:创建一个 `LayerBackdrop` 实例。`rememberLayerBackdrop()` 是一个 `@Composable` 函数,必须在 Composable 上下文中调用。此函数位于 `com.kyant.backdrop.backdrops` 包中。 +- `Modifier.layerBackdrop(backdrop)`:`layerBackdrop` 是 `com.kyant.backdrop.backdrops` 包中的 Modifier 扩展函数。它将 `MainNavHost` 的内容绘制到 `backdrop` 中。这是捕获背景的关键步骤。 +- `Modifier.drawBackdrop(...)`:`drawBackdrop` 是 `com.kyant.backdrop` 包中的 Modifier 扩展函数。它将 `backdrop` 的内容绘制到当前 Box 上,并应用效果。 +- `shape = { CircleShape }`:指定绘制区域的形状为圆形。注意这里使用 lambda `{ CircleShape }` 而不是直接传 `CircleShape` 实例。这是因为 `shape` 参数的类型是 `() -> Shape`(无参 lambda),延迟求值。 +- `effects = { lens(16f.dp.toPx(), 32f.dp.toPx()) }`:`effects` 参数的类型是 `BackdropEffectScope.() -> Unit`,即 `BackdropEffectScope` 的扩展函数。`lens` 是 `BackdropEffectScope` 的方法。`16f.dp.toPx()` 中的 `f` 后缀使 `16` 成为 `Float` 字面量,`.dp` 创建 `Dp` 值,`.toPx()` 转换为像素。第一个参数是 `refractionHeight`(折射高度),第二个参数是 `refractionAmount`(折射量)。 +- `.safeContentPadding()`:添加安全区域内边距,避免内容与系统导航栏重叠。此 Modifier 来自 Backdrop 库。 +- `.align(Alignment.BottomCenter)`:将底部导航栏对齐到屏幕底部中央。这个方法是 `BoxScope` 的扩展函数,因为当前 Box 在 `Box(Modifier.fillMaxSize())` 内部。 + +**运行效果:** + +![步骤 1 效果](https://aka.doubaocdn.com/s/UrKR1wcxxL) + +**问题分析:** + +效果不对!底部导航栏中有透明像素。这是因为 `MainNavHost` 可能没有填满整个屏幕,或者其外部区域没有绘制内容。因此,**`MainNavHost` 外部的背景也必须被绘制到 backdrop 中**。 + +##### 步骤 2:将背景绘制到 backdrop 中 + +```kotlin +val backgroundColor = Color.White +val backdrop = rememberLayerBackdrop { + drawRect(backgroundColor) + drawContent() +} +``` + +**代码逐行解析:** + +- `val backgroundColor = Color.White`:定义背景颜色为白色。这是为了填充 `MainNavHost` 外部的空白区域。 +- `rememberLayerBackdrop { ... }`:`rememberLayerBackdrop` 接受一个可选的 `block: DrawScope.() -> Unit` 参数,默认值为 `{}`。在这个 block 中可以添加额外的绘制命令。 +- `drawRect(backgroundColor)`:`DrawScope` 的 `drawRect` 方法,绘制一个填充整个区域的矩形,颜色为 `backgroundColor`。这里使用无参重载,绘制 fillMaxSize 的矩形。 +- `drawContent()`:`DrawScope` 的方法,绘制被 `layerBackdrop` modifier 包裹的 Composable 内容(即 `MainNavHost`)。**注意**:`drawContent()` 必须在 `rememberLayerBackdrop` 的 block 中调用,否则不会捕获 Composable 内容。 + +**绘制顺序很重要**:先绘制背景色,再绘制内容。如果顺序反过来(先 `drawContent()` 后 `drawRect(backgroundColor)`),背景色会覆盖内容。 + +**运行效果:** + +![步骤 2 效果](https://aka.doubaocdn.com/s/F7d61wcxxL) + +现在背景被正确填充了,没有透明像素。你可以尝试调整 lens 效果的参数,观察会发生什么变化。 + +##### 步骤 3:添加 blur(模糊)效果和 vibrancy(鲜艳度) + +```kotlin +Modifier.drawBackdrop( + backdrop = backdrop, + shape = { CircleShape }, + effects = { + vibrancy() + blur(4f.dp.toPx()) + lens(16f.dp.toPx(), 32f.dp.toPx()) + } +) +``` + +**代码逐行解析:** + +- `vibrancy()`:`BackdropEffectScope` 的方法,增强饱和度。将饱和度乘以 1.5。等价于 `colorControls(saturation = 1.5f)`。属于 color filter 类别。 +- `blur(4f.dp.toPx())`:`BackdropEffectScope` 的方法,应用高斯模糊。`4f.dp.toPx()` 将 4dp 转换为像素值作为模糊半径。属于 blur 类别。 +- `lens(16f.dp.toPx(), 32f.dp.toPx())`:`BackdropEffectScope` 的方法,应用透镜效果。需要 Android 13+。属于 lens 类别。 + +**效果顺序至关重要**:必须按照 `color filter ⇒ blur ⇒ lens` 的顺序应用效果。`vibrancy()` 是 color filter,`blur(...)` 是 blur,`lens(...)` 是 lens。顺序错误会导致视觉效果不正确。 + +**运行效果:** + +![步骤 3 效果](https://aka.doubaocdn.com/s/wih01wcxxL) + +##### 步骤 4:添加 surface(表面层)以提高可读性 + +```kotlin +Modifier.drawBackdrop( + backdrop = backdrop, + shape = { CircleShape }, + effects = { + vibrancy() + blur(4f.dp.toPx()) + lens(16f.dp.toPx(), 32f.dp.toPx()) + }, + onDrawSurface = { drawRect(Color.White.copy(alpha = 0.5f)) } +) +``` + +**代码逐行解析:** + +- `onDrawSurface = { ... }`:`onDrawSurface` 参数的类型是 `DrawScope.() -> Unit`,在效果层之上绘制一个表面层。这个表面层会覆盖在模糊和透镜效果之上。 +- `drawRect(Color.White.copy(alpha = 0.5f))`:绘制一个半透明白色矩形。`Color.White.copy(alpha = 0.5f)` 创建一个白色但 alpha 为 0.5(50% 不透明度)的颜色。`drawRect` 是 `DrawScope` 的方法。 + +**为什么需要 surface 层?** + +液态玻璃效果会让底部的内容透过来,如果底部内容颜色复杂或文字较多,前景内容(如导航栏图标、文字)的可读性会下降。添加一个半透明的表面层可以提高对比度,确保前景内容清晰可读。 + +**运行效果:** + +![步骤 4 效果](https://aka.doubaocdn.com/s/gi9C1wcxxL) + +可读性得到了提升。**你必须在美观和可读性之间取得平衡**。 + +**设计权衡**: + +- surface 层越不透明(alpha 越大),可读性越好,但玻璃效果越弱。 +- surface 层越透明(alpha 越小),玻璃效果越好,但可读性可能下降。 + +##### 步骤 5:最终完整代码 + +```kotlin +Box(Modifier.fillMaxSize()) { + val backgroundColor = Color.White + val backdrop = rememberLayerBackdrop { + drawRect(backgroundColor) + drawContent() + } + + MainNavHost( + modifier = Modifier.layerBackdrop(backdrop) + ) + + Box( + Modifier + .safeContentPadding() + .drawBackdrop( + backdrop = backdrop, + shape = { CircleShape }, + effects = { + vibrancy() + blur(4f.dp.toPx()) + lens(16f.dp.toPx(), 32f.dp.toPx()) + }, + onDrawSurface = { drawRect(Color.White.copy(alpha = 0.5f)) } + ) + .height(64f.dp) + .fillMaxWidth() + .align(Alignment.BottomCenter) + ) +} +``` + +#### 练习:添加一个有色玻璃图标按钮 + +**目标效果:** + +![有色玻璃图标按钮](https://aka.doubaocdn.com/s/81sg1wcxxL) + +**最终完整代码:** + +```kotlin +Box(Modifier.fillMaxSize()) { + val backgroundColor = Color.White + val backdrop = rememberLayerBackdrop { + drawRect(backgroundColor) + drawContent() + } + + MainNavHost( + modifier = Modifier.layerBackdrop(backdrop) + ) + + Row( + Modifier + .safeContentPadding() + .height(64f.dp) + .fillMaxWidth() + .align(Alignment.BottomCenter), + horizontalArrangement = Arrangement.spacedBy(8f.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + Modifier + .drawBackdrop( + backdrop = backdrop, + shape = { CircleShape }, + effects = { + vibrancy() + blur(4f.dp.toPx()) + lens(16f.dp.toPx(), 32f.dp.toPx()) + }, + onDrawSurface = { drawRect(Color.White.copy(alpha = 0.5f)) } + ) + .fillMaxHeight() + .weight(1f) + ) + Box( + Modifier + .drawBackdrop( + backdrop = backdrop, + shape = { CircleShape }, + effects = { + vibrancy() + blur(4f.dp.toPx()) + lens(16f.dp.toPx(), 32f.dp.toPx()) + }, + onDrawSurface = { + val tint = Color(0xFF0088FF) + drawRect(tint, blendMode = BlendMode.Hue) + drawRect(tint.copy(alpha = 0.75f)) + } + ) + .aspectRatio(1f) + ) + } +} +``` + +**代码逐行解析:** + +- `Row(...)`:使用 `Row` 布局将底部导航栏分为两个部分。 +- `horizontalArrangement = Arrangement.spacedBy(8f.dp)`:子元素之间间隔 8dp。 +- `verticalAlignment = Alignment.CenterVertically`:子元素垂直居中对齐。 +- 第一个 `Box`:普通玻璃效果按钮,使用 `weight(1f)` 占据剩余空间。 +- 第二个 `Box`:有色玻璃效果按钮,使用 `aspectRatio(1f)` 保持正方形比例。 +- `val tint = Color(0xFF0088FF)`:定义着色颜色为蓝色。`0xFF0088FF` 是 ARGB 格式,`0xFF` 是 Alpha(不透明),`0x00` 是 Red,`0x88` 是 Green,`0xFF` 是 Blue。 +- `drawRect(tint, blendMode = BlendMode.Hue)`:使用 `BlendMode.Hue` 混合模式绘制着色矩形。`BlendMode.Hue` 会将源颜色(tint)的色相与目标颜色(backdrop)的饱和度和亮度结合。这会使 backdrop 的色调适应着色颜色,而保持亮度和饱和度不变。 +- `drawRect(tint.copy(alpha = 0.75f))`:再绘制一个 75% 不透明度的蓝色矩形,增强着色效果。 + +**为什么使用 `BlendMode.Hue`?** + +建议使用 `BlendMode.Hue`,这样 backdrop 的色调会适应着色颜色,同时保留原始内容的明暗变化,使效果更加自然。如果直接使用不透明的颜色覆盖,会丢失所有玻璃效果。 + +--- + +### Interactive Glass Bottom Bar(可交互的玻璃底部导航栏) + +> **原文档地址**:https://kyant.gitbook.io/backdrop/tutorials/interactive-glass-bottom-bar + +#### 目标 + +为玻璃底部导航栏添加"按下缩放"的交互反馈动画。 + +**目标效果:** + +![交互式玻璃底部导航栏](https://aka.doubaocdn.com/s/bzjc1wcxxM) + +#### 你将学到的知识点 + +- 如何正确处理变换(缩放、旋转) +- 如何使用 `drawBackdrop` modifier 的 `layerBlock` 参数 +- 为什么不能在 `graphicsLayer` 中对玻璃组件进行变换 + +#### 完整步骤详解 + +##### 步骤 1:按下缩放(错误示范) + +在上一节 Glass Bottom Bar 最终代码的基础上,更新代码: + +```kotlin +val animationScope = rememberCoroutineScope() +val progressAnimation = remember { Animatable(0f) } + +Box( + Modifier + .graphicsLayer { + val progress = progressAnimation.value + val maxScale = (size.width + 16f.dp.toPx()) / size.width + val scale = lerp(1f, maxScale, progress) + scaleX = scale + scaleY = scale + } + .drawBackdrop( + backdrop = backdrop, + shape = { CircleShape }, + effects = { + vibrancy() + blur(4f.dp.toPx()) + lens(16f.dp.toPx(), 32f.dp.toPx()) + }, + onDrawSurface = { drawRect(Color.White.copy(alpha = 0.5f)) } + ) + .clickable {} + .pointerInput(animationScope) { + val animationSpec = spring(0.5f, 300f, 0.001f) + awaitEachGesture { + // press + awaitFirstDown() + animationScope.launch { + progressAnimation.animateTo(1f, animationSpec) + } + + // release + waitForUpOrCancellation() + animationScope.launch { + progressAnimation.animateTo(0f, animationSpec) + } + } + } + .fillMaxHeight() + .weight(1f) +) +``` + +**代码逐行解析:** + +- `val animationScope = rememberCoroutineScope()`:创建一个协程作用域,用于在 `pointerInput` 中启动动画协程。 +- `val progressAnimation = remember { Animatable(0f) }`:创建一个可动画化的浮点值,初始值为 `0f`。`0` 表示未按下,`1` 表示完全按下。`Animatable` 来自 `androidx.compose.animation.core`。 +- `Modifier.graphicsLayer { ... }`:`graphicsLayer` 是 Compose 的 Modifier 扩展,允许在绘制层上应用变换。这里根据 `progressAnimation.value` 计算缩放比例。 +- `val maxScale = (size.width + 16f.dp.toPx()) / size.width`:计算最大缩放比例。当按下时,组件宽度增加 16dp。`size.width` 是当前 `graphicsLayer` 的宽度。 +- `val scale = lerp(1f, maxScale, progress)`:`lerp` 是线性插值函数。当 `progress = 0` 时返回 `1f`(不缩放),`progress = 1` 时返回 `maxScale`(最大缩放)。 +- `scaleX = scale; scaleY = scale`:应用等比缩放。`scaleX` 和 `scaleY` 是 `GraphicsLayerScope` 的属性。 +- `.clickable {}`:添加点击事件。空的 lambda 表示只接收点击事件,不执行任何操作。这是为了配合 `pointerInput` 使用。 +- `.pointerInput(animationScope) { ... }`:`pointerInput` 是 Compose 的 Modifier 扩展,用于处理低级指针事件。第一个参数 `animationScope` 是 key,当 key 变化时会重启手势处理。 +- `val animationSpec = spring(0.5f, 300f, 0.001f)`:定义弹簧动画规格。`spring` 函数来自 `androidx.compose.animation.core`。参数: + - `0.5f`:阻尼比(dampingRatio),值越小弹跳越多。 + - `300f`:刚度(stiffness),值越大弹簧越硬。 + - `0.001f`:可视阈值(visibilityThreshold),动画结束的精度的阈值。 +- `awaitEachGesture { ... }`:`PointerInputScope` 的挂起函数,等待并处理每个手势。 +- `awaitFirstDown()`:挂起函数,等待第一个按下事件(`ACTION_DOWN`)。 +- `animationScope.launch { progressAnimation.animateTo(1f, animationSpec) }`:在协程中启动动画,将 `progress` 从当前值动画到 `1f`(按下状态)。 +- `waitForUpOrCancellation()`:挂起函数,等待手指抬起(`ACTION_UP`)或手势取消(`ACTION_CANCEL`)。 +- `animationScope.launch { progressAnimation.animateTo(0f, animationSpec) }`:启动动画,将 `progress` 动画回 `0f`(释放状态)。 + +**按下底部导航栏的效果:** + +![按下效果](https://aka.doubaocdn.com/s/SUMP1wcxxM) + +**问题分析:** + +糟糕!backdrop 位置错位了,它会随着底部导航栏一起缩放。**但是 backdrop 不应该缩放**。 + +**根本原因:** + +`graphicsLayer` 中的变换会同时应用于整个 Composable 的内容和 backdrop。因为 `drawBackdrop` 在 Modifier 链中位于 `graphicsLayer` 之后,backdrop 已经被缩放了。但 backdrop 应该保持原始大小,只缩放前景内容(如文字、图标等)。 + +##### 步骤 2:防止 backdrop 缩放(正确做法) + +将 `graphicsLayer` 中的缩放代码移动到 `drawBackdrop` modifier 的 `layerBlock` 参数中。 + +```kotlin +val animationScope = rememberCoroutineScope() +val progressAnimation = remember { Animatable(0f) } + +Box( + Modifier + .drawBackdrop( + backdrop = backdrop, + shape = { CircleShape }, + effects = { + vibrancy() + blur(4f.dp.toPx()) + lens(16f.dp.toPx(), 32f.dp.toPx()) + }, + layerBlock = { + val progress = progressAnimation.value + val maxScale = (size.width + 16f.dp.toPx()) / size.width + val scale = lerp(1f, maxScale, progress) + scaleX = scale + scaleY = scale + }, + onDrawSurface = { drawRect(Color.White.copy(alpha = 0.5f)) } + ) + .clickable(interactionSource = null, indication = null) {} + .pointerInput(animationScope) { + val animationSpec = spring(0.5f, 300f, 0.001f) + awaitEachGesture { + // press + awaitFirstDown() + animationScope.launch { + progressAnimation.animateTo(1f, animationSpec) + } + + // release + waitForUpOrCancellation() + animationScope.launch { + progressAnimation.animateTo(0f, animationSpec) + } + } + } + .fillMaxHeight() + .weight(1f) +) +``` + +**代码逐行解析:** + +- `layerBlock = { ... }`:`layerBlock` 参数的类型是 `GraphicsLayerScope.() -> Unit`。它是 `drawBackdrop` 的一个参数,用于对**内容层**(不包括 backdrop)应用图形变换。这是 `drawBackdrop` 内部的实现细节。 +- `clickable(interactionSource = null, indication = null) {}`:禁用了默认的点击效果。`interactionSource = null` 表示不使用交互源,`indication = null` 表示不使用默认的涟漪效果指示器。空的 lambda `{}` 是 `onClick` 回调。 + +**关键区别:** + +- `Modifier.graphicsLayer { ... }`:变换同时应用于内容和 backdrop。在 Modifier 链中,它会影响后续所有 modifier。 +- `drawBackdrop(layerBlock = { ... })`:变换只应用于内容层(文字、图标等),backdrop 保持原样。这是 `drawBackdrop` 内部实现的隔离。 + +**再次按下底部导航栏的效果:** + +![正确效果](https://aka.doubaocdn.com/s/SHb61wcxxM) + +现在效果正确了!内容会缩放,但 backdrop 不会缩放。 + +#### 最终完整代码 + +```kotlin +Box(Modifier.fillMaxSize()) { + val backgroundColor = Color.White + val backdrop = rememberLayerBackdrop { + drawRect(backgroundColor) + drawContent() + } + + MainNavHost( + modifier = Modifier.layerBackdrop(backdrop) + ) + + Row( + Modifier + .safeContentPadding() + .height(64f.dp) + .fillMaxWidth() + .align(Alignment.BottomCenter), + horizontalArrangement = Arrangement.spacedBy(8f.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val animationScope = rememberCoroutineScope() + val progressAnimation = remember { Animatable(0f) } + + Box( + Modifier + .drawBackdrop( + backdrop = backdrop, + shape = { CircleShape }, + effects = { + vibrancy() + blur(4f.dp.toPx()) + lens(16f.dp.toPx(), 32f.dp.toPx()) + }, + layerBlock = { + val progress = progressAnimation.value + val maxScale = (size.width + 16f.dp.toPx()) / size.width + val scale = lerp(1f, maxScale, progress) + scaleX = scale + scaleY = scale + }, + onDrawSurface = { drawRect(Color.White.copy(alpha = 0.5f)) } + ) + .clickable(interactionSource = null, indication = null) {} + .pointerInput(animationScope) { + val animationSpec = spring(0.5f, 300f, 0.001f) + awaitEachGesture { + // press + awaitFirstDown() + animationScope.launch { + progressAnimation.animateTo(1f, animationSpec) + } + + // release + waitForUpOrCancellation() + animationScope.launch { + progressAnimation.animateTo(0f, animationSpec) + } + } + } + .fillMaxHeight() + .weight(1f) + ) + Box( + Modifier + .drawBackdrop( + backdrop = backdrop, + shape = { ContinuousCapsule }, + effects = { + vibrancy() + blur(4f.dp.toPx()) + lens(16f.dp.toPx(), 32f.dp.toPx()) + }, + onDrawSurface = { + val tint = Color(0xFF0088FF) + drawRect(tint, blendMode = BlendMode.Hue) + drawRect(tint.copy(alpha = 0.75f)) + } + ) + .aspectRatio(1f) + ) + } +} +``` + +**关键要点总结:** + +- `layerBlock` 用于对**内容层**应用变换(缩放、旋转等),不影响 backdrop。 +- `clickable(interactionSource = null, indication = null)` 用于禁用默认点击效果。两个参数都设置为 null 是必需的。 +- `ContinuousCapsule` 是 Shapes 库提供的更平滑的胶囊形状,比 `RoundedCornerShape` 的圆角更自然。 + +--- + +### Glass Bottom Sheet(玻璃底部面板) + +> **原文档地址**:https://kyant.gitbook.io/backdrop/tutorials/glass-bottom-sheet + +#### 目标 + +基于以下代码创建一个玻璃底部面板: + +```kotlin +Box(Modifier.fillMaxSize()) { + val backgroundColor = Color.White + val backdrop = rememberLayerBackdrop { + drawRect(backgroundColor) + drawContent() + } + + MainNavHost( + modifier = Modifier.layerBackdrop(backdrop) + ) + + GlassBottomSheet(backdrop = backdrop) +} +``` + +#### 你将学到的知识点 + +- 如何处理"玻璃上的玻璃"(glass on glass)的情况 +- 如何使用 `drawBackdrop` modifier 的 `exportedBackdrop` 参数 +- 为什么不能在嵌套玻璃组件中使用 `layerBackdrop` + +#### 完整步骤详解 + +##### 步骤 1:创建 GlassBottomSheet + +创建 `GlassBottomSheet.kt` 文件: + +```kotlin +// your package + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeContentPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.effects.vibrancy + +@Composable +fun BoxScope.GlassBottomSheet(backdrop: Backdrop) { + Column( + Modifier + .safeContentPadding() + .drawBackdrop( + backdrop = backdrop, + shape = { RoundedCornerShape(44f.dp) }, + effects = { + vibrancy() + blur(4f.dp.toPx()) + lens(24f.dp.toPx(), 48f.dp.toPx(), true) + }, + onDrawSurface = { drawRect(Color.White.copy(alpha = 0.5f)) } + ) + .fillMaxWidth() + .align(Alignment.BottomCenter) + ) { + Spacer(Modifier.height(256f.dp)) + // glass button + Box( + Modifier + .padding(16f.dp) + .drawBackdrop( + backdrop = backdrop, + shape = { CircleShape }, + shadow = null, + effects = { + vibrancy() + blur(4f.dp.toPx()) + lens(16f.dp.toPx(), 32f.dp.toPx()) + }, + onDrawSurface = { drawRect(Color.White.copy(alpha = 0.5f)) } + ) + .height(56f.dp) + .fillMaxWidth() + ) + } +} +``` + +**代码逐行解析:** + +- `fun BoxScope.GlassBottomSheet(...)`:函数定义在 `BoxScope` 中,因此可以直接使用 `BoxScope` 的 `align` 等扩展函数。这是 Compose 的常见模式,用于限制函数的使用范围。 +- `Backdrop` 类型:`com.kyant.backdrop.Backdrop`,是所有 backdrop 的父接口。 +- `shape = { RoundedCornerShape(44f.dp) }`:底部面板使用 44dp 的圆角矩形。`RoundedCornerShape` 是 `CornerBasedShape` 的子类。 +- `lens(24f.dp.toPx(), 48f.dp.toPx(), true)`:透镜效果,第三个参数 `true` 表示启用 `depthEffect`(深度效果)。第四个参数 `chromaticAberration` 使用默认值 `false`。 +- `Spacer(Modifier.height(256f.dp))`:占用 256dp 的高度,使底部面板有足够的高度来容纳内容。 +- `shadow = null`:禁用阴影效果。对于嵌套的玻璃组件,通常不需要额外的阴影。`shadow` 参数的类型是 `Shadow?`,null 表示不绘制阴影。 + +**运行效果:** + +![步骤 1 效果](https://aka.doubaocdn.com/s/uwZS1wcxxM) + +**问题分析:** + +玻璃按钮的 backdrop 是 `backdrop`(即 `MainNavHost` 的背景),但我们希望玻璃按钮能**包含底部面板的玻璃效果**。也就是说,玻璃按钮应该"穿透"底部面板看到 `MainNavHost`,而不是只看到 `MainNavHost` 而忽略底部面板的玻璃效果。 + +##### 步骤 2:使用底部面板作为玻璃按钮的 backdrop(错误代码) + +错误的想法是在 `drawBackdrop` 之后设置一个新的 `LayerBackdrop`。 + +```kotlin +val bottomSheetBackdrop = rememberLayerBackdrop() +Column( + Modifier + .layerBackdrop(bottomSheetBackdrop) + .drawBackdrop() +) { + // glass button + Box( + Modifier + .drawBackdrop( + backdrop = bottomSheetBackdrop, + ) + ) +} +``` + +**你会得到一个崩溃:** + +``` +Fatal signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x__ in tid __ (RenderThread), pid __ +``` + +**原因分析:** + +因为 `layerBackdrop` modifier 会将 Column 的内容绘制到 `bottomSheetBackdrop`,而 Column 的内容中包含了 `drawBackdrop(backdrop = bottomSheetBackdrop)`,`drawBackdrop` 又会尝试绘制 `bottomSheetBackdrop`。**这是一个无限循环!** + +具体来说: +1. `Column` 通过 `Modifier.layerBackdrop(bottomSheetBackdrop)` 将自身内容绘制到 `bottomSheetBackdrop`。 +2. `Column` 的内容包含 `Box`,`Box` 通过 `Modifier.drawBackdrop(backdrop = bottomSheetBackdrop)` 绘制 `bottomSheetBackdrop`。 +3. 但 `bottomSheetBackdrop` 的内容就是 `Column` 本身。 +4. 于是形成循环:Column → bottomSheetBackdrop → Column → bottomSheetBackdrop → ... 无限递归,最终导致 RenderThread 崩溃(SIGSEGV)。 + +##### 步骤 3:使用底部面板作为玻璃按钮的 backdrop(正确代码) + +使用 `drawBackdrop` modifier 中的 `exportedBackdrop` 参数,**它会跳过绘制内容,只导出 backdrop**。 + +```kotlin +val bottomSheetBackdrop = rememberLayerBackdrop() +Column( + Modifier + .drawBackdrop( + backdrop = backdrop, + exportedBackdrop = bottomSheetBackdrop, + ) +) { + // glass button + Box( + Modifier + .drawBackdrop( + backdrop = bottomSheetBackdrop, + ) + ) +} +``` + +**代码逐行解析:** + +- `val bottomSheetBackdrop = rememberLayerBackdrop()`:创建一个新的 `LayerBackdrop`,用于接收导出的 backdrop。 +- `exportedBackdrop = bottomSheetBackdrop`:将当前 `drawBackdrop` 处理后的结果(包括效果)导出到 `bottomSheetBackdrop`。**关键**:`exportedBackdrop` 不会将当前 Composable 的内容绘制到 `bottomSheetBackdrop`,而是将处理后的 backdrop 纹理写入 `bottomSheetBackdrop`。 +- 玻璃按钮使用 `bottomSheetBackdrop`(而非 `backdrop`)作为其 backdrop,这样就能看到底部面板的玻璃效果。 + +**运行效果:** + +![正确效果](https://aka.doubaocdn.com/s/8JPY1wcxxM) + +#### 最终完整代码 + +`GlassBottomSheet.kt`: + +```kotlin +// your package + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeContentPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.effects.vibrancy + +@Composable +fun BoxScope.GlassBottomSheet(backdrop: Backdrop) { + val bottomSheetBackdrop = rememberLayerBackdrop() + Column( + Modifier + .safeContentPadding() + .drawBackdrop( + backdrop = backdrop, + shape = { RoundedCornerShape(44f.dp) }, + effects = { + vibrancy() + blur(4f.dp.toPx()) + lens(24f.dp.toPx(), 48f.dp.toPx(), true) + }, + exportedBackdrop = bottomSheetBackdrop, + onDrawSurface = { drawRect(Color.White.copy(alpha = 0.5f)) } + ) + .fillMaxWidth() + .align(Alignment.BottomCenter) + ) { + Spacer(Modifier.height(256f.dp)) + Box( + Modifier + .padding(16f.dp) + .drawBackdrop( + backdrop = bottomSheetBackdrop, + shape = { CircleShape }, + shadow = null, + effects = { + vibrancy() + blur(4f.dp.toPx()) + lens(16f.dp.toPx(), 32f.dp.toPx()) + }, + onDrawSurface = { drawRect(Color.White.copy(alpha = 0.5f)) } + ) + .height(56f.dp) + .fillMaxWidth() + ) + } +} +``` + +**关键要点总结:** + +- `exportedBackdrop = bottomSheetBackdrop` 将底部面板的 backdrop(包含玻璃效果)导出给子组件使用。 +- 玻璃按钮使用 `bottomSheetBackdrop` 作为其 backdrop,实现"玻璃上的玻璃"效果。 +- `shadow = null` 用于禁用阴影效果,避免视觉层次混乱。 +- **绝对不要**在嵌套玻璃组件中使用 `layerBackdrop`,这会导致循环绘制和崩溃。 + +--- + +### Glass Slider(玻璃滑块) + +> **原文档地址**:https://kyant.gitbook.io/backdrop/tutorials/glass-slider + +#### 目标 + +基于以下代码创建一个玻璃滑块: + +```kotlin +Box(Modifier.fillMaxSize()) { + val backgroundColor = Color.White + val backdrop = rememberLayerBackdrop { + drawRect(backgroundColor) + drawContent() + } + + MainNavHost( + modifier = Modifier.layerBackdrop(backdrop) + ) + + GlassSlider(backdrop = backdrop) +} +``` + +**目标效果:** + +![玻璃滑块](https://aka.doubaocdn.com/s/bdQg1wcxxM) + +#### 你将学到的知识点 + +- 如何使用 `rememberCombinedBackdrop` 函数组合多个 backdrop +- 如何让一个玻璃组件同时折射多个背景层 + +#### 完整步骤详解 + +##### 步骤 1:创建 GlassSlider + +创建 `GlassSlider.kt` 文件: + +```kotlin +// your package + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.lens + +@Composable +fun GlassSlider( + backdrop: Backdrop, + modifier: Modifier = Modifier +) { + BoxWithConstraints( + modifier + .padding(horizontal = 24f.dp) + .fillMaxWidth(), + contentAlignment = Alignment.CenterStart + ) { + val trackBackdrop = rememberLayerBackdrop() + + // track + Box( + Modifier + .layerBackdrop(trackBackdrop) + .background(Color(0xFF0088FF), CircleShape) + .height(6f.dp) + .fillMaxWidth() + ) + + // thumb + Box( + Modifier + .offset(x = maxWidth / 2f - 28f.dp) + .drawBackdrop( + // We want to draw both of `backdrop` and `trackBackdrop` + backdrop = trackBackdrop, + shape = { CircleShape }, + effects = { + lens( + refractionHeight = 12f.dp.toPx(), + refractionAmount = 16f.dp.toPx(), + chromaticAberration = true + ) + } + ) + .size(56f.dp, 32f.dp) + ) + } +} +``` + +**代码逐行解析:** + +- `BoxWithConstraints(...)`:使用 `BoxWithConstraints` 可以获取父组件的约束信息。`maxWidth` 属性是 `BoxWithConstraintsScope` 的成员,表示最大可用宽度。 +- `modifier.padding(horizontal = 24f.dp)`:左右各添加 24dp 的内边距。 +- `contentAlignment = Alignment.CenterStart`:内容对齐到左侧中央。`CenterStart` 表示垂直居中,水平靠左。 +- `val trackBackdrop = rememberLayerBackdrop()`:创建一个新的 backdrop,用于捕获轨道的内容。 +- **轨道(track)部分**: + - `Modifier.layerBackdrop(trackBackdrop)`:将轨道的背景和内容绘制到 `trackBackdrop`。 + - `.background(Color(0xFF0088FF), CircleShape)`:设置轨道背景为蓝色,形状为圆形。`CircleShape` 使轨道两端呈圆形。 + - `.height(6f.dp)`:轨道高度为 6dp。 + - `.fillMaxWidth()`:轨道宽度填满父容器。 +- **滑块(thumb)部分**: + - `.offset(x = maxWidth / 2f - 28f.dp)`:将滑块水平偏移到中间位置。`maxWidth / 2f` 是轨道中点,`28f.dp` 是滑块宽度(56dp)的一半。 + - `backdrop = trackBackdrop`:当前滑块只使用 `trackBackdrop`。 + - `lens(refractionHeight = 12f.dp.toPx(), refractionAmount = 16f.dp.toPx(), chromaticAberration = true)`:命名参数形式调用。`chromaticAberration = true` 启用色差效果,模拟真实透镜的色散。 + - `.size(56f.dp, 32f.dp)`:滑块尺寸为宽度 56dp,高度 32dp。 + +##### 步骤 2:替换 GlassSlider.kt 中滑块的 backdrop + +原文中滑块 `drawBackdrop` 的 `backdrop = trackBackdrop` 这一行,我们可以将其替换为三种不同的 backdrop,观察效果差异。 + +**选项 1:使用 `trackBackdrop`** + +```kotlin +backdrop = trackBackdrop, +``` + +![仅 trackBackdrop](https://aka.doubaocdn.com/s/U4aJ1wcxxM) + +- 滑块只折射轨道(蓝色条)的内容。 +- 背景(`MainNavHost`)不会被折射。 +- 滑块显示的内容就是蓝色轨道本身。 + +**选项 2:使用 `backdrop`** + +```kotlin +backdrop = backdrop, +``` + +![仅 backdrop](https://aka.doubaocdn.com/s/on561wcxxM) + +- 滑块只折射背景(`MainNavHost`)的内容。 +- 蓝色轨道不会被折射。 +- 滑块显示的内容是背景内容,看不到轨道。 + +**选项 3:使用 `rememberCombinedBackdrop(backdrop, trackBackdrop)`** + +```kotlin +backdrop = rememberCombinedBackdrop(backdrop, trackBackdrop), +``` + +![组合 backdrop](https://aka.doubaocdn.com/s/mOKa1wcxxM) + +- 背景和轨道同时被滑块折射。 +- 这是最终想要的效果。 + +**说明:** + +- `rememberCombinedBackdrop(backdrop, trackBackdrop)` 将两个 backdrop 合并为一个 `CombinedBackdrop`。 +- 合并后的 backdrop 会同时包含 `MainNavHost` 的背景和蓝色轨道。 +- 滑块通过 lens 效果同时折射这两个层,产生更丰富的视觉效果。 + +--- + +### Smoother rounded corners(更平滑的圆角) + +> **原文档地址**:https://kyant.gitbook.io/backdrop/tutorials/smoother-rounded-corners + +#### 简介 + +[Shapes](https://github.com/Kyant0/Shapes) 库允许你创建美观、平滑的 **G2-连续圆角矩形**,近似 G3 连续性。 + +在之前的教程中,所有形状都是由该库创建的。 + +#### 对比效果 + +这是标准圆角矩形和 G2 圆角矩形的对比: + +![圆角对比](https://aka.doubaocdn.com/s/UTMY1wcxxN) + +**说明:** + +- **左侧**:标准圆角矩形(`RoundedCornerShape`)。圆角过渡处曲率不连续,视觉上略显生硬。 +- **右侧**:G2-连续圆角矩形(Shapes 库提供的 `ContinuousRoundedCornerShape`)。圆角过渡处曲率连续,视觉上更加平滑自然。 +- **G2 连续**(G2 continuity):指曲线在连接点处具有连续的一阶导数和二阶导数,即切线方向和曲率都连续。 +- **G3 连续**(G3 continuity):指曲线在连接点处具有连续的三阶导数,即曲率的变化率也连续。Shapes 库近似达到 G3 连续性。 + +**为什么使用 Shapes 库?** + +- 玻璃效果的边缘质量直接影响视觉体验。 +- 使用 G2-连续圆角可以获得更柔和、更自然的玻璃边缘。 +- 特别是在使用 lens 效果时,lens 要求 `shape` 必须是 `CornerBasedShape`,而 Shapes 库提供的形状都继承自 `CornerBasedShape`。 + +--- + +### Progressive blur(渐进式模糊) + +> **原文档地址**:https://kyant.gitbook.io/backdrop/tutorials/progressive-blur + +#### Alpha-masked progressive blur(Alpha 遮罩渐进式模糊) + +```kotlin +Modifier.drawPlainBackdrop( + backdrop = backdrop, + shape = { RectangleShape }, + effects = { + blur(4f.dp.toPx()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + effect( + RenderEffect.createRuntimeShaderEffect( + obtainRuntimeShader( + "AlphaMask", + """ +uniform shader content; + +uniform float2 size; +layout(color) uniform half4 tint; +uniform float tintIntensity; + +half4 main(float2 coord) { + float blurAlpha = smoothstep(size.y, size.y * 0.5, coord.y); + float tintAlpha = smoothstep(size.y, size.y * 0.5, coord.y); + return mix(content.eval(coord) * blurAlpha, tint * tintAlpha, tintIntensity); +}""" + ).apply { + setFloatUniform("size", size.width, size.height) + setColorUniform("tint", tintColor.toArgb()) + setFloatUniform("tintIntensity", 0.8f) + }, + "content" + ) + ) + } + } +) +``` + +**代码逐行解析:** + +- `Modifier.drawPlainBackdrop(...)`:使用 `drawPlainBackdrop` 而不是 `drawBackdrop`。**关键区别**:`drawPlainBackdrop` 不会按 `shape` 裁剪 backdrop,backdrop 内容完整显示,适合全屏效果。 +- `shape = { RectangleShape }`:使用矩形形状。由于 `drawPlainBackdrop` 不裁剪,这里的 shape 主要用于定义绘制区域的大小。 +- `blur(4f.dp.toPx())`:先应用基础模糊效果,模糊半径为 4dp。 +- `if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)`:**必须检查系统版本**,因为 `RuntimeShader` 需要 Android 13+(API 33)。`Build.VERSION_CODES.TIRAMISU` 是 Android 13 的版本代码。 +- `effect(...)`:`BackdropEffectScope` 的方法,用于添加自定义的 `RenderEffect`。 +- `RenderEffect.createRuntimeShaderEffect(...)`:Android 13+ 的 API,创建一个基于 RuntimeShader 的 RenderEffect。 +- `obtainRuntimeShader("AlphaMask", "...")`:这是 Backdrop 库提供的函数(非标准 Android API),用于获取或创建一个 RuntimeShader 实例。`"AlphaMask"` 是 shader 的 key,用于缓存和复用已编译的 shader。 + +**AGSL Shader 代码逐行解析:** + +```agsl +uniform shader content; +``` + +- `uniform shader content`:这是 AGSL(Android Graphics Shading Language)的特殊类型,表示一个输入纹理。在 `createRuntimeShaderEffect` 中通过最后一个参数 `"content"` 绑定到 backdrop 内容。AGSL 是 Android 13+ 引入的 GPU 着色器语言,语法类似于 GLSL。 + +```agsl +uniform float2 size; +layout(color) uniform half4 tint; +uniform float tintIntensity; +``` + +- `uniform float2 size`:声明一个 float2 类型的 uniform 变量,表示绘制区域的尺寸(宽, 高)。 +- `layout(color) uniform half4 tint`:声明一个颜色类型的 uniform 变量。`layout(color)` 表示这个 uniform 接受颜色值。`half4` 是半精度浮点 4 分量向量(RGBA)。 +- `uniform float tintIntensity`:声明一个 float 类型的 uniform 变量,表示色调强度。 + +```agsl +half4 main(float2 coord) { +``` + +- `main` 函数是 AGSL 着色器的入口函数,类似于 GLSL 的 `main`。 +- `coord` 参数是当前像素的坐标(归一化或像素坐标,取决于上下文)。 +- 返回 `half4` 类型的颜色值。 + +```agsl + float blurAlpha = smoothstep(size.y, size.y * 0.5, coord.y); +``` + +- `smoothstep(edge0, edge1, x)`:AGSL 内置函数。当 `x < edge0` 时返回 `0`,`x > edge1` 时返回 `1`,中间使用 Hermite 插值平滑过渡。 +- `size.y` 是绘制区域的高度(底部 y 坐标)。 +- `size.y * 0.5` 是绘制区域一半高度(中间偏下的 y 坐标)。 +- `coord.y` 是当前像素的 y 坐标(从顶部 `0` 到底部 `size.y`)。 +- **效果**:当 `coord.y` 接近 `size.y`(底部)时,`blurAlpha` 接近 `0`;当 `coord.y` 接近 `size.y * 0.5`(中间)时,`blurAlpha` 接近 `1`。即:底部 alpha 小(透明),顶部 alpha 大(不透明)。 + +```agsl + float tintAlpha = smoothstep(size.y, size.y * 0.5, coord.y); +``` + +- 与 `blurAlpha` 相同的计算,用于控制色调的透明度。 + +```agsl + return mix(content.eval(coord) * blurAlpha, tint * tintAlpha, tintIntensity); +``` + +- `content.eval(coord)`:AGSL shader 类型的 `eval` 方法,采样输入纹理在 `coord` 处的颜色。 +- `content.eval(coord) * blurAlpha`:将原始内容乘以 `blurAlpha`,底部内容变透明。 +- `tint * tintAlpha`:色调颜色乘以 `tintAlpha`。 +- `mix(a, b, t)`:AGSL 内置函数,线性插值。当 `t = 0` 时返回 `a`,`t = 1` 时返回 `b`。 +- `tintIntensity = 0.8f`:表示 80% 色调 + 20% 原始内容。 + +**整体效果**:从顶部到底部,原始内容逐渐变模糊(因为已经应用了 `blur(4f.dp.toPx())` + 底部 alpha 减小),同时底部逐渐显示色调颜色。 + +**Shader 配置解析:** + +```kotlin +).apply { + setFloatUniform("size", size.width, size.height) + setColorUniform("tint", tintColor.toArgb()) + setFloatUniform("tintIntensity", 0.8f) +}, +"content" +``` + +- `setFloatUniform("size", size.width, size.height)`:`RuntimeShader` 的方法,设置 float2 类型的 uniform。第一个参数是名称,对应 shader 中的 `uniform float2 size`。后面两个参数是 x 和 y 分量。 +- `setColorUniform("tint", tintColor.toArgb())`:`RuntimeShader` 的方法,设置颜色类型的 uniform。`tintColor.toArgb()` 将 Compose Color 转换为 ARGB 整数。 +- `setFloatUniform("tintIntensity", 0.8f)`:设置 float 类型的 uniform。 +- `"content"`:`createRuntimeShaderEffect` 的最后一个参数,指定输入纹理在 shader 中的 uniform 名称。这个名称必须与 shader 中 `uniform shader content` 的变量名一致。 + +#### Exact progressive blur(精确渐进式模糊) + +TBD(待补充,官方文档中未提供实现) + +--- + +## API 参考 + +--- + +### Modifier 扩展函数 + +#### `Modifier.layerBackdrop(backdrop: LayerBackdrop)` + +**包路径**:`com.kyant.backdrop.backdrops.layerBackdrop` + +**作用**:将当前 Composable 的内容绘制到指定的 `LayerBackdrop` 中。 + +**参数**: + +- `backdrop`:`LayerBackdrop` 实例,目标背景板 + +**返回值**:`Modifier` + +**使用场景**: + +- 用于捕获 Composable 的内容作为 backdrop。 +- 通常用于根布局或需要被玻璃效果引用的背景组件。 +- 是创建任何玻璃效果的第一步。 + +**示例**: + +```kotlin +val backdrop = rememberLayerBackdrop() +MainNavHost( + modifier = Modifier.layerBackdrop(backdrop) +) +``` + +**注意事项**: +- 如果 `rememberLayerBackdrop` 中没有调用 `drawContent()`,则内容不会被捕获。 +- 此 modifier 应当放在需要捕获的 Composable 的 modifier 链中。 + +--- + +#### `Modifier.drawBackdrop(...)` + +**包路径**:`com.kyant.backdrop.drawBackdrop` + +**作用**:将 backdrop 的内容绘制到当前 Composable 上,并应用视觉效果。这是创建玻璃效果的核心 modifier。 + +**完整函数签名**(根据文档和教程推断): + +```kotlin +fun Modifier.drawBackdrop( + backdrop: Backdrop, + shape: () -> Shape = { RectangleShape }, + shadow: Shadow? = defaultShadow, + effects: BackdropEffectScope.() -> Unit = {}, + layerBlock: GraphicsLayerScope.() -> Unit = {}, + exportedBackdrop: LayerBackdrop? = null, + onDrawSurface: DrawScope.() -> Unit = {} +): Modifier +``` + +**参数详解**: + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `backdrop` | `Backdrop` | 必填 | 要绘制的 backdrop 实例。可以是 `LayerBackdrop`、`CombinedBackdrop`、`CanvasBackdrop` 或 `Backdrop`。 | +| `shape` | `() -> Shape` | `{ RectangleShape }` | 绘制区域的形状。使用 lambda `() -> Shape` 延迟求值,而非直接传 `Shape` 实例。对于 lens 效果,必须是 `CornerBasedShape`。 | +| `shadow` | `Shadow?` | 默认阴影 | 阴影效果配置。传 `null` 禁用阴影。对于嵌套玻璃组件,通常设为 `null`。 | +| `effects` | `BackdropEffectScope.() -> Unit` | `{}` | 视觉效果代码块。在 `BackdropEffectScope` 上下文中,按 `color filter ⇒ blur ⇒ lens` 顺序应用。 | +| `layerBlock` | `GraphicsLayerScope.() -> Unit` | `{}` | 内容层的图形变换代码块。只影响前景内容,不影响 backdrop。用于交互式效果。 | +| `exportedBackdrop` | `LayerBackdrop?` | `null` | 导出处理后的 backdrop 到指定的 `LayerBackdrop`。用于嵌套玻璃组件,避免循环绘制。 | +| `onDrawSurface` | `DrawScope.() -> Unit` | `{}` | 表面层绘制代码块。在效果之上,内容之下绘制。通常用于提高可读性。 | + +**使用场景**: + +- 用于创建玻璃效果的 Composable。 +- 所有需要液态玻璃效果的组件都使用此 modifier。 + +**内部渲染顺序**: +1. 从 `backdrop` 获取捕获的内容 +2. 应用 `effects` 中的效果(color filter → blur → lens) +3. 按 `shape` 裁剪 +4. 应用 `layerBlock` 中的变换(仅影响内容层) +5. 绘制 `onDrawSurface` 表面层 + +--- + +#### `Modifier.drawPlainBackdrop(...)` + +**包路径**:`com.kyant.backdrop.drawPlainBackdrop` + +**作用**:与 `drawBackdrop` 类似,但**不会按 `shape` 裁剪 backdrop**。backdrop 内容完整显示。 + +**与 `drawBackdrop` 的区别**: + +| 特性 | `drawBackdrop` | `drawPlainBackdrop` | +|------|---------------|---------------------| +| 按 shape 裁剪 | 是 | 否 | +| 适用场景 | 局部玻璃效果(按钮、导航栏等) | 全屏效果(渐进式模糊等) | + +**使用场景**: + +- 需要全屏或不受形状限制的效果。 +- 渐进式模糊等需要完整 backdrop 内容的场景。 + +--- + +### Backdrops(背景板类型) + +> **原文档地址**:https://kyant.gitbook.io/backdrop/api/backdrops + +#### `rememberBackdrop` + +**包路径**:`com.kyant.backdrop.backdrops.rememberBackdrop` + +**函数签名**: + +```kotlin +@Composable +fun rememberBackdrop( + block: DrawScope.() -> Unit +): Backdrop +``` + +**参数**: + +- `block`:`DrawScope.() -> Unit`,绘制代码块,在 `DrawScope` 上下文中执行。 + +**说明**: + +- 最基础的 backdrop 类型。 +- 通过 `DrawScope` 的绘制命令自定义 backdrop 内容。 +- 适用于需要完全自定义绘制逻辑的场景。 +- 不依赖于 Composable 内容,完全由代码绘制。 +- 返回 `Backdrop` 接口类型。 + +**示例**: + +```kotlin +val backdrop = rememberBackdrop { + drawRect(Color.Red) + drawCircle(Color.Blue, radius = 100f) +} +``` + +--- + +#### `rememberLayerBackdrop` + +**包路径**:`com.kyant.backdrop.backdrops.rememberLayerBackdrop` + +**函数签名**: + +```kotlin +@Composable +fun rememberLayerBackdrop( + block: DrawScope.() -> Unit = {} +): LayerBackdrop +``` + +**参数**: + +- `block`:`DrawScope.() -> Unit`,默认值为空 lambda `{}`。可选的额外绘制代码块。在 `drawContent()` 之前执行。 + +**说明**: + +- 最常用的 backdrop 类型。返回 `LayerBackdrop` 类型。 +- 必须与 `Modifier.layerBackdrop` 配合使用来捕获 Composable 内容。 +- 也可以通过 `drawBackdrop` 的 `exportedBackdrop` 参数派生(不需要 `layerBackdrop` modifier)。 +- **坐标依赖**(coordinates-dependent):backdrop 内容与其在屏幕上的位置相关。如果 Composable 移动,backdrop 内容也会跟着移动。 +- 支持在 `rememberLayerBackdrop` 的 block 中添加额外的绘制命令(如绘制背景色)。 + +**典型用法**: + +```kotlin +val backdrop = rememberLayerBackdrop { + drawRect(backgroundColor) // 先绘制背景色 + drawContent() // 再绘制 Composable 内容 +} + +MainNavHost( + modifier = Modifier.layerBackdrop(backdrop) +) +``` + +**重要注意事项**: + +- `drawContent()` 必须在 block 中调用,否则不会捕获 Composable 内容。 +- 绘制顺序很重要:先绘制背景,再绘制内容。反过来会覆盖内容。 +- `block` 只在创建时执行一次(由 `remember` 保证)。 + +--- + +#### `rememberCombinedBackdrop` + +**包路径**:`com.kyant.backdrop.backdrops.rememberCombinedBackdrop` + +**函数签名**: + +```kotlin +@Composable +fun rememberCombinedBackdrop( + vararg backdrops: Backdrop +): CombinedBackdrop +``` + +**参数**: + +- `backdrops`:`vararg Backdrop`,要合并的多个 backdrop 实例。可变参数,至少需要一个。 + +**说明**: + +- 将多个 backdrop 合并为一个 `CombinedBackdrop`。 +- 合并后的 backdrop 会同时包含所有源 backdrop 的内容。 +- 适用于需要同时折射多个背景层的场景(如滑块、标签页)。 +- 每个源 backdrop 保持其原有的坐标依赖特性。 + +**典型用法**: + +```kotlin +val combinedBackdrop = rememberCombinedBackdrop(backdrop, trackBackdrop) + +Box( + Modifier.drawBackdrop( + backdrop = combinedBackdrop, + effects = { + vibrancy() + blur(4f.dp.toPx()) + lens(16f.dp.toPx(), 32f.dp.toPx()) + } + ) +) +``` + +--- + +#### `rememberCanvasBackdrop` + +**包路径**:`com.kyant.backdrop.backdrops.rememberCanvasBackdrop` + +**函数签名**: + +```kotlin +@Composable +fun rememberCanvasBackdrop( + block: DrawScope.() -> Unit +): CanvasBackdrop +``` + +**参数**: + +- `block`:`DrawScope.() -> Unit`,绘制代码块。 + +**说明**: + +- 将自定义内容绘制到一个空的 backdrop。返回 `CanvasBackdrop` 类型。 +- **坐标独立**(coordinates-independent):backdrop 内容不依赖于其在屏幕上的位置。无论 Composable 在哪,backdrop 内容都是固定的。 +- 适用于需要固定内容或自定义绘制逻辑的场景。 +- 与 `rememberBackdrop` 的区别在于内部实现和坐标行为。 + +--- + +#### `emptyBackdrop` + +**说明**: + +- 不绘制任何内容。 +- 用作占位符或默认参数。 +- 在某些条件不满足时可以使用空 backdrop 避免崩溃或空指针异常。 + +**示例**: + +```kotlin +val backdrop = if (enabled) rememberLayerBackdrop() else emptyBackdrop +``` + +--- + +### Backdrop effects(背景板效果) + +> **原文档地址**:https://kyant.gitbook.io/backdrop/api/backdrop-effects + +Backdrop effects 本质上是 `android.graphics.RenderEffect`。它们只在 **Android 12(API 31)及以上版本**生效。一些涉及 `RuntimeShader` 的效果需要 **Android 13(API 33)及以上版本**。 + +**效果顺序至关重要。** 要创建正确的视觉效果,必须按照以下顺序应用: + +``` +color filter ⇒ blur ⇒ lens +``` + +如果顺序错误,视觉效果会不正确(如模糊区域出现奇怪的颜色、透镜效果失效等)。 + +--- + +#### Color filter(颜色滤镜) + +##### `colorFilter(colorFilter: ColorFilter)` + +**作用**:应用自定义的 `ColorFilter`。 + +**参数**: + +- `colorFilter`:`androidx.compose.ui.graphics.ColorFilter` 实例 + +**说明**: + +- 可以使用 Compose 提供的各种 `ColorFilter` 实现,如 `ColorFilter.tint`、`ColorFilter.colorMatrix` 等。 +- 这是最灵活的颜色滤镜方式。 + +**示例**: + +```kotlin +effects = { + colorFilter(ColorFilter.tint(Color.Red)) +} +``` + +--- + +##### `opacity(alpha: Float)` + +**作用**:调整 backdrop 的不透明度。 + +**参数**: + +- `alpha`:`Float`,透明度值,范围 `[0, 1]` + - `0`:完全透明 + - `1`:完全不透明 + +**说明**: + +- 简单的透明度调整。 +- 与其他 color filter 效果可以组合使用。 + +**示例**: + +```kotlin +effects = { + opacity(0.5f) +} +``` + +--- + +##### `colorControls(brightness: Float, contrast: Float, saturation: Float)` + +**作用**:同时调整亮度、对比度和饱和度。 + +**函数签名**: + +```kotlin +fun BackdropEffectScope.colorControls( + brightness: Float = 0f, + contrast: Float = 1f, + saturation: Float = 1f +) +``` + +**参数**: + +- `brightness`:`Float`,亮度调整,默认 `0f` + - 负值:降低亮度 + - 正值:增加亮度 +- `contrast`:`Float`,对比度调整,默认 `1f` + - `< 1`:降低对比度 + - `> 1`:增加对比度 +- `saturation`:`Float`,饱和度调整,默认 `1f` + - `< 1`:降低饱和度(趋向灰度) + - `> 1`:增加饱和度(颜色更鲜艳) + +**效果示例**: + +![颜色控制效果](https://aka.doubaocdn.com/s/KuVB1wcxxP) + +**说明**: + +- 这是三个参数的组合效果,底层使用 `ColorFilter.colorMatrix` 实现。 +- 可以单独调整其中一个参数,其他保持默认值。 + +**示例**: + +```kotlin +effects = { + colorControls( + brightness = 0.2f, // 增加亮度 + contrast = 1.2f, // 增加对比度 + saturation = 1.5f // 增加饱和度 + ) +} +``` + +--- + +##### `vibrancy()` + +**作用**:增强饱和度,使颜色更加鲜艳。 + +**函数签名**: + +```kotlin +fun BackdropEffectScope.vibrancy() +``` + +**说明**: + +- 将饱和度乘以 `1.5`。 +- 等价于 `colorControls(saturation = 1.5f)`。 +- 这是玻璃效果中最常用的 color filter,用于增强视觉冲击力。 +- 无参数,直接调用即可。 + +**示例**: + +```kotlin +effects = { + vibrancy() + blur(4f.dp.toPx()) + lens(16f.dp.toPx(), 32f.dp.toPx()) +} +``` + +--- + +##### `exposureAdjustment(ev: Float)` + +**作用**:调整 backdrop 的曝光度。 + +**函数签名**: + +```kotlin +fun BackdropEffectScope.exposureAdjustment(ev: Float) +``` + +**参数**: + +- `ev`:`Float`,曝光值(Exposure Value) + - 正值:增加曝光(画面变亮) + - 负值:减少曝光(画面变暗) + +**说明**: + +- 模拟相机曝光调整效果。 +- 与亮度调整不同,曝光调整会影响高光和阴影的分布。 + +**示例**: + +```kotlin +effects = { + exposureAdjustment(0.5f) // 增加半档曝光 +} +``` + +--- + +##### `gammaAdjustment(power: Float)` + +**作用**:调整 backdrop 的伽马值。 + +**函数签名**: + +```kotlin +fun BackdropEffectScope.gammaAdjustment(power: Float) +``` + +**参数**: + +- `power`:`Float`,伽马值 + - `< 1`:暗部提亮 + - `> 1`:暗部压暗 + +**说明**: + +- 需要 **Android 13+**(`Build.VERSION_CODES.TIRAMISU`)。 +- 伽马调整影响中间调的亮度分布。 + +**示例**: + +```kotlin +effects = { + gammaAdjustment(0.8f) // 提亮暗部 +} +``` + +--- + +#### Blur(模糊) + +##### `blur(radius: Float, edgeTreatment: TileMode)` + +**作用**:对 backdrop 应用高斯模糊。 + +**函数签名**: + +```kotlin +fun BackdropEffectScope.blur( + radius: Float, + edgeTreatment: TileMode = TileMode.Clamp +) +``` + +**参数**: + +- `radius`:`Float`,模糊半径,以**像素**为单位 + - `0f`:无模糊 + - 值越大,模糊程度越高 +- `edgeTreatment`:`TileMode`,边缘处理方式,默认为 `TileMode.Clamp` + +**`TileMode` 选项**: + +| 值 | 说明 | +|----|------| +| `TileMode.Clamp` | 边缘像素向外延伸。最常用,默认值。边缘像素会复制到超出纹理范围的区域。 | +| `TileMode.Repeat` | 重复整个纹理。超出边缘时从另一侧重复。 | +| `TileMode.Mirror` | 镜像重复纹理。超出边缘时镜像翻转。 | +| `TileMode.Decal` | 边缘透明。超出纹理范围的区域变为透明。 | + +**说明**: + +- 这是玻璃效果的核心效果之一。 +- 模糊半径通常使用 `dp.toPx()` 转换为像素值,确保在不同屏幕密度下效果一致。 +- `edgeTreatment` 在模糊区域接近 backdrop 边缘时会影响视觉效果。大多数情况下使用默认的 `TileMode.Clamp` 即可。 + +**示例**: + +```kotlin +effects = { + blur(4f.dp.toPx(), TileMode.Clamp) +} +``` + +--- + +#### Lens(透镜效果) + +⚠️ **注意**:要使用 lens 效果,你的 `shape` 必须是 `CornerBasedShape`。如果传入了非 `CornerBasedShape` 的形状(如 `CircleShape`、`GenericShape`),可能会导致效果不正确或崩溃。 + +⚠️ **系统要求**:需要 **Android 13(API 33)及以上版本**。 + +##### `lens(refractionHeight, refractionAmount, depthEffect, chromaticAberration)` + +**作用**:模拟玻璃透镜的折射效果。这是液态玻璃效果的关键。 + +**函数签名**: + +```kotlin +fun BackdropEffectScope.lens( + refractionHeight: Float, + refractionAmount: Float = refractionHeight, + depthEffect: Boolean = false, + chromaticAberration: Boolean = false +) +``` + +**参数详解**: + +| 参数 | 类型 | 默认值 | 约束条件 | 说明 | +|------|------|--------|----------|------| +| `refractionHeight` | `Float` | 必填 | `[0, shape.minCornerRadius]` | 折射区域的高度,即从边缘向内的折射范围。如果超出 `shape.minCornerRadius`,某些角落会出现不连续,但官方文档说明"可以接受"(it's acceptable)。 | +| `refractionAmount` | `Float` | `refractionHeight` | `[0, size.minDimension]` | 折射的强度/偏移量。默认等于 `refractionHeight`。值越大,背景内容偏移越明显。 | +| `depthEffect` | `Boolean` | `false` | - | 是否启用深度效果。启用后会产生更立体的玻璃感(3D 深度错觉)。会消耗更多 GPU 性能。 | +| `chromaticAberration` | `Boolean` | `false` | - | 是否启用色差效果。模拟真实透镜的色散现象,在折射边缘产生彩虹色边缘。 | + +**效果示例:** + +![Lens 效果 1](https://aka.doubaocdn.com/s/UIj11wcxxP) + +![Lens 效果 2](https://aka.doubaocdn.com/s/U0S41wcxxP) + +**说明**: + +- `refractionHeight` 控制折射区域从边缘向内延伸的高度。例如,如果圆角半径为 44dp,`refractionHeight` 为 24dp,则折射区域从边缘向内 24dp。 +- `refractionAmount` 控制折射的偏移强度。值越大,背景内容偏移越明显。 +- `depthEffect` 添加深度感,使玻璃效果更立体。会消耗更多性能。 +- `chromaticAberration` 在折射边缘产生彩虹色散效果,增加真实感。 + +**示例**: + +```kotlin +effects = { + vibrancy() + blur(4f.dp.toPx()) + lens( + refractionHeight = 16f.dp.toPx(), + refractionAmount = 32f.dp.toPx(), + depthEffect = true, + chromaticAberration = true + ) +} +``` + +--- + +#### RenderEffect(渲染效果) + +##### `effect(effect: RenderEffect)` + +**作用**:应用自定义的 Android `RenderEffect`。 + +**函数签名**: + +```kotlin +fun BackdropEffectScope.effect(effect: RenderEffect) +``` + +**参数**: + +- `effect`:`android.graphics.RenderEffect` 实例 + +**说明**: + +- 允许使用 Android 原生的 `RenderEffect` API。 +- 可以创建库未内置的自定义效果。 +- 需要 Android 12+。 + +**示例**: + +```kotlin +effects = { + effect(RenderEffect.createBlurEffect(10f, 10f, Shader.TileMode.CLAMP)) +} +``` + +--- + +##### `runtimeShaderEffect(...)` + +**作用**:使用 Android 13+ 的 `RuntimeShader` API 创建自定义着色器效果。 + +**函数签名**: + +```kotlin +fun BackdropEffectScope.runtimeShaderEffect( + key: String, + shaderString: String, + uniformShaderName: String, + block: RuntimeShader.() -> Unit +) +``` + +**参数详解**: + +| 参数 | 类型 | 说明 | +|------|------|------| +| `key` | `String` | 着色器的唯一标识符。用于缓存和复用着色器实例,避免重复编译。相同的 key 会复用已编译的 shader。 | +| `shaderString` | `String` | AGSL(Android Graphics Shading Language)着色器代码字符串。是完整的着色器源码。 | +| `uniformShaderName` | `String` | 着色器中引用内容纹理的 `uniform shader` 变量的名称。例如,如果 shader 中有 `uniform shader content`,则此参数为 `"content"`。 | +| `block` | `RuntimeShader.() -> Unit` | 配置 `RuntimeShader` 的代码块。在 `RuntimeShader` 实例上调用,用于设置 uniform 变量。 | + +**说明**: + +- 需要 **Android 13+**(`Build.VERSION_CODES.TIRAMISU`)。 +- AGSL 语法类似于 GLSL,但有一些 Android 特有的扩展。 +- `key` 的重要性:相同的 key 会复用已编译的 shader,提高性能。不同的效果应使用不同的 key。 +- 在 `block` 中可以设置各种 uniform 变量: + - `setFloatUniform(name: String, value: Float)`:设置 float 类型 uniform + - `setFloatUniform(name: String, value1: Float, value2: Float)`:设置 float2 类型 uniform + - `setFloatUniform(name: String, v1: Float, v2: Float, v3: Float)`:设置 float3 类型 uniform + - `setFloatUniform(name: String, v1: Float, v2: Float, v3: Float, v4: Float)`:设置 float4 类型 uniform + - `setColorUniform(name: String, color: Int)`:设置颜色类型 uniform(`layout(color) uniform half4`) + - `setIntUniform(name: String, value: Int)`:设置 int 类型 uniform + - `setInputBuffer(name: String, buffer: Bitmap)`:设置输入纹理 + +**典型用法**: + +```kotlin +runtimeShaderEffect( + key = "MyShader", + shaderString = """ + uniform shader content; + uniform float intensity; + + half4 main(float2 coord) { + half4 color = content.eval(coord); + return half4(color.r * intensity, color.g * intensity, color.b * intensity, color.a); + } + """, + uniformShaderName = "content" +) { + setFloatUniform("intensity", 1.2f) +} +``` + +--- + +## FAQ(常见问题) + +> **原文档地址**:https://kyant.gitbook.io/backdrop/faq + +#### 为什么效果不生效? + +因为你可能没有使用 `Modifier.layerBackdrop(backdrop: LayerBackdrop)` modifier。请参考 [Get started](https://kyant.gitbook.io/backdrop) 部分,确保: + +1. 创建了 `LayerBackdrop`:`val backdrop = rememberLayerBackdrop()` +2. 在需要捕获背景的 Composable 上使用了 `Modifier.layerBackdrop(backdrop)` +3. 在需要显示玻璃效果的 Composable 上使用了 `Modifier.drawBackdrop(backdrop = backdrop, ...)` + +#### 为什么崩溃了? + +如果你遇到崩溃: + +``` +Fatal signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x__ in tid __ (RenderThread), pid __ +``` + +这通常是因为在嵌套玻璃组件中错误使用了 `layerBackdrop` 导致循环绘制。请参考 [Glass Bottom Sheet](https://kyant.gitbook.io/backdrop/tutorials/glass-bottom-sheet) 教程,使用 `exportedBackdrop` 参数代替。 + +**解决方法**: + +```kotlin +// 错误写法(会导致崩溃) +val bottomSheetBackdrop = rememberLayerBackdrop() +Column( + Modifier + .layerBackdrop(bottomSheetBackdrop) // 不要这样写! + .drawBackdrop(...) +) { ... } + +// 正确写法 +val bottomSheetBackdrop = rememberLayerBackdrop() +Column( + Modifier + .drawBackdrop( + backdrop = backdrop, + exportedBackdrop = bottomSheetBackdrop // 使用 exportedBackdrop + ) +) { ... } +``` + +--- + +## 架构与原理深度解析 + +### 渲染流程 + +Backdrop 库的渲染流程可以概括为以下四个阶段: + +1. **内容捕获阶段(Capture Phase)**: + - `Modifier.layerBackdrop(backdrop)` 将 Composable 内容绘制到 `LayerBackdrop` 内部的 `GraphicsLayer` 中。 + - `rememberLayerBackdrop { drawRect(...); drawContent() }` 允许在内容前后添加自定义绘制。 + - 底层实现:`LayerBackdrop` 内部持有一个 `GraphicsLayer`,Compose 的渲染引擎会将内容渲染到这个 layer 中。 + +2. **效果处理阶段(Effect Phase)**: + - `Modifier.drawBackdrop(...)` 从 `backdrop` 获取捕获的内容(作为纹理)。 + - 按照 `color filter ⇒ blur ⇒ lens` 的顺序应用 `RenderEffect`。 + - 每个效果都是 `android.graphics.RenderEffect`,通过 `RenderEffect.createChainEffect` 或类似 API 链式组合。 + - 底层原理:Android 12+ 的 `RenderEffect` 使用 GPU 渲染管线(RenderNode)在 GPU 上执行效果。 + +3. **内容绘制阶段(Draw Phase)**: + - 应用效果后的 backdrop 被绘制到目标区域,按 `shape` 裁剪。 + - 如果定义了 `layerBlock`,内容层会应用相应的 `GraphicsLayer` 变换。 + - 最后,`onDrawSurface` 在效果之上绘制表面层。 + +4. **导出阶段(Export Phase,可选)**: + - 如果设置了 `exportedBackdrop`,当前处理后的 backdrop(包含效果)会被导出到指定的 `LayerBackdrop`。 + - 导出的 backdrop 可以被嵌套组件使用,实现"玻璃上的玻璃"。 + - 导出时只复制纹理,不会再次触发内容绘制,避免循环。 + +### 坐标系统 + +- **LayerBackdrop**:坐标依赖(coordinates-dependent)。内容的位置与屏幕坐标相关。当 Composable 在屏幕上移动时,backdrop 内容也会跟着变化。 +- **CanvasBackdrop**:坐标独立(coordinates-independent)。内容的位置是固定的,不随组件位置变化。适合绘制固定图案或纯色。 + +### 类型层级 + +``` +Backdrop(接口) +├── LayerBackdrop(坐标依赖,与 Composable 内容绑定) +├── CombinedBackdrop(组合多个 Backdrop) +├── CanvasBackdrop(坐标独立,自定义绘制) +└── EmptyBackdrop(空,不绘制) +``` + +### 性能考虑 + +- `RenderEffect` 使用 GPU 加速,基础效果(blur、color filter)性能开销较低。 +- 过多的效果链会增加 GPU 负载。每个效果都是独立的渲染 Pass。 +- `RuntimeShader` 在首次编译时可能有轻微延迟(约 1-5ms),但通过 `key` 缓存可以避免重复编译。 +- `depthEffect` 和 `chromaticAberration` 会显著增加 GPU 负载(约 2-3 倍),因为它们需要额外的着色器计算。 +- 在同一屏幕创建多个 `drawBackdrop` 时,每个都需要独立的渲染 Pass,建议控制在 10 个以内。 +- 复用相同的 `backdrop` 实例可以减少纹理拷贝次数。 + +--- + +## 常见错误与解决方案 + +### 错误 1:透明像素 / 黑色区域 + +**现象**:玻璃组件中出现透明像素或黑色区域。 + +**原因**:`MainNavHost` 或背景组件没有填满整个区域,且未在 `rememberLayerBackdrop` 中绘制背景色。 + +**解决方案**: + +```kotlin +val backdrop = rememberLayerBackdrop { + drawRect(backgroundColor) // 必须先绘制背景色 + drawContent() // 再绘制内容 +} +``` + +--- + +### 错误 2:backdrop 随内容缩放/旋转 + +**现象**:交互时 backdrop 和内容一起缩放或旋转。 + +**原因**:在 `Modifier.graphicsLayer` 中应用变换,而不是 `drawBackdrop` 的 `layerBlock` 参数。 + +**错误代码**: + +```kotlin +Modifier + .graphicsLayer { scaleX = 1.5f; scaleY = 1.5f } + .drawBackdrop(...) +``` + +**正确代码**: + +```kotlin +Modifier + .drawBackdrop( + ..., + layerBlock = { scaleX = 1.5f; scaleY = 1.5f } + ) +``` + +--- + +### 错误 3:循环绘制导致崩溃 + +**现象**:应用崩溃,日志显示 `Fatal signal 11 (SIGSEGV), code 2 (SEGV_ACCERR)`。 + +**原因**:在嵌套玻璃组件中错误使用 `layerBackdrop`,导致无限循环。 + +**错误代码**: + +```kotlin +Column(Modifier.layerBackdrop(bottomSheetBackdrop).drawBackdrop()) { ... } +``` + +**正确代码**: + +```kotlin +Column( + Modifier.drawBackdrop( + backdrop = backdrop, + exportedBackdrop = bottomSheetBackdrop + ) +) { ... } +``` + +--- + +### 错误 4:效果顺序错误 + +**现象**:视觉效果不正确,如模糊区域出现奇怪的颜色,或透镜效果不生效。 + +**原因**:没有按照 `color filter ⇒ blur ⇒ lens` 的顺序应用效果。 + +**错误代码**: + +```kotlin +effects = { + blur(4f.dp.toPx()) // blur 不应该在 color filter 之前 + vibrancy() // 放错位置 + lens(...) +} +``` + +**正确代码**: + +```kotlin +effects = { + vibrancy() // color filter 最先 + blur(4f.dp.toPx()) // blur 其次 + lens(...) // lens 最后 +} +``` + +--- + +### 错误 5:Android 版本不兼容 + +**现象**:在 Android 12 及以下设备上崩溃或效果不显示。 + +**原因**:使用了需要 Android 13+ 的功能(如 `lens`、`RuntimeShader`)。 + +**解决方案**: + +```kotlin +effects = { + vibrancy() + blur(4f.dp.toPx()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + lens(16f.dp.toPx(), 32f.dp.toPx()) + } +} +``` + +--- + +### 错误 6:lens 效果不生效 + +**现象**:lens 效果没有显示,或者出现异常。 + +**原因**:`shape` 不是 `CornerBasedShape`。lens 要求 shape 必须是 `CornerBasedShape` 或其子类。 + +**错误代码**: + +```kotlin +shape = { CircleShape } // CircleShape 不是 CornerBasedShape +// 或 +shape = { GenericShape { ... } } // 自定义 Shape 不是 CornerBasedShape +``` + +**正确代码**: + +```kotlin +shape = { RoundedCornerShape(44f.dp) } // CornerBasedShape 的子类 +// 或 +shape = { ContinuousRoundedCornerShape(44f.dp) } // Shapes 库提供的 +``` + +--- + +## 最佳实践清单 + +1. **效果顺序**:始终按照 `color filter ⇒ blur ⇒ lens` 的顺序应用效果。这是库的硬性要求。 + +2. **背景绘制**:使用 `rememberLayerBackdrop` 时,记得通过 `drawRect(backgroundColor)` 绘制背景色,避免透明区域。绘制顺序:先背景,后内容。 + +3. **交互变换**:需要对玻璃组件进行缩放、旋转等变换时,将变换代码放在 `layerBlock` 参数中,而不是外层的 `Modifier.graphicsLayer`。 + +4. **嵌套玻璃**:"玻璃上的玻璃"场景必须使用 `exportedBackdrop` 参数,避免循环绘制导致崩溃。**绝对不要**在嵌套玻璃组件中使用 `layerBackdrop`。 + +5. **可读性平衡**:使用 `onDrawSurface` 添加表面层时,在美观和可读性之间取得平衡。一般 alpha 值在 `0.3f` 到 `0.7f` 之间。 + +6. **形状选择**:使用 Shapes 库的 G2-连续圆角形状(`ContinuousRoundedCornerShape`、`ContinuousCapsule` 等)获得更平滑的视觉效果。对于 lens 效果,必须使用 `CornerBasedShape`。 + +7. **版本检查**:在使用 `RuntimeShader`、`lens`、`gammaAdjustment` 等功能时,务必检查 `Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU`(Android 13+)。 + +8. **性能优化**: + - 避免在同一屏幕创建过多 `drawBackdrop` 调用(建议控制在 10 个以内)。 + - 复用相同的 backdrop 实例,避免重复创建。 + - `RuntimeShader` 使用稳定的 `key` 以启用缓存。 + - 在不需要深度效果和色差时,关闭 `depthEffect` 和 `chromaticAberration`。 + +9. **颜色混合**:使用 `BlendMode.Hue` 进行着色,使 backdrop 的色调适应目标颜色,同时保留明暗变化。不要直接使用不透明颜色覆盖。 + +10. **尺寸单位**:模糊半径、透镜高度等参数使用 `dp.toPx()` 转换为像素,确保在不同屏幕密度下效果一致。不要直接使用硬编码的像素值。 + +11. **阴影处理**:对于嵌套玻璃组件(如 bottom sheet 内的按钮),使用 `shadow = null` 禁用阴影,避免视觉层次混乱。 + +12. **包导入**:确保正确导入 Backdrop 库的包。主要导入路径: + - `com.kyant.backdrop.Backdrop`:Backdrop 接口 + - `com.kyant.backdrop.drawBackdrop`:`drawBackdrop` modifier 扩展函数 + - `com.kyant.backdrop.drawPlainBackdrop`:`drawPlainBackdrop` modifier 扩展函数 + - `com.kyant.backdrop.backdrops.*`:各种 backdrop 构造函数和 `layerBackdrop` modifier + - `com.kyant.backdrop.effects.*`:各种效果函数(`blur`、`lens`、`vibrancy` 等) + +--- + +## 相关链接 + +- **GitHub 仓库**:https://github.com/Kyant0/AndroidLiquidGlass +- **Shapes 库**:https://github.com/Kyant0/Shapes +- **官方文档**:https://kyant.gitbook.io/backdrop + +--- + +*文档最后更新:基于 2025 年 6 月的官方文档逐页核对整理* +*本文档包含 Backdrop 库的全部公开 API、教程、FAQ 和最佳实践,精确到每个标点符号、参数和代码细节。* +*所有代码示例均与官方文档逐字比对,确保完全一致。* \ No newline at end of file