package com.ambient.launcher import android.Manifest import android.app.role.RoleManager import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.pm.PackageManager import android.content.res.Configuration import android.os.BatteryManager import android.os.Build import android.os.Bundle import android.provider.Settings import android.view.WindowManager import androidx.activity.ComponentActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.compose.animation.AnimatedContent import androidx.compose.animation.Crossfade import androidx.compose.animation.core.tween import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer 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.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow 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.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Apps import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Image as ImageIcon import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.VolumeUp import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Checkbox import androidx.compose.material3.Divider import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationRail import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.core.graphics.drawable.toBitmap import androidx.core.net.toUri import androidx.core.content.ContextCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.compose.AsyncImage import kotlinx.coroutines.delay import java.time.DayOfWeek import java.time.LocalDate import java.time.LocalDateTime import java.time.LocalTime import java.time.format.DateTimeFormatter import java.time.format.TextStyle import java.util.Locale import kotlin.math.roundToInt class MainActivity : ComponentActivity() { private val viewModel: LauncherViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() WindowCompat.setDecorFitsSystemWindows(window, false) WindowInsetsControllerCompat(window, window.decorView).apply { hide(WindowInsetsCompat.Type.systemBars()) systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE } setContent { AmbientTheme { val state by viewModel.uiState.collectAsStateWithLifecycle() KeepAwakeEffect(state.settings.keepAwakeMode) AmbientLauncherApp( state = state, viewModel = viewModel, requestHomeRole = ::requestHomeRole ) } } } override fun onStart() { super.onStart() viewModel.setLauncherVisible(true) } override fun onStop() { viewModel.setLauncherVisible(false) super.onStop() } private fun requestHomeRole() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val roleManager = getSystemService(RoleManager::class.java) if (roleManager.isRoleAvailable(RoleManager.ROLE_HOME) && !roleManager.isRoleHeld(RoleManager.ROLE_HOME) ) { startActivity(roleManager.createRequestRoleIntent(RoleManager.ROLE_HOME)) } } else { startActivity(Intent(Settings.ACTION_HOME_SETTINGS)) } } } @Composable private fun AmbientTheme(content: @Composable () -> Unit) { MaterialTheme( colorScheme = androidx.compose.material3.darkColorScheme( primary = Color(0xFFB9D9FF), secondary = Color(0xFFB8C8DA), surface = Color(0xFF11161D), surfaceVariant = Color(0xFF202832) ), content = content ) } @Composable private fun KeepAwakeEffect(mode: KeepAwakeMode) { val context = LocalContext.current val view = LocalView.current var charging by remember { mutableStateOf(false) } DisposableEffect(context) { fun update(intent: Intent?) { val status = intent?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) charging = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL } val receiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) = update(intent) } val filter = IntentFilter(Intent.ACTION_BATTERY_CHANGED) update(context.registerReceiver(receiver, filter)) onDispose { runCatching { context.unregisterReceiver(receiver) } } } LaunchedEffect(mode, charging, view) { view.keepScreenOn = when (mode) { KeepAwakeMode.WHILE_VISIBLE -> true KeepAwakeMode.WHILE_CHARGING -> charging KeepAwakeMode.SYSTEM_DEFAULT -> false } } } private enum class Screen { HOME, SETTINGS } @Composable private fun AmbientLauncherApp( state: LauncherUiState, viewModel: LauncherViewModel, requestHomeRole: () -> Unit ) { var screen by rememberSaveable { mutableStateOf(Screen.HOME) } AnimatedContent(targetState = screen, label = "screen") { destination -> when (destination) { Screen.HOME -> HomeScreen( state, onSettings = { screen = Screen.SETTINGS }, onRefreshWeather = viewModel::refreshWeather, onTogglePinned = viewModel::togglePinned, onLaunchApp = viewModel::launch ) Screen.SETTINGS -> SettingsScreen( state = state, onBack = { screen = Screen.HOME }, updateSettings = viewModel::updateSettings, setLocalFolder = viewModel::setLocalFolder, refreshWeather = viewModel::refreshWeather, requestHomeRole = requestHomeRole, setWakeWordEnabled = viewModel::setWakeWordEnabled, setWakeWordSensitivity = viewModel::setWakeWordSensitivity, pauseWakeWord = viewModel::pauseWakeWord, resumeWakeWord = viewModel::resumeWakeWord, testWakeWord = viewModel::testWakeWord ) } } } private val onlinePhotos = listOf( "https://images.unsplash.com/photo-1500534314209-a25ddb2bd429?auto=format&fit=crop&w=2400&q=88", "https://images.unsplash.com/photo-1470770841072-f978cf4d019e?auto=format&fit=crop&w=2400&q=88", "https://images.unsplash.com/photo-1449824913935-59a10b8d2000?auto=format&fit=crop&w=2400&q=88", "https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?auto=format&fit=crop&w=2400&q=88", "https://images.unsplash.com/photo-1472214103451-9374bd1c798e?auto=format&fit=crop&w=2400&q=88", "https://images.unsplash.com/photo-1511818966892-d7d671e672a2?auto=format&fit=crop&w=2400&q=88", "https://images.unsplash.com/photo-1501854140801-50d01698950b?auto=format&fit=crop&w=2400&q=88", "https://images.unsplash.com/photo-1470252649378-9c29740c9fa8?auto=format&fit=crop&w=2400&q=88" ) @Composable private fun HomeScreen( state: LauncherUiState, onSettings: () -> Unit, onRefreshWeather: () -> Unit, onTogglePinned: (LauncherApp) -> Unit, onLaunchApp: (LauncherApp) -> Boolean ) { var trayOpen by remember { mutableStateOf(false) } var photoIndex by rememberSaveable { mutableIntStateOf(0) } var photoCount by rememberSaveable { mutableIntStateOf(0) } var showWeatherCard by rememberSaveable { mutableStateOf(false) } val sources = remember(state.localImages, state.settings.localFolderUri) { if (state.settings.localFolderUri.isNotBlank() && state.localImages.isNotEmpty()) { state.localImages } else { onlinePhotos } } LaunchedEffect(state.settings.intervalSeconds, sources.size, state.settings.weatherEveryPhotos) { while (true) { delay(state.settings.intervalSeconds.coerceAtLeast(10) * 1_000L) if (showWeatherCard) { showWeatherCard = false photoIndex = (photoIndex + 1) % sources.size.coerceAtLeast(1) } else { photoCount++ if ( state.settings.showWeather && state.weather != null && photoCount % state.settings.weatherEveryPhotos.coerceAtLeast(1) == 0 ) { showWeatherCard = true } else { photoIndex = (photoIndex + 1) % sources.size.coerceAtLeast(1) } } } } var now by remember { mutableStateOf(LocalDateTime.now()) } LaunchedEffect(Unit) { while (true) { now = LocalDateTime.now() delay(30_000) } } val nightActive = isNightRoutineActive(state.settings, now.toLocalTime()) val overlayPercent = if (nightActive) { maxOf(state.settings.darkenPercent, state.settings.nightDimPercent) } else state.settings.darkenPercent Box(Modifier.fillMaxSize().background(Color.Black)) { Crossfade( targetState = if (showWeatherCard) "weather" else "photo-$photoIndex", animationSpec = tween(1_400), label = "slideshow" ) { if (showWeatherCard && state.weather != null) { FullWeatherSlide(state.weather, state.settings) } else if (sources.isNotEmpty()) { AsyncImage( model = sources[photoIndex % sources.size], contentDescription = "Ambient photograph", placeholder = painterResource(R.drawable.ambient_fallback), error = painterResource(R.drawable.ambient_fallback), contentScale = ContentScale.Crop, modifier = Modifier .fillMaxSize() .graphicsLayer { scaleX = if (nightActive) 1f else 1.025f scaleY = if (nightActive) 1f else 1.025f } ) } } Box( Modifier .fillMaxSize() .background(Color.Black.copy(alpha = overlayPercent.coerceIn(0, 95) / 100f)) ) Box( Modifier .fillMaxSize() .background( Brush.verticalGradient( listOf(Color.Black.copy(alpha = .28f), Color.Transparent, Color.Black.copy(alpha = .34f)) ) ) ) FilledIconButton( onClick = { trayOpen = true }, modifier = Modifier.align(Alignment.TopStart).padding(24.dp), colors = translucentIconColors() ) { Icon(Icons.Default.Apps, "Open applications") } FilledIconButton( onClick = onSettings, modifier = Modifier.align(Alignment.TopEnd).padding(24.dp), colors = translucentIconColors() ) { Icon(Icons.Default.Settings, "Open settings") } if (!showWeatherCard) { ClockWeather( now = now, weather = state.weather, settings = state.settings, nightActive = nightActive, onRefresh = onRefreshWeather, modifier = clockAlignment(state.settings.clockCorner) ) } } if (trayOpen) { AppTray( apps = state.apps, pinned = state.settings.pinnedComponents, onDismiss = { trayOpen = false }, onTogglePinned = onTogglePinned, onLaunch = { app -> if (onLaunchApp(app)) trayOpen = false } ) } } @Composable private fun translucentIconColors() = androidx.compose.material3.IconButtonDefaults.filledIconButtonColors( containerColor = Color.Black.copy(alpha = .42f), contentColor = Color.White ) private fun clockAlignment(corner: ClockCorner): Modifier = when (corner) { ClockCorner.BOTTOM_LEFT -> Modifier.fillMaxSize().padding(28.dp).wrapContentSize(Alignment.BottomStart) ClockCorner.BOTTOM_RIGHT -> Modifier.fillMaxSize().padding(28.dp).wrapContentSize(Alignment.BottomEnd) ClockCorner.TOP_LEFT -> Modifier.fillMaxSize().padding(28.dp, 92.dp).wrapContentSize(Alignment.TopStart) ClockCorner.TOP_RIGHT -> Modifier.fillMaxSize().padding(28.dp, 92.dp).wrapContentSize(Alignment.TopEnd) } @Composable private fun ClockWeather( now: LocalDateTime, weather: WeatherNow?, settings: LauncherSettings, nightActive: Boolean, onRefresh: () -> Unit, modifier: Modifier = Modifier ) { val shift = ((now.minute / 10) % 4) val x = if (shift % 2 == 0) 0.dp else 5.dp val y = if (shift < 2) 0.dp else 4.dp Column( modifier = modifier .offset(x, y) .clip(RoundedCornerShape(24.dp)) .background(Color.Black.copy(alpha = .34f)) .padding(horizontal = 22.dp, vertical = 15.dp), horizontalAlignment = Alignment.End ) { Text( now.format(DateTimeFormatter.ofPattern("h:mm")), fontSize = 56.sp, lineHeight = 56.sp, fontWeight = FontWeight.Light, color = if (nightActive) Color(0xFF9B3030) else Color.White ) Text( now.format(DateTimeFormatter.ofPattern("EEEE, MMMM d")), fontSize = 16.sp, color = if (nightActive) Color(0xFF7E3535) else Color.White.copy(alpha = .86f) ) if (settings.showWeather && !nightActive) { Spacer(Modifier.height(7.dp)) if (weather != null) { Text( "${weatherGlyph(weather.weatherCode)} ${weather.temperature.roundToInt()}° " + "${weatherDescription(weather.weatherCode)}", fontSize = 20.sp, color = Color.White ) Text( "${settings.locationName} H ${weather.high.roundToInt()}° L ${weather.low.roundToInt()}°", fontSize = 14.sp, color = Color.White.copy(alpha = .78f) ) } else { TextButton(onClick = onRefresh) { Text("Weather unavailable · retry") } } } } } @Composable private fun FullWeatherSlide(weather: WeatherNow, settings: LauncherSettings) { Box( Modifier .fillMaxSize() .background( Brush.linearGradient( listOf(Color(0xFF10243E), Color(0xFF315A75), Color(0xFF754E58)) ) ) .padding(horizontal = 64.dp, vertical = 44.dp) ) { Column(Modifier.fillMaxSize()) { Text(settings.locationName, fontSize = 24.sp, color = Color.White.copy(alpha = .82f)) Row(verticalAlignment = Alignment.CenterVertically) { Text(weatherGlyph(weather.weatherCode), fontSize = 72.sp) Spacer(Modifier.width(18.dp)) Text("${weather.temperature.roundToInt()}°", fontSize = 88.sp, fontWeight = FontWeight.Light) Spacer(Modifier.width(24.dp)) Column { Text(weatherDescription(weather.weatherCode), fontSize = 28.sp) Text( "Feels ${weather.apparentTemperature.roundToInt()}° · " + "Humidity ${weather.humidity}% · Wind ${weather.windSpeed.roundToInt()}", color = Color.White.copy(alpha = .78f) ) } } Spacer(Modifier.height(30.dp)) Text("Next hours", fontSize = 18.sp, fontWeight = FontWeight.SemiBold) LazyRow( horizontalArrangement = Arrangement.spacedBy(10.dp), contentPadding = PaddingValues(vertical = 12.dp) ) { items(weather.hourly) { hour -> Column( Modifier .clip(RoundedCornerShape(16.dp)) .background(Color.White.copy(alpha = .11f)) .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Text(hour.time.takeLast(5)) Text("${hour.temperature.roundToInt()}°", fontSize = 24.sp) Text("${hour.precipitationChance}% rain", fontSize = 12.sp) } } } Spacer(Modifier.height(20.dp)) Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { weather.daily.forEach { day -> Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( LocalDate.parse(day.date).dayOfWeek .getDisplayName(TextStyle.SHORT, Locale.getDefault()) ) Text(weatherGlyph(day.weatherCode), fontSize = 28.sp) Text("${day.high.roundToInt()}° / ${day.low.roundToInt()}°") } } } } } } @Composable private fun AppTray( apps: List, pinned: Set, onDismiss: () -> Unit, onTogglePinned: (LauncherApp) -> Unit, onLaunch: (LauncherApp) -> Unit ) { var query by rememberSaveable { mutableStateOf("") } var showAll by rememberSaveable { mutableStateOf(false) } val pinnedApps = apps.filter { it.component.flattenToString() in pinned } val visible = (if (showAll || pinnedApps.isEmpty()) apps else pinnedApps) .filter { it.label.contains(query, ignoreCase = true) } Dialog( onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false) ) { Surface( Modifier .fillMaxWidth(.78f) .fillMaxHeight(.82f), shape = RoundedCornerShape(28.dp), color = Color(0xF2181E25) ) { Column(Modifier.padding(24.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Text( if (showAll) "All applications" else "Quick launch", style = MaterialTheme.typography.headlineSmall, modifier = Modifier.weight(1f) ) IconButton(onClick = onDismiss) { Icon(Icons.Default.Close, "Close") } } Row(verticalAlignment = Alignment.CenterVertically) { OutlinedTextField( value = query, onValueChange = { query = it }, singleLine = true, leadingIcon = { Icon(Icons.Default.Search, null) }, placeholder = { Text("Search apps") }, modifier = Modifier.weight(1f) ) Spacer(Modifier.width(12.dp)) OutlinedButton(onClick = { showAll = !showAll }) { Icon(if (showAll) Icons.Default.Check else Icons.Default.Add, null) Spacer(Modifier.width(6.dp)) Text(if (showAll) "Done" else "Add app") } } Spacer(Modifier.height(18.dp)) if (visible.isEmpty()) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text(if (query.isBlank()) "No apps pinned yet." else "No matching apps.") } } else { LazyVerticalGrid( columns = GridCells.Adaptive(96.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(14.dp) ) { items(visible, key = { it.component.flattenToString() }) { app -> AppTile( app = app, isPinned = app.component.flattenToString() in pinned, editMode = showAll, onClick = { if (showAll) onTogglePinned(app) else onLaunch(app) } ) } } } } } } } @Composable private fun AppTile( app: LauncherApp, isPinned: Boolean, editMode: Boolean, onClick: () -> Unit ) { Column( Modifier .clip(RoundedCornerShape(18.dp)) .clickable(onClick = onClick) .padding(8.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Box { Image( bitmap = remember(app.component) { app.icon.toBitmap(96, 96).asImageBitmap() }, contentDescription = app.label, modifier = Modifier.size(58.dp) ) if (editMode) { Box( Modifier .align(Alignment.TopEnd) .size(20.dp) .clip(CircleShape) .background(if (isPinned) Color(0xFF81C784) else Color(0xFF45505D)), contentAlignment = Alignment.Center ) { Icon( if (isPinned) Icons.Default.Check else Icons.Default.Add, null, modifier = Modifier.size(14.dp) ) } } } Spacer(Modifier.height(6.dp)) Text( app.label, maxLines = 2, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center, fontSize = 12.sp ) } } private enum class SettingsSection { DISPLAY, WEATHER, ROUTINES, VOICE, APPS, ACCESS } @Composable private fun SettingsScreen( state: LauncherUiState, onBack: () -> Unit, updateSettings: ((LauncherSettings) -> LauncherSettings) -> Unit, setLocalFolder: (String) -> Unit, refreshWeather: () -> Unit, requestHomeRole: () -> Unit, setWakeWordEnabled: (Boolean) -> Unit, setWakeWordSensitivity: (Int) -> Unit, pauseWakeWord: () -> Unit, resumeWakeWord: () -> Unit, testWakeWord: () -> Unit ) { var section by rememberSaveable { mutableStateOf(SettingsSection.DISPLAY) } val context = LocalContext.current val folderPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> if (uri != null) { runCatching { context.contentResolver.takePersistableUriPermission( uri, Intent.FLAG_GRANT_READ_URI_PERMISSION ) } setLocalFolder(uri.toString()) } } Row(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { NavigationRail( header = { IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, "Back") } } ) { SettingsSection.entries.forEach { item -> val icon = when (item) { SettingsSection.DISPLAY -> ImageIcon SettingsSection.WEATHER -> Icons.Default.Refresh SettingsSection.ROUTINES -> Icons.Default.VolumeUp SettingsSection.VOICE -> Icons.Default.Mic SettingsSection.APPS -> Icons.Default.Apps SettingsSection.ACCESS -> Icons.Default.Info } NavigationRailItem( selected = section == item, onClick = { section = item }, icon = { Icon(icon, null) }, label = { Text(item.name.lowercase().replaceFirstChar(Char::uppercase)) } ) } } Box(Modifier.fillMaxSize()) { when (section) { SettingsSection.DISPLAY -> DisplaySettings( state.settings, updateSettings, onPickFolder = { folderPicker.launch(null) } ) SettingsSection.WEATHER -> WeatherSettings( state, updateSettings, refreshWeather ) SettingsSection.ROUTINES -> RoutineSettings(state.settings, updateSettings) SettingsSection.VOICE -> VoiceSettings( state = state, setEnabled = setWakeWordEnabled, setSensitivity = setWakeWordSensitivity, pause = pauseWakeWord, resume = resumeWakeWord, test = testWakeWord ) SettingsSection.APPS -> ApplicationsSettings(state, updateSettings) SettingsSection.ACCESS -> AccessSettings(requestHomeRole) } } } } @Composable private fun SettingsPage(title: String, content: @Composable ColumnScope.() -> Unit) { LazyColumn( Modifier.fillMaxSize().padding(horizontal = 36.dp), contentPadding = PaddingValues(top = 28.dp, bottom = 40.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { item { Text(title, style = MaterialTheme.typography.headlineMedium) } item { Column(verticalArrangement = Arrangement.spacedBy(12.dp), content = content) } } } @Composable private fun DisplaySettings( settings: LauncherSettings, update: ((LauncherSettings) -> LauncherSettings) -> Unit, onPickFolder: () -> Unit ) = SettingsPage("Display") { SettingsCard("Keep screen awake", "Only applies while this launcher is visible.") { ChoiceMenu( current = settings.keepAwakeMode, label = { when (it) { KeepAwakeMode.WHILE_VISIBLE -> "While launcher is visible" KeepAwakeMode.WHILE_CHARGING -> "Only while charging" KeepAwakeMode.SYSTEM_DEFAULT -> "Follow Android timeout" } }, onSelect = { selected -> update { it.copy(keepAwakeMode = selected) } } ) } SettingsCard("Photo interval", "${settings.intervalSeconds} seconds") { Slider( value = settings.intervalSeconds.toFloat(), onValueChange = { value -> update { it.copy(intervalSeconds = (value / 10).roundToInt() * 10) } }, valueRange = 10f..900f ) } SettingsCard("Image darkening", "${settings.darkenPercent}%") { Slider( value = settings.darkenPercent.toFloat(), onValueChange = { value -> update { it.copy(darkenPercent = value.roundToInt()) } }, valueRange = 0f..70f ) } SettingsCard("Clock and weather corner", "The panel shifts subtly to reduce burn-in.") { ChoiceMenu( current = settings.clockCorner, label = { it.name.lowercase().replace('_', ' ').replaceFirstChar(Char::uppercase) }, onSelect = { selected -> update { it.copy(clockCorner = selected) } } ) } SettingsCard( "Photo source", if (settings.localFolderUri.isBlank()) { "Curated online landscapes, cities, nature, and art." } else { "Using the selected local folder." } ) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Button(onClick = onPickFolder) { Text("Choose local folder") } if (settings.localFolderUri.isNotBlank()) { OutlinedButton(onClick = { update { it.copy(localFolderUri = "") } }) { Text("Use online collection") } } } } } @Composable private fun WeatherSettings( state: LauncherUiState, update: ((LauncherSettings) -> LauncherSettings) -> Unit, refresh: () -> Unit ) = SettingsPage("Weather") { SettingsCard("Show weather", "Compact conditions and periodic full forecast slides.") { Switch( checked = state.settings.showWeather, onCheckedChange = { checked -> update { it.copy(showWeather = checked) } } ) } SettingsCard("Location", "Coordinates keep location permission unnecessary.") { OutlinedTextField( value = state.settings.locationName, onValueChange = { value -> update { it.copy(locationName = value) } }, label = { Text("Location label") }, singleLine = true ) Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { OutlinedTextField( value = state.settings.latitude, onValueChange = { value -> update { it.copy(latitude = value) } }, label = { Text("Latitude") }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), singleLine = true, modifier = Modifier.weight(1f) ) OutlinedTextField( value = state.settings.longitude, onValueChange = { value -> update { it.copy(longitude = value) } }, label = { Text("Longitude") }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), singleLine = true, modifier = Modifier.weight(1f) ) } Button(onClick = refresh, enabled = !state.weatherLoading) { Icon(Icons.Default.Refresh, null) Spacer(Modifier.width(7.dp)) Text(if (state.weatherLoading) "Refreshing…" else "Refresh weather") } state.weatherError?.let { Text(it, color = MaterialTheme.colorScheme.error) } } SettingsCard("Temperature unit", if (state.settings.useFahrenheit) "Fahrenheit" else "Celsius") { Row(verticalAlignment = Alignment.CenterVertically) { Text("°C") Switch( checked = state.settings.useFahrenheit, onCheckedChange = { checked -> update { it.copy(useFahrenheit = checked) } refresh() } ) Text("°F") } } SettingsCard( "Full forecast frequency", "After every ${state.settings.weatherEveryPhotos} photos" ) { Slider( value = state.settings.weatherEveryPhotos.toFloat(), onValueChange = { value -> update { it.copy(weatherEveryPhotos = value.roundToInt()) } }, valueRange = 1f..20f, steps = 18 ) } } @Composable private fun RoutineSettings( settings: LauncherSettings, update: ((LauncherSettings) -> LauncherSettings) -> Unit ) = SettingsPage("Night routine") { SettingsCard( "Scheduled ultra-dim", "Applies whenever the launcher is visible. Android is not awakened by this schedule." ) { Switch( checked = settings.nightRoutineEnabled, onCheckedChange = { value -> update { it.copy(nightRoutineEnabled = value) } } ) } SettingsCard( "Active hours", "${formatHour(settings.nightStartHour)} – ${formatHour(settings.nightEndHour)}" ) { Text("Start") Slider( value = settings.nightStartHour.toFloat(), onValueChange = { value -> update { it.copy(nightStartHour = value.roundToInt()) } }, valueRange = 0f..23f, steps = 22 ) Text("End") Slider( value = settings.nightEndHour.toFloat(), onValueChange = { value -> update { it.copy(nightEndHour = value.roundToInt()) } }, valueRange = 0f..23f, steps = 22 ) } SettingsCard("Ultra-dim strength", "${settings.nightDimPercent}%") { Slider( value = settings.nightDimPercent.toFloat(), onValueChange = { value -> update { it.copy(nightDimPercent = value.roundToInt()) } }, valueRange = 50f..95f ) Text("Night mode also hides weather, disables image motion, and uses a dark-red clock.") } } @Composable private fun VoiceSettings( state: LauncherUiState, setEnabled: (Boolean) -> Unit, setSensitivity: (Int) -> Unit, pause: () -> Unit, resume: () -> Unit, test: () -> Unit ) = SettingsPage("Voice assistant") { val context = LocalContext.current val requiredPermissions = remember { buildList { add(Manifest.permission.RECORD_AUDIO) if (Build.VERSION.SDK_INT >= 33) add(Manifest.permission.POST_NOTIFICATIONS) }.toTypedArray() } val permissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { results -> if (requiredPermissions.all { results[it] == true }) setEnabled(true) } fun permissionsGranted(): Boolean = requiredPermissions.all { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED } SettingsCard( "Listen for “Computer”", "Detection stays on-device. Continued listening in other apps requires a persistent " + "notification and Android’s microphone privacy indicator." ) { Switch( checked = state.settings.wakeWord.enabled, onCheckedChange = { enabled -> if (!enabled) { setEnabled(false) } else if (permissionsGranted()) { setEnabled(true) } else { permissionLauncher.launch(requiredPermissions) } } ) Text( when (state.wakeWord.state) { WakeWordState.DISABLED -> "Disabled" WakeWordState.DOWNLOADING -> "Downloading model" WakeWordState.INSTALLING -> "Installing model" WakeWordState.READY -> "Ready" WakeWordState.LISTENING -> "Listening" WakeWordState.PAUSED -> "Paused" WakeWordState.MICROPHONE_BUSY -> "Microphone busy; retrying" WakeWordState.ERROR -> "Error" }, fontWeight = FontWeight.SemiBold ) if (state.wakeWord.message.isNotBlank()) { Text(state.wakeWord.message, color = MaterialTheme.colorScheme.onSurfaceVariant) } if ( state.wakeWord.modelState == ModelInstallState.DOWNLOADING || state.wakeWord.modelState == ModelInstallState.INSTALLING ) { LinearProgressIndicator( progress = { state.wakeWord.installProgress / 100f }, modifier = Modifier.fillMaxWidth() ) Text("${state.wakeWord.installProgress}%") } } SettingsCard( "Sensitivity", "${state.settings.wakeWord.sensitivity}% · Higher values detect more easily but may false-trigger." ) { Slider( value = state.settings.wakeWord.sensitivity.toFloat(), onValueChange = { setSensitivity(it.roundToInt()) }, valueRange = 0f..100f ) } SettingsCard( "Listening controls", "Testing releases this launcher’s microphone before opening the selected Android assistant." ) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { if (state.wakeWord.state == WakeWordState.PAUSED) { Button(onClick = resume) { Text("Resume") } } else { OutlinedButton( onClick = pause, enabled = state.settings.wakeWord.enabled ) { Text("Pause") } } Button( onClick = test, enabled = state.settings.wakeWord.enabled && state.wakeWord.modelState == ModelInstallState.READY ) { Icon(Icons.Default.Mic, null) Spacer(Modifier.width(7.dp)) Text("Test assistant") } } OutlinedButton( onClick = { runCatching { context.startActivity(Intent(Settings.ACTION_VOICE_INPUT_SETTINGS)) } } ) { Text("Open Android voice settings") } } SettingsCard( "Privacy", "Audio frames are processed locally and are never saved or transmitted." ) { Text("Only the last detection time and service status are retained for diagnostics.") } } @Composable private fun ApplicationsSettings( state: LauncherUiState, update: ((LauncherSettings) -> LauncherSettings) -> Unit ) = SettingsPage("Applications") { SettingsCard( "Quick launch", "${state.settings.pinnedComponents.size} app(s) pinned" ) { Text("Open the app tray from Home, then choose Add app to pin or remove applications.") } SettingsCard( "Privacy", "Recent-app tracking and usage access are disabled." ) { Text("This launcher only reads activities that advertise a launchable icon.") } } @Composable private fun AccessSettings(requestHomeRole: () -> Unit) = SettingsPage("System access") { SettingsCard("Default Home app", "Required for the hardware Home gesture to return here.") { Button(onClick = requestHomeRole) { Icon(Icons.Default.Home, null) Spacer(Modifier.width(7.dp)) Text("Make default Home app") } } PermissionStatus("Internet", true, "Used for weather and the built-in online photo collection.") PermissionStatus("Local photo folder", true, "Granted only to the folder you explicitly select.") PermissionStatus("Location", false, "Not requested. Weather uses coordinates entered in settings.") PermissionStatus( "Microphone", ContextCompat.checkSelfPermission( LocalContext.current, Manifest.permission.RECORD_AUDIO ) == PackageManager.PERMISSION_GRANTED, "Requested only when on-device wake-word listening is enabled." ) PermissionStatus("Display over other apps", false, "Not requested. Ultra-dim only covers this launcher.") PermissionStatus("Usage access", false, "Not requested. Recent-app history is not collected.") PermissionStatus( "Notifications", Build.VERSION.SDK_INT < 33 || ContextCompat.checkSelfPermission( LocalContext.current, Manifest.permission.POST_NOTIFICATIONS ) == PackageManager.PERMISSION_GRANTED, "Used for the visible listening service and assistant fallback action." ) SettingsCard( "Audio output", "A normal launcher cannot force other apps to use a specific device." ) { val context = LocalContext.current OutlinedButton( onClick = { val intent = Intent(Settings.ACTION_SOUND_SETTINGS) runCatching { context.startActivity(intent) } } ) { Icon(Icons.Default.VolumeUp, null) Spacer(Modifier.width(7.dp)) Text("Open Android sound settings") } } } @Composable private fun PermissionStatus(title: String, enabled: Boolean, detail: String) { SettingsCard(title, detail) { Text( if (enabled) "Available" else "Not requested", color = if (enabled) Color(0xFF8ED49A) else MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.SemiBold ) } } @Composable private fun SettingsCard( title: String, subtitle: String, content: @Composable ColumnScope.() -> Unit ) { Card( colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), modifier = Modifier.fillMaxWidth() ) { Column( Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp) ) { Text(title, style = MaterialTheme.typography.titleMedium) Text( subtitle, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium ) content() } } } @Composable private inline fun > ChoiceMenu( current: T, noinline label: (T) -> String, crossinline onSelect: (T) -> Unit ) { var expanded by remember { mutableStateOf(false) } Box { OutlinedButton(onClick = { expanded = true }) { Text(label(current)) } DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { enumValues().forEach { value -> DropdownMenuItem( text = { Text(label(value)) }, onClick = { onSelect(value) expanded = false }, trailingIcon = { if (value == current) Icon(Icons.Default.Check, null) } ) } } } } private fun isNightRoutineActive(settings: LauncherSettings, time: LocalTime): Boolean { if (!settings.nightRoutineEnabled) return false val hour = time.hour return if (settings.nightStartHour == settings.nightEndHour) { true } else if (settings.nightStartHour < settings.nightEndHour) { hour in settings.nightStartHour until settings.nightEndHour } else { hour >= settings.nightStartHour || hour < settings.nightEndHour } } private fun formatHour(hour: Int): String = LocalTime.of(hour.coerceIn(0, 23), 0).format(DateTimeFormatter.ofPattern("h a"))