generated from me/template-mit
qwen3.6:35b's first attempt, does not compile (yet).
This commit is contained in:
1 parent
eeda4a18c5
commit
337403721b
17 files changed
+1485
-39
No files matched your search
@@ -3,6 +3,8 @@ logback = "1.6.2"
|
|||||||
androidx-activity-compose = "1.13.0"
|
androidx-activity-compose = "1.13.0"
|
||||||
androidx-lifecycle = "2.11.0"
|
androidx-lifecycle = "2.11.0"
|
||||||
kotlinx-coroutines = "1.11.0"
|
kotlinx-coroutines = "1.11.0"
|
||||||
|
kotlinx-serialization = "1.9.0"
|
||||||
|
ktor = "3.0.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
|
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-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" }
|
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" }
|
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" }
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
product:
|
product: kmp/lib
|
||||||
type: kmp/lib
|
|
||||||
platforms: [ jvm, android ]
|
platforms: [ jvm, android ]
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -12,8 +11,14 @@ dependencies:
|
|||||||
- $compose.hotReload.runtimeApi
|
- $compose.hotReload.runtimeApi
|
||||||
- $libs.androidx.lifecycle.viewmodel.compose
|
- $libs.androidx.lifecycle.viewmodel.compose
|
||||||
- $libs.androidx.lifecycle.runtime.compose
|
- $libs.androidx.lifecycle.runtime.compose
|
||||||
|
- $libs.ktor.client.core
|
||||||
|
- $libs.kotlinx.serialization.json
|
||||||
|
|
||||||
|
dependencies@jvm:
|
||||||
|
- $libs.ktor.client.cio
|
||||||
|
|
||||||
dependencies@android:
|
dependencies@android:
|
||||||
|
- $libs.ktor.client.okhttp
|
||||||
- $compose.uiTooling
|
- $compose.uiTooling
|
||||||
|
|
||||||
settings:
|
settings:
|
||||||
|
|||||||
+47
-37
@@ -1,51 +1,61 @@
|
|||||||
package com.hyperling.weather
|
package com.hyperling.weather
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
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
|
@Composable
|
||||||
@Preview
|
|
||||||
@DevelopmentEntryPoint
|
|
||||||
fun App() {
|
fun App() {
|
||||||
|
// Initialize platform-specific storage and environment (JVM/Desktop vs Android)
|
||||||
|
val platformInit = remember { getPlatformInit() }
|
||||||
|
LaunchedEffect(platformInit) {
|
||||||
|
platformInit.initialize()
|
||||||
|
}
|
||||||
|
|
||||||
MaterialTheme {
|
MaterialTheme {
|
||||||
var showContent by remember { mutableStateOf(false) }
|
MainScreen(modifier = Modifier.fillMaxSize())
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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() }
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Double, Double>? {
|
||||||
|
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<DailyWeather>,
|
||||||
|
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<Double>,
|
||||||
|
val longitude: List<Any>, // 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<String>,
|
||||||
|
val weathercode: List<Int>?,
|
||||||
|
val temperature_2m_max: List<Double>?,
|
||||||
|
val temperature_2m_min: List<Double>?,
|
||||||
|
val apparent_temperature_max: List<Double>?,
|
||||||
|
val apparent_temperature_min: List<Double>?,
|
||||||
|
val sunrise: List<String>?,
|
||||||
|
val sunset: List<String>?,
|
||||||
|
val precipitation_sum: List<Double>?,
|
||||||
|
val wind_speed_10m_max: List<Double>?,
|
||||||
|
val shortwave_radiation_sum: List<Double>?,
|
||||||
|
)
|
||||||
@@ -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<LocationEntry>): String {
|
||||||
|
return json.encodeToString(
|
||||||
|
kotlinx.serialization.listSerializer(StoredLocation.serializer()),
|
||||||
|
locations.map { locationToStored(it) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun jsonToLocations(jsonStr: String): List<LocationEntry> {
|
||||||
|
return try {
|
||||||
|
json.decodeFromString<List<StoredLocation>>(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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.hyperling.weather
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platform-agnostic location storage abstraction.
|
||||||
|
*/
|
||||||
|
object LocationStorage {
|
||||||
|
/**
|
||||||
|
* Load all saved locations.
|
||||||
|
*/
|
||||||
|
fun load(): List<LocationEntry> = getStorage().load()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save all locations (replaces existing).
|
||||||
|
*/
|
||||||
|
fun save(locations: List<LocationEntry>) = 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<LocationEntry>
|
||||||
|
fun save(locations: List<LocationEntry>)
|
||||||
|
fun saveOne(location: LocationEntry)
|
||||||
|
fun delete(id: Long)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect fun getPlatformStorage(): StorageBackend
|
||||||
@@ -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<List<LocationEntry>>(emptyList())
|
||||||
|
private set
|
||||||
|
|
||||||
|
// Weather data keyed by location id
|
||||||
|
val weatherData = mutableStateMapOf<Long, LocationWeather>()
|
||||||
|
|
||||||
|
// 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<String?, LocationEntry, Boolean> {
|
||||||
|
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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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<DailyWeather> {
|
||||||
|
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<LocationWeather> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<LocationEntry> {
|
||||||
|
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<LocationEntry>) {
|
||||||
|
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")
|
||||||
@@ -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()
|
||||||
@@ -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<LocationEntry> {
|
||||||
|
return try {
|
||||||
|
if (STORAGE_FILE.exists()) {
|
||||||
|
LocationSerializer.jsonToLocations(STORAGE_FILE.readText())
|
||||||
|
} else emptyList()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun save(locations: List<LocationEntry>) {
|
||||||
|
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()
|
||||||
@@ -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()
|
||||||
@@ -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<LocationEntry> {
|
||||||
|
return try {
|
||||||
|
if (STORAGE_FILE.exists()) LocationSerializer.jsonToLocations(STORAGE_FILE.readText()) else emptyList()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun save(locations: List<LocationEntry>) {
|
||||||
|
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()
|
||||||
Reference in new issue
Block a user