From d8c1cf30cc870a86334155ee3b76f554c944f7bd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 10:03:40 +0000 Subject: [PATCH] Inline screen-time day insight, calendar drag/zoom, Pastebin, Clear Sky Map Screen Time - Tapping a bar selects that day inline (iOS-style: other bars dim) and the stat cards switch to that day's numbers; "Show week" clears it. - The download button now opens an export dialog (JSON, CSV by day, CSV by app; this week or all history) that writes into Downloads. Calendar - Long-press-and-drag in the day/week timeline sweeps a time range and opens the editor with those times pre-filled; steps follow the zoom level (5/10/15/30/60 minutes). - Pinch to zoom the time ladder between 5-minute detail and a compressed hourly view. - The new-event sheet opens fully expanded instead of half-height. Pastebin (new module) - Create pastes with expiry, visibility, syntax format, password and burn-after-read; sign in to list, open and delete your own pastes. - Share-sheet defaults (expiry, visibility, burner, password) live in the module's Settings tab. Share to LifeOS - Sharing text or a link now shows a small floating chooser over the source app - LifeOS never opens - offering "Log it" (capture inbox) or a Pastebin link created with the saved share defaults and copied to the clipboard. - The old silent Memex share target is gone so only one entry appears in the share sheet. Downloader - Browsable catalogue of supported sites with search, covering video, social, audio, broadcast, file hosts and adult tube sites (ThisVid, LinkedIn, Threads and many more). - Extractor gains site helpers (Reddit post JSON, LinkedIn progressive streams, Instagram/Threads embed pages, Vimeo player config), kt_player (KVS) link decoding, player-JSON key scanning and one level of iframe following. Navigation and clock - Any module can be assigned to a bottom-bar slot, not just the original four. - Timer: tap an h/m/s wheel to type the value on a number-only keyboard; a complete number automatically advances to the field on the right. Clear Sky Map (new module) - Full clearoutside.com forecast for any spot: hourly good/OK/bad ratings, total/low/medium/high cloud, visibility, fog, precipitation type, probability and amount, wind speed and direction, temperature, feels-like, dew point, humidity, pressure, ozone, sun and moon ephemeris with civil, nautical and astronomical dark windows, plus estimated sky quality and Bortle class. - Places come from search (OpenStreetMap Nominatim), typed coordinates or the device's last known fix; saved spots persist. No API key anywhere. - HTML scrape covered by unit tests. Version 0.1.0-alpha.19. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011kSsXNrSk6NDjM2FjFZ7Y9 --- README.md | 76 +-- app/build.gradle.kts | 6 +- .../kotlin/com/lifeos/app/ui/LifeOsApp.kt | 4 + .../com/lifeos/app/ui/screen/HomeScreen.kt | 14 + .../app/ui/settings/SettingsViewModel.kt | 2 +- .../datastore/DataStoreSettingsRepository.kt | 32 ++ .../core/datastore/SettingsRepository.kt | 23 + .../core/ui/navigation/LifeDestination.kt | 6 + .../core/ui/navigation/TopLevelDestination.kt | 73 ++- .../lifeos/feature/calendar/CalendarScreen.kt | 167 ++++++- .../feature/calendar/CalendarViewModel.kt | 38 ++ feature/clearsky/build.gradle.kts | 21 + feature/clearsky/src/main/AndroidManifest.xml | 7 + .../lifeos/feature/clearsky/ClearSkyScreen.kt | 439 ++++++++++++++++++ .../feature/clearsky/ClearSkyViewModel.kt | 139 ++++++ .../feature/clearsky/data/ClearOutside.kt | 180 +++++++ .../clearsky/data/ClearSkyRepository.kt | 125 +++++ .../clearsky/data/ClearOutsideParserTest.kt | 91 ++++ .../com/lifeos/feature/clock/ClockScreen.kt | 175 ++++++- .../feature/downloader/DownloaderScreen.kt | 73 ++- .../feature/downloader/data/KvsPlayer.kt | 60 +++ .../feature/downloader/data/MediaExtractor.kt | 256 ++++++++-- .../feature/downloader/data/SiteCatalog.kt | 114 +++++ feature/memex/src/main/AndroidManifest.xml | 21 +- .../feature/memex/MemexShareActivity.kt | 38 -- feature/pastebin/build.gradle.kts | 21 + feature/pastebin/src/main/AndroidManifest.xml | 25 + .../lifeos/feature/pastebin/PastebinScreen.kt | 372 +++++++++++++++ .../feature/pastebin/PastebinViewModel.kt | 166 +++++++ .../feature/pastebin/data/PastebinApi.kt | 197 ++++++++ .../pastebin/data/PastebinRepository.kt | 107 +++++ .../pastebin/share/ShareRouterActivity.kt | 169 +++++++ .../pastebin/src/main/res/values/themes.xml | 15 + .../feature/screentime/ScreenTimeScreen.kt | 204 +++++--- .../feature/screentime/ScreenTimeViewModel.kt | 97 ++-- settings.gradle.kts | 2 + 36 files changed, 3303 insertions(+), 252 deletions(-) create mode 100644 feature/clearsky/build.gradle.kts create mode 100644 feature/clearsky/src/main/AndroidManifest.xml create mode 100644 feature/clearsky/src/main/kotlin/com/lifeos/feature/clearsky/ClearSkyScreen.kt create mode 100644 feature/clearsky/src/main/kotlin/com/lifeos/feature/clearsky/ClearSkyViewModel.kt create mode 100644 feature/clearsky/src/main/kotlin/com/lifeos/feature/clearsky/data/ClearOutside.kt create mode 100644 feature/clearsky/src/main/kotlin/com/lifeos/feature/clearsky/data/ClearSkyRepository.kt create mode 100644 feature/clearsky/src/test/kotlin/com/lifeos/feature/clearsky/data/ClearOutsideParserTest.kt create mode 100644 feature/downloader/src/main/kotlin/com/lifeos/feature/downloader/data/KvsPlayer.kt create mode 100644 feature/downloader/src/main/kotlin/com/lifeos/feature/downloader/data/SiteCatalog.kt delete mode 100644 feature/memex/src/main/kotlin/com/lifeos/feature/memex/MemexShareActivity.kt create mode 100644 feature/pastebin/build.gradle.kts create mode 100644 feature/pastebin/src/main/AndroidManifest.xml create mode 100644 feature/pastebin/src/main/kotlin/com/lifeos/feature/pastebin/PastebinScreen.kt create mode 100644 feature/pastebin/src/main/kotlin/com/lifeos/feature/pastebin/PastebinViewModel.kt create mode 100644 feature/pastebin/src/main/kotlin/com/lifeos/feature/pastebin/data/PastebinApi.kt create mode 100644 feature/pastebin/src/main/kotlin/com/lifeos/feature/pastebin/data/PastebinRepository.kt create mode 100644 feature/pastebin/src/main/kotlin/com/lifeos/feature/pastebin/share/ShareRouterActivity.kt create mode 100644 feature/pastebin/src/main/res/values/themes.xml diff --git a/README.md b/README.md index 7b61060..5813119 100644 --- a/README.md +++ b/README.md @@ -11,40 +11,48 @@ and rule traces back to a section (and often a community demand source) there. | Area | State | |---|---| -| Foundation: multi-module, M3 Expressive theme, Room (10 schema versions, auto-migrations), encrypted Vault (Tink+Keystore), foreground service, event bus + rules engine | ✅ | -| AI: Ollama streaming + on-device Gemma (MediaPipe), AiRouter w/ privacy tag + fallback, NotesRag | ✅ | -| Capture spine: quick capture, voice brain-dump w/ review sheet, structured logger | ✅ | -| Notes (Markdown files, vault option, backlinks, ask-my-notes) | ✅ | -| Time: exact reminders (lockscreen alarm, boot reschedule, NL times), to-do (lists+nesting), local calendar | ✅ | -| Message Center (notification listener), Email (IMAP to Proton Bridge) | ✅ | -| Rules live: R1 tracking→package+reminder · R2 invoice→task · R6 leave-by · R7 invite→event · R8 receipt→finance+warranty · R9 subscriptions · R10 brain-dump · R11 @scene tags · R12 quick-capture routing | ✅ | -| DHL tracking (hourly polling), Scan (CameraX+ML Kit receipts/boards), Finance (budget, subscriptions, warranties, CSV import) | ✅ | -| Books, Routes, Smart Home (HA REST), NAS browser + server-apps board, Planner "Jarvis" + Home top card | ✅ | -| Assistant role (long-press home → quick capture), Settings hub, theme palette picker, in-app Gemma model downloads | ✅ | -| Power-button capture auto-detects timers ("timer 6m"), reminders, calendar events, and time-stamped to-dos ("6pm feed cat") on-device | ✅ | -| Time-stamped to-dos surface in the Calendar; Clock has a Samsung-style wheel timer (mm:ss↔seconds toggle) + stopwatch laps; Routes embeds a native osmdroid OpenStreetMap | ✅ | -| Calendar v2: Month/Week/Day views, hour timeline, tap-to-edit, all-day + minute precision, Proton ICS one-way sync + .ics export | ✅ | -| Offline voice via open-source Vosk (one-time 40 MB model) — the Google recognizer is gone; nav hand-offs use plain geo: URIs | ✅ | -| Customizable bottom bar (toggle + reorder) + Home grid/list toggle with long-press drag-arrange; planner accept/skip persists (✓ completes the to-do); reminder alarms ring on the ALARM stream even in silent mode | ✅ | -| Clock (analog/digital/word faces, world clock, stopwatch, timer) | ✅ | -| ADHD tools: visual focus timer, streaks, overwhelm "What's next?" overlay (SYSTEM_ALERT_WINDOW) | ✅ | -| Memex archive: share-sheet clip + timeline, annotate-to-keep, 12-month auto-purge | ✅ | -| Agentic macros: natural-language → validated IR (MacroCompiler) → accessibility executor, dry-run gated | ✅ | -| Evolution layer: on-device interaction log + planner accept-rate; Planner accept/skip feeds it | ✅ | -| Calendar: one-way system Calendar Provider mirror + iCalendar (RFC 5545) codec (Proton ICS bridge, §8.6) | ✅ | -| Mail MCP client (JSON-RPC over HTTP/SSE) for the NAS Proton mail MCP; IMAP fallback stays primary | ✅ | -| Assistant overlay (Gemini-style): long-press home floats a glowing-border panel over any app — auto-listens (Vosk), text input, deep module commands (timers, packages, planner, to-dos) + AI fallback | ✅ | -| Foreground AlarmService rings reminders on the ALARM stream (silent-mode/app-killed proof) + "Test alarm" button; Clock gains a tap-to-add time-zone map and a zone converter | ✅ | -| Jarvis v3: chat is pure LLM (no pre-canned answers) — every module exposes live content via a prompt snapshot, and the model acts through `[[tool: args]]` lines the app executes + confirms (tasks, timers, reminders, events, notes, search); power phrases stay in the power menu/overlay | ✅ | -| New modules: Downloader (on-device stream extraction → Downloads, incl. HLS), Plants (offline care atlas + every-N-days watering reminders), News (RSS tile scroll: tagesschau, ZEIT, SZ, DLF, taz), hidden Vault (long-press "LifeOS" title, biometric/PIN, encrypted gallery w/ sort) | ✅ | -| Routes v2: tap-tap route planning with car/bike/foot timings + distance via OSRM, polyline on the osmdroid map | ✅ | -| Vault v2: dedicated screens per item — Proton-Pass-style logins (password generator, on-device TOTP 2FA, custom fields, attachments), Markdown secure texts, zoomable image gallery (downsampled, no OOM); hidden behind a 5-second hold on the Home title | ✅ | -| Notes editor: Word-style Markdown toolbar (bold/italic/heading/list/quote/code/link on the selection), rendered⇄raw toggle + pen; optional readable mirror to /Internal storage/LifeOS/Notes/*.md (all-files access) | ✅ | -| Focus timer: tap the ring for a custom HH:MM:SS time, plus an "Overlay" button that floats the countdown over any app (tap once for a close X that leaves it running); Overwhelm overlay now follows the theme | ✅ | -| Screen Time: mirrors Android digital-wellbeing into LifeOS and keeps it forever (survives Samsung's ~monthly purge) — weekly bars + average, week scrolling, tap a day for its own apps/unlocks, JSON export. Totals are derived from the raw RESUMED/PAUSED event stream, not `queryAndAggregateUsageStats` (which reports whole-bucket sums per day) | ✅ | -| Screen Time · Plants (custom photos + care atlas) · News · Downloader on Home; NAS server apps redesigned as an app-store list; Jarvis answers from a live data snapshot (note bodies included) and a Developer Options "Jarvis Debugging" toggle exposes snapshot/output/tool-calls with a copy button | ✅ | -| Brick (§Module Brick): tap-to-block modes — pick blocked apps + optional per-app daily allowances, turn a mode on/off by NFC tag, time window or by hand, strict mode refuses early exits; blocked apps hit a full-screen wall via an accessibility blocker (the only route Android gives a sideloaded app). Blocking rules covered by unit tests | ✅ | -| Deferred post-alpha: Glance home-screen widgets, HA WebSocket live state/zones, Vault unlock UI, first-run onboarding checklist (grants live in Settings → System access), FinTS bank sync | 🔜 | +| Foundation: multi-module, M3 Expressive theme, Room (10 schema versions, auto-migrations), encrypted Vault (Tink+Keystore), foreground service, event bus + rules engine | Done | +| AI: Ollama streaming + on-device Gemma (MediaPipe), AiRouter w/ privacy tag + fallback, NotesRag | Done | +| Capture spine: quick capture, voice brain-dump w/ review sheet, structured logger | Done | +| Notes (Markdown files, vault option, backlinks, ask-my-notes) | Done | +| Time: exact reminders (lockscreen alarm, boot reschedule, NL times), to-do (lists+nesting), local calendar | Done | +| Message Center (notification listener), Email (IMAP to Proton Bridge) | Done | +| Rules live: R1 tracking→package+reminder · R2 invoice→task · R6 leave-by · R7 invite→event · R8 receipt→finance+warranty · R9 subscriptions · R10 brain-dump · R11 @scene tags · R12 quick-capture routing | Done | +| DHL tracking (hourly polling), Scan (CameraX+ML Kit receipts/boards), Finance (budget, subscriptions, warranties, CSV import) | Done | +| Books, Routes, Smart Home (HA REST), NAS browser + server-apps board, Planner "Jarvis" + Home top card | Done | +| Assistant role (long-press home → quick capture), Settings hub, theme palette picker, in-app Gemma model downloads | Done | +| Power-button capture auto-detects timers ("timer 6m"), reminders, calendar events, and time-stamped to-dos ("6pm feed cat") on-device | Done | +| Time-stamped to-dos surface in the Calendar; Clock has a Samsung-style wheel timer (mm:ss↔seconds toggle) + stopwatch laps; Routes embeds a native osmdroid OpenStreetMap | Done | +| Calendar v2: Month/Week/Day views, hour timeline, tap-to-edit, all-day + minute precision, Proton ICS one-way sync + .ics export | Done | +| Offline voice via open-source Vosk (one-time 40 MB model) — the Google recognizer is gone; nav hand-offs use plain geo: URIs | Done | +| Customizable bottom bar (toggle + reorder) + Home grid/list toggle with long-press drag-arrange; planner accept/skip persists (✓ completes the to-do); reminder alarms ring on the ALARM stream even in silent mode | Done | +| Clock (analog/digital/word faces, world clock, stopwatch, timer) | Done | +| ADHD tools: visual focus timer, streaks, overwhelm "What's next?" overlay (SYSTEM_ALERT_WINDOW) | Done | +| Memex archive: share-sheet clip + timeline, annotate-to-keep, 12-month auto-purge | Done | +| Agentic macros: natural-language → validated IR (MacroCompiler) → accessibility executor, dry-run gated | Done | +| Evolution layer: on-device interaction log + planner accept-rate; Planner accept/skip feeds it | Done | +| Calendar: one-way system Calendar Provider mirror + iCalendar (RFC 5545) codec (Proton ICS bridge, §8.6) | Done | +| Mail MCP client (JSON-RPC over HTTP/SSE) for the NAS Proton mail MCP; IMAP fallback stays primary | Done | +| Assistant overlay (Gemini-style): long-press home floats a glowing-border panel over any app — auto-listens (Vosk), text input, deep module commands (timers, packages, planner, to-dos) + AI fallback | Done | +| Foreground AlarmService rings reminders on the ALARM stream (silent-mode/app-killed proof) + "Test alarm" button; Clock gains a tap-to-add time-zone map and a zone converter | Done | +| Jarvis v3: chat is pure LLM (no pre-canned answers) — every module exposes live content via a prompt snapshot, and the model acts through `[[tool: args]]` lines the app executes + confirms (tasks, timers, reminders, events, notes, search); power phrases stay in the power menu/overlay | Done | +| New modules: Downloader (on-device stream extraction → Downloads, incl. HLS), Plants (offline care atlas + every-N-days watering reminders), News (RSS tile scroll: tagesschau, ZEIT, SZ, DLF, taz), hidden Vault (long-press "LifeOS" title, biometric/PIN, encrypted gallery w/ sort) | Done | +| Routes v2: tap-tap route planning with car/bike/foot timings + distance via OSRM, polyline on the osmdroid map | Done | +| Vault v2: dedicated screens per item — Proton-Pass-style logins (password generator, on-device TOTP 2FA, custom fields, attachments), Markdown secure texts, zoomable image gallery (downsampled, no OOM); hidden behind a 5-second hold on the Home title | Done | +| Notes editor: Word-style Markdown toolbar (bold/italic/heading/list/quote/code/link on the selection), rendered⇄raw toggle + pen; optional readable mirror to /Internal storage/LifeOS/Notes/*.md (all-files access) | Done | +| Focus timer: tap the ring for a custom HH:MM:SS time, plus an "Overlay" button that floats the countdown over any app (tap once for a close X that leaves it running); Overwhelm overlay now follows the theme | Done | +| Screen Time: mirrors Android digital-wellbeing into LifeOS and keeps it forever (survives Samsung's ~monthly purge) — weekly bars + average, week scrolling, tap a day for its own apps/unlocks, JSON export. Totals are derived from the raw RESUMED/PAUSED event stream, not `queryAndAggregateUsageStats` (which reports whole-bucket sums per day) | Done | +| Screen Time · Plants (custom photos + care atlas) · News · Downloader on Home; NAS server apps redesigned as an app-store list; Jarvis answers from a live data snapshot (note bodies included) and a Developer Options "Jarvis Debugging" toggle exposes snapshot/output/tool-calls with a copy button | Done | +| Brick (§Module Brick): tap-to-block modes — pick blocked apps + optional per-app daily allowances, turn a mode on/off by NFC tag, time window or by hand, strict mode refuses early exits; blocked apps hit a full-screen wall via an accessibility blocker (the only route Android gives a sideloaded app). Blocking rules covered by unit tests | Done | +| Screen Time day insight: tapping a bar selects that day inline (others dim) and the stat cards switch to that day; the download button opens an export dialog (JSON, CSV by day, CSV by app; week or all history) writing to Downloads | Done | +| Calendar: long-press-and-drag in day/week view sweeps a time range (5/10/15/30/60-minute steps) and opens the editor pre-filled; pinch to zoom the time ladder between 5-minute and hourly steps; the event sheet opens fully expanded | Done | +| Pastebin module: create pastes with expiry, visibility, syntax format, password and burn-after-read; sign in to list, open and delete your pastes; share-sheet defaults live in its Settings tab | Done | +| Share to LifeOS: sharing text or a link shows a small floating chooser over the source app (LifeOS never opens) - log it to the capture inbox, or mint a Pastebin link with your share defaults and copy it | Done | +| Downloader: browsable catalogue of 70+ supported sites (ThisVid, LinkedIn, Threads, Reddit, Instagram, file hosts, broadcasters, audio) plus site helpers - Reddit post JSON, LinkedIn progressive streams, Instagram/Threads embeds, Vimeo player config, kt_player/KVS link decoding and one level of iframe following | Done | +| Navigation bar: any module can be a bottom-bar tab, not just Calendar/Jarvis/Inbox/Tasks | Done | +| Clock timer: tap an h/m/s wheel to type the value on a number keyboard; a full number hops to the field on the right automatically | Done | +| Clear Sky Map module: full clearoutside.com forecast for any spot (search, coordinates or device location) - hourly good/OK/bad ratings, total/low/medium/high cloud, visibility, fog, precipitation, wind, temperature, dew point, humidity, pressure, ozone, sun/moon ephemeris, dark windows and estimated sky quality/Bortle class. No API key needed; place lookup via OpenStreetMap Nominatim | Done | +| Deferred post-alpha: Glance home-screen widgets, HA WebSocket live state/zones, Vault unlock UI, first-run onboarding checklist (grants live in Settings → System access), FinTS bank sync | Planned | **Google-free by design:** no Google service is ever called at runtime (no Play Services, no Google recognizer, no Google Maps). Remaining Google-*authored* open-source, fully on-device libraries: AndroidX/Jetpack (unavoidable on Android), MediaPipe (Gemma inference), ML Kit on-device OCR/barcode (no network) — swap candidates documented in the plan. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 29fbd62..e2268b2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -10,8 +10,8 @@ android { defaultConfig { applicationId = "com.lifeos" - versionCode = 18 - versionName = "0.1.0-alpha.18" + versionCode = 19 + versionName = "0.1.0-alpha.19" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" @@ -87,6 +87,8 @@ dependencies { implementation(projects.feature.vault) implementation(projects.feature.screentime) implementation(projects.feature.brick) + implementation(projects.feature.pastebin) + implementation(projects.feature.clearsky) implementation(projects.core.model) implementation(projects.core.network) diff --git a/app/src/main/kotlin/com/lifeos/app/ui/LifeOsApp.kt b/app/src/main/kotlin/com/lifeos/app/ui/LifeOsApp.kt index e5e37cf..a32a92f 100644 --- a/app/src/main/kotlin/com/lifeos/app/ui/LifeOsApp.kt +++ b/app/src/main/kotlin/com/lifeos/app/ui/LifeOsApp.kt @@ -51,6 +51,8 @@ import com.lifeos.feature.news.NewsRoute import com.lifeos.feature.vault.VaultRoute import com.lifeos.feature.screentime.ScreenTimeRoute import com.lifeos.feature.brick.BrickRoute +import com.lifeos.feature.clearsky.ClearSkyRoute +import com.lifeos.feature.pastebin.PastebinRoute /** * Single-activity app shell (§1.3): adaptive scaffold, short M3E bottom bar, @@ -193,6 +195,8 @@ fun LifeOsApp(captureRequests: Int = 0, navBarIds: List = emptyList()) { composable { VaultRoute() } composable { ScreenTimeRoute() } composable { BrickRoute() } + composable { PastebinRoute() } + composable { ClearSkyRoute() } } } diff --git a/app/src/main/kotlin/com/lifeos/app/ui/screen/HomeScreen.kt b/app/src/main/kotlin/com/lifeos/app/ui/screen/HomeScreen.kt index 780c5ac..21adf9e 100644 --- a/app/src/main/kotlin/com/lifeos/app/ui/screen/HomeScreen.kt +++ b/app/src/main/kotlin/com/lifeos/app/ui/screen/HomeScreen.kt @@ -10,6 +10,8 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentPaste +import androidx.compose.material.icons.filled.NightsStay import androidx.compose.material.icons.automirrored.filled.Note import androidx.compose.material.icons.filled.Insights import androidx.compose.material.icons.automirrored.filled.MenuBook @@ -211,6 +213,18 @@ fun HomeScreen( icon = Icons.Filled.Timelapse, destination = LifeDestination.ScreenTime, ), + AppGridItem( + label = "Pastebin", + description = "Create, share and manage pastes", + icon = Icons.Filled.ContentPaste, + destination = LifeDestination.Pastebin, + ), + AppGridItem( + label = "Clear Sky Map", + description = "Stargazing forecast for any spot", + icon = Icons.Filled.NightsStay, + destination = LifeDestination.ClearSky, + ), ) // Hidden Vault reveal (§Module Vault): long-press the "LifeOS" title and a diff --git a/app/src/main/kotlin/com/lifeos/app/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/lifeos/app/ui/settings/SettingsViewModel.kt index 32e2c8e..81b1013 100644 --- a/app/src/main/kotlin/com/lifeos/app/ui/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/lifeos/app/ui/settings/SettingsViewModel.kt @@ -131,7 +131,7 @@ class SettingsViewModel @Inject constructor( val DEFAULT_HOME_ORDER = listOf( "Notes", "Logger", "Packages", "Finance", "Scan", "Planner", "Books", "Routes", "Smart home", "NAS", "Clock", "Focus", "Memex", "Macros", "Evolution", - "Downloader", "Plants", "News", "Brick", "Screen Time", + "Downloader", "Plants", "News", "Brick", "Screen Time", "Pastebin", "Clear Sky Map", ) } } diff --git a/core/datastore/src/main/kotlin/com/lifeos/core/datastore/DataStoreSettingsRepository.kt b/core/datastore/src/main/kotlin/com/lifeos/core/datastore/DataStoreSettingsRepository.kt index af33a8e..877be84 100644 --- a/core/datastore/src/main/kotlin/com/lifeos/core/datastore/DataStoreSettingsRepository.kt +++ b/core/datastore/src/main/kotlin/com/lifeos/core/datastore/DataStoreSettingsRepository.kt @@ -104,6 +104,34 @@ internal class DataStoreSettingsRepository @Inject constructor( dataStore.edit { prefs -> prefs[KEY_SCREEN_TIME_REBUILT] = done } } + override val pastebinShareDefaults: Flow = + dataStore.data.map { prefs -> prefs[KEY_PASTEBIN_SHARE_DEFAULTS] ?: "" } + + override suspend fun setPastebinShareDefaults(value: String) { + dataStore.edit { prefs -> prefs[KEY_PASTEBIN_SHARE_DEFAULTS] = value } + } + + override val pastebinUserKey: Flow = + dataStore.data.map { prefs -> prefs[KEY_PASTEBIN_USER_KEY] ?: "" } + + override suspend fun setPastebinUserKey(key: String) { + dataStore.edit { prefs -> prefs[KEY_PASTEBIN_USER_KEY] = key.trim() } + } + + override val clearSkyPlaces: Flow = + dataStore.data.map { prefs -> prefs[KEY_CLEAR_SKY_PLACES] ?: "" } + + override suspend fun setClearSkyPlaces(value: String) { + dataStore.edit { prefs -> prefs[KEY_CLEAR_SKY_PLACES] = value } + } + + override val clearSkyLastPlace: Flow = + dataStore.data.map { prefs -> prefs[KEY_CLEAR_SKY_LAST_PLACE] ?: "" } + + override suspend fun setClearSkyLastPlace(value: String) { + dataStore.edit { prefs -> prefs[KEY_CLEAR_SKY_LAST_PLACE] = value } + } + override val publicFolderMirror: Flow = dataStore.data.map { prefs -> prefs[KEY_PUBLIC_FOLDER_MIRROR] ?: false } @@ -124,5 +152,9 @@ internal class DataStoreSettingsRepository @Inject constructor( val KEY_JARVIS_DEBUG = booleanPreferencesKey("jarvis_debug") val KEY_PUBLIC_FOLDER_MIRROR = booleanPreferencesKey("public_folder_mirror") val KEY_SCREEN_TIME_REBUILT = booleanPreferencesKey("screen_time_rebuilt_v2") + val KEY_PASTEBIN_SHARE_DEFAULTS = stringPreferencesKey("pastebin_share_defaults") + val KEY_PASTEBIN_USER_KEY = stringPreferencesKey("pastebin_user_key") + val KEY_CLEAR_SKY_PLACES = stringPreferencesKey("clear_sky_places") + val KEY_CLEAR_SKY_LAST_PLACE = stringPreferencesKey("clear_sky_last_place") } } diff --git a/core/datastore/src/main/kotlin/com/lifeos/core/datastore/SettingsRepository.kt b/core/datastore/src/main/kotlin/com/lifeos/core/datastore/SettingsRepository.kt index e1b49cc..14f457f 100644 --- a/core/datastore/src/main/kotlin/com/lifeos/core/datastore/SettingsRepository.kt +++ b/core/datastore/src/main/kotlin/com/lifeos/core/datastore/SettingsRepository.kt @@ -66,4 +66,27 @@ interface SettingsRepository { val screenTimeRebuilt: Flow suspend fun setScreenTimeRebuilt(done: Boolean) + + /** + * Defaults applied to pastes created from the Android share sheet + * (§Module Pastebin): "EXPIRY|VISIBILITY|burn|password". + */ + val pastebinShareDefaults: Flow + + suspend fun setPastebinShareDefaults(value: String) + + /** Pastebin account user key, minted from username/password once. */ + val pastebinUserKey: Flow + + suspend fun setPastebinUserKey(key: String) + + /** Saved Clear Sky observing spots, one "name~lat~lon" per line. */ + val clearSkyPlaces: Flow + + suspend fun setClearSkyPlaces(value: String) + + /** The Clear Sky place shown on open ("name~lat~lon"); empty = ask. */ + val clearSkyLastPlace: Flow + + suspend fun setClearSkyLastPlace(value: String) } diff --git a/core/ui/src/main/kotlin/com/lifeos/core/ui/navigation/LifeDestination.kt b/core/ui/src/main/kotlin/com/lifeos/core/ui/navigation/LifeDestination.kt index 5be23e2..0e072fd 100644 --- a/core/ui/src/main/kotlin/com/lifeos/core/ui/navigation/LifeDestination.kt +++ b/core/ui/src/main/kotlin/com/lifeos/core/ui/navigation/LifeDestination.kt @@ -91,6 +91,12 @@ sealed interface LifeDestination { @Serializable data object Brick : LifeDestination + + @Serializable + data object Pastebin : LifeDestination + + @Serializable + data object ClearSky : LifeDestination } const val DEEP_LINK_SCHEME = "lifeos" diff --git a/core/ui/src/main/kotlin/com/lifeos/core/ui/navigation/TopLevelDestination.kt b/core/ui/src/main/kotlin/com/lifeos/core/ui/navigation/TopLevelDestination.kt index cfbbc70..eb3b4ad 100644 --- a/core/ui/src/main/kotlin/com/lifeos/core/ui/navigation/TopLevelDestination.kt +++ b/core/ui/src/main/kotlin/com/lifeos/core/ui/navigation/TopLevelDestination.kt @@ -11,9 +11,56 @@ import androidx.compose.material.icons.outlined.Home import androidx.compose.material.icons.outlined.Inbox import androidx.compose.material.icons.filled.AutoAwesome import androidx.compose.material.icons.outlined.AutoAwesome +import androidx.compose.material.icons.automirrored.filled.MenuBook +import androidx.compose.material.icons.automirrored.filled.Note +import androidx.compose.material.icons.automirrored.outlined.MenuBook +import androidx.compose.material.icons.automirrored.outlined.Note +import androidx.compose.material.icons.filled.AccountBalanceWallet +import androidx.compose.material.icons.filled.Archive +import androidx.compose.material.icons.filled.AutoAwesomeMosaic +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.ContentPaste +import androidx.compose.material.icons.filled.DocumentScanner +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Insights +import androidx.compose.material.icons.filled.Lightbulb +import androidx.compose.material.icons.filled.LocalFlorist +import androidx.compose.material.icons.filled.LocalShipping +import androidx.compose.material.icons.filled.Navigation +import androidx.compose.material.icons.filled.Newspaper +import androidx.compose.material.icons.filled.NightsStay +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material.icons.filled.Shield +import androidx.compose.material.icons.filled.SmartToy +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material.icons.filled.Timelapse +import androidx.compose.material.icons.filled.Timeline +import androidx.compose.material.icons.outlined.AccountBalanceWallet +import androidx.compose.material.icons.outlined.Archive +import androidx.compose.material.icons.outlined.AutoAwesomeMosaic +import androidx.compose.material.icons.outlined.Bolt +import androidx.compose.material.icons.outlined.ContentPaste +import androidx.compose.material.icons.outlined.DocumentScanner +import androidx.compose.material.icons.outlined.Download +import androidx.compose.material.icons.outlined.Insights +import androidx.compose.material.icons.outlined.Lightbulb +import androidx.compose.material.icons.outlined.LocalFlorist +import androidx.compose.material.icons.outlined.LocalShipping +import androidx.compose.material.icons.outlined.Navigation +import androidx.compose.material.icons.outlined.Newspaper +import androidx.compose.material.icons.outlined.NightsStay +import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material.icons.outlined.Shield +import androidx.compose.material.icons.outlined.SmartToy +import androidx.compose.material.icons.outlined.Storage +import androidx.compose.material.icons.outlined.Timelapse +import androidx.compose.material.icons.outlined.Timeline import androidx.compose.ui.graphics.vector.ImageVector -/** The five bottom-bar destinations (§1.3). */ +/** + * Everything that can sit on the bottom bar (§1.3). Home is pinned first; the + * rest are opt-in and reorderable from Settings, so any module can be a tab. + */ enum class TopLevelDestination( val label: String, val selectedIcon: ImageVector, @@ -50,4 +97,28 @@ enum class TopLevelDestination( unselectedIcon = Icons.Outlined.AutoAwesome, route = LifeDestination.Assistant, ), + + // Every other module, so the bar is fully user-composed. + NOTES("Notes", Icons.AutoMirrored.Filled.Note, Icons.AutoMirrored.Outlined.Note, LifeDestination.Notes), + LOGGER("Logger", Icons.Filled.Insights, Icons.Outlined.Insights, LifeDestination.Logger), + PACKAGES("Packages", Icons.Filled.LocalShipping, Icons.Outlined.LocalShipping, LifeDestination.Packages), + SCAN("Scan", Icons.Filled.DocumentScanner, Icons.Outlined.DocumentScanner, LifeDestination.Scan), + FINANCE("Finance", Icons.Filled.AccountBalanceWallet, Icons.Outlined.AccountBalanceWallet, LifeDestination.Finance), + NAS("NAS", Icons.Filled.Storage, Icons.Outlined.Storage, LifeDestination.Nas), + BOOKS("Books", Icons.AutoMirrored.Filled.MenuBook, Icons.AutoMirrored.Outlined.MenuBook, LifeDestination.Books), + ROUTES("Routes", Icons.Filled.Navigation, Icons.Outlined.Navigation, LifeDestination.Routes), + SMART_HOME("Smart home", Icons.Filled.Lightbulb, Icons.Outlined.Lightbulb, LifeDestination.SmartHome), + PLANNER("Planner", Icons.Filled.AutoAwesomeMosaic, Icons.Outlined.AutoAwesomeMosaic, LifeDestination.Planner), + CLOCK("Clock", Icons.Filled.Schedule, Icons.Outlined.Schedule, LifeDestination.Clock), + FOCUS("Focus", Icons.Filled.Bolt, Icons.Outlined.Bolt, LifeDestination.Focus), + MEMEX("Memex", Icons.Filled.Archive, Icons.Outlined.Archive, LifeDestination.Memex), + MACROS("Macros", Icons.Filled.SmartToy, Icons.Outlined.SmartToy, LifeDestination.Macros), + EVOLUTION("Evolution", Icons.Filled.Timeline, Icons.Outlined.Timeline, LifeDestination.Evolution), + DOWNLOADER("Downloader", Icons.Filled.Download, Icons.Outlined.Download, LifeDestination.Downloader), + PLANTS("Plants", Icons.Filled.LocalFlorist, Icons.Outlined.LocalFlorist, LifeDestination.Plants), + NEWS("News", Icons.Filled.Newspaper, Icons.Outlined.Newspaper, LifeDestination.News), + BRICK("Brick", Icons.Filled.Shield, Icons.Outlined.Shield, LifeDestination.Brick), + SCREEN_TIME("Screen Time", Icons.Filled.Timelapse, Icons.Outlined.Timelapse, LifeDestination.ScreenTime), + PASTEBIN("Pastebin", Icons.Filled.ContentPaste, Icons.Outlined.ContentPaste, LifeDestination.Pastebin), + SKY("Clear Sky", Icons.Filled.NightsStay, Icons.Outlined.NightsStay, LifeDestination.ClearSky), } diff --git a/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/CalendarScreen.kt b/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/CalendarScreen.kt index 9b50f2a..2650ea3 100644 --- a/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/CalendarScreen.kt +++ b/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/CalendarScreen.kt @@ -5,8 +5,10 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -352,8 +354,6 @@ private fun MonthView(uiState: CalendarUiState, onEvent: (CalendarUiEvent) -> Un // ----------------------------------------------------------------- timeline -- -private val HOUR_HEIGHT = 56.dp - @Composable private fun WeekView(uiState: CalendarUiState, onEvent: (CalendarUiEvent) -> Unit) { val days = (0 until 7).map { uiState.anchor + it * DAY_MS } @@ -380,7 +380,13 @@ private fun WeekView(uiState: CalendarUiState, onEvent: (CalendarUiEvent) -> Uni } } } - Timeline(days = days, events = uiState.events, onEvent = onEvent) + Timeline( + days = days, + events = uiState.events, + hourHeight = uiState.hourHeightDp.dp, + minutesPerStep = uiState.minutesPerStep, + onEvent = onEvent, + ) } } @@ -402,27 +408,55 @@ private fun DayView(uiState: CalendarUiState, onEvent: (CalendarUiEvent) -> Unit ) } } - Timeline(days = listOf(uiState.anchor), events = uiState.events, onEvent = onEvent) + Timeline( + days = listOf(uiState.anchor), + events = uiState.events, + hourHeight = uiState.hourHeightDp.dp, + minutesPerStep = uiState.minutesPerStep, + onEvent = onEvent, + ) } } -/** Shared hour grid: 24 rows, tappable slots, positioned event blocks, now-line. */ +/** + * Shared hour grid. Pinch anywhere to zoom: the hour height scales and the + * ladder snaps through 60/30/15/10/5-minute steps, down to a compressed + * overview where a whole day fits on screen. Long-press then drag inside a day + * column sweeps out a time range (Google-Calendar style) and opens the editor + * pre-filled with those exact times. + */ @Composable private fun Timeline( days: List, events: List, + hourHeight: androidx.compose.ui.unit.Dp, + minutesPerStep: Int, onEvent: (CalendarUiEvent) -> Unit, ) { - val scroll = rememberScrollState(initial = with(LocalDensity.current) { (HOUR_HEIGHT * 7).roundToPx() }) + val density = LocalDensity.current + val scroll = rememberScrollState(initial = with(density) { (hourHeight * 7).roundToPx() }) val now = System.currentTimeMillis() + val hourHeightPx = with(density) { hourHeight.toPx() } + + // Pinch zoom: vertical scale drives hour height, which in turn picks the + // ladder granularity — coarse when compressed, fine when stretched. + val zoomModifier = Modifier.pointerInput(Unit) { + detectTransformGestures { _, _, zoom, _ -> + if (zoom == 1f) return@detectTransformGestures + val next = (hourHeight.value * zoom).coerceIn(24f, 220f) + onEvent(CalendarUiEvent.SetZoom(stepForHeight(next), next)) + } + } + Row( modifier = Modifier .fillMaxSize() + .then(zoomModifier) .verticalScroll(scroll), ) { Column(modifier = Modifier.width(44.dp)) { repeat(24) { hour -> - Box(modifier = Modifier.height(HOUR_HEIGHT), contentAlignment = Alignment.TopCenter) { + Box(modifier = Modifier.height(hourHeight), contentAlignment = Alignment.TopCenter) { Text( "%02d".format(hour), style = MaterialTheme.typography.labelSmall, @@ -435,37 +469,95 @@ private fun Timeline( val dayEvents = events.filter { !it.allDay && it.startsAt < day + DAY_MS && it.endsAt > day } + // Live drag selection for this column, in minutes from midnight. + var dragFrom by remember(day) { mutableStateOf(null) } + var dragTo by remember(day) { mutableStateOf(null) } + Box( modifier = Modifier .weight(1f) - .height(HOUR_HEIGHT * 24) + .height(hourHeight * 24) .padding(horizontal = 1.dp) - .pointerInput(day) { + .pointerInput(day, minutesPerStep, hourHeightPx) { detectTapGestures { offset -> - val hour = (offset.y / (HOUR_HEIGHT.toPx())).toInt().coerceIn(0, 23) + val hour = (offset.y / hourHeightPx).toInt().coerceIn(0, 23) onEvent(CalendarUiEvent.NewEventAt(day, hour)) } + } + .pointerInput(day, minutesPerStep, hourHeightPx) { + detectDragGesturesAfterLongPress( + onDragStart = { offset -> + val minute = snapMinutes(offset.y / hourHeightPx * 60f, minutesPerStep) + dragFrom = minute + dragTo = minute + minutesPerStep + }, + onDrag = { change, _ -> + val start = dragFrom ?: return@detectDragGesturesAfterLongPress + val minute = snapMinutes(change.position.y / hourHeightPx * 60f, minutesPerStep) + // Always keep at least one step selected. + dragTo = if (minute <= start) start + minutesPerStep else minute + }, + onDragEnd = { + val start = dragFrom + val end = dragTo + if (start != null && end != null) { + onEvent( + CalendarUiEvent.NewEventForRange( + dayStart = day, + startMinuteOfDay = start.coerceIn(0, 24 * 60 - minutesPerStep), + durationMinutes = (end - start).coerceAtLeast(minutesPerStep), + ), + ) + } + dragFrom = null + dragTo = null + }, + onDragCancel = { dragFrom = null; dragTo = null }, + ) }, ) { - // Hour grid lines. - repeat(24) { hour -> + // Ladder lines: every step, with the hour lines drawn stronger. + val stepsPerDay = (24 * 60) / minutesPerStep + repeat(stepsPerDay + 1) { index -> + val minute = index * minutesPerStep + val onHour = minute % 60 == 0 HorizontalDivider( - modifier = Modifier.offset(y = HOUR_HEIGHT * hour), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + modifier = Modifier.offset(y = hourHeight * (minute / 60f)), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = if (onHour) 0.7f else 0.3f), ) } + // The sweep being dragged right now. + val from = dragFrom + val to = dragTo + if (from != null && to != null) { + Surface( + color = MaterialTheme.colorScheme.tertiaryContainer, + shape = RoundedCornerShape(6.dp), + modifier = Modifier + .fillMaxWidth() + .offset(y = hourHeight * (from / 60f)) + .height(hourHeight * ((to - from) / 60f)), + ) { + Text( + "${formatMinuteOfDay(from)} – ${formatMinuteOfDay(to)}", + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(4.dp), + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + } + } dayEvents.forEach { event -> val startMin = ((maxOf(event.startsAt, day) - day) / 60_000L).toInt() val endMin = ((minOf(event.endsAt, day + DAY_MS) - day) / 60_000L).toInt() - val height = ((endMin - startMin).coerceAtLeast(24) / 60f) + val height = ((endMin - startMin).coerceAtLeast(minutesPerStep) / 60f) Surface( onClick = { onEvent(CalendarUiEvent.EditEvent(event)) }, color = MaterialTheme.colorScheme.primaryContainer, shape = RoundedCornerShape(6.dp), modifier = Modifier .fillMaxWidth() - .offset(y = HOUR_HEIGHT * (startMin / 60f)) - .height(HOUR_HEIGHT * height), + .offset(y = hourHeight * (startMin / 60f)) + .height(hourHeight * height), ) { Column(modifier = Modifier.padding(4.dp)) { Text( @@ -475,19 +567,21 @@ private fun Timeline( overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.onPrimaryContainer, ) - Text( - TIME.format(Date(event.startsAt)), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f), - ) + // A squeezed block has no room for a second line. + if (hourHeight * height > 34.dp) { + Text( + TIME.format(Date(event.startsAt)), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f), + ) + } } } } - // Current-time indicator on today's column. if (CalendarViewModel.startOfDay(now) == day) { val nowMin = ((now - day) / 60_000L).toInt() HorizontalDivider( - modifier = Modifier.offset(y = HOUR_HEIGHT * (nowMin / 60f)), + modifier = Modifier.offset(y = hourHeight * (nowMin / 60f)), thickness = 2.dp, color = MaterialTheme.colorScheme.error, ) @@ -497,6 +591,22 @@ private fun Timeline( } } +/** Rounds a minute-of-day to the visible ladder step. */ +private fun snapMinutes(minutes: Float, step: Int): Int = + ((minutes / step).toInt() * step).coerceIn(0, 24 * 60) + +/** Finer ladder the more the user zooms in; coarse when compressed. */ +private fun stepForHeight(hourHeightDp: Float): Int = when { + hourHeightDp >= 180f -> 5 + hourHeightDp >= 130f -> 10 + hourHeightDp >= 95f -> 15 + hourHeightDp >= 60f -> 30 + else -> 60 +} + +private fun formatMinuteOfDay(minute: Int): String = + "%02d:%02d".format((minute / 60).coerceAtMost(23), minute % 60) + // ------------------------------------------------------------------- shared -- @Composable @@ -534,7 +644,14 @@ private fun EventRow(event: CalendarEventEntity, onEvent: (CalendarUiEvent) -> U @OptIn(ExperimentalMaterial3Api::class) @Composable private fun EventEditorSheet(uiState: CalendarUiState, onEvent: (CalendarUiEvent) -> Unit) { - ModalBottomSheet(onDismissRequest = { onEvent(CalendarUiEvent.ToggleEditor) }) { + // Fully expanded immediately: tapping + should land straight in the form. + val sheetState = androidx.compose.material3.rememberModalBottomSheetState( + skipPartiallyExpanded = true, + ) + ModalBottomSheet( + onDismissRequest = { onEvent(CalendarUiEvent.ToggleEditor) }, + sheetState = sheetState, + ) { Column( modifier = Modifier .padding(horizontal = 24.dp) diff --git a/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/CalendarViewModel.kt b/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/CalendarViewModel.kt index c9f22f5..6d5f59a 100644 --- a/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/CalendarViewModel.kt +++ b/feature/calendar/src/main/kotlin/com/lifeos/feature/calendar/CalendarViewModel.kt @@ -37,6 +37,10 @@ data class CalendarUiState( val editorDurationMinutes: String = "60", val editorAllDay: Boolean = false, val editorRemind: Boolean = true, + /** Ladder granularity in minutes (5, 10, 15, 30, 60) — pinch to change. */ + val minutesPerStep: Int = 60, + /** Height of one hour in dp; shrinks for the compressed overview. */ + val hourHeightDp: Float = 56f, val showConnections: Boolean = false, val protonUrlDraft: String = "", val syncing: Boolean = false, @@ -51,6 +55,19 @@ sealed interface CalendarUiEvent { data class SelectDay(val dayStart: Long) : CalendarUiEvent /** Opens the editor pre-filled for [dayStart] at [hour] (timeline tap / FAB). */ data class NewEventAt(val dayStart: Long, val hour: Int) : CalendarUiEvent + + /** + * Drag-to-create (Google-Calendar style): the dragged window becomes the + * event's start minute and duration, snapped by the visible ladder. + */ + data class NewEventForRange( + val dayStart: Long, + val startMinuteOfDay: Int, + val durationMinutes: Int, + ) : CalendarUiEvent + + /** Pinch zoom: minutes represented by one ladder step (5..60). */ + data class SetZoom(val minutesPerStep: Int, val hourHeightDp: Float) : CalendarUiEvent data object ToggleEditor : CalendarUiEvent data class EditEvent(val event: CalendarEventEntity) : CalendarUiEvent data class EditorTitleChanged(val value: String) : CalendarUiEvent @@ -120,6 +137,27 @@ class CalendarViewModel @Inject constructor( window.value = windowFor(uiState.value.viewMode, anchor) } is CalendarUiEvent.SelectDay -> updateState { it.copy(selectedDay = event.dayStart) } + is CalendarUiEvent.SetZoom -> updateState { + it.copy( + minutesPerStep = event.minutesPerStep.coerceIn(5, 60), + hourHeightDp = event.hourHeightDp.coerceIn(24f, 220f), + ) + } + is CalendarUiEvent.NewEventForRange -> updateState { + it.copy( + selectedDay = event.dayStart, + showEditor = true, + editingEventId = null, + editorTitle = "", + editorLocation = "", + editorNotes = "", + editorHour = (event.startMinuteOfDay / 60).toString(), + editorMinute = (event.startMinuteOfDay % 60).toString(), + editorDurationMinutes = event.durationMinutes.coerceAtLeast(5).toString(), + editorAllDay = false, + editorRemind = true, + ) + } is CalendarUiEvent.NewEventAt -> updateState { it.copy( selectedDay = event.dayStart, diff --git a/feature/clearsky/build.gradle.kts b/feature/clearsky/build.gradle.kts new file mode 100644 index 0000000..a0b65cf --- /dev/null +++ b/feature/clearsky/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + alias(libs.plugins.lifeos.android.library) + alias(libs.plugins.lifeos.android.compose) + alias(libs.plugins.lifeos.hilt) +} + +dependencies { + implementation(projects.core.common) + implementation(projects.core.designsystem) + implementation(projects.core.datastore) + implementation(projects.core.ui) + + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.core.ktx) + implementation(libs.okhttp) + + testImplementation(libs.junit) +} diff --git a/feature/clearsky/src/main/AndroidManifest.xml b/feature/clearsky/src/main/AndroidManifest.xml new file mode 100644 index 0000000..5f0f9c2 --- /dev/null +++ b/feature/clearsky/src/main/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/feature/clearsky/src/main/kotlin/com/lifeos/feature/clearsky/ClearSkyScreen.kt b/feature/clearsky/src/main/kotlin/com/lifeos/feature/clearsky/ClearSkyScreen.kt new file mode 100644 index 0000000..a748b61 --- /dev/null +++ b/feature/clearsky/src/main/kotlin/com/lifeos/feature/clearsky/ClearSkyScreen.kt @@ -0,0 +1,439 @@ +package com.lifeos.feature.clearsky + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.location.LocationManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.MyLocation +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.StarOutline +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.InputChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.hilt.navigation.compose.hiltViewModel +import com.lifeos.core.designsystem.component.EmptyState +import com.lifeos.feature.clearsky.data.SkyDay +import com.lifeos.feature.clearsky.data.SkyForecast +import com.lifeos.feature.clearsky.data.SkyPlace +import com.lifeos.feature.clearsky.data.SkyRating +import com.lifeos.feature.clearsky.data.SkyRow + +/** + * Clear Sky Map (§Module Clear Sky Map): the full clearoutside.com forecast for + * any spot - hourly go/no-go ratings, cloud layers at three altitudes, seeing + * conditions, sun and moon ephemeris and the estimated sky quality. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ClearSkyRoute(viewModel: ClearSkyViewModel = hiltViewModel()) { + val state by viewModel.uiState.collectAsState() + val context = LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } + + val locationPermission = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + if (granted) context.lastKnownLocation()?.let { viewModel.useDeviceLocation(it.first, it.second) } + } + + LaunchedEffect(state.message) { + state.message?.let { + snackbarHostState.showSnackbar(it) + viewModel.dismissMessage() + } + } + + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { Text("Clear Sky Map") }, + actions = { + IconButton( + onClick = { + val fix = context.lastKnownLocation() + if (fix != null) { + viewModel.useDeviceLocation(fix.first, fix.second) + } else { + locationPermission.launch(Manifest.permission.ACCESS_COARSE_LOCATION) + } + }, + ) { Icon(Icons.Filled.MyLocation, contentDescription = "Use my location") } + IconButton(onClick = viewModel::refresh) { + Icon(Icons.Filled.Refresh, contentDescription = "Refresh") + } + }, + ) + }, + ) { padding -> + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding), + contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + OutlinedTextField( + value = state.query, + onValueChange = viewModel::onQuery, + modifier = Modifier.fillMaxWidth(), + label = { Text("Place or \"lat, lon\"") }, + singleLine = true, + leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions( + onSearch = { if (!viewModel.useTypedCoordinates()) viewModel.search() }, + ), + trailingIcon = { + if (state.searching) { + CircularProgressIndicator(modifier = Modifier.size(20.dp)) + } else { + TextButton( + onClick = { if (!viewModel.useTypedCoordinates()) viewModel.search() }, + ) { Text("Find") } + } + }, + ) + } + + if (state.results.isNotEmpty()) { + items(state.results) { result -> + Card(modifier = Modifier.fillMaxWidth()) { + TextButton( + onClick = { viewModel.selectPlace(result) }, + modifier = Modifier.fillMaxWidth().padding(4.dp), + ) { Text(result.name, textAlign = TextAlign.Start) } + } + } + } + + if (state.places.isNotEmpty()) { + item { + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(state.places) { place -> + InputChip( + selected = state.place?.name == place.name, + onClick = { viewModel.selectPlace(place) }, + label = { Text(place.name.take(28)) }, + trailingIcon = { + IconButton(onClick = { viewModel.removePlace(place) }) { + Icon( + Icons.Filled.Close, + contentDescription = "Remove spot", + modifier = Modifier.size(16.dp), + ) + } + }, + ) + } + } + } + } + + item { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SkyView.entries.forEach { view -> + FilterChip( + selected = state.view == view, + onClick = { viewModel.selectView(view) }, + label = { Text(view.label) }, + ) + } + FilterChip( + selected = state.metric, + onClick = viewModel::toggleMetric, + label = { Text(if (state.metric) "Metric" else "Imperial") }, + ) + } + } + + val forecast = state.forecast + when { + state.loading && forecast == null -> item { + Box(modifier = Modifier.fillMaxWidth().padding(48.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + + forecast == null -> item { + EmptyState( + title = "Pick an observing spot", + description = "Search for a place, paste coordinates, or use your current location. " + + "Forecasts come straight from clearoutside.com, no account needed.", + ) + } + + else -> { + item { QualityCard(forecast, onSave = viewModel::savePlace) } + item { + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(forecast.days.size) { index -> + val day = forecast.days[index] + FilterChip( + selected = state.selectedDay == index, + onClick = { viewModel.selectDay(index) }, + label = { Text("${day.weekday.take(3)} ${day.dayOfMonth}") }, + ) + } + } + } + forecast.days.getOrNull(state.selectedDay)?.let { day -> + item { RatingStrip(day) } + item { EphemerisCard(day) } + item { DetailTable(day, metric = state.metric) } + } + item { + Text( + "Generated ${forecast.generated} - ${forecast.range} - timezone ${forecast.timezone}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} + +@Composable +private fun QualityCard(forecast: SkyForecast, onSave: () -> Unit) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(forecast.locationName, style = MaterialTheme.typography.titleMedium) + Text( + "${forecast.latitude}, ${forecast.longitude}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (forecast.skyQualityMagnitude.isNotBlank()) { + Text("Sky quality ${forecast.skyQualityMagnitude} mag - Bortle class ${forecast.bortleClass}") + } + if (forecast.brightness.isNotBlank()) { + Text( + "Brightness ${forecast.brightness} mcd/m2 - artificial ${forecast.artificialBrightness} ucd/m2", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + AssistChip( + onClick = onSave, + label = { Text("Save this spot") }, + leadingIcon = { Icon(Icons.Filled.StarOutline, contentDescription = null) }, + ) + } + } +} + +@Composable +private fun RatingStrip(day: SkyDay) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(vertical = 12.dp)) { + Text( + "Hourly conditions", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + day.hours.forEach { hour -> + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.width(CELL_WIDTH), + ) { + Text(hour.hour, style = MaterialTheme.typography.labelSmall) + Box( + modifier = Modifier + .padding(top = 2.dp) + .size(width = 26.dp, height = 26.dp) + .background(hour.rating.color(), RoundedCornerShape(6.dp)), + ) + } + } + } + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Legend("Good", SkyRating.GOOD) + Legend("OK", SkyRating.OK) + Legend("Bad", SkyRating.BAD) + } + } + } +} + +@Composable +private fun Legend(label: String, rating: SkyRating) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Box(modifier = Modifier.size(10.dp).background(rating.color(), RoundedCornerShape(3.dp))) + Text(label, style = MaterialTheme.typography.labelSmall) + } +} + +@Composable +private fun EphemerisCard(day: SkyDay) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("${day.weekday} ${day.dayOfMonth}", style = MaterialTheme.typography.titleSmall) + EphemerisLine("Sun", "rise ${day.sunRise}, set ${day.sunSet}, transit ${day.sunTransit}") + EphemerisLine("Moon", "${day.moonPhase} ${day.moonIllumination}, rise ${day.moonRise}, set ${day.moonSet}") + EphemerisLine("Civil dark", day.civilDark) + EphemerisLine("Nautical dark", day.nauticalDark) + EphemerisLine("Astro dark", day.astroDark) + } + } +} + +@Composable +private fun EphemerisLine(label: String, value: String) { + if (value.isBlank()) return + Row { + Text( + "$label: ", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text(value, style = MaterialTheme.typography.bodySmall) + } +} + +/** Every clearoutside detail row, scrolled sideways in step with the hours. */ +@Composable +private fun DetailTable(day: SkyDay, metric: Boolean) { + val scroll = rememberScrollState() + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(vertical = 12.dp)) { + Text( + "Details", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + day.rows.forEach { row -> + val converted = row.converted(metric) + Column(modifier = Modifier.padding(vertical = 4.dp)) { + Text( + converted.label, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Row( + modifier = Modifier.horizontalScroll(scroll).padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + converted.values.forEach { value -> + Text( + value.ifBlank { "-" }, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + textAlign = TextAlign.Center, + modifier = Modifier.width(CELL_WIDTH).padding(top = 2.dp), + ) + } + } + } + } + } + } +} + +/** clearoutside serves miles and mph; convert when the user wants metric. */ +private fun SkyRow.converted(metric: Boolean): SkyRow { + if (!metric) return this + return when { + label.contains("(miles)") -> SkyRow( + label = label.replace("(miles)", "(km)"), + values = values.map { it.scaled(1.609344) }, + details = details, + ) + + label.contains("(mph)") -> SkyRow( + label = label.replace("(mph)", "(km/h)"), + values = values.map { it.scaled(1.609344) }, + details = details, + ) + + else -> this + } +} + +private fun String.scaled(factor: Double): String { + val number = trim().toDoubleOrNull() ?: return this + val result = number * factor + return if (result >= 10) result.toInt().toString() else String.format(java.util.Locale.US, "%.1f", result) +} + +@Composable +private fun SkyRating.color(): Color = when (this) { + SkyRating.GOOD -> Color(0xFF2E7D32) + SkyRating.OK -> Color(0xFFF9A825) + SkyRating.BAD -> Color(0xFFC62828) + SkyRating.UNKNOWN -> MaterialTheme.colorScheme.surfaceVariant +} + +/** Last known fix from the OS providers - no Google Play services involved. */ +private fun Context.lastKnownLocation(): Pair? { + val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == + PackageManager.PERMISSION_GRANTED + if (!granted) return null + val manager = getSystemService(LocationManager::class.java) ?: return null + val providers = listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER, LocationManager.PASSIVE_PROVIDER) + return providers.asSequence() + .mapNotNull { provider -> + runCatching { manager.getLastKnownLocation(provider) }.getOrNull() + } + .maxByOrNull { it.time } + ?.let { it.latitude to it.longitude } +} + +private val CELL_WIDTH = 34.dp diff --git a/feature/clearsky/src/main/kotlin/com/lifeos/feature/clearsky/ClearSkyViewModel.kt b/feature/clearsky/src/main/kotlin/com/lifeos/feature/clearsky/ClearSkyViewModel.kt new file mode 100644 index 0000000..c166fa4 --- /dev/null +++ b/feature/clearsky/src/main/kotlin/com/lifeos/feature/clearsky/ClearSkyViewModel.kt @@ -0,0 +1,139 @@ +package com.lifeos.feature.clearsky + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lifeos.feature.clearsky.data.ClearSkyRepository +import com.lifeos.feature.clearsky.data.SkyForecast +import com.lifeos.feature.clearsky.data.SkyPlace +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** Which hour the strip is centred on, mirroring clearoutside's own views. */ +enum class SkyView(val query: String?, val label: String) { + NOW(null, "Now"), + MIDNIGHT("midnight", "Midnight"), + MIDDAY("midday", "Midday"), +} + +data class ClearSkyUiState( + val loading: Boolean = false, + val place: SkyPlace? = null, + val forecast: SkyForecast? = null, + val view: SkyView = SkyView.NOW, + val selectedDay: Int = 0, + val places: List = emptyList(), + val query: String = "", + val results: List = emptyList(), + val searching: Boolean = false, + val metric: Boolean = true, + val message: String? = null, +) + +@HiltViewModel +class ClearSkyViewModel @Inject constructor( + private val repository: ClearSkyRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(ClearSkyUiState()) + val uiState = _uiState.asStateFlow() + + init { + viewModelScope.launch { + val saved = repository.places() + val last = repository.lastPlace() ?: saved.firstOrNull() + _uiState.value = _uiState.value.copy(places = saved, place = last) + if (last != null) load(last) + } + } + + fun refresh() { + _uiState.value.place?.let { load(it) } + } + + fun selectView(view: SkyView) { + _uiState.value = _uiState.value.copy(view = view) + refresh() + } + + fun selectDay(index: Int) { + _uiState.value = _uiState.value.copy(selectedDay = index) + } + + fun toggleMetric() { + _uiState.value = _uiState.value.copy(metric = !_uiState.value.metric) + } + + fun onQuery(value: String) { + _uiState.value = _uiState.value.copy(query = value) + } + + fun search() { + val query = _uiState.value.query + viewModelScope.launch { + _uiState.value = _uiState.value.copy(searching = true) + val result = repository.search(query) + _uiState.value = _uiState.value.copy( + searching = false, + results = result.getOrDefault(emptyList()), + message = result.exceptionOrNull()?.let { "Place lookup failed: ${it.message}" } + ?: if (result.getOrDefault(emptyList()).isEmpty()) "No places matched" else null, + ) + } + } + + /** Accepts "52.52, 13.40" typed straight into the search field. */ + fun useTypedCoordinates(): Boolean { + val match = Regex("""^\s*(-?\d+(?:\.\d+)?)\s*[,; ]\s*(-?\d+(?:\.\d+)?)\s*$""") + .find(_uiState.value.query) ?: return false + val lat = match.groupValues[1].toDoubleOrNull() ?: return false + val lon = match.groupValues[2].toDoubleOrNull() ?: return false + selectPlace(SkyPlace("$lat, $lon", lat, lon)) + return true + } + + fun selectPlace(place: SkyPlace) { + _uiState.value = _uiState.value.copy(place = place, results = emptyList(), query = "") + viewModelScope.launch { repository.setLastPlace(place) } + load(place) + } + + fun savePlace() { + val place = _uiState.value.place ?: return + val named = _uiState.value.forecast?.locationName?.takeIf { it.isNotBlank() } ?: place.name + viewModelScope.launch { + repository.savePlace(place.copy(name = named)) + _uiState.value = _uiState.value.copy(places = repository.places(), message = "Spot saved") + } + } + + fun removePlace(place: SkyPlace) { + viewModelScope.launch { + repository.removePlace(place) + _uiState.value = _uiState.value.copy(places = repository.places()) + } + } + + fun useDeviceLocation(latitude: Double, longitude: Double) { + selectPlace(SkyPlace("My location", latitude, longitude)) + } + + fun dismissMessage() { + _uiState.value = _uiState.value.copy(message = null) + } + + private fun load(place: SkyPlace) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(loading = true) + val result = repository.forecast(place, _uiState.value.view.query) + _uiState.value = _uiState.value.copy( + loading = false, + forecast = result.getOrNull() ?: _uiState.value.forecast, + selectedDay = 0, + message = result.exceptionOrNull()?.let { "Could not load the forecast: ${it.message}" }, + ) + } + } +} diff --git a/feature/clearsky/src/main/kotlin/com/lifeos/feature/clearsky/data/ClearOutside.kt b/feature/clearsky/src/main/kotlin/com/lifeos/feature/clearsky/data/ClearOutside.kt new file mode 100644 index 0000000..3dc37df --- /dev/null +++ b/feature/clearsky/src/main/kotlin/com/lifeos/feature/clearsky/data/ClearOutside.kt @@ -0,0 +1,180 @@ +package com.lifeos.feature.clearsky.data + +/** + * Parsed clearoutside.com forecast (§Module Clear Sky Map). + * + * clearoutside.com has no public JSON API - the referenced ClearOutsideAPY + * project scrapes the HTML forecast page, and so does this. No key is needed. + */ +data class SkyForecast( + val locationName: String, + val latitude: Double, + val longitude: Double, + val skyQualityMagnitude: String, + val bortleClass: String, + val brightness: String, + val artificialBrightness: String, + val generated: String, + val range: String, + val timezone: String, + val days: List, +) + +/** One forecast day column: ratings, ephemeris and every detail row. */ +data class SkyDay( + val weekday: String, + val dayOfMonth: String, + val moonPhase: String, + val moonIllumination: String, + val moonRise: String, + val moonSet: String, + val sunRise: String, + val sunSet: String, + val sunTransit: String, + val civilDark: String, + val nauticalDark: String, + val astroDark: String, + val hours: List, + val rows: List, +) + +/** Hourly "is it clear out?" verdict as shown in the top strip. */ +data class SkyHour(val hour: String, val rating: SkyRating) + +enum class SkyRating { GOOD, OK, BAD, UNKNOWN } + +/** One detail row (clouds, wind, temperature, ...) aligned to [SkyDay.hours]. */ +data class SkyRow( + val label: String, + val values: List, + val details: List = emptyList(), +) + +internal object ClearOutsideParser { + + fun parse(html: String, fallbackLat: Double, fallbackLon: Double): SkyForecast { + val header = Regex("""

