Compare commits
No commits in common. "main" and "v0.1.0" have entirely different histories.
|
|
@ -1,167 +0,0 @@
|
||||||
name: Build and Publish Alpha Releases
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-android:
|
|
||||||
name: Build Android APK
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout Repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Java 17
|
|
||||||
uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: 'temurin'
|
|
||||||
java-version: '17'
|
|
||||||
|
|
||||||
- name: Set up Flutter
|
|
||||||
uses: subosito/flutter-action@v2
|
|
||||||
with:
|
|
||||||
flutter-version: '3.47.x'
|
|
||||||
channel: 'stable'
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Install Dependencies
|
|
||||||
run: flutter pub get
|
|
||||||
|
|
||||||
- name: Run Code Generation
|
|
||||||
run: dart run build_runner build --delete-conflicting-outputs
|
|
||||||
|
|
||||||
- name: Decode Android Keystore & Key Properties
|
|
||||||
env:
|
|
||||||
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
|
||||||
KEY_PROPERTIES: ${{ secrets.ANDROID_KEY_PROPERTIES }}
|
|
||||||
run: |
|
|
||||||
if [ -n "$KEYSTORE_BASE64" ]; then
|
|
||||||
echo "$KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks
|
|
||||||
echo "$KEY_PROPERTIES" > android/key.properties
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Build Split APKs
|
|
||||||
run: flutter build apk --release --split-per-abi
|
|
||||||
|
|
||||||
- name: Rename and Stage APK
|
|
||||||
run: |
|
|
||||||
mkdir -p dist
|
|
||||||
TAG_NAME=${GITHUB_REF#refs/tags/}
|
|
||||||
cp build/app/outputs/flutter-apk/app-arm64-v8a-release.apk "dist/GLP1Companion-Android-arm64-${TAG_NAME}.apk"
|
|
||||||
|
|
||||||
- name: Upload Artifact for Release
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: android-release-apk
|
|
||||||
path: dist/*.apk
|
|
||||||
retention-days: 1
|
|
||||||
|
|
||||||
build-windows:
|
|
||||||
name: Build Windows MSIX & Portable
|
|
||||||
runs-on: windows-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout Repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Flutter
|
|
||||||
uses: subosito/flutter-action@v2
|
|
||||||
with:
|
|
||||||
flutter-version: '3.47.x'
|
|
||||||
channel: 'stable'
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Install Dependencies
|
|
||||||
shell: pwsh
|
|
||||||
run: flutter pub get
|
|
||||||
|
|
||||||
- name: Run Code Generation
|
|
||||||
shell: pwsh
|
|
||||||
run: dart run build_runner build --delete-conflicting-outputs
|
|
||||||
|
|
||||||
- name: Build Windows Native Release
|
|
||||||
shell: pwsh
|
|
||||||
run: flutter build windows --release
|
|
||||||
|
|
||||||
- name: Generate MSIX Installer
|
|
||||||
shell: pwsh
|
|
||||||
run: dart run msix:create --install-certificate false
|
|
||||||
|
|
||||||
- name: Package Portable Zip & Stage MSIX
|
|
||||||
shell: pwsh
|
|
||||||
run: |
|
|
||||||
New-Item -ItemType Directory -Force -Path dist | Out-Null
|
|
||||||
$tag = $env:GITHUB_REF -replace 'refs/tags/', ''
|
|
||||||
|
|
||||||
# Copy MSIX
|
|
||||||
Copy-Item "build\windows\x64\runner\Release\*.msix" "dist\GLP1Companion-Windows-${tag}.msix" -Force
|
|
||||||
|
|
||||||
# Compress standalone runner folder
|
|
||||||
Compress-Archive -Path "build\windows\x64\runner\Release\*" -DestinationPath "dist\GLP1Companion-Windows-x64-${tag}-Portable.zip" -Force
|
|
||||||
|
|
||||||
- name: Upload Artifacts for Release
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: windows-release-packages
|
|
||||||
path: dist/*
|
|
||||||
retention-days: 1
|
|
||||||
|
|
||||||
publish-release:
|
|
||||||
name: Attach Assets to Forgejo Release
|
|
||||||
needs: [build-android, build-windows]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout Repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Download Staged Artifacts
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
path: staged-assets
|
|
||||||
|
|
||||||
- name: Create Forgejo Release and Upload Assets
|
|
||||||
env:
|
|
||||||
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
TAG_NAME=${GITHUB_REF#refs/tags/}
|
|
||||||
REPO="${{ github.repository }}"
|
|
||||||
SERVER="${{ github.server_url }}"
|
|
||||||
|
|
||||||
echo "Target Release: $TAG_NAME on $REPO"
|
|
||||||
|
|
||||||
# 1. Fetch or Create Release
|
|
||||||
RELEASE_JSON=$(curl -s -X POST "$SERVER/api/v1/repos/$REPO/releases" \
|
|
||||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{
|
|
||||||
\"tag_name\": \"$TAG_NAME\",
|
|
||||||
\"name\": \"GLP-1 Companion $TAG_NAME\",
|
|
||||||
\"body\": \"Automated alpha release build for $TAG_NAME.\",
|
|
||||||
\"draft\": false,
|
|
||||||
\"prerelease\": true
|
|
||||||
}")
|
|
||||||
|
|
||||||
RELEASE_ID=$(echo "$RELEASE_JSON" | grep -o '"id":[0-9]*' | head -n1 | cut -d: -f2)
|
|
||||||
|
|
||||||
if [ -z "$RELEASE_ID" ]; then
|
|
||||||
echo "Release may already exist, fetching existing release ID..."
|
|
||||||
RELEASE_JSON=$(curl -s "$SERVER/api/v1/repos/$REPO/releases/tags/$TAG_NAME" \
|
|
||||||
-H "Authorization: token $FORGEJO_TOKEN")
|
|
||||||
RELEASE_ID=$(echo "$RELEASE_JSON" | grep -o '"id":[0-9]*' | head -n1 | cut -d: -f2)
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Release ID: $RELEASE_ID"
|
|
||||||
|
|
||||||
# 2. Upload All Staged Artifacts to Release
|
|
||||||
find staged-assets -type f \( -name "*.apk" -o -name "*.msix" -o -name "*.zip" \) | while read -r FILE; do
|
|
||||||
FILENAME=$(basename "$FILE")
|
|
||||||
echo "Uploading $FILENAME to release $RELEASE_ID..."
|
|
||||||
|
|
||||||
curl -s -X POST "$SERVER/api/v1/repos/$REPO/releases/$RELEASE_ID/assets?name=$FILENAME" \
|
|
||||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
|
||||||
-H "Content-Type: application/octet-stream" \
|
|
||||||
--data-binary @"$FILE" > /dev/null
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "All release assets uploaded successfully."
|
|
||||||
|
|
@ -34,7 +34,6 @@ migrate_working_dir/
|
||||||
.pub-cache/
|
.pub-cache/
|
||||||
.pub/
|
.pub/
|
||||||
/build/
|
/build/
|
||||||
/dist/
|
|
||||||
|
|
||||||
# Web specific
|
# Web specific
|
||||||
|
|
||||||
|
|
@ -55,8 +54,6 @@ macos/Flutter/ephemeral/
|
||||||
**/android/gradlew.bat
|
**/android/gradlew.bat
|
||||||
**/android/local.properties
|
**/android/local.properties
|
||||||
**/android/**/GeneratedPluginRegistrant.java
|
**/android/**/GeneratedPluginRegistrant.java
|
||||||
**/android/key.properties
|
|
||||||
**/android/build/
|
|
||||||
**/ios/**/*.mode1v3
|
**/ios/**/*.mode1v3
|
||||||
**/ios/**/*.mode2v3
|
**/ios/**/*.mode2v3
|
||||||
**/ios/**/*.moved-aside
|
**/ios/**/*.moved-aside
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
|
|
||||||
## Active Target
|
## Active Target
|
||||||
- **Current Milestone:** Phase 7: Navigation & Domain Expansion (Exercise, Recipe Vault, Settings) — COMPLETE
|
- **Current Milestone:** Phase 7: Navigation & Domain Expansion (Exercise, Recipe Vault, Settings) — COMPLETE
|
||||||
- **Status:** Phase 7 Complete (all phases 1–7 done); alpha release pipeline landed 2026-10-18
|
- **Status:** Phase 7 Complete (release-ready: all phases 1–7 done)
|
||||||
- **Latest Addendum:** Alpha Release Pipeline & Scripting (2026-10-18)
|
- **Latest Addendum:** Phase 4B — Private Recipe Sources & Mealie Integration (2026-10-17)
|
||||||
|
|
||||||
## Phase Checklist
|
## Phase Checklist
|
||||||
- [x] Phase 1: Foundation & Identity (Passwordless SSO & Encrypted Drift DB)
|
- [x] Phase 1: Foundation & Identity (Passwordless SSO & Encrypted Drift DB)
|
||||||
|
|
@ -197,50 +197,3 @@
|
||||||
- [x] Codegen: `dart run build_runner build` (`flutter pub run` is retired on current Flutter; `--delete-conflicting-outputs` is now ignored by build_runner) — drift re-emits `app_database.g.dart` byte-identically (no schema change).
|
- [x] Codegen: `dart run build_runner build` (`flutter pub run` is retired on current Flutter; `--delete-conflicting-outputs` is now ignored by build_runner) — drift re-emits `app_database.g.dart` byte-identically (no schema change).
|
||||||
- [x] `flutter test` — 184/184 green (170 pre-existing + 14 new Mealie tests), including the 390 dp mobile navigation suite.
|
- [x] `flutter test` — 184/184 green (170 pre-existing + 14 new Mealie tests), including the 390 dp mobile navigation suite.
|
||||||
- [ ] Manual Mealie round trip on a reachable instance: Settings → Private Recipe Integrations → Connect Mealie (URL + API token) → Test & Save → badge flips to Connected → Sync Now (or the Recipe Vault AppBar sync action) → vault shows the Mealie badge and the Mealie/Web Scraped filter chips isolate each source.
|
- [ ] Manual Mealie round trip on a reachable instance: Settings → Private Recipe Integrations → Connect Mealie (URL + API token) → Test & Save → badge flips to Connected → Sync Now (or the Recipe Vault AppBar sync action) → vault shows the Mealie badge and the Mealie/Web Scraped filter chips isolate each source.
|
||||||
|
|
||||||
## Alpha Release Pipeline & Tooling (2026-10-18)
|
|
||||||
Release engineering for alpha (pre-release) builds across Android and Windows, driven either locally (one-shot PowerShell script) or by CI when a version tag is pushed.
|
|
||||||
|
|
||||||
- [x] CI: `.forgejo/workflows/release.yaml` — "Build and Publish Alpha Releases", triggered on `push` of tags matching `v*`. Three jobs:
|
|
||||||
- `build-android` (ubuntu): Java 17 (Temurin) + Flutter 3.47.x → `flutter pub get` → `dart run build_runner build --delete-conflicting-outputs` → decode `ANDROID_KEYSTORE_BASE64` / `ANDROID_KEY_PROPERTIES` repo secrets into `android/app/upload-keystore.jks` + `android/key.properties` (step skipped when secrets are absent) → `flutter build apk --release --split-per-abi` → stages `GLP1Companion-Android-arm64-<tag>.apk` under `dist/`.
|
|
||||||
- `build-windows` (windows): Flutter 3.47.x → `flutter pub get` + codegen → `flutter build windows --release` → `dart run msix:create --install-certificate false` → stages `GLP1Companion-Windows-<tag>.msix` and `GLP1Companion-Windows-x64-<tag>-Portable.zip` (runner Release folder zipped via `Compress-Archive`).
|
|
||||||
- `publish-release` (needs both build jobs): downloads the staged artifacts and drives the Forgejo REST API (`POST /api/v1/repos/{owner}/{repo}/releases`, then `/releases/{id}/assets?name=`) with `GITHUB_TOKEN` to create the release with `prerelease: true` — falling back to the existing release for the tag if creation fails — and uploads every `.apk` / `.msix` / `.zip`.
|
|
||||||
- Flutter pinned to 3.47.x to match the dev toolchain: `pubspec.lock` declares `flutter: ">=3.44.0"`, so an older CI toolchain (e.g. the originally drafted 3.24.x) fails at `flutter pub get`.
|
|
||||||
- [x] Local script: `scripts/publish_alpha.ps1 -Version <x.y.z> [-ForgejoToken <token>]` (token defaults to `$env:FORGEJO_TOKEN`; server `https://git.kellsupport.com`, repo `mtkell/glp-companion`) — same flow end to end: codegen → arm64 APK → portable zip → MSIX → Forgejo release (`prerelease: true`) with asset upload; artifacts land in `dist/v<version>/`.
|
|
||||||
- [x] Forgejo API probe: `scripts/test.ps1` verifies release lookup/creation and `multipart/form-data` asset upload against a throwaway draft release (`v0.0.1-test`) with a dummy text asset; the token is read from `$env:FORGEJO_TOKEN` only (the previously hardcoded token was removed — rotation tracked in the gates below).
|
|
||||||
- [x] Android release signing: `android/app/build.gradle.kts` loads `android/key.properties` from the project root when present and wires a `release` signing config (alias / password / store) with `isMinifyEnabled` + `isShrinkResources` and `proguard-rules.pro`; when the file is absent the properties are empty (CI decodes them from secrets; local release builds need their own `key.properties`).
|
|
||||||
- [x] ProGuard rules (`android/app/proguard-rules.pro`): keep Flutter embedding/plugin/util/view classes, `net.sqlcipher.**` + `net.sqlcipher.database.**` (SQLCipher backing the encrypted drift DB), annotation/enum/signature metadata for JSON serialization, plus `-dontwarn` suppressions for Play Core deferred components, conscrypt, and javax.annotation.
|
|
||||||
- [x] MSIX packaging: `msix: ^3.16.7` dev dependency (resolved 3.18.0) + `msix_config` block in `pubspec.yaml` — display name "GLP-1 Companion", publisher "Kell Engineering LLC" (`CN=Kell Engineering LLC, O=..., L=Valrico, S=Florida, C=US`), identity `KellEngineering.GLP1Companion`, `msix_version 0.1.0.0`, `logo_path: web/icons/Icon-512.png`, `internetClient` capability, execution alias `glp-companion`.
|
|
||||||
- [x] Secret & artifact hygiene: `/dist/` (local release artifacts, ~107 MB), `**/android/build/` (Gradle problems report), and `**/android/key.properties` added to `.gitignore`; the hardcoded Forgejo token stripped from `scripts/test.ps1` before it could ever reach git history.
|
|
||||||
|
|
||||||
## Alpha Release Verification Gates (2026-10-18, to run)
|
|
||||||
- [ ] **Rotate the Forgejo token** that was hardcoded in `scripts/test.ps1`: Forgejo → Settings → Applications → delete the exposed token, mint a new one, export it as `FORGEJO_TOKEN` locally, and store it as a repo secret. (The old token never entered git history, but it existed in plaintext on disk.)
|
|
||||||
- [ ] Configure Forgejo Actions secrets on `mtkell/glp-companion`: `ANDROID_KEYSTORE_BASE64` (base64 of `upload-keystore.jks`) and `ANDROID_KEY_PROPERTIES` (keyAlias / keyPassword / storeFile=`upload-keystore.jks` / storePassword). Without them the Android job's release build fails at signing.
|
|
||||||
- [ ] Push a `v*` tag (e.g. `git tag v0.1.0 && git push origin v0.1.0`) and confirm all three workflow jobs go green and the prerelease at `https://git.kellsupport.com/mtkell/glp-companion/releases` carries the APK, MSIX, and portable zip assets.
|
|
||||||
- [ ] Install-gate: sideload the arm64 APK on an Android device and install the MSIX on Windows; launch both and confirm the encrypted drift DB opens (the minify/shrink build is exactly why the SQLCipher keep-rules exist).
|
|
||||||
|
|
||||||
## Post-Alpha Fixes (2026-10-18)
|
|
||||||
|
|
||||||
- [x] Android Google Health connect fix (field-reported alpha: authorization completed in the browser, but on return the app "only shows the localhost call" — Connect stayed highlighted and the row never flipped to Connected). Root cause: `GoogleHealthConnector.authorize()` ran the RFC 8252 **loopback redirect** on Android too — it bound `HttpServer` on the device loopback and needed the *backgrounded* app to serve Chrome's `http://localhost:52789/callback` after consent, which Android's cached-app freezer prevents; the callback never arrived, so `authorize()` hung (button `busy`) until the 5-minute timeout. Google deprecates loopback for mobile client types, and custom-scheme redirects are rejected on the existing Web-type client ("Custom scheme URIs are not allowed for 'WEB' client type"), so the fix follows Google's recommended Android path (Google Identity Services):
|
|
||||||
- Connector (`google_health_connector_io.dart`): on `Platform.isAndroid` only, `authorize()` now runs **`package:google_sign_in`** (native account chooser + consent sheet — the app stays foreground, no HTTP callback) with `serverClientId = GOOGLE_HEALTH_CLIENT_ID` and `forceCodeForRefreshToken: true` (the `prompt=consent` equivalent), then exchanges the returned one-time `serverAuthCode` on the shared token endpoint (GIS codes carry no `redirect_uri`) — secure-storage tokens, transparent refresh, REST reads, and the parser are untouched. A 5-minute timeout mirrors the loopback path; a missing code logs the Android-client/SHA-1 checklist. `disconnect()` additionally signs the SDK out (best-effort) so Connect re-prompts for an account. Desktop/iOS keep the proven loopback flow.
|
|
||||||
- UX: the Google Health row's failure copy is now OAuth-accurate — "Google Health sign-in was cancelled or did not complete. Tap Connect to try again." — instead of pointing the user at device settings (integrations controller branches on `HealthProviderType.googleHealth`).
|
|
||||||
- Dependency: `google_sign_in: 6.3.0` added (Android implementation 6.2.1).
|
|
||||||
- Verified: `flutter analyze` clean; `flutter test` 184/184 green (including `integrations_controller_test.dart` and `settings_screen_test.dart`).
|
|
||||||
- [x] Android post-connect sync fix (field-reported right after the GIS fix: Google Health **connected**, but the app then showed "Health permissions were denied."): `BiometricController.syncFromHealth()` unconditionally demanded the **native** Health Connect grant first and bailed on denial — never reaching the cloud connector, which needs no OS permission. The bail is now `!granted && !await gateway.isCloudSyncReady()` — a native denial is fatal only when no cloud partner holds a grant; with Google Health connected, cloud reads proceed (native degrades to empty while ungranted) and the status reports `Synced N weight sample(s)…`. Tests: `biometric_controller_test.dart` gains a `cloudReady` fake override plus "native denial with a connected cloud partner still syncs partner data" (11 tests in file); full suite 185/185, analyze clean. Still prompts for Health Connect once — a denial there no longer breaks the cloud sync.
|
|
||||||
|
|
||||||
## Google Health on Android — Verification Gates (2026-10-18, manual)
|
|
||||||
- [x] Google Cloud Android OAuth client(s) — DONE 2026-10-18 (the on-device connect succeeded, proving a matching package/SHA-1 client exists): Google Cloud → the same project as `GOOGLE_HEALTH_CLIENT_ID` → APIs & Services → Credentials → **Create OAuth client ID → Application type Android**: package name `com.companionglp.glpcompanion` with the SHA-1 of the release `upload-keystore.jks` (`keytool -list -v -keystore upload-keystore.jks -alias <keyAlias>`); add a second Android client with the debug keystore SHA-1 (`%USERPROFILE%\.android\debug.keystore`, alias `androiddebugkey`, storepass `android`) for debug builds. The GIS SDK resolves the caller by package/SHA-1 — without a match it returns no `serverAuthCode` and Connect fails (the connector logs the checklist in debug). Fingerprints computed 2026-10-18 (re-verify against the installed APK with `keytool -printcert -jarfile <apk>`): release `upload-keystore.jks` alias `upload` = `79:8B:73:52:70:77:C3:46:C7:12:06:88:42:0E:97:67:CC:B8:E2:85`; debug `~/.android/debug.keystore` alias `androiddebugkey` = `67:BA:FE:4A:15:5B:52:73:E1:8F:0D:F7:44:81:7B:5C:72:C3:A1:36`.
|
|
||||||
- [x] On-device connect round trip (confirmed 2026-10-18): install the rebuilt arm64 APK → Settings → Google Health → Connect → native account chooser → Google consent (activity & fitness + health metrics read scopes) → returns straight into the app → row flips **Connected**.
|
|
||||||
- [ ] Data round trip (pending an APK rebuild containing the post-connect sync fix above): after Connect, status reads `Synced N weight sample(s)…` instead of "Health permissions were denied.", and weight/active energy appear on the Nutrition dashboard (Android imports persist under the `healthConnect` platform source).
|
|
||||||
|
|
||||||
## Health Connect (Samsung & Pixel) Row — Fix (2026-10-18)
|
|
||||||
|
|
||||||
- [x] Native Health Connect permission request failed silently (field-reported: tapping the "Health Connect (Samsung & Pixel)" row showed "Health permissions were denied. Enable them in your device settings" with **no permission dialog ever appearing**). Root cause per the `package:health` v13.3.1 README: `requestAuthorization()` registers its permission launcher via `(activity as ComponentActivity).registerForActivityResult(...)` in `onAttachedToActivity` — the host activity must therefore extend **`FlutterFragmentActivity`**, but `MainActivity` extended plain `FlutterActivity` (`android.app.Activity`, not androidx `ComponentActivity`). The cast fails → the launcher stays `null` → the plugin answers `success(false)` immediately (HealthPlugin.kt:317) → our gateway maps it to "denied". Three fixes:
|
|
||||||
- `MainActivity.kt` now extends `FlutterFragmentActivity` (with a comment explaining the cast requirement). Drop-in for all other plugins (app_links, supabase, url_launcher, notifications, google_sign_in all bind to any Activity).
|
|
||||||
- `AndroidManifest.xml`: added the README-required `<intent-filter>` for `androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE` on `.MainActivity` (the `<queries>` block and `ViewPermissionUsageActivity` alias were already present; `health_connect_config.xml` is optional restore tooling and was not added).
|
|
||||||
- Read-set hygiene: `STEPS` removed from `_androidReadTypes` and `READ_STEPS` from the manifest — steps are **never fetched** anywhere in the app, and the README ties step access to the `ACTIVITY_RECOGNITION` permission this app does not declare; an ungrantable entry risks poisoning the whole permission request. Remaining set: weight, total calories burned, active energy burned, blood glucose (all four declared in the manifest).
|
|
||||||
- Verified: `flutter analyze` clean; `flutter test` 185/185 green; **`flutter build apk --debug` → `Built build\app\outputs\flutter-apk\app-debug.apk`** (also clears the standing build-gate item for the debug APK; Gradle 135.1 s; pre-existing KGP warning for device_info_plus/health/mobile_scanner is non-fatal today).
|
|
||||||
- [ ] On-device Health Connect round trip: tap the Samsung/Pixel row → the Health Connect permission screen **appears** (weight, calories, glucose toggles) → grant → row flips **Connected** → status shows a `Synced …` result. If a stale denial exists from earlier attempts, clear it once under Android Settings → Apps → glpcompanion → Permissions (or the Health Connect app's App permissions screen), then retry in-app.
|
|
||||||
- [ ] Disconnect then Connect again re-prompts for an account (SDK sign-out path), and the row recovers cleanly if the sign-in sheet is dismissed (cancel → the OAuth-specific error banner, button un-busy).
|
|
||||||
- [ ] Follow-up (not part of this fix): iOS still uses the loopback redirect and carries the same background fragility — migrate it to the SDK path when iOS alpha starts (needs an iOS-type OAuth client + reversed-client-ID URL scheme).
|
|
||||||
- [ ] Web-client housekeeping (field-checked 2026-10-18: the console screenshot showed the WRONG client — the project has **two** Web clients): `GOOGLE_HEALTH_CLIENT_ID` = `305941286539-2bqe3cte3gg6b2m64k0uosdp3tvrcfhq…` is the **Google Health** client — confirm it holds `http://localhost:52789/callback` under Authorized redirect URIs (desktop/iOS loopback only; the Android GIS flow uses **no** redirect URI) and that its secret matches `GOOGLE_HEALTH_CLIENT_SECRET`. The other client, `305941286539-8p9itkud4m0r9luiu53rg2s9liqon7lm…` ("GLP-1 Companion Web", redirect `https://pyrgjdvqbjzakuwvbpxw.supabase.co/auth/v1/callback`), is the **Supabase sign-in** client — leave it untouched. Secrets are no longer viewable in the console — rotate via **Add secret** + `.env` update if mismatched.
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,3 @@
|
||||||
import java.io.FileInputStream
|
|
||||||
import java.util.Properties
|
|
||||||
|
|
||||||
val keystoreProperties = Properties()
|
|
||||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
|
||||||
if (keystorePropertiesFile.exists()) {
|
|
||||||
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
|
||||||
}
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
|
|
@ -28,43 +19,36 @@ android {
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
applicationId = "com.companionglp.glpcompanion"
|
applicationId = "com.companionglp.glpcompanion"
|
||||||
|
// You can update the following values to match your application needs.
|
||||||
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
|
//
|
||||||
// API 26 (Android 8.0) floor required by the `health` plugin (Health
|
// API 26 (Android 8.0) floor required by the `health` plugin (Health
|
||||||
// Connect / HealthKit bridge), which declares minSdk 26 in its
|
// Connect / HealthKit bridge), which declares minSdk 26 in its
|
||||||
// manifest; Health Connect itself only ships on Android 8.0+.
|
// manifest; Health Connect itself only ships on Android 8.0+.
|
||||||
minSdk = maxOf(flutter.minSdkVersion, 26)
|
minSdk = maxOf(flutter.minSdkVersion, 26)
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
|
// Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION
|
||||||
|
// is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
|
||||||
|
// You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true`
|
||||||
|
// flag during build.
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
|
||||||
create("release") {
|
|
||||||
if (keystorePropertiesFile.exists()) {
|
|
||||||
keyAlias = keystoreProperties.getProperty("keyAlias")
|
|
||||||
keyPassword = keystoreProperties.getProperty("keyPassword")
|
|
||||||
storeFile = file(keystoreProperties.getProperty("storeFile"))
|
|
||||||
storePassword = keystoreProperties.getProperty("storePassword")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
getByName("release") {
|
release {
|
||||||
signingConfig = signingConfigs.getByName("release")
|
// TODO: Add your own signing config for the release build.
|
||||||
isMinifyEnabled = true
|
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||||
isShrinkResources = true
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
proguardFiles(
|
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
|
||||||
"proguard-rules.pro"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
# Flutter Wrapper
|
|
||||||
-keep class io.flutter.app.** { *; }
|
|
||||||
-keep class io.flutter.plugin.** { *; }
|
|
||||||
-keep class io.flutter.util.** { *; }
|
|
||||||
-keep class io.flutter.view.** { *; }
|
|
||||||
-keep class io.flutter.** { *; }
|
|
||||||
-keep class io.flutter.plugins.** { *; }
|
|
||||||
|
|
||||||
# Drift / SQLCipher
|
|
||||||
-keep class net.sqlcipher.** { *; }
|
|
||||||
-keep class net.sqlcipher.database.** { *; }
|
|
||||||
|
|
||||||
# OpenFoodFacts & JSON Serialization
|
|
||||||
-keepattributes *Annotation*,EnclosingMethod,Signature
|
|
||||||
-keepclassmembers enum * { *; }
|
|
||||||
|
|
||||||
# Flutter Play Store Split / Deferred Components (referenced by engine, not used here)
|
|
||||||
-dontwarn com.google.android.play.core.**
|
|
||||||
|
|
||||||
# Additional standard Flutter & third-party suppressions
|
|
||||||
-dontwarn io.flutter.embedding.engine.deferredcomponents.**
|
|
||||||
-dontwarn javax.annotation.**
|
|
||||||
-dontwarn org.conscrypt.**
|
|
||||||
|
|
@ -4,12 +4,11 @@
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
|
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
|
||||||
<!-- Health Connect (Android 14+ / Play services): read permissions matching
|
<!-- Health Connect (Android 14+ / Play services): read permissions matching
|
||||||
the HealthGateway request set (weight, total calories burned, active
|
the HealthGateway request set (weight, steps, total calories burned,
|
||||||
energy burned, blood glucose). STEPS was removed from the set: it is
|
active energy burned, blood glucose). Health Connect federates
|
||||||
never fetched, and the package:health README ties step access to the
|
Samsung Health and Pixel sensor feeds through this runtime. -->
|
||||||
ACTIVITY_RECOGNITION permission this app does not declare. Health
|
|
||||||
Connect federates Samsung Health and Pixel sensor feeds. -->
|
|
||||||
<uses-permission android:name="android.permission.health.READ_WEIGHT"/>
|
<uses-permission android:name="android.permission.health.READ_WEIGHT"/>
|
||||||
|
<uses-permission android:name="android.permission.health.READ_STEPS"/>
|
||||||
<uses-permission android:name="android.permission.health.READ_TOTAL_CALORIES_BURNED"/>
|
<uses-permission android:name="android.permission.health.READ_TOTAL_CALORIES_BURNED"/>
|
||||||
<uses-permission android:name="android.permission.health.READ_ACTIVE_CALORIES_BURNED"/>
|
<uses-permission android:name="android.permission.health.READ_ACTIVE_CALORIES_BURNED"/>
|
||||||
<uses-permission android:name="android.permission.health.READ_BLOOD_GLUCOSE"/>
|
<uses-permission android:name="android.permission.health.READ_BLOOD_GLUCOSE"/>
|
||||||
|
|
@ -47,12 +46,6 @@
|
||||||
<category android:name="android.intent.category.BROWSABLE"/>
|
<category android:name="android.intent.category.BROWSABLE"/>
|
||||||
<data android:scheme="com.companionglp.glpcompanion"/>
|
<data android:scheme="com.companionglp.glpcompanion"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<!-- Health Connect (package:health README): lets the Health
|
|
||||||
Connect permissions screen resolve this app for the
|
|
||||||
rationale step of the permission flow. -->
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE"/>
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
</activity>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
package com.companionglp.glpcompanion
|
package com.companionglp.glpcompanion
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
// FlutterFragmentActivity (not the default FlutterActivity) is required by
|
class MainActivity : FlutterActivity()
|
||||||
// package:health v13: it casts the host activity to
|
|
||||||
// androidx.activity.ComponentActivity to call registerForActivityResult for
|
|
||||||
// the Health Connect permission flow. With plain FlutterActivity that cast
|
|
||||||
// fails, the permission launcher stays null, and requestAuthorization()
|
|
||||||
// silently returns false ("Health permissions were denied" with no dialog).
|
|
||||||
class MainActivity : FlutterFragmentActivity()
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import 'package:crypto/crypto.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:google_sign_in/google_sign_in.dart';
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
|
@ -55,30 +54,18 @@ class GoogleHealthTokens {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Google Health cloud connector: OAuth2 through Google Identity Services on
|
/// Google Health cloud connector: OAuth2 (authorization-code + PKCE) through
|
||||||
/// Android (native account chooser + consent, `serverAuthCode` exchange) and
|
/// the system browser with a loopback redirect
|
||||||
/// authorization-code + PKCE through the system browser with a loopback
|
/// (`http://localhost:<port>/callback`), secure-storage-backed tokens with
|
||||||
/// redirect (`http://localhost:<port>/callback`) on desktop/iOS, backed by
|
/// transparent refresh, and normalized weight / active-energy reads from the
|
||||||
/// secure-storage tokens with transparent refresh, and normalized weight /
|
/// Google Health API — the official successor to the retired Fitbit Web API
|
||||||
/// active-energy reads from the Google Health API — the official successor to
|
/// (shutdown 2026-10-30); Fitbit, Pixel and Wear OS data flows into Google
|
||||||
/// the retired Fitbit Web API (shutdown 2026-10-30); Fitbit, Pixel and Wear
|
/// Health.
|
||||||
/// OS data flows into Google Health.
|
|
||||||
///
|
///
|
||||||
/// Requires `GOOGLE_HEALTH_CLIENT_ID` (a **Web application** OAuth client)
|
/// Requires `GOOGLE_HEALTH_CLIENT_ID` and `GOOGLE_HEALTH_CLIENT_SECRET` in
|
||||||
/// and `GOOGLE_HEALTH_CLIENT_SECRET` in `.env` (optionally
|
/// `.env` (optionally `GOOGLE_HEALTH_REDIRECT_PORT`, default 52789). Create
|
||||||
/// `GOOGLE_HEALTH_REDIRECT_PORT`, default 52789). Create them in a Google
|
/// them in a Google Cloud project with the Google Health API enabled; the
|
||||||
/// Cloud project with the Google Health API enabled; the loopback redirect
|
/// loopback redirect must be an Authorized redirect URI on the OAuth client.
|
||||||
/// must be an Authorized redirect URI on the OAuth client. Android also
|
|
||||||
/// needs an **Android** OAuth client (package `com.companionglp.glpcompanion`
|
|
||||||
/// + the signing certificate's SHA-1) in the same project, because the SDK
|
|
||||||
/// resolves the caller by package/SHA-1 before issuing a server auth code.
|
|
||||||
///
|
|
||||||
/// Android deliberately avoids the loopback redirect: the callback requires
|
|
||||||
/// the *backgrounded* app to serve HTTP to the browser, and Android's
|
|
||||||
/// cached-app freezer prevents that — the browser only ever showed the
|
|
||||||
/// `localhost` URL and the Connect button hung until the 5-minute timeout.
|
|
||||||
/// Google likewise deprecates loopback for mobile client types and
|
|
||||||
/// recommends this SDK path instead.
|
|
||||||
class GoogleHealthConnector implements ICloudHealthConnector {
|
class GoogleHealthConnector implements ICloudHealthConnector {
|
||||||
GoogleHealthConnector({http.Client? client, FlutterSecureStorage? storage})
|
GoogleHealthConnector({http.Client? client, FlutterSecureStorage? storage})
|
||||||
: _client = client ?? http.Client(),
|
: _client = client ?? http.Client(),
|
||||||
|
|
@ -151,14 +138,6 @@ class GoogleHealthConnector implements ICloudHealthConnector {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Android runs the Google Identity Services SDK path instead of the
|
|
||||||
// loopback redirect: that flow needs the browser to reach an HTTP server
|
|
||||||
// held by this (backgrounded) app, which Android's process freezer
|
|
||||||
// prevents — the callback never arrived and authorize() hung.
|
|
||||||
if (Platform.isAndroid) {
|
|
||||||
return _authorizeViaGoogleSignIn();
|
|
||||||
}
|
|
||||||
|
|
||||||
final verifier = _randomUrlSafe(64);
|
final verifier = _randomUrlSafe(64);
|
||||||
final authorizeUrl = Uri.parse(
|
final authorizeUrl = Uri.parse(
|
||||||
'$_authorizeEndpoint?response_type=code'
|
'$_authorizeEndpoint?response_type=code'
|
||||||
|
|
@ -200,73 +179,9 @@ class GoogleHealthConnector implements ICloudHealthConnector {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Android authorization through the Google Identity Services SDK
|
|
||||||
/// (`package:google_sign_in`): the consent sheet renders natively inside a
|
|
||||||
/// foreground activity, so the round trip no longer depends on serving a
|
|
||||||
/// loopback HTTP callback from the backgrounded app.
|
|
||||||
///
|
|
||||||
/// The SDK hands back a one-time [GoogleSignInAccount.serverAuthCode]
|
|
||||||
/// issued against the same Web client (`serverClientId`), which is
|
|
||||||
/// exchanged on the shared token endpoint — yielding the identical
|
|
||||||
/// access/refresh token pair the desktop loopback flow produces, so token
|
|
||||||
/// storage, transparent refresh, and the REST layer stay unchanged.
|
|
||||||
/// [forceCodeForRefreshToken] mirrors `prompt=consent` by guaranteeing a
|
|
||||||
/// refresh token on every re-consent.
|
|
||||||
Future<bool> _authorizeViaGoogleSignIn() async {
|
|
||||||
try {
|
|
||||||
final signIn = GoogleSignIn(
|
|
||||||
scopes: _scopes.split(' '),
|
|
||||||
serverClientId: _clientId,
|
|
||||||
forceCodeForRefreshToken: true,
|
|
||||||
);
|
|
||||||
final account = await signIn.signIn().timeout(_callbackTimeout);
|
|
||||||
if (account == null) {
|
|
||||||
debugPrint('GoogleHealthConnector: Google sign-in was cancelled.');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
final code = account.serverAuthCode;
|
|
||||||
if (code == null || code.isEmpty) {
|
|
||||||
debugPrint(
|
|
||||||
'GoogleHealthConnector: Google sign-in returned no authorization '
|
|
||||||
'code - verify the Android OAuth client (package '
|
|
||||||
'com.companionglp.glpcompanion + signing-cert SHA-1) exists in the '
|
|
||||||
'same Google Cloud project as GOOGLE_HEALTH_CLIENT_ID.',
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Google-issued SDK codes are exchanged without a redirect_uri (per
|
|
||||||
// the GIS server-auth-code flow); everything else matches the shared
|
|
||||||
// [_requestTokens] path used by desktop.
|
|
||||||
final tokens = await _requestTokens({
|
|
||||||
'grant_type': 'authorization_code',
|
|
||||||
'code': code,
|
|
||||||
});
|
|
||||||
await _writeTokens(tokens);
|
|
||||||
return true;
|
|
||||||
} catch (error, stackTrace) {
|
|
||||||
debugPrintStack(
|
|
||||||
stackTrace: stackTrace,
|
|
||||||
label: 'GoogleHealthConnector: Android sign-in failed ($error)',
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> disconnect() async {
|
Future<void> disconnect() async {
|
||||||
await _storage.delete(key: tokenStorageKey);
|
await _storage.delete(key: tokenStorageKey);
|
||||||
if (Platform.isAndroid) {
|
|
||||||
// Best-effort: drop the SDK session too, so the next Connect re-prompts
|
|
||||||
// for an account instead of silently reusing the previous grant.
|
|
||||||
try {
|
|
||||||
await GoogleSignIn(
|
|
||||||
scopes: _scopes.split(' '),
|
|
||||||
serverClientId: _clientId,
|
|
||||||
).signOut();
|
|
||||||
} catch (error) {
|
|
||||||
debugPrint('GoogleHealthConnector: SDK sign-out failed ($error)');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@ class MobileHealthGateway extends IHealthGateway {
|
||||||
/// Pixel sensor feeds into this single runtime.
|
/// Pixel sensor feeds into this single runtime.
|
||||||
static const List<HealthDataType> _androidReadTypes = [
|
static const List<HealthDataType> _androidReadTypes = [
|
||||||
HealthDataType.WEIGHT,
|
HealthDataType.WEIGHT,
|
||||||
|
HealthDataType.STEPS,
|
||||||
HealthDataType.TOTAL_CALORIES_BURNED,
|
HealthDataType.TOTAL_CALORIES_BURNED,
|
||||||
HealthDataType.ACTIVE_ENERGY_BURNED,
|
HealthDataType.ACTIVE_ENERGY_BURNED,
|
||||||
HealthDataType.BLOOD_GLUCOSE,
|
HealthDataType.BLOOD_GLUCOSE,
|
||||||
|
|
|
||||||
|
|
@ -219,9 +219,9 @@ class BiometricController extends AsyncNotifier<BiometricState> {
|
||||||
/// same source/window is replaced, not duplicated).
|
/// same source/window is replaced, not duplicated).
|
||||||
///
|
///
|
||||||
/// Returns `false` when no sync source exists on this platform (Web with
|
/// Returns `false` when no sync source exists on this platform (Web with
|
||||||
/// no cloud connector provisioned), when native permission is denied
|
/// no cloud connector provisioned), when permission is denied, or when
|
||||||
/// *and* no cloud partner holds a valid grant, or when the sync throws;
|
/// the sync throws; the reason is reflected in
|
||||||
/// the reason is reflected in [BiometricState.statusMessage].
|
/// [BiometricState.statusMessage].
|
||||||
Future<bool> syncFromHealth({Duration window = syncWindow}) async {
|
Future<bool> syncFromHealth({Duration window = syncWindow}) async {
|
||||||
final gateway = ref.read(healthGatewayProvider);
|
final gateway = ref.read(healthGatewayProvider);
|
||||||
if (!gateway.supportsNativeHealth && !gateway.supportsCloudSync) {
|
if (!gateway.supportsNativeHealth && !gateway.supportsCloudSync) {
|
||||||
|
|
@ -245,14 +245,10 @@ class BiometricController extends AsyncNotifier<BiometricState> {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Native stores need an OS permission grant first; cloud connectors
|
// Native stores need an OS permission grant first; cloud connectors
|
||||||
// manage their own OAuth grant at connect time. A native denial is
|
// manage their own OAuth grant at connect time.
|
||||||
// therefore fatal only when no cloud partner can supply the data —
|
|
||||||
// with Google Health connected, `isCloudSyncReady()` is true and the
|
|
||||||
// cloud reads proceed without any OS permission (the gateway merges
|
|
||||||
// both sources; native degrades to empty while ungranted).
|
|
||||||
if (gateway.supportsNativeHealth) {
|
if (gateway.supportsNativeHealth) {
|
||||||
final granted = await gateway.requestPermissions();
|
final granted = await gateway.requestPermissions();
|
||||||
if (!granted && !await gateway.isCloudSyncReady()) {
|
if (!granted) {
|
||||||
await _refresh(
|
await _refresh(
|
||||||
statusMessage: 'Health permissions were denied.',
|
statusMessage: 'Health permissions were denied.',
|
||||||
error: StateError('Health permissions denied'),
|
error: StateError('Health permissions denied'),
|
||||||
|
|
|
||||||
|
|
@ -203,12 +203,6 @@ class IntegrationsController extends Notifier<IntegrationsState> {
|
||||||
isRequestingPermissions: false,
|
isRequestingPermissions: false,
|
||||||
errorMessage: granted
|
errorMessage: granted
|
||||||
? null
|
? null
|
||||||
: provider == HealthProviderType.googleHealth
|
|
||||||
// OAuth round trip (SDK on Android, browser loopback on
|
|
||||||
// desktop): nothing to enable in device settings — the flow
|
|
||||||
// was cancelled or the redirect never completed.
|
|
||||||
? 'Google Health sign-in was cancelled or did not complete. '
|
|
||||||
'Tap Connect to try again.'
|
|
||||||
: 'Health permissions were denied. Enable them in your device '
|
: 'Health permissions were denied. Enable them in your device '
|
||||||
'settings to sync biometrics.',
|
'settings to sync biometrics.',
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import app_links
|
||||||
import device_info_plus
|
import device_info_plus
|
||||||
import flutter_local_notifications
|
import flutter_local_notifications
|
||||||
import flutter_secure_storage_macos
|
import flutter_secure_storage_macos
|
||||||
import google_sign_in_ios
|
|
||||||
import mobile_scanner
|
import mobile_scanner
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import url_launcher_macos
|
import url_launcher_macos
|
||||||
|
|
@ -19,7 +18,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||||
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
||||||
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
|
|
||||||
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
|
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
|
|
|
||||||
96
pubspec.lock
96
pubspec.lock
|
|
@ -49,14 +49,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.5"
|
version: "1.0.5"
|
||||||
archive:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: archive
|
|
||||||
sha256: "0d58d1fa1fddf6588251ccf2819f3fb3b6d0452c1b2813ac7413a05592bfbefe"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "4.5.2"
|
|
||||||
args:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -193,14 +185,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
version: "1.19.1"
|
||||||
console:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: console
|
|
||||||
sha256: e04e7824384c5b39389acdd6dc7d33f3efe6b232f6f16d7626f194f6a01ad69a
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "4.1.0"
|
|
||||||
convert:
|
convert:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -453,14 +437,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.7.1"
|
version: "2.7.1"
|
||||||
get_it:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: get_it
|
|
||||||
sha256: "2b5c6b077aad30bba9782178baff6076a2044141f1d5f2f15b43bca3fbe74874"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "9.3.0"
|
|
||||||
glob:
|
glob:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -477,54 +453,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "14.8.1"
|
version: "14.8.1"
|
||||||
google_identity_services_web:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: google_identity_services_web
|
|
||||||
sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.3.3+1"
|
|
||||||
google_sign_in:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: google_sign_in
|
|
||||||
sha256: d0a2c3bcb06e607bb11e4daca48bd4b6120f0bbc4015ccebbe757d24ea60ed2a
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "6.3.0"
|
|
||||||
google_sign_in_android:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: google_sign_in_android
|
|
||||||
sha256: d5e23c56a4b84b6427552f1cf3f98f716db3b1d1a647f16b96dbb5b93afa2805
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "6.2.1"
|
|
||||||
google_sign_in_ios:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: google_sign_in_ios
|
|
||||||
sha256: "102005f498ce18442e7158f6791033bbc15ad2dcc0afa4cf4752e2722a516c96"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "5.9.0"
|
|
||||||
google_sign_in_platform_interface:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: google_sign_in_platform_interface
|
|
||||||
sha256: "5f6f79cf139c197261adb6ac024577518ae48fdff8e53205c5373b5f6430a8aa"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.5.0"
|
|
||||||
google_sign_in_web:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: google_sign_in_web
|
|
||||||
sha256: "460547beb4962b7623ac0fb8122d6b8268c951cf0b646dd150d60498430e4ded"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.12.4+4"
|
|
||||||
gotrue:
|
gotrue:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -589,14 +517,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
image:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: image
|
|
||||||
sha256: "89ed75b2f8231fe676808c5564f9590d728024ded7ac782a7ed8474f1c95b72a"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "4.12.0"
|
|
||||||
intl:
|
intl:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -733,14 +653,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.2.3"
|
version: "5.2.3"
|
||||||
msix:
|
|
||||||
dependency: "direct dev"
|
|
||||||
description:
|
|
||||||
name: msix
|
|
||||||
sha256: "61415c352e8aea084332b8a5514e6408c961c8cdeca202804fff1040eb555ac7"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.18.0"
|
|
||||||
native_toolchain_c:
|
native_toolchain_c:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -877,14 +789,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.3"
|
version: "1.5.3"
|
||||||
posix:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: posix
|
|
||||||
sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "6.5.2"
|
|
||||||
postgrest:
|
postgrest:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
12
pubspec.yaml
12
pubspec.yaml
|
|
@ -55,7 +55,6 @@ dependencies:
|
||||||
sqlite3: ^3.4.0
|
sqlite3: ^3.4.0
|
||||||
# Scheduling support for flutter_local_notifications (zonedSchedule).
|
# Scheduling support for flutter_local_notifications (zonedSchedule).
|
||||||
timezone: ^0.9.4
|
timezone: ^0.9.4
|
||||||
google_sign_in: 6.3.0
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
@ -68,17 +67,6 @@ dev_dependencies:
|
||||||
# Riverpod 3 migration.
|
# Riverpod 3 migration.
|
||||||
build_runner: ^2.16.0
|
build_runner: ^2.16.0
|
||||||
drift_dev: ^2.35.1
|
drift_dev: ^2.35.1
|
||||||
msix: ^3.16.7
|
|
||||||
|
|
||||||
msix_config:
|
|
||||||
display_name: GLP-1 Companion
|
|
||||||
publisher_display_name: Kell Engineering LLC
|
|
||||||
identity_name: KellEngineering.GLP1Companion
|
|
||||||
publisher: CN=Kell Engineering LLC, O=Kell Engineering LLC, L=Valrico, S=Florida, C=US
|
|
||||||
msix_version: 0.1.0.0
|
|
||||||
logo_path: web/icons/Icon-512.png
|
|
||||||
capabilities: internetClient
|
|
||||||
execution_alias: glp-companion
|
|
||||||
|
|
||||||
# Compile SQLCipher into the SQLite engine bundled by package:sqlite3's build
|
# Compile SQLCipher into the SQLite engine bundled by package:sqlite3's build
|
||||||
# hooks (the sqlcipher_flutter_libs plugin is retired on sqlite3 3.x).
|
# hooks (the sqlcipher_flutter_libs plugin is retired on sqlite3 3.x).
|
||||||
|
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
param(
|
|
||||||
[Parameter(Mandatory=$true)]
|
|
||||||
[string]$Version,
|
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
|
||||||
[string]$ForgejoToken =$env:FORGEJO_TOKEN,
|
|
||||||
|
|
||||||
[string]$Server = "https://git.kellsupport.com",
|
|
||||||
[string]$Owner = "mtkell",
|
|
||||||
[string]$Repo = "glp-companion"
|
|
||||||
)
|
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
|
|
||||||
if (-not $ForgejoToken) {
|
|
||||||
Write-Error "Forgejo Token missing! Pass -ForgejoToken <token> or set `$env:FORGEJO_TOKEN."
|
|
||||||
}
|
|
||||||
|
|
||||||
$DistDir = "dist\v$Version"
|
|
||||||
$Tag = "v$Version"
|
|
||||||
|
|
||||||
Write-Host "==================================================" -ForegroundColor Cyan
|
|
||||||
Write-Host " Building & Publishing Alpha Release: $Tag" -ForegroundColor Cyan
|
|
||||||
Write-Host " Target: $Server/$Owner/$Repo" -ForegroundColor Cyan
|
|
||||||
Write-Host "==================================================" -ForegroundColor Cyan
|
|
||||||
|
|
||||||
New-Item -ItemType Directory -Force -Path $DistDir | Out-Null
|
|
||||||
|
|
||||||
Write-Host "`n[1/5] Running Drift build_runner..." -ForegroundColor Yellow
|
|
||||||
dart run build_runner build --delete-conflicting-outputs
|
|
||||||
|
|
||||||
Write-Host "`n[2/5] Building Android arm64 APK..." -ForegroundColor Yellow
|
|
||||||
flutter build apk --release --split-per-abi
|
|
||||||
$ApkSrc = "build\app\outputs\flutter-apk\app-arm64-v8a-release.apk"
|
|
||||||
$ApkDest = "$DistDir\GLP1Companion-Android-arm64-$Tag.apk"
|
|
||||||
Copy-Item $ApkSrc $ApkDest -Force
|
|
||||||
|
|
||||||
Write-Host "`n[3/5] Building Windows Release & Packaging Standalone Zip..." -ForegroundColor Yellow
|
|
||||||
flutter build windows --release
|
|
||||||
$ZipDest = "$DistDir\GLP1Companion-Windows-x64-$Tag-Portable.zip"
|
|
||||||
Compress-Archive -Path "build\windows\x64\runner\Release\*" -DestinationPath $ZipDest -Force
|
|
||||||
|
|
||||||
Write-Host "`n[4/5] Building Windows MSIX..." -ForegroundColor Yellow
|
|
||||||
dart run msix:create --install-certificate false
|
|
||||||
$MsixSrc = Get-ChildItem "build\windows\x64\runner\Release\*.msix" | Select-Object -First 1
|
|
||||||
$MsixDest = "$DistDir\GLP1Companion-Windows-$Tag.msix"
|
|
||||||
Copy-Item $MsixSrc.FullName $MsixDest -Force
|
|
||||||
|
|
||||||
Write-Host "`n[5/5] Publishing Release & Assets to Forgejo..." -ForegroundColor Yellow
|
|
||||||
$Headers = @{
|
|
||||||
"Authorization" = "token $ForgejoToken"
|
|
||||||
"Accept" = "application/json"
|
|
||||||
}
|
|
||||||
|
|
||||||
$ReleaseBody = @{
|
|
||||||
tag_name = $Tag
|
|
||||||
name = "GLP-1 Companion $Tag"
|
|
||||||
body = "Automated alpha release build for $Tag containing Android APK and Windows packages."
|
|
||||||
draft = $false
|
|
||||||
prerelease = $true
|
|
||||||
} | ConvertTo-Json
|
|
||||||
|
|
||||||
$ReleaseUrl = "$Server/api/v1/repos/$Owner/$Repo/releases"
|
|
||||||
|
|
||||||
try {
|
|
||||||
$ReleaseResp = Invoke-RestMethod -Uri $ReleaseUrl -Method Post -Headers $Headers -Body $ReleaseBody -ContentType "application/json"
|
|
||||||
$ReleaseId = $ReleaseResp.id
|
|
||||||
Write-Host "Created release $ReleaseId for$Tag" -ForegroundColor Green
|
|
||||||
} catch {
|
|
||||||
Write-Host "Release for $Tag may already exist, fetching existing ID..." -ForegroundColor Gray
|
|
||||||
$Existing = Invoke-RestMethod -Uri "$ReleaseUrl/tags/$Tag" -Method Get -Headers $Headers
|
|
||||||
$ReleaseId = $Existing.id
|
|
||||||
Write-Host "Using existing release ID: $ReleaseId" -ForegroundColor Green
|
|
||||||
}
|
|
||||||
|
|
||||||
$Assets = @($ApkDest, $ZipDest,$MsixDest)
|
|
||||||
|
|
||||||
foreach ($FilePath in $Assets) {
|
|
||||||
if (Test-Path $FilePath) {
|
|
||||||
$FileName = [System.IO.Path]::GetFileName($FilePath)
|
|
||||||
Write-Host "Uploading $FileName..." -ForegroundColor Cyan
|
|
||||||
|
|
||||||
& curl.exe -s -X POST "$Server/api/v1/repos/$Owner/$Repo/releases/$ReleaseId/assets?name=$FileName" `
|
|
||||||
-H "Authorization: token $ForgejoToken" `
|
|
||||||
-H "Content-Type: multipart/form-data" `
|
|
||||||
-F "attachment=@$FilePath" | Out-Null
|
|
||||||
|
|
||||||
Write-Host " -> Uploaded $FileName" -ForegroundColor Green
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "`nSuccessfully released $Tag to$Server/$Owner/$Repo/releases!" -ForegroundColor Green
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
$Server = "https://git.kellsupport.com"
|
|
||||||
$Owner = "mtkell"
|
|
||||||
$Repo = "glp-companion"
|
|
||||||
$Tag = "v0.0.1-test"
|
|
||||||
|
|
||||||
# Token is read from the environment only — never hardcode credentials here.
|
|
||||||
$Token = $env:FORGEJO_TOKEN
|
|
||||||
if (-not $Token) {
|
|
||||||
Write-Error "FORGEJO_TOKEN environment variable is not set. Export it before running this script."
|
|
||||||
}
|
|
||||||
|
|
||||||
$DummyFile = "dummy-asset-test.txt"
|
|
||||||
"Forgejo API asset upload verification payload - $(Get-Date)" | Set-Content -Path $DummyFile -Encoding utf8
|
|
||||||
|
|
||||||
$Headers = @{
|
|
||||||
"Authorization" = "token $Token"
|
|
||||||
"Accept" = "application/json"
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "Checking for release for tag: $Tag..." -ForegroundColor Cyan
|
|
||||||
$ReleaseUrl = "$Server/api/v1/repos/$Owner/$Repo/releases"
|
|
||||||
|
|
||||||
try {
|
|
||||||
$Release = Invoke-RestMethod -Uri "$ReleaseUrl/tags/$Tag" -Headers $Headers -Method Get
|
|
||||||
$ReleaseId =$Release.id
|
|
||||||
Write-Host "Found existing release ID: $ReleaseId" -ForegroundColor Green
|
|
||||||
} catch {
|
|
||||||
Write-Host "Creating new draft test release for tag $Tag..." -ForegroundColor Yellow
|
|
||||||
$Release = Invoke-RestMethod -Uri $ReleaseUrl -Headers $Headers -Method Post -ContentType "application/json" -Body (@{
|
|
||||||
tag_name = $Tag
|
|
||||||
name = "Test Asset Upload Release"
|
|
||||||
body = "Testing Forgejo attachment upload endpoint."
|
|
||||||
draft = $true
|
|
||||||
prerelease = $true
|
|
||||||
} | ConvertTo-Json)
|
|
||||||
$ReleaseId =$Release.id
|
|
||||||
Write-Host "Created test release ID: $ReleaseId" -ForegroundColor Green
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "Uploading $DummyFile to release ID$ReleaseId via curl..." -ForegroundColor Cyan
|
|
||||||
$UploadUrl = "$Server/api/v1/repos/$Owner/$Repo/releases/$ReleaseId/assets?name=$DummyFile"
|
|
||||||
|
|
||||||
& curl.exe -s -X POST $UploadUrl `
|
|
||||||
-H "Authorization: token $Token" `
|
|
||||||
-F "attachment=@$DummyFile" `
|
|
||||||
-o "curl_response.json"
|
|
||||||
|
|
||||||
Write-Host "`nServer Response:" -ForegroundColor Gray
|
|
||||||
Get-Content -Path "curl_response.json"
|
|
||||||
|
|
||||||
Remove-Item -Path $DummyFile -Force -ErrorAction SilentlyContinue
|
|
||||||
Remove-Item -Path "curl_response.json" -Force -ErrorAction SilentlyContinue
|
|
||||||
|
|
@ -86,7 +86,6 @@ class _FakeHealthSyncRepository implements IHealthSyncRepository {
|
||||||
class _FakeHealthGateway extends IHealthGateway {
|
class _FakeHealthGateway extends IHealthGateway {
|
||||||
bool nativeSupported = true;
|
bool nativeSupported = true;
|
||||||
bool granted = true;
|
bool granted = true;
|
||||||
bool cloudReady = false;
|
|
||||||
List<HealthMetric> weights = [];
|
List<HealthMetric> weights = [];
|
||||||
List<HealthMetric> energies = [];
|
List<HealthMetric> energies = [];
|
||||||
int permissionRequests = 0;
|
int permissionRequests = 0;
|
||||||
|
|
@ -105,9 +104,6 @@ class _FakeHealthGateway extends IHealthGateway {
|
||||||
return granted;
|
return granted;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
Future<bool> isCloudSyncReady() async => cloudReady;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<HealthMetric>> fetchWeightHistory({
|
Future<List<HealthMetric>> fetchWeightHistory({
|
||||||
required DateTime start,
|
required DateTime start,
|
||||||
|
|
@ -290,32 +286,6 @@ void main() {
|
||||||
expect(repository.entries, isEmpty);
|
expect(repository.entries, isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
|
||||||
'native denial with a connected cloud partner still syncs partner '
|
|
||||||
'data', () async {
|
|
||||||
gateway.granted = false;
|
|
||||||
gateway.cloudReady = true;
|
|
||||||
gateway.weights = [
|
|
||||||
HealthMetric(
|
|
||||||
timestamp: DateTime.now().subtract(const Duration(hours: 1)),
|
|
||||||
value: 182,
|
|
||||||
unit: 'lb',
|
|
||||||
),
|
|
||||||
];
|
|
||||||
await load();
|
|
||||||
|
|
||||||
final ok = await notifier().syncFromHealth();
|
|
||||||
|
|
||||||
expect(ok, isTrue);
|
|
||||||
// The native grant is still requested exactly once…
|
|
||||||
expect(gateway.permissionRequests, 1);
|
|
||||||
final state = container.read(biometricControllerProvider).valueOrNull!;
|
|
||||||
// …but the denial no longer blocks the cloud-backed sync.
|
|
||||||
expect(state.statusMessage, contains('Synced 1 weight sample(s)'));
|
|
||||||
expect(state.statusMessage, isNot(contains('denied')));
|
|
||||||
expect(repository.entries, hasLength(1));
|
|
||||||
});
|
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'imports weights and per-day active energy, then re-syncs without '
|
'imports weights and per-day active energy, then re-syncs without '
|
||||||
'duplicates', () async {
|
'duplicates', () async {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue