qwen3.6:35b's first attempt, does not compile (yet).

This commit is contained in:
me committed 2026-11-02 15:28:46 -07:00
1 parent eeda4a18c5
commit 337403721b
17 files changed
+1484 -38

No files matched your search

+7
View File
@@ -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" }
+8 -3
View File
@@ -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:
+45 -35
View File
@@ -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!")
MainScreen(modifier = Modifier.fillMaxSize())
}
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
)
}
}
}
+437
View File
@@ -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)
)
}
+140
View File
@@ -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() }
) {}
}
}
+125
View File
@@ -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>?,
)
+46
View File
@@ -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)
}
+37
View File
@@ -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
+195
View File
@@ -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
+11
View File
@@ -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
+166
View File
@@ -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
}
+97
View File
@@ -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()
+49
View File
@@ -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()
+6
View File
@@ -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()
+59
View File
@@ -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()