Forecast for (.*?)\s*\(([-0-9.]+),\s*([-0-9.]+)\)

""") + .find(html) + val quality = Regex( + """Est\. Sky Quality:.*?([^<]*)\s*Magnitude.*?Class ([^<]*)""" + + """\s*Bortle.*?([^<]*)\s*mcd.*?([^<]*)\s*μcd""", + RegexOption.DOT_MATCHES_ALL, + ).find(html) ?: Regex( + """Est\. Sky Quality:.*?([^<]*)\s*Magnitude.*?Class ([^<]*)\s*Bortle""", + RegexOption.DOT_MATCHES_ALL, + ).find(html) + val generated = Regex("""

Generated:\s*(.*?)\.\s*Forecast:\s*(.*?)\.\s*Timezone:\s*(.*?)

""") + .find(html) + + return SkyForecast( + locationName = header?.groupValues?.get(1)?.let(::unescape).orEmpty() + .ifBlank { "$fallbackLat, $fallbackLon" }, + latitude = header?.groupValues?.get(2)?.toDoubleOrNull() ?: fallbackLat, + longitude = header?.groupValues?.get(3)?.toDoubleOrNull() ?: fallbackLon, + skyQualityMagnitude = quality?.groupValues?.getOrNull(1)?.trim().orEmpty(), + bortleClass = quality?.groupValues?.getOrNull(2)?.trim().orEmpty(), + brightness = quality?.groupValues?.getOrNull(3)?.trim().orEmpty(), + artificialBrightness = quality?.groupValues?.getOrNull(4)?.trim().orEmpty(), + generated = generated?.groupValues?.getOrNull(1)?.trim().orEmpty(), + range = generated?.groupValues?.getOrNull(2)?.trim().orEmpty(), + timezone = generated?.groupValues?.getOrNull(3)?.trim().orEmpty(), + days = dayBlocks(html).map(::parseDay), + ) + } + + /** Splits the forecast container into one chunk per `
`. */ + private fun dayBlocks(html: String): List { + val starts = Regex("""
""").findAll(html).map { it.range.first }.toList() + if (starts.isEmpty()) return emptyList() + return starts.mapIndexed { index, start -> + val end = starts.getOrNull(index + 1) ?: html.length + html.substring(start, end) + } + } + + private fun parseDay(block: String): SkyDay { + val date = Regex("""class="fc_day_date"[^>]*>([^<]*)\s*([0-9]+)""").find(block) + val moonRiseSet = Regex("""class="fc_moon_riseset">(.*?)""", RegexOption.DOT_MATCHES_ALL) + .find(block)?.groupValues?.get(1).orEmpty() + val moonTimes = Regex("""\d{2}:\d{2}""").findAll(moonRiseSet).map { it.value }.toList() + val daylight = Regex("""class="fc_daylight"[^>]*data-content="(.*?)"""", RegexOption.DOT_MATCHES_ALL) + .find(block)?.groupValues?.get(1)?.let(::unescape).orEmpty() + + return SkyDay( + weekday = date?.groupValues?.get(1).orEmpty(), + dayOfMonth = date?.groupValues?.get(2).orEmpty(), + moonPhase = Regex("""class="fc_moon_phase">([^<]*)<""").find(block)?.groupValues?.get(1)?.trim().orEmpty(), + moonIllumination = Regex("""class="fc_moon_percentage">([^<]*)<""") + .find(block)?.groupValues?.get(1)?.trim().orEmpty(), + moonRise = moonTimes.getOrNull(0).orEmpty(), + moonSet = moonTimes.getOrNull(1).orEmpty(), + sunRise = field(daylight, "Sunrise"), + sunSet = field(daylight, "Sunset"), + sunTransit = field(daylight, "Sun Transit"), + civilDark = field(daylight, "Civil Dark"), + nauticalDark = field(daylight, "Nautical Dark"), + astroDark = field(daylight, "Astro Dark"), + hours = parseHours(block), + rows = parseRows(block), + ) + } + + private fun parseHours(block: String): List { + val strip = Regex("""fc_hour_ratings.*?""", RegexOption.DOT_MATCHES_ALL).find(block)?.value.orEmpty() + return Regex("""
  • .*?\s*([0-9]{1,2})\s*""") + .findAll(strip) + .map { match -> + SkyHour( + hour = match.groupValues[2].padStart(2, '0'), + rating = when (match.groupValues[1]) { + "good" -> SkyRating.GOOD + "ok" -> SkyRating.OK + "bad" -> SkyRating.BAD + else -> SkyRating.UNKNOWN + }, + ) + } + .toList() + } + + private fun parseRows(block: String): List { + val detail = Regex("""
    (.*)""", RegexOption.DOT_MATCHES_ALL) + .find(block)?.groupValues?.get(1).orEmpty() + val rowRegex = Regex( + """(.*?)(.*?)""", + RegexOption.DOT_MATCHES_ALL, + ) + return rowRegex.findAll(detail).map { match -> + val label = unescape(match.groupValues[1]).trim() + val body = match.groupValues[2] + val items = Regex("""]*)>(.*?)
  • """, RegexOption.DOT_MATCHES_ALL).findAll(body).toList() + SkyRow( + label = label, + values = items.map { unescape(stripTags(it.groupValues[2])).trim() }, + details = items.map { + Regex("""title="([^"]*)"""").find(it.groupValues[1])?.groupValues?.get(1) + ?.let(::unescape).orEmpty() + }, + ) + }.filter { it.values.isNotEmpty() }.toList() + } + + /** Pulls `Label: value` out of the popover text. */ + private fun field(text: String, label: String): String = + Regex("""$label:\s*([^<]*)""").find(text)?.groupValues?.get(1)?.trim()?.trim('.') + ?.replace(Regex("""\s{2,}"""), " ") + .orEmpty() + + private fun stripTags(value: String): String = value.replace(Regex("""<[^>]*>"""), " ") + + private fun unescape(value: String): String = value + .replace("°", "°") + .replace("μ", "µ") + .replace(" ", " ") + .replace("&", "&") + .replace(""", "\"") + .replace("'", "'") + .replace("<", "<") + .replace(">", ">") + .replace(Regex("""\s{2,}"""), " ") +} diff --git a/feature/clearsky/src/main/kotlin/com/lifeos/feature/clearsky/data/ClearSkyRepository.kt b/feature/clearsky/src/main/kotlin/com/lifeos/feature/clearsky/data/ClearSkyRepository.kt new file mode 100644 index 0000000..77990bf --- /dev/null +++ b/feature/clearsky/src/main/kotlin/com/lifeos/feature/clearsky/data/ClearSkyRepository.kt @@ -0,0 +1,125 @@ +package com.lifeos.feature.clearsky.data + +import com.lifeos.core.datastore.SettingsRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONArray +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +/** A place the user watches the sky from. */ +data class SkyPlace( + val name: String, + val latitude: Double, + val longitude: Double, +) { + fun encode(): String = "$name~$latitude~$longitude" + + companion object { + fun decode(raw: String): SkyPlace? { + val parts = raw.split('~') + val lat = parts.getOrNull(1)?.toDoubleOrNull() ?: return null + val lon = parts.getOrNull(2)?.toDoubleOrNull() ?: return null + return SkyPlace(parts[0], lat, lon) + } + } +} + +/** + * Fetches and caches clearoutside.com forecasts plus the saved places + * (§Module Clear Sky Map). Geocoding uses OpenStreetMap's Nominatim, so no + * account or API key is involved anywhere in this module. + */ +@Singleton +class ClearSkyRepository @Inject constructor( + private val settingsRepository: SettingsRepository, +) { + + private val client = OkHttpClient.Builder() + .connectTimeout(20, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + suspend fun places(): List = + settingsRepository.clearSkyPlaces.first() + .split('\n') + .filter { it.isNotBlank() } + .mapNotNull(SkyPlace::decode) + + suspend fun savePlace(place: SkyPlace) { + val next = (places().filterNot { it.sameSpot(place) } + place).takeLast(20) + settingsRepository.setClearSkyPlaces(next.joinToString("\n") { it.encode() }) + } + + suspend fun removePlace(place: SkyPlace) { + val next = places().filterNot { it.sameSpot(place) } + settingsRepository.setClearSkyPlaces(next.joinToString("\n") { it.encode() }) + } + + suspend fun lastPlace(): SkyPlace? = SkyPlace.decode(settingsRepository.clearSkyLastPlace.first()) + + suspend fun setLastPlace(place: SkyPlace) = + settingsRepository.setClearSkyLastPlace(place.encode()) + + /** + * @param view "midnight" centres the hour strip on midnight (the clearoutside + * default is the current hour); "midday" centres on noon. + */ + suspend fun forecast(place: SkyPlace, view: String?): Result = withContext(Dispatchers.IO) { + runCatching { + val lat = trim(place.latitude) + val lon = trim(place.longitude) + val url = buildString { + append("https://clearoutside.com/forecast/") + append(lat).append('/').append(lon) + if (!view.isNullOrBlank()) append("?view=").append(view) + } + val html = get(url) + val parsed = ClearOutsideParser.parse(html, place.latitude, place.longitude) + if (parsed.days.isEmpty()) error("clearoutside.com returned no forecast for this location") + parsed + } + } + + /** Free-text place lookup (Nominatim, keyless). */ + suspend fun search(query: String): Result> = withContext(Dispatchers.IO) { + runCatching { + if (query.isBlank()) return@runCatching emptyList() + val encoded = java.net.URLEncoder.encode(query.trim(), "UTF-8") + val body = get( + "https://nominatim.openstreetmap.org/search?format=json&limit=8&q=$encoded", + ) + val array = JSONArray(body) + (0 until array.length()).mapNotNull { index -> + val item = array.optJSONObject(index) ?: return@mapNotNull null + val lat = item.optString("lat").toDoubleOrNull() ?: return@mapNotNull null + val lon = item.optString("lon").toDoubleOrNull() ?: return@mapNotNull null + SkyPlace(item.optString("display_name").take(80), lat, lon) + } + } + } + + private fun get(url: String): String { + val request = Request.Builder() + .url(url) + // Both hosts reject requests without a real UA. + .header("User-Agent", "LifeOS/1.0 (personal astronomy client)") + .header("Accept-Language", "en") + .build() + client.newCall(request).execute().use { response -> + val body = response.body?.string().orEmpty() + if (!response.isSuccessful) error("HTTP ${response.code} from ${request.url.host}") + return body + } + } + + /** clearoutside.com expects at most two decimals in the path. */ + private fun trim(value: Double): String = String.format(java.util.Locale.US, "%.2f", value) +} + +private fun SkyPlace.sameSpot(other: SkyPlace): Boolean = + kotlin.math.abs(latitude - other.latitude) < 0.005 && kotlin.math.abs(longitude - other.longitude) < 0.005 diff --git a/feature/clearsky/src/test/kotlin/com/lifeos/feature/clearsky/data/ClearOutsideParserTest.kt b/feature/clearsky/src/test/kotlin/com/lifeos/feature/clearsky/data/ClearOutsideParserTest.kt new file mode 100644 index 0000000..ca318d8 --- /dev/null +++ b/feature/clearsky/src/test/kotlin/com/lifeos/feature/clearsky/data/ClearOutsideParserTest.kt @@ -0,0 +1,91 @@ +package com.lifeos.feature.clearsky.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Guards the HTML scrape against small markup drifts on clearoutside.com. */ +class ClearOutsideParserTest { + + private val html = """ +

    Forecast for Mitte, Germany (52.52,13.40)

    + Est. Sky Quality: 18.19 Magnitude. + Class 8 Bortle. 5.75 mcd/m2 Brightness. + 5573.88 μcd/m2 Artificial Brightness. +

    Generated: 28/07/26 11:37:05. Forecast: 28/07/26 to 03/08/26. Timezone: UTC+2.00

    +
    +
    +
    Tuesday 28
    +
    + Waxing Gibbous + 100% + 20:57 04:40 +
    +
    +
    • 11 Good
    • +
    • 12 OK
    • +
    • 13 Bad
    +
    Sun
    +
    + +
    +
    + """.trimIndent() + + @Test + fun `parses header and sky quality`() { + val forecast = ClearOutsideParser.parse(html, 0.0, 0.0) + assertEquals("Mitte, Germany", forecast.locationName) + assertEquals(52.52, forecast.latitude, 0.001) + assertEquals(13.40, forecast.longitude, 0.001) + assertEquals("18.19", forecast.skyQualityMagnitude) + assertEquals("8", forecast.bortleClass) + assertEquals("UTC+2.00", forecast.timezone) + } + + @Test + fun `parses day ephemeris and ratings`() { + val day = ClearOutsideParser.parse(html, 0.0, 0.0).days.single() + assertEquals("Tuesday", day.weekday) + assertEquals("28", day.dayOfMonth) + assertEquals("Waxing Gibbous", day.moonPhase) + assertEquals("100%", day.moonIllumination) + assertEquals("20:57", day.moonRise) + assertEquals("04:40", day.moonSet) + assertEquals("05:22", day.sunRise) + assertEquals("21:05", day.sunSet) + assertEquals("13:12", day.sunTransit) + assertEquals("00:28 - 02:05", day.astroDark) + assertEquals(listOf("11", "12", "13"), day.hours.map { it.hour }) + assertEquals( + listOf(SkyRating.GOOD, SkyRating.OK, SkyRating.BAD), + day.hours.map { it.rating }, + ) + } + + @Test + fun `parses every detail row with hourly values`() { + val day = ClearOutsideParser.parse(html, 0.0, 0.0).days.single() + assertEquals(3, day.rows.size) + assertEquals("Total Clouds (% Sky Obscured)", day.rows[0].label) + assertEquals(listOf("12", "20", "43"), day.rows[0].values) + assertEquals(listOf("9", "10", "11"), day.rows[1].values) + assertTrue(day.rows[1].details[0].contains("from the West")) + assertEquals("Temperature (°C)", day.rows[2].label) + } +} diff --git a/feature/clock/src/main/kotlin/com/lifeos/feature/clock/ClockScreen.kt b/feature/clock/src/main/kotlin/com/lifeos/feature/clock/ClockScreen.kt index d1df3ac..9b8f3a6 100644 --- a/feature/clock/src/main/kotlin/com/lifeos/feature/clock/ClockScreen.kt +++ b/feature/clock/src/main/kotlin/com/lifeos/feature/clock/ClockScreen.kt @@ -3,9 +3,9 @@ package com.lifeos.feature.clock import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.Canvas import androidx.compose.foundation.background -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -19,7 +19,9 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.SwapHoriz @@ -29,6 +31,7 @@ import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField @@ -52,18 +55,23 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.lifeos.core.designsystem.component.EmptyState -import kotlinx.coroutines.delay import java.time.LocalTime import java.time.ZoneId import java.time.ZonedDateTime @@ -71,6 +79,7 @@ import java.time.format.DateTimeFormatter import kotlin.math.cos import kotlin.math.min import kotlin.math.sin +import kotlinx.coroutines.delay /** Clock module (§Module 4): faces, world clock, stopwatch, timer. */ @OptIn(ExperimentalMaterial3Api::class) @@ -470,6 +479,8 @@ private fun TimerTab() { var remainingSeconds by remember { mutableLongStateOf(0L) } var running by remember { mutableStateOf(false) } var showAsSeconds by remember { mutableStateOf(false) } + // null = wheels; 0/1/2 = typing into hours/minutes/seconds. + var typedField by remember { mutableStateOf(null) } LaunchedEffect(running) { while (running && remainingSeconds > 0) { @@ -500,19 +511,32 @@ private fun TimerTab() { Icon(Icons.Filled.SwapHoriz, contentDescription = null) Text(if (showAsSeconds) " Show mm:ss" else " Show seconds") } + } else if (typedField != null) { + // Typed entry: number keyboard, auto-advancing to the next field. + TypedDuration( + hours = hours, + minutes = minutes, + seconds = seconds, + startField = typedField ?: 0, + onHours = { hours = it }, + onMinutes = { minutes = it }, + onSeconds = { seconds = it }, + onDone = { typedField = null }, + ) } else { - // Samsung-style three infinite wheels. + // Samsung-style three infinite wheels; tap one to type instead. Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - WheelPicker(range = 0..99, value = hours, onValue = { hours = it }) + WheelPicker(range = 0..99, value = hours, onValue = { hours = it }, onTap = { typedField = 0 }) WheelLabel("h") - WheelPicker(range = 0..59, value = minutes, onValue = { minutes = it }) + WheelPicker(range = 0..59, value = minutes, onValue = { minutes = it }, onTap = { typedField = 1 }) WheelLabel("m") - WheelPicker(range = 0..59, value = seconds, onValue = { seconds = it }) + WheelPicker(range = 0..59, value = seconds, onValue = { seconds = it }, onTap = { typedField = 2 }) WheelLabel("s") } + TextButton(onClick = { typedField = 0 }) { Text("Type a duration") } } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { listOf(1L, 5L, 10L, 25L).forEach { m -> @@ -572,6 +596,7 @@ private fun WheelPicker( value: Int, onValue: (Int) -> Unit, modifier: Modifier = Modifier, + onTap: (() -> Unit)? = null, ) { val count = range.count() val itemHeight = 48.dp @@ -594,7 +619,15 @@ private fun WheelPicker( Box( modifier = modifier .width(64.dp) - .height(itemHeight * 3), + .height(itemHeight * 3) + .then( + if (onTap == null) { + Modifier + } else { + // Tap (not scroll) switches this field to typed entry. + Modifier.pointerInput(onTap) { detectTapGestures(onTap = { onTap() }) } + }, + ), contentAlignment = Alignment.Center, ) { Box( @@ -635,3 +668,129 @@ private fun WheelPicker( } } } + +/** + * Typed duration entry (§Module 4): three number fields with a digits-only + * keyboard. Entering two digits (or a value that can't grow, e.g. "6" minutes) + * hops straight to the field on the right, Samsung-style. + */ +@Composable +private fun TypedDuration( + hours: Int, + minutes: Int, + seconds: Int, + startField: Int, + onHours: (Int) -> Unit, + onMinutes: (Int) -> Unit, + onSeconds: (Int) -> Unit, + onDone: () -> Unit, +) { + var hourText by remember { mutableStateOf(if (hours == 0) "" else hours.toString()) } + var minuteText by remember { mutableStateOf(if (minutes == 0) "" else minutes.toString()) } + var secondText by remember { mutableStateOf(if (seconds == 0) "" else seconds.toString()) } + + val hourFocus = remember { FocusRequester() } + val minuteFocus = remember { FocusRequester() } + val secondFocus = remember { FocusRequester() } + val keyboard = LocalSoftwareKeyboardController.current + + LaunchedEffect(startField) { + when (startField) { + 0 -> hourFocus.requestFocus() + 1 -> minuteFocus.requestFocus() + else -> secondFocus.requestFocus() + } + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + DurationField( + value = hourText, + focusRequester = hourFocus, + max = 99, + onValue = { text, full -> + hourText = text + onHours(text.toIntOrNull() ?: 0) + if (full) minuteFocus.requestFocus() + }, + ) + WheelLabel("h") + DurationField( + value = minuteText, + focusRequester = minuteFocus, + max = 59, + onValue = { text, full -> + minuteText = text + onMinutes(text.toIntOrNull() ?: 0) + if (full) secondFocus.requestFocus() + }, + ) + WheelLabel("m") + DurationField( + value = secondText, + focusRequester = secondFocus, + max = 59, + onValue = { text, full -> + secondText = text + onSeconds(text.toIntOrNull() ?: 0) + if (full) keyboard?.hide() + }, + ) + WheelLabel("s") + } + TextButton( + onClick = { + keyboard?.hide() + onDone() + }, + ) { Text("Use the wheels") } + } +} + +/** One h/m/s box: digits only, at most two of them, clamped to [max]. */ +@Composable +private fun DurationField( + value: String, + focusRequester: FocusRequester, + max: Int, + onValue: (String, Boolean) -> Unit, +) { + OutlinedTextField( + value = value, + onValueChange = { raw -> + val digits = raw.filter { it.isDigit() }.takeLast(2) + val number = digits.toIntOrNull() + when { + digits.isEmpty() -> onValue("", false) + number == null -> Unit + number > max -> onValue(max.toString(), true) + // Two digits typed, or a first digit that can no longer grow. + digits.length == 2 || number * 10 > max -> onValue(digits, true) + else -> onValue(digits, false) + } + }, + modifier = Modifier.width(76.dp).focusRequester(focusRequester), + textStyle = LocalTextStyle.current.copy( + fontFamily = FontFamily.Monospace, + fontSize = 26.sp, + textAlign = TextAlign.Center, + ), + placeholder = { + Text( + "00", + fontFamily = FontFamily.Monospace, + fontSize = 26.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Next), + ) +} diff --git a/feature/downloader/src/main/kotlin/com/lifeos/feature/downloader/DownloaderScreen.kt b/feature/downloader/src/main/kotlin/com/lifeos/feature/downloader/DownloaderScreen.kt index 049cea3..b84df4b 100644 --- a/feature/downloader/src/main/kotlin/com/lifeos/feature/downloader/DownloaderScreen.kt +++ b/feature/downloader/src/main/kotlin/com/lifeos/feature/downloader/DownloaderScreen.kt @@ -2,6 +2,7 @@ package com.lifeos.feature.downloader import android.content.Intent import android.net.Uri +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -14,6 +15,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Public import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api @@ -40,6 +42,8 @@ import com.lifeos.core.designsystem.component.EmptyState import com.lifeos.feature.downloader.data.DownloadEngine import com.lifeos.feature.downloader.data.MediaCandidate import com.lifeos.feature.downloader.data.MediaExtractor +import com.lifeos.feature.downloader.data.SiteCatalog +import com.lifeos.feature.downloader.data.SupportedSite import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -95,6 +99,16 @@ class DownloaderViewModel @Inject constructor( fun delete(id: Long) { viewModelScope.launch { downloadDao.delete(id) } } + + private val _sourceQuery = MutableStateFlow("") + val sourceQuery = _sourceQuery.asStateFlow() + private val _showSources = MutableStateFlow(false) + val showSources = _showSources.asStateFlow() + + fun onSourceQuery(value: String) { _sourceQuery.value = value } + fun toggleSources() { _showSources.value = !_showSources.value } + + fun sources(): List = SiteCatalog.search(_sourceQuery.value) } /** @@ -112,7 +126,21 @@ fun DownloaderRoute(viewModel: DownloaderViewModel = hiltViewModel()) { val message by viewModel.message.collectAsState() val context = LocalContext.current - Scaffold(topBar = { TopAppBar(title = { Text("Downloader") }) }) { innerPadding -> + val showSources by viewModel.showSources.collectAsState() + val sourceQuery by viewModel.sourceQuery.collectAsState() + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Downloader") }, + actions = { + IconButton(onClick = viewModel::toggleSources) { + Icon(Icons.Filled.Public, contentDescription = "Supported sites") + } + }, + ) + }, + ) { innerPadding -> Column(modifier = Modifier.fillMaxSize().padding(innerPadding)) { Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), @@ -140,6 +168,32 @@ fun DownloaderRoute(viewModel: DownloaderViewModel = hiltViewModel()) { } LazyColumn(modifier = Modifier.fillMaxSize()) { + if (showSources) { + item { + OutlinedTextField( + value = sourceQuery, + onValueChange = viewModel::onSourceQuery, + label = { Text("Search ${SiteCatalog.sites.size} supported sites") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + val matches = viewModel.sources() + SiteCatalog.categories.forEach { category -> + val inCategory = matches.filter { it.category == category } + if (inCategory.isEmpty()) return@forEach + item(key = "cat-$category") { + Text( + category, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), + ) + } + items(inCategory, key = { "site-${it.host}-${it.name}" }) { site -> + SiteRow(site) { viewModel.onUrl("https://${it.host}/") } + } + } + } items(candidates, key = { it.url }) { candidate -> ListItem( headlineContent = { Text(candidate.title, maxLines = 1) }, @@ -202,12 +256,13 @@ fun DownloaderRoute(viewModel: DownloaderViewModel = hiltViewModel()) { } } } - if (candidates.isEmpty() && downloads.isEmpty()) { + if (candidates.isEmpty() && downloads.isEmpty() && !showSources) { item { EmptyState( title = "Nothing here yet", - description = "Paste a link from YouTube, Vimeo, TikTok, X, Instagram or any page " + - "with media — LifeOS finds the stream on-device and saves it to Downloads.", + description = "Paste a link from YouTube, Vimeo, TikTok, X, Instagram, Threads, " + + "LinkedIn, Reddit, ThisVid or any page with media - LifeOS finds the stream " + + "on-device and saves it to Downloads. The globe icon lists every supported site.", ) } } @@ -215,3 +270,13 @@ fun DownloaderRoute(viewModel: DownloaderViewModel = hiltViewModel()) { } } } + +/** One catalogue row: tapping it seeds the URL field with that site. */ +@Composable +private fun SiteRow(site: SupportedSite, onPick: (SupportedSite) -> Unit) { + ListItem( + modifier = Modifier.clickable { onPick(site) }, + headlineContent = { Text(site.name) }, + supportingContent = { Text("${site.host} - ${site.note}", maxLines = 2) }, + ) +} diff --git a/feature/downloader/src/main/kotlin/com/lifeos/feature/downloader/data/KvsPlayer.kt b/feature/downloader/src/main/kotlin/com/lifeos/feature/downloader/data/KvsPlayer.kt new file mode 100644 index 0000000..2e5fe7b --- /dev/null +++ b/feature/downloader/src/main/kotlin/com/lifeos/feature/downloader/data/KvsPlayer.kt @@ -0,0 +1,60 @@ +package com.lifeos.feature.downloader.data + +/** + * kt_player ("KVS") link decoder. ThisVid and a long tail of tube sites hand + * out `function/0/https://.../` URLs plus a `license_code`; the + * player unscrambles one path segment client-side before playing. + * + * Reimplemented here so those pages resolve fully on-device, with no external + * extractor binary involved. + */ +internal object KvsPlayer { + + /** Turns a license code into the digit sequence the shuffle is driven by. */ + fun licenseToken(license: String): List { + val modified = license.replace("$", "").replace("0", "1") + if (modified.length < 2) return emptyList() + val center = modified.length / 2 + val front = modified.substring(0, center + 1).toLongOrNull() ?: return emptyList() + val back = modified.substring(center).toLongOrNull() ?: return emptyList() + val shuffled = (4 * kotlin.math.abs(front - back)).toString() + val trimmed = shuffled.take(center + 1) + return trimmed.map { char -> char.digitToIntOrNull() ?: 0 } + } + + /** + * @param url a `function/0/` style link from the page's flashvars. + * @return the playable URL, or null when the input is not a KVS link. + */ + fun decode(url: String, licenseCode: String): String? { + if (!url.contains("/get_file/") && !url.startsWith("function/0/")) return null + val real = url.removePrefix("function/0/") + if (licenseCode.isBlank()) return real + val parts = real.split('/').toMutableList() + // The scrambled segment is the one after the /get_file/ marker. + val index = parts.indexOf("get_file").let { if (it >= 0) it + 1 else 5 } + val segment = parts.getOrNull(index) ?: return real + if (segment.length < 32) return real + val token = licenseToken(licenseCode) + if (token.isEmpty()) return real + + var magic = segment.take(32) + for (o in 31 downTo 0) { + val sum = token.drop(o).sum() + val l = (o + sum) % 32 + val builder = StringBuilder(32) + for (i in 0 until 32) { + builder.append( + when (i) { + o -> magic[l] + l -> magic[o] + else -> magic[i] + }, + ) + } + magic = builder.toString() + } + parts[index] = magic + segment.substring(32) + return parts.joinToString("/") + } +} diff --git a/feature/downloader/src/main/kotlin/com/lifeos/feature/downloader/data/MediaExtractor.kt b/feature/downloader/src/main/kotlin/com/lifeos/feature/downloader/data/MediaExtractor.kt index bd5c215..7089b5b 100644 --- a/feature/downloader/src/main/kotlin/com/lifeos/feature/downloader/data/MediaExtractor.kt +++ b/feature/downloader/src/main/kotlin/com/lifeos/feature/downloader/data/MediaExtractor.kt @@ -17,10 +17,13 @@ data class MediaCandidate( /** * Generic on-device media extractor (§Module Downloader). Given any URL it * finds direct media: the URL itself when it already serves video/audio, or - * streams declared in the page's HTML — OpenGraph (`og:video`, used by - * Vimeo/TikTok/X/Instagram and many others), `