8000 feat(ui): add safe Today navigation shell by shroominic · Pull Request #69 · shroominic/OpenGlucose · GitHub
[go: up one dir, main page]

Skip to content
Draft
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ expectations are defined in [docs/compatibility.md](docs/compatibility.md).

## [Unreleased]

### Added

- A state-restored Today, Timeline, and Trends product shell. Timeline and
Trends show glucose context only while the current sensor state is usable;
warmup, stale, unavailable, and retained readings remain explicit and are
never labelled as live. The unified multi-source body timeline, journal
entries, imports, and final dark-theme migration remain separate work.

## [0.1.4] - 2026-08-22

### Added
Expand Down
20 changes: 20 additions & 0 deletions docs/review-evidence/issue-38/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Issue 38 review evidence

The screenshots in this folder use synthetic, redacted fixtures. They contain
no real sensor identifier, connection metadata, or glucose value.

- `compact-375.png` shows the 375 x 812 compact Trends layout after the
timestamp freshness boundary. The live-pattern metrics are absent.
- `wide-900.png` shows the 900 x 900 rail layout after the 15-day sensor-life
boundary. The Timeline has no live glucose list.

`openhealth/test/today_navigation_test.dart` verifies the same two transitions
with a controlled clock and no session snapshot event. It also enables Flutter
semantics and verifies a labelled live region for both stale and inactive
states, as well as the named navigation destinations. Flutter exposes this
semantics tree to VoiceOver and TalkBack.

No physical VoiceOver or TalkBack session was run for this synthetic review
capture. The controls register has no established device accessibility lane;
this is not a waiver. The accountable owner must perform and record device
screen-reader verification before a production-readiness claim.
Binary file added docs/review-evidence/issue-38/compact-375.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/review-evidence/issue-38/wide-900.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
208 changes: 148 additions & 60 deletions openhealth/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'package:cgm_ble/cgm_ble.dart';
import 'package:cgm_core/cgm_core.dart';
import 'package:openglucose/src/ai/ai_settings_pane.dart';
import 'package:openglucose/src/app_controller.dart';
import 'package:openglucose/src/app_theme.dart';
import 'package:openglucose/src/dashboard_chart.dart';
import 'package:openglucose/src/display_preferences.dart';
import 'package:openglucose/src/driver_factory.dart';
Expand All @@ -26,6 +27,7 @@ import 'package:openglucose/src/sensor_archive_export.dart';
import 'package:openglucose/src/sensor_archive_share_file.dart';
import 'package:openglucose/src/sample_dashboard_screen.dart';
import 'package:openglucose/src/session_presentation.dart';
import 'package:openglucose/src/today_navigation.dart';
import 'package:openglucose/src/weekly_recap/weekly_recap_screen.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
Expand Down Expand Up @@ -298,6 +300,8 @@ class OpenGlucoseApp extends StatelessWidget {
required this.preferences,
this.messageController,
this.archivedSensorShareAction,
this.clock = DateTime.now,
this.foregroundFreshnessInterval = const Duration(seconds: 45),
});

final CgmAppController controller;
Expand All @@ -311,45 +315,19 @@ class OpenGlucoseApp extends StatelessWidget {
/// Optional share-sheet seam used by export integration tests.
final ArchivedSensorShareAction? archivedSensorShareAction;

/// Presentation clock shared with the home screen.
final DateTime Function() clock;

/// Foreground interval for best-effort device refreshes.
final Duration foregroundFreshnessInterval;

@override
Widget build(BuildContext context) {
const seed = Color(0xFF0B6E69);
final colorScheme = ColorScheme.fromSeed(
seedColor: seed,
brightness: Brightness.light,
surface: const Color(0xFFFFF8F1),
);
return MaterialApp(
title: 'OpenGlucose',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: colorScheme,
scaffoldBackgroundColor: const Color(0xFFF6EFE6),
useMaterial3: true,
appBarTheme: const AppBarTheme(
backgroundColor: Colors.transparent,
elevation: 0,
surfaceTintColor: Colors.transparent,
),
cardTheme: CardThemeData(
color: Colors.white.withValues(alpha: 0.94),
surfaceTintColor: Colors.transparent,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: const Color(0xFFF4F6F2),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFFD8E3DE)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFFD8E3DE)),
),
),
),
restorationScopeId: 'openglucose',
theme: OpenGlucoseTokens.lightTheme(),
// --- TASK-007 onboarding gate ---
// First-run only: show the skippable onboarding flow, then hand off to
// the existing scan/connect home. Persisted via OnboardingStore; once
Expand All @@ -365,6 +343,8 @@ class OpenGlucoseApp extends StatelessWidget {
home: CgmHomePage(
controller: controller,
messageController: messageController,
clock: clock,
foregroundFreshnessInterval: foregroundFreshnessInterval,
),
),
),
Expand Down Expand Up @@ -424,32 +404,71 @@ class CgmHomePage extends StatefulWidget {
super.key,
required this.controller,
this.messageController,
this.clock = DateTime.now,
this.foregroundFreshnessInterval = const Duration(seconds: 45),
});

final CgmAppController controller;
final MessageController? messageController;

/// Presentation clock used to evaluate data age and sensor lifecycle.
final DateTime Function() clock;

/// Best-effort device refresh interval while the app is foregrounded.
final Duration foregroundFreshnessInterval;

@override
State<CgmHomePage> createState() => _CgmHomePageState();
}

class _CgmHomePageState extends State<CgmHomePage> with WidgetsBindingObserver {
static const _foregroundFreshnessInterval = Duration(seconds: 45);

class _CgmHomePageState extends State<CgmHomePage>
with WidgetsBindingObserver, RestorationMixin {
Timer? _freshnessTimer;
Timer? _presentationDeadlineTimer;
final RestorableInt _destinationIndex = RestorableInt(
OpenGlucoseDestination.today.index,
);

@override
String? get restorationId => 'cgm-home';

@override
void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
registerForRestoration(_destinationIndex, 'selected-destination');
}

@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_freshnessTimer = Timer.periodic(_foregroundFreshnessInterval, (_) {
unawaited(widget.controller.ensureFreshData());
});
widget.controller.addListener(_onControllerChanged);
_startForegroundRefreshTimer();
_schedulePresentationReevaluation();
}

@override
void didUpdateWidget(covariant CgmHomePage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller != widget.controller) {
oldWidget.controller.removeListener(_onControllerChanged);
widget.controller.addListener(_onControllerChanged);
}
if (oldWidget.foregroundFreshnessInterval !=
widget.foregroundFreshnessInterval) {
_startForegroundRefreshTimer();
}
if (oldWidget.controller != widget.controller ||
oldWidget.clock != widget.clock) {
_schedulePresentationReevaluation();
}
}

@override
void dispose() {
_freshnessTimer?.cancel();
_presentationDeadlineTimer?.cancel();
widget.controller.removeListener(_onControllerChanged);
_destinationIndex.dispose();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
Expand All @@ -459,15 +478,66 @@ class _CgmHomePageState extends State<CgmHomePage> with WidgetsBindingObserver {
if (state != AppLifecycleState.resumed) {
return;
}
setState(() {});
_schedulePresentationReevaluation();
unawaited(widget.controller.ensureFreshData(force: true));
}

void _startForegroundRefreshTimer() {
_freshnessTimer?.cancel();
_freshnessTimer = Timer.periodic(widget.foregroundFreshnessInterval, (_) {
unawaited(widget.controller.ensureFreshData());
});
}

void _onControllerChanged() {
if (!mounted) {
return;
}
_schedulePresentationReevaluation();
}

/// Rebuild at the next wall-clock freshness or lifecycle boundary.
///
/// This is deliberately separate from [CgmAppController.ensureFreshData]. A
/// device refresh can take time or never produce a snapshot, but that must
/// not leave Timeline or Trends showing values as live after their deadline.
void _schedulePresentationReevaluation() {
_presentationDeadlineTimer?.cancel();
final now = widget.clock();
final deadline = nextLivePresentationDeadline(
widget.controller.snapshot,
widget.controller.displayLatestReading,
now: now,
);
if (deadline == null) {
return;
}
final delay = deadline.difference(now);
_presentationDeadlineTimer = Timer(
delay.isNegative ? Duration.zero : delay,
_onPresentationDeadline,
);
}

void _onPresentationDeadline() {
if (!mounted) {
return;
}
setState(() {});
_schedulePresentationReevaluation();
}

@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: widget.controller,
builder: (context, _) {
final snapshot = widget.controller.snapshot;
final presentationNow = widget.clock();
final destination = OpenGlucoseDestination.fromRestoredIndex(
_destinationIndex.value,
);
// Contextual-messaging bridge: recompute which messages are relevant
// from the latest app state on every controller change. Deferred to
// post-frame so it never triggers a rebuild during this build pass.
Expand All @@ -481,25 +551,41 @@ class _CgmHomePageState extends State<CgmHomePage> with WidgetsBindingObserver {
}
return Scaffold(
body: DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: <Color>[
Color(0xFFF7F0E4),
Color(0xFFE9F3EF),
Color(0xFFF7F5EE),
],
),
),
decoration: OpenGlucoseTokens.backgroundDecoration,
child: SafeArea(
child: snapshot == null
? _ScanView(controller: widget.controller)
: _DashboardView(
controller: widget.controller,
snapshot: snapshot,
messageController: widget.messageController,
),
child: OpenGlucoseNavigationShell(
destination: destination,
onDestinationSelected: (selection) {
setState(() => _destinationIndex.value = selection.index);
},
today: snapshot == null
? _ScanView(controller: widget.controller)
: _DashboardView(
controller: widget.controller,
snapshot: snapshot,
messageController: widget.messageController,
),
timeline: GlucoseTimelinePane(
snapshot: snapshot,
displayReading: widget.controller.displayLatestReading,
visibleHistory: widget.controller.visibleHistory,
retainedHistoryCount:
widget.controller.allHistoricalReadings.length,
preferences: widget.controller.displayPreferences,
isSampleData: widget.controller.isMockDriver,
now: presentationNow,
),
trends: GlucoseTrendsPane(
snapshot: snapshot,
displayReading: widget.controller.displayLatestReading,
visibleHistory: widget.controller.visibleHistory,
retainedHistoryCount:
widget.controller.allHistoricalReadings.length,
preferences: widget.controller.displayPreferences,
isSampleData: widget.controller.isMockDriver,
now: presentationNow,
),
),
),
),
);
Expand Down Expand Up @@ -651,15 +737,17 @@ class _ScanView extends StatelessWidget {
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6),
child: Row(
child: Wrap(
spacing: 8,
runSpacing: 2,
alignment: WrapAlignment.spaceBetween,
children: <Widget>[
Text(
'Nearby sensors',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
const Spacer(),
Text(
'${controller.sensors.length} found',
style: theme.textTheme.bodyMedium?.copyWith(
Expand Down
22 changes: 2 additions & 20 deletions openhealth/lib/src/app_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ class CgmAppController extends ChangeNotifier {
static const _scanTimeout = Duration(seconds: 6);
static const _historyPersistDebounce = Duration(milliseconds: 900);
static const _restoredConnectDelay = Duration(milliseconds: 700);
static const _liveRefreshThreshold = Duration(minutes: 2);
static const _historyCatchUpThreshold = Duration(minutes: 5);
static const _resumeOffsetMetadataKey = 'resumeOffset';
static const _resumeCountMetadataKey = 'resumeCount';
Expand Down Expand Up @@ -1783,25 +1782,8 @@ class CgmAppController extends ChangeNotifier {
);
}

bool _needsLiveRefresh(CgmSessionSnapshot snapshot) {
final latest = latestReading;
if (latest == null) {
return true;
}
final recordedAt = latest.recordedAt;
if (recordedAt != null &&
DateTime.now().difference(recordedAt) >= _liveRefreshThreshold) {
return true;
}
final latestMinute = latest.sensorMinute;
final elapsedMinutes = snapshot.sessionInfo.elapsedMinutes;
if (latestMinute != null &&
elapsedMinutes != null &&
elapsedMinutes - latestMinute >= 2) {
return true;
}
return recordedAt == null && latestMinute == null;
}
bool _needsLiveRefresh(CgmSessionSnapshot snapshot) =>
needsLiveReadingRefresh(snapshot, latestReading);

bool _needsHistoryCatchUp(CgmSessionSnapshot snapshot) {
if (!snapshot.capabilities.supportsHistory) {
Expand Down
Loading
0