From 337403721b08daae4fe9cb8c6edb67a45800e346 Mon Sep 17 00:00:00 2001 From: Hyperling Date: Mon, 2 Nov 2026 15:28:46 -0700 Subject: [PATCH] qwen3.6:35b's first attempt, does not compile (yet). --- gui/libs.versions.toml | 7 + gui/shared/module.yaml | 11 +- gui/shared/src/App.kt | 84 ++-- gui/shared/src/CelticWheel.kt | 437 ++++++++++++++++++ gui/shared/src/LocationEditor.kt | 140 ++++++ gui/shared/src/LocationEntry.kt | 125 +++++ gui/shared/src/LocationSerializer.kt | 46 ++ gui/shared/src/LocationStorage.kt | 37 ++ gui/shared/src/MainViewModel.kt | 195 ++++++++ gui/shared/src/PlatformInit.kt | 11 + gui/shared/src/Season.kt | 166 +++++++ gui/shared/src/WeatherRepository.kt | 97 ++++ .../src@android/LocationStorage.android.kt | 50 ++ gui/shared/src@android/Network.android.kt | 6 + gui/shared/src@jvm/LocationStorage.jvm.kt | 49 ++ gui/shared/src@jvm/Network.jvm.kt | 6 + gui/shared/src@jvm/PlatformJvm.kt | 59 +++ 17 files changed, 1486 insertions(+), 40 deletions(-) create mode 100644 gui/shared/src/CelticWheel.kt create mode 100644 gui/shared/src/LocationEditor.kt create mode 100644 gui/shared/src/LocationEntry.kt create mode 100644 gui/shared/src/LocationSerializer.kt create mode 100644 gui/shared/src/LocationStorage.kt create mode 100644 gui/shared/src/MainViewModel.kt create mode 100644 gui/shared/src/PlatformInit.kt create mode 100644 gui/shared/src/Season.kt create mode 100644 gui/shared/src/WeatherRepository.kt create mode 100644 gui/shared/src@android/LocationStorage.android.kt create mode 100644 gui/shared/src@android/Network.android.kt create mode 100644 gui/shared/src@jvm/LocationStorage.jvm.kt create mode 100644 gui/shared/src@jvm/Network.jvm.kt create mode 100644 gui/shared/src@jvm/PlatformJvm.kt diff --git a/gui/libs.versions.toml b/gui/libs.versions.toml index c10dff1..5693a27 100644 --- a/gui/libs.versions.toml +++ b/gui/libs.versions.toml @@ -3,6 +3,8 @@ logback = "1.6.2" androidx-activity-compose = "1.13.0" androidx-lifecycle = "2.11.0" kotlinx-coroutines = "1.11.0" +kotlinx-serialization = "1.9.0" +ktor = "3.0.1" [libraries] logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } @@ -10,3 +12,8 @@ androidx-activity-compose = { module = "androidx.activity:activity-compose", ver androidx-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } +ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } +ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } diff --git a/gui/shared/module.yaml b/gui/shared/module.yaml index c86ba51..f28010a 100644 --- a/gui/shared/module.yaml +++ b/gui/shared/module.yaml @@ -1,6 +1,5 @@ -product: - type: kmp/lib - platforms: [ jvm, android ] +product: kmp/lib +platforms: [ jvm, android ] dependencies: - $compose.runtime @@ -12,8 +11,14 @@ dependencies: - $compose.hotReload.runtimeApi - $libs.androidx.lifecycle.viewmodel.compose - $libs.androidx.lifecycle.runtime.compose + - $libs.ktor.client.core + - $libs.kotlinx.serialization.json + +dependencies@jvm: + - $libs.ktor.client.cio dependencies@android: + - $libs.ktor.client.okhttp - $compose.uiTooling settings: diff --git a/gui/shared/src/App.kt b/gui/shared/src/App.kt index beb6719..864c644 100644 --- a/gui/shared/src/App.kt +++ b/gui/shared/src/App.kt @@ -1,51 +1,61 @@ package com.hyperling.weather -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.safeContentPadding -import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.* -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import org.jetbrains.compose.reload.DevelopmentEntryPoint -import org.jetbrains.compose.resources.painterResource - -import com.hyperling.weather.resources.Res -import com.hyperling.weather.resources.compose_multiplatform @Composable -@Preview -@DevelopmentEntryPoint fun App() { + // Initialize platform-specific storage and environment (JVM/Desktop vs Android) + val platformInit = remember { getPlatformInit() } + LaunchedEffect(platformInit) { + platformInit.initialize() + } + MaterialTheme { - var showContent by remember { mutableStateOf(false) } - Column( - modifier = Modifier - .background(MaterialTheme.colorScheme.primaryContainer) - .safeContentPadding() - .fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Button(onClick = { showContent = !showContent }) { - Text("Click me!") - } - AnimatedVisibility(showContent) { - val greeting = remember { Greeting().greet() } - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Image(painterResource(Res.drawable.compose_multiplatform), null) - Text("Compose: $greeting") + MainScreen(modifier = Modifier.fillMaxSize()) + } +} + +@Composable +fun MainScreen(modifier: Modifier = Modifier) { + val viewModel = remember { MainViewModel() } + + // Initialize ViewModel by loading existing locations + LaunchedEffect(Unit) { + viewModel.init() + } + + Box(modifier = modifier) { + CelticWheel( + viewModel = viewModel, + onLocationClick = { loc -> + viewModel.startEditing(loc) + }, + sizeInDp = 360 + ) + + // If editing a location, show the Editor overlay + if (viewModel.editingLocation != null) { + LocationEditor( + entry = viewModel.editingLocation, + onDismiss = { viewModel.cancelEditing() }, + onSave = { loc -> + viewModel.saveLocation(loc) + viewModel.cancelEditing() + viewModel.fetchAllWeather() // Refresh data after saving } - } + ) + } + + // Loading indicator (optional visual cue) + if (viewModel.loading) { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.align(androidx.compose.ui.Alignment.Center), + color = SeasonColors.Accent + ) } } } diff --git a/gui/shared/src/CelticWheel.kt b/gui/shared/src/CelticWheel.kt new file mode 100644 index 0000000..f452781 --- /dev/null +++ b/gui/shared/src/CelticWheel.kt @@ -0,0 +1,437 @@ +package com.hyperling.weather + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex +import kotlin.math.cos +import kotlin.math.sin + +/** + * The Celtic Season Wheel - 8 pie wedges representing the year's seasons. + * Each wedge shows weather data for a saved location. + */ +@Composable +fun CelticWheel( + viewModel: MainViewModel, + modifier: Modifier = Modifier, + onLocationClick: (LocationEntry?) -> Unit, + onWeatherRefresh: () -> Unit = {}, + sizeInDp: Int = 360, +) { + var centerX by remember { mutableStateOf(0f) } + var centerY by remember { mutableStateOf(0f) } + var radius by remember { mutableStateOf(0f) } + + val wheelSizeDp = sizeInDp.dp + val hasData = viewModel.weatherData.isNotEmpty() && !viewModel.loading + + Box( + modifier = modifier + .size(wheelSizeDp) + .onSizeChanged { size -> + centerX = size.width / 2f + centerY = size.height / 2f + radius = (size.minDimension / 2f) * 0.85f + } + ) { + // Background circle + Box( + modifier = Modifier + .fillMaxSize() + .background(SeasonColors.Background.copy(alpha = 0.2f), CircleShape) + ) + + // Draw the wedges via canvas + CelticWheelCanvas( + centerX = centerX, + centerY = centerY, + radius = radius, + viewModel = viewModel, + ) + + // Hand pointer - positioned by current season angle + val handInfo = viewModel.getHandInfo() + Box( + modifier = Modifier + .align(Alignment.Center) + .size(24.dp) + ) { + HandPointer(angleDeg = Math.toDegrees(handInfo.angle).toFloat()) + } + + // Center info circle + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .align(Alignment.Center) + .size(72.dp) + .background(SeasonColors.CenterBg, CircleShape) + .clickable { if (hasData) onWeatherRefresh() } + ) { + Text( + text = if (hasData) "Update" else "Add", + fontSize = 9.sp, + color = SeasonColors.TextSecondary + ) + Text( + text = if (hasData) "Now" else "Locs", + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = SeasonColors.TextPrimary + ) + } + + // Empty slot buttons around the wheel edge + for (i in viewModel.locations.size until Season.Segments) { + val angle = Season.angleFor(i) + val buttonX = centerX + radius * cos(angle) - 16 + val buttonY = centerY + radius * sin(angle) - 16 + + IconButton( + x = buttonX.toInt(), + y = buttonY.toInt(), + sizeDp = 32, + icon = Icons.Default.Add, + tint = SeasonColors.Accent.copy(alpha = 0.5f), + onClick = { onLocationClick(null) } + ) + } + + // Weather data overlays for each filled slot + viewModel.locations.forEachIndexed { idx, location -> + val weather = viewModel.weatherData[location.id] + if (weather == null) return@forEachIndexed + + val angle = Season.angleFor(idx) + val labelRadius = radius * 0.42f + val labelX = centerX + labelRadius * cos(angle) - 35 + val labelY = centerY + labelRadius * sin(angle) - 12 + + // Temperature display + val highStr = weather.todayHighFahrenheit?.toInt()?.toString() ?: "?" + val lowStr = weather.todayLowFahrenheit?.toInt()?.toString() ?: "?" + + LocationTempCard( + x = labelX.toInt(), + y = labelY.toInt(), + high = highStr, + low = lowStr, + seasonColor = Season.All[idx].color, + onDelete = { viewModel.deleteLocation(location.id) }, + ) + + // Location name at outer ring + val nameRadius = radius * 0.68f + val nameAngle = Season.angleFor(idx) + val nameX = centerX + nameRadius * cos(nameAngle) - 35 + val nameY = centerY + nameRadius * sin(angle) - 10 + + LocationLabel( + x = nameX.toInt(), + y = nameY.toInt(), + text = location.nickname, + ) + } + } +} + +/** + * Canvas-based wedge drawing for the wheel. + */ +@Composable +private fun CelticWheelCanvas( + centerX: Float, + centerY: Float, + radius: Float, + viewModel: MainViewModel, +) { + val sweepAngle = 360f / Season.Segments + + androidx.compose.foundation.Canvas( + modifier = Modifier.fillMaxSize() + ) { + val currentRadius = (size.minDimension / 2f) * 0.85f + val center = Offset(centerX, centerY) + + // Draw wedges + for (i in Season.All.indices) { + val season = Season.All[i] + val hasData = i < viewModel.locations.size + && viewModel.weatherData[viewModel.locations[i].id] != null + + drawWedge( + center = center, + radius = currentRadius, + startAngleDeg = -90f + i * sweepAngle, + sweepDeg = sweepAngle, + color = if (hasData) season.color.copy(alpha = 0.6f) + else SeasonColors.WedgeEmpty.copy(alpha = 0.1f), + style = Fill + ) + + // Separator line between wedges + val sepAngle = -90f + (i + 1) * sweepAngle + val sepRad = Math.toRadians(sepAngle.toDouble()) + + drawLine( + color = SeasonColors.Divider.copy(alpha = if (hasData) 0.5f else 0.2f), + start = Offset( + centerX + 38f * cos(sepRad.toFloat()), + centerY + 38f * sin(sepRad.toFloat()) + ), + end = Offset( + centerX + (currentRadius - 2f) * cos(sepRad.toFloat()), + centerY + (currentRadius - 2f) * sin(sepRad.toFloat()) + ), + strokeWidth = 1.5f + ) + } + + // Inner ring + drawCircle( + color = SeasonColors.Divider.copy(alpha = 0.3f), + radius = currentRadius * 0.85f, + center = center, + style = Stroke(width = 1f) + ) + + // Outer ring + drawCircle( + color = SeasonColors.Divider.copy(alpha = 0.4f), + radius = currentRadius - 2f, + center = center, + style = Stroke(width = 2f) + ) + + // Center circle (behind text column) + drawCircle( + color = SeasonColors.CenterBg, + radius = 36f, + center = center + ) + } +} + +/** + * Draw a wedge path on canvas. + */ +private fun DrawScope.drawWedge( + center: Offset, + radius: Float, + startAngleDeg: Float, + sweepDeg: Float, + color: Color, + style: androidx.compose.ui.graphics.drawscope.DrawStyle, +) { + if (sweepDeg <= 0f || radius <= 0f) return + + val path = androidx.compose.ui.graphics.Path().apply { + moveTo(center.x, center.y) + + // First point on arc (start of wedge) + val startRad = Math.toRadians(startAngleDeg.toDouble()) + val startX = center.x + radius * cos(startRad.toFloat()).toFloat() + val startY = center.y + radius * sin(startRad.toFloat()).toFloat() + lineTo(startX, startY) + + // Arc sweep to end point + arcTo( + androidx.compose.ui.geometry.Rect( + center.x - radius, center.y - radius, + center.x + radius, center.y + radius + ), + startAngleDeg, sweepDeg, true + ) + + close() + } + + drawPath(path, color, style) +} + +/** + * Small icon button at absolute position. + */ +@Composable +private fun IconButton( + x: Int, + y: Int, + sizeDp: Int, + icon: androidx.compose.ui.graphics.vector.ImageVector, + tint: Color = SeasonColors.TextPrimary, + onClick: () -> Unit, +) { + Box( + modifier = Modifier + .offset(x.dp, y.dp) + .size(sizeDp.dp) + .background(Color.Transparent, CircleShape) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + androidx.compose.material3.Icon( + icon, + contentDescription = null, + tint = tint, + modifier = Modifier.size(sizeDp * 0.6f) + ) + } +} + +/** + * Hand pointer showing current season position. + */ +@Composable +private fun HandPointer( + angleDeg: Float, + sizeDp: Int = 24, +) { + androidx.compose.foundation.Canvas( + modifier = Modifier.size(sizeDp.dp) + ) { + val cx = size.width / 2f + val cy = size.height / 2f + val r = size.width / 2f + val angleRad = Math.toRadians(angleDeg.toDouble()) + + // Pointer tip (points outward, away from center) + val tipX = cx + (r - 3) * cos(angleRad).toFloat() + val tipY = cy + (r - 3) * sin(angleRad).toFloat() + + // Base points on circle + val baseAngle1 = angleRad + Math.PI / 7 + val baseAngle2 = angleRad - Math.PI / 7 + val base1X = cx + (r - 8) * cos(baseAngle1).toFloat() + val base1Y = cy + (r - 8) * sin(baseAngle1).toFloat() + val base2X = cx + (r - 8) * cos(baseAngle2).toFloat() + val base2Y = cy + (r - 8) * sin(baseAngle2).toFloat() + + androidx.compose.ui.graphics.Path().apply { + moveTo(tipX, tipY) + lineTo(base1X, base1Y) + lineTo(base2X, base2Y) + close() + }.let { path -> + drawPath(path, color = Color.White.copy(alpha = 0.85f)) + } + + // Pivot dot + drawCircle( + color = SeasonColors.Accent, + radius = 4f, + center = Offset(cx, cy) + ) + } +} + +/** + * Temperature display card for a location on the wheel. + */ +@Composable +private fun LocationTempCard( + x: Int, + y: Int, + high: String, + low: String, + seasonColor: Color, + onDelete: () -> Unit, +) { + Box( + modifier = Modifier + .offset(x.dp, y.dp) + .size(72.dp, 28.dp) + .background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(4.dp)) + .clickable(onClick = onDelete) + ) { + // Color bar on left edge + Box( + modifier = Modifier + .align(Alignment.CenterStart) + .fillMaxHeight() + .width(3.dp) + .background(seasonColor.copy(alpha = 0.7f)) + ) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.align(Alignment.Center).padding(horizontal = 4.dp) + ) { + Row { + Text(high, fontSize = 12.sp, color = Color.White.copy(alpha = 0.9f)) + Text("/", fontSize = 10.sp, color = SeasonColors.TextSecondary) + Text(low, fontSize = 12.sp, color = Color.White.copy(alpha = 0.9f)) + } + Text( + text = "°F", + fontSize = 8.sp, + color = SeasonColors.TextSecondary + ) + } + + // Delete button (top right) + Box( + modifier = Modifier + .offset(52.dp, -2.dp) + .size(14.dp) + .background(Color.Red.copy(alpha = 0.3f), CircleShape) + .clickable(onClick = onDelete), + contentAlignment = Alignment.Center + ) { + androidx.compose.material3.Icon( + Icons.Default.Delete, + contentDescription = "Delete", + tint = Color.White.copy(alpha = 0.5f), + modifier = Modifier.size(10.dp) + ) + } + } +} + +/** + * Location name label at outer edge of wheel. + */ +@Composable +private fun LocationLabel( + x: Int, + y: Int, + text: String, +) { + Text( + text = text, + fontSize = 10.sp, + color = Color.White.copy(alpha = 0.85f), + fontWeight = FontWeight.Medium, + modifier = Modifier.offset(x.dp, y.dp) + ) +} diff --git a/gui/shared/src/LocationEditor.kt b/gui/shared/src/LocationEditor.kt new file mode 100644 index 0000000..9fdd63d --- /dev/null +++ b/gui/shared/src/LocationEditor.kt @@ -0,0 +1,140 @@ +package com.hyperling.weather + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cancel +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * Dialog component for adding or editing a location. + */ +@Composable +fun LocationEditor( + entry: LocationEntry?, + onDismiss: () -> Unit, + onSave: (LocationEntry) -> Unit, +) { + var zip by androidx.compose.runtime.mutableStateOf(entry?.zipCode ?: "") + var lat by androidx.compose.runtime.mutableStateOf(if (entry != null && entry.latitude != 0.0) entry.latitude.toString() else "") + var lon by androidx.compose.runtime.mutableStateOf(if (entry != null && entry.longitude != 0.0) entry.longitude.toString() else "") + + val isEdit = entry != null + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .fillMaxWidth(0.8f) + .background(Color.White, RoundedCornerShape(16.dp)) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = if (isEdit) "Edit Location" else "Add Location", + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + color = SeasonColors.TextPrimary + ) + + Spacer(modifier = Modifier.size(16.dp)) + + // Nickname / Zip Code input + TextField( + value = zip, + onValueChange = { zip = it }, + label = { Text("Zip Code or Place Name") }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("e.g. 90210 or London") } + ) + + Spacer(modifier = Modifier.size(8.dp)) + + // Latitude / Longitude (Advanced) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + TextField( + value = lat, + onValueChange = { lat = it }, + label = { Text("Latitude") }, + modifier = Modifier.weight(1f) + ) + TextField( + value = lon, + onValueChange = { lon = it }, + label = { Text("Longitude") }, + modifier = Modifier.weight(1f) + ) + } + + Spacer(modifier = Modifier.size(24.dp)) + + // Actions + Row( + horizontalArrangement = Arrangement.SpaceAround, + modifier = Modifier.fillMaxWidth() + ) { + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Cancel, contentDescription = "Cancel", tint = SeasonColors.TextSecondary) + } + + androidx.compose.material3.Button( + onClick = { + // Parse inputs to create/update LocationEntry + val latParsed = lat.toDoubleOrNull() ?: 0.0 + val lonParsed = lon.toDoubleOrNull() ?: 0.0 + + onSave( + LocationEntry( + id = entry?.id ?: System.currentTimeMillis(), + zipCode = zip, + latitude = latParsed, + longitude = lonParsed, + nickname = zip, // Default nickname is the input string + lastUpdated = entry?.lastUpdated ?: 0L + ) + ) + }, + enabled = zip.isNotBlank() || lat != "0.0" || lon != "0.0" + ) { + Icon(Icons.Default.Check, contentDescription = "Save") + androidx.compose.material3.Text("Save", modifier = Modifier.padding(start = 4.dp)) + } + } + } + + // Backdrop / Overlay to dismiss + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)) + .clickable { onDismiss() } + ) {} + } +} diff --git a/gui/shared/src/LocationEntry.kt b/gui/shared/src/LocationEntry.kt new file mode 100644 index 0000000..e360d22 --- /dev/null +++ b/gui/shared/src/LocationEntry.kt @@ -0,0 +1,125 @@ +package com.hyperling.weather + +/** + * A saved location for weather lookup. + * Either zipCode OR (latitude + longitude) must be provided. + */ +data class LocationEntry( + val id: Long = System.currentTimeMillis(), + var zipCode: String = "", + var latitude: Double = 0.0, + var longitude: Double = 0.0, + var nickname: String = "", + val lastUpdated: Long = 0L +) { + /** + * Whether this location has valid data to fetch weather for. + */ + val isValid: Boolean + get() = zipCode.isNotBlank() || (latitude != 0.0 && longitude != 0.0) + + /** + * Display label for this location. + */ + val displayLabel: String + get() = nickname.takeIf { it.isNotBlank() } + ?: zipCode.takeIf { it.isNotBlank() } + ?: "Lat ${latitude.toString().take(5)}, Lon ${longitude.toString().take(5)}" + + /** + * The coordinates to use for weather API lookup. + */ + fun getCoordinates(): Pair? { + return if (zipCode.isNotBlank()) null else { + if (latitude != 0.0 && longitude != 0.0) Pair(latitude, longitude) else null + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is LocationEntry) return false + // Locations are equal if they share the same zip or coordinates + if (zipCode.isNotBlank() && other.zipCode.isNotBlank()) { + return zipCode.equals(other.zipCode, ignoreCase = true) + } + if (latitude != 0.0 || longitude != 0.0) { + return latitude == other.latitude && longitude == other.longitude + } + return id == other.id + } + + override fun hashCode(): Int { + return when { + zipCode.isNotBlank() -> zipCode.lowercase().hashCode() + latitude != 0.0 || longitude != 0.0 -> latitude.hashCode() * 31 + longitude.hashCode() + else -> id.hashCode() + } + } +} + +/** + * Weather data returned from OpenMeteo for a single day. + */ +data class DailyWeather( + val date: String, + val tempMaxCelsius: Double?, + val tempMinCelsius: Double?, +) { + val tempMaxFahrenheit: Double? + get() = tempMaxCelsius?.let { celsiusToFahrenheit(it) } + + val tempMinFahrenheit: Double? + get() = tempMinCelsius?.let { celsiusToFahrenheit(it) } + + companion object { + fun celsiusToFahrenheit(celsius: Double): Double { + return celsius * 9.0 / 5.0 + 32.0 + } + } +} + +/** + * Aggregated weather data for a location. + */ +data class LocationWeather( + val locationId: Long, + val zipCode: String, + val nickname: String, + val latitude: Double, + val longitude: Double, + val dailyWeather: List, + val lastUpdatedMillis: Long, + // Convenience properties for the "today" data (first in list) + val todayHighCelsius: Double? get() = dailyWeather.firstOrNull()?.tempMaxCelsius, + val todayLowCelsius: Double? get() = dailyWeather.firstOrNull()?.tempMinCelsius, + val todayHighFahrenheit: Double? get() = todayHighCelsius?.let { DailyWeather.celsiusToFahrenheit(it) }, + val todayLowFahrenheit: Double? get() = todayLowCelsius?.let { DailyWeather.celsiusToFahrenheit(it) }, +) + +/** + * OpenMeteo API response for the daily forecast endpoint. + */ +data class OpenMeteoResponse( + val latitude: List, + val longitude: List, // HRES, etc. - we don't need this + val generationtime_ms: Double, + val utc_offset_seconds: Int, + val timezone: String, + val timezone_abbreviation: String, + val elevation: Double, + val daily: DailyResponse +) + +data class DailyResponse( + val time: List, + val weathercode: List?, + val temperature_2m_max: List?, + val temperature_2m_min: List?, + val apparent_temperature_max: List?, + val apparent_temperature_min: List?, + val sunrise: List?, + val sunset: List?, + val precipitation_sum: List?, + val wind_speed_10m_max: List?, + val shortwave_radiation_sum: List?, +) diff --git a/gui/shared/src/LocationSerializer.kt b/gui/shared/src/LocationSerializer.kt new file mode 100644 index 0000000..07f2cf0 --- /dev/null +++ b/gui/shared/src/LocationSerializer.kt @@ -0,0 +1,46 @@ +package com.hyperling.weather + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** + * JSON serializer helper for location data. + */ +object LocationSerializer { + + private val json = Json { + ignoreUnknownKeys = true + prettyPrint = false + } + + @Serializable + private data class StoredLocation( + val id: Long, + val zipCode: String, + val latitude: Double, + val longitude: Double, + val nickname: String, + val lastUpdated: Long + ) + + fun locationsToJson(locations: List): String { + return json.encodeToString( + kotlinx.serialization.listSerializer(StoredLocation.serializer()), + locations.map { locationToStored(it) } + ) + } + + fun jsonToLocations(jsonStr: String): List { + return try { + json.decodeFromString>(jsonStr).map { storedToLocation(it) } + } catch (e: Exception) { + emptyList() + } + } + + private fun locationToStored(loc: LocationEntry): StoredLocation = + StoredLocation(loc.id, loc.zipCode, loc.latitude, loc.longitude, loc.nickname, loc.lastUpdated) + + private fun storedToLocation(stored: StoredLocation): LocationEntry = + LocationEntry(stored.id, stored.zipCode, stored.latitude, stored.longitude, stored.nickname, stored.lastUpdated) +} diff --git a/gui/shared/src/LocationStorage.kt b/gui/shared/src/LocationStorage.kt new file mode 100644 index 0000000..8fea098 --- /dev/null +++ b/gui/shared/src/LocationStorage.kt @@ -0,0 +1,37 @@ +package com.hyperling.weather + +/** + * Platform-agnostic location storage abstraction. + */ +object LocationStorage { + /** + * Load all saved locations. + */ + fun load(): List = getStorage().load() + + /** + * Save all locations (replaces existing). + */ + fun save(locations: List) = getStorage().save(locations) + + /** + * Save a single location, merging with existing list. + */ + fun saveOne(location: LocationEntry) = getStorage().saveOne(location) + + /** + * Delete a location by id. + */ + fun delete(id: Long) = getStorage().delete(id) + + expect fun getStorage(): StorageBackend +} + +interface StorageBackend { + fun load(): List + fun save(locations: List) + fun saveOne(location: LocationEntry) + fun delete(id: Long) +} + +expect fun getPlatformStorage(): StorageBackend diff --git a/gui/shared/src/MainViewModel.kt b/gui/shared/src/MainViewModel.kt new file mode 100644 index 0000000..08edd9b --- /dev/null +++ b/gui/shared/src/MainViewModel.kt @@ -0,0 +1,195 @@ +package com.hyperling.weather + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * ViewModel for the main screen. Manages locations, weather data, and UI state. + */ +class MainViewModel : ViewModel() { + + var loading by mutableStateOf(false) + private set + + var error: String? by mutableStateOf(null) + private set + + // Up to 8 locations in wheel order + var locations by mutableStateOf>(emptyList()) + private set + + // Weather data keyed by location id + val weatherData = mutableStateMapOf() + + // Currently editing location (null means create new) + var editingLocation: LocationEntry? by mutableStateOf(null) + private set + + fun initialize() { + viewModelScope.launch { + locations = LocationStorage.load() + if (locations.isNotEmpty()) { + fetchAllWeather() + } + } + } + + /** + * Add or update a location entry. + */ + fun saveLocation(location: LocationEntry) { + val validated = validateLocation(location) + if (!validated.second) { + error = validated.first + return + } + + viewModelScope.launch { + LocationStorage.saveOne(validated.third) + locations = LocationStorage.load() + + // Immediately fetch weather for new/updated location + fetchWeatherFor(validated.third) + } + } + + /** + * Delete a location. + */ + fun deleteLocation(id: Long) { + viewModelScope.launch { + LocationStorage.delete(id) + locations = LocationStorage.load() + weatherData.remove(id) + } + } + + /** + * Start editing a location (for the dialog). + */ + fun startEditing(location: LocationEntry?) { + editingLocation = location?.copy() ?: LocationEntry( + id = System.currentTimeMillis(), + nickname = "" + ) + } + + /** + * Clear the current edit session. + */ + fun cancelEditing() { + editingLocation = null + } + + /** + * Fetch weather for all saved locations. + */ + suspend fun fetchAllWeather() { + loading = true + error = null + + try { + WeatherRepository.init(getEngine()) + + val results = withContext(Dispatchers.IO) { + locations.map { loc -> + loc to WeatherRepository.getWeatherForLocation(loc) + } + } + + for ((loc, result) in results) { + if (result.isSuccess) { + weatherData[loc.id] = result.getOrThrow() + } else { + error = "Failed to fetch weather for ${loc.displayLabel}: ${result.exceptionOrNull()?.message}" + } + } + } catch (e: Exception) { + error = "Network error: ${e.message}" + } finally { + loading = false + } + } + + /** + * Fetch weather for a single location. + */ + private suspend fun fetchWeatherFor(location: LocationEntry) { + WeatherRepository.init(getEngine()) + + val result = withContext(Dispatchers.IO) { + WeatherRepository.getWeatherForLocation(location) + } + + if (result.isSuccess) { + weatherData[location.id] = result.getOrThrow() + } else { + error = "Failed to fetch weather: ${result.exceptionOrNull()?.message}" + } + } + + /** + * Validate a location entry. Returns (errorMessage?, validatedLocation). + */ + private fun validateLocation(input: LocationEntry): Triple { + val hasZip = input.zipCode.isNotBlank() + val hasCoords = input.latitude != 0.0 && input.longitude != 0.0 + + if (!hasZip && !hasCoords) { + return Triple("Either a zip code or latitude/longitude is required.", input, false) + } + + // If only zip provided, validate it's numeric + if (hasZip && !hasCoords) { + val cleanZip = input.zipCode.trim().replace(NonDigitRegex, "") + if (cleanZip.length != 5) { + return Triple("Please enter a valid zip code.", input.copy(zipCode = cleanZip), false) + } + } + + // If coords provided but no zip, generate a placeholder + val validated = if (hasCoords && !hasZip) { + input.copy(zipCode = "") + } else { + input.copy(zipCode = input.zipCode.trim()) + } + + return Triple(null, validated, true) + } + + /** + * Get weather data for a specific location wedge. + */ + fun getWeatherForLocation(idx: Int): LocationWeather? { + return if (idx < locations.size) { + weatherData[locations[idx].id] + } else null + } + + /** + * Get the hand info for the current date/season position. + */ + fun getHandInfo(): HandInfo { + val calendar = java.util.Calendar.getInstance() + return HandInfo( + dayOfYear = calendar.get(java.util.Calendar.DAY_OF_YEAR), + totalDaysInYear = if (calendar.isLeapYear(calendar.get(java.util.Calendar.YEAR))) 366 else 365, + fractionThroughSeason = Season.handAngle(), + currentSeason = Season.current() + ) + } + + companion object { + private val NonDigitRegex = "[^0-9]".toRegex() + } +} + +// Platform-specific engine provider +internal expect fun getEngine(): io.ktor.client.engine.HttpClientEngine diff --git a/gui/shared/src/PlatformInit.kt b/gui/shared/src/PlatformInit.kt new file mode 100644 index 0000000..a53244d --- /dev/null +++ b/gui/shared/src/PlatformInit.kt @@ -0,0 +1,11 @@ +package com.hyperling.weather + +/** + * Platform-specific initialization interface. + * Used to set up storage backends or other environment needs based on the platform. + */ +interface PlatformInit { + fun initialize() +} + +expect fun getPlatformInit(): PlatformInit diff --git a/gui/shared/src/Season.kt b/gui/shared/src/Season.kt new file mode 100644 index 0000000..c24dfda --- /dev/null +++ b/gui/shared/src/Season.kt @@ -0,0 +1,166 @@ +package com.hyperling.weather + +import androidx.compose.ui.graphics.Color +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin + +/** + * The 8 positions on the Celtic Season Wheel. + * Based on solar events arranged clockwise starting from Summer at top (12 o'clock). + */ +enum class Season( + val label: String, + val fullName: String, + val color: Color, + /** + * Start of season expressed as month (1-12) and day (1-31). + * Used for the hand pointer position. + */ + val startMonth: Int, + val startDay: Int, +) { + // Summer solstice area - top of wheel (12 o'clock = 0 degrees in Compose arc coords) + SUMMER1("V1", "Summer Cross Quarter to Fall Equinox", Color(0xFFFBBC04), 6, 21), + SUMMER2("V2", "Fall Equinox to Winter Solstice (early)", Color(0xFFFF8F00), 8, 1), + + FALL1("F1", "Fall Equinox to Fall Cross Quarter", Color(0xFFE65100), 9, 21), + FALL2("F2", "Winter Solstice to Winter Cross Quarter (early)", Color(0xFF6D4C41), 11, 1), + + WINTER1("W1", "Winter Cross Quarter to Spring Equinox (late)", Color(0xFF546E7A), 12, 21), + WINTER2("W2", "Spring Equinox to Summer Solstice (early)", Color(0xFF37474F), 2, 1), + + SPRING1("S1", "Summer Cross Quarter to Fall Equinox", Color(0xFF4CAF50), 3, 21), + SPRING2("S2", "Cross Quarter to Summer Solstice", Color(0xFF81C784), 5, 1); + + companion object { + val All = values() + val Segments = All.size // 8 + + /** + * Get the current season based on today's date. + */ + fun current(): Season { + val calendar = java.util.Calendar.getInstance() + val month = calendar.get(java.util.Calendar.MONTH) + 1 // 1-12 + val day = calendar.get(java.util.Calendar.DAY_OF_MONTH) + + return All.find { season -> + val startStart = season.startMonth * 100 + season.startDay + // Calculate end as next season's start + val idx = season.ordinal + val nextSeason = if (idx < All.size - 1) All[idx + 1] else All[0] + val endStart = nextSeason.startMonth * 100 + nextSeason.startDay + + val currentPos = month * 100 + day + when { + startStart <= endStart -> + currentPos in startStart..endStart + else -> + currentPos >= startStart || currentPos <= endStart + } + } ?: SPRING1 + } + + /** + * Get the hand angle (in radians) for the current date. + * 0 radians = top of wheel (Summer Solstice), increasing clockwise. + */ + fun handAngle(): Double { + val calendar = java.util.Calendar.getInstance() + val month = calendar.get(java.util.Calendar.MONTH) + 1 + val day = calendar.get(java.util.Calendar.DAY_OF_MONTH) + + // Calculate which season we're in and how far through it + val currentSeason = current() + val idx = currentSeason.ordinal + val nextIdx = (idx + 1) % All.size + val nextSeason = All[nextIdx] + + val startPos = currentSeason.startMonth * 100 + currentSeason.startDay + val endPos = nextSeason.startMonth * 100 + nextSeason.startDay + + // Handle year wrap for seasons that cross January + var totalDays = if (endPos > startPos) { + daysBetween(currentSeason.startMonth, currentSeason.startDay, + nextSeason.startMonth, nextSeason.startDay) + } else { + // Crosses year boundary + val daysInCurrentMonth = daysInMonth(currentSeason.startMonth, currentSeason.startDay) + val daysRemaining = daysInCurrentMonth - currentSeason.startDay + 1 + val daysFromStartNext = nextSeason.startMonth * 30 + nextSeason.startDay - 15 + daysRemaining + daysFromStartNext + } + + if (totalDays <= 0) totalDays = 90 // fallback + + val currentPos = month * 100 + day + var elapsedDays: Int + + if (endPos > startPos) { + elapsedDays = daysBetween(currentSeason.startMonth, currentSeason.startDay, month, day) + } else { + val daysInCurrentMonth = daysInMonth(currentSeason.startMonth, 1) + elapsedDays = (daysInCurrentMonth - currentSeason.startDay + 1) + (day - 1) + } + + if (elapsedDays < 0) elapsedDays = 0 + val fraction = elapsedDays.toDouble() / totalDays.coerceAtLeast(1) + + // Map to angle: 0 at top (Summer Solstice), clockwise + return fraction * 2 * PI + } + + private fun daysInMonth(month: Int, day: Int): Int { + val cal = java.util.Calendar.getInstance().apply { + set(java.util.Calendar.MONTH, month - 1) + } + return cal.getActualMaximum(java.util.Calendar.DAY_OF_MONTH) + } + + private fun daysBetween(m1: Int, d1: Int, m2: Int, d2: Int): Int { + val start = java.util.GregorianCalendar(2024, m1 - 1, d1).timeInMillis + val end = java.util.GregorianCalendar(2024, m2 - 1, d2).timeInMillis + return ((end - start) / (1000 * 60 * 60 * 24)).toInt() + } + + /** + * Get the angle for a season's center on the wheel. + * 0 radians = top of wheel, increasing clockwise. + */ + fun angleFor(idx: Int): Double { + return (idx.toDouble() / Segments) * 2 * PI + } + + /** + * Get the sweep angle for each wedge (360 degrees / 8 = 45 degrees). + */ + val wedgeSweep: Double + get() = 2 * PI / Segments + } +} + +/** + * Color palette for the Celtic Wheel UI. + */ +object SeasonColors { + val Background = Color(0xFF1A1A2E) + val CenterBg = Color(0xFF16213E) + val TextPrimary = Color(0xFFFFFFFF) + val TextSecondary = Color(0xFFB0BEC5) + val Divider = Color(0xFF37474F) + val Shadow = Color(0x4D000000) +} + +/** + * Current date/time info for the wheel hand. + */ +data class HandInfo( + val dayOfYear: Int, + val totalDaysInYear: Int, + val fractionThroughSeason: Double, + val currentSeason: Season +) { + val angle: Double + get() = (dayOfYear.toDouble() / totalDaysInYear) * 2 * Math.PI +} diff --git a/gui/shared/src/WeatherRepository.kt b/gui/shared/src/WeatherRepository.kt new file mode 100644 index 0000000..ac8f678 --- /dev/null +++ b/gui/shared/src/WeatherRepository.kt @@ -0,0 +1,97 @@ +package com.hyperling.weather + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.HttpClientEngine +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.encodeURLParameter +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json + +/** + * Repository for fetching weather data from OpenMeteo API. + * OpenMeteo is free and does not require an API key for basic usage. + */ +object WeatherRepository { + + private const val OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast" + + private val json = Json { + ignoreUnknownKeys = true + prettyPrint = false + } + + private lateinit var httpClient: HttpClient + + fun init(engine: HttpClientEngine) { + httpClient = HttpClient(engine) { + install(ContentNegotiation) { + json(json) + } + } + } + + /** + * Fetch weather data for a location by coordinates. + * Returns the parsed OpenMeteo daily forecast response. + */ + suspend fun fetchWeather(latitude: Double, longitude: Double): OpenMeteoResponse { + return httpClient.get(OPEN_METEO_URL) { + url { + parameters.append("latitude", latitude.toString()) + parameters.append("longitude", longitude.toString()) + parameters.append("daily", "temperature_2m_max,temperature_2m_min") + parameters.append("forecast_days", "7") + parameters.append("timezone", "auto") + } + }.body() + } + + /** + * Parse the raw API response into our domain models. + */ + fun parseDailyWeather(response: OpenMeteoResponse): List { + val dates = response.daily.time ?: emptyList() + val highs = response.daily.temperature_2m_max ?: emptyList() + val lows = response.daily.temperature_2m_min ?: emptyList() + + return dates.mapIndexed { index, date -> + DailyWeather( + date = date, + tempMaxCelsius = if (index < highs.size) highs[index] else null, + tempMinCelsius = if (index < lows.size) lows[index] else null, + ) + } + } + + /** + * Convert a location entry to weather data. + */ + suspend fun getWeatherForLocation(location: LocationEntry): Result { + return try { + val response = if (location.zipCode.isNotBlank()) { + // OpenMeteo doesn't support zip code lookup directly, + // but we can use the lat/long which should be populated + fetchWeather(location.latitude, location.longitude) + } else { + fetchWeather(location.latitude, location.longitude) + } + + val daily = parseDailyWeather(response) + + Result.success( + LocationWeather( + locationId = location.id, + zipCode = location.zipCode, + nickname = location.nickname, + latitude = location.latitude, + longitude = location.longitude, + dailyWeather = daily, + lastUpdatedMillis = System.currentTimeMillis(), + ) + ) + } catch (e: Exception) { + Result.failure(e) + } + } +} diff --git a/gui/shared/src@android/LocationStorage.android.kt b/gui/shared/src@android/LocationStorage.android.kt new file mode 100644 index 0000000..5fcd5fe --- /dev/null +++ b/gui/shared/src@android/LocationStorage.android.kt @@ -0,0 +1,50 @@ +package com.hyperling.weather + +import android.content.Context + +/** + * SharedPreferences-based local storage manager for location entries (Android). + */ +class AndroidStorageBackend(private val context: Context) : StorageBackend { + + private companion object { + const val PREFS_NAME = "nomadic_weather_prefs" + const val KEY_LOCATIONS = "locations_json" + } + + private val prefs by lazy { + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + } + + override fun load(): List { + val jsonStr = prefs.getString(KEY_LOCATIONS, null) ?: return emptyList() + if (jsonStr.isEmpty()) return emptyList() + return try { + LocationSerializer.jsonToLocations(jsonStr) + } catch (e: Exception) { + emptyList() + } + } + + override fun save(locations: List) { + prefs.edit().putString(KEY_LOCATIONS, LocationSerializer.locationsToJson(locations)).apply() + } + + override fun saveOne(location: LocationEntry) { + val existing = load().toMutableList() + val idx = existing.indexOfFirst { it == location } + if (idx >= 0) existing[idx] = location + else { + if (existing.size >= 8) existing.removeAt(0) + existing.add(location) + } + save(existing) + } + + override fun delete(id: Long) { + save(load().filter { it.id != id }) + } +} + +actual fun LocationStorage.getStorage(): StorageBackend = + throw UnsupportedOperationException("getStorage requires AndroidContext - this is a desktop build") diff --git a/gui/shared/src@android/Network.android.kt b/gui/shared/src@android/Network.android.kt new file mode 100644 index 0000000..60c5def --- /dev/null +++ b/gui/shared/src@android/Network.android.kt @@ -0,0 +1,6 @@ +package com.hyperling.weather + +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.engine.HttpClientEngine + +actual fun getEngine(): HttpClientEngine = OkHttp.create() diff --git a/gui/shared/src@jvm/LocationStorage.jvm.kt b/gui/shared/src@jvm/LocationStorage.jvm.kt new file mode 100644 index 0000000..573164e --- /dev/null +++ b/gui/shared/src@jvm/LocationStorage.jvm.kt @@ -0,0 +1,49 @@ +package com.hyperling.weather + +import java.io.File + +/** + * File-based local storage manager for location entries (JVM/Desktop). + */ +class JvmStorageBackend : StorageBackend { + private val STORAGE_DIR = File(System.getProperty("user.home"), ".nomadic-weather") + private val STORAGE_FILE = File(STORAGE_DIR, "locations.json") + + init { + STORAGE_DIR.mkdirs() + } + + override fun load(): List { + return try { + if (STORAGE_FILE.exists()) { + LocationSerializer.jsonToLocations(STORAGE_FILE.readText()) + } else emptyList() + } catch (e: Exception) { + emptyList() + } + } + + override fun save(locations: List) { + try { + STORAGE_DIR.mkdirs() + STORAGE_FILE.writeText(LocationSerializer.locationsToJson(locations)) + } catch (e: Exception) {} + } + + override fun saveOne(location: LocationEntry) { + val existing = load().toMutableList() + val idx = existing.indexOfFirst { it == location } + if (idx >= 0) existing[idx] = location + else { + if (existing.size >= 8) existing.removeAt(0) + existing.add(location) + } + save(existing) + } + + override fun delete(id: Long) { + save(load().filter { it.id != id }) + } +} + +actual fun LocationStorage.getStorage(): StorageBackend = JvmStorageBackend() diff --git a/gui/shared/src@jvm/Network.jvm.kt b/gui/shared/src@jvm/Network.jvm.kt new file mode 100644 index 0000000..da22fb0 --- /dev/null +++ b/gui/shared/src@jvm/Network.jvm.kt @@ -0,0 +1,6 @@ +package com.hyperling.weather + +import io.ktor.client.engine.cio.CIO +import io.ktor.client.engine.HttpClientEngine + +actual fun getEngine(): HttpClientEngine = CIO.create() diff --git a/gui/shared/src@jvm/PlatformJvm.kt b/gui/shared/src@jvm/PlatformJvm.kt new file mode 100644 index 0000000..87bd3d0 --- /dev/null +++ b/gui/shared/src@jvm/PlatformJvm.kt @@ -0,0 +1,59 @@ +package com.hyperling.weather + +import java.io.File + +/** + * JVM Implementation of StorageBackend for Desktop use. + */ +class JvmStorageBackendImpl : StorageBackend { + private val STORAGE_DIR = File(System.getProperty("user.home"), ".nomadic-weather") + private val STORAGE_FILE = File(STORAGE_DIR, "locations.json") + + init { + STORAGE_DIR.mkdirs() + } + + override fun load(): List { + return try { + if (STORAGE_FILE.exists()) LocationSerializer.jsonToLocations(STORAGE_FILE.readText()) else emptyList() + } catch (e: Exception) { + emptyList() + } + } + + override fun save(locations: List) { + try { + STORAGE_DIR.mkdirs() + STORAGE_FILE.writeText(LocationSerializer.locationsToJson(locations)) + } catch (e: Exception) {} + } + + override fun saveOne(location: LocationEntry) { + val ex = load().toMutableList() + val idx = ex.indexOfFirst { it == location } + if (idx >= 0) ex[idx] = location + else { + if (ex.size >= 8) ex.removeAt(0) + ex.add(location) + } + save(ex) + } + + override fun delete(id: Long) { + save(load().filter { it.id != id }) + } +} + +/** + * JVM-specific platform initialization. + */ +class JvmPlatformInit : PlatformInit { + override fun initialize() { + val dir = File(System.getProperty("user.home"), ".nomadic-weather") + if (!dir.exists()) dir.mkdirs() + println("Nomadic Weather Desktop Initialized") + } +} + +actual fun getPlatformStorage(): StorageBackend = JvmStorageBackendImpl() +actual fun getPlatformInit(): PlatformInit = JvmPlatformInit()