mirror of
https://github.com/jahruz67/Ambient-Launcher.git
synced 2026-08-08 18:14:05 +00:00
Ambient Launcher #1
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
*.iml
|
||||||
|
.gradle/
|
||||||
|
.idea/
|
||||||
|
local.properties
|
||||||
|
build/
|
||||||
|
captures/
|
||||||
|
.externalNativeBuild/
|
||||||
|
.cxx/
|
||||||
|
app/release/
|
||||||
@@ -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.
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
# No custom rules are required.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@drawable/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.AmbientLauncher">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:screenOrientation="userLandscape">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.HOME" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -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<LauncherApp> = emptyList(),
|
||||||
|
val localImages: List<Any> = 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<LauncherUiState> = _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<Application>().startActivity(intent)
|
||||||
|
}.isSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadApps() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
val context = getApplication<Application>()
|
||||||
|
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<Application>()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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<HourlyWeather>,
|
||||||
|
val daily: List<DailyWeather>
|
||||||
|
)
|
||||||
|
|
||||||
|
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<String> = 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 -> "•"
|
||||||
|
}
|
||||||
@@ -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<LauncherSettings> = 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 <reified T : Enum<T>> enumOrDefault(raw: String?, default: T): T =
|
||||||
|
enumValues<T>().firstOrNull { it.name == raw } ?: default
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<gradient
|
||||||
|
android:angle="315"
|
||||||
|
android:endColor="#16243A"
|
||||||
|
android:startColor="#594755"
|
||||||
|
android:type="linear" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path android:fillColor="#132238" android:pathData="M0,0h108v108h-108z" />
|
||||||
|
<path android:fillColor="#84BFEA" android:pathData="M0,78L27,46L43,63L67,30L108,78V108H0z" />
|
||||||
|
<path android:fillColor="#F6D58D" android:pathData="M77,19a11,11 0,1 0,0.1 0z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">Ambient Launcher</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<resources>
|
||||||
|
<style name="Theme.AmbientLauncher" parent="android:style/Theme.Material.NoActionBar">
|
||||||
|
<item name="android:fontFamily">sans</item>
|
||||||
|
<item name="android:windowActionModeOverlay">true</item>
|
||||||
|
<item name="android:windowLightStatusBar">false</item>
|
||||||
|
<item name="android:colorAccent">#D4E7FF</item>
|
||||||
|
<item name="android:navigationBarColor">#000000</item>
|
||||||
|
<item name="android:statusBarColor">#000000</item>
|
||||||
|
<item name="android:windowNoTitle">true</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx1536m -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user