mirror of
https://github.com/jahruz67/Ambient-Launcher.git
synced 2026-08-08 18:14:05 +00:00
- Integrate sherpa-onnx for local "Computer" wake-word detection - Add foreground service (microphone type) for continued listening across apps - Request RECORD_AUDIO, POST_NOTIFICATIONS, and foreground service permissions - Download and verify pinned sherpa-onnx model (17 MB) on first enable - Update LauncherViewModel with wake-word state and controller - Add WorkManager and commons-compress dependencies for model extraction - Update README with wake-word feature details and limitations
1196 lines
46 KiB
Kotlin
1196 lines
46 KiB
Kotlin
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<LauncherApp>,
|
||
pinned: Set<String>,
|
||
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 <reified T : Enum<T>> 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<T>().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"))
|