commit 29fc34dbdf9999c16898bb030b9d84a15cb8da81 Author: jahruz67 Date: Sat Jul 25 14:25:23 2026 -0700 Ambient Launcher #1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ee92525 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle/ +.idea/ +local.properties +build/ +captures/ +.externalNativeBuild/ +.cxx/ +app/release/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..3867f84 --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# Ambient Launcher + +An Android tablet home-screen replacement written in Kotlin and Jetpack Compose. + +## Included + +- Android Home-role declaration and setup prompt +- Full-screen ambient slideshow with crossfade, interval control, image darkening, and optional local folder +- Clock with subtle burn-in position shifting +- API-key-free current weather and forecast from Open-Meteo +- Floating searchable app tray with persistent pinned apps +- Keep-awake options, including charging-only +- Scheduled ultra-dim/night presentation while the launcher is visible +- Honest capability and permission status screen +- Manual Android media output panel shortcut + +## Setup + +Open this folder in a compatible Android Studio installation, let Gradle sync, and run it on an Android 8.0+ device. Select **Make default Home app** during onboarding or later in Settings. + +No API key is required. The initial location is San Francisco; change latitude, longitude, and location label under Weather settings. + +## Deliberately not claimed + +Always-listening wake-word detection, system-wide audio routing, and overlays over unrelated apps are not included. Those features require a wake-word engine/model and/or sensitive Android services and permissions. The permissions screen explains these boundaries. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..f841300 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,53 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") +} + +android { + namespace = "com.ambient.launcher" + compileSdk = 35 + + defaultConfig { + applicationId = "com.ambient.launcher" + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + } + + buildFeatures { + compose = true + buildConfig = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + packaging { + resources.excludes += "/META-INF/{AL2.0,LGPL2.1}" + } +} + +dependencies { + implementation(platform("androidx.compose:compose-bom:2024.12.01")) + implementation("androidx.core:core-ktx:1.15.0") + implementation("androidx.activity:activity-compose:1.10.0") + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") + implementation("androidx.datastore:datastore-preferences:1.1.1") + implementation("androidx.documentfile:documentfile:1.0.1") + implementation("io.coil-kt:coil-compose:2.7.0") + + debugImplementation("androidx.compose.ui:ui-tooling") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..e857e22 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1 @@ +# No custom rules are required. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..85eebf6 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/ambient/launcher/LauncherViewModel.kt b/app/src/main/java/com/ambient/launcher/LauncherViewModel.kt new file mode 100644 index 0000000..7133df1 --- /dev/null +++ b/app/src/main/java/com/ambient/launcher/LauncherViewModel.kt @@ -0,0 +1,159 @@ +package com.ambient.launcher + +import android.app.Application +import android.content.ComponentName +import android.content.Intent +import android.content.pm.PackageManager +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +data class LauncherUiState( + val settings: LauncherSettings = LauncherSettings(), + val apps: List = emptyList(), + val localImages: List = emptyList(), + val weather: WeatherNow? = null, + val weatherLoading: Boolean = false, + val weatherError: String? = null +) + +class LauncherViewModel(application: Application) : AndroidViewModel(application) { + private val store = SettingsStore(application) + private val weatherRepository = WeatherRepository() + private val _uiState = MutableStateFlow(LauncherUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadApps() + viewModelScope.launch { + store.settings.collectLatest { settings -> + val old = _uiState.value.settings + _uiState.value = _uiState.value.copy(settings = settings) + if (settings.localFolderUri != old.localFolderUri) loadLocalImages(settings.localFolderUri) + if ( + settings.useFahrenheit != old.useFahrenheit || + _uiState.value.weather == null + ) { + refreshWeather() + } + } + } + } + + fun updateSettings(transform: (LauncherSettings) -> LauncherSettings) { + val updated = transform(_uiState.value.settings) + _uiState.value = _uiState.value.copy(settings = updated) + viewModelScope.launch { store.save(updated) } + } + + fun togglePinned(app: LauncherApp) { + updateSettings { settings -> + val id = app.component.flattenToString() + val next = settings.pinnedComponents.toMutableSet().apply { + if (!add(id)) remove(id) + } + settings.copy(pinnedComponents = next) + } + } + + fun setLocalFolder(uri: String) { + updateSettings { it.copy(localFolderUri = uri) } + loadLocalImages(uri) + } + + fun refreshWeather() { + val settings = _uiState.value.settings + val lat = settings.latitude.toDoubleOrNull() + val lon = settings.longitude.toDoubleOrNull() + if (lat == null || lon == null || lat !in -90.0..90.0 || lon !in -180.0..180.0) { + _uiState.value = _uiState.value.copy( + weatherLoading = false, + weatherError = "Enter valid latitude and longitude." + ) + return + } + viewModelScope.launch { + _uiState.value = _uiState.value.copy(weatherLoading = true, weatherError = null) + runCatching { weatherRepository.load(lat, lon, settings.useFahrenheit) } + .onSuccess { + _uiState.value = _uiState.value.copy( + weather = it, + weatherLoading = false, + weatherError = null + ) + } + .onFailure { + _uiState.value = _uiState.value.copy( + weatherLoading = false, + weatherError = "Weather unavailable. Check the connection." + ) + } + } + } + + fun launch(app: LauncherApp): Boolean { + val intent = Intent(Intent.ACTION_MAIN) + .addCategory(Intent.CATEGORY_LAUNCHER) + .setComponent(app.component) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED) + return runCatching { + getApplication().startActivity(intent) + }.isSuccess + } + + private fun loadApps() { + viewModelScope.launch { + val result = withContext(Dispatchers.IO) { + val context = getApplication() + val pm = context.packageManager + val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) + val flags = if (android.os.Build.VERSION.SDK_INT >= 33) { + PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_ALL.toLong()) + } else null + val resolved = if (flags != null) { + pm.queryIntentActivities(intent, flags) + } else { + @Suppress("DEPRECATION") + pm.queryIntentActivities(intent, PackageManager.MATCH_ALL) + } + resolved.mapNotNull { info -> + val activity = info.activityInfo ?: return@mapNotNull null + if (activity.packageName == context.packageName) return@mapNotNull null + LauncherApp( + label = info.loadLabel(pm).toString(), + component = ComponentName(activity.packageName, activity.name), + icon = info.loadIcon(pm) + ) + }.distinctBy { it.component }.sortedBy { it.label.lowercase() } + } + _uiState.value = _uiState.value.copy(apps = result) + } + } + + private fun loadLocalImages(uri: String) { + viewModelScope.launch { + val images = withContext(Dispatchers.IO) { + if (uri.isBlank()) return@withContext emptyList() + val context = getApplication() + runCatching { + DocumentFile.fromTreeUri(context, android.net.Uri.parse(uri)) + ?.listFiles() + ?.asSequence() + ?.filter { it.isFile && it.type?.startsWith("image/") == true } + ?.mapNotNull { it.uri } + ?.take(250) + ?.toList() + ?: emptyList() + }.getOrDefault(emptyList()) + } + _uiState.value = _uiState.value.copy(localImages = images) + } + } +} diff --git a/app/src/main/java/com/ambient/launcher/MainActivity.kt b/app/src/main/java/com/ambient/launcher/MainActivity.kt new file mode 100644 index 0000000..038a4a0 --- /dev/null +++ b/app/src/main/java/com/ambient/launcher/MainActivity.kt @@ -0,0 +1,1029 @@ +package com.ambient.launcher + +import android.app.role.RoleManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +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.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.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.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 + ) + } + } + } + + 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 + ) + } + } +} + +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, APPS, ACCESS } + +@Composable +private fun SettingsScreen( + state: LauncherUiState, + onBack: () -> Unit, + updateSettings: ((LauncherSettings) -> LauncherSettings) -> Unit, + setLocalFolder: (String) -> Unit, + refreshWeather: () -> Unit, + requestHomeRole: () -> 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.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.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 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", false, "Not requested. No wake-word model is bundled.") + 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", false, "Not requested because no background voice service runs.") + 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")) diff --git a/app/src/main/java/com/ambient/launcher/Models.kt b/app/src/main/java/com/ambient/launcher/Models.kt new file mode 100644 index 0000000..888e551 --- /dev/null +++ b/app/src/main/java/com/ambient/launcher/Models.kt @@ -0,0 +1,87 @@ +package com.ambient.launcher + +import android.content.ComponentName +import android.graphics.drawable.Drawable + +data class LauncherApp( + val label: String, + val component: ComponentName, + val icon: Drawable +) + +data class WeatherNow( + val temperature: Double, + val apparentTemperature: Double, + val weatherCode: Int, + val high: Double, + val low: Double, + val humidity: Int, + val windSpeed: Double, + val hourly: List, + val daily: List +) + +data class HourlyWeather( + val time: String, + val temperature: Double, + val precipitationChance: Int +) + +data class DailyWeather( + val date: String, + val high: Double, + val low: Double, + val weatherCode: Int +) + +enum class KeepAwakeMode { WHILE_VISIBLE, WHILE_CHARGING, SYSTEM_DEFAULT } +enum class ClockCorner { BOTTOM_LEFT, BOTTOM_RIGHT, TOP_LEFT, TOP_RIGHT } + +data class LauncherSettings( + val intervalSeconds: Int = 60, + val darkenPercent: Int = 18, + val keepAwakeMode: KeepAwakeMode = KeepAwakeMode.WHILE_CHARGING, + val clockCorner: ClockCorner = ClockCorner.BOTTOM_RIGHT, + val showWeather: Boolean = true, + val locationName: String = "San Francisco", + val latitude: String = "37.7749", + val longitude: String = "-122.4194", + val useFahrenheit: Boolean = true, + val weatherEveryPhotos: Int = 5, + val localFolderUri: String = "", + val pinnedComponents: Set = emptySet(), + val nightRoutineEnabled: Boolean = false, + val nightStartHour: Int = 23, + val nightEndHour: Int = 7, + val nightDimPercent: Int = 88 +) + +sealed interface Slide { + data class Photo(val source: Any, val credit: String) : Slide + data object Weather : Slide +} + +fun weatherDescription(code: Int): String = when (code) { + 0 -> "Clear" + 1, 2 -> "Partly cloudy" + 3 -> "Overcast" + 45, 48 -> "Fog" + 51, 53, 55, 56, 57 -> "Drizzle" + 61, 63, 65, 66, 67 -> "Rain" + 71, 73, 75, 77 -> "Snow" + 80, 81, 82 -> "Rain showers" + 85, 86 -> "Snow showers" + 95, 96, 99 -> "Thunderstorm" + else -> "Unknown" +} + +fun weatherGlyph(code: Int): String = when (code) { + 0 -> "☀" + 1, 2 -> "🌤" + 3 -> "☁" + 45, 48 -> "🌫" + 51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82 -> "🌧" + 71, 73, 75, 77, 85, 86 -> "❄" + 95, 96, 99 -> "⛈" + else -> "•" +} diff --git a/app/src/main/java/com/ambient/launcher/SettingsStore.kt b/app/src/main/java/com/ambient/launcher/SettingsStore.kt new file mode 100644 index 0000000..5260f61 --- /dev/null +++ b/app/src/main/java/com/ambient/launcher/SettingsStore.kt @@ -0,0 +1,82 @@ +package com.ambient.launcher + +import android.content.Context +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.dataStore by preferencesDataStore("ambient_launcher") + +class SettingsStore(private val context: Context) { + private object Keys { + val interval = intPreferencesKey("interval") + val darken = intPreferencesKey("darken") + val keepAwake = stringPreferencesKey("keep_awake") + val corner = stringPreferencesKey("clock_corner") + val showWeather = booleanPreferencesKey("show_weather") + val locationName = stringPreferencesKey("location_name") + val latitude = stringPreferencesKey("latitude") + val longitude = stringPreferencesKey("longitude") + val fahrenheit = booleanPreferencesKey("fahrenheit") + val weatherFrequency = intPreferencesKey("weather_frequency") + val localFolder = stringPreferencesKey("local_folder") + val pinned = stringPreferencesKey("pinned") + val nightEnabled = booleanPreferencesKey("night_enabled") + val nightStart = intPreferencesKey("night_start") + val nightEnd = intPreferencesKey("night_end") + val nightDim = intPreferencesKey("night_dim") + } + + val settings: Flow = context.dataStore.data.map { p -> + LauncherSettings( + intervalSeconds = p[Keys.interval] ?: 60, + darkenPercent = p[Keys.darken] ?: 18, + keepAwakeMode = enumOrDefault(p[Keys.keepAwake], KeepAwakeMode.WHILE_CHARGING), + clockCorner = enumOrDefault(p[Keys.corner], ClockCorner.BOTTOM_RIGHT), + showWeather = p[Keys.showWeather] ?: true, + locationName = p[Keys.locationName] ?: "San Francisco", + latitude = p[Keys.latitude] ?: "37.7749", + longitude = p[Keys.longitude] ?: "-122.4194", + useFahrenheit = p[Keys.fahrenheit] ?: true, + weatherEveryPhotos = p[Keys.weatherFrequency] ?: 5, + localFolderUri = p[Keys.localFolder] ?: "", + pinnedComponents = p[Keys.pinned] + ?.split('|') + ?.filter(String::isNotBlank) + ?.toSet() + ?: emptySet(), + nightRoutineEnabled = p[Keys.nightEnabled] ?: false, + nightStartHour = p[Keys.nightStart] ?: 23, + nightEndHour = p[Keys.nightEnd] ?: 7, + nightDimPercent = p[Keys.nightDim] ?: 88 + ) + } + + suspend fun save(value: LauncherSettings) { + context.dataStore.edit { p -> + p[Keys.interval] = value.intervalSeconds + p[Keys.darken] = value.darkenPercent + p[Keys.keepAwake] = value.keepAwakeMode.name + p[Keys.corner] = value.clockCorner.name + p[Keys.showWeather] = value.showWeather + p[Keys.locationName] = value.locationName + p[Keys.latitude] = value.latitude + p[Keys.longitude] = value.longitude + p[Keys.fahrenheit] = value.useFahrenheit + p[Keys.weatherFrequency] = value.weatherEveryPhotos + p[Keys.localFolder] = value.localFolderUri + p[Keys.pinned] = value.pinnedComponents.joinToString("|") + p[Keys.nightEnabled] = value.nightRoutineEnabled + p[Keys.nightStart] = value.nightStartHour + p[Keys.nightEnd] = value.nightEndHour + p[Keys.nightDim] = value.nightDimPercent + } + } + + private inline fun > enumOrDefault(raw: String?, default: T): T = + enumValues().firstOrNull { it.name == raw } ?: default +} diff --git a/app/src/main/java/com/ambient/launcher/WeatherRepository.kt b/app/src/main/java/com/ambient/launcher/WeatherRepository.kt new file mode 100644 index 0000000..eda49ba --- /dev/null +++ b/app/src/main/java/com/ambient/launcher/WeatherRepository.kt @@ -0,0 +1,81 @@ +package com.ambient.launcher + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder + +class WeatherRepository { + suspend fun load(latitude: Double, longitude: Double, fahrenheit: Boolean): WeatherNow = + withContext(Dispatchers.IO) { + val unit = if (fahrenheit) "fahrenheit" else "celsius" + val windUnit = if (fahrenheit) "mph" else "kmh" + val endpoint = buildString { + append("https://api.open-meteo.com/v1/forecast") + append("?latitude=${encode(latitude)}&longitude=${encode(longitude)}") + append("¤t=temperature_2m,apparent_temperature,relative_humidity_2m,weather_code,wind_speed_10m") + append("&hourly=temperature_2m,precipitation_probability") + append("&daily=weather_code,temperature_2m_max,temperature_2m_min") + append("&temperature_unit=$unit&wind_speed_unit=$windUnit&timezone=auto&forecast_days=7") + } + val connection = URL(endpoint).openConnection() as HttpURLConnection + connection.connectTimeout = 8_000 + connection.readTimeout = 8_000 + connection.setRequestProperty("Accept", "application/json") + try { + if (connection.responseCode !in 200..299) { + error("Weather service returned ${connection.responseCode}") + } + val root = JSONObject(connection.inputStream.bufferedReader().use { it.readText() }) + parse(root) + } finally { + connection.disconnect() + } + } + + private fun parse(root: JSONObject): WeatherNow { + val current = root.getJSONObject("current") + val daily = root.getJSONObject("daily") + val hourly = root.getJSONObject("hourly") + val dailyTime = daily.getJSONArray("time") + val dailyCodes = daily.getJSONArray("weather_code") + val highs = daily.getJSONArray("temperature_2m_max") + val lows = daily.getJSONArray("temperature_2m_min") + val hourlyTimes = hourly.getJSONArray("time") + val hourlyTemps = hourly.getJSONArray("temperature_2m") + val rain = hourly.getJSONArray("precipitation_probability") + val currentTime = current.getString("time") + var start = 0 + for (i in 0 until hourlyTimes.length()) { + if (hourlyTimes.getString(i) >= currentTime) { + start = i + break + } + } + return WeatherNow( + temperature = current.getDouble("temperature_2m"), + apparentTemperature = current.getDouble("apparent_temperature"), + weatherCode = current.getInt("weather_code"), + high = highs.getDouble(0), + low = lows.getDouble(0), + humidity = current.getInt("relative_humidity_2m"), + windSpeed = current.getDouble("wind_speed_10m"), + hourly = (start until minOf(start + 8, hourlyTimes.length())).map { i -> + HourlyWeather(hourlyTimes.getString(i), hourlyTemps.getDouble(i), rain.optInt(i)) + }, + daily = (0 until minOf(7, dailyTime.length())).map { i -> + DailyWeather( + dailyTime.getString(i), + highs.getDouble(i), + lows.getDouble(i), + dailyCodes.getInt(i) + ) + } + ) + } + + private fun encode(value: Double): String = + URLEncoder.encode(value.toString(), Charsets.UTF_8.name()) +} diff --git a/app/src/main/res/drawable/ambient_fallback.xml b/app/src/main/res/drawable/ambient_fallback.xml new file mode 100644 index 0000000..0e243a0 --- /dev/null +++ b/app/src/main/res/drawable/ambient_fallback.xml @@ -0,0 +1,8 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher.xml b/app/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..a60a034 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..04c2798 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Ambient Launcher + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..88559df --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,11 @@ + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..cfd711d --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("com.android.application") version "8.7.3" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" apply false +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..9418425 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx1536m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..e9fc3a4 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "AmbientLauncher" +include(":app")