Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -115,14 +115,26 @@ jobs:
echo "EOF"
} >> $GITHUB_OUTPUT

- name: Decode release keystore
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > /tmp/repforge-release.jks

- name: Build APK
working-directory: ./workout-logger
run: flutter build apk --release
env:
KEYSTORE_PATH: /tmp/repforge-release.jks
KEY_STORE_PASSWORD: ${{ secrets.KEY_STORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: flutter build apk --release --split-per-abi

- name: Rename APK
- name: Rename APKs
run: |
mv workout-logger/build/app/outputs/flutter-apk/app-release.apk \
workout-logger/build/app/outputs/flutter-apk/repforge-v${{ steps.version.outputs.value }}.apk
V="${{ steps.version.outputs.value }}"
DIR="workout-logger/build/app/outputs/flutter-apk"
mv "$DIR/app-arm64-v8a-release.apk" "$DIR/repforge-v${V}-arm64-v8a.apk" 2>/dev/null || true
mv "$DIR/app-armeabi-v7a-release.apk" "$DIR/repforge-v${V}-armeabi-v7a.apk" 2>/dev/null || true
mv "$DIR/app-x86_64-release.apk" "$DIR/repforge-v${V}-x86_64.apk" 2>/dev/null || true

- name: Sanitize ref name for artifact
id: sanitize_ref
Expand All @@ -133,7 +145,7 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: repforge-v${{ steps.version.outputs.value }}-${{ steps.sanitize_ref.outputs.ref_name }}
path: workout-logger/build/app/outputs/flutter-apk/repforge-v${{ steps.version.outputs.value }}.apk
path: workout-logger/build/app/outputs/flutter-apk/repforge-v${{ steps.version.outputs.value }}-*.apk
retention-days: 7

- name: Create GitHub Release
Expand All @@ -156,7 +168,7 @@ jobs:
- **Build Date**: ${{ github.event.head_commit.timestamp }}
- **Commit**: ${{ github.sha }}
files: |
workout-logger/build/app/outputs/flutter-apk/repforge-v${{ steps.version.outputs.value }}.apk
workout-logger/build/app/outputs/flutter-apk/repforge-v${{ steps.version.outputs.value }}-*.apk
draft: false
prerelease: false
env:
Expand Down
92 changes: 92 additions & 0 deletions docs/FUTURE_IMPROVEMENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Future Improvements — Open Source Store Launch

This file tracks the remaining work for Options B and C of the open-source store launch plan.
Option A (production signing + IzzyOnDroid/Obtainium) is complete.

---

## Option B — F-Droid Readiness

### 1. Bundle Geist fonts locally (google_fonts)

Currently `google_fonts` may fetch font files from Google's CDN at first launch. F-Droid requires
all network access to be under user control — a silent font download at startup fails that bar.

**Fix:** Download the Geist Sans and Geist Mono `.ttf` files, add them to `assets/fonts/`, declare
them in `pubspec.yaml` under `flutter.fonts`, and replace `GoogleFonts.geist(...)` calls with
`TextStyle(fontFamily: 'Geist')`. Then remove the `google_fonts` package.

### 2. F-Droid metadata file

Create `fdroid/metadata/com.devasy.repforge.yml` following the F-Droid metadata spec:

```yaml
Categories:
- Sports & Health
License: Apache-2.0
SourceCode: https://github.com/<your-org>/repforge
IssueTracker: https://github.com/<your-org>/repforge/issues

AutoName: RepForge
Summary: Workout logger with AI-powered coaching
Description: |-
RepForge is an open-source workout logging app with set/rep/weight tracking,
progress analytics, AI coaching (optional, requires user-supplied Gemini API key),
and Health Connect integration.

AntiFeatures:
NonFreeNet:
- description: >
Optional AI Coach and Routine Optimizer features send data to Google's Gemini API.
These features are disabled unless the user provides their own API key in Settings.

Builds:
- versionName: 2.x.x
versionCode: xx
commit: vX.X.X
subdir: workout-logger
gradle:
- release
```

### 3. Fastlane store metadata

Create `fastlane/metadata/android/en-US/` with:
- `title.txt` — "RepForge"
- `short_description.txt` — one-line summary (≤80 chars)
- `full_description.txt` — full store description
- `changelogs/<versionCode>.txt` — per-release changelog

IzzyOnDroid also reads fastlane metadata for its store listing.

---

## Option C — Strict F-Droid Compliance

### 4. Health Connect graceful degradation

Health Connect is an OS API (not Google Play Services) so F-Droid accepts it. However, for
maximum compatibility on AOSP/custom ROMs without Health Connect:

- Add an `isHealthConnectAvailable()` check at startup
- Show a "Health Connect not available" state in the Readiness screen instead of crashing
- Make daily readiness score optional in the Home screen when Health Connect is absent

### 5. Replace google_fonts package entirely

After completing item B.1, the `google_fonts` package can be removed from `pubspec.yaml` entirely.
This eliminates any risk of runtime Google CDN fetches and removes a transitive dependency.

---

## IzzyOnDroid Submission Checklist

- [ ] Merge this branch to main and confirm a production-signed release appears on GitHub Releases
- [ ] Submit via: https://gitlab.com/IzzyOnDroid/repo/-/issues (open a new issue, "App submission" template)
- [ ] Provide: repo URL, anti-features (NonFreeNet), brief description
- [ ] Wait for review (typically 1–7 days)

## Obtainium

No submission needed. Users add the GitHub repo URL directly in Obtainium and install the latest
release APK automatically. Share the repo URL in your README.
209 changes: 209 additions & 0 deletions docs/superpowers/specs/2026-06-11-sleep-hr-chart-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
# Sleep HR Chart — Design Spec

**Date:** 2026-06-11
**Status:** Approved
**Feature area:** Readiness → Sleep heart-rate visualization

---

## 1. Problem

The Readiness feature currently reads resting HR and sleep duration from Health Connect. HRV is unavailable (Samsung Health writer has no HRV permission on this device). Minute-level heart-rate data during sleep is already accessible via `heartRateSeries`, and sleep stage timeline data is already extracted per `SleepPeriod` (`lightMinutes`, `deepMinutes`, `remMinutes`, `awakeMinutes`). Neither is surfaced to the user.

Users want to understand how their heart behaved overnight — specifically whether deep sleep reached a true low, whether REM stayed elevated, and what a clean P95 "resting proxy" looks like — without needing to open Samsung Health.

---

## 2. Goal

Two new surfaces:
1. **Compact card** on the home screen (below the readiness ring card) showing a sparkline + three key numbers.
2. **Full detail bottom sheet** accessible by tapping the compact card, showing:
- A 10-minute bar chart (low/high per segment, color-coded by sleep stage, moving-average trend line)
- A "HR range by stage" horizontal distribution chart (min–max + P25–P75 + avg for each of Awake, REM, Light, Deep)

---

## 3. Data Models

### 3.1 `SleepHrSegment` (new)

Represents one 10-minute window of the sleep period.

```dart
class SleepHrSegment {
final DateTime windowStart; // truncated to 10-min boundary
final int minBpm;
final int maxBpm;
final double avgBpm;
final String stage; // 'deep' | 'rem' | 'light' | 'awake'
}
```

### 3.2 `SleepStageStats` (new)

Aggregate stats for one stage, used by the distribution chart.

```dart
class SleepStageStats {
final String stage;
final int minBpm;
final int p25Bpm;
final double avgBpm;
final int p75Bpm;
final int maxBpm;
final int sampleCount;
}
```

### 3.3 `SleepHrSnapshot` (new)

Container stored in `ReadinessManager` and passed to both widgets.

```dart
class SleepHrSnapshot {
final DateTime sleepStart;
final DateTime sleepEnd;
final int p95Bpm; // P95 of all overnight HR samples
final List<SleepHrSegment> segments; // ordered by windowStart
final List<SleepStageStats> stageStats; // one entry per stage present
}
```

No persistence required — recomputed each `refresh()`. If the snapshot is null the compact card hides itself (`SizedBox.shrink()`).

---

## 4. Data Pipeline

### 4.1 New Health Connect service method

```dart
// IHealthConnectService
Future<List<HealthSample>> readHeartRateSamples(DateTime start, DateTime end);
// Already exists — no interface change needed.
```

`ReadinessManager.refresh()` calls `readHeartRateSamples(sleepStart - 30min, sleepEnd + 30min)` **only when** `HealthReadType.heartRate` is granted and at least one sleep period exists for last night.

### 4.2 Stage assignment per sample

Each `HealthSample` is tagged with the sleep stage active at its timestamp by walking the `SleepPeriod.samples` stage timeline (from `SleepSessionRecord.samples`, already loaded). Samples outside any stage window → tagged `'awake'`.

### 4.3 Segment aggregation

Samples are bucketed into 10-minute windows aligned to `sleepStart`. For each window: `minBpm`, `maxBpm`, `avgBpm` are computed. The stage for the window is the **mode** of sample stages in that window (most-frequent). Windows with zero samples are omitted.

### 4.4 P95 and stage stats

- **P95:** Sort all sample bpms → take the value at index `floor(0.95 * n)`.
- **Stage stats:** Group samples by stage → compute min, P25, avg, P75, max via sort-and-index.

### 4.5 Where it lives in `ReadinessManager`

```dart
SleepHrSnapshot? _sleepHrSnapshot;
SleepHrSnapshot? get sleepHrSnapshot => _sleepHrSnapshot;
```

Computed and stored at the end of `refresh()`, alongside the readiness score. Triggers `notifyListeners()` once (same call as the score update).

---

## 5. UI Components

### 5.1 `SleepHrCard` (compact, home screen)

**File:** `lib/screens/widgets/sleep_hr_card.dart`

Layout:
```
┌─────────────────────────────────┐
│ Sleep heart rate 1:24–8:17 │ ← header row
│ P95 67bpm REM 64bpm Deep 52bpm│ ← three mini-stats
│ [sparkline bar chart] │ ← canvas, 38dp tall
└─────────────────────────────────┘
```

- Tapping the card opens `SleepHrSheet` via `showModalBottomSheet`.
- Hidden (`SizedBox.shrink()`) when `snapshot.sleepHrSnapshot == null`.
- Placed in `HomeScreen` body, directly below `ReadinessCard`.

### 5.2 `SleepHrSheet` (full detail bottom sheet)

**File:** `lib/screens/widgets/sleep_hr_sheet.dart`

Sections top → bottom:
1. **Handle + title + subtitle** ("Sleep heart rate · 1:24 AM – 8:17 AM")
2. **Three key stats** (P95 HR, Deep avg, REM avg) in pill chips
3. **Bar chart** — `CustomPainter`, 140dp tall
- Y-axis: BPM labels (50, 60, 70, 80) with horizontal grid lines
- X-axis: time labels every 60 min
- Each bar: low→high range, fill color = stage color at 73% opacity
- Moving-average line (window=5 segments): `#00D9FF`, dashed
4. **Stage timeline bar** — thin colored strip below chart, same proportions
5. **Legend** (Deep / REM / Light / Awake / Avg line)
6. **"HR range by stage" section**
- Title label
- Four horizontal range rows: Awake → REM → Light → Deep (top → bottom)
- Each row: full-range bar (22% opacity) + IQR bar (72% opacity) + avg dot + avg bpm label
- Shared BPM x-axis with vertical grid lines (45, 50 … 85)
- Sub-legend: min–max / P25–P75 / Avg

Scrollable (`SingleChildScrollView`) so it fits all screen sizes.

---

## 6. Painting Strategy

Both the bar chart and the distribution chart use `CustomPainter` (not canvas HTML). Stage colors are sourced from a local constant map in the widget file; no dependency on `AppColors.muscleGroupColors`.

Stage color map:
```dart
const _stageColors = {
'deep': Color(0xFF4C8EFF),
'rem': Color(0xFFA78BFA),
'light': Color(0xFF34D399),
'awake': Color(0xFFF59E0B),
};
```

---

## 7. Error / Empty States

| Condition | Behavior |
|-----------|----------|
| `heartRate` not granted | `sleepHrSnapshot` = null → compact card hidden |
| Sleep period missing | `sleepHrSnapshot` = null → compact card hidden |
| < 5 HR samples in a segment | Segment omitted from chart |
| Stage has < 3 samples | `SleepStageStats` for that stage omitted from distribution |
| Sheet opened with null snapshot | Should not happen (card hidden); guard with early return |

---

## 8. HRV Lookback Cleanup

`_todayHrv()` in `ReadinessManager` currently uses a 30-day diagnostic window. This should be reverted to 48 hours once the Sleep HR feature ships (confirms the device never writes HRV, so the wide window has no ongoing value).

---

## 9. Files Changed / Created

| Action | File |
|--------|------|
| New | `lib/models/sleep_hr_models.dart` — `SleepHrSegment`, `SleepStageStats`, `SleepHrSnapshot` |
| Modified | `lib/services/managers/readiness_manager.dart` — add `_buildSleepHrSnapshot()`, store result |
| New | `lib/screens/widgets/sleep_hr_card.dart` |
| New | `lib/screens/widgets/sleep_hr_sheet.dart` |
| Modified | `lib/screens/home_screen.dart` (or equivalent) — insert `SleepHrCard` below `ReadinessCard` |
| Modified | `lib/services/managers/readiness_manager.dart` — revert HRV window to 48h |

---

## 10. Out of Scope

- Trend over multiple nights (tonight vs last 7 nights) — future feature
- Tap-to-see-segment detail in the bar chart — future feature
- P95 participating in the readiness score formula — deferred; it replaces HRV only if baseline data accumulates
- Exporting or sharing the chart
27 changes: 21 additions & 6 deletions workout-logger/android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,23 @@ android {
jvmTarget = JavaVersion.VERSION_11.toString()
}

signingConfigs {
create("release") {
val keystorePath = System.getenv("KEYSTORE_PATH")
val storePass = System.getenv("KEY_STORE_PASSWORD")
val alias = System.getenv("KEY_ALIAS")
val keyPass = System.getenv("KEY_PASSWORD")
if (keystorePath != null && storePass != null && alias != null && keyPass != null) {
storeFile = file(keystorePath)
storePassword = storePass
keyAlias = alias
keyPassword = keyPass
}
}
}

defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.devasy.repforge"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
// MIGRATION NOTE: minSdk is intentionally set to 26 (Android 8.0 Oreo).
// Health Connect requires API 26+. Devices running API <26 are no longer
// supported. If downgrading, remove the health_connector dependency and
Expand All @@ -48,9 +60,12 @@ android {
manifestPlaceholders["appLabel"] = "RepForge (Debug)"
}
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
// Uses the production EC P-256 keystore when KEYSTORE_PATH env var is set
// (CI injects it via GitHub Secrets). Falls back to debug key for local
// flutter run --release without env vars configured.
val releaseConfig = signingConfigs.getByName("release")
signingConfig = if (releaseConfig.storeFile != null) releaseConfig
else signingConfigs.getByName("debug")
}
}
}
Expand Down
Loading
Loading