diff --git a/.claude/.gitignore b/.claude/.gitignore new file mode 100644 index 0000000..93c0f73 --- /dev/null +++ b/.claude/.gitignore @@ -0,0 +1 @@ +settings.local.json diff --git a/.claude/commands/commit.md b/.claude/commands/commit.md new file mode 100644 index 0000000..06cd8db --- /dev/null +++ b/.claude/commands/commit.md @@ -0,0 +1,43 @@ +--- +description: Commit recent changes using a conventional commit message, after syncing with the remote. +allowed-tools: Bash(git status:*), Bash(git pull:*), Bash(git diff:*), Bash(git add:*), Bash(git commit:*), Bash(git push:*), AskUserQuestion, Edit, Write +context: fork +--- +Commit recent changes using a conventional commit message, after syncing with the remote. + +## Steps + +1. **Check git status** — run `git status` to see staged, unstaged, and untracked files. If there is nothing staged, stage recent changes. If there are unstaged or changed files from previous sessions group them (if possible) and use `AskUserQuestion` to ask the user if these changes shall be commited as well. + +2. **Sync** — run `git pull --rebase` to incorporate upstream changes before committing. If it fails (e.g. conflicts), stop and report the error; do not proceed. + +3. **Analyze the diff** — run `git diff --cached` to understand what is staged. + +4. **Draft a conventional commit message** following this format: + ``` + (): + + + ``` + - **type**: `feat` | `fix` | `docs` | `style` | `refactor` | `test` | `chore` | `perf` | `ci` | `build` | `revert` + - **scope**: affected module/component (omit if unclear or cross-cutting) + - **short description**: imperative mood, lowercase, no trailing period, ≤ 72 chars + - **body**: wrap at 72 chars; omit if the subject line is self-explanatory + - Add `BREAKING CHANGE: ` in the footer if applicable + +5. **Update CHANGELOG.md** — prepend an entry for this commit under the `[Unreleased]` section (create the file and section if absent). Format: + ``` + ## [Unreleased] + + ### + - + ``` + Categories: `Added` | `Changed` | `Fixed` | `Removed` | `Security` | `Performance`. Use one category block per entry; add multiple bullets if needed. Stage `CHANGELOG.md` with `git add CHANGELOG.md` before committing. + +6. **Confirm** — use `AskUserQuestion` with two questions in a single call: + 1. Show the drafted commit message and CHANGELOG entry; ask the user to approve or request changes. + 2. Ask whether to push after committing. + +7. **Commit** — once confirmed, run `git commit -m ""`. + +8. **Push** — if the user chose to push, run `git push`. diff --git a/.claude/commands/release.md b/.claude/commands/release.md new file mode 100644 index 0000000..4e47f56 --- /dev/null +++ b/.claude/commands/release.md @@ -0,0 +1,81 @@ +--- +description: Bump the app version (major/minor/patch or explicit semver), update CHANGELOG.md, and commit the release. +allowed-tools: Bash(git status:*), Bash(git pull:*), Bash(git diff:*), Bash(git add:*), Bash(git commit:*), Bash(git push:*), Read, Edit, Bash, AskUserQuestion, TodoWrite, TodoRead +context: fork +--- + +You are preparing a new release for the AutoSugar Android app. Your job is to determine the new version number, update `app/build.gradle.kts`, restructure `CHANGELOG.md`, and commit the result. + +## Input + +The user may have provided an argument: `$ARGUMENTS` + +Interpret `$ARGUMENTS` as follows: +- `major`, `minor`, or `patch` (case-insensitive) → bump that semver component +- A full semver string like `1.0.4` or `2.0.0` → use it exactly +- Empty / blank → analyse the changelog and suggest a bump type (see below) +- Anything else → it is unusual; flag it and ask for confirmation or correction + +## Step 1 — Read current version + +Read `app/build.gradle.kts` and extract: +- `versionName` (e.g. `"0.1.0"`) +- `versionCode` (integer) + +## Step 2 — Determine target version + +**If argument is `major` / `minor` / `patch`:** +Compute the new semver by incrementing the appropriate component (reset lower components to 0). + +**If argument is a valid semver (`X.Y.Z` with all three numeric parts):** +Use it as-is. Warn (but don't block) if the new version is less than or equal to the current one. + +**If argument is empty:** +Read the `## [Unreleased]` section of `CHANGELOG.md`. Based on the entries: +- Any `### Added` → at minimum a `minor` bump +- Only `### Fixed` or `### Changed` (no new features) → `patch` +- Breaking change indicated (rare) → `major` + +Formulate a suggestion with a one-sentence rationale. + +**If argument is unusual** (e.g. only two parts like `1.2`, has leading zeros, non-numeric, etc.): +Do not proceed. Ask the user to confirm or correct. + +## Step 3 — Confirm with user + +Before making any changes, use **AskUserQuestion** to confirm. Show: +- Current version +- Proposed new version +- New versionCode (current + 1) + +Single question, two options: "Proceed" and "Cancel / change". If the user cancels or provides a correction, re-evaluate from Step 2 with their input, then confirm again. + +## Step 4 — Apply changes + +**4a. Update `app/build.gradle.kts`** + +Replace the `versionCode` and `versionName` lines with the new values. Use Edit — do not rewrite the whole file. + +**4b. Update `CHANGELOG.md`** + +Get today's date via: `date +%Y-%m-%d` + +Replace the line `## [Unreleased]` (at the top of the Unreleased section) with: + +``` +## [Unreleased] + +## [X.Y.Z] - YYYY-MM-DD +``` + +This preserves an empty Unreleased section for future work and stamps the release with today's date. + +## Step 5 — Commit + +Run: +``` +git add app/build.gradle.kts CHANGELOG.md +git commit -m "chore(release): bump version to X.Y.Z" +``` + +Do **not** push. Tell the user the commit is ready and they can push / open a PR when ready. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3831efd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,70 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - uses: gradle/actions/setup-gradle@v3 + + - name: Assemble debug APK + run: ./gradlew assembleDebug + + test: + name: Unit Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - uses: gradle/actions/setup-gradle@v3 + + - name: Run unit tests + run: ./gradlew test + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: app/build/reports/tests/ + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - uses: gradle/actions/setup-gradle@v3 + + - name: Run lint + run: ./gradlew lint + + - name: Upload lint report + if: always() + uses: actions/upload-artifact@v4 + with: + name: lint-report + path: app/build/reports/lint-results-debug.html diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..48a1b38 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,114 @@ +name: Release + +on: + push: + branches: [main] + workflow_dispatch: + inputs: + track: + description: Google Play track to upload to + required: false + default: beta + type: choice + options: + - internal + - alpha + - beta + - production + +jobs: + release: + name: Tag, publish & deploy + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Read version + id: version + run: | + VERSION=$(grep 'versionName' app/build.gradle.kts | sed 's/.*"\(.*\)".*/\1/') + echo "name=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=v$VERSION" >> "$GITHUB_OUTPUT" + + - name: Check if tag already exists + id: tag_check + run: | + if git rev-parse "${{ steps.version.outputs.tag }}" >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Extract release notes from CHANGELOG.md + if: steps.tag_check.outputs.exists == 'false' + id: notes + run: | + VERSION="${{ steps.version.outputs.name }}" + NOTES=$(awk "/^## \[$VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md \ + | sed '/^[[:space:]]*$/d' | awk 'NR==1{p=1} p') + # Preserve multiline output + { + echo "body<> "$GITHUB_OUTPUT" + + - name: Create and push tag + if: steps.tag_check.outputs.exists == 'false' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag "${{ steps.version.outputs.tag }}" + git push origin "${{ steps.version.outputs.tag }}" + + - name: Create GitHub Release + if: steps.tag_check.outputs.exists == 'false' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "${{ steps.version.outputs.tag }}" \ + --title "${{ steps.version.outputs.tag }}" \ + --notes "${{ steps.notes.outputs.body }}" + + - uses: actions/setup-java@v4 + if: steps.tag_check.outputs.exists == 'false' + with: + distribution: temurin + java-version: 17 + + - uses: gradle/actions/setup-gradle@v3 + if: steps.tag_check.outputs.exists == 'false' + + - name: Decode keystore + if: steps.tag_check.outputs.exists == 'false' + run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > keystore.jks + + - name: Build signed AAB + if: steps.tag_check.outputs.exists == 'false' + env: + KEYSTORE_PATH: keystore.jks + KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + run: ./gradlew bundleRelease + + - name: Prepare what's new for Google Play + if: steps.tag_check.outputs.exists == 'false' + run: | + mkdir -p whatsnew + echo "${{ steps.notes.outputs.body }}" > whatsnew/whatsnew-en-US + + - name: Upload to Google Play + if: steps.tag_check.outputs.exists == 'false' + uses: r0adkll/upload-google-play@v1 + with: + serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }} + packageName: de.autosugar + releaseFiles: app/build/outputs/bundle/release/*.aab + track: ${{ inputs.track || 'beta' }} + whatsNewDirectory: whatsnew/ diff --git a/AGENTS.md b/AGENTS.md index 19e9ebc..8c3e383 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,12 @@ This document defines specific roles for AI agents or developers to ensure a modular and scalable implementation of AutoSugar. +## Branch Strategy + +* **`main`** is protected. Never commit or push directly to `main`. +* All work happens on **`develop`** (or feature branches off `develop`). +* Merges into `main` are done via pull request only. + ## Agent 1: Android Auto UX/UI Specialist * **Focus:** Implementing the `CarAppService` and `Screen` hierarchy. * **Tasks:** * Design a dynamic `PaneTemplate` that updates based on the selected source. diff --git a/AutoSugar.svg b/AutoSugar.svg new file mode 100644 index 0000000..1bfe5ff --- /dev/null +++ b/AutoSugar.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CHANGELOG.md b/CHANGELOG.md index f5f6e03..9c56b4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,84 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Changed +- `/commit` command now includes frontmatter metadata for Claude Code command system integration + +### Added +- Unit tests for GlucoseEntry display logic (displayValue, displayDelta, trendArrow) +- Unit tests for ProfileSerializer JSON round-trip including alertsEnabled and icon fallback +- Expanded NightscoutRepository unit tests covering all methods (getCurrentEntry delta + handling, getThresholds defaults, getHistory, saveProfile upsert, deleteProfile, saveAll) + +### Fixed +- Remove hardcoded local JDK path from gradle.properties so CI builds succeed on GitHub Actions +- Add missing German translations for glucose alert strings (lint MissingTranslation) + +### Added +- Per-profile glucose alert notifications for high and low BG using + Nightscout's bgHigh/bgLow thresholds, with 15-minute repeat cooldown +- Predictive alerts: 15-min linear extrapolation warns before a + threshold is crossed (requires delta data from Nightscout) +- POST_NOTIFICATIONS permission for Android 13+ alert support + +### Changed +- Settings screen: profile cards show an alerts toggle; tap to edit, + long-press to drag-reorder profiles; unit removed from card display +- Profile edit screen: save moved to top-bar icon, test connection + placed directly below API token field, delete button moved to bottom +- `/commit` command now auto-stages recent changes and prompts about other unstaged files instead of stopping when nothing is staged + +### Added +- Monochrome adaptive icon (`ic_launcher_monochrome.xml`) for Android 13+ themed icon support +- `` layer added to `ic_launcher.xml` adaptive icon definition + +### Changed +- Adaptive icon background color updated from `#F4EFE4` to `#302919` + ### Added +- Fetch glucose target thresholds (`bgTargetBottom`/`bgTargetTop`) from Nightscout `/api/v1/status.json` and use them for the graph's target band and dynamic Y-axis scaling +- `StatusDto`, `SettingsDto`, `ThresholdsDto` DTOs for Nightscout status response +- `getStatus()` endpoint in `NightscoutApi` and `getTargetRange()` in `NightscoutRepository` + +### Changed +- Graph Y-axis now scales dynamically to fit actual readings plus the target range (with padding), instead of a fixed 40–400 mg/dL range +- Time labels moved to the bottom of the graph; grid lines skip values too close to chart edges + +### Added +- Glucose history graph in Car UI: 3-hour bitmap rendered via `Pane.setImage()` with target range band, dotted grid lines, drop-pin value labels at 20-min marks, and half-hour time labels +- Reading age ("X min ago") and received timestamp shown as secondary row in Car UI +- Expanded profile icon set: male, female, boy, girl, baby, elderly man, elderly woman variants +- German translations for all new strings + +### Fixed +- Profile icon now shown in Settings overview list +- Glucose unit displayed as `mg/dL` / `mmol/L` (was `MG/DL` / `MMOL/L`) + +### Changed +- Icon picker in profile edit screen uses `FlowRow` for dynamic wrapping instead of fixed 4-per-row + +- TabTemplate for multi-profile switching (CarApi >= 6, 2–4 profiles); falls back to numbered icon ActionStrip +- Trend arrow rendered as bitmap image in Car UI row; delta shown below glucose value +- Configurable auto-refresh interval (30 s / 1 min / 2 min / 5 min) via Settings +- Configurable tab icon per profile (Person, Home, Heart, Star, Car, Medical) with icon picker in profile edit screen +- App launcher icon and mipmap assets +- DHU simulator setup guide (`TESTING.md`) +- `AppPreferencesDataStore` for persisting app-level preferences +- History endpoint (`getEntries`) in Nightscout API client + +### Fixed +- App not appearing in Android Auto (missing `com.google.android.gms.car.application` meta-data) +- Crash on `ActionStrip` exceeding max 1 titled action +- Glucose unit labels shown in uppercase (now `mg/dL` / `mmol/L`) +- Java 17 toolchain required for Kotlin 2.0.21 compatibility + +### Added +- Gradle wrapper (8.9) so CI runners can build without a local Gradle install +- GitHub Actions CI workflow with separate Build, Unit Tests, and Lint jobs +- Branch strategy documented in AGENTS.md: `main` is protected, all work on `develop` + +### Changed +- Replaced deprecated `kotlinOptions { jvmTarget }` with `kotlin { jvmToolchain(17) }` in app build script - MIT License (Copyright 2026 Nico Wiedemann) - Full Android project scaffold: Gradle version catalog, root and app-level build files, ProGuard rules - `AndroidManifest.xml` with `CarAppService` (IOT category), `MainActivity`, internet and foreground-service permissions diff --git a/README.md b/README.md index 11c5f24..02c0a0f 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,66 @@ # AutoSugar -AutoSugar is an unofficial Android Auto application designed to monitor Nightscout CGM data. It allows users to track blood glucose levels from one or multiple sources (e.g., self and children) directly on their vehicle's head unit. +AutoSugar icon + +AutoSugar brings your blood glucose data into the car. Monitor your Nightscout CGM readings directly on your vehicle's head unit — glanceable, hands-free, and always up to date. + +
+ +AutoSugar on Android Auto + +
+ +[Get it on Google Play](https://play.google.com/store/apps/details?id=de.autosugar) + +--- ## Features -* **N-Source Support:** Monitor a single source or switch between multiple Nightscout instances seamlessly. -* **Multilingual UI:** Initial support for English and German, designed for easy localization (i18n). -* **Glanceable Data:** Displays current glucose values, trend arrows, and deltas using the `PaneTemplate`. -* **Background Updates:** Automated data polling while driving to ensure real-time accuracy. - -## Prerequisites & Installation -1. **Android Studio:** Required to compile the APK from source. -2. **Developer Mode:** - Open the Android Auto app on your smartphone. - - Tap the "Version" footer 10 times to unlock Developer Settings. - - In the Developer Settings (top-right menu), enable **"Unknown sources"**. -3. **Sideloading:** Install the compiled APK directly via ADB or file transfer. - -## Technical Beleg (Google Documentation) -According to the official Android for Cars documentation: -- **Category:** The app uses the `IOT` category (`androidx.car.app.category.IOT`), which is permitted to display information from connected sensors/services. -- **Testing:** Local testing of sideloaded apps is supported via the "Unknown sources" toggle ([developer.android.com/training/cars/testing](https://developer.android.com/training/cars/testing)). + +- **Multiple sources** — switch between Nightscout instances for yourself, your children, or anyone you care for +- **Glanceable display** — current glucose value, trend arrow, and delta at a glance +- **Background updates** — continuous polling keeps readings fresh while you drive +- **Configurable alerts** — get notified when glucose leaves your target range +- **English & German** — with more languages easy to add + +--- + +## Getting Started + +### 1. Install the app + +Get AutoSugar from the [Google Play Store](https://play.google.com/store/apps/details?id=de.autosugar) or sideload the APK from [GitHub Releases](https://github.com/EarMaster/AutoSugar/releases). + +### 2. Enable Developer Mode in Android Auto *(sideloaded APK only)* + +If you installed from the Play Store, skip this step. For sideloaded APKs: + +1. Open the **Android Auto** app on your phone +2. Tap the **"Version"** entry in the footer 10 times to unlock Developer Settings +3. Open the top-right menu → **Developer settings** → enable **Unknown sources** + +### 3. Connect your Nightscout source + +Source configuration screen + +Open AutoSugar on your phone and add a source: + +- **Display name** — shown as a label on the Android Auto screen +- **Nightscout URL** — the base URL of your Nightscout instance (e.g. `https://mynb.herokuapp.com`) +- **API Token** — your Nightscout API token (leave empty if your instance is public) +- **Glucose alerts** — enable to receive alerts when readings go out of range +- **Tab icon** — choose an icon to identify this source on the car screen +- **Glucose unit** — mg/dL or mmol/L + +Tap **Test connection** to verify, then save. Add more sources to switch between them on the car screen. + +--- + +## Privacy + +AutoSugar communicates exclusively with the Nightscout instances you configure. No data is sent to any third party. + +--- + +## License + +[MIT](LICENSE) diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..8cadfe8 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,59 @@ +# Testing with Android Auto Desktop Head Unit (DHU) + +## Prerequisites + +- Physical Android phone (emulators do not support Android Auto) +- Android SDK installed (`$ANDROID_HOME` set to `~/Library/Android/sdk`) +- ADB connected to your phone (USB or WiFi) + +## One-time Setup + +### 1. Enable Developer Mode in Android Auto + +On your phone, open the **Android Auto** app: +- Tap the **version number** in the footer **10 times** +- Go to **Developer Settings** → enable **Unknown sources** + +### 2. Set ANDROID_HOME (if not already set) + +Add to `~/.zshrc`: + +```bash +export ANDROID_HOME=$HOME/Library/Android/sdk +export PATH=$PATH:$ANDROID_HOME/tools:$ANDROID_HOME/platform-tools +``` + +Then reload: `source ~/.zshrc` + +## Running a Test Session + +### 1. Install the debug APK + +```bash +./gradlew assembleDebug +adb install app/build/outputs/apk/debug/app-debug.apk +``` + +### 2. Start Head Unit Server on your phone + +In the Android Auto app: tap the **three-dot menu** → **Head Unit Server** + +### 3. Forward the ADB port + +```bash +adb forward tcp:5277 tcp:5277 +``` + +If connected via WiFi, specify the device ID: + +```bash +adb -s forward tcp:5277 tcp:5277 +``` + +> Your device ID can be found via `adb devices`. + +### 4. Launch the DHU + +```bash +$ANDROID_HOME/extras/google/auto/desktop-head-unit +``` diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 13ef0ad..3742949 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -7,22 +7,32 @@ plugins { } android { - namespace = "com.autosugar" + namespace = "de.autosugar" compileSdk = 35 defaultConfig { - applicationId = "com.autosugar" + applicationId = "de.autosugar" minSdk = 26 targetSdk = 35 versionCode = 1 versionName = "0.1.0" } + signingConfigs { + create("release") { + storeFile = System.getenv("KEYSTORE_PATH")?.let { file(it) } + storePassword = System.getenv("KEYSTORE_PASSWORD") + keyAlias = System.getenv("KEY_ALIAS") + keyPassword = System.getenv("KEY_PASSWORD") + } + } + buildTypes { debug { buildConfigField("Boolean", "ENABLE_HTTP_LOGGING", "true") } release { + signingConfig = signingConfigs.getByName("release") isMinifyEnabled = true buildConfigField("Boolean", "ENABLE_HTTP_LOGGING", "false") proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") @@ -34,8 +44,6 @@ android { targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { jvmTarget = "17" } - buildFeatures { compose = true buildConfig = true diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a78bcdb..07458ed 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,10 +4,13 @@ + @@ -36,6 +39,9 @@ + diff --git a/app/src/main/java/com/autosugar/car/GlucoseScreen.kt b/app/src/main/java/com/autosugar/car/GlucoseScreen.kt deleted file mode 100644 index 93f6b9b..0000000 --- a/app/src/main/java/com/autosugar/car/GlucoseScreen.kt +++ /dev/null @@ -1,168 +0,0 @@ -package com.autosugar.car - -import androidx.car.app.CarContext -import androidx.car.app.Screen -import androidx.car.app.model.Action -import androidx.car.app.model.ActionStrip -import androidx.car.app.model.Pane -import androidx.car.app.model.PaneTemplate -import androidx.car.app.model.Row -import androidx.car.app.model.Template -import com.autosugar.R -import com.autosugar.data.model.GlucoseEntry -import com.autosugar.data.model.NightscoutProfile -import com.autosugar.data.repository.NightscoutRepository -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch - -private const val POLL_INTERVAL_MS = 60_000L - -class GlucoseScreen( - carContext: CarContext, - private val repository: NightscoutRepository, - private var activeProfileId: String, -) : Screen(carContext) { - - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) - - private var profiles: List = emptyList() - private var entry: GlucoseEntry? = null - private var isLoading = true - private var errorMessage: String? = null - - init { - scope.launch { - repository.profilesFlow.collect { updated -> - profiles = updated - invalidate() - } - } - startPolling() - } - - private fun startPolling() { - scope.launch { - while (isActive) { - fetch() - delay(POLL_INTERVAL_MS) - } - } - } - - private suspend fun fetch() { - isLoading = entry == null - errorMessage = null - repository.getCurrentEntry(activeProfileId) - .onSuccess { result -> - entry = result - isLoading = false - } - .onFailure { e -> - isLoading = false - errorMessage = e.message ?: carContext.getString(R.string.error_fetch_failed) - } - invalidate() - } - - override fun onGetTemplate(): Template { - val activeProfile = profiles.find { it.id == activeProfileId } - val title = activeProfile?.displayName ?: carContext.getString(R.string.app_name) - val unit = activeProfile?.unit - - val pane = when { - isLoading -> Pane.Builder() - .setLoading(true) - .build() - - errorMessage != null -> Pane.Builder() - .addRow(Row.Builder() - .setTitle(carContext.getString(R.string.error_fetch_failed)) - .addText(errorMessage ?: "") - .build()) - .addAction(Action.Builder() - .setTitle(carContext.getString(R.string.action_retry)) - .setOnClickListener { scope.launch { fetch() } } - .build()) - .build() - - else -> { - val e = entry!! - val displayUnit = unit ?: com.autosugar.data.model.GlucoseUnit.MG_DL - Pane.Builder() - .addRow(Row.Builder() - .setTitle(carContext.getString(R.string.label_glucose_value)) - .addText("${e.displayValue(displayUnit)} ${displayUnit.label(carContext)}") - .build()) - .addRow(Row.Builder() - .setTitle(carContext.getString(R.string.label_trend)) - .addText(e.trendArrow) - .build()) - .addRow(Row.Builder() - .setTitle(carContext.getString(R.string.label_delta)) - .addText(e.displayDelta(displayUnit) ?: "-") - .build()) - .build() - } - } - - val actionStrip = buildActionStrip() - - return PaneTemplate.Builder(pane) - .setTitle(title) - .apply { if (actionStrip != null) setActionStrip(actionStrip) } - .build() - } - - private fun buildActionStrip(): ActionStrip? { - // ActionStrip supports max 4 actions. - if (profiles.size <= 1) return null - - return if (profiles.size <= 4) { - // One button per source - val actions = profiles.map { profile -> - Action.Builder() - .setTitle(profile.displayName) - .setOnClickListener { switchTo(profile.id) } - .build() - } - ActionStrip.Builder().apply { actions.forEach { addAction(it) } }.build() - } else { - // Too many profiles for the strip — push a selection screen - ActionStrip.Builder() - .addAction(Action.Builder() - .setTitle(carContext.getString(R.string.action_switch_source)) - .setOnClickListener { - screenManager.push(SourceSelectScreen(carContext, repository) { id -> - switchTo(id) - }) - } - .build()) - .build() - } - } - - private fun switchTo(profileId: String) { - if (profileId == activeProfileId) return - activeProfileId = profileId - repository.setActiveProfile(profileId) - entry = null - isLoading = true - invalidate() - scope.launch { fetch() } - } - - override fun onDestroy() { - scope.cancel() - } -} - -private fun com.autosugar.data.model.GlucoseUnit.label(context: android.content.Context): String = - when (this) { - com.autosugar.data.model.GlucoseUnit.MG_DL -> context.getString(R.string.label_unit_mgdl) - com.autosugar.data.model.GlucoseUnit.MMOL_L -> context.getString(R.string.label_unit_mmoll) - } diff --git a/app/src/main/java/com/autosugar/data/model/NightscoutProfile.kt b/app/src/main/java/com/autosugar/data/model/NightscoutProfile.kt deleted file mode 100644 index 6009ddc..0000000 --- a/app/src/main/java/com/autosugar/data/model/NightscoutProfile.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.autosugar.data.model - -import java.util.UUID - -enum class GlucoseUnit { MG_DL, MMOL_L } - -data class NightscoutProfile( - val id: String = UUID.randomUUID().toString(), - val displayName: String, - val baseUrl: String, - val apiToken: String, - val unit: GlucoseUnit = GlucoseUnit.MG_DL, -) diff --git a/app/src/main/java/com/autosugar/data/network/NightscoutApi.kt b/app/src/main/java/com/autosugar/data/network/NightscoutApi.kt deleted file mode 100644 index fdc244d..0000000 --- a/app/src/main/java/com/autosugar/data/network/NightscoutApi.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.autosugar.data.network - -import com.autosugar.data.network.dto.EntryDto -import retrofit2.http.GET -import retrofit2.http.Query - -interface NightscoutApi { - @GET("api/v1/entries/current.json") - suspend fun getCurrentEntry( - @Query("token") token: String? = null, - @Query("count") count: Int = 2, - ): List -} diff --git a/app/src/main/java/com/autosugar/data/repository/NightscoutRepository.kt b/app/src/main/java/com/autosugar/data/repository/NightscoutRepository.kt deleted file mode 100644 index e3dacf5..0000000 --- a/app/src/main/java/com/autosugar/data/repository/NightscoutRepository.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.autosugar.data.repository - -import com.autosugar.data.model.GlucoseEntry -import com.autosugar.data.model.NightscoutProfile -import com.autosugar.data.network.NightscoutApiFactory -import com.autosugar.data.storage.ProfileDataStore -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class NightscoutRepository @Inject constructor( - private val dataStore: ProfileDataStore, - private val apiFactory: NightscoutApiFactory, -) { - val profilesFlow: Flow> = dataStore.profilesFlow - - private val _activeProfileId = MutableStateFlow(null) - val activeProfileId: StateFlow = _activeProfileId.asStateFlow() - - fun setActiveProfile(id: String) { - _activeProfileId.value = id - } - - suspend fun getCurrentEntry(profileId: String): Result = runCatching { - val profiles = dataStore.profilesFlow.first() - val profile = profiles.find { it.id == profileId } - ?: error("Profile $profileId not found") - - val api = apiFactory.get(profile.baseUrl) - val entries = api.getCurrentEntry( - token = profile.apiToken.ifBlank { null }, - count = 2, - ) - val latest = entries.firstOrNull() ?: error("No entries returned") - val previous = entries.getOrNull(1) - - val delta = previous?.let { (latest.sgv - it.sgv).toDouble() } - - GlucoseEntry( - sgv = latest.sgv, - direction = latest.direction ?: "NOT COMPUTABLE", - dateIso = latest.dateString ?: latest.date.toString(), - delta = latest.delta ?: delta, - ) - } - - suspend fun saveProfile(profile: NightscoutProfile) { - val profiles = dataStore.profilesFlow.first().toMutableList() - val idx = profiles.indexOfFirst { it.id == profile.id } - if (idx >= 0) profiles[idx] = profile else profiles.add(profile) - dataStore.save(profiles) - // Invalidate cached API instance in case URL changed - apiFactory.invalidate(profile.baseUrl) - } - - suspend fun deleteProfile(id: String) { - val profiles = dataStore.profilesFlow.first().filter { it.id != id } - dataStore.save(profiles) - if (_activeProfileId.value == id) _activeProfileId.value = null - } -} diff --git a/app/src/main/java/com/autosugar/ui/settings/ProfileEditScreen.kt b/app/src/main/java/com/autosugar/ui/settings/ProfileEditScreen.kt deleted file mode 100644 index 87a220f..0000000 --- a/app/src/main/java/com/autosugar/ui/settings/ProfileEditScreen.kt +++ /dev/null @@ -1,170 +0,0 @@ -package com.autosugar.ui.settings - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.Button -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.OutlinedButton -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.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.res.stringResource -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import com.autosugar.R -import com.autosugar.data.model.GlucoseUnit - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ProfileEditScreen( - profileId: String?, - onNavigateUp: () -> Unit, - viewModel: ProfileEditViewModel = hiltViewModel(), -) { - LaunchedEffect(profileId) { - profileId?.let { viewModel.loadProfile(it) } - } - - val uiState by viewModel.uiState.collectAsState() - val displayName by viewModel.displayName.collectAsState() - val baseUrl by viewModel.baseUrl.collectAsState() - val apiToken by viewModel.apiToken.collectAsState() - val unit by viewModel.unit.collectAsState() - val snackbarHostState = remember { SnackbarHostState() } - - LaunchedEffect(uiState) { - when (val state = uiState) { - is ProfileEditUiState.Saved -> onNavigateUp() - is ProfileEditUiState.TestSuccess -> { - snackbarHostState.showSnackbar("BG: ${state.message}") - viewModel.clearState() - } - is ProfileEditUiState.Error -> { - snackbarHostState.showSnackbar(state.message) - viewModel.clearState() - } - else -> Unit - } - } - - val isLoading = uiState is ProfileEditUiState.Loading - val title = if (profileId == null) { - stringResource(R.string.label_add_source) - } else { - stringResource(R.string.label_edit_source) - } - - Scaffold( - topBar = { - TopAppBar( - title = { Text(title) }, - navigationIcon = { - IconButton(onClick = onNavigateUp) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) - } - }, - ) - }, - snackbarHost = { SnackbarHost(snackbarHostState) }, - ) { padding -> - Column( - modifier = Modifier - .padding(padding) - .padding(16.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - OutlinedTextField( - value = displayName, - onValueChange = { viewModel.displayName.value = it }, - label = { Text(stringResource(R.string.hint_display_name)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - enabled = !isLoading, - ) - - OutlinedTextField( - value = baseUrl, - onValueChange = { viewModel.baseUrl.value = it }, - label = { Text(stringResource(R.string.hint_base_url)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), - enabled = !isLoading, - ) - - OutlinedTextField( - value = apiToken, - onValueChange = { viewModel.apiToken.value = it }, - label = { Text(stringResource(R.string.hint_api_token)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - enabled = !isLoading, - ) - - Text(stringResource(R.string.label_unit)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - GlucoseUnit.entries.forEach { u -> - FilterChip( - selected = unit == u, - onClick = { viewModel.unit.value = u }, - label = { Text(u.name.replace("_", "/")) }, - enabled = !isLoading, - ) - } - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedButton( - onClick = { viewModel.testConnection() }, - enabled = !isLoading && baseUrl.isNotBlank(), - modifier = Modifier.weight(1f), - ) { - Text(stringResource(R.string.btn_test_connection)) - } - - Button( - onClick = { viewModel.save() }, - enabled = !isLoading && displayName.isNotBlank() && baseUrl.isNotBlank(), - modifier = Modifier.weight(1f), - ) { - if (isLoading) { - CircularProgressIndicator(modifier = Modifier.padding(4.dp)) - } else { - Text(stringResource(R.string.btn_save)) - } - } - } - } - } -} diff --git a/app/src/main/java/com/autosugar/ui/settings/SettingsScreen.kt b/app/src/main/java/com/autosugar/ui/settings/SettingsScreen.kt deleted file mode 100644 index 629dec5..0000000 --- a/app/src/main/java/com/autosugar/ui/settings/SettingsScreen.kt +++ /dev/null @@ -1,119 +0,0 @@ -package com.autosugar.ui.settings - -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.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material3.Card -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import com.autosugar.R -import com.autosugar.data.model.NightscoutProfile - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun SettingsScreen( - onAddProfile: () -> Unit, - onEditProfile: (String) -> Unit, - viewModel: SettingsViewModel = hiltViewModel(), -) { - val profiles by viewModel.profiles.collectAsState() - - Scaffold( - topBar = { - TopAppBar(title = { Text(stringResource(R.string.label_settings)) }) - }, - floatingActionButton = { - FloatingActionButton(onClick = onAddProfile) { - Icon(Icons.Default.Add, contentDescription = stringResource(R.string.btn_add_profile)) - } - }, - ) { padding -> - if (profiles.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize().padding(padding), - contentAlignment = Alignment.Center, - ) { - Text( - text = stringResource(R.string.label_no_sources), - style = MaterialTheme.typography.bodyLarge, - ) - } - } else { - LazyColumn( - modifier = Modifier.fillMaxSize().padding(padding), - contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(profiles, key = { it.id }) { profile -> - ProfileCard( - profile = profile, - onEdit = { onEditProfile(profile.id) }, - onDelete = { viewModel.deleteProfile(profile.id) }, - ) - } - } - } - } -} - -@Composable -private fun ProfileCard( - profile: NightscoutProfile, - onEdit: () -> Unit, - onDelete: () -> Unit, -) { - Card(modifier = Modifier.fillMaxWidth()) { - Row( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text(text = profile.displayName, style = MaterialTheme.typography.titleMedium) - Text( - text = profile.baseUrl, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - ) - Text( - text = profile.unit.name.replace("_", "/"), - style = MaterialTheme.typography.labelSmall, - ) - } - IconButton(onClick = onEdit) { - Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.btn_edit)) - } - IconButton(onClick = onDelete) { - Icon( - Icons.Default.Delete, - contentDescription = stringResource(R.string.btn_delete), - tint = MaterialTheme.colorScheme.error, - ) - } - } - } -} diff --git a/app/src/main/java/com/autosugar/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/autosugar/ui/settings/SettingsViewModel.kt deleted file mode 100644 index aa2e280..0000000 --- a/app/src/main/java/com/autosugar/ui/settings/SettingsViewModel.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.autosugar.ui.settings - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.autosugar.data.model.NightscoutProfile -import com.autosugar.data.repository.NightscoutRepository -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import javax.inject.Inject - -@HiltViewModel -class SettingsViewModel @Inject constructor( - private val repository: NightscoutRepository, -) : ViewModel() { - - val profiles: StateFlow> = repository.profilesFlow - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) - - fun deleteProfile(id: String) { - viewModelScope.launch { repository.deleteProfile(id) } - } -} diff --git a/app/src/main/java/com/autosugar/AutoSugarApp.kt b/app/src/main/java/de/autosugar/AutoSugarApp.kt similarity index 85% rename from app/src/main/java/com/autosugar/AutoSugarApp.kt rename to app/src/main/java/de/autosugar/AutoSugarApp.kt index 3ee9ba8..9c4a336 100644 --- a/app/src/main/java/com/autosugar/AutoSugarApp.kt +++ b/app/src/main/java/de/autosugar/AutoSugarApp.kt @@ -1,4 +1,4 @@ -package com.autosugar +package de.autosugar import android.app.Application import dagger.hilt.android.HiltAndroidApp diff --git a/app/src/main/java/com/autosugar/car/AutoSugarCarAppService.kt b/app/src/main/java/de/autosugar/car/AutoSugarCarAppService.kt similarity index 69% rename from app/src/main/java/com/autosugar/car/AutoSugarCarAppService.kt rename to app/src/main/java/de/autosugar/car/AutoSugarCarAppService.kt index 39f9f1e..c492b90 100644 --- a/app/src/main/java/com/autosugar/car/AutoSugarCarAppService.kt +++ b/app/src/main/java/de/autosugar/car/AutoSugarCarAppService.kt @@ -1,9 +1,10 @@ -package com.autosugar.car +package de.autosugar.car import androidx.car.app.CarAppService import androidx.car.app.Session import androidx.car.app.validation.HostValidator -import com.autosugar.data.repository.NightscoutRepository +import de.autosugar.data.repository.NightscoutRepository +import de.autosugar.data.storage.AppPreferencesDataStore import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @@ -11,9 +12,10 @@ import javax.inject.Inject class AutoSugarCarAppService : CarAppService() { @Inject lateinit var repository: NightscoutRepository + @Inject lateinit var appPrefs: AppPreferencesDataStore override fun createHostValidator(): HostValidator = HostValidator.ALLOW_ALL_HOSTS_VALIDATOR - override fun onCreateSession(): Session = AutoSugarSession(repository) + override fun onCreateSession(): Session = AutoSugarSession(repository, appPrefs) } diff --git a/app/src/main/java/com/autosugar/car/AutoSugarSession.kt b/app/src/main/java/de/autosugar/car/AutoSugarSession.kt similarity index 60% rename from app/src/main/java/com/autosugar/car/AutoSugarSession.kt rename to app/src/main/java/de/autosugar/car/AutoSugarSession.kt index ea68498..7a4d8c8 100644 --- a/app/src/main/java/com/autosugar/car/AutoSugarSession.kt +++ b/app/src/main/java/de/autosugar/car/AutoSugarSession.kt @@ -1,23 +1,26 @@ -package com.autosugar.car +package de.autosugar.car import android.content.Intent import androidx.car.app.Screen import androidx.car.app.Session -import com.autosugar.data.repository.NightscoutRepository +import de.autosugar.data.repository.NightscoutRepository +import de.autosugar.data.storage.AppPreferencesDataStore import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking -class AutoSugarSession(private val repository: NightscoutRepository) : Session() { +class AutoSugarSession( + private val repository: NightscoutRepository, + private val appPrefs: AppPreferencesDataStore, +) : Session() { override fun onCreateScreen(intent: Intent): Screen { val profiles = runBlocking { repository.profilesFlow.first() } return when { - profiles.isEmpty() -> NoProfilesScreen(carContext, repository) + profiles.isEmpty() -> NoProfilesScreen(carContext, repository, appPrefs) else -> { - // Default to the first profile; user can switch via ActionStrip val activeId = repository.activeProfileId.value ?: profiles.first().id repository.setActiveProfile(activeId) - GlucoseScreen(carContext, repository, activeId) + GlucoseScreen(carContext, repository, appPrefs, activeId) } } } diff --git a/app/src/main/java/de/autosugar/car/GlucoseAlertManager.kt b/app/src/main/java/de/autosugar/car/GlucoseAlertManager.kt new file mode 100644 index 0000000..7ccd523 --- /dev/null +++ b/app/src/main/java/de/autosugar/car/GlucoseAlertManager.kt @@ -0,0 +1,92 @@ +package de.autosugar.car + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import androidx.core.app.NotificationCompat +import de.autosugar.R +import de.autosugar.data.model.GlucoseUnit + +class GlucoseAlertManager(private val context: Context) { + + companion object { + private const val CHANNEL_ID = "glucose_alerts" + private const val NOTIF_HIGH = 1001 + private const val NOTIF_LOW = 1002 + private const val NOTIF_PREDICTED_HIGH = 1003 + private const val NOTIF_PREDICTED_LOW = 1004 + } + + private val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + init { + nm.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + context.getString(R.string.notif_channel_name), + NotificationManager.IMPORTANCE_HIGH, + ).apply { + description = context.getString(R.string.notif_channel_desc) + } + ) + } + + fun sendHighAlert(sgv: Int, unit: GlucoseUnit) { + post( + id = NOTIF_HIGH, + title = context.getString(R.string.notif_title_high), + text = formatValue(sgv, unit), + ) + } + + fun sendLowAlert(sgv: Int, unit: GlucoseUnit) { + post( + id = NOTIF_LOW, + title = context.getString(R.string.notif_title_low), + text = formatValue(sgv, unit), + ) + } + + fun sendPredictedHighAlert(projectedSgv: Int, unit: GlucoseUnit) { + post( + id = NOTIF_PREDICTED_HIGH, + title = context.getString(R.string.notif_title_predicted_high), + text = context.getString(R.string.notif_text_predicted, formatValue(projectedSgv, unit)), + ) + } + + fun sendPredictedLowAlert(projectedSgv: Int, unit: GlucoseUnit) { + post( + id = NOTIF_PREDICTED_LOW, + title = context.getString(R.string.notif_title_predicted_low), + text = context.getString(R.string.notif_text_predicted, formatValue(projectedSgv, unit)), + ) + } + + private fun formatValue(sgv: Int, unit: GlucoseUnit): String { + val value = when (unit) { + GlucoseUnit.MG_DL -> sgv.toString() + GlucoseUnit.MMOL_L -> "%.1f".format(sgv / 18.0) + } + val label = when (unit) { + GlucoseUnit.MG_DL -> context.getString(R.string.label_unit_mgdl) + GlucoseUnit.MMOL_L -> context.getString(R.string.label_unit_mmoll) + } + return "$value $label" + } + + private fun post(id: Int, title: String, text: String) { + try { + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_profile_medical) + .setContentTitle(title) + .setContentText(text) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + .build() + nm.notify(id, notification) + } catch (_: SecurityException) { + // POST_NOTIFICATIONS not granted on Android 13+ — alerts silently suppressed + } + } +} diff --git a/app/src/main/java/de/autosugar/car/GlucoseScreen.kt b/app/src/main/java/de/autosugar/car/GlucoseScreen.kt new file mode 100644 index 0000000..557a582 --- /dev/null +++ b/app/src/main/java/de/autosugar/car/GlucoseScreen.kt @@ -0,0 +1,568 @@ +package de.autosugar.car + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.DashPathEffect +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RectF +import android.graphics.Typeface +import androidx.car.app.CarContext +import androidx.car.app.Screen +import androidx.car.app.annotations.RequiresCarApi +import androidx.car.app.model.Action +import androidx.car.app.model.ActionStrip +import androidx.car.app.model.CarIcon +import androidx.car.app.model.Pane +import androidx.car.app.model.PaneTemplate +import androidx.car.app.model.Row +import androidx.car.app.model.Tab +import androidx.car.app.model.TabContents +import androidx.car.app.model.TabTemplate +import androidx.car.app.model.Template +import androidx.core.graphics.drawable.IconCompat +import androidx.lifecycle.lifecycleScope +import de.autosugar.R +import de.autosugar.data.model.GlucoseEntry +import de.autosugar.data.model.GlucoseThresholds +import de.autosugar.data.model.GlucoseUnit +import de.autosugar.data.model.NightscoutProfile +import de.autosugar.data.repository.NightscoutRepository +import de.autosugar.data.storage.AppPreferencesDataStore +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +class GlucoseScreen( + carContext: CarContext, + private val repository: NightscoutRepository, + private val appPrefs: AppPreferencesDataStore, + private var activeProfileId: String, +) : Screen(carContext) { + + private var profiles: List = emptyList() + private var entry: GlucoseEntry? = null + private var history: List = emptyList() + private var lastFetchedMs: Long = 0L + private var isLoading = true + private var thresholds: GlucoseThresholds = GlucoseThresholds( + bgLow = 70, bgTargetBottom = 70, bgTargetTop = 180, bgHigh = 180, + ) + private var errorMessage: String? = null + private var pollingJob: Job? = null + + private val alertManager = GlucoseAlertManager(carContext) + private val alertCooldownMs = 15 * 60_000L + private var lastHighAlertMs = 0L + private var lastLowAlertMs = 0L + private var lastPredictedHighAlertMs = 0L + private var lastPredictedLowAlertMs = 0L + + init { + lifecycleScope.launch { + repository.profilesFlow.collect { updated -> + profiles = updated + invalidate() + } + } + + lifecycleScope.launch { + appPrefs.refreshIntervalSeconds.collect { intervalSeconds -> + pollingJob?.cancel() + pollingJob = lifecycleScope.launch { + while (isActive) { + fetch() + delay(intervalSeconds * 1000L) + } + } + } + } + } + + private suspend fun fetch() { + isLoading = entry == null + errorMessage = null + coroutineScope { + val entryResult = async { repository.getCurrentEntry(activeProfileId) } + val historyResult = async { repository.getHistory(activeProfileId, count = 36) } + val thresholdsResult = async { repository.getThresholds(activeProfileId) } + entryResult.await() + .onSuccess { result -> + entry = result + lastFetchedMs = System.currentTimeMillis() + isLoading = false + } + .onFailure { e -> + isLoading = false + errorMessage = e.message ?: carContext.getString(R.string.error_fetch_failed) + } + historyResult.await() + .onSuccess { h -> history = h.sortedBy { it.dateMs } } + thresholdsResult.await() + .onSuccess { t -> thresholds = t } + } + checkAlerts() + invalidate() + } + + private fun checkAlerts() { + val currentEntry = entry ?: return + val profile = profiles.find { it.id == activeProfileId } ?: return + if (!profile.alertsEnabled) return + + val sgv = currentEntry.sgv + val now = System.currentTimeMillis() + + if (sgv > thresholds.bgHigh && now - lastHighAlertMs > alertCooldownMs) { + alertManager.sendHighAlert(sgv, profile.unit) + lastHighAlertMs = now + } + if (sgv < thresholds.bgLow && now - lastLowAlertMs > alertCooldownMs) { + alertManager.sendLowAlert(sgv, profile.unit) + lastLowAlertMs = now + } + + val delta = currentEntry.delta ?: return + val projected15 = sgv + delta * 3 // 3 readings × ~5 min = 15 min ahead + + if (projected15 > thresholds.bgHigh && sgv <= thresholds.bgHigh && + now - lastPredictedHighAlertMs > alertCooldownMs + ) { + alertManager.sendPredictedHighAlert(projected15.toInt(), profile.unit) + lastPredictedHighAlertMs = now + } + if (projected15 < thresholds.bgLow && sgv >= thresholds.bgLow && + now - lastPredictedLowAlertMs > alertCooldownMs + ) { + alertManager.sendPredictedLowAlert(projected15.toInt(), profile.unit) + lastPredictedLowAlertMs = now + } + } + + override fun onGetTemplate(): Template { + return if (carContext.carAppApiLevel >= 6 && profiles.size in 2..4) { + buildTabTemplate() + } else { + buildPaneTemplate() + } + } + + // region TabTemplate (CarApi >= 6, 2–5 profiles) + + @RequiresCarApi(6) + private fun buildTabTemplate(): Template { + val activeProfile = profiles.find { it.id == activeProfileId } + val unit = activeProfile?.unit ?: GlucoseUnit.MG_DL + + val tabContents = TabContents.Builder( + PaneTemplate.Builder(buildPane(unit)) + .setTitle(activeProfile?.displayName ?: "") + .build() + ).build() + + val callback = object : TabTemplate.TabCallback { + override fun onTabSelected(contentId: String) = switchTo(contentId) + } + val builder = TabTemplate.Builder(callback) + .setHeaderAction(Action.APP_ICON) + .setActiveTabContentId(activeProfileId) + .setTabContents(tabContents) + + profiles.forEach { profile -> + builder.addTab( + Tab.Builder() + .setTitle(profile.displayName) + .setIcon(CarIcon.Builder( + IconCompat.createWithResource(carContext, profile.icon.resId) + ).build()) + .setContentId(profile.id) + .build() + ) + } + + return builder.build() + } + + // endregion + + // region PaneTemplate (fallback: 1 profile, >5 profiles, or CarApi < 6) + + private fun buildPaneTemplate(): Template { + val activeProfile = profiles.find { it.id == activeProfileId } + val unit = activeProfile?.unit ?: GlucoseUnit.MG_DL + val title = activeProfile?.displayName ?: carContext.getString(R.string.app_name) + + return PaneTemplate.Builder(buildPane(unit)) + .setTitle(title) + .setActionStrip(buildActionStrip()) + .build() + } + + private fun buildActionStrip(): ActionStrip { + val builder = ActionStrip.Builder() + return when { + profiles.size > 4 -> builder.addAction( + Action.Builder() + .setTitle(carContext.getString(R.string.action_switch_source)) + .setOnClickListener { + screenManager.push( + SourceSelectScreen(carContext, repository) { id -> switchTo(id) } + ) + } + .build() + ).build() + profiles.size in 2..5 -> { + // Numbered icon fallback when TabTemplate is unavailable + profiles.forEachIndexed { index, profile -> + builder.addAction( + Action.Builder() + .setIcon(profileNumberIcon(index + 1, profile.id == activeProfileId)) + .setOnClickListener { switchTo(profile.id) } + .build() + ) + } + builder.build() + } + else -> builder.addAction(Action.APP_ICON).build() + } + } + + private fun profileNumberIcon(number: Int, active: Boolean): CarIcon { + val size = 96 + val bmp = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val c = Canvas(bmp) + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = if (active) Color.WHITE else Color.argb(150, 200, 200, 200) + textSize = size * 0.65f + textAlign = Paint.Align.CENTER + typeface = Typeface.DEFAULT_BOLD + } + val textY = size / 2f - (paint.fontMetrics.ascent + paint.fontMetrics.descent) / 2f + c.drawText(number.toString(), size / 2f, textY, paint) + return CarIcon.Builder(IconCompat.createWithBitmap(bmp)).build() + } + + // endregion + + // region Pane content (shared by both templates) + + private fun buildPane(unit: GlucoseUnit): Pane = when { + isLoading -> Pane.Builder().setLoading(true).build() + + errorMessage != null -> Pane.Builder() + .addRow( + Row.Builder() + .setTitle(carContext.getString(R.string.error_fetch_failed)) + .addText(errorMessage ?: "") + .build() + ) + .addAction( + Action.Builder() + .setTitle(carContext.getString(R.string.action_retry)) + .setOnClickListener { lifecycleScope.launch { fetch() } } + .build() + ) + .build() + + else -> { + val e = entry!! + val now = System.currentTimeMillis() + val statsRow = Row.Builder() + .setTitle(carContext.getString(R.string.label_reading, ageString(now - e.dateMs))) + if (lastFetchedMs > 0) { + statsRow.addText(carContext.getString(R.string.label_received, ageString(now - lastFetchedMs))) + } + val pane = Pane.Builder() + .addRow( + Row.Builder() + .setTitle("${e.displayValue(unit)} ${unitLabel(unit)}") + .setImage(trendArrowIcon(e.direction), Row.IMAGE_TYPE_LARGE) + .addText("${e.displayDelta(unit) ?: "-"} ${unitLabel(unit)}") + .build() + ) + .addRow(statsRow.build()) + if (history.size >= 2) { + pane.setImage(graphIcon(history, unit, thresholds.bgTargetBottom.toFloat(), thresholds.bgTargetTop.toFloat())) + } + pane.build() + } + } + + private fun ageString(ageMs: Long): String { + val min = (ageMs / 60_000L).coerceAtLeast(0L).toInt() + return if (min < 1) carContext.getString(R.string.label_just_now) + else carContext.getString(R.string.label_n_min_ago, min) + } + + private fun graphIcon(entries: List, unit: GlucoseUnit, bgTargetBottom: Float, bgTargetTop: Float): CarIcon { + val w = 600 + val h = 400 + val pad = 16f + val plotW = w - 2 * pad + val plotH = h - 2 * pad + + val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bmp) + + val offset = (bgTargetTop - bgTargetBottom) * 0.25f + val yMin = minOf(entries.minOf { it.sgv }.toFloat(), bgTargetBottom - offset).coerceAtLeast(0f) + val yMax = maxOf(entries.maxOf { it.sgv }.toFloat(), bgTargetTop + offset) + val tMin = entries.first().dateMs + val tMax = entries.last().dateMs + val tRange = (tMax - tMin).coerceAtLeast(1L).toFloat() + + fun xOf(ms: Long) = pad + (ms - tMin).toFloat() / tRange * plotW + fun yOf(sgv: Float) = pad + plotH * (1f - (sgv.coerceIn(yMin, yMax) - yMin) / (yMax - yMin)) + + // Target range band + canvas.drawRect( + pad, yOf(bgTargetTop), pad + plotW, yOf(bgTargetBottom), + Paint().apply { color = Color.argb(55, 100, 220, 100); style = Paint.Style.FILL }, + ) + + val gridPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.argb(70, 200, 200, 200) + style = Paint.Style.STROKE + strokeWidth = 1.5f + pathEffect = DashPathEffect(floatArrayOf(8f, 8f), 0f) + } + // Labels are drawn inside the plot so they can't be cropped by the host's image slot + val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.argb(160, 200, 200, 200) + textSize = 22f + } + + // Horizontal grid lines + val yGridMgdl: List = when (unit) { + GlucoseUnit.MG_DL -> (1..7).map { it * 50f } // 50, 100, …, 350 + GlucoseUnit.MMOL_L -> (1..7).map { it * 3f * 18f } // 3, 6, …, 21 mmol/L → ×18 + } + val edgeMargin = 20f + for (sgv in yGridMgdl) { + val y = yOf(sgv) + if (y < pad + edgeMargin || y > pad + plotH - edgeMargin) continue + canvas.drawLine(pad, y, pad + plotW, y, gridPaint) + } + + if (tMax > tMin) { + val msPerHour = 3_600_000L + val ms20 = 20 * 60_000L + val ms30 = 30 * 60_000L + + // Horizontal line labels: only at full 100s (mg/dL) / every-other step (mmol/L), + // placed at the most recent :30 mark (~80% across for a 3-hour window). + val tMaxHour = (tMax / msPerHour) * msPerHour + // Prefer the :30 mark of the *previous* hour (e.g. 22:30 for a window ending at 23:xx) + // so labels never sit at the right edge of the graph. + val yLabelT = when { + tMaxHour - ms30 in tMin..tMax -> tMaxHour - ms30 + tMaxHour + ms30 in tMin..tMax -> tMaxHour + ms30 + else -> (tMin + tMax) / 2 + } + val yLabelX = xOf(yLabelT) + labelPaint.textAlign = Paint.Align.CENTER + yGridMgdl.forEachIndexed { i, sgv -> + val y = yOf(sgv) + if (y < pad + edgeMargin || y > pad + plotH - edgeMargin) return@forEachIndexed + // Show label every other line, starting at index 1 (100, 200, 300 for mg/dL) + if (i % 2 != 1) return@forEachIndexed + val label = when (unit) { + GlucoseUnit.MG_DL -> sgv.toInt().toString() + GlucoseUnit.MMOL_L -> "%.0f".format(sgv / 18f) + } + canvas.drawText(label, yLabelX, y - 5f, labelPaint) + } + + // Half-hour labels at the bottom (HH:30) — no vertical line + val tMinHour = (tMin / msPerHour) * msPerHour + var tHalf = tMinHour + ms30 + if (tHalf < tMin) tHalf += msPerHour + while (tHalf <= tMax) { + val cal = java.util.Calendar.getInstance().apply { timeInMillis = tHalf } + labelPaint.color = Color.argb(160, 200, 200, 200) + labelPaint.textAlign = Paint.Align.CENTER + canvas.drawText( + "%02d:30".format(cal.get(java.util.Calendar.HOUR_OF_DAY)), + xOf(tHalf), pad + plotH - 4f, labelPaint, + ) + tHalf += msPerHour + } + + // Vertical lines at full hours only; drop-pin labels at every 20-min mark. + val dropTailH = 10f + val dropBodyW = 46f + val dropBodyH = 28f + val dropCorner = 8f + val dropTailW = 14f + // Paints reused across marks + val dropFill = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } + val dropText = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.rgb(20, 20, 20) + textSize = 18f + textAlign = Paint.Align.CENTER + typeface = Typeface.DEFAULT_BOLD + } + // Minimum tipY: keep the whole drop within the plot area + val minTipY = pad + dropTailH + dropBodyH + 4f + + var t = (tMin / ms20 + 1) * ms20 + while (t <= tMax) { + val x = xOf(t) + val cal = java.util.Calendar.getInstance().apply { timeInMillis = t } + val minute = cal.get(java.util.Calendar.MINUTE) + + // Dotted vertical line + time label at full hours + if (minute == 0) { + canvas.drawLine(x, pad, x, pad + plotH, gridPaint) + labelPaint.color = Color.argb(160, 200, 200, 200) + labelPaint.textAlign = Paint.Align.CENTER + canvas.drawText( + "%02d:00".format(cal.get(java.util.Calendar.HOUR_OF_DAY)), + x, pad + plotH - 4f, labelPaint, + ) + } + + // Drop-pin at every 20-min mark + val closest = entries.minByOrNull { kotlin.math.abs(it.dateMs - t) } + if (closest != null) { + val valueLabel = when (unit) { + GlucoseUnit.MG_DL -> closest.sgv.toString() + GlucoseUnit.MMOL_L -> "%.1f".format(closest.sgv / 18.0) + } + dropFill.color = when { + closest.sgv < 70 -> Color.rgb(255, 80, 80) + closest.sgv > 180 -> Color.rgb(255, 200, 60) + else -> Color.WHITE + } + + val tipY = (yOf(closest.sgv.toFloat()) - 3f).coerceAtLeast(minTipY) + val bodyBottom = tipY - dropTailH + val bodyTop = bodyBottom - dropBodyH + val bL = x - dropBodyW / 2f + val bR = x + dropBodyW / 2f + val r = dropCorner + + val drop = Path().apply { + moveTo(bL + r, bodyTop) + lineTo(bR - r, bodyTop) + arcTo(RectF(bR - 2*r, bodyTop, bR, bodyTop + 2*r), -90f, 90f) + lineTo(bR, bodyBottom - r) + arcTo(RectF(bR - 2*r, bodyBottom - 2*r, bR, bodyBottom), 0f, 90f) + lineTo(x + dropTailW / 2f, bodyBottom) + lineTo(x, tipY) + lineTo(x - dropTailW / 2f, bodyBottom) + lineTo(bL + r, bodyBottom) + arcTo(RectF(bL, bodyBottom - 2*r, bL + 2*r, bodyBottom), 90f, 90f) + lineTo(bL, bodyTop + r) + arcTo(RectF(bL, bodyTop, bL + 2*r, bodyTop + 2*r), 180f, 90f) + close() + } + canvas.drawPath(drop, dropFill) + canvas.drawText(valueLabel, x, bodyTop + dropBodyH / 2f + dropText.textSize / 3f, dropText) + } + + t += ms20 + } + } + + // Colour-coded line + val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = 4f + strokeJoin = Paint.Join.ROUND + strokeCap = Paint.Cap.ROUND + } + for (i in 0 until entries.lastIndex) { + val sgv = entries[i].sgv + linePaint.color = when { + sgv < 70 -> Color.rgb(255, 80, 80) + sgv > 180 -> Color.rgb(255, 200, 60) + else -> Color.WHITE + } + canvas.drawLine( + xOf(entries[i].dateMs), yOf(sgv.toFloat()), + xOf(entries[i + 1].dateMs), yOf(entries[i + 1].sgv.toFloat()), + linePaint, + ) + } + + // Dot at newest reading + val last = entries.last() + canvas.drawCircle( + xOf(last.dateMs), yOf(last.sgv.toFloat()), 7f, + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = when { + last.sgv < 70 -> Color.rgb(255, 80, 80) + last.sgv > 180 -> Color.rgb(255, 200, 60) + else -> Color.WHITE + } + style = Paint.Style.FILL + }, + ) + + return CarIcon.Builder(IconCompat.createWithBitmap(bmp)).build() + } + + private fun trendArrowIcon(direction: String): CarIcon { + val size = 128 + val bmp = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bmp) + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + style = Paint.Style.FILL + } + val rotDeg = when (direction) { + "DoubleUp", "SingleUp" -> -90f + "FortyFiveUp" -> -45f + "Flat" -> 0f + "FortyFiveDown" -> 45f + "DoubleDown", "SingleDown" -> 90f + else -> 0f + } + canvas.save() + canvas.translate(size / 2f, size / 2f) + canvas.rotate(rotDeg) + canvas.drawPath(arrowPath(size * 0.38f), paint) + canvas.restore() + return CarIcon.Builder(IconCompat.createWithBitmap(bmp)).build() + } + + private fun arrowPath(r: Float): Path { + val hh = r * 0.52f + val sh = r * 0.20f + val hd = r * 0.50f + return Path().apply { + moveTo(r, 0f) + lineTo(r - hd, -hh) + lineTo(r - hd, -sh) + lineTo(-r, -sh) + lineTo(-r, sh) + lineTo(r - hd, sh) + lineTo(r - hd, hh) + close() + } + } + + private fun unitLabel(unit: GlucoseUnit): String = when (unit) { + GlucoseUnit.MG_DL -> carContext.getString(R.string.label_unit_mgdl) + GlucoseUnit.MMOL_L -> carContext.getString(R.string.label_unit_mmoll) + } + + // endregion + + private fun switchTo(profileId: String) { + if (profileId == activeProfileId) return + activeProfileId = profileId + repository.setActiveProfile(profileId) + entry = null + history = emptyList() + lastFetchedMs = 0L + isLoading = true + invalidate() + lifecycleScope.launch { fetch() } + } +} diff --git a/app/src/main/java/com/autosugar/car/NoProfilesScreen.kt b/app/src/main/java/de/autosugar/car/NoProfilesScreen.kt similarity index 62% rename from app/src/main/java/com/autosugar/car/NoProfilesScreen.kt rename to app/src/main/java/de/autosugar/car/NoProfilesScreen.kt index 80a448e..6dd6e59 100644 --- a/app/src/main/java/com/autosugar/car/NoProfilesScreen.kt +++ b/app/src/main/java/de/autosugar/car/NoProfilesScreen.kt @@ -1,32 +1,28 @@ -package com.autosugar.car +package de.autosugar.car import androidx.car.app.CarContext import androidx.car.app.Screen import androidx.car.app.model.MessageTemplate import androidx.car.app.model.Template -import com.autosugar.R -import com.autosugar.data.repository.NightscoutRepository -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel +import androidx.lifecycle.lifecycleScope +import de.autosugar.R +import de.autosugar.data.repository.NightscoutRepository +import de.autosugar.data.storage.AppPreferencesDataStore import kotlinx.coroutines.launch class NoProfilesScreen( carContext: CarContext, private val repository: NightscoutRepository, + private val appPrefs: AppPreferencesDataStore, ) : Screen(carContext) { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) - init { - // Watch for profiles being added on the phone side - scope.launch { + lifecycleScope.launch { repository.profilesFlow.collect { profiles -> if (profiles.isNotEmpty()) { val id = profiles.first().id repository.setActiveProfile(id) - screenManager.push(GlucoseScreen(carContext, repository, id)) + screenManager.push(GlucoseScreen(carContext, repository, appPrefs, id)) } } } @@ -35,8 +31,4 @@ class NoProfilesScreen( override fun onGetTemplate(): Template = MessageTemplate.Builder( carContext.getString(R.string.label_no_profiles) ).setTitle(carContext.getString(R.string.app_name)).build() - - override fun onDestroy() { - scope.cancel() - } } diff --git a/app/src/main/java/com/autosugar/car/SourceSelectScreen.kt b/app/src/main/java/de/autosugar/car/SourceSelectScreen.kt similarity index 75% rename from app/src/main/java/com/autosugar/car/SourceSelectScreen.kt rename to app/src/main/java/de/autosugar/car/SourceSelectScreen.kt index 032f979..bccaef8 100644 --- a/app/src/main/java/com/autosugar/car/SourceSelectScreen.kt +++ b/app/src/main/java/de/autosugar/car/SourceSelectScreen.kt @@ -1,4 +1,4 @@ -package com.autosugar.car +package de.autosugar.car import androidx.car.app.CarContext import androidx.car.app.Screen @@ -6,13 +6,10 @@ import androidx.car.app.model.ItemList import androidx.car.app.model.ListTemplate import androidx.car.app.model.Row import androidx.car.app.model.Template -import com.autosugar.R -import com.autosugar.data.model.NightscoutProfile -import com.autosugar.data.repository.NightscoutRepository -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel +import androidx.lifecycle.lifecycleScope +import de.autosugar.R +import de.autosugar.data.model.NightscoutProfile +import de.autosugar.data.repository.NightscoutRepository import kotlinx.coroutines.launch class SourceSelectScreen( @@ -21,11 +18,10 @@ class SourceSelectScreen( private val onProfileSelected: (String) -> Unit, ) : Screen(carContext) { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private var profiles: List = emptyList() init { - scope.launch { + lifecycleScope.launch { repository.profilesFlow.collect { updated -> profiles = updated invalidate() @@ -53,8 +49,4 @@ class SourceSelectScreen( .setHeaderAction(androidx.car.app.model.Action.BACK) .build() } - - override fun onDestroy() { - scope.cancel() - } } diff --git a/app/src/main/java/com/autosugar/data/model/GlucoseEntry.kt b/app/src/main/java/de/autosugar/data/model/GlucoseEntry.kt similarity index 91% rename from app/src/main/java/com/autosugar/data/model/GlucoseEntry.kt rename to app/src/main/java/de/autosugar/data/model/GlucoseEntry.kt index 3989241..9e5adfc 100644 --- a/app/src/main/java/com/autosugar/data/model/GlucoseEntry.kt +++ b/app/src/main/java/de/autosugar/data/model/GlucoseEntry.kt @@ -1,4 +1,4 @@ -package com.autosugar.data.model +package de.autosugar.data.model data class GlucoseEntry( /** Raw value always in mg/dL as returned by Nightscout. */ @@ -9,6 +9,8 @@ data class GlucoseEntry( val dateIso: String, /** Delta to the previous reading, in the same unit as sgv (mg/dL). Null if unavailable. */ val delta: Double?, + /** Unix timestamp in milliseconds — used for reliable chronological sorting. */ + val dateMs: Long = 0L, ) { /** Returns the display value converted to the requested unit. */ fun displayValue(unit: GlucoseUnit): String = when (unit) { diff --git a/app/src/main/java/de/autosugar/data/model/GlucoseThresholds.kt b/app/src/main/java/de/autosugar/data/model/GlucoseThresholds.kt new file mode 100644 index 0000000..fd02e98 --- /dev/null +++ b/app/src/main/java/de/autosugar/data/model/GlucoseThresholds.kt @@ -0,0 +1,14 @@ +package de.autosugar.data.model + +/** + * All four Nightscout threshold values, in mg/dL. + * + * bgLow / bgHigh are the alert boundaries (outside these = alert). + * bgTargetBottom / bgTargetTop are the target-range boundaries (used for the green graph band). + */ +data class GlucoseThresholds( + val bgLow: Int, + val bgTargetBottom: Int, + val bgTargetTop: Int, + val bgHigh: Int, +) diff --git a/app/src/main/java/de/autosugar/data/model/NightscoutProfile.kt b/app/src/main/java/de/autosugar/data/model/NightscoutProfile.kt new file mode 100644 index 0000000..57fc48a --- /dev/null +++ b/app/src/main/java/de/autosugar/data/model/NightscoutProfile.kt @@ -0,0 +1,38 @@ +package de.autosugar.data.model + +import androidx.annotation.DrawableRes +import de.autosugar.R +import java.util.UUID + +enum class GlucoseUnit { MG_DL, MMOL_L } + +enum class ProfileIcon(@DrawableRes val resId: Int) { + // People — generic + PERSON(R.drawable.ic_profile_person), + // People — adults + MAN(R.drawable.ic_profile_man), + WOMAN(R.drawable.ic_profile_woman), + // People — children + BOY(R.drawable.ic_profile_boy), + GIRL(R.drawable.ic_profile_girl), + BABY(R.drawable.ic_profile_baby), + // People — elderly + ELDERLY_MAN(R.drawable.ic_profile_elderly_man), + ELDERLY_WOMAN(R.drawable.ic_profile_elderly_woman), + // Other + HOME(R.drawable.ic_profile_home), + HEART(R.drawable.ic_profile_heart), + STAR(R.drawable.ic_profile_star), + CAR(R.drawable.ic_profile_car), + MEDICAL(R.drawable.ic_profile_medical), +} + +data class NightscoutProfile( + val id: String = UUID.randomUUID().toString(), + val displayName: String, + val baseUrl: String, + val apiToken: String, + val unit: GlucoseUnit = GlucoseUnit.MG_DL, + val icon: ProfileIcon = ProfileIcon.PERSON, + val alertsEnabled: Boolean = false, +) diff --git a/app/src/main/java/de/autosugar/data/network/NightscoutApi.kt b/app/src/main/java/de/autosugar/data/network/NightscoutApi.kt new file mode 100644 index 0000000..2ca7440 --- /dev/null +++ b/app/src/main/java/de/autosugar/data/network/NightscoutApi.kt @@ -0,0 +1,25 @@ +package de.autosugar.data.network + +import de.autosugar.data.network.dto.EntryDto +import de.autosugar.data.network.dto.StatusDto +import retrofit2.http.GET +import retrofit2.http.Query + +interface NightscoutApi { + @GET("api/v1/entries/current.json") + suspend fun getCurrentEntry( + @Query("token") token: String? = null, + @Query("count") count: Int = 2, + ): List + + @GET("api/v1/entries.json") + suspend fun getEntries( + @Query("token") token: String? = null, + @Query("count") count: Int = 24, + ): List + + @GET("api/v1/status.json") + suspend fun getStatus( + @Query("token") token: String? = null, + ): StatusDto +} diff --git a/app/src/main/java/com/autosugar/data/network/NightscoutApiFactory.kt b/app/src/main/java/de/autosugar/data/network/NightscoutApiFactory.kt similarity index 96% rename from app/src/main/java/com/autosugar/data/network/NightscoutApiFactory.kt rename to app/src/main/java/de/autosugar/data/network/NightscoutApiFactory.kt index 5f10b46..ca03b04 100644 --- a/app/src/main/java/com/autosugar/data/network/NightscoutApiFactory.kt +++ b/app/src/main/java/de/autosugar/data/network/NightscoutApiFactory.kt @@ -1,6 +1,6 @@ -package com.autosugar.data.network +package de.autosugar.data.network -import com.autosugar.BuildConfig +import de.autosugar.BuildConfig import com.squareup.moshi.Moshi import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import okhttp3.OkHttpClient diff --git a/app/src/main/java/com/autosugar/data/network/dto/EntryDto.kt b/app/src/main/java/de/autosugar/data/network/dto/EntryDto.kt similarity index 87% rename from app/src/main/java/com/autosugar/data/network/dto/EntryDto.kt rename to app/src/main/java/de/autosugar/data/network/dto/EntryDto.kt index 1465618..0c20795 100644 --- a/app/src/main/java/com/autosugar/data/network/dto/EntryDto.kt +++ b/app/src/main/java/de/autosugar/data/network/dto/EntryDto.kt @@ -1,4 +1,4 @@ -package com.autosugar.data.network.dto +package de.autosugar.data.network.dto import com.squareup.moshi.Json import com.squareup.moshi.JsonClass diff --git a/app/src/main/java/de/autosugar/data/network/dto/StatusDto.kt b/app/src/main/java/de/autosugar/data/network/dto/StatusDto.kt new file mode 100644 index 0000000..3b91727 --- /dev/null +++ b/app/src/main/java/de/autosugar/data/network/dto/StatusDto.kt @@ -0,0 +1,21 @@ +package de.autosugar.data.network.dto + +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class StatusDto( + val settings: SettingsDto?, +) + +@JsonClass(generateAdapter = true) +data class SettingsDto( + val thresholds: ThresholdsDto?, +) + +@JsonClass(generateAdapter = true) +data class ThresholdsDto( + val bgHigh: Int?, + val bgTargetTop: Int?, + val bgTargetBottom: Int?, + val bgLow: Int?, +) diff --git a/app/src/main/java/de/autosugar/data/repository/NightscoutRepository.kt b/app/src/main/java/de/autosugar/data/repository/NightscoutRepository.kt new file mode 100644 index 0000000..cde3277 --- /dev/null +++ b/app/src/main/java/de/autosugar/data/repository/NightscoutRepository.kt @@ -0,0 +1,123 @@ +package de.autosugar.data.repository + +import de.autosugar.data.model.GlucoseEntry +import de.autosugar.data.model.GlucoseThresholds +import de.autosugar.data.model.NightscoutProfile +import de.autosugar.data.network.NightscoutApiFactory +import de.autosugar.data.storage.ProfileDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NightscoutRepository @Inject constructor( + private val dataStore: ProfileDataStore, + private val apiFactory: NightscoutApiFactory, +) { + val profilesFlow: Flow> = dataStore.profilesFlow + + private val _activeProfileId = MutableStateFlow(null) + val activeProfileId: StateFlow = _activeProfileId.asStateFlow() + + fun setActiveProfile(id: String) { + _activeProfileId.value = id + } + + suspend fun testConnection(profile: NightscoutProfile): Result = runCatching { + val api = apiFactory.get(profile.baseUrl) + val entries = api.getCurrentEntry( + token = profile.apiToken.ifBlank { null }, + count = 2, + ) + val latest = entries.firstOrNull() ?: error("No entries returned") + val previous = entries.getOrNull(1) + val delta = previous?.let { (latest.sgv - it.sgv).toDouble() } + GlucoseEntry( + sgv = latest.sgv, + direction = latest.direction ?: "NOT COMPUTABLE", + dateIso = latest.dateString ?: latest.date.toString(), + delta = latest.delta ?: delta, + dateMs = latest.date, + ) + } + + suspend fun getCurrentEntry(profileId: String): Result = runCatching { + val profiles = dataStore.profilesFlow.first() + val profile = profiles.find { it.id == profileId } + ?: error("Profile $profileId not found") + + val api = apiFactory.get(profile.baseUrl) + val entries = api.getCurrentEntry( + token = profile.apiToken.ifBlank { null }, + count = 2, + ) + val latest = entries.firstOrNull() ?: error("No entries returned") + val previous = entries.getOrNull(1) + + val delta = previous?.let { (latest.sgv - it.sgv).toDouble() } + + GlucoseEntry( + sgv = latest.sgv, + direction = latest.direction ?: "NOT COMPUTABLE", + dateIso = latest.dateString ?: latest.date.toString(), + delta = latest.delta ?: delta, + dateMs = latest.date, + ) + } + + /** Returns all four Nightscout threshold values in mg/dL. */ + suspend fun getThresholds(profileId: String): Result = runCatching { + val profiles = dataStore.profilesFlow.first() + val profile = profiles.find { it.id == profileId } + ?: error("Profile $profileId not found") + val api = apiFactory.get(profile.baseUrl) + val t = api.getStatus(token = profile.apiToken.ifBlank { null }).settings?.thresholds + GlucoseThresholds( + bgLow = t?.bgLow ?: 70, + bgTargetBottom = t?.bgTargetBottom ?: error("bgTargetBottom not in status response"), + bgTargetTop = t?.bgTargetTop ?: error("bgTargetTop not in status response"), + bgHigh = t?.bgHigh ?: 180, + ) + } + + suspend fun getHistory(profileId: String, count: Int = 24): Result> = runCatching { + val profiles = dataStore.profilesFlow.first() + val profile = profiles.find { it.id == profileId } + ?: error("Profile $profileId not found") + + val api = apiFactory.get(profile.baseUrl) + api.getEntries(token = profile.apiToken.ifBlank { null }, count = count) + .map { dto -> + GlucoseEntry( + sgv = dto.sgv, + direction = dto.direction ?: "NOT COMPUTABLE", + dateIso = dto.dateString ?: dto.date.toString(), + delta = dto.delta, + dateMs = dto.date, + ) + } + } + + suspend fun saveProfile(profile: NightscoutProfile) { + val profiles = dataStore.profilesFlow.first().toMutableList() + val idx = profiles.indexOfFirst { it.id == profile.id } + if (idx >= 0) profiles[idx] = profile else profiles.add(profile) + dataStore.save(profiles) + // Invalidate cached API instance in case URL changed + apiFactory.invalidate(profile.baseUrl) + } + + suspend fun deleteProfile(id: String) { + val profiles = dataStore.profilesFlow.first().filter { it.id != id } + dataStore.save(profiles) + if (_activeProfileId.value == id) _activeProfileId.value = null + } + + suspend fun saveAll(profiles: List) { + dataStore.save(profiles) + } +} diff --git a/app/src/main/java/de/autosugar/data/storage/AppPreferencesDataStore.kt b/app/src/main/java/de/autosugar/data/storage/AppPreferencesDataStore.kt new file mode 100644 index 0000000..232b6f1 --- /dev/null +++ b/app/src/main/java/de/autosugar/data/storage/AppPreferencesDataStore.kt @@ -0,0 +1,31 @@ +package de.autosugar.data.storage + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +private val Context.appPrefsDataStore: DataStore by preferencesDataStore(name = "app_prefs") + +private val REFRESH_INTERVAL_KEY = intPreferencesKey("refresh_interval_seconds") + +@Singleton +class AppPreferencesDataStore @Inject constructor( + @ApplicationContext private val context: Context, +) { + val refreshIntervalSeconds: Flow = context.appPrefsDataStore.data + .map { prefs -> prefs[REFRESH_INTERVAL_KEY] ?: 60 } + + suspend fun setRefreshInterval(seconds: Int) { + context.appPrefsDataStore.edit { prefs -> + prefs[REFRESH_INTERVAL_KEY] = seconds + } + } +} diff --git a/app/src/main/java/com/autosugar/data/storage/ProfileDataStore.kt b/app/src/main/java/de/autosugar/data/storage/ProfileDataStore.kt similarity index 93% rename from app/src/main/java/com/autosugar/data/storage/ProfileDataStore.kt rename to app/src/main/java/de/autosugar/data/storage/ProfileDataStore.kt index 31999e6..266e1a3 100644 --- a/app/src/main/java/com/autosugar/data/storage/ProfileDataStore.kt +++ b/app/src/main/java/de/autosugar/data/storage/ProfileDataStore.kt @@ -1,4 +1,4 @@ -package com.autosugar.data.storage +package de.autosugar.data.storage import android.content.Context import androidx.datastore.core.DataStore @@ -6,7 +6,7 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore -import com.autosugar.data.model.NightscoutProfile +import de.autosugar.data.model.NightscoutProfile import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map diff --git a/app/src/main/java/com/autosugar/data/storage/ProfileSerializer.kt b/app/src/main/java/de/autosugar/data/storage/ProfileSerializer.kt similarity index 75% rename from app/src/main/java/com/autosugar/data/storage/ProfileSerializer.kt rename to app/src/main/java/de/autosugar/data/storage/ProfileSerializer.kt index f517102..fdf2ee6 100644 --- a/app/src/main/java/com/autosugar/data/storage/ProfileSerializer.kt +++ b/app/src/main/java/de/autosugar/data/storage/ProfileSerializer.kt @@ -1,6 +1,6 @@ -package com.autosugar.data.storage +package de.autosugar.data.storage -import com.autosugar.data.model.NightscoutProfile +import de.autosugar.data.model.NightscoutProfile import com.squareup.moshi.JsonClass import com.squareup.moshi.Moshi import com.squareup.moshi.Types @@ -34,6 +34,8 @@ internal data class NightscoutProfileJson( val baseUrl: String, val apiToken: String, val unit: String, + val icon: String = de.autosugar.data.model.ProfileIcon.PERSON.name, + val alertsEnabled: Boolean = false, ) private fun NightscoutProfile.toJson() = NightscoutProfileJson( @@ -42,6 +44,8 @@ private fun NightscoutProfile.toJson() = NightscoutProfileJson( baseUrl = baseUrl, apiToken = apiToken, unit = unit.name, + icon = icon.name, + alertsEnabled = alertsEnabled, ) private fun NightscoutProfileJson.toModel() = NightscoutProfile( @@ -49,5 +53,8 @@ private fun NightscoutProfileJson.toModel() = NightscoutProfile( displayName = displayName, baseUrl = baseUrl, apiToken = apiToken, - unit = com.autosugar.data.model.GlucoseUnit.valueOf(unit), + unit = de.autosugar.data.model.GlucoseUnit.valueOf(unit), + icon = runCatching { de.autosugar.data.model.ProfileIcon.valueOf(icon) } + .getOrDefault(de.autosugar.data.model.ProfileIcon.PERSON), + alertsEnabled = alertsEnabled, ) diff --git a/app/src/main/java/com/autosugar/ui/MainActivity.kt b/app/src/main/java/de/autosugar/ui/MainActivity.kt similarity index 91% rename from app/src/main/java/com/autosugar/ui/MainActivity.kt rename to app/src/main/java/de/autosugar/ui/MainActivity.kt index 728f9ae..4b1b035 100644 --- a/app/src/main/java/com/autosugar/ui/MainActivity.kt +++ b/app/src/main/java/de/autosugar/ui/MainActivity.kt @@ -1,4 +1,4 @@ -package com.autosugar.ui +package de.autosugar.ui import android.os.Bundle import androidx.activity.ComponentActivity @@ -6,9 +6,9 @@ import androidx.activity.compose.setContent import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController -import com.autosugar.ui.settings.ProfileEditScreen -import com.autosugar.ui.settings.SettingsScreen -import com.autosugar.ui.theme.AutoSugarTheme +import de.autosugar.ui.settings.ProfileEditScreen +import de.autosugar.ui.settings.SettingsScreen +import de.autosugar.ui.theme.AutoSugarTheme import dagger.hilt.android.AndroidEntryPoint private const val ROUTE_SETTINGS = "settings" diff --git a/app/src/main/java/de/autosugar/ui/settings/ProfileEditScreen.kt b/app/src/main/java/de/autosugar/ui/settings/ProfileEditScreen.kt new file mode 100644 index 0000000..7f0f26a --- /dev/null +++ b/app/src/main/java/de/autosugar/ui/settings/ProfileEditScreen.kt @@ -0,0 +1,295 @@ +package de.autosugar.ui.settings + +import android.Manifest +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +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.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +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.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import de.autosugar.R +import de.autosugar.data.model.GlucoseUnit +import de.autosugar.data.model.ProfileIcon + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProfileEditScreen( + profileId: String?, + onNavigateUp: () -> Unit, + viewModel: ProfileEditViewModel = hiltViewModel(), +) { + LaunchedEffect(profileId) { + profileId?.let { viewModel.loadProfile(it) } + } + + val uiState by viewModel.uiState.collectAsState() + val displayName by viewModel.displayName.collectAsState() + val baseUrl by viewModel.baseUrl.collectAsState() + val apiToken by viewModel.apiToken.collectAsState() + val unit by viewModel.unit.collectAsState() + val icon by viewModel.icon.collectAsState() + val alertsEnabled by viewModel.alertsEnabled.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + + val notificationPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { /* no-op: we gracefully handle missing permission in GlucoseAlertManager */ } + + LaunchedEffect(uiState) { + when (val state = uiState) { + is ProfileEditUiState.Saved -> onNavigateUp() + is ProfileEditUiState.Deleted -> onNavigateUp() + is ProfileEditUiState.TestSuccess -> { + snackbarHostState.showSnackbar("BG: ${state.message}") + viewModel.clearState() + } + is ProfileEditUiState.Error -> { + snackbarHostState.showSnackbar(state.message) + viewModel.clearState() + } + else -> Unit + } + } + + val isLoading = uiState is ProfileEditUiState.Loading + val isValidUrl = runCatching { + val uri = java.net.URI(baseUrl.trim()) + uri.scheme in listOf("http", "https") && + !uri.host.isNullOrEmpty() && + uri.host.contains('.') + }.getOrDefault(false) + val canSave = !isLoading && displayName.isNotBlank() && isValidUrl + + val title = if (profileId == null) { + stringResource(R.string.label_add_source) + } else { + stringResource(R.string.label_edit_source) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(title) }, + navigationIcon = { + IconButton(onClick = onNavigateUp) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + actions = { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier + .padding(horizontal = 16.dp) + .size(24.dp), + strokeWidth = 2.5.dp, + ) + } else { + IconButton( + onClick = { viewModel.save() }, + enabled = canSave, + ) { + Icon( + Icons.Default.Check, + contentDescription = stringResource(R.string.btn_save), + ) + } + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // ── Connection fields ───────────────────────────────────────── + OutlinedTextField( + value = displayName, + onValueChange = { viewModel.displayName.value = it }, + label = { Text(stringResource(R.string.hint_display_name)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading, + ) + OutlinedTextField( + value = baseUrl, + onValueChange = { viewModel.baseUrl.value = it }, + label = { Text(stringResource(R.string.hint_base_url)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + autoCorrectEnabled = false, + capitalization = KeyboardCapitalization.None, + imeAction = ImeAction.Next, + ), + isError = baseUrl.isNotBlank() && !isValidUrl, + supportingText = if (baseUrl.isNotBlank() && !isValidUrl) { + { Text(stringResource(R.string.error_invalid_url)) } + } else null, + enabled = !isLoading, + ) + OutlinedTextField( + value = apiToken, + onValueChange = { viewModel.apiToken.value = it }, + label = { Text(stringResource(R.string.hint_api_token)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + enabled = !isLoading, + ) + OutlinedButton( + onClick = { viewModel.testConnection() }, + enabled = !isLoading && baseUrl.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.btn_test_connection)) + } + + // ── Alerts ──────────────────────────────────────────────────── + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(stringResource(R.string.label_glucose_alerts)) + Switch( + checked = alertsEnabled, + onCheckedChange = { enabled -> + viewModel.alertsEnabled.value = enabled + if (enabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + }, + enabled = !isLoading, + ) + } + + // ── Tab icon ───────────────────────────────────────────────── + Text(stringResource(R.string.label_tab_icon)) + @OptIn(ExperimentalLayoutApi::class) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + ProfileIcon.entries.forEach { profileIcon -> + val selected = icon == profileIcon + Card( + onClick = { viewModel.icon.value = profileIcon }, + colors = CardDefaults.cardColors( + containerColor = if (selected) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surfaceVariant, + contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer + else MaterialTheme.colorScheme.onSurfaceVariant, + ), + modifier = Modifier.size(48.dp), + enabled = !isLoading, + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize(), + ) { + Icon( + painter = painterResource(profileIcon.resId), + contentDescription = profileIcon.name, + ) + } + } + } + } + + // ── Unit ───────────────────────────────────────────────────── + Text(stringResource(R.string.label_unit)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + GlucoseUnit.entries.forEach { u -> + FilterChip( + selected = unit == u, + onClick = { viewModel.unit.value = u }, + label = { + Text(when (u) { + GlucoseUnit.MG_DL -> "mg/dL" + GlucoseUnit.MMOL_L -> "mmol/L" + }) + }, + enabled = !isLoading, + ) + } + } + + // ── Delete (edit mode only) ─────────────────────────────────── + if (profileId != null) { + Spacer(Modifier.height(8.dp)) + OutlinedButton( + onClick = { viewModel.delete() }, + enabled = !isLoading, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.error), + ) { + Icon( + Icons.Default.Delete, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp), + ) + Text(stringResource(R.string.btn_delete)) + } + } + } + } +} diff --git a/app/src/main/java/com/autosugar/ui/settings/ProfileEditViewModel.kt b/app/src/main/java/de/autosugar/ui/settings/ProfileEditViewModel.kt similarity index 73% rename from app/src/main/java/com/autosugar/ui/settings/ProfileEditViewModel.kt rename to app/src/main/java/de/autosugar/ui/settings/ProfileEditViewModel.kt index 8d573e3..53aa1e8 100644 --- a/app/src/main/java/com/autosugar/ui/settings/ProfileEditViewModel.kt +++ b/app/src/main/java/de/autosugar/ui/settings/ProfileEditViewModel.kt @@ -1,10 +1,11 @@ -package com.autosugar.ui.settings +package de.autosugar.ui.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.autosugar.data.model.GlucoseUnit -import com.autosugar.data.model.NightscoutProfile -import com.autosugar.data.repository.NightscoutRepository +import de.autosugar.data.model.GlucoseUnit +import de.autosugar.data.model.NightscoutProfile +import de.autosugar.data.model.ProfileIcon +import de.autosugar.data.repository.NightscoutRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -19,6 +20,7 @@ sealed interface ProfileEditUiState { data class TestSuccess(val message: String) : ProfileEditUiState data class Error(val message: String) : ProfileEditUiState data object Saved : ProfileEditUiState + data object Deleted : ProfileEditUiState } @HiltViewModel @@ -30,6 +32,8 @@ class ProfileEditViewModel @Inject constructor( var baseUrl = MutableStateFlow("") var apiToken = MutableStateFlow("") var unit = MutableStateFlow(GlucoseUnit.MG_DL) + var icon = MutableStateFlow(ProfileIcon.PERSON) + var alertsEnabled = MutableStateFlow(false) private var editingId: String? = null @@ -44,6 +48,8 @@ class ProfileEditViewModel @Inject constructor( baseUrl.value = profile.baseUrl apiToken.value = profile.apiToken unit.value = profile.unit + icon.value = profile.icon + alertsEnabled.value = profile.alertsEnabled } } @@ -51,21 +57,17 @@ class ProfileEditViewModel @Inject constructor( _uiState.value = ProfileEditUiState.Loading val tempProfile = buildProfile() viewModelScope.launch { - // Save temporarily so the repository can fetch using it - repository.saveProfile(tempProfile) - repository.getCurrentEntry(tempProfile.id) + repository.testConnection(tempProfile) .onSuccess { entry -> _uiState.value = ProfileEditUiState.TestSuccess( - "${entry.displayValue(tempProfile.unit)} ${tempProfile.unit.name.replace("_", "/")}" + "${entry.displayValue(tempProfile.unit)} ${when (tempProfile.unit) { + GlucoseUnit.MG_DL -> "mg/dL" + GlucoseUnit.MMOL_L -> "mmol/L" + }}" ) - // Revert if we were just testing (don't keep the temp profile if it was new) - if (editingId == null) { - repository.deleteProfile(tempProfile.id) - } } .onFailure { e -> _uiState.value = ProfileEditUiState.Error(e.message ?: "Unknown error") - if (editingId == null) repository.deleteProfile(tempProfile.id) } } } @@ -78,6 +80,14 @@ class ProfileEditViewModel @Inject constructor( } } + fun delete() { + val id = editingId ?: return + viewModelScope.launch { + repository.deleteProfile(id) + _uiState.value = ProfileEditUiState.Deleted + } + } + fun clearState() { _uiState.value = ProfileEditUiState.Idle } @@ -88,5 +98,7 @@ class ProfileEditViewModel @Inject constructor( baseUrl = baseUrl.value.trim(), apiToken = apiToken.value.trim(), unit = unit.value, + icon = icon.value, + alertsEnabled = alertsEnabled.value, ) } diff --git a/app/src/main/java/de/autosugar/ui/settings/SettingsScreen.kt b/app/src/main/java/de/autosugar/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..6a1fca4 --- /dev/null +++ b/app/src/main/java/de/autosugar/ui/settings/SettingsScreen.kt @@ -0,0 +1,302 @@ +package de.autosugar.ui.settings + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +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.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import androidx.hilt.navigation.compose.hiltViewModel +import de.autosugar.BuildConfig +import de.autosugar.R +import de.autosugar.data.model.NightscoutProfile +import kotlin.math.roundToInt + +// Items before the profile list in the LazyColumn (RefreshSection + Divider) +private const val HEADER_COUNT = 2 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + onAddProfile: () -> Unit, + onEditProfile: (String) -> Unit, + viewModel: SettingsViewModel = hiltViewModel(), +) { + val profiles by viewModel.profiles.collectAsState() + val refreshInterval by viewModel.refreshIntervalSeconds.collectAsState() + + val lazyListState = rememberLazyListState() + val localProfiles = remember { mutableStateListOf() } + var isDragging by remember { mutableStateOf(false) } + + // Keep local list in sync with repository, but not during an active drag + LaunchedEffect(profiles) { + if (!isDragging) { + localProfiles.clear() + localProfiles.addAll(profiles) + } + } + + val dragState = remember(lazyListState) { + DragDropState(lazyListState, HEADER_COUNT) { from, to -> + val item = localProfiles.removeAt(from) + localProfiles.add(to, item) + } + } + + Scaffold( + topBar = { + TopAppBar(title = { Text(stringResource(R.string.label_settings)) }) + }, + floatingActionButton = { + FloatingActionButton(onClick = onAddProfile) { + Icon(Icons.Default.Add, contentDescription = stringResource(R.string.btn_add_profile)) + } + }, + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .pointerInput(Unit) { + detectDragGesturesAfterLongPress( + onDragStart = { offset -> + isDragging = true + dragState.onDragStart(offset.y) + }, + onDrag = { change, dragAmount -> + change.consume() + dragState.onDrag(dragAmount.y) + }, + onDragEnd = { + isDragging = false + dragState.onDragEnd() + viewModel.saveOrder(localProfiles.toList()) + }, + onDragCancel = { + isDragging = false + dragState.onDragEnd() + localProfiles.clear() + localProfiles.addAll(profiles) + }, + ) + } + ) { + LazyColumn( + state = lazyListState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + item { + RefreshIntervalSection( + currentSeconds = refreshInterval, + onSelect = { viewModel.setRefreshInterval(it) }, + ) + } + item { HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) } + if (localProfiles.isEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 32.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.label_no_sources), + style = MaterialTheme.typography.bodyLarge, + ) + } + } + } else { + itemsIndexed(localProfiles, key = { _, p -> p.id }) { index, profile -> + val isDraggingThis = index == dragState.draggingIndex + ProfileCard( + profile = profile, + isDragging = isDraggingThis, + modifier = Modifier + .zIndex(if (isDraggingThis) 1f else 0f) + .graphicsLayer { + translationY = if (isDraggingThis) dragState.dragOffset else 0f + shadowElevation = if (isDraggingThis) 16f else 0f + }, + onAlertsToggled = { enabled -> + viewModel.setAlertsEnabled(profile.id, enabled) + }, + onClick = { onEditProfile(profile.id) }, + ) + } + } + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.label_app_version, BuildConfig.VERSION_NAME), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} + +private val REFRESH_OPTIONS = listOf( + 30 to R.string.label_refresh_30s, + 60 to R.string.label_refresh_1min, + 120 to R.string.label_refresh_2min, + 300 to R.string.label_refresh_5min, +) + +@Composable +private fun RefreshIntervalSection(currentSeconds: Int, onSelect: (Int) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringResource(R.string.label_refresh_interval), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + REFRESH_OPTIONS.forEach { (seconds, labelRes) -> + FilterChip( + selected = currentSeconds == seconds, + onClick = { onSelect(seconds) }, + label = { Text(stringResource(labelRes)) }, + ) + } + } + } +} + +@Composable +private fun ProfileCard( + profile: NightscoutProfile, + isDragging: Boolean, + modifier: Modifier = Modifier, + onAlertsToggled: (Boolean) -> Unit, + onClick: () -> Unit, +) { + val elevation by animateDpAsState( + targetValue = if (isDragging) 8.dp else 0.dp, + label = "cardElevation", + ) + Card( + onClick = onClick, + modifier = modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = elevation), + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + painter = painterResource(profile.icon.resId), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = profile.displayName, + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = profile.baseUrl, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + Switch( + checked = profile.alertsEnabled, + onCheckedChange = onAlertsToggled, + ) + } + } +} + +private class DragDropState( + private val lazyListState: LazyListState, + private val headerCount: Int, + private val onSwap: (Int, Int) -> Unit, +) { + var draggingIndex by mutableStateOf(null) + private set + var dragOffset by mutableStateOf(0f) + private set + + fun onDragStart(touchY: Float) { + val item = lazyListState.layoutInfo.visibleItemsInfo + .filter { it.index >= headerCount } + .firstOrNull { touchY.roundToInt() in it.offset..(it.offset + it.size) } + draggingIndex = item?.let { it.index - headerCount } + dragOffset = 0f + } + + fun onDrag(dy: Float) { + val idx = draggingIndex ?: return + dragOffset += dy + val absoluteIdx = idx + headerCount + val dragged = lazyListState.layoutInfo.visibleItemsInfo + .firstOrNull { it.index == absoluteIdx } ?: return + val centerY = dragged.offset.toFloat() + dragOffset + dragged.size / 2f + val target = lazyListState.layoutInfo.visibleItemsInfo + .filter { it.index >= headerCount && it.index != absoluteIdx } + .firstOrNull { centerY.roundToInt() in it.offset..(it.offset + it.size) } + if (target != null) { + val targetIdx = target.index - headerCount + dragOffset += dragged.offset - target.offset + onSwap(idx, targetIdx) + draggingIndex = targetIdx + } + } + + fun onDragEnd() { + draggingIndex = null + dragOffset = 0f + } +} diff --git a/app/src/main/java/de/autosugar/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/autosugar/ui/settings/SettingsViewModel.kt new file mode 100644 index 0000000..2324cbc --- /dev/null +++ b/app/src/main/java/de/autosugar/ui/settings/SettingsViewModel.kt @@ -0,0 +1,43 @@ +package de.autosugar.ui.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import de.autosugar.data.model.NightscoutProfile +import de.autosugar.data.repository.NightscoutRepository +import de.autosugar.data.storage.AppPreferencesDataStore +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SettingsViewModel @Inject constructor( + private val repository: NightscoutRepository, + private val appPrefs: AppPreferencesDataStore, +) : ViewModel() { + + val profiles: StateFlow> = repository.profilesFlow + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + val refreshIntervalSeconds: StateFlow = appPrefs.refreshIntervalSeconds + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 60) + + fun setAlertsEnabled(id: String, enabled: Boolean) { + viewModelScope.launch { + val updated = profiles.value.map { p -> + if (p.id == id) p.copy(alertsEnabled = enabled) else p + } + repository.saveAll(updated) + } + } + + fun saveOrder(ordered: List) { + viewModelScope.launch { repository.saveAll(ordered) } + } + + fun setRefreshInterval(seconds: Int) { + viewModelScope.launch { appPrefs.setRefreshInterval(seconds) } + } +} diff --git a/app/src/main/java/com/autosugar/ui/theme/Theme.kt b/app/src/main/java/de/autosugar/ui/theme/Theme.kt similarity index 96% rename from app/src/main/java/com/autosugar/ui/theme/Theme.kt rename to app/src/main/java/de/autosugar/ui/theme/Theme.kt index 9f4c55f..a3cf62f 100644 --- a/app/src/main/java/com/autosugar/ui/theme/Theme.kt +++ b/app/src/main/java/de/autosugar/ui/theme/Theme.kt @@ -1,4 +1,4 @@ -package com.autosugar.ui.theme +package de.autosugar.ui.theme import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme diff --git a/app/src/main/res/drawable/autosugar.xml b/app/src/main/res/drawable/autosugar.xml new file mode 100644 index 0000000..6e3a98c --- /dev/null +++ b/app/src/main/res/drawable/autosugar.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000..467c2be --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_profile_baby.xml b/app/src/main/res/drawable/ic_profile_baby.xml new file mode 100644 index 0000000..6896273 --- /dev/null +++ b/app/src/main/res/drawable/ic_profile_baby.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_profile_boy.xml b/app/src/main/res/drawable/ic_profile_boy.xml new file mode 100644 index 0000000..0fabb66 --- /dev/null +++ b/app/src/main/res/drawable/ic_profile_boy.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_profile_car.xml b/app/src/main/res/drawable/ic_profile_car.xml new file mode 100644 index 0000000..af1b6cb --- /dev/null +++ b/app/src/main/res/drawable/ic_profile_car.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_profile_elderly_man.xml b/app/src/main/res/drawable/ic_profile_elderly_man.xml new file mode 100644 index 0000000..bd41bc1 --- /dev/null +++ b/app/src/main/res/drawable/ic_profile_elderly_man.xml @@ -0,0 +1,21 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_profile_elderly_woman.xml b/app/src/main/res/drawable/ic_profile_elderly_woman.xml new file mode 100644 index 0000000..07bcea1 --- /dev/null +++ b/app/src/main/res/drawable/ic_profile_elderly_woman.xml @@ -0,0 +1,22 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_profile_girl.xml b/app/src/main/res/drawable/ic_profile_girl.xml new file mode 100644 index 0000000..44ce00a --- /dev/null +++ b/app/src/main/res/drawable/ic_profile_girl.xml @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_profile_heart.xml b/app/src/main/res/drawable/ic_profile_heart.xml new file mode 100644 index 0000000..e79d8c3 --- /dev/null +++ b/app/src/main/res/drawable/ic_profile_heart.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_profile_home.xml b/app/src/main/res/drawable/ic_profile_home.xml new file mode 100644 index 0000000..0637edc --- /dev/null +++ b/app/src/main/res/drawable/ic_profile_home.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_profile_man.xml b/app/src/main/res/drawable/ic_profile_man.xml new file mode 100644 index 0000000..e81011e --- /dev/null +++ b/app/src/main/res/drawable/ic_profile_man.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_profile_medical.xml b/app/src/main/res/drawable/ic_profile_medical.xml new file mode 100644 index 0000000..0b91a28 --- /dev/null +++ b/app/src/main/res/drawable/ic_profile_medical.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_profile_person.xml b/app/src/main/res/drawable/ic_profile_person.xml new file mode 100644 index 0000000..1940f1e --- /dev/null +++ b/app/src/main/res/drawable/ic_profile_person.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_profile_star.xml b/app/src/main/res/drawable/ic_profile_star.xml new file mode 100644 index 0000000..5de119b --- /dev/null +++ b/app/src/main/res/drawable/ic_profile_star.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_profile_woman.xml b/app/src/main/res/drawable/ic_profile_woman.xml new file mode 100644 index 0000000..e3d5f7a --- /dev/null +++ b/app/src/main/res/drawable/ic_profile_woman.xml @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..800684b --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..4004c88 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..d26acc7 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..250fb35 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..233e51c Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..576b242 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 3087ff4..36f8852 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -13,6 +13,11 @@ Datenabruf fehlgeschlagen Wiederholen Keine Quellen konfiguriert.\nÖffne AutoSugar auf deinem Smartphone, um eine Nightscout-Quelle hinzuzufügen. + 3h + gerade eben + vor %d Min. + Messung: %s + Empfangen: %s Einstellungen @@ -32,4 +37,24 @@ Quelle gespeichert. Quelle gelöscht. Noch keine Quellen.\nTippe auf +, um deine Nightscout-URL hinzuzufügen. + Tab-Symbol + + + Glukose-Alarme + Glukose-Alarme + Alarme bei hohem und niedrigem Blutzucker mit 15-Minuten-Wiederholungsintervall + Hoher Blutzucker + Niedriger Blutzucker + Blutzucker steigt + Blutzucker fällt + Voraussichtlich in 15 Min.: %s + + + Aktualisierungsintervall + 30 Sek. + 1 Min. + 2 Min. + 5 Min. + Version %s + Muss mit http:// oder https:// beginnen diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..4e11a74 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + + + #302919 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dbbc6bd..2e6cce3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -32,4 +32,30 @@ Source saved. Source deleted. No sources yet.\nTap + to add your Nightscout URL. + + Tab Icon + 3h + just now + %d min ago + Reading: %s + Received: %s + + + Glucose alerts + Glucose Alerts + High and low blood glucose alerts with 15-minute repeat cooldown + High glucose + Low glucose + Glucose trending high + Glucose trending low + Predicted in 15 min: %s + + + Refresh Interval + 30 sec + 1 min + 2 min + 5 min + Version %s + Must start with http:// or https:// diff --git a/app/src/test/java/com/autosugar/data/repository/NightscoutRepositoryTest.kt b/app/src/test/java/com/autosugar/data/repository/NightscoutRepositoryTest.kt deleted file mode 100644 index 606ed98..0000000 --- a/app/src/test/java/com/autosugar/data/repository/NightscoutRepositoryTest.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.autosugar.data.repository - -import com.autosugar.data.model.GlucoseUnit -import com.autosugar.data.model.NightscoutProfile -import com.autosugar.data.network.NightscoutApi -import com.autosugar.data.network.NightscoutApiFactory -import com.autosugar.data.network.dto.EntryDto -import com.autosugar.data.storage.ProfileDataStore -import io.mockk.coEvery -import io.mockk.every -import io.mockk.mockk -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -class NightscoutRepositoryTest { - - private val profile = NightscoutProfile( - id = "test-id", - displayName = "Test", - baseUrl = "https://example.nightscout.io", - apiToken = "secret", - unit = GlucoseUnit.MG_DL, - ) - - private val mockApi = mockk() - private val mockDataStore = mockk() - private val mockFactory = mockk() - - private val repository = NightscoutRepository(mockDataStore, mockFactory) - - @Test - fun `getCurrentEntry maps dto to GlucoseEntry correctly`() = runTest { - every { mockDataStore.profilesFlow } returns flowOf(listOf(profile)) - every { mockFactory.get(any()) } returns mockApi - coEvery { mockApi.getCurrentEntry(any(), any()) } returns listOf( - EntryDto(sgv = 120, direction = "Flat", date = 1_000_000L, dateString = "2024-01-01T12:00:00Z", delta = -3.0), - ) - - val result = repository.getCurrentEntry("test-id") - assertTrue(result.isSuccess) - val entry = result.getOrThrow() - assertEquals(120, entry.sgv) - assertEquals("Flat", entry.direction) - assertEquals("→", entry.trendArrow) - assertEquals("-3", entry.displayDelta(GlucoseUnit.MG_DL)) - } - - @Test - fun `getCurrentEntry returns failure when profile not found`() = runTest { - every { mockDataStore.profilesFlow } returns flowOf(emptyList()) - - val result = repository.getCurrentEntry("nonexistent") - assertTrue(result.isFailure) - } - - @Test - fun `getCurrentEntry returns failure on network error`() = runTest { - every { mockDataStore.profilesFlow } returns flowOf(listOf(profile)) - every { mockFactory.get(any()) } returns mockApi - coEvery { mockApi.getCurrentEntry(any(), any()) } throws RuntimeException("timeout") - - val result = repository.getCurrentEntry("test-id") - assertTrue(result.isFailure) - assertEquals("timeout", result.exceptionOrNull()?.message) - } -} diff --git a/app/src/test/java/de/autosugar/data/model/GlucoseEntryTest.kt b/app/src/test/java/de/autosugar/data/model/GlucoseEntryTest.kt new file mode 100644 index 0000000..5722545 --- /dev/null +++ b/app/src/test/java/de/autosugar/data/model/GlucoseEntryTest.kt @@ -0,0 +1,128 @@ +package de.autosugar.data.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import java.util.Locale + +class GlucoseEntryTest { + + @Before + fun setUp() { + Locale.setDefault(Locale.US) + } + + private fun entry( + sgv: Int = 120, + direction: String = "Flat", + delta: Double? = null, + ) = GlucoseEntry( + sgv = sgv, + direction = direction, + dateIso = "2024-01-01T12:00:00Z", + delta = delta, + dateMs = 1_000_000L, + ) + + // region displayValue + + @Test + fun `displayValue returns raw sgv for mg_dL`() { + assertEquals("120", entry(sgv = 120).displayValue(GlucoseUnit.MG_DL)) + } + + @Test + fun `displayValue converts 180 mg_dL to 10_0 mmol_L`() { + assertEquals("10.0", entry(sgv = 180).displayValue(GlucoseUnit.MMOL_L)) + } + + @Test + fun `displayValue rounds mmol_L to one decimal place`() { + // 100 / 18.0 = 5.555… → 5.6 + assertEquals("5.6", entry(sgv = 100).displayValue(GlucoseUnit.MMOL_L)) + } + + @Test + fun `displayValue converts 90 mg_dL to 5_0 mmol_L`() { + assertEquals("5.0", entry(sgv = 90).displayValue(GlucoseUnit.MMOL_L)) + } + + // endregion + + // region displayDelta + + @Test + fun `displayDelta returns null when delta is null for mg_dL`() { + assertNull(entry(delta = null).displayDelta(GlucoseUnit.MG_DL)) + } + + @Test + fun `displayDelta returns null when delta is null for mmol_L`() { + assertNull(entry(delta = null).displayDelta(GlucoseUnit.MMOL_L)) + } + + @Test + fun `displayDelta prefixes positive delta with plus in mg_dL`() { + assertEquals("+5", entry(delta = 5.0).displayDelta(GlucoseUnit.MG_DL)) + } + + @Test + fun `displayDelta negative delta in mg_dL shows minus sign`() { + assertEquals("-3", entry(delta = -3.0).displayDelta(GlucoseUnit.MG_DL)) + } + + @Test + fun `displayDelta zero delta shows plus sign in mg_dL`() { + assertEquals("+0", entry(delta = 0.0).displayDelta(GlucoseUnit.MG_DL)) + } + + @Test + fun `displayDelta converts positive delta to mmol_L with one decimal`() { + // 5.4 / 18.0 = 0.3 + assertEquals("+0.3", entry(delta = 5.4).displayDelta(GlucoseUnit.MMOL_L)) + } + + @Test + fun `displayDelta converts negative delta to mmol_L with minus sign`() { + // -3.6 / 18.0 = -0.2 + assertEquals("-0.2", entry(delta = -3.6).displayDelta(GlucoseUnit.MMOL_L)) + } + + // endregion + + // region trendArrow + + @Test + fun `trendArrow maps all known Nightscout direction strings`() { + mapOf( + "DoubleUp" to "⇈", + "SingleUp" to "↑", + "FortyFiveUp" to "↗", + "Flat" to "→", + "FortyFiveDown" to "↘", + "SingleDown" to "↓", + "DoubleDown" to "⇊", + "NOT COMPUTABLE" to "?", + "RATE OUT OF RANGE" to "⚠", + ).forEach { (direction, expected) -> + assertEquals( + "Expected '$expected' for direction '$direction'", + expected, + entry(direction = direction).trendArrow, + ) + } + } + + @Test + fun `trendArrow returns dash for unknown direction`() { + assertEquals("-", entry(direction = "Unknown").trendArrow) + } + + @Test + fun `trendArrow returns dash for empty direction`() { + assertEquals("-", entry(direction = "").trendArrow) + } + + // endregion +} diff --git a/app/src/test/java/de/autosugar/data/repository/NightscoutRepositoryTest.kt b/app/src/test/java/de/autosugar/data/repository/NightscoutRepositoryTest.kt new file mode 100644 index 0000000..ad692a3 --- /dev/null +++ b/app/src/test/java/de/autosugar/data/repository/NightscoutRepositoryTest.kt @@ -0,0 +1,337 @@ +package de.autosugar.data.repository + +import de.autosugar.data.model.GlucoseUnit +import de.autosugar.data.model.NightscoutProfile +import de.autosugar.data.network.NightscoutApi +import de.autosugar.data.network.NightscoutApiFactory +import de.autosugar.data.network.dto.EntryDto +import de.autosugar.data.network.dto.SettingsDto +import de.autosugar.data.network.dto.StatusDto +import de.autosugar.data.network.dto.ThresholdsDto +import de.autosugar.data.storage.ProfileDataStore +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coJustRun +import io.mockk.coVerify +import io.mockk.every +import io.mockk.justRun +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class NightscoutRepositoryTest { + + private val profile = NightscoutProfile( + id = "test-id", + displayName = "Test", + baseUrl = "https://example.nightscout.io", + apiToken = "secret", + unit = GlucoseUnit.MG_DL, + ) + + private val mockApi = mockk() + private val mockDataStore = mockk() + private val mockFactory = mockk() + + private lateinit var repository: NightscoutRepository + + @Before + fun setUp() { + // profilesFlow is accessed during NightscoutRepository construction; provide a default + every { mockDataStore.profilesFlow } returns flowOf(emptyList()) + repository = NightscoutRepository(mockDataStore, mockFactory) + } + + // region getCurrentEntry + + @Test + fun `getCurrentEntry maps dto to GlucoseEntry correctly`() = runTest { + every { mockDataStore.profilesFlow } returns flowOf(listOf(profile)) + every { mockFactory.get(any()) } returns mockApi + coEvery { mockApi.getCurrentEntry(any(), any()) } returns listOf( + EntryDto(sgv = 120, direction = "Flat", date = 1_000_000L, dateString = "2024-01-01T12:00:00Z", delta = -3.0), + ) + + val result = repository.getCurrentEntry("test-id") + assertTrue(result.isSuccess) + val entry = result.getOrThrow() + assertEquals(120, entry.sgv) + assertEquals("Flat", entry.direction) + assertEquals("→", entry.trendArrow) + assertEquals("-3", entry.displayDelta(GlucoseUnit.MG_DL)) + } + + @Test + fun `getCurrentEntry dto delta takes priority over calculated delta`() = runTest { + every { mockDataStore.profilesFlow } returns flowOf(listOf(profile)) + every { mockFactory.get(any()) } returns mockApi + coEvery { mockApi.getCurrentEntry(any(), any()) } returns listOf( + EntryDto(sgv = 120, direction = "Flat", date = 1_000_000L, dateString = null, delta = -3.0), + EntryDto(sgv = 100, direction = "Flat", date = 900_000L, dateString = null, delta = null), + ) + + val entry = repository.getCurrentEntry("test-id").getOrThrow() + // dto.delta = -3.0 should win over calculated 120-100 = 20 + assertEquals(-3.0, entry.delta) + } + + @Test + fun `getCurrentEntry calculates delta from two entries when dto delta is null`() = runTest { + every { mockDataStore.profilesFlow } returns flowOf(listOf(profile)) + every { mockFactory.get(any()) } returns mockApi + coEvery { mockApi.getCurrentEntry(any(), any()) } returns listOf( + EntryDto(sgv = 120, direction = "Flat", date = 1_000_000L, dateString = null, delta = null), + EntryDto(sgv = 110, direction = "Flat", date = 900_000L, dateString = null, delta = null), + ) + + val entry = repository.getCurrentEntry("test-id").getOrThrow() + assertEquals(10.0, entry.delta) + } + + @Test + fun `getCurrentEntry uses date toString when dateString is null`() = runTest { + every { mockDataStore.profilesFlow } returns flowOf(listOf(profile)) + every { mockFactory.get(any()) } returns mockApi + coEvery { mockApi.getCurrentEntry(any(), any()) } returns listOf( + EntryDto(sgv = 120, direction = "Flat", date = 1_000_000L, dateString = null, delta = null), + ) + + val entry = repository.getCurrentEntry("test-id").getOrThrow() + assertEquals("1000000", entry.dateIso) + } + + @Test + fun `getCurrentEntry falls back to NOT COMPUTABLE when direction is null`() = runTest { + every { mockDataStore.profilesFlow } returns flowOf(listOf(profile)) + every { mockFactory.get(any()) } returns mockApi + coEvery { mockApi.getCurrentEntry(any(), any()) } returns listOf( + EntryDto(sgv = 120, direction = null, date = 1_000_000L, dateString = null, delta = null), + ) + + val entry = repository.getCurrentEntry("test-id").getOrThrow() + assertEquals("NOT COMPUTABLE", entry.direction) + } + + @Test + fun `getCurrentEntry returns failure when profile not found`() = runTest { + every { mockDataStore.profilesFlow } returns flowOf(emptyList()) + + val result = repository.getCurrentEntry("nonexistent") + assertTrue(result.isFailure) + } + + @Test + fun `getCurrentEntry returns failure on network error`() = runTest { + every { mockDataStore.profilesFlow } returns flowOf(listOf(profile)) + every { mockFactory.get(any()) } returns mockApi + coEvery { mockApi.getCurrentEntry(any(), any()) } throws RuntimeException("timeout") + + val result = repository.getCurrentEntry("test-id") + assertTrue(result.isFailure) + assertEquals("timeout", result.exceptionOrNull()?.message) + } + + @Test + fun `getCurrentEntry returns failure when entries list is empty`() = runTest { + every { mockDataStore.profilesFlow } returns flowOf(listOf(profile)) + every { mockFactory.get(any()) } returns mockApi + coEvery { mockApi.getCurrentEntry(any(), any()) } returns emptyList() + + val result = repository.getCurrentEntry("test-id") + assertTrue(result.isFailure) + } + + // endregion + + // region getThresholds + + @Test + fun `getThresholds maps all threshold values from status response`() = runTest { + every { mockDataStore.profilesFlow } returns flowOf(listOf(profile)) + every { mockFactory.get(any()) } returns mockApi + coEvery { mockApi.getStatus(any()) } returns StatusDto( + settings = SettingsDto( + thresholds = ThresholdsDto(bgHigh = 180, bgTargetTop = 160, bgTargetBottom = 80, bgLow = 70), + ), + ) + + val thresholds = repository.getThresholds("test-id").getOrThrow() + assertEquals(70, thresholds.bgLow) + assertEquals(80, thresholds.bgTargetBottom) + assertEquals(160, thresholds.bgTargetTop) + assertEquals(180, thresholds.bgHigh) + } + + @Test + fun `getThresholds uses default bgLow and bgHigh when null in response`() = runTest { + every { mockDataStore.profilesFlow } returns flowOf(listOf(profile)) + every { mockFactory.get(any()) } returns mockApi + coEvery { mockApi.getStatus(any()) } returns StatusDto( + settings = SettingsDto( + thresholds = ThresholdsDto(bgHigh = null, bgTargetTop = 160, bgTargetBottom = 80, bgLow = null), + ), + ) + + val thresholds = repository.getThresholds("test-id").getOrThrow() + assertEquals(70, thresholds.bgLow) + assertEquals(180, thresholds.bgHigh) + } + + @Test + fun `getThresholds returns failure when bgTargetBottom is missing`() = runTest { + every { mockDataStore.profilesFlow } returns flowOf(listOf(profile)) + every { mockFactory.get(any()) } returns mockApi + coEvery { mockApi.getStatus(any()) } returns StatusDto(settings = null) + + val result = repository.getThresholds("test-id") + assertTrue(result.isFailure) + } + + @Test + fun `getThresholds returns failure when profile not found`() = runTest { + every { mockDataStore.profilesFlow } returns flowOf(emptyList()) + + val result = repository.getThresholds("nonexistent") + assertTrue(result.isFailure) + } + + // endregion + + // region getHistory + + @Test + fun `getHistory maps list of DTOs to GlucoseEntry list`() = runTest { + every { mockDataStore.profilesFlow } returns flowOf(listOf(profile)) + every { mockFactory.get(any()) } returns mockApi + coEvery { mockApi.getEntries(any(), any()) } returns listOf( + EntryDto(sgv = 120, direction = "Flat", date = 1_000_000L, dateString = "2024-01-01T12:00:00Z", delta = -2.0), + EntryDto(sgv = 115, direction = "FortyFiveDown", date = 900_000L, dateString = null, delta = null), + ) + + val history = repository.getHistory("test-id").getOrThrow() + assertEquals(2, history.size) + assertEquals(120, history[0].sgv) + assertEquals("Flat", history[0].direction) + assertEquals(-2.0, history[0].delta) + assertEquals(115, history[1].sgv) + assertNull(history[1].delta) + } + + @Test + fun `getHistory returns failure when profile not found`() = runTest { + every { mockDataStore.profilesFlow } returns flowOf(emptyList()) + + val result = repository.getHistory("nonexistent") + assertTrue(result.isFailure) + } + + // endregion + + // region saveProfile + + @Test + fun `saveProfile adds new profile when id not in list`() = runTest { + every { mockDataStore.profilesFlow } returns flowOf(emptyList()) + val savedSlot = slot>() + coEvery { mockDataStore.save(capture(savedSlot)) } just Runs + justRun { mockFactory.invalidate(any()) } + + repository.saveProfile(profile) + + assertEquals(1, savedSlot.captured.size) + assertEquals(profile, savedSlot.captured[0]) + } + + @Test + fun `saveProfile updates existing profile in place`() = runTest { + val updated = profile.copy(displayName = "Updated") + every { mockDataStore.profilesFlow } returns flowOf(listOf(profile)) + val savedSlot = slot>() + coEvery { mockDataStore.save(capture(savedSlot)) } just Runs + justRun { mockFactory.invalidate(any()) } + + repository.saveProfile(updated) + + assertEquals(1, savedSlot.captured.size) + assertEquals("Updated", savedSlot.captured[0].displayName) + } + + @Test + fun `saveProfile invalidates api factory cache`() = runTest { + every { mockDataStore.profilesFlow } returns flowOf(listOf(profile)) + coJustRun { mockDataStore.save(any()) } + justRun { mockFactory.invalidate(any()) } + + repository.saveProfile(profile) + + verify { mockFactory.invalidate(profile.baseUrl) } + } + + // endregion + + // region deleteProfile + + @Test + fun `deleteProfile removes profile from dataStore`() = runTest { + every { mockDataStore.profilesFlow } returns flowOf(listOf(profile)) + val savedSlot = slot>() + coEvery { mockDataStore.save(capture(savedSlot)) } just Runs + + repository.deleteProfile("test-id") + + assertTrue(savedSlot.captured.isEmpty()) + } + + @Test + fun `deleteProfile clears activeProfileId when deleted profile was active`() = runTest { + every { mockDataStore.profilesFlow } returns flowOf(listOf(profile)) + coJustRun { mockDataStore.save(any()) } + repository.setActiveProfile("test-id") + + repository.deleteProfile("test-id") + + assertNull(repository.activeProfileId.value) + } + + @Test + fun `deleteProfile does not clear activeProfileId when different profile is deleted`() = runTest { + val other = profile.copy(id = "other-id", displayName = "Other") + every { mockDataStore.profilesFlow } returns flowOf(listOf(profile, other)) + coJustRun { mockDataStore.save(any()) } + repository.setActiveProfile("test-id") + + repository.deleteProfile("other-id") + + assertEquals("test-id", repository.activeProfileId.value) + } + + // endregion + + // region setActiveProfile / saveAll + + @Test + fun `setActiveProfile updates activeProfileId state`() { + repository.setActiveProfile("test-id") + assertEquals("test-id", repository.activeProfileId.value) + } + + @Test + fun `saveAll delegates to dataStore save`() = runTest { + val profiles = listOf(profile) + coJustRun { mockDataStore.save(any()) } + + repository.saveAll(profiles) + + coVerify { mockDataStore.save(profiles) } + } + + // endregion +} diff --git a/app/src/test/java/de/autosugar/data/storage/ProfileSerializerTest.kt b/app/src/test/java/de/autosugar/data/storage/ProfileSerializerTest.kt new file mode 100644 index 0000000..f505f5f --- /dev/null +++ b/app/src/test/java/de/autosugar/data/storage/ProfileSerializerTest.kt @@ -0,0 +1,76 @@ +package de.autosugar.data.storage + +import de.autosugar.data.model.GlucoseUnit +import de.autosugar.data.model.NightscoutProfile +import de.autosugar.data.model.ProfileIcon +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ProfileSerializerTest { + + private val serializer = ProfileSerializer() + + private val profile = NightscoutProfile( + id = "test-id", + displayName = "Test Profile", + baseUrl = "https://example.nightscout.io", + apiToken = "secret", + unit = GlucoseUnit.MG_DL, + icon = ProfileIcon.PERSON, + alertsEnabled = false, + ) + + @Test + fun `round-trip preserves all fields`() { + val result = serializer.fromJson(serializer.toJson(listOf(profile))) + assertEquals(1, result.size) + assertEquals(profile, result[0]) + } + + @Test + fun `round-trip preserves alertsEnabled true`() { + val withAlerts = profile.copy(alertsEnabled = true) + val result = serializer.fromJson(serializer.toJson(listOf(withAlerts))) + assertTrue(result[0].alertsEnabled) + } + + @Test + fun `round-trip preserves mmol_L unit`() { + val mmol = profile.copy(unit = GlucoseUnit.MMOL_L) + val result = serializer.fromJson(serializer.toJson(listOf(mmol))) + assertEquals(GlucoseUnit.MMOL_L, result[0].unit) + } + + @Test + fun `round-trip preserves non-default icon`() { + val withIcon = profile.copy(icon = ProfileIcon.HEART) + val result = serializer.fromJson(serializer.toJson(listOf(withIcon))) + assertEquals(ProfileIcon.HEART, result[0].icon) + } + + @Test + fun `fromJson falls back to PERSON for unknown icon`() { + val json = """[{"id":"x","displayName":"X","baseUrl":"http://x.test","apiToken":"","unit":"MG_DL","icon":"INVALID_ICON","alertsEnabled":false}]""" + val result = serializer.fromJson(json) + assertEquals(ProfileIcon.PERSON, result[0].icon) + } + + @Test + fun `fromJson returns empty list for empty array`() { + assertTrue(serializer.fromJson("[]").isEmpty()) + } + + @Test + fun `toJson and fromJson handle multiple profiles`() { + val profiles = listOf( + profile, + profile.copy(id = "id-2", displayName = "Profile 2", unit = GlucoseUnit.MMOL_L, icon = ProfileIcon.HEART), + ) + val result = serializer.fromJson(serializer.toJson(profiles)) + assertEquals(2, result.size) + assertEquals("id-2", result[1].id) + assertEquals(GlucoseUnit.MMOL_L, result[1].unit) + assertEquals(ProfileIcon.HEART, result[1].icon) + } +} diff --git a/docs/CNAME b/docs/CNAME new file mode 100644 index 0000000..6fb6d42 --- /dev/null +++ b/docs/CNAME @@ -0,0 +1 @@ +autosugar.de diff --git a/docs/_config.yml b/docs/_config.yml new file mode 100644 index 0000000..ad7db23 --- /dev/null +++ b/docs/_config.yml @@ -0,0 +1,7 @@ +title: AutoSugar +description: Blood glucose monitoring for Android Auto +url: "https://autosugar.de" +theme: minima + +minima: + skin: dark diff --git a/docs/adr/0001-architecture-and-multi-source-strategy.md b/docs/adr/0001-architecture-and-multi-source-strategy.md index 22e2f66..beb2c09 100644 --- a/docs/adr/0001-architecture-and-multi-source-strategy.md +++ b/docs/adr/0001-architecture-and-multi-source-strategy.md @@ -4,7 +4,7 @@ Accepted ## Context -The user requires a solution to monitor blood glucose levels via Android Auto. The solution must support a variable number of sources (1 to N) and be available in multiple languages (starting with English and German). Due to the medical nature and unofficial API usage, the app will be distributed via sideloading. +The user requires a solution to monitor blood glucose levels via Android Auto. The solution must support a variable number of sources (1 to N) and be available in multiple languages (starting with English and German). The app is distributed via the Google Play Store. Sideloading from GitHub Releases remains supported for development and testing purposes. ## Decision 1. **App Category:** We will use the IoT category (`androidx.car.app.category.IOT`). This allows the app to stay active in the background and coexist with Google Maps in split-screen layouts. @@ -17,4 +17,4 @@ The user requires a solution to monitor blood glucose levels via Android Auto. T ## Consequences * **Pros:** Scalable for families with multiple T1D members; compliant with Android Auto's "Coolwalk" multi-tasking; easier for international users. -* **Cons:** UI complexity increases as the number of sources grows (templates have limits on button counts). Sideloading requires manual updates. +* **Cons:** UI complexity increases as the number of sources grows (templates have limits on button counts). diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..92e9263 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,21 @@ +--- +layout: home +title: AutoSugar +--- + +# AutoSugar + +Blood glucose monitoring for Android Auto — see your Nightscout CGM data at a glance, right on your car's display. + +[![Get it on Google Play](https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png)](https://play.google.com/store/apps/details?id=de.autosugar){:height="60px"} + +## Features + +- Monitor one or multiple Nightscout sources (e.g. yourself and your children) +- Current glucose value, trend arrow, and delta +- Configurable glucose alerts +- English and German UI + +## Source code + +AutoSugar is open source. Find the code and report issues on [GitHub](https://github.com/EarMaster/AutoSugar). diff --git a/docs/privacy-policy.md b/docs/privacy-policy.md new file mode 100644 index 0000000..04bcb55 --- /dev/null +++ b/docs/privacy-policy.md @@ -0,0 +1,48 @@ +--- +layout: page +title: Privacy Policy +permalink: /privacy-policy/ +--- + +*Last updated: 2026-03-31* + +AutoSugar ("the app") is developed and maintained by Nico Wiedemann. This policy explains what data the app accesses and how it is handled. + +## Summary + +AutoSugar does not collect, store, or share any personal data with the developer or any third party. All data stays on your device or travels directly to the Nightscout servers you configure. + +## Data the app accesses + +### Nightscout credentials +The app stores the Nightscout URLs and API tokens you enter in the settings. This data is saved locally on your device using Android's DataStore and is never transmitted anywhere other than to the Nightscout instance you configured. + +### Glucose data +The app fetches blood glucose readings from the Nightscout URLs you provide. This data is displayed on screen and used to trigger alerts. It is not stored persistently and is not sent to any third party. + +### Notifications +If you enable glucose alerts, the app uses Android's notification system to alert you when readings go out of range. No notification content is shared with anyone. + +## Data the app does not collect + +- No analytics or usage tracking +- No crash reporting +- No advertising identifiers +- No location data +- No data is ever sent to the developer or any third party + +## Third-party services + +The app communicates exclusively with the Nightscout instances you configure yourself. The privacy practices of your Nightscout instance are governed by whoever operates it. + +## Data retention + +All settings are stored locally on your device. Uninstalling the app removes all stored data. + +## Changes to this policy + +If this policy changes, the updated version will be published at this URL with a new "Last updated" date. + +## Contact + +Questions about this policy can be directed to [autosugar@wiedemann.rocks](mailto:autosugar@wiedemann.rocks). diff --git a/docs/screenshots/android-auto-glucose-monitor-profile-1.png b/docs/screenshots/android-auto-glucose-monitor-profile-1.png new file mode 100644 index 0000000..939a15a Binary files /dev/null and b/docs/screenshots/android-auto-glucose-monitor-profile-1.png differ diff --git a/docs/screenshots/android-auto-glucose-monitor-profile-2.png b/docs/screenshots/android-auto-glucose-monitor-profile-2.png new file mode 100644 index 0000000..d08314d Binary files /dev/null and b/docs/screenshots/android-auto-glucose-monitor-profile-2.png differ diff --git a/docs/screenshots/android-settings-edit-profile.png b/docs/screenshots/android-settings-edit-profile.png new file mode 100644 index 0000000..230e483 Binary files /dev/null and b/docs/screenshots/android-settings-edit-profile.png differ diff --git a/docs/screenshots/android-settings-list-profiles.png b/docs/screenshots/android-settings-list-profiles.png new file mode 100644 index 0000000..279495c Binary files /dev/null and b/docs/screenshots/android-settings-list-profiles.png differ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b53864a..f297a06 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.5.2" +agp = "8.13.2" kotlin = "2.0.21" compose-bom = "2024.09.03" car-app = "1.4.0" diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..d997cfc Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..37f853b --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..739907d --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..e509b2d --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega