From 84a98f4b325240935786816c7263f731fc5170d5 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:38:04 +0530 Subject: [PATCH 1/5] refactor(ui): extract shared screen chrome into rf_shell, refine workout flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls the header/icon-button/screen chrome that each screen had been rebuilding by hand into a single rf_shell.dart, then rewrites the workout flow, coach, and summary screens on top of it. - rf_shell: RFIconButton and RFScreenHeader — one fill, one size, tooltips required on icon-only buttons so they carry a screen-reader label. - rf_widgets/rf_dialogs: shared dialog chrome, AmbientGlow with an AmbientMotionScope installed above the Navigator in main.dart so every route feeds the same glow. - exercise_input_section: the set-entry UI no longer clips the weight field or overruns the assisted-load pill at large system font sizes; covered by exercise_input_section_text_scale_test across 3 widths x 3 text scales. - workout_flow/workout_summary/ai_coach/workout_header/floating_nav_bar/ rest_timer_view: rebuilt on the shared chrome, net ~1k lines lighter. - flutter_test_config.dart pins AmbientGlow.motionEnabled = false for the suite; its drift loop never completes and would hang pumpAndSettle. 1032 tests pass; flutter analyze is clean. Co-Authored-By: Claude Opus 5 --- workout-logger/lib/main.dart | 4 + .../lib/screens/ai_coach_screen.dart | 645 ++++++++---------- .../widgets/exercise_input_section.dart | 352 +++++----- .../lib/screens/widgets/floating_nav_bar.dart | 52 +- .../lib/screens/widgets/rest_timer_view.dart | 24 +- .../lib/screens/widgets/rf_dialogs.dart | 172 +++++ .../lib/screens/widgets/rf_shell.dart | 385 +++++++++++ .../lib/screens/widgets/rf_widgets.dart | 401 +++++++++-- .../lib/screens/widgets/workout_header.dart | 172 ++--- .../lib/screens/workout_flow_screen.dart | 293 +++----- .../lib/screens/workout_summary_screen.dart | 151 ++-- workout-logger/lib/theme/app_theme.dart | 25 + workout-logger/test/flutter_test_config.dart | 12 + ...xercise_input_section_text_scale_test.dart | 165 +++++ .../workout_flow_screen_full_test.dart | 4 +- .../test/userflow_screens_sweep_test.dart | 4 +- .../test/userflow_workout_logging_test.dart | 10 +- 17 files changed, 1895 insertions(+), 976 deletions(-) create mode 100644 workout-logger/lib/screens/widgets/rf_shell.dart create mode 100644 workout-logger/test/flutter_test_config.dart create mode 100644 workout-logger/test/screens/exercise_input_section_text_scale_test.dart diff --git a/workout-logger/lib/main.dart b/workout-logger/lib/main.dart index b7e13f1..edae728 100644 --- a/workout-logger/lib/main.dart +++ b/workout-logger/lib/main.dart @@ -37,6 +37,7 @@ import 'genui/a2ui.dart'; import 'theme/a2ui_app_theme.dart'; import 'screens/home_screen.dart'; import 'screens/onboarding_screen.dart'; +import 'screens/widgets/rf_widgets.dart'; /// Resolved once in main() before runApp(). Read lazily by /// WorkoutLoggerApp._storageService's static initializer, which only runs @@ -219,6 +220,9 @@ class WorkoutLoggerApp extends StatelessWidget { title: 'Workout Logger', debugShowCheckedModeBanner: false, theme: AppTheme.darkTheme, + // Above the Navigator, so every route feeds the ambient glow. + builder: (context, child) => + AmbientMotionScope(child: child ?? const SizedBox.shrink()), home: const AppInitializer(), ), ), diff --git a/workout-logger/lib/screens/ai_coach_screen.dart b/workout-logger/lib/screens/ai_coach_screen.dart index 7d96c51..224c51b 100644 --- a/workout-logger/lib/screens/ai_coach_screen.dart +++ b/workout-logger/lib/screens/ai_coach_screen.dart @@ -18,8 +18,12 @@ import '../services/managers/conversation_manager.dart'; import '../services/settings_provider.dart'; import '../theme/app_theme.dart'; import 'widgets/rf_widgets.dart'; +import 'widgets/rf_shell.dart'; import 'profile_screen.dart'; +/// Bubbles stop short of the far edge; full-bleed ones read as banners. +const double _kBubbleMaxWidthFactor = 0.82; + /// Public entry point. Owns the screen-scoped [AiCoachViewModel]. class AiCoachScreen extends StatelessWidget { const AiCoachScreen({super.key, this.seedPrompt}); @@ -103,7 +107,7 @@ class _AiCoachViewState extends State<_AiCoachView> { if (_scrollCtrl.hasClients) { _scrollCtrl.animateTo( _scrollCtrl.position.maxScrollExtent, - duration: const Duration(milliseconds: 250), + duration: AppDurations.moderate, curve: Curves.easeOut, ); } @@ -119,18 +123,17 @@ class _AiCoachViewState extends State<_AiCoachView> { body: Stack( children: [ const AmbientGlow(), - SafeArea( - child: Column( - children: [ - _buildHeader(context, vm), - Expanded( - child: vm.isConfigured - ? _buildChatArea(vm) - : _buildNoKeyState(context), - ), - if (vm.isConfigured) _buildInputBar(vm), - ], - ), + // No wrapping SafeArea: header takes the top inset, input bar the bottom. + Column( + children: [ + _buildHeader(context, vm), + Expanded( + child: vm.isConfigured + ? _buildChatArea(vm) + : _buildNoKeyState(context), + ), + if (vm.isConfigured) _buildInputBar(vm), + ], ), ], ), @@ -138,91 +141,25 @@ class _AiCoachViewState extends State<_AiCoachView> { } Widget _buildHeader(BuildContext context, AiCoachViewModel vm) { - return Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.md, - AppSpacing.sm, - AppSpacing.md, - 0, - ), - child: Row( - children: [ - GestureDetector( - onTap: () => Navigator.pop(context), - child: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: AppColors.glass3, - borderRadius: BorderRadius.circular(AppRadius.sm), - border: Border.all(color: AppColors.glassBorder), - ), - child: const Icon( - Icons.arrow_back_rounded, - color: AppColors.textSoft, - size: 18, - ), - ), + return RFScreenHeader( + title: 'AI Coach', + subtitle: 'Powered by Gemini', + badgeIcon: Icons.auto_awesome_rounded, + onBack: () => Navigator.pop(context), + actions: [ + if (vm.isConfigured) ...[ + RFIconButton( + icon: Icons.history_rounded, + tooltip: 'Past conversations', + onTap: () => _openHistory(context, vm), ), - const SizedBox(width: AppSpacing.md), - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [AppColors.primary, Color(0xFF5B21B6)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(AppRadius.sm), - boxShadow: [ - BoxShadow( - color: AppColors.primaryGlow(0.4), - blurRadius: 12, - spreadRadius: -4, - ), - ], - ), - child: const Icon(Icons.auto_awesome_rounded, color: Colors.white, size: 16), + RFIconButton( + icon: Icons.add_rounded, + tooltip: 'New chat', + onTap: vm.newConversation, ), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'AI Coach', - style: TextStyle(fontFamily: 'Geist', - color: AppColors.textPrimary, - fontSize: 16, - fontWeight: FontWeight.w700, - letterSpacing: -0.3, - ), - ), - Text( - 'Powered by Gemini', - style: TextStyle(fontFamily: 'Geist', - color: AppColors.textMuted, - fontSize: 11, - ), - ), - ], - ), - ), - if (vm.isConfigured) ...[ - _HeaderIconButton( - icon: Icons.history_rounded, - onTap: () => _openHistory(context, vm), - ), - const SizedBox(width: AppSpacing.sm), - _HeaderIconButton( - icon: Icons.add_rounded, - onTap: () { - HapticFeedback.lightImpact(); - vm.newConversation(); - }, - ), - ], ], - ), + ], ); } @@ -246,11 +183,13 @@ class _AiCoachViewState extends State<_AiCoachView> { return ListView.builder( controller: _scrollCtrl, + physics: const BouncingScrollPhysics(), + // Extra bottom room so the last turn settles clear of the input bar. padding: const EdgeInsets.fromLTRB( AppSpacing.md, AppSpacing.md, AppSpacing.md, - AppSpacing.sm, + AppSpacing.lg, ), itemCount: messages.length + (vm.isLoading ? 1 : 0), itemBuilder: (_, i) { @@ -273,29 +212,11 @@ class _AiCoachViewState extends State<_AiCoachView> { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Container( - width: 72, - height: 72, - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [AppColors.primary, Color(0xFF5B21B6)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(AppRadius.xl), - boxShadow: [ - BoxShadow( - color: AppColors.primaryGlow(0.45), - blurRadius: 28, - spreadRadius: -4, - ), - ], - ), - child: const Icon( - Icons.auto_awesome_rounded, - color: Colors.white, - size: 32, - ), + const RFGradientBadge( + icon: Icons.auto_awesome_rounded, + size: 72, + radius: AppRadius.xl, + glow: 0.45, ), const SizedBox(height: AppSpacing.lg), Text( @@ -329,7 +250,7 @@ class _AiCoachViewState extends State<_AiCoachView> { 'Am I progressing on bench?', 'Suggest a deload week', ]) - _SuggestionChip( + RFOptionChip( label: s, onTap: () { _controller.text = s; @@ -373,44 +294,46 @@ class _AiCoachViewState extends State<_AiCoachView> { } Widget _buildInputBar(AiCoachViewModel vm) { - final loading = vm.isLoading; - return Container( - padding: EdgeInsets.fromLTRB( - AppSpacing.md, - AppSpacing.sm, - AppSpacing.md, - AppSpacing.md + MediaQuery.of(context).padding.bottom, - ), - decoration: BoxDecoration( - color: AppColors.surface.withValues(alpha: 0.9), - border: const Border(top: BorderSide(color: AppColors.glassBorder)), - ), + return RFBottomBar( child: Row( + crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( child: Container( + constraints: const BoxConstraints(minHeight: 44), + alignment: Alignment.centerLeft, decoration: BoxDecoration( - color: AppColors.glass3, + color: AppColors.glass2, borderRadius: BorderRadius.circular(AppRadius.xl), border: Border.all(color: AppColors.glassBorderStrong), ), child: TextField( controller: _controller, - style: TextStyle(fontFamily: 'Geist', + style: const TextStyle( + fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 14, + height: 1.4, ), - maxLines: 4, + maxLines: 5, minLines: 1, textCapitalization: TextCapitalization.sentences, - decoration: InputDecoration( - hintText: 'Ask your coach...', - hintStyle: TextStyle(fontFamily: 'Geist', + cursorColor: AppColors.primary, + decoration: const InputDecoration( + hintText: 'Ask your coach…', + hintStyle: TextStyle( + fontFamily: 'Geist', color: AppColors.textFaint, fontSize: 14, ), + // The container draws the only frame; the theme otherwise nests + // a filled 12-radius box and focus ring inside this 18-radius pill. + filled: false, border: InputBorder.none, - contentPadding: const EdgeInsets.symmetric( + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.symmetric( horizontal: AppSpacing.md, vertical: AppSpacing.sm + 4, ), @@ -420,48 +343,13 @@ class _AiCoachViewState extends State<_AiCoachView> { ), ), const SizedBox(width: AppSpacing.sm), - GestureDetector( - onTap: loading ? null : _send, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - width: 44, - height: 44, - decoration: BoxDecoration( - gradient: loading - ? null - : const LinearGradient( - colors: [AppColors.primary, Color(0xFF5B21B6)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - color: loading ? AppColors.glass3 : null, - borderRadius: BorderRadius.circular(AppRadius.xl), - boxShadow: loading - ? null - : [ - BoxShadow( - color: AppColors.primaryGlow(0.4), - blurRadius: 12, - spreadRadius: -4, - ), - ], - ), - child: loading - ? const Center( - child: SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 1.5, - valueColor: AlwaysStoppedAnimation(AppColors.primary), - ), - ), - ) - : const Icon( - Icons.arrow_upward_rounded, - color: Colors.white, - size: 20, - ), + // Keystrokes rebuild only this button, not the whole transcript. + ValueListenableBuilder( + valueListenable: _controller, + builder: (_, value, _) => _SendButton( + loading: vm.isLoading, + enabled: value.text.trim().isNotEmpty, + onTap: _send, ), ), ], @@ -470,25 +358,68 @@ class _AiCoachViewState extends State<_AiCoachView> { } } -// ── Header icon button ────────────────────────────────────────────────────── +// ── Send button ────────────────────────────────────────────────────────────── + +/// Three distinguishable states: sending, ready, and nothing-to-send. +class _SendButton extends StatelessWidget { + const _SendButton({ + required this.loading, + required this.enabled, + required this.onTap, + }); -class _HeaderIconButton extends StatelessWidget { - const _HeaderIconButton({required this.icon, required this.onTap}); - final IconData icon; + final bool loading; + final bool enabled; final VoidCallback onTap; @override Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: AppColors.glass, - borderRadius: BorderRadius.circular(AppRadius.sm), - border: Border.all(color: AppColors.glassBorder), + final active = enabled && !loading; + return Semantics( + button: true, + enabled: active, + label: loading ? 'Sending' : 'Send message', + child: GestureDetector( + onTap: active ? onTap : null, + child: AnimatedContainer( + duration: AppDurations.fast, + curve: Curves.easeOut, + width: 44, + height: 44, + decoration: BoxDecoration( + gradient: active ? AppColors.primaryGradient : null, + color: active ? null : AppColors.glass2, + borderRadius: BorderRadius.circular(AppRadius.xl), + border: active + ? null + : Border.all(color: AppColors.glassBorder), + boxShadow: active + ? [ + BoxShadow( + color: AppColors.primaryGlow(0.4), + blurRadius: 12, + spreadRadius: -4, + ), + ] + : null, + ), + child: Center( + child: loading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(AppColors.primary), + ), + ) + : Icon( + Icons.arrow_upward_rounded, + color: active ? Colors.white : AppColors.textFaint, + size: 20, + ), + ), ), - child: Icon(icon, color: AppColors.textSoft, size: 18), ), ); } @@ -655,37 +586,6 @@ class _ConversationTile extends StatelessWidget { } } -// ── Suggestion chip ─────────────────────────────────────────────────────────── - -class _SuggestionChip extends StatelessWidget { - const _SuggestionChip({required this.label, required this.onTap}); - final String label; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - decoration: BoxDecoration( - color: AppColors.primary.withValues(alpha: 0.10), - borderRadius: BorderRadius.circular(AppRadius.full), - border: Border.all(color: AppColors.primary.withValues(alpha: 0.30)), - ), - child: Text( - label, - style: TextStyle(fontFamily: 'Geist', - color: AppColors.primary, - fontSize: 13, - fontWeight: FontWeight.w500, - ), - ), - ), - ); - } -} - // ── Message bubble ──────────────────────────────────────────────────────────── class _MessageBubble extends StatelessWidget { @@ -695,76 +595,20 @@ class _MessageBubble extends StatelessWidget { @override Widget build(BuildContext context) { final isUser = message.role == 'user'; - return Padding( - padding: const EdgeInsets.only(bottom: AppSpacing.md), - child: Row( - mainAxisAlignment: - isUser ? MainAxisAlignment.end : MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - if (!isUser) ...[ - _AiAvatar(), - const SizedBox(width: AppSpacing.sm), - ], - Flexible( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - if (!isUser && (message.toolCalls?.isNotEmpty ?? false)) - Padding( - padding: const EdgeInsets.only(bottom: AppSpacing.xs), - child: _ToolCallChips(toolNames: message.toolCalls!), - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.sm + 2, - ), - decoration: BoxDecoration( - gradient: isUser - ? const LinearGradient( - colors: [AppColors.primary, Color(0xFF5B21B6)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ) - : null, - color: isUser ? null : AppColors.glass3, - borderRadius: BorderRadius.only( - topLeft: const Radius.circular(AppRadius.lg), - topRight: const Radius.circular(AppRadius.lg), - bottomLeft: Radius.circular(isUser ? AppRadius.lg : 4), - bottomRight: Radius.circular(isUser ? 4 : AppRadius.lg), - ), - border: isUser - ? null - : Border.all(color: AppColors.glassBorder), - boxShadow: isUser - ? [ - BoxShadow( - color: AppColors.primaryGlow(0.25), - blurRadius: 12, - spreadRadius: -4, - ), - ] - : null, - ), - child: isUser - ? Text( - message.text, - style: TextStyle(fontFamily: 'Geist', - color: AppColors.textPrimary, - fontSize: 14, - height: 1.55, - ), - ) - : CoachMessageContent(text: message.text), - ), - ], - ), - ), - ], - ), + return _Turn( + isUser: isUser, + toolCalls: isUser ? const [] : (message.toolCalls ?? const []), + child: isUser + ? Text( + message.text, + style: const TextStyle( + fontFamily: 'Geist', + color: Colors.white, + fontSize: 14, + height: 1.55, + ), + ) + : CoachMessageContent(text: message.text), ); } } @@ -776,45 +620,121 @@ class _StreamingBubble extends StatelessWidget { @override Widget build(BuildContext context) { + return _Turn( + isUser: false, + toolCalls: toolCalls, + toolCallsActive: true, + // An empty stream is still a bubble — the dots need somewhere to sit. + forceBubble: text.isEmpty, + child: text.isEmpty + ? const RFLoadingDots() + : CoachMessageContent(text: text, streaming: true), + ); + } +} + +// ── Turn layout ────────────────────────────────────────────────────────────── + +/// One turn: avatar, tool-call chips, and content — bubbled for prose, bare +/// for a dashboard, whose own cards would otherwise sit in a second frame. +class _Turn extends StatelessWidget { + const _Turn({ + required this.isUser, + required this.child, + this.toolCalls = const [], + this.toolCallsActive = false, + this.forceBubble = false, + }); + + final bool isUser; + final Widget child; + final List toolCalls; + final bool toolCallsActive; + final bool forceBubble; + + @override + Widget build(BuildContext context) { + // A dashboard takes the full column; prose takes a bubble. + final isDashboard = !forceBubble && + !isUser && + child is CoachMessageContent && + CoachMessageContent.rendersAsDashboard( + (child as CoachMessageContent).text, + ); + + final content = isDashboard + ? child + : Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + 2, + ), + decoration: BoxDecoration( + gradient: isUser ? AppColors.primaryGradient : null, + color: isUser ? null : AppColors.glass2, + // The square corner marks the speaker's side. + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(AppRadius.lg), + topRight: const Radius.circular(AppRadius.lg), + bottomLeft: Radius.circular(isUser ? AppRadius.lg : 4), + bottomRight: Radius.circular(isUser ? 4 : AppRadius.lg), + ), + border: + isUser ? null : Border.all(color: AppColors.glassBorder), + boxShadow: isUser + ? [ + BoxShadow( + color: AppColors.primaryGlow(0.25), + blurRadius: 12, + spreadRadius: -4, + ), + ] + : null, + ), + child: child, + ); + + final column = Column( + crossAxisAlignment: + isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (toolCalls.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sm), + child: _ToolCallChips( + toolNames: toolCalls, + active: toolCallsActive, + ), + ), + content, + ], + ); + return Padding( padding: const EdgeInsets.only(bottom: AppSpacing.md), child: Row( - crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: + isUser ? MainAxisAlignment.end : MainAxisAlignment.start, + // Top, so a tall turn's avatar sits beside its first line, not its last. + crossAxisAlignment: CrossAxisAlignment.start, children: [ - _AiAvatar(), - const SizedBox(width: AppSpacing.sm), - Flexible( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - if (toolCalls.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: AppSpacing.xs), - child: _ToolCallChips(toolNames: toolCalls, active: true), - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.sm + 2, - ), - decoration: BoxDecoration( - color: AppColors.glass3, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(AppRadius.lg), - topRight: Radius.circular(AppRadius.lg), - bottomLeft: Radius.circular(4), - bottomRight: Radius.circular(AppRadius.lg), - ), - border: Border.all(color: AppColors.glassBorder), - ), - child: text.isEmpty - ? const RFLoadingDots() - : CoachMessageContent(text: text, streaming: true), + if (!isUser) ...[ + const _AiAvatar(), + const SizedBox(width: AppSpacing.sm), + ], + if (isDashboard) + Expanded(child: column) + else + Flexible( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: MediaQuery.sizeOf(context).width * + _kBubbleMaxWidthFactor, ), - ], + child: column, + ), ), - ), ], ), ); @@ -892,6 +812,27 @@ class CoachMessageContent extends StatefulWidget { this.streaming = false, }); + static final _parser = A2UiParser(defaultA2UiRegistry); + + /// Parsed nodes by source text. Shared because the turn layout needs the + /// result before this widget builds; bounded so transcripts don't accumulate. + static final Map _nodeCache = {}; + static const _nodeCacheLimit = 32; + + static A2UiNode? nodeFor(String text) { + if (_nodeCache.containsKey(text)) return _nodeCache[text]; + if (_nodeCache.length >= _nodeCacheLimit) { + _nodeCache.remove(_nodeCache.keys.first); + } + return _nodeCache[text] = _parser.parse(text); + } + + /// True when [text] is a complete A2UI payload, so renders full-width. + static bool rendersAsDashboard(String text) => nodeFor(text) != null; + + /// True when [text] is a partial A2UI payload still arriving. + static bool looksLikeUi(String text) => _parser.looksLikeUi(text); + final String text; /// True while tokens are still arriving, so a half-written JSON payload @@ -903,33 +844,14 @@ class CoachMessageContent extends StatefulWidget { } class _CoachMessageContentState extends State { - static final _parser = A2UiParser(defaultA2UiRegistry); - - A2UiNode? _node; - String? _parsedFrom; - - @override - void didUpdateWidget(CoachMessageContent oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.text != widget.text) _parsedFrom = null; - } - - A2UiNode? get _resolved { - if (_parsedFrom != widget.text) { - _parsedFrom = widget.text; - _node = _parser.parse(widget.text); - } - return _node; - } - @override Widget build(BuildContext context) { - final node = _resolved; + final node = CoachMessageContent.nodeFor(widget.text); if (node != null) return A2UiRenderer(node: node); // Mid-stream JSON: hide the braces behind a progress row rather than // letting the Markdown renderer spill raw payload into the bubble. - if (widget.streaming && _parser.looksLikeUi(widget.text)) { + if (widget.streaming && CoachMessageContent.looksLikeUi(widget.text)) { return Row( mainAxisSize: MainAxisSize.min, children: [ @@ -973,27 +895,14 @@ class _CoachMarkdown extends StatelessWidget { } class _AiAvatar extends StatelessWidget { + const _AiAvatar(); + @override Widget build(BuildContext context) { - return Container( - width: 28, - height: 28, - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [AppColors.primary, Color(0xFF5B21B6)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(AppRadius.sm), - boxShadow: [ - BoxShadow( - color: AppColors.primaryGlow(0.35), - blurRadius: 8, - spreadRadius: -2, - ), - ], - ), - child: const Icon(Icons.auto_awesome_rounded, color: Colors.white, size: 14), + return const RFGradientBadge( + icon: Icons.auto_awesome_rounded, + size: 28, + radius: AppRadius.sm, ); } } diff --git a/workout-logger/lib/screens/widgets/exercise_input_section.dart b/workout-logger/lib/screens/widgets/exercise_input_section.dart index 23a25fb..5b46aee 100644 --- a/workout-logger/lib/screens/widgets/exercise_input_section.dart +++ b/workout-logger/lib/screens/widgets/exercise_input_section.dart @@ -5,14 +5,14 @@ import 'package:flutter/services.dart'; import '../../models/models.dart'; import '../../services/settings_provider.dart'; import '../../theme/app_theme.dart'; -import 'rf_widgets.dart'; +import 'rf_shell.dart'; // ── ExerciseInputSection ────────────────────────────────────────────────────── -// Renders: AI suggestion card, weight/reps inputs, dropset section, -// LOG SET button, previous sets, last session info, program metadata banner. +// Suggestion card, weight/reps inputs, dropset, session history, program meta. class ExerciseInputSection extends StatelessWidget { const ExerciseInputSection({ super.key, + required this.contentWidth, required this.currentWeight, required this.currentReps, required this.isDropset, @@ -32,7 +32,6 @@ class ExerciseInputSection extends StatelessWidget { required this.onDropRemoved, required this.onDropWeightChanged, required this.onDropRepsChanged, - required this.onLogSet, required this.onApplyRecommendation, this.programSlot, this.programWeek, @@ -42,6 +41,9 @@ class ExerciseInputSection extends StatelessWidget { this.onHandleChanged, }); + /// Layout width minus padding. Passed in, not measured: the host screen's [IntrinsicHeight] (which [Spacer] needs) forbids a [LayoutBuilder] under it. + final double contentWidth; + final double currentWeight; final int currentReps; final bool isDropset; @@ -61,7 +63,6 @@ class ExerciseInputSection extends StatelessWidget { final ValueChanged onDropRemoved; final void Function(int index, double weight) onDropWeightChanged; final void Function(int index, int reps) onDropRepsChanged; - final VoidCallback onLogSet; final VoidCallback onApplyRecommendation; final ProgramExerciseSlot? programSlot; final ProgramWeek? programWeek; @@ -114,6 +115,7 @@ class ExerciseInputSection extends StatelessWidget { // Weight + reps inputs if (!isDropset) ...[ _InputRow( + contentWidth: contentWidth, currentWeight: currentWeight, currentReps: currentReps, settings: settings, @@ -134,9 +136,12 @@ class ExerciseInputSection extends StatelessWidget { children: [ const Icon(Icons.fitness_center_rounded, size: 14, color: AppColors.primary), const SizedBox(width: 6), - Text( - 'Effective Volume Load: ${effectiveWeightDisplay.toStringAsFixed(1)} ${settings.unitLabel} (${bodyWeightDisplay.toStringAsFixed(1)} BW − ${currentWeightDisplay.toStringAsFixed(1)} Assist) × $currentReps reps', - style: const TextStyle(fontSize: 11, color: AppColors.textSoft, fontWeight: FontWeight.w500), + // Long enough to wrap on a narrow phone, more so at a large text scale. + Expanded( + child: Text( + 'Effective Volume Load: ${effectiveWeightDisplay.toStringAsFixed(1)} ${settings.unitLabel} (${bodyWeightDisplay.toStringAsFixed(1)} BW − ${currentWeightDisplay.toStringAsFixed(1)} Assist) × $currentReps reps', + style: const TextStyle(fontSize: 11, color: AppColors.textSoft, fontWeight: FontWeight.w500), + ), ), ], ), @@ -165,21 +170,13 @@ class ExerciseInputSection extends StatelessWidget { const SizedBox(height: AppSpacing.lg), - // LOG SET button - GlowButton( - label: 'LOG SET', - icon: Icons.check_rounded, - onPressed: onLogSet, - ), + // Absorbs leftover height so the history below reads as a footer. + const Spacer(), - // Previous sets if (previousSets.isNotEmpty) ...[ - const SizedBox(height: AppSpacing.lg), _PreviousSetsSection(sets: previousSets, settings: settings), + const SizedBox(height: AppSpacing.lg), ], - - // Last session - const SizedBox(height: AppSpacing.lg), _LastSessionSection(lastSession: lastSession, settings: settings), ], ); @@ -208,44 +205,20 @@ class _HandleSelector extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'ATTACHMENT / HANDLE VARIATION', - style: TextStyle( - color: AppColors.textMuted, - fontSize: 10, - fontWeight: FontWeight.w600, - letterSpacing: 0.5, - ), - ), - const SizedBox(height: 6), + const RFLabel('Attachment'), + const SizedBox(height: AppSpacing.sm), SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: availableHandles.map((handle) { - final isSelected = selectedHandle == handle; return Padding( padding: const EdgeInsets.only(right: 6), - child: FilterChip( - label: Text(handle), - selected: isSelected, - onSelected: locked + child: RFOptionChip( + label: handle, + selected: selectedHandle == handle, + onTap: locked || onChanged == null ? null - : (selected) { - if (selected && onChanged != null) { - onChanged!(handle); - } - }, - selectedColor: AppColors.primary.withValues(alpha: 0.25), - backgroundColor: AppColors.surface, - checkmarkColor: AppColors.primary, - labelStyle: TextStyle( - color: isSelected ? AppColors.primary : AppColors.textSoft, - fontSize: 12, - fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, - ), - side: BorderSide( - color: isSelected ? AppColors.primary : AppColors.glassBorder, - ), + : () => onChanged!(handle), ), ); }).toList(), @@ -315,13 +288,17 @@ class _RecommendationCard extends StatelessWidget { children: [ Row( children: [ - const Text( - 'AI Suggestion', - style: TextStyle( - color: AppColors.textSoft, - fontSize: 11, - fontWeight: FontWeight.w600, - letterSpacing: 0.5, + const Flexible( + child: Text( + 'AI Suggestion', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: AppColors.textSoft, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), ), ), const SizedBox(width: 6), @@ -371,6 +348,7 @@ class _RecommendationCard extends StatelessWidget { // ── Input Row ──────────────────────────────────────────────────────────────── class _InputRow extends StatelessWidget { const _InputRow({ + required this.contentWidth, required this.currentWeight, required this.currentReps, required this.settings, @@ -379,6 +357,7 @@ class _InputRow extends StatelessWidget { this.isAssistedBW = false, }); + final double contentWidth; final double currentWeight; final int currentReps; final SettingsProvider settings; @@ -392,27 +371,42 @@ class _InputRow extends StatelessWidget { isAssistedBW ? 'Assist (${settings.unitLabel})' : settings.unitLabel; final displayWeight = settings.toDisplay(currentWeight); + // Once a large system font squeezes the value past legibility, stack rather than shrink the digits further. + final pairedWidth = (contentWidth - AppSpacing.md) / 2; + final stacked = !_NumberInputCard.valueFits(context, pairedWidth); + final cardWidth = stacked ? contentWidth : pairedWidth; + + final weightCard = _NumberInputCard( + cardWidth: cardWidth, + label: weightLabel, + value: displayWeight, + step: settings.weightIncrement, + decimals: 1, + onChanged: (v) => onWeightChanged(settings.toStorage(v)), + ); + final repsCard = _NumberInputCard( + cardWidth: cardWidth, + label: 'Reps', + value: currentReps.toDouble(), + step: 1, + decimals: 0, + onChanged: (v) => onRepsChanged(v.toInt()), + ); + + if (stacked) { + return Column( + children: [ + weightCard, + const SizedBox(height: AppSpacing.md), + repsCard, + ], + ); + } return Row( children: [ - Expanded( - child: _NumberInputCard( - label: weightLabel, - value: displayWeight, - step: settings.weightIncrement, - decimals: 1, - onChanged: (v) => onWeightChanged(settings.toStorage(v)), - ), - ), + Expanded(child: weightCard), const SizedBox(width: AppSpacing.md), - Expanded( - child: _NumberInputCard( - label: 'Reps', - value: currentReps.toDouble(), - step: 1, - decimals: 0, - onChanged: (v) => onRepsChanged(v.toInt()), - ), - ), + Expanded(child: repsCard), ], ); } @@ -421,6 +415,7 @@ class _InputRow extends StatelessWidget { // ── Number Input Card ───────────────────────────────────────────────────────── class _NumberInputCard extends StatefulWidget { const _NumberInputCard({ + required this.cardWidth, required this.label, required this.value, required this.step, @@ -428,12 +423,49 @@ class _NumberInputCard extends StatefulWidget { required this.onChanged, }); + /// Laid-out width; passed for the reason [ExerciseInputSection.contentWidth] gives. + final double cardWidth; + final String label; final double value; final double step; final int decimals; final ValueChanged onChanged; + /// Tighter than the usual `md`, to leave the value more of the row. + static const double hPadding = AppSpacing.sm + 2; + + static const double maxValueFontSize = 36; + static const double minValueFontSize = 18; + + /// Room left for the value between the two steppers in a card [cardWidth] wide. + static double valueSlotWidth(BuildContext context, double cardWidth) => + cardWidth - hPadding * 2 - _StepBtn.sizeOf(context) * 2; + + /// Whether a card [cardWidth] wide still shows `100.0` legibly — [_InputRow] stacks when it does not. + static bool valueFits(BuildContext context, double cardWidth) => + valueSlotWidth(context, cardWidth) >= + measureValue(context, '100.0', minValueFontSize); + + static TextStyle valueStyle(double fontSize) => TextStyle( + fontFamily: 'GeistMono', + color: AppColors.textPrimary, + fontSize: fontSize, + fontWeight: FontWeight.w700, + ); + + /// Width [text] paints at, honouring the reader's text scale. + static double measureValue( + BuildContext context, String text, double fontSize) { + final painter = TextPainter( + text: TextSpan(text: text, style: valueStyle(fontSize)), + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + maxLines: 1, + )..layout(); + return painter.width; + } + @override State<_NumberInputCard> createState() => _NumberInputCardState(); } @@ -469,10 +501,27 @@ class _NumberInputCardState extends State<_NumberInputCard> { ? widget.value.toStringAsFixed(widget.decimals) : widget.value.toInt().toString(); + /// Largest size that paints [text] inside the value slot, floored at [_NumberInputCard.minValueFontSize]. + double _fitFontSize(BuildContext context, String text) { + const maxSize = _NumberInputCard.maxValueFontSize; + final slot = _NumberInputCard.valueSlotWidth(context, widget.cardWidth); + if (!slot.isFinite) return maxSize; + // A few pixels for the caret, which sits past the last glyph. + final available = slot - 4; + if (available <= 0) return _NumberInputCard.minValueFontSize; + final natural = _NumberInputCard.measureValue(context, text, maxSize); + if (natural <= available || natural <= 0) return maxSize; + return (maxSize * available / natural) + .clamp(_NumberInputCard.minValueFontSize, maxSize); + } + @override Widget build(BuildContext context) { return Container( - padding: const EdgeInsets.all(AppSpacing.md), + padding: const EdgeInsets.symmetric( + horizontal: _NumberInputCard.hPadding, + vertical: AppSpacing.md, + ), decoration: BoxDecoration( color: AppColors.card, borderRadius: BorderRadius.circular(AppRadius.lg), @@ -480,14 +529,10 @@ class _NumberInputCardState extends State<_NumberInputCard> { ), child: Column( children: [ - Text( - widget.label, - style: TextStyle(fontFamily: 'Geist', - color: AppColors.textMuted, - fontSize: 11, - fontWeight: FontWeight.w600, - letterSpacing: 0.5, - ), + // Kept to one line so the two cards in a row stay the same height. + FittedBox( + fit: BoxFit.scaleDown, + child: RFLabel(widget.label), ), const SizedBox(height: AppSpacing.sm), Row( @@ -500,41 +545,49 @@ class _NumberInputCardState extends State<_NumberInputCard> { ), ), Expanded( - child: TextField( - controller: _controller, - focusNode: _focusNode, - style: TextStyle(fontFamily: 'GeistMono', - color: AppColors.textPrimary, - fontSize: 36, - fontWeight: FontWeight.w700, - ), - textAlign: TextAlign.center, - keyboardType: TextInputType.numberWithOptions( - decimal: widget.decimals > 0, - ), - inputFormatters: widget.decimals > 0 - ? [ - FilteringTextInputFormatter.allow( - RegExp(r'^\d*\.?\d*$'), - ), - ] - : [FilteringTextInputFormatter.digitsOnly], - decoration: const InputDecoration( - border: InputBorder.none, - contentPadding: EdgeInsets.zero, - isDense: true, + // Re-fitted per keystroke: a 3-digit weight or a large system font would otherwise be clipped. + child: ValueListenableBuilder( + valueListenable: _controller, + builder: (context, value, _) => TextField( + controller: _controller, + focusNode: _focusNode, + style: _NumberInputCard.valueStyle( + _fitFontSize( + context, + value.text.isEmpty ? _format() : value.text, + ), + ), + maxLines: 1, + textAlign: TextAlign.center, + keyboardType: TextInputType.numberWithOptions( + decimal: widget.decimals > 0, + ), + inputFormatters: widget.decimals > 0 + ? [ + FilteringTextInputFormatter.allow( + RegExp(r'^\d*\.?\d*$'), + ), + ] + : [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration( + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + isDense: true, + ), + onChanged: (text) { + final parsed = double.tryParse(text); + if (parsed != null) { + widget.onChanged(parsed.clamp(0, 999).toDouble()); + } + }, + onEditingComplete: () { + final formatted = _format(); + if (_controller.text != formatted) { + _controller.text = formatted; + } + _focusNode.unfocus(); + }, ), - onChanged: (text) { - final parsed = double.tryParse(text); - if (parsed != null) { - widget.onChanged(parsed.clamp(0, 999).toDouble()); - } - }, - onEditingComplete: () { - final formatted = _format(); - if (_controller.text != formatted) _controller.text = formatted; - _focusNode.unfocus(); - }, ), ), _StepBtn( @@ -556,9 +609,13 @@ class _StepBtn extends StatelessWidget { final IconData icon; final VoidCallback onTap; + /// Fixed: a stepper stays a thumb target, so the value beside it is what gives. + static double sizeOf(BuildContext context) => + MediaQuery.sizeOf(context).width < AppBreakpoints.narrow ? 36.0 : 40.0; + @override Widget build(BuildContext context) { - final size = MediaQuery.sizeOf(context).width < AppBreakpoints.narrow ? 36.0 : 40.0; + final size = sizeOf(context); return GestureDetector( onTap: () { onTap(); @@ -632,15 +689,16 @@ class _DropsetSection extends StatelessWidget { size: 18, ), const SizedBox(width: 8), - const Text( - 'Dropset', - style: TextStyle( - color: AppColors.textPrimary, - fontSize: 14, - fontWeight: FontWeight.w600, + const Expanded( + child: Text( + 'Dropset', + style: TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), ), ), - const Spacer(), Switch( value: isDropset, onChanged: onToggled, @@ -799,15 +857,7 @@ class _PreviousSetsSection extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'THIS SESSION', - style: TextStyle(fontFamily: 'Geist', - color: AppColors.textFaint, - fontSize: 10, - fontWeight: FontWeight.w600, - letterSpacing: 1.2, - ), - ), + const RFLabel('This session', dim: true), const SizedBox(height: AppSpacing.sm), Wrap( spacing: 6, @@ -920,9 +970,11 @@ class _LastSessionSection extends StatelessWidget { Icon(Icons.star_outline_rounded, color: AppColors.textMuted, size: 16), SizedBox(width: 8), - Text( - 'First time doing this exercise!', - style: TextStyle(color: AppColors.textMuted, fontSize: 13), + Expanded( + child: Text( + 'First time doing this exercise!', + style: TextStyle(color: AppColors.textMuted, fontSize: 13), + ), ), ], ), @@ -932,15 +984,7 @@ class _LastSessionSection extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'LAST SESSION', - style: TextStyle(fontFamily: 'Geist', - color: AppColors.textFaint, - fontSize: 10, - fontWeight: FontWeight.w600, - letterSpacing: 1.2, - ), - ), + const RFLabel('Last session', dim: true), const SizedBox(height: AppSpacing.sm), Wrap( spacing: 6, @@ -1005,12 +1049,14 @@ class _ProgramMetaBanner extends StatelessWidget { child: Icon(Icons.battery_charging_full_rounded, size: 14, color: Colors.amber), ), - Text( - 'Target: $displaySets × $repRange', - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 13, - fontWeight: FontWeight.w600, + Flexible( + child: Text( + 'Target: $displaySets × $repRange', + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w600, + ), ), ), ], diff --git a/workout-logger/lib/screens/widgets/floating_nav_bar.dart b/workout-logger/lib/screens/widgets/floating_nav_bar.dart index d3e9af8..fb59dc8 100644 --- a/workout-logger/lib/screens/widgets/floating_nav_bar.dart +++ b/workout-logger/lib/screens/widgets/floating_nav_bar.dart @@ -95,6 +95,8 @@ class FloatingNavBarTheme { this.inactiveIconColor, this.outerShadowColor, this.outerGlowColor, + /// Colour of the unread-indicator dot on a badged item. + this.badgeColor = const Color(0xFFE05040), // ── Sizes ──────────────────────────────────────────────────────────────── this.navHeight = 60.0, this.chipHeight = 46.0, @@ -130,8 +132,10 @@ class FloatingNavBarTheme { // ── Scroll behaviour ───────────────────────────────────────────────────── /// Set to false to keep the nav bar permanently visible. this.hideOnScroll = true, - this.scrollDownThreshold = 2.0, - this.scrollUpThreshold = 2.0, + /// Cumulative downward travel, in pixels, before the bar hides. + this.scrollDownThreshold = 48.0, + /// Cumulative upward travel before it returns; smaller, so it comes back fast. + this.scrollUpThreshold = 16.0, // ── Misc ───────────────────────────────────────────────────────────────── this.bottomMargin = 16.0, this.hapticFeedback = true, @@ -167,6 +171,7 @@ class FloatingNavBarTheme { final Color? inactiveIconColor; final Color? outerShadowColor; final Color? outerGlowColor; + final Color badgeColor; // ── Sizes ──────────────────────────────────────────────────────────────────── final double navHeight; @@ -260,7 +265,10 @@ class FloatingNavBar extends StatelessWidget { @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - final bottomPad = MediaQuery.of(context).padding.bottom; + final bottomPad = MediaQuery.paddingOf(context).bottom; + // Never below bottomMargin, and always a visible gap above any inset. + final bottomInset = + bottomPad + 8 > theme.bottomMargin ? bottomPad + 8 : theme.bottomMargin; // ── Resolve colours ────────────────────────────────────────────────────── final bg = theme.backgroundColor ?? @@ -280,9 +288,8 @@ class FloatingNavBar extends StatelessWidget { return Align( alignment: Alignment.bottomCenter, child: Padding( - padding: EdgeInsets.only( - bottom: bottomPad > 0 ? bottomPad : theme.bottomMargin, - ), + // Clears the system inset and keeps a margin off the gesture handle. + padding: EdgeInsets.only(bottom: bottomInset), child: _ShadowWrapper( outerShadow: outerShadow, outerGlow: outerGlow, @@ -567,7 +574,7 @@ class _NavCellState extends State<_NavCell> width: 8, height: 8, decoration: BoxDecoration( - color: Colors.red, + color: t.badgeColor, shape: BoxShape.circle, border: Border.all( // border matches the chip bg for a @@ -688,26 +695,49 @@ class _FloatingNavBarScaffoldState extends State { // ── Tab change — always restore visibility ──────────────────────────────── void _handleTabChange(int index) { + _travel = 0; if (!_visible) setState(() => _visible = true); widget.onTabChanged(index); } // ── Scroll detection ────────────────────────────────────────────────────── + /// Travel since the last direction change; positive is down, reset on reversal. + double _travel = 0; + bool _handleScrollNotification(ScrollNotification n) { if (!widget.theme.hideOnScroll) return false; + // A settled scroll starts a fresh gesture — don't carry momentum across. + if (n is ScrollEndNotification) { + _travel = 0; + return false; + } + if (n is ScrollUpdateNotification) { + // Ignore overscroll bounce: rubber-banding reads as a drag it isn't. + final m = n.metrics; + if (m.pixels < m.minScrollExtent || m.pixels > m.maxScrollExtent) { + return false; + } + final delta = n.scrollDelta ?? 0; + // Direction reversal restarts the count. + if (delta.sign != _travel.sign) _travel = 0; + _travel += delta; - if (delta > widget.theme.scrollDownThreshold && _visible) { + if (_travel > widget.theme.scrollDownThreshold && _visible) { + _travel = 0; setState(() => _visible = false); - } else if (delta < -widget.theme.scrollUpThreshold && !_visible) { + } else if (-_travel > widget.theme.scrollUpThreshold && !_visible) { + _travel = 0; setState(() => _visible = true); } - // At the very top → always show. - if (n.metrics.pixels <= 0 && !_visible) { + // Near the very top → always show. A small band rather than an exact + // zero, so the bar is already back by the time the bounce settles. + if (m.pixels <= m.minScrollExtent + 8 && !_visible) { + _travel = 0; setState(() => _visible = true); } } diff --git a/workout-logger/lib/screens/widgets/rest_timer_view.dart b/workout-logger/lib/screens/widgets/rest_timer_view.dart index b614e6a..687f15f 100644 --- a/workout-logger/lib/screens/widgets/rest_timer_view.dart +++ b/workout-logger/lib/screens/widgets/rest_timer_view.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../theme/app_theme.dart'; import 'rf_widgets.dart'; +import 'rf_shell.dart'; class RestTimerView extends StatelessWidget { const RestTimerView({ @@ -31,15 +32,7 @@ class RestTimerView extends StatelessWidget { // Top hint Padding( padding: const EdgeInsets.only(top: AppSpacing.lg), - child: Text( - 'REST', - style: const TextStyle( - color: AppColors.textMuted, - fontSize: 11, - fontWeight: FontWeight.w700, - letterSpacing: 2, - ), - ), + child: const RFLabel('Rest'), ), // Ring + time fills most of the screen Expanded( @@ -66,15 +59,8 @@ class RestTimerView extends StatelessWidget { ), if (nextExerciseName != null) ...[ const SizedBox(height: AppSpacing.lg), - Text( - 'Next up', - style: const TextStyle( - color: AppColors.textMuted, - fontSize: 11, - letterSpacing: 0.5, - ), - ), - const SizedBox(height: 4), + const RFLabel('Next up', dim: true), + const SizedBox(height: AppSpacing.sm), Text( nextExerciseName!, style: const TextStyle( @@ -98,7 +84,7 @@ class RestTimerView extends StatelessWidget { AppSpacing.xl, ), child: OutlineGlowButton( - label: 'SKIP REST', + label: 'Skip rest', onPressed: onSkip, color: AppColors.textSoft, fullWidth: true, diff --git a/workout-logger/lib/screens/widgets/rf_dialogs.dart b/workout-logger/lib/screens/widgets/rf_dialogs.dart index 15b5e1a..49612d3 100644 --- a/workout-logger/lib/screens/widgets/rf_dialogs.dart +++ b/workout-logger/lib/screens/widgets/rf_dialogs.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../theme/app_theme.dart'; +import 'rf_widgets.dart'; /// Types of snackbar toast notifications. enum RFSnackBarType { info, success, warning, error } @@ -71,6 +72,177 @@ extension RFSnackBarContext on BuildContext { } } +/// One choice in an [showRFActionSheet]. +class RFAction { + const RFAction({ + required this.label, + required this.value, + this.description, + this.icon, + this.isPrimary = false, + this.isDanger = false, + }); + + final String label; + final T value; + + /// One short line under the label saying what the choice does. + final String? description; + final IconData? icon; + + /// Renders as the filled brand button. At most one per sheet. + final bool isPrimary; + final bool isDanger; +} + +/// Bottom sheet for three or more choices, where a dialog's action row wraps. +/// Returns null if dismissed without a choice. +Future showRFActionSheet( + BuildContext context, { + required String title, + String? message, + required List> actions, +}) { + return showModalBottomSheet( + context: context, + backgroundColor: AppColors.surface, + barrierColor: Colors.black.withValues(alpha: 0.6), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)), + ), + builder: (ctx) => SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.sm, + AppSpacing.md, + AppSpacing.md, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Grabber + Center( + child: Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(bottom: AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.glassBorderStrong, + borderRadius: BorderRadius.circular(AppRadius.full), + ), + ), + ), + Text( + title, + style: const TextStyle( + fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + ), + ), + if (message != null) ...[ + const SizedBox(height: AppSpacing.xs), + Text( + message, + style: const TextStyle( + fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 14, + height: 1.4, + ), + ), + ], + const SizedBox(height: AppSpacing.lg), + for (final action in actions) ...[ + if (action.isPrimary) + GlowButton( + label: action.label, + icon: action.icon, + onPressed: () => Navigator.pop(ctx, action.value), + ) + else + _SheetChoice(action: action, ctx: ctx), + if (action != actions.last) + const SizedBox(height: AppSpacing.sm), + ], + ], + ), + ), + ), + ); +} + +class _SheetChoice extends StatelessWidget { + const _SheetChoice({required this.action, required this.ctx}); + + final RFAction action; + final BuildContext ctx; + + @override + Widget build(BuildContext context) { + final fg = action.isDanger ? AppColors.error : AppColors.textPrimary; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => Navigator.pop(ctx, action.value), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.md - 2, + ), + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: BorderRadius.circular(AppRadius.button), + border: Border.all( + color: action.isDanger + ? AppColors.error.withValues(alpha: 0.35) + : AppColors.glassBorder, + ), + ), + child: Row( + children: [ + if (action.icon != null) ...[ + Icon(action.icon, size: 18, color: fg), + const SizedBox(width: AppSpacing.sm + 2), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + action.label, + style: TextStyle( + fontFamily: 'Geist', + color: fg, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + if (action.description != null) ...[ + const SizedBox(height: 2), + Text( + action.description!, + style: const TextStyle( + fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 12, + ), + ), + ], + ], + ), + ), + ], + ), + ), + ); + } +} + /// Displays a standardized glassmorphic confirm dialog. Future showRFConfirmDialog( BuildContext context, { diff --git a/workout-logger/lib/screens/widgets/rf_shell.dart b/workout-logger/lib/screens/widgets/rf_shell.dart new file mode 100644 index 0000000..4d81c22 --- /dev/null +++ b/workout-logger/lib/screens/widgets/rf_shell.dart @@ -0,0 +1,385 @@ +// rf_shell.dart — Screen chrome shared by every RepForge screen. + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../../theme/app_theme.dart'; + +// ── RFIconButton ───────────────────────────────────────────────────────────── +// The glass square icon button used in every screen header. One fill, one size. +class RFIconButton extends StatelessWidget { + const RFIconButton({ + super.key, + required this.icon, + required this.onTap, + this.tooltip, + this.color, + this.size = 38, + }); + + final IconData icon; + final VoidCallback? onTap; + + /// Screen-reader label and tooltip. Always supply one: these are icon-only. + final String? tooltip; + + /// Tints the icon (e.g. destructive actions). Defaults to soft text. + final Color? color; + final double size; + + @override + Widget build(BuildContext context) { + final enabled = onTap != null; + final button = Semantics( + button: true, + enabled: enabled, + label: tooltip, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: enabled + ? () { + HapticFeedback.lightImpact(); + onTap!(); + } + : null, + child: Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: Icon( + icon, + size: 18, + color: enabled + ? (color ?? AppColors.textSoft) + : AppColors.textFaint, + ), + ), + ), + ); + return tooltip == null + ? button + : Tooltip(message: tooltip!, child: button); + } +} + +// ── RFGradientBadge ────────────────────────────────────────────────────────── +// Small brand-gradient tile — the AI mark in headers, avatars and empty states. +class RFGradientBadge extends StatelessWidget { + const RFGradientBadge({ + super.key, + required this.icon, + this.size = 34, + this.radius = AppRadius.md, + this.glow = 0.35, + }); + + final IconData icon; + final double size; + final double radius; + final double glow; + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration( + gradient: AppColors.primaryGradient, + borderRadius: BorderRadius.circular(radius), + boxShadow: [ + BoxShadow( + color: AppColors.primaryGlow(glow), + blurRadius: size * 0.4, + spreadRadius: -size * 0.12, + ), + ], + ), + child: Icon(icon, color: Colors.white, size: size * 0.5), + ); + } +} + +// ── RFScreenHeader ─────────────────────────────────────────────────────────── +// Leading affordance · optional badge · title/subtitle · actions. +class RFScreenHeader extends StatelessWidget { + const RFScreenHeader({ + super.key, + required this.title, + this.subtitle, + this.badgeIcon, + this.onBack, + this.leadingIcon = Icons.arrow_back_rounded, + this.leadingTooltip = 'Back', + this.actions = const [], + this.centreTitle = false, + this.bottom, + }); + + final String title; + final String? subtitle; + + /// When set, a brand-gradient badge sits between the back button and title. + final IconData? badgeIcon; + + /// Omit to hide the leading button entirely (root-level screens). + final VoidCallback? onBack; + final IconData leadingIcon; + final String leadingTooltip; + final List actions; + final bool centreTitle; + + /// Rendered full-bleed under the header row — e.g. a progress bar. + final Widget? bottom; + + @override + Widget build(BuildContext context) { + // One action = one 38pt button + one 8pt gap, matching RFIconButton. + const cellWidth = 38.0 + AppSpacing.sm; + final leadingWidth = onBack != null ? cellWidth : 0.0; + final trailingWidth = actions.length * cellWidth; + + final titleBlock = Column( + crossAxisAlignment: + centreTitle ? CrossAxisAlignment.center : CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: centreTitle ? TextAlign.center : TextAlign.start, + style: const TextStyle( + fontFamily: 'Geist', + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + ), + ), + if (subtitle != null) ...[ + const SizedBox(height: 2), + Text( + subtitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: centreTitle ? TextAlign.center : TextAlign.start, + style: const TextStyle( + fontFamily: 'Geist', + color: AppColors.textMuted, + fontSize: 11, + ), + ), + ], + ], + ); + + return Container( + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: AppColors.glassBorder)), + ), + child: SafeArea( + bottom: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.sm, + AppSpacing.md, + AppSpacing.sm, + ), + child: Row( + children: [ + if (onBack != null) ...[ + RFIconButton( + icon: leadingIcon, + tooltip: leadingTooltip, + onTap: onBack, + ), + const SizedBox(width: AppSpacing.sm), + ], + if (badgeIcon != null) ...[ + RFGradientBadge(icon: badgeIcon!), + const SizedBox(width: AppSpacing.sm), + ], + // Counterweight, so a centred title lands on true centre. + if (centreTitle && trailingWidth > leadingWidth) + SizedBox(width: trailingWidth - leadingWidth), + Expanded(child: titleBlock), + if (centreTitle && leadingWidth > trailingWidth) + SizedBox(width: leadingWidth - trailingWidth), + for (final action in actions) ...[ + const SizedBox(width: AppSpacing.sm), + action, + ], + ], + ), + ), + if (bottom != null) + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + 0, + AppSpacing.md, + AppSpacing.sm, + ), + child: bottom!, + ), + ], + ), + ), + ); + } +} + +// ── RFBottomBar ────────────────────────────────────────────────────────────── +// Sticky foot-of-screen action strip. Owns its safe-area inset; never add one. +class RFBottomBar extends StatelessWidget { + const RFBottomBar({super.key, required this.child}); + + final Widget child; + + /// Floor for the space a scroll view must reserve to clear a one-row bar. + static double clearance(BuildContext context) => + 72 + MediaQuery.paddingOf(context).bottom; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.sm + 4, + AppSpacing.md, + AppSpacing.sm + 4 + MediaQuery.paddingOf(context).bottom, + ), + decoration: const BoxDecoration( + color: AppColors.surface, + border: Border(top: BorderSide(color: AppColors.glassBorder)), + ), + child: child, + ); + } +} + +// ── RFLabel ────────────────────────────────────────────────────────────────── +// The one uppercase micro-label: headings, captions and overlines share it. +class RFLabel extends StatelessWidget { + const RFLabel(this.text, {super.key, this.color, this.dim = false}); + + final String text; + final Color? color; + + /// Drops to the faintest text tier — for labels over already-quiet content. + final bool dim; + + @override + Widget build(BuildContext context) { + return Text( + text.toUpperCase(), + style: TextStyle( + fontFamily: 'Geist', + color: color ?? (dim ? AppColors.textFaint : AppColors.textMuted), + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + ), + ); + } +} + +// ── RFOptionChip ───────────────────────────────────────────────────────────── +// A chip the user picks, as opposed to RFChip which only labels. +class RFOptionChip extends StatelessWidget { + const RFOptionChip({ + super.key, + required this.label, + required this.onTap, + this.selected = false, + this.color, + this.icon, + }); + + final String label; + + /// Null renders the chip inert (shown, but not selectable). + final VoidCallback? onTap; + final bool selected; + + /// Accent for the selected state. Defaults to the brand violet. + final Color? color; + final IconData? icon; + + @override + Widget build(BuildContext context) { + final c = color ?? AppColors.primary; + final enabled = onTap != null; + final fg = selected + ? c + : enabled + ? AppColors.textSoft + : AppColors.textFaint; + + return Semantics( + button: enabled, + selected: selected, + label: label, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: enabled + ? () { + HapticFeedback.selectionClick(); + onTap!(); + } + : null, + child: AnimatedContainer( + duration: AppDurations.fast, + curve: Curves.easeOut, + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: AppSpacing.sm + 1, + ), + decoration: BoxDecoration( + color: selected + ? c.withValues(alpha: 0.16) + : AppColors.glass2, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all( + color: selected + ? c.withValues(alpha: 0.55) + : AppColors.glassBorder, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 13, color: fg), + const SizedBox(width: 6), + ], + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: 'Geist', + color: fg, + fontSize: 13, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/workout-logger/lib/screens/widgets/rf_widgets.dart b/workout-logger/lib/screens/widgets/rf_widgets.dart index a08d842..aaeac90 100644 --- a/workout-logger/lib/screens/widgets/rf_widgets.dart +++ b/workout-logger/lib/screens/widgets/rf_widgets.dart @@ -5,6 +5,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../../theme/app_theme.dart'; +import 'rf_shell.dart'; // ── Route helper ────────────────────────────────────────────────────────────── // Right-to-left slide push, shared by the home screen and detail entry points. @@ -52,18 +53,23 @@ class GlassCard extends StatelessWidget { @override Widget build(BuildContext context) { final radius = borderRadius ?? BorderRadius.circular(AppRadius.xl); - final effectiveBorderColor = accentBorder - ? AppColors.primary - : (borderColor ?? AppColors.glassBorder); + + // An explicit border is a state signal (selected, accented), so it stays a + // flat ring at full strength. The graded ring is the default *material*, + // and grading it would mute the signal. + final overrideColor = + accentBorder ? AppColors.primary : borderColor; final decoration = BoxDecoration( gradient: const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [Color(0x09FFFFFF), Color(0x04FFFFFF)], + colors: [AppColors.glassFillTop, AppColors.glassFillBottom], ), borderRadius: radius, - border: Border.all(color: effectiveBorderColor, width: 1), + border: overrideColor != null + ? Border.all(color: overrideColor, width: 1) + : null, boxShadow: glowColor != null ? [ BoxShadow( @@ -75,13 +81,22 @@ class GlassCard extends StatelessWidget { : null, ); - final content = Container( + Widget content = Container( padding: padding ?? const EdgeInsets.all(AppSpacing.md), - margin: margin, decoration: decoration, child: child, ); + if (overrideColor == null) { + content = CustomPaint( + foregroundPainter: _GradedRingPainter(radius: radius), + child: content, + ); + } + if (margin != null) { + content = Padding(padding: margin!, child: content); + } + if (onTap == null) return content; return Semantics( button: true, @@ -94,40 +109,334 @@ class GlassCard extends StatelessWidget { } } +/// A 1px border that grades from [AppColors.glassEdgeTop] down to +/// [AppColors.glassEdgeBottom]. +/// +/// Real glass catches light on the edge facing the source; a flat ring on all +/// four sides is the thing that made these panels read as outlines. Flutter's +/// [Border] takes a single colour per side, so the ring is stroked by hand. +class _GradedRingPainter extends CustomPainter { + const _GradedRingPainter({required this.radius}); + + final BorderRadius radius; + + @override + void paint(Canvas canvas, Size size) { + final rect = Offset.zero & size; + // A stroke straddles its path, so pull in by half the width to keep the + // full pixel inside the card rather than bleeding over the neighbour. + final rrect = radius.toRRect(rect).deflate(0.5); + final paint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1 + ..shader = const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [AppColors.glassEdgeTop, AppColors.glassEdgeBottom], + ).createShader(rect); + canvas.drawRRect(rrect, paint); + } + + @override + bool shouldRepaint(_GradedRingPainter oldDelegate) => + oldDelegate.radius != radius; +} + +// ── AmbientMotion ──────────────────────────────────────────────────────────── + +/// App-wide vertical scroll position, published for [AmbientGlow] to lean against. +class AmbientMotion extends InheritedNotifier> { + const AmbientMotion({ + super.key, + required ValueNotifier super.notifier, + required super.child, + }); + + /// Reads the current offset without subscribing. + static double read(BuildContext context) { + final element = + context.getElementForInheritedWidgetOfExactType(); + final widget = element?.widget as AmbientMotion?; + return widget?.notifier?.value ?? 0; + } +} + +/// Installs the [AmbientMotion] signal. Mount once, above the app's Navigator. +class AmbientMotionScope extends StatefulWidget { + const AmbientMotionScope({super.key, required this.child}); + + final Widget child; + + @override + State createState() => _AmbientMotionScopeState(); +} + +class _AmbientMotionScopeState extends State { + final _offset = ValueNotifier(0); + + @override + void dispose() { + _offset.dispose(); + super.dispose(); + } + + bool _onScroll(ScrollNotification n) { + if (n is ScrollUpdateNotification && n.metrics.axis == Axis.vertical) { + _offset.value = n.metrics.pixels; + } + return false; + } + + @override + Widget build(BuildContext context) { + return NotificationListener( + onNotification: _onScroll, + child: AmbientMotion(notifier: _offset, child: widget.child), + ); + } +} + // ── AmbientGlow ────────────────────────────────────────────────────────────── -// Matches the design's rf-ambient pseudo-elements. -class AmbientGlow extends StatelessWidget { + +/// Violet wash behind every screen: a full-height floor plus three radial +/// pools that drift slowly and lean against the user's scroll. Only transforms +/// and opacity animate. +/// +/// The floor is not decorative. Pools are finite and a scrolling column is not, +/// so pools alone can only ever light the top of a screen — the previous rig +/// lit the content column from 0 to 264dp and left the remaining 70% of a +/// Pixel flat. The floor guarantees light everywhere; the pools give it a +/// direction. +class AmbientGlow extends StatefulWidget { const AmbientGlow({super.key}); + /// Set false to render the wash static. The drift loop never ends, so under + /// the test binding it would hold `pumpAndSettle` open forever. + static bool motionEnabled = true; + + @override + State createState() => _AmbientGlowState(); +} + +class _AmbientGlowState extends State + with SingleTickerProviderStateMixin { + /// Every drift period divides this evenly, so the loop closes without a snap. + static const _cycle = Duration(seconds: 120); + + /// Scroll travel that maps to the full counter-offset. + static const _parallaxRange = 640.0; + + /// Peak counter-offset, in logical pixels. + static const _parallaxDepthNear = 34.0; + static const _parallaxDepthFar = 14.0; + + /// Pool boxes are a fraction of viewport height, not fixed dp. The old fixed + /// sizes meant coverage degraded as phones got taller — the same 480dp pool + /// lit 37% of a 720dp screen but only 28% of a 956dp one. + static const _keyScale = 0.82; + static const _counterScale = 0.97; + + /// Counterweight centre, as a fraction of viewport height, and its horizontal + /// offset from centre. Offset rather than centred so the pair reads as two + /// sources instead of a symmetric vignette. + static const _counterCentreY = 0.74; + static const _counterOffsetX = 58.0; + + /// The far pool hugs the right bezel: its centre sits 166dp from the content + /// column with a 108dp reach, so it never touches the cards. It is edge + /// atmosphere, and sized in fixed dp on purpose. + static const _farBox = 360.0; + + late final AnimationController _ctrl; + + double _parallax = 0; + + @override + void initState() { + super.initState(); + // Constructed eagerly: a lazy late-final would build its Ticker inside + // dispose(), when ancestor lookup is already unsafe. + _ctrl = AnimationController(vsync: this, duration: _cycle); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_animates) { + if (!_ctrl.isAnimating) _ctrl.repeat(); + } else if (_ctrl.isAnimating) { + _ctrl.stop(); + } + } + + bool get _animates => + AmbientGlow.motionEnabled && !MediaQuery.disableAnimationsOf(context); + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + /// Sine at [harmonic] cycles per loop; coprime harmonics never resync. + double _wave(double t, int harmonic) => + math.sin(2 * math.pi * harmonic * t); + + /// Eased toward the live scroll position so route changes glide, not jump. + double _sampleParallax(BuildContext context) { + final target = + (AmbientMotion.read(context) / _parallaxRange).clamp(0.0, 1.0); + _parallax += (target - _parallax) * 0.08; + return _parallax; + } + + /// One pool. When the rig is static the drift and fade are skipped entirely + /// rather than sampled at rest, so nothing wraps the wash that need not. + Widget _pool({ + required double box, + required double left, + required double top, + required double opacity, + required bool animate, + Offset drift = Offset.zero, + double fade = 1, + }) { + final wash = _Wash(size: box, opacity: opacity); + return Positioned( + left: left, + top: top, + child: animate + ? Transform.translate( + offset: drift, + child: Opacity(opacity: fade, child: wash), + ) + : wash, + ); + } + + Widget _rig(Size size, {required bool animate, double t = 0, double p = 0}) { + final w = size.width; + final h = size.height; + final keyBox = h * _keyScale; + final counterBox = h * _counterScale; + + return Stack( + children: [ + const Positioned.fill(child: _WashFloor()), + // Key: top-anchored and centred. Establishes the light direction. + _pool( + box: keyBox, + left: (w - keyBox) / 2, + top: -120, + opacity: 0.32, + animate: animate, + drift: Offset( + _wave(t, 2) * 20, + _wave(t, 3) * 13 - p * _parallaxDepthNear, + ), + fade: 0.86 + 0.14 * (0.5 + 0.5 * _wave(t, 5)), + ), + // Far: right bezel only. Less travel — the gap is the parallax. + _pool( + box: _farBox, + left: w + 140 - _farBox, + top: 40, + opacity: 0.16, + animate: animate, + drift: Offset( + _wave(t, 3) * -9, + _wave(t, 2) * 7 - p * _parallaxDepthFar, + ), + fade: 0.80 + 0.20 * (0.5 + 0.5 * _wave(t, 3)), + ), + // Counterweight: lower third, off-axis, and quiet. It gives the bottom + // of the screen a source rather than a flat tint. + _pool( + box: counterBox, + left: (w - counterBox) / 2 + _counterOffsetX, + top: h * _counterCentreY - counterBox / 2, + opacity: 0.14, + animate: animate, + drift: Offset( + _wave(t, 2) * -11, + _wave(t, 3) * 8 - p * _parallaxDepthFar, + ), + fade: 0.84 + 0.16 * (0.5 + 0.5 * _wave(t, 2)), + ), + ], + ); + } + @override Widget build(BuildContext context) { return Positioned.fill( child: IgnorePointer( - child: Stack( - children: [ - // Top violet wash - Positioned( - top: -120, - left: 0, - right: 0, - child: Center( - child: Container( - width: 480, - height: 480, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: RadialGradient( - colors: [ - const Color(0xFF5B21B6).withValues(alpha: 0.35), - Colors.transparent, - ], - stops: const [0, 0.6], - ), - ), + child: RepaintBoundary( + child: LayoutBuilder( + builder: (context, constraints) { + final size = constraints.biggest; + // An always-moving backdrop is what this setting exists to stop. + if (!_animates) return _rig(size, animate: false); + return AnimatedBuilder( + animation: _ctrl, + builder: (context, _) => _rig( + size, + animate: true, + t: _ctrl.value, + p: _sampleParallax(context), ), - ), - ), + ); + }, + ), + ), + ), + ); + } +} + +/// The floor: a full-height grade that keeps every pixel of canvas fractionally +/// above flat black, so glass always has something behind it to sit on. +class _WashFloor extends StatelessWidget { + const _WashFloor(); + + @override + Widget build(BuildContext context) { + return const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.washFloorTop, + AppColors.washFloorMid, + AppColors.washFloorBottom, + ], + stops: [0, 0.45, 1], + ), + ), + ); + } +} + +class _Wash extends StatelessWidget { + const _Wash({required this.size, required this.opacity}); + + final double size; + final double opacity; + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + AppColors.primaryDeep.withValues(alpha: opacity), + Colors.transparent, ], + stops: const [0, 0.6], ), ), ); @@ -261,13 +570,17 @@ class _GlowButtonState extends State ), const SizedBox(width: AppSpacing.sm), ], - Text( - widget.label, - style: TextStyle( - color: disabled ? AppColors.textMuted : Colors.white, - fontSize: widget.small ? 14 : 16, - fontWeight: FontWeight.w700, - letterSpacing: 0.5, + Flexible( + child: Text( + widget.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: disabled ? AppColors.textMuted : Colors.white, + fontSize: widget.small ? 14 : 16, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + ), ), ), ], @@ -392,15 +705,7 @@ class RFSectionHeader extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - title.toUpperCase(), - style: const TextStyle( - color: AppColors.textMuted, - fontSize: 11, - fontWeight: FontWeight.w700, - letterSpacing: 1.2, - ), - ), + RFLabel(title), ?trailing, ], ), diff --git a/workout-logger/lib/screens/widgets/workout_header.dart b/workout-logger/lib/screens/widgets/workout_header.dart index 729ae49..844802a 100644 --- a/workout-logger/lib/screens/widgets/workout_header.dart +++ b/workout-logger/lib/screens/widgets/workout_header.dart @@ -4,6 +4,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import '../../theme/app_theme.dart'; import 'rf_widgets.dart'; +import 'rf_shell.dart'; // ── WorkoutHeader ───────────────────────────────────────────────────────────── // Shows exercise name, set/exercise progress, elapsed timer, and nav actions. @@ -16,11 +17,7 @@ class WorkoutHeader extends StatefulWidget { required this.setNumber, required this.workoutStartTime, required this.progress, - required this.isFirst, - required this.isLast, required this.onClose, - required this.onPrevious, - required this.onNext, required this.onFinish, required this.onRemoveLastSet, required this.onSetRestTime, @@ -33,11 +30,7 @@ class WorkoutHeader extends StatefulWidget { final int setNumber; final DateTime? workoutStartTime; final double progress; - final bool isFirst; - final bool isLast; final VoidCallback onClose; - final VoidCallback onPrevious; - final VoidCallback onNext; final VoidCallback onFinish; final VoidCallback onRemoveLastSet; final void Function(int seconds) onSetRestTime; @@ -80,113 +73,70 @@ class _WorkoutHeaderState extends State { @override Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - gradient: const LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0xFF0C0C12), Color(0x000C0C12)], + // Left-aligned title, matching every other screen header in the app. The + // title used to be centred while the leading and trailing clusters had + // very different widths, which pushed it visibly off the optical centre. + return RFScreenHeader( + title: widget.exerciseName, + subtitle: + 'Exercise ${widget.currentExerciseIndex + 1} of ${widget.totalExercises} · Set ${widget.setNumber}', + onBack: widget.onClose, + leadingIcon: Icons.close_rounded, + leadingTooltip: 'Cancel workout', + actions: [ + _ElapsedChip(label: _elapsedLabel), + _OptionsMenu( + restSeconds: widget.restSeconds, + onRemoveLastSet: widget.onRemoveLastSet, + onSetRestTime: widget.onSetRestTime, + onFinish: widget.onFinish, ), - border: Border(bottom: BorderSide(color: AppColors.glassBorder)), + ], + bottom: RFProgressBar( + value: widget.progress, + height: 3, + showGlow: false, ), - child: SafeArea( - bottom: false, - child: Column( + ); + } +} + +// ── Elapsed chip ────────────────────────────────────────────────────────────── +class _ElapsedChip extends StatelessWidget { + const _ElapsedChip({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + return Semantics( + label: 'Elapsed time', + value: label, + child: Container( + // A floor, not a fixed height: a large text scale needs more than 38pt. + constraints: const BoxConstraints(minHeight: 38), + alignment: Alignment.center, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm + 2, + vertical: 4, + ), + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: Row( mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(4, 4, 4, 0), - child: Row( - children: [ - // Close button - IconButton( - icon: const Icon(Icons.close_rounded, size: 22), - color: AppColors.textSoft, - onPressed: widget.onClose, - ), - // Exercise info - Expanded( - child: Column( - children: [ - Text( - widget.exerciseName, - style: TextStyle(fontFamily: 'Geist', - color: AppColors.textPrimary, - fontSize: 17, - fontWeight: FontWeight.w600, - letterSpacing: -0.3, - ), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'Exercise ${widget.currentExerciseIndex + 1} of ${widget.totalExercises} · Set ${widget.setNumber}', - style: TextStyle(fontFamily: 'Geist', - color: AppColors.textMuted, - fontSize: 11, - ), - ), - ], - ), - ], - ), - ), - // Timer chip + menu - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(AppRadius.full), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.timer_outlined, size: 12, color: AppColors.textMuted), - const SizedBox(width: 4), - Text( - _elapsedLabel, - style: TextStyle(fontFamily: 'GeistMono', - color: AppColors.textSoft, - fontSize: 12, - ), - ), - ], - ), - ), - _OptionsMenu( - restSeconds: widget.restSeconds, - onRemoveLastSet: widget.onRemoveLastSet, - onSetRestTime: widget.onSetRestTime, - onFinish: widget.onFinish, - ), - ], - ), - ], - ), - ), - // Progress bar - Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.md, - AppSpacing.sm, - AppSpacing.md, - AppSpacing.sm, - ), - child: RFProgressBar( - value: widget.progress, - height: 4, - showGlow: false, + const Icon(Icons.timer_outlined, size: 13, color: AppColors.textMuted), + const SizedBox(width: 5), + Text( + label, + style: const TextStyle( + fontFamily: 'GeistMono', + color: AppColors.textSoft, + fontSize: 12, + fontFeatures: [FontFeature.tabularFigures()], ), ), ], diff --git a/workout-logger/lib/screens/workout_flow_screen.dart b/workout-logger/lib/screens/workout_flow_screen.dart index c3290a0..3e85ac3 100644 --- a/workout-logger/lib/screens/workout_flow_screen.dart +++ b/workout-logger/lib/screens/workout_flow_screen.dart @@ -15,6 +15,9 @@ import '../theme/app_theme.dart'; import 'add_custom_exercise_screen.dart'; import 'exercise_library_screen.dart'; import 'workout_summary_screen.dart'; +import 'widgets/rf_widgets.dart'; +import 'widgets/rf_shell.dart'; +import 'widgets/rf_dialogs.dart'; import 'widgets/workout_header.dart'; import 'widgets/exercise_input_section.dart'; import 'widgets/rest_timer_view.dart'; @@ -297,27 +300,26 @@ class _WorkoutFlowScreenState extends State { setNumber: (log?.sets.length ?? 0) + 1, workoutStartTime: provider.workoutStartTime, progress: totalExercises > 0 ? (idx + 1) / totalExercises : 0, - isFirst: isFirst, - isLast: isLast, onClose: _showCancelDialog, - onPrevious: () { - provider.previousExercise(); - _loadLastSessionData(); - }, - onNext: () { - provider.nextExercise(); - _loadLastSessionData(); - }, onFinish: _finishWorkout, onRemoveLastSet: provider.removeLastSet, onSetRestTime: (s) => setState(() => _restSeconds = s), restSeconds: _restSeconds, ), Expanded( - child: SingleChildScrollView( - physics: const BouncingScrollPhysics(), - padding: const EdgeInsets.all(AppSpacing.md), - child: ExerciseInputSection( + // minHeight + IntrinsicHeight let the section's Spacer do its work. + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(AppSpacing.md), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - AppSpacing.md * 2, + ), + child: IntrinsicHeight( + child: ExerciseInputSection( + // The section cannot measure itself under IntrinsicHeight. + contentWidth: constraints.maxWidth - AppSpacing.md * 2, currentWeight: _currentWeight, currentReps: _currentReps, isDropset: _isDropset, @@ -358,7 +360,6 @@ class _WorkoutFlowScreenState extends State { _drops[i] = DropsetEntry(weight: _drops[i].weight, reps: r); } }, - onLogSet: _completeSet, onApplyRecommendation: () { if (recommendations.isEmpty) return; final setIdx = (log?.sets.length ?? 0) @@ -375,6 +376,9 @@ class _WorkoutFlowScreenState extends State { _mainRepsCtrl.text = rec.reps.toString(); }); }, + ), + ), + ), ), ), ), @@ -399,98 +403,44 @@ class _WorkoutFlowScreenState extends State { ); } + /// Log set is tapped twenty-odd times a session and exercise nav a handful, + /// so the log action owns the width and the thumb zone. It used to be inverted. Widget _buildBottomNav(WorkoutProvider provider, bool isFirst, bool isLast) { - final bottomPad = MediaQuery.of(context).padding.bottom; - return Container( - padding: EdgeInsets.fromLTRB(16, 12, 16, 12 + bottomPad), - decoration: BoxDecoration( - color: AppColors.surface.withValues(alpha: 0.95), - border: Border(top: BorderSide(color: AppColors.glassBorder)), - ), + return RFBottomBar( child: Row( children: [ - if (!isFirst) - Flexible( - child: GestureDetector( - onTap: () { - provider.previousExercise(); - _loadLastSessionData(); - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - decoration: BoxDecoration( - color: AppColors.glass2, - borderRadius: BorderRadius.circular(AppRadius.button), - border: Border.all(color: AppColors.glassBorderStrong), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.arrow_back_rounded, size: 16, color: AppColors.textMuted), - const SizedBox(width: 6), - Text( - 'Prev', - style: TextStyle(fontFamily: 'Geist', - fontSize: 13, - fontWeight: FontWeight.w600, - color: AppColors.textMuted, - ), - ), - ], - ), - ), - ), - ) - else - const SizedBox.shrink(), - const Spacer(), - Flexible( - child: GestureDetector( - onTap: isLast - ? _finishWorkout - : () { - provider.nextExercise(); - _loadLastSessionData(); - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), - decoration: BoxDecoration( - color: isLast ? AppColors.success : AppColors.primary, - borderRadius: BorderRadius.circular(AppRadius.button), - boxShadow: [ - BoxShadow( - color: (isLast ? AppColors.success : AppColors.primary) - .withValues(alpha: 0.35), - blurRadius: 16, - offset: const Offset(0, 4), - ), - ], - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - isLast ? 'Finish' : 'Next exercise', - overflow: TextOverflow.ellipsis, - style: TextStyle(fontFamily: 'Geist', - fontSize: 13, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), - ), - const SizedBox(width: 6), - Icon( - isLast ? Icons.check_rounded : Icons.arrow_forward_rounded, - size: 16, - color: Colors.white, - ), - ], - ), - ), + RFIconButton( + icon: Icons.arrow_back_rounded, + tooltip: 'Previous exercise', + onTap: isFirst + ? null + : () { + provider.previousExercise(); + _loadLastSessionData(); + }, + size: 46, + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: GlowButton( + label: 'Log set', + icon: Icons.check_rounded, + onPressed: _completeSet, ), ), + const SizedBox(width: AppSpacing.sm), + RFIconButton( + icon: isLast ? Icons.flag_rounded : Icons.arrow_forward_rounded, + tooltip: isLast ? 'Finish workout' : 'Next exercise', + color: isLast ? AppColors.success : AppColors.textSoft, + onTap: isLast + ? _finishWorkout + : () { + provider.nextExercise(); + _loadLastSessionData(); + }, + size: 46, + ), ], ), ); @@ -666,98 +616,69 @@ class _WorkoutFlowScreenState extends State { // ── Dialogs ───────────────────────────────────────────────────────────────── - void _showCancelDialog() { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: AppColors.cardHigh, - title: const Text('Cancel Workout?'), - content: const Text('Your progress will not be saved.'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('Continue'), - ), - TextButton( - onPressed: () async { - final nav = Navigator.of(context); - final ctxNav = Navigator.of(ctx); - await context.read().cancelWorkout(); - if (!mounted) return; - ctxNav.pop(); - nav.pop(); - }, - style: TextButton.styleFrom(foregroundColor: AppColors.error), - child: const Text('Discard'), - ), - ], - ), + Future _showCancelDialog() async { + final discard = await showRFConfirmDialog( + context, + title: 'Discard this workout?', + content: 'Nothing from this session will be saved.', + cancelText: 'Keep going', + confirmText: 'Discard', + isDanger: true, ); + if (discard != true || !mounted) return; + final nav = Navigator.of(context); + await context.read().cancelWorkout(); + if (!mounted) return; + nav.pop(); } - void _finishWorkout() { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: AppColors.cardHigh, - title: const Text('Finish Workout?'), - content: const Text('Ready to save this session?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('Continue'), - ), - ElevatedButton( - onPressed: () async { - final nav = Navigator.of(context); - final prManager = context.read(); - Navigator.of(ctx).pop(); - final session = - await context.read().finishWorkout(); - final newPRs = await prManager.checkAndUpdatePRs(session); - if (!mounted) return; - nav.pushReplacement(MaterialPageRoute( - builder: (_) => WorkoutSummaryScreen( - session: session, - newPRs: newPRs, - ), - )); - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.success, - ), - child: const Text('Save & Finish'), - ), - ], - ), + Future _finishWorkout() async { + final confirmed = await showRFConfirmDialog( + context, + title: 'Finish workout?', + content: 'This session will be saved to your history.', + cancelText: 'Keep going', + confirmText: 'Save & finish', ); + if (confirmed != true || !mounted) return; + + final nav = Navigator.of(context); + final prManager = context.read(); + final session = await context.read().finishWorkout(); + final newPRs = await prManager.checkAndUpdatePRs(session); + if (!mounted) return; + nav.pushReplacement(MaterialPageRoute( + builder: (_) => WorkoutSummaryScreen(session: session, newPRs: newPRs), + )); } Future _handleBack() async { - final action = await showDialog<_LeaveAction>( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: AppColors.cardHigh, - title: const Text('Leave workout?'), - content: const Text( - 'Progress is saved. You can resume next time.', + // A sheet, not a dialog: three dialog actions wrap into a ragged column. + final action = await showRFActionSheet<_LeaveAction>( + context, + title: 'Leave this workout?', + message: 'Your sets so far are already saved.', + actions: const [ + RFAction( + label: 'Keep & exit', + value: _LeaveAction.keep, + description: 'Resume where you left off next time', + icon: Icons.bookmark_outline_rounded, + isPrimary: true, ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, _LeaveAction.discard), - style: TextButton.styleFrom(foregroundColor: AppColors.error), - child: const Text('Discard'), - ), - TextButton( - onPressed: () => Navigator.pop(ctx, _LeaveAction.keep), - child: const Text('Keep & exit'), - ), - TextButton( - onPressed: () => Navigator.pop(ctx, _LeaveAction.cancel), - child: const Text('Cancel'), - ), - ], - ), + RFAction( + label: 'Stay here', + value: _LeaveAction.cancel, + icon: Icons.arrow_back_rounded, + ), + RFAction( + label: 'Discard workout', + value: _LeaveAction.discard, + description: 'Delete this session for good', + icon: Icons.delete_outline_rounded, + isDanger: true, + ), + ], ); if (!mounted) return; diff --git a/workout-logger/lib/screens/workout_summary_screen.dart b/workout-logger/lib/screens/workout_summary_screen.dart index b872b77..2ae1e0a 100644 --- a/workout-logger/lib/screens/workout_summary_screen.dart +++ b/workout-logger/lib/screens/workout_summary_screen.dart @@ -11,6 +11,7 @@ import '../services/settings_provider.dart'; import '../theme/app_theme.dart'; import 'widgets/rf_widgets.dart'; import 'widgets/rf_cards.dart'; +import 'widgets/rf_shell.dart'; class WorkoutSummaryScreen extends StatelessWidget { const WorkoutSummaryScreen({ @@ -48,52 +49,70 @@ class WorkoutSummaryScreen extends StatelessWidget { return Scaffold( backgroundColor: AppColors.background, - body: SafeArea( - child: CustomScrollView( - physics: const BouncingScrollPhysics(), - slivers: [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const SizedBox(height: AppSpacing.lg), - _buildTrophyHeader(context), - const SizedBox(height: AppSpacing.xl), - _buildStatGrid( - session.duration, - volStr, - totalSets, - session.exercises.length, - settings.unitLabel, - ), - if (newPRs.isNotEmpty) ...[ - const SizedBox(height: AppSpacing.lg), - _buildPRSection(newPRs, provider), - ], - if (muscles.isNotEmpty) ...[ - const SizedBox(height: AppSpacing.lg), - _buildMusclesSection(muscles, provider), + body: Stack( + children: [ + const AmbientGlow(), + Column( + children: [ + Expanded( + child: SafeArea( + bottom: false, + child: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: AppSpacing.lg), + _buildTrophyHeader(context), + const SizedBox(height: AppSpacing.xl), + _buildStatGrid( + session.duration, + volStr, + totalSets, + session.exercises.length, + settings.unitLabel, + ), + // The only ask on this screen, above the read-only sections. + const SizedBox(height: AppSpacing.lg), + _EffortChipRow(session: session), + if (newPRs.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.lg), + _buildPRSection(newPRs, provider), + ], + if (muscles.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.lg), + _buildMusclesSection(muscles, provider), + ], + const SizedBox(height: AppSpacing.lg), + _buildExerciseSummary( + session, + provider, + settings, + ), + const SizedBox(height: AppSpacing.md), + ], + ), + ), + ), ], - const SizedBox(height: AppSpacing.lg), - _buildExerciseSummary(session, provider), - const SizedBox(height: AppSpacing.lg), - _EffortChipRow(session: session), - const SizedBox(height: AppSpacing.xl), - GlowButton( - label: 'Done', - icon: Icons.check_rounded, - onPressed: () => Navigator.of(context) - .popUntil((r) => r.isFirst), - ), - const SizedBox(height: AppSpacing.lg), - ], + ), ), ), - ), - ], - ), + RFBottomBar( + child: GlowButton( + label: 'Done', + icon: Icons.check_rounded, + onPressed: () => + Navigator.of(context).popUntil((r) => r.isFirst), + ), + ), + ], + ), + ], ), ); } @@ -210,7 +229,7 @@ class WorkoutSummaryScreen extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const RFSectionHeader('New Personal Records'), + const RFSectionHeader('New personal records'), const SizedBox(height: AppSpacing.sm), ...prs.map((pr) { final name = provider.getExerciseName(pr.exerciseId); @@ -273,7 +292,7 @@ class WorkoutSummaryScreen extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const RFSectionHeader('Muscles Trained'), + const RFSectionHeader('Muscles trained'), const SizedBox(height: AppSpacing.sm), Wrap( spacing: 6, @@ -293,14 +312,19 @@ class WorkoutSummaryScreen extends StatelessWidget { Widget _buildExerciseSummary( WorkoutSession session, WorkoutProvider provider, + SettingsProvider settings, ) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const RFSectionHeader('Exercise Breakdown'), + const RFSectionHeader('Exercise breakdown'), const SizedBox(height: AppSpacing.sm), ...session.exercises.map( - (log) => _ExerciseSummaryRow(log: log, provider: provider), + (log) => _ExerciseSummaryRow( + log: log, + provider: provider, + settings: settings, + ), ), ], ); @@ -311,15 +335,18 @@ class _ExerciseSummaryRow extends StatelessWidget { const _ExerciseSummaryRow({ required this.log, required this.provider, + required this.settings, }); final ExerciseLog log; final WorkoutProvider provider; + final SettingsProvider settings; @override Widget build(BuildContext context) { final name = provider.getExerciseName(log.exerciseId); - final volume = log.totalVolume; + // Converted like every other figure here; this row used to print raw kg. + final volume = settings.toDisplay(log.totalVolume); final volStr = volume >= 1000 ? '${(volume / 1000).toStringAsFixed(1)}k' : volume.toStringAsFixed(0); @@ -358,7 +385,7 @@ class _ExerciseSummaryRow extends StatelessWidget { ), ), Text( - '$volStr kg', + '$volStr ${settings.unitLabel}', style: const TextStyle( color: AppColors.success, fontSize: 13, @@ -417,29 +444,11 @@ class _EffortChipRowState extends State<_EffortChipRow> { } Widget _buildChip(({int value, String label, Color color}) option) { - final isSelected = _selected == option.value; - return GestureDetector( + return RFOptionChip( + label: option.label, + color: option.color, + selected: _selected == option.value, onTap: () => _select(option.value), - child: Container( - padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), - alignment: Alignment.center, - decoration: BoxDecoration( - color: option.color.withValues(alpha: isSelected ? 0.2 : 0.08), - borderRadius: BorderRadius.circular(AppRadius.md), - border: Border.all( - color: option.color.withValues(alpha: isSelected ? 0.8 : 0.3), - width: isSelected ? 1.5 : 1, - ), - ), - child: Text( - option.label, - style: TextStyle( - color: isSelected ? option.color : AppColors.textMuted, - fontSize: 13, - fontWeight: isSelected ? FontWeight.w700 : FontWeight.w600, - ), - ), - ), ); } } diff --git a/workout-logger/lib/theme/app_theme.dart b/workout-logger/lib/theme/app_theme.dart index 1b27eb2..3c57bbb 100644 --- a/workout-logger/lib/theme/app_theme.dart +++ b/workout-logger/lib/theme/app_theme.dart @@ -20,11 +20,36 @@ class AppColors { static const glassBorderStrong = Color(0x21FFFFFF); // --border-strong 13% static const divider = Color(0x0FFFFFFF); // 6% white + // GlassCard material. The fill grades top-to-bottom and the edge grades with + // it, so a panel reads as a surface catching light from above rather than as + // an outline. The old fill (3.5% -> 1.6%) resolved to +9/255 and +4/255 over + // the canvas, which left the border twice as bright as the face it wrapped. + static const glassFillTop = Color(0x12FFFFFF); // 7% -> +17.5/255 + static const glassFillBottom = Color(0x05FFFFFF); // 2% -> +4.9/255 + static const glassEdgeTop = Color(0x24FFFFFF); // 14% -> +34.9/255 + static const glassEdgeBottom = Color(0x0AFFFFFF); // 4% -> +9.7/255 + + // Ambient wash floor. A radial pool's reach is finite and a scrolling column + // is not, so this full-height grade is the only layer that can guarantee a + // non-zero floor everywhere. Without it the canvas below the key pool is + // literally flat and glass has nothing to sit on. + static const washFloorTop = Color(0x065B21B6); // 2.4% violet + static const washFloorMid = Color(0x045B21B6); // 1.6% + static const washFloorBottom = Color(0x055B21B6); // 2.0% + // Brand — electric violet primary, cyan data static const primary = Color(0xFF7C3AED); // --accent oklch(0.68 0.18 285) + static const primaryDeep = Color(0xFF5B21B6); // gradient end / ambient wash static const secondary = Color(0xFF00C2D4); // --data oklch(0.78 0.14 200) static const accent = Color(0xFF7C3AED); // alias for primary + /// The one brand gradient, so every violet surface catches the same light. + static const primaryGradient = LinearGradient( + colors: [primary, primaryDeep], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ); + // Semantic static const success = Color(0xFF00C89B); // --success oklch(0.78 0.16 155) static const warning = Color(0xFFDBA520); // --warn oklch(0.78 0.14 60) diff --git a/workout-logger/test/flutter_test_config.dart b/workout-logger/test/flutter_test_config.dart new file mode 100644 index 0000000..f398996 --- /dev/null +++ b/workout-logger/test/flutter_test_config.dart @@ -0,0 +1,12 @@ +// Wraps every test in this suite. See dart.dev/go/flutter-test-config. + +import 'dart:async'; + +import 'package:repforge/screens/widgets/rf_widgets.dart'; + +Future testExecutable(FutureOr Function() testMain) async { + // AmbientGlow drifts on a loop that never completes, which would keep + // pumpAndSettle waiting for a frame that never stops coming. + AmbientGlow.motionEnabled = false; + await testMain(); +} diff --git a/workout-logger/test/screens/exercise_input_section_text_scale_test.dart b/workout-logger/test/screens/exercise_input_section_text_scale_test.dart new file mode 100644 index 0000000..d739b34 --- /dev/null +++ b/workout-logger/test/screens/exercise_input_section_text_scale_test.dart @@ -0,0 +1,165 @@ +// Guards the set-entry UI against large system font sizes. A reader running +// Android's bigger-font settings was seeing the weight value clipped inside +// its own field and the assisted-load line run off the edge of its pill. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/screens/widgets/exercise_input_section.dart'; +import 'package:repforge/services/settings_provider.dart'; +import 'package:repforge/theme/app_theme.dart'; + +import '../test_utils/mock_storage_service.dart'; + +/// Screen widths worth covering: a small phone, the common ~411dp phone, and a +/// tablet-ish width. +const _widths = [360, 411, 720]; + +/// 1.0 is the default; 1.35 is roughly the reported device; 2.0 is the largest +/// font size Android's accessibility settings offer. +const _textScales = [1.0, 1.35, 2.0]; + +void main() { + late SettingsProvider settings; + + setUp(() async { + settings = SettingsProvider(MockStorageService()); + await settings.init(); + }); + + Widget harness({ + required double width, + required double textScale, + required double weight, + String? exerciseId, + }) { + return MediaQuery( + data: MediaQueryData( + size: Size(width, 900), + textScaler: TextScaler.linear(textScale), + ), + child: MaterialApp( + theme: AppTheme.darkTheme, + home: Scaffold( + body: SizedBox( + width: width, + height: 900, + // Mirrors WorkoutFlowScreen: a scroll view tall enough for the + // section's Spacer to have something to absorb. + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.md), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - AppSpacing.md * 2, + ), + child: IntrinsicHeight( + child: ExerciseInputSection( + contentWidth: + constraints.maxWidth - AppSpacing.md * 2, + currentWeight: weight, + currentReps: 12, + isDropset: false, + drops: const [], + mainWeightController: TextEditingController(), + mainRepsController: TextEditingController(), + dropWeightControllers: const [], + dropRepsControllers: const [], + recommendations: [ + SetRecommendation( + weight: 19, + reps: 14, + confidence: 'high', + reasoning: 'test', + ), + ], + previousSets: const [], + lastSession: null, + settings: settings, + exerciseId: exerciseId, + onWeightChanged: (_) {}, + onRepsChanged: (_) {}, + onDropsetToggled: (_) {}, + onDropAdded: () {}, + onDropRemoved: (_) {}, + onDropWeightChanged: (_, _) {}, + onDropRepsChanged: (_, _) {}, + onApplyRecommendation: () {}, + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + + group('ExerciseInputSection lays out without overflow', () { + for (final width in _widths) { + for (final scale in _textScales) { + testWidgets('${width.toInt()}dp at ${scale}x text scale', + (tester) async { + tester.view.physicalSize = Size(width, 900); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + // A three-digit weight is the widest the field ever has to show. + await tester.pumpWidget(harness( + width: width, + textScale: scale, + weight: 102.5, + exerciseId: 'pull_ups', + )); + await tester.pump(); + + expect(tester.takeException(), isNull); + }); + } + } + }); + + group('value text is fitted rather than clipped', () { + testWidgets('a three-digit weight paints inside its field', (tester) async { + tester.view.physicalSize = const Size(411, 900); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget(harness( + width: 411, + textScale: 1.35, + weight: 102.5, + )); + await tester.pump(); + + final field = find.widgetWithText(TextField, '102.5'); + expect(field, findsOneWidget); + + final fieldWidth = tester.getSize(field).width; + final style = tester.widget(field).style!; + final painter = TextPainter( + text: TextSpan(text: '102.5', style: style), + textDirection: TextDirection.ltr, + textScaler: const TextScaler.linear(1.35), + )..layout(); + + expect(painter.width, lessThanOrEqualTo(fieldWidth)); + // Still shrunk only as far as it had to be. + expect(style.fontSize, greaterThanOrEqualTo(18.0)); + }); + + testWidgets('a short value keeps the full display size', (tester) async { + tester.view.physicalSize = const Size(411, 900); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget(harness(width: 411, textScale: 1.0, weight: 20)); + await tester.pump(); + + final field = find.widgetWithText(TextField, '20.0'); + expect(field, findsOneWidget); + expect(tester.widget(field).style!.fontSize, 36.0); + }); + }); +} diff --git a/workout-logger/test/screens/workout_flow_screen_full_test.dart b/workout-logger/test/screens/workout_flow_screen_full_test.dart index db4df20..c71a443 100644 --- a/workout-logger/test/screens/workout_flow_screen_full_test.dart +++ b/workout-logger/test/screens/workout_flow_screen_full_test.dart @@ -44,7 +44,7 @@ void main() { robot.expectVisible(WorkoutFlowScreen); // Tap Log Set button if present - final logBtn = find.text('LOG SET'); + final logBtn = find.text('Log set'); if (logBtn.evaluate().isNotEmpty) { await tester.tap(logBtn); await tester.pumpAndSettle(); @@ -73,7 +73,7 @@ void main() { robot.expectVisible(WorkoutFlowScreen); // Log set - final logBtn = find.text('LOG SET'); + final logBtn = find.text('Log set'); if (logBtn.evaluate().isNotEmpty) { await tester.tap(logBtn); await tester.pumpAndSettle(); diff --git a/workout-logger/test/userflow_screens_sweep_test.dart b/workout-logger/test/userflow_screens_sweep_test.dart index 44b81a1..7070ecd 100644 --- a/workout-logger/test/userflow_screens_sweep_test.dart +++ b/workout-logger/test/userflow_screens_sweep_test.dart @@ -178,12 +178,12 @@ void main() { robot.expectVisible(WorkoutFlowScreen); // Interact with set logging and rest timer - final logSetBtn = find.text('LOG SET'); + final logSetBtn = find.text('Log set'); if (logSetBtn.evaluate().isNotEmpty) { await tester.tap(logSetBtn); await tester.pumpAndSettle(); - final restTargets = ['+30s', 'SKIP REST']; + final restTargets = ['+30s', 'Skip rest']; await TestSweep.tapAll(tester, restTargets); } diff --git a/workout-logger/test/userflow_workout_logging_test.dart b/workout-logger/test/userflow_workout_logging_test.dart index 4c67c59..72e432f 100644 --- a/workout-logger/test/userflow_workout_logging_test.dart +++ b/workout-logger/test/userflow_workout_logging_test.dart @@ -78,15 +78,15 @@ void main() { // Verify WorkoutFlowScreen renders exercise name expect(find.text('Barbell Bench Press'), findsWidgets); - // 2. Drive production flow: Tap 'LOG SET' to trigger RestTimerView overlay in WorkoutFlowScreen - final logSetBtn = find.text('LOG SET'); + // 2. Drive production flow: Tap 'Log set' to trigger RestTimerView overlay in WorkoutFlowScreen + final logSetBtn = find.text('Log set'); expect(logSetBtn, findsOneWidget); await tester.tap(logSetBtn); await tester.pumpAndSettle(); // Verify RestTimerView overlay appears via WorkoutFlowScreen production state expect(find.text('REST'), findsWidgets); - expect(find.text('SKIP REST'), findsOneWidget); + expect(find.text('Skip rest'), findsOneWidget); // Tap '+30s' button during rest final addTimeBtn = find.text('+30s'); @@ -94,8 +94,8 @@ void main() { await tester.tap(addTimeBtn); await tester.pump(); - // Tap 'SKIP REST' to return to active workout view - final skipBtn = find.text('SKIP REST'); + // Tap 'Skip rest' to return to active workout view + final skipBtn = find.text('Skip rest'); await tester.tap(skipBtn); await tester.pumpAndSettle(); From d6fcd083e88f3b16836dd0ba7183e484db483c3e Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Sat, 29 Aug 2026 22:06:11 +0530 Subject: [PATCH 2/5] fix: address CodeRabbit review findings on PR #76 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AnalyticsManager: memoize the fallback exercise map on the `exercises` list identity. It was rebuilt every call, so the `_lastTrainedFor` identity check never hit and lastTrainedPerMuscle re-walked all sessions on every recommendation. - AnalyticsManager.getRecommendations: forward readinessBand and sessionFatigueFactor to recommendSets, matching what WorkoutProvider.getRecommendations already passes. - WorkoutProvider.recordSessionEffort: recompute the calibration offset from every stored answer in date order instead of folding the chip in incrementally. The chip is re-answerable, so changing your mind used to apply both answers. - WorkoutProvider.recordSessionEffort: roll the session back if the offset write fails, so a partial failure can't leave the persisted session ahead of the persisted offset. - SettingsProvider.init: fall back to kDefaultGeminiModel when the stored model is no longer in kGeminiModels — an id from an older build matched no dropdown item and tripped its assertion. - SettingsProvider.setGeminiModel/setGeminiThinkingLevel: persist before committing in memory, so a failed write leaves the saved value active. - Gemini model picker: handle a failed _selectModel instead of dropping the Future, and clamp the thinking-level slider's live index so a drag that outlives its level list can't exceed the new max. Co-Authored-By: Claude Opus 5 --- .../lib/screens/widgets/profile_sections.dart | 21 ++++-- .../services/managers/analytics_manager.dart | 36 +++++++++- .../lib/services/settings_provider.dart | 36 +++++++--- .../lib/services/workout_provider.dart | 65 ++++++++++++++++--- .../test/settings_provider_test.dart | 15 ++++- .../test/workout_provider_test.dart | 24 +++++++ 6 files changed, 172 insertions(+), 25 deletions(-) diff --git a/workout-logger/lib/screens/widgets/profile_sections.dart b/workout-logger/lib/screens/widgets/profile_sections.dart index f406729..5b1a343 100644 --- a/workout-logger/lib/screens/widgets/profile_sections.dart +++ b/workout-logger/lib/screens/widgets/profile_sections.dart @@ -746,10 +746,18 @@ class _AiSettingsSectionState extends State { } Future _selectModel(String modelId) async { + // Same contract as _commitThinkingLevel below: the dropdown's onChanged + // drops this Future, so a storage failure would otherwise surface as an + // unhandled error. On failure the provider keeps the previous model and + // the dropdown rebuilds back onto it, so there's nothing to undo here. final settings = context.read(); final gemini = context.read(); - await settings.setGeminiModel(modelId); - gemini.updateModel(modelId); + try { + await settings.setGeminiModel(modelId); + gemini.updateModel(modelId); + } catch (e, st) { + debugPrint('Failed to save Gemini model: $e\n$st'); + } } Future _commitMaxToolRounds(int rounds) async { @@ -925,8 +933,13 @@ class _AiSettingsSectionState extends State { Builder(builder: (context) { final levels = supportedThinkingLevels(settings.geminiModel); final currentIndex = levels.indexOf(settings.geminiThinkingLevel); - final liveIndex = _draggingThinkingLevelIndex ?? - (currentIndex >= 0 ? currentIndex.toDouble() : 0.0); + // Clamped because a drag in progress can outlive the level list + // it was started against: picking a model with fewer levels + // leaves _draggingThinkingLevelIndex past the new max, which + // Slider asserts on. + final liveIndex = (_draggingThinkingLevelIndex ?? + (currentIndex >= 0 ? currentIndex.toDouble() : 0.0)) + .clamp(0.0, (levels.length - 1).toDouble()); final liveLevel = levels[liveIndex.round().clamp(0, levels.length - 1)]; final liveLevelLabel = liveLevel[0].toUpperCase() + liveLevel.substring(1); return Row( diff --git a/workout-logger/lib/services/managers/analytics_manager.dart b/workout-logger/lib/services/managers/analytics_manager.dart index b5c2e65..151e9a3 100644 --- a/workout-logger/lib/services/managers/analytics_manager.dart +++ b/workout-logger/lib/services/managers/analytics_manager.dart @@ -41,6 +41,13 @@ class AnalyticsManager extends ChangeNotifier { Map? _lastTrained; Map? _lastTrainedFor; + // The map built from a caller's `exercises` list when it passes no + // exerciseMap. Held so repeated calls with the same list reuse one map + // instance — without this, the literal below is a fresh object every call + // and _lastTrainedFor's identity check never hits. + List? _fallbackLookupFor; + Map? _fallbackLookup; + // Callback to update targets with new growth models final void Function(String exerciseId, GrowthModel model)? onGrowthModelUpdated; @@ -78,6 +85,8 @@ class AnalyticsManager extends ChangeNotifier { // it's dropped here and rebuilt lazily on the next getRecommendations. _lastTrained = null; _lastTrainedFor = null; + _fallbackLookupFor = null; + _fallbackLookup = null; } /// Get growth model for a specific exercise @@ -154,12 +163,21 @@ class AnalyticsManager extends ChangeNotifier { /// /// [exerciseMap] is a pre-built id → Exercise map for O(1) lookups; falls /// back to building one from [exercises] when omitted (see - /// [getWeeklyVolumeByMuscle] for the same convention). + /// [getWeeklyVolumeByMuscle] for the same convention). That fallback is + /// memoized on the [exercises] instance, because the last-trained cache + /// below keys off the map's identity. + /// + /// [readinessBand] and [sessionFatigueFactor] are forwarded straight to + /// [IMLService.recommendSets], matching what + /// `WorkoutProvider.getRecommendations` passes — the two entry points + /// have drifted apart before (see [recoveryRecommendationInputs]). List getRecommendations( String exerciseId, List sessions, { List exercises = const [], Map? exerciseMap, + ReadinessBand? readinessBand, + double sessionFatigueFactor = 0.0, }) { final isFresh = identical(_lastIndexedSessions, sessions); @@ -177,7 +195,7 @@ class AnalyticsManager extends ChangeNotifier { ? logs.take(3).map((e) => e.log.sets).toList() : [lastLog.sets]; - final lookup = exerciseMap ?? {for (final e in exercises) e.id: e}; + final lookup = exerciseMap ?? _fallbackLookupFrom(exercises); // Reuse the last-trained walk across calls — it's invalidated by // buildSessionIndex, the same signal that invalidates _sessionIndex. if (!isFresh || !identical(_lastTrainedFor, lookup)) { @@ -198,9 +216,23 @@ class AnalyticsManager extends ChangeNotifier { growthModel: _growthModels[exerciseId], recoveryScores: recoveryInputs.recoveryScores, primaryMuscleIds: recoveryInputs.primaryMuscleIds, + readinessBand: readinessBand, + sessionFatigueFactor: sessionFatigueFactor, ); } + /// id → Exercise map for [exercises], reusing the previously built one + /// while the caller keeps passing the same list instance. The identity + /// check in [getRecommendations] compares map instances, so rebuilding + /// this on every call would invalidate [_lastTrained] every time. + Map _fallbackLookupFrom(List exercises) { + if (!identical(_fallbackLookupFor, exercises) || _fallbackLookup == null) { + _fallbackLookup = {for (final e in exercises) e.id: e}; + _fallbackLookupFor = exercises; + } + return _fallbackLookup!; + } + /// Get volume progression for an exercise. /// /// Reads from the pre-built session index (sorted oldest-first by diff --git a/workout-logger/lib/services/settings_provider.dart b/workout-logger/lib/services/settings_provider.dart index 947efad..96b90e6 100644 --- a/workout-logger/lib/services/settings_provider.dart +++ b/workout-logger/lib/services/settings_provider.dart @@ -4,7 +4,8 @@ import 'package:flutter/foundation.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'ai/gemini_ai_service.dart' show kDefaultMaxToolRounds, kMinMaxToolRounds, kMaxMaxToolRounds, - kDefaultThinkingLevel, clampThinkingLevel; + kDefaultThinkingLevel, kDefaultGeminiModel, kGeminiModels, + clampThinkingLevel; import 'interfaces/storage_service_interface.dart'; enum WeightUnit { kg, lbs } @@ -19,7 +20,7 @@ class SettingsProvider extends ChangeNotifier { String? _userName; String? _lastSeenVersion; String _geminiApiKey = ''; - String _geminiModel = 'gemini-3.6-flash'; + String _geminiModel = kDefaultGeminiModel; int _geminiMaxToolRounds = kDefaultMaxToolRounds; String _geminiThinkingLevel = kDefaultThinkingLevel; String _weeklyInsights = ''; @@ -68,7 +69,14 @@ class SettingsProvider extends ChangeNotifier { _userName = await _storage.getSetting('userName'); _lastSeenVersion = await _storage.getSetting('lastSeenVersion'); _geminiApiKey = await _storage.getSetting('geminiApiKey') ?? ''; - _geminiModel = await _storage.getSetting('geminiModel') ?? 'gemini-3.6-flash'; + // Normalize on read: the picker only offers kGeminiModels, and a value + // left behind by an older build (the list has churned across releases) + // would match none of its items and trip DropdownButtonFormField's + // "exactly one item per value" assertion. + final storedModel = await _storage.getSetting('geminiModel'); + _geminiModel = _isKnownGeminiModel(storedModel) + ? storedModel! + : kDefaultGeminiModel; final maxRounds = await _storage.getSetting('geminiMaxToolRounds'); // Clamp on read as well as on write: a stored value from an older build or // a hand-edited settings row would otherwise bypass the bounds that @@ -86,6 +94,10 @@ class SettingsProvider extends ChangeNotifier { _showAdvancedMetrics = advMetrics == 'true'; } + /// Whether [model] is one the model picker actually offers. + static bool _isKnownGeminiModel(String? model) => + model != null && kGeminiModels.any((entry) => entry.$1 == model); + /// A valid bodyweight must be finite (not NaN/Infinity) and strictly positive. static bool _isValidBodyWeight(double? weight) => weight != null && weight.isFinite && weight > 0; @@ -147,14 +159,18 @@ class SettingsProvider extends ChangeNotifier { notifyListeners(); } + /// Persists before committing in memory, for the same reason as + /// [setGeminiThinkingLevel]: the picker's onChanged drops this Future, so a + /// failed write must leave the previously saved model active rather than a + /// value that only exists in memory. Future setGeminiModel(String model) async { - _geminiModel = model; await _storage.saveSetting('geminiModel', model); - final clamped = clampThinkingLevel(_geminiModel, _geminiThinkingLevel); + final clamped = clampThinkingLevel(model, _geminiThinkingLevel); if (clamped != _geminiThinkingLevel) { - _geminiThinkingLevel = clamped; await _storage.saveSetting('geminiThinkingLevel', clamped); } + _geminiModel = model; + _geminiThinkingLevel = clamped; notifyListeners(); } @@ -164,9 +180,13 @@ class SettingsProvider extends ChangeNotifier { notifyListeners(); } + /// Persists before committing in memory: the slider's onChangeEnd swallows + /// a throw from here, so assigning first would leave the provider showing a + /// level that was never written and never notified. Future setGeminiThinkingLevel(String level) async { - _geminiThinkingLevel = clampThinkingLevel(_geminiModel, level); - await _storage.saveSetting('geminiThinkingLevel', _geminiThinkingLevel); + final clamped = clampThinkingLevel(_geminiModel, level); + await _storage.saveSetting('geminiThinkingLevel', clamped); + _geminiThinkingLevel = clamped; notifyListeners(); } diff --git a/workout-logger/lib/services/workout_provider.dart b/workout-logger/lib/services/workout_provider.dart index a6c2081..996dc57 100644 --- a/workout-logger/lib/services/workout_provider.dart +++ b/workout-logger/lib/services/workout_provider.dart @@ -920,26 +920,73 @@ class WorkoutProvider extends ChangeNotifier { /// Lighter than [updateWorkoutSession]: this only annotates metadata, so /// it skips the growth-model/target retraining that method does for /// exercise-data changes. + /// + /// The chip is re-answerable — the summary screen leaves it tappable — so + /// the offset is recomputed from every stored answer in date order rather + /// than folded in incrementally. Folding would count a changed answer + /// twice (tapping Brutal then Easy would apply both). Future recordSessionEffort(String sessionId, int chipValue) async { final index = _sessions.indexWhere((s) => s.id == sessionId); if (index == -1) return; - final updated = _sessions[index].copyWith(sessionEffort: chipValue); + final previousSession = _sessions[index]; + final previousOffset = _effortCalibrationOffset; + final updated = previousSession.copyWith(sessionEffort: chipValue); + + final nextSessions = List.from(_sessions)..[index] = updated; + final nextOffset = _recomputeEffortCalibrationOffset(nextSessions); + + // No transaction across boxes here, so on a partial failure put both the + // session and the offset back the way they were and rethrow — the caller + // (the summary screen's chip) restores its selection on the throw. await _storage.saveWorkoutSession(updated); - _sessions = List.from(_sessions)..[index] = updated; + try { + await _storage.saveSetting( + _effortCalibrationOffsetKey, + nextOffset.toString(), + ); + } catch (_) { + await _restoreSessionEffort(previousSession, previousOffset); + rethrow; + } + + _sessions = nextSessions; + _effortCalibrationOffset = nextOffset; _invalidateHistoryCache(); _historyManager?.patchSession(updated); - _effortCalibrationOffset = - _effortCalibration.updateOffset(_effortCalibrationOffset, chipValue); - await _storage.saveSetting( - _effortCalibrationOffsetKey, - _effortCalibrationOffset.toString(), - ); - notifyListeners(); } + /// Rebuilds the rolling offset from scratch over every answered session, + /// oldest first, so the result depends only on the stored answers and not + /// on how many times the user tapped to get there. + double _recomputeEffortCalibrationOffset(List sessions) { + final answered = sessions.where((s) => s.sessionEffort != null).toList() + ..sort((a, b) => a.date.compareTo(b.date)); + var offset = 0.0; + for (final session in answered) { + offset = _effortCalibration.updateOffset(offset, session.sessionEffort!); + } + return offset; + } + + /// Best-effort undo of a half-applied [recordSessionEffort]. A failure here + /// leaves the session row ahead of the stored offset, which the next + /// answered chip recomputes away; swallowing it keeps the original error + /// as the one the caller sees. + Future _restoreSessionEffort( + WorkoutSession previousSession, + double previousOffset, + ) async { + try { + await _storage.saveWorkoutSession(previousSession); + } catch (e, st) { + debugPrint('Failed to roll back session effort: $e\n$st'); + } + _effortCalibrationOffset = previousOffset; + } + // ==================== ROUTINES ==================== Future createRoutine(String name, List exerciseIds) async { diff --git a/workout-logger/test/settings_provider_test.dart b/workout-logger/test/settings_provider_test.dart index cd18493..8a88a88 100644 --- a/workout-logger/test/settings_provider_test.dart +++ b/workout-logger/test/settings_provider_test.dart @@ -32,7 +32,7 @@ void main() { await mockStorage.saveSetting('readinessEnabled', 'true'); await mockStorage.saveSetting('userName', 'Devasy'); await mockStorage.saveSetting('geminiApiKey', 'secret_key'); - await mockStorage.saveSetting('geminiModel', 'gemini-1.5-pro'); + await mockStorage.saveSetting('geminiModel', 'gemini-3.5-flash'); await mockStorage.saveSetting('geminiThinkingLevel', 'high'); await mockStorage.saveSetting('showAdvancedMetrics', 'true'); @@ -45,11 +45,22 @@ void main() { expect(provider.readinessEnabled, isTrue); expect(provider.userName, equals('Devasy')); expect(provider.geminiApiKey, equals('secret_key')); - expect(provider.geminiModel, equals('gemini-1.5-pro')); + expect(provider.geminiModel, equals('gemini-3.5-flash')); expect(provider.geminiThinkingLevel, equals('high')); expect(provider.showAdvancedMetrics, isTrue); }); + test('init falls back to the default model when the stored one is no ' + 'longer offered', () async { + // A model id left behind by an older build. The picker only has items + // for kGeminiModels, so surfacing this one would assert. + await mockStorage.saveSetting('geminiModel', 'gemini-1.5-pro'); + + await provider.init(); + + expect(provider.geminiModel, equals('gemini-3.6-flash')); + }); + test('init clamps a stored thinking level that the stored model no longer supports', () async { await mockStorage.saveSetting('geminiModel', 'gemini-3.7-flash'); await mockStorage.saveSetting('geminiThinkingLevel', 'minimal'); diff --git a/workout-logger/test/workout_provider_test.dart b/workout-logger/test/workout_provider_test.dart index cf659b1..09a6fd2 100644 --- a/workout-logger/test/workout_provider_test.dart +++ b/workout-logger/test/workout_provider_test.dart @@ -465,6 +465,30 @@ void main() { await provider.recordSessionEffort('does-not-exist', 3); expect(provider.effortCalibrationOffset, 0.0); }); + + test('re-answering the chip replaces the previous answer rather than ' + 'folding both in', () async { + mockStorage.addMockSession( + session('s1', DateTime(2025, 1, 1), [log('bench_press')]), + ); + await provider.init(); + + // The summary screen leaves the chip tappable, so changing your + // mind must land on the same offset as answering Easy once. + await provider.recordSessionEffort('s1', 3); // Brutal + await provider.recordSessionEffort('s1', 1); // ...actually, Easy + final afterChange = provider.effortCalibrationOffset; + + final fresh = WorkoutProvider( + mockStorage, + programManager: ProgramManager(mockStorage), + ); + mockStorage.settings.remove('effort.calibrationOffset'); + await fresh.init(); + await fresh.recordSessionEffort('s1', 1); // Easy, first time + + expect(afterChange, closeTo(fresh.effortCalibrationOffset, 0.0001)); + }); }); }); From 61bc2c5ba343eb58d8ac1fd8de408fbafe09b7c6 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Sat, 29 Aug 2026 22:27:23 +0530 Subject: [PATCH 3/5] feat: confirm AI settings saves with a toast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pairs with the persist-before-commit change: now that a failed write leaves the previous value active, the screen says so instead of just appearing not to respond. Uses the existing RFSnackBar design-system helper. Success toasts only on the deliberate actions (Save on the API key, picking a model); the thinking-level and tool-round sliders commit on every drag-release, so they stay quiet unless the write fails. Every failure toasts. Also gives the API key Save button a catch — it previously had a try/finally with no handler, so a storage failure was an unhandled error from the button's onPressed. Co-Authored-By: Claude Opus 5 --- .../lib/screens/widgets/profile_sections.dart | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/workout-logger/lib/screens/widgets/profile_sections.dart b/workout-logger/lib/screens/widgets/profile_sections.dart index 5b1a343..68a3ab3 100644 --- a/workout-logger/lib/screens/widgets/profile_sections.dart +++ b/workout-logger/lib/screens/widgets/profile_sections.dart @@ -8,6 +8,7 @@ import '../../services/debug_log_buffer.dart'; import '../../services/settings_provider.dart'; import '../../services/ai/gemini_ai_service.dart'; import '../../theme/app_theme.dart'; +import 'rf_dialogs.dart'; import 'rf_widgets.dart'; const String _createdBy = 'Devasy Patel'; @@ -732,6 +733,22 @@ class _AiSettingsSectionState extends State { super.dispose(); } + /// Reports the outcome of a settings write. Success is only worth a toast + /// for the deliberate actions (Save, picking a model) — the thinking + /// slider commits on every drag-release, so it stays quiet unless it fails. + void _reportSaved(String message) { + if (!mounted) return; + context.showRFSnackBar(message, type: RFSnackBarType.success); + } + + void _reportSaveFailed(String what) { + if (!mounted) return; + context.showRFSnackBar( + "Couldn't save $what — the change wasn't applied.", + type: RFSnackBarType.error, + ); + } + Future _save() async { setState(() => _saving = true); final key = _ctrl.text.trim(); @@ -740,6 +757,10 @@ class _AiSettingsSectionState extends State { try { await settings.setGeminiApiKey(key); gemini.updateApiKey(key); + _reportSaved(key.isEmpty ? 'API key cleared' : 'API key saved'); + } catch (e, st) { + debugPrint('Failed to save Gemini API key: $e\n$st'); + _reportSaveFailed('your API key'); } finally { if (mounted) setState(() => _saving = false); } @@ -755,8 +776,10 @@ class _AiSettingsSectionState extends State { try { await settings.setGeminiModel(modelId); gemini.updateModel(modelId); + _reportSaved('Model saved'); } catch (e, st) { debugPrint('Failed to save Gemini model: $e\n$st'); + _reportSaveFailed('the model'); } } @@ -768,6 +791,7 @@ class _AiSettingsSectionState extends State { await context.read().setGeminiMaxToolRounds(rounds); } catch (e, st) { debugPrint('Failed to save max tool rounds: $e\n$st'); + _reportSaveFailed('the tool-round limit'); } finally { if (mounted) { setState(() => _draggingMaxToolRounds = null); @@ -784,6 +808,7 @@ class _AiSettingsSectionState extends State { gemini.updateThinkingLevel(level); } catch (e, st) { debugPrint('Failed to save thinking level: $e\n$st'); + _reportSaveFailed('the thinking level'); } finally { if (mounted) { setState(() => _draggingThinkingLevelIndex = null); From 2936ce8037ca06e3d78eb232b2611a1c114b81c4 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:53:24 +0530 Subject: [PATCH 4/5] fix: address CodeRabbit review findings on PR #77 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RFIconButton: InkWell instead of a bare GestureDetector, so the back/close control in every header is reachable by keyboard and switch access — the requirement RFOptionChip already states in this file. Gesture area expanded to Material's 48pt minimum; painted box stays 38pt. - RFIconButton: expose standardSize/minTapTarget/standardExtent, and key RFScreenHeader's counterweight off standardExtent rather than a hardcoded 38.0. Documented that the counterweight only holds while every action is a default-size RFIconButton. - showRFActionSheet: isScrollControlled + SingleChildScrollView. The 9/16 height cap clipped the last action with no way to scroll to it — by 50px at default text scale on a 400x640 viewport, 655px at 2.0x. Added a text-scale widget test; verified it fails without the fix. - _PoolRig: fold the drift fade into the wash gradient's alpha instead of an Opacity widget, dropping three near-fullscreen saveLayers per frame from a loop that never stops. Equivalent output — the gradient's far stop is fully transparent. - _SendButton: InkWell so the coach's send button joins the focus traversal order (Enter from the text field already worked). - _buildExerciseSummary: named parameters, per CLAUDE.md's 3+ argument convention. Co-Authored-By: Claude Opus 5 --- .../lib/screens/ai_coach_screen.dart | 171 +++++++++++------- .../lib/screens/widgets/rf_dialogs.dart | 9 +- .../lib/screens/widgets/rf_shell.dart | 106 +++++++---- .../lib/screens/widgets/rf_widgets.dart | 75 ++++---- .../lib/screens/workout_summary_screen.dart | 33 ++-- .../rf_action_sheet_text_scale_test.dart | 112 ++++++++++++ 6 files changed, 348 insertions(+), 158 deletions(-) create mode 100644 workout-logger/test/screens/rf_action_sheet_text_scale_test.dart diff --git a/workout-logger/lib/screens/ai_coach_screen.dart b/workout-logger/lib/screens/ai_coach_screen.dart index 2455b52..41bc8c3 100644 --- a/workout-logger/lib/screens/ai_coach_screen.dart +++ b/workout-logger/lib/screens/ai_coach_screen.dart @@ -220,8 +220,11 @@ class _AiCoachViewState extends State<_AiCoachView> { ), const SizedBox(height: AppSpacing.lg), Text( - name != null && name.isNotEmpty ? 'Hey $name 👋' : 'Your AI Coach', - style: TextStyle(fontFamily: 'Geist', + name != null && name.isNotEmpty + ? 'Hey $name 👋' + : 'Your AI Coach', + style: TextStyle( + fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 22, fontWeight: FontWeight.w700, @@ -232,7 +235,8 @@ class _AiCoachViewState extends State<_AiCoachView> { Text( 'Ask me anything — what to train today, how to break a plateau, reading your progress, anything.', textAlign: TextAlign.center, - style: TextStyle(fontFamily: 'Geist', + style: TextStyle( + fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 14, height: 1.5, @@ -275,7 +279,8 @@ class _AiCoachViewState extends State<_AiCoachView> { const RFEmptyState( icon: Icons.key_rounded, title: 'API Key Required', - subtitle: 'Add your Gemini API key in\nProfile → AI Features to start chatting', + subtitle: + 'Add your Gemini API key in\nProfile → AI Features to start chatting', ), const SizedBox(height: AppSpacing.lg), GlowButton( @@ -379,45 +384,52 @@ class _SendButton extends StatelessWidget { button: true, enabled: active, label: loading ? 'Sending' : 'Send message', - child: GestureDetector( - onTap: active ? onTap : null, - child: AnimatedContainer( - duration: AppDurations.fast, - curve: Curves.easeOut, - width: 44, - height: 44, - decoration: BoxDecoration( - gradient: active ? AppColors.primaryGradient : null, - color: active ? null : AppColors.glass2, - borderRadius: BorderRadius.circular(AppRadius.xl), - border: active - ? null - : Border.all(color: AppColors.glassBorder), - boxShadow: active - ? [ - BoxShadow( - color: AppColors.primaryGlow(0.4), - blurRadius: 12, - spreadRadius: -4, - ), - ] - : null, - ), - child: Center( - child: loading - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(AppColors.primary), + // InkWell, not a bare GestureDetector: the button needs to sit in the + // focus traversal order and activate from the keyboard. Enter already + // sends from the text field, so this is about reaching the control + // itself, not about the action being unavailable. + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: active ? onTap : null, + borderRadius: BorderRadius.circular(AppRadius.xl), + focusColor: AppColors.primaryGlow(0.35), + child: AnimatedContainer( + duration: AppDurations.fast, + curve: Curves.easeOut, + width: 44, + height: 44, + decoration: BoxDecoration( + gradient: active ? AppColors.primaryGradient : null, + color: active ? null : AppColors.glass2, + borderRadius: BorderRadius.circular(AppRadius.xl), + border: active ? null : Border.all(color: AppColors.glassBorder), + boxShadow: active + ? [ + BoxShadow( + color: AppColors.primaryGlow(0.4), + blurRadius: 12, + spreadRadius: -4, + ), + ] + : null, + ), + child: Center( + child: loading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(AppColors.primary), + ), + ) + : Icon( + Icons.arrow_upward_rounded, + color: active ? Colors.white : AppColors.textFaint, + size: 20, ), - ) - : Icon( - Icons.arrow_upward_rounded, - color: active ? Colors.white : AppColors.textFaint, - size: 20, - ), + ), ), ), ), @@ -449,7 +461,8 @@ class _ConversationsSheet extends StatelessWidget { children: [ Text( 'Conversations', - style: TextStyle(fontFamily: 'Geist', + style: TextStyle( + fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 16, fontWeight: FontWeight.w700, @@ -463,12 +476,16 @@ class _ConversationsSheet extends StatelessWidget { }, child: Row( children: [ - const Icon(Icons.add_rounded, - color: AppColors.primary, size: 18), + const Icon( + Icons.add_rounded, + color: AppColors.primary, + size: 18, + ), const SizedBox(width: 4), Text( 'New chat', - style: TextStyle(fontFamily: 'Geist', + style: TextStyle( + fontFamily: 'Geist', color: AppColors.primary, fontSize: 13, fontWeight: FontWeight.w600, @@ -482,10 +499,13 @@ class _ConversationsSheet extends StatelessWidget { const SizedBox(height: AppSpacing.md), if (conversations.isEmpty) Padding( - padding: const EdgeInsets.symmetric(vertical: AppSpacing.lg), + padding: const EdgeInsets.symmetric( + vertical: AppSpacing.lg, + ), child: Text( 'No saved conversations yet.', - style: TextStyle(fontFamily: 'Geist', + style: TextStyle( + fontFamily: 'Geist', color: AppColors.textMuted, fontSize: 13, ), @@ -548,23 +568,31 @@ class _ConversationTile extends StatelessWidget { vertical: AppSpacing.sm + 2, ), decoration: BoxDecoration( - color: isActive ? AppColors.primary.withValues(alpha: 0.12) : AppColors.glass3, + color: isActive + ? AppColors.primary.withValues(alpha: 0.12) + : AppColors.glass3, borderRadius: BorderRadius.circular(AppRadius.md), border: Border.all( - color: isActive ? AppColors.primary.withValues(alpha: 0.4) : AppColors.glassBorder, + color: isActive + ? AppColors.primary.withValues(alpha: 0.4) + : AppColors.glassBorder, ), ), child: Row( children: [ - const Icon(Icons.chat_bubble_outline_rounded, - color: AppColors.textMuted, size: 16), + const Icon( + Icons.chat_bubble_outline_rounded, + color: AppColors.textMuted, + size: 16, + ), const SizedBox(width: AppSpacing.sm), Expanded( child: Text( conversation.title.isEmpty ? 'New chat' : conversation.title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(fontFamily: 'Geist', + style: TextStyle( + fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 13, fontWeight: FontWeight.w500, @@ -575,8 +603,11 @@ class _ConversationTile extends StatelessWidget { onTap: onDelete, child: const Padding( padding: EdgeInsets.only(left: AppSpacing.sm), - child: Icon(Icons.delete_outline_rounded, - color: AppColors.textFaint, size: 18), + child: Icon( + Icons.delete_outline_rounded, + color: AppColors.textFaint, + size: 18, + ), ), ), ], @@ -655,7 +686,8 @@ class _Turn extends StatelessWidget { @override Widget build(BuildContext context) { // A dashboard takes the full column; prose takes a bubble. - final isDashboard = !forceBubble && + final isDashboard = + !forceBubble && !isUser && child is CoachMessageContent && CoachMessageContent.rendersAsDashboard( @@ -679,8 +711,7 @@ class _Turn extends StatelessWidget { bottomLeft: Radius.circular(isUser ? AppRadius.lg : 4), bottomRight: Radius.circular(isUser ? 4 : AppRadius.lg), ), - border: - isUser ? null : Border.all(color: AppColors.glassBorder), + border: isUser ? null : Border.all(color: AppColors.glassBorder), boxShadow: isUser ? [ BoxShadow( @@ -695,8 +726,9 @@ class _Turn extends StatelessWidget { ); final column = Column( - crossAxisAlignment: - isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, + crossAxisAlignment: isUser + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ if (toolCalls.isNotEmpty) @@ -714,8 +746,9 @@ class _Turn extends StatelessWidget { return Padding( padding: const EdgeInsets.only(bottom: AppSpacing.md), child: Row( - mainAxisAlignment: - isUser ? MainAxisAlignment.end : MainAxisAlignment.start, + mainAxisAlignment: isUser + ? MainAxisAlignment.end + : MainAxisAlignment.start, // Top, so a tall turn's avatar sits beside its first line, not its last. crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -729,8 +762,8 @@ class _Turn extends StatelessWidget { Flexible( child: ConstrainedBox( constraints: BoxConstraints( - maxWidth: MediaQuery.sizeOf(context).width * - _kBubbleMaxWidthFactor, + maxWidth: + MediaQuery.sizeOf(context).width * _kBubbleMaxWidthFactor, ), child: column, ), @@ -772,7 +805,9 @@ class _ToolCallChips extends StatelessWidget { decoration: BoxDecoration( color: AppColors.secondary.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(AppRadius.full), - border: Border.all(color: AppColors.secondary.withValues(alpha: 0.3)), + border: Border.all( + color: AppColors.secondary.withValues(alpha: 0.3), + ), ), child: Row( mainAxisSize: MainAxisSize.min, @@ -785,7 +820,8 @@ class _ToolCallChips extends StatelessWidget { const SizedBox(width: 4), Text( _label(name), - style: const TextStyle(fontFamily: 'GeistMono', + style: const TextStyle( + fontFamily: 'GeistMono', color: AppColors.secondary, fontSize: 10, fontWeight: FontWeight.w600, @@ -885,7 +921,8 @@ class _CoachMarkdown extends StatelessWidget { Widget build(BuildContext context) { return GptMarkdown( text, - style: TextStyle(fontFamily: 'Geist', + style: TextStyle( + fontFamily: 'Geist', color: AppColors.textPrimary, fontSize: 14, height: 1.55, diff --git a/workout-logger/lib/screens/widgets/rf_dialogs.dart b/workout-logger/lib/screens/widgets/rf_dialogs.dart index 49612d3..74d8729 100644 --- a/workout-logger/lib/screens/widgets/rf_dialogs.dart +++ b/workout-logger/lib/screens/widgets/rf_dialogs.dart @@ -107,11 +107,15 @@ Future showRFActionSheet( context: context, backgroundColor: AppColors.surface, barrierColor: Colors.black.withValues(alpha: 0.6), + // Without this the sheet is capped at 9/16 of the viewport. Three actions + // with descriptions already fill most of that, so at a large text scale + // the bottom action would be clipped with no way to scroll to it. + isScrollControlled: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)), ), builder: (ctx) => SafeArea( - child: Padding( + child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB( AppSpacing.md, AppSpacing.sm, @@ -166,8 +170,7 @@ Future showRFActionSheet( ) else _SheetChoice(action: action, ctx: ctx), - if (action != actions.last) - const SizedBox(height: AppSpacing.sm), + if (action != actions.last) const SizedBox(height: AppSpacing.sm), ], ], ), diff --git a/workout-logger/lib/screens/widgets/rf_shell.dart b/workout-logger/lib/screens/widgets/rf_shell.dart index 4d93a48..a240d28 100644 --- a/workout-logger/lib/screens/widgets/rf_shell.dart +++ b/workout-logger/lib/screens/widgets/rf_shell.dart @@ -13,9 +13,23 @@ class RFIconButton extends StatelessWidget { required this.onTap, this.tooltip, this.color, - this.size = 38, + this.size = standardSize, }); + /// The painted box size for every header button. [RFScreenHeader]'s + /// centred-title counterweight assumes each action is this wide. + static const double standardSize = 38; + + /// Material's minimum touch target. The painted box stays [size]; the + /// gesture area is expanded to this when [size] is smaller. + static const double minTapTarget = 48; + + /// The width a default-size button actually occupies once the tap target is + /// applied — what a caller laying out around it needs, not [standardSize]. + static const double standardExtent = standardSize > minTapTarget + ? standardSize + : minTapTarget; + final IconData icon; final VoidCallback? onTap; @@ -29,40 +43,55 @@ class RFIconButton extends StatelessWidget { @override Widget build(BuildContext context) { final enabled = onTap != null; + final target = size < minTapTarget ? minTapTarget : size; + // InkWell, not a bare GestureDetector: this is the back/close control in + // every header, so it has to be reachable by keyboard and switch access — + // the same requirement RFOptionChip states below. The Material is there + // for headers that sit outside a Scaffold, where InkWell has no ancestor + // to paint its splash into. final button = Semantics( button: true, enabled: enabled, label: tooltip, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: enabled - ? () { - HapticFeedback.lightImpact(); - onTap!(); - } - : null, - child: Container( - width: size, - height: size, - alignment: Alignment.center, - decoration: BoxDecoration( - color: AppColors.glass2, - borderRadius: BorderRadius.circular(AppRadius.md), - border: Border.all(color: AppColors.glassBorder), - ), - child: Icon( - icon, - size: 18, - color: enabled - ? (color ?? AppColors.textSoft) - : AppColors.textFaint, + child: Material( + type: MaterialType.transparency, + child: InkWell( + borderRadius: BorderRadius.circular(AppRadius.md), + onTap: enabled + ? () { + HapticFeedback.lightImpact(); + onTap!(); + } + : null, + // The gesture area is the full 48pt target; only the decorated box + // inside it is painted at `size`. + child: SizedBox( + width: target, + height: target, + child: Center( + child: Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.glass2, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: AppColors.glassBorder), + ), + child: Icon( + icon, + size: 18, + color: enabled + ? (color ?? AppColors.textSoft) + : AppColors.textFaint, + ), + ), + ), ), ), ), ); - return tooltip == null - ? button - : Tooltip(message: tooltip!, child: button); + return tooltip == null ? button : Tooltip(message: tooltip!, child: button); } } @@ -138,14 +167,21 @@ class RFScreenHeader extends StatelessWidget { @override Widget build(BuildContext context) { - // One action = one 38pt button + one 8pt gap, matching RFIconButton. - const cellWidth = 38.0 + AppSpacing.sm; + // One action = one default-size button plus its 8pt gap. + // + // This counterweight is only correct while every action is an + // RFIconButton at the default size: the header cannot measure its actions + // before layout, so a caller passing differently-sized actions (as + // workout_header.dart does) with centreTitle: true will see the title sit + // off centre. Measure the trailing cluster if that case ever needs to work. + const cellWidth = RFIconButton.standardExtent + AppSpacing.sm; final leadingWidth = onBack != null ? cellWidth : 0.0; final trailingWidth = actions.length * cellWidth; final titleBlock = Column( - crossAxisAlignment: - centreTitle ? CrossAxisAlignment.center : CrossAxisAlignment.start, + crossAxisAlignment: centreTitle + ? CrossAxisAlignment.center + : CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( @@ -329,8 +365,8 @@ class RFOptionChip extends StatelessWidget { final fg = selected ? c : enabled - ? AppColors.textSoft - : AppColors.textFaint; + ? AppColors.textSoft + : AppColors.textFaint; return Semantics( button: enabled, @@ -357,9 +393,7 @@ class RFOptionChip extends StatelessWidget { vertical: AppSpacing.sm + 1, ), decoration: BoxDecoration( - color: selected - ? c.withValues(alpha: 0.16) - : AppColors.glass2, + color: selected ? c.withValues(alpha: 0.16) : AppColors.glass2, borderRadius: BorderRadius.circular(AppRadius.full), border: Border.all( color: selected diff --git a/workout-logger/lib/screens/widgets/rf_widgets.dart b/workout-logger/lib/screens/widgets/rf_widgets.dart index aaeac90..b3efb5c 100644 --- a/workout-logger/lib/screens/widgets/rf_widgets.dart +++ b/workout-logger/lib/screens/widgets/rf_widgets.dart @@ -45,6 +45,7 @@ class GlassCard extends StatelessWidget { final BorderRadius? borderRadius; final Color? glowColor; final Color? borderColor; + /// When true, uses accent colour border (e.g. Analytics exercise selector). final bool accentBorder; final VoidCallback? onTap; @@ -57,8 +58,7 @@ class GlassCard extends StatelessWidget { // An explicit border is a state signal (selected, accented), so it stays a // flat ring at full strength. The graded ring is the default *material*, // and grading it would mute the signal. - final overrideColor = - accentBorder ? AppColors.primary : borderColor; + final overrideColor = accentBorder ? AppColors.primary : borderColor; final decoration = BoxDecoration( gradient: const LinearGradient( @@ -101,10 +101,7 @@ class GlassCard extends StatelessWidget { return Semantics( button: true, label: semanticsLabel, - child: GestureDetector( - onTap: onTap, - child: content, - ), + child: GestureDetector(onTap: onTap, child: content), ); } } @@ -154,8 +151,8 @@ class AmbientMotion extends InheritedNotifier> { /// Reads the current offset without subscribing. static double read(BuildContext context) { - final element = - context.getElementForInheritedWidgetOfExactType(); + final element = context + .getElementForInheritedWidgetOfExactType(); final widget = element?.widget as AmbientMotion?; return widget?.notifier?.value ?? 0; } @@ -279,13 +276,14 @@ class _AmbientGlowState extends State } /// Sine at [harmonic] cycles per loop; coprime harmonics never resync. - double _wave(double t, int harmonic) => - math.sin(2 * math.pi * harmonic * t); + double _wave(double t, int harmonic) => math.sin(2 * math.pi * harmonic * t); /// Eased toward the live scroll position so route changes glide, not jump. double _sampleParallax(BuildContext context) { - final target = - (AmbientMotion.read(context) / _parallaxRange).clamp(0.0, 1.0); + final target = (AmbientMotion.read(context) / _parallaxRange).clamp( + 0.0, + 1.0, + ); _parallax += (target - _parallax) * 0.08; return _parallax; } @@ -301,16 +299,21 @@ class _AmbientGlowState extends State Offset drift = Offset.zero, double fade = 1, }) { - final wash = _Wash(size: box, opacity: opacity); + // The fade is folded into the gradient's own alpha rather than wrapped in + // an Opacity: these boxes are a large fraction of the viewport and the + // drift loop never stops, so an Opacity here would mean three + // near-fullscreen saveLayers on every frame, forever. Equivalent output — + // the gradient's far stop is fully transparent, so scaling the near stop + // scales the whole ramp. return Positioned( left: left, top: top, child: animate ? Transform.translate( offset: drift, - child: Opacity(opacity: fade, child: wash), + child: _Wash(size: box, opacity: opacity * fade), ) - : wash, + : _Wash(size: box, opacity: opacity), ); } @@ -447,7 +450,6 @@ class _Wash extends StatelessWidget { // Moved to floating_nav_bar.dart (zero-dependency, drop-in portable widget). // Import and use FloatingNavBar / FloatingNavBarScaffold / FloatingNavItem. - // ── GlowButton ────────────────────────────────────────────────────────────── // Full-width primary action button with glow shadow + haptic feedback. class GlowButton extends StatefulWidget { @@ -521,10 +523,8 @@ class _GlowButtonState extends State return AnimatedBuilder( animation: _scale, - builder: (context, child) => Transform.scale( - scale: _scale.value, - child: child, - ), + builder: (context, child) => + Transform.scale(scale: _scale.value, child: child), child: Semantics( button: true, label: widget.label, @@ -558,8 +558,9 @@ class _GlowButtonState extends State ], ), child: Row( - mainAxisSize: - widget.fullWidth ? MainAxisSize.max : MainAxisSize.min, + mainAxisSize: widget.fullWidth + ? MainAxisSize.max + : MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ if (widget.icon != null) ...[ @@ -704,10 +705,7 @@ class RFSectionHeader extends StatelessWidget { padding: EdgeInsets.only(bottom: bottomPad ? AppSpacing.sm : 0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - RFLabel(title), - ?trailing, - ], + children: [RFLabel(title), ?trailing], ), ); } @@ -803,7 +801,8 @@ class AnimatedCounter extends StatelessWidget { : v.toInt().toString(); return Text( '$display$suffix', - style: style ?? + style: + style ?? const TextStyle( color: AppColors.textPrimary, fontSize: 22, @@ -1037,10 +1036,17 @@ class RFProgressBar extends StatelessWidget { curve: Curves.easeOutCubic, width: constraints.maxWidth * clamped, decoration: BoxDecoration( - gradient: LinearGradient(colors: [c, Color.lerp(c, Colors.white, 0.2)!]), + gradient: LinearGradient( + colors: [c, Color.lerp(c, Colors.white, 0.2)!], + ), borderRadius: BorderRadius.circular(AppRadius.full), boxShadow: showGlow - ? [BoxShadow(color: c.withValues(alpha: 0.5), blurRadius: 8)] + ? [ + BoxShadow( + color: c.withValues(alpha: 0.5), + blurRadius: 8, + ), + ] : null, ), ), @@ -1071,8 +1077,9 @@ class RestTimerRing extends StatelessWidget { final progress = total > 0 ? (remaining / total).clamp(0.0, 1.0) : 0.0; final mins = remaining ~/ 60; final secs = remaining % 60; - final label = - mins > 0 ? '$mins:${secs.toString().padLeft(2, '0')}' : '$secs'; + final label = mins > 0 + ? '$mins:${secs.toString().padLeft(2, '0')}' + : '$secs'; return SizedBox( width: size, @@ -1292,7 +1299,10 @@ class _RFTextFieldState extends State { style: const TextStyle(color: AppColors.textPrimary, fontSize: 14), decoration: InputDecoration( hintText: widget.hint, - hintStyle: const TextStyle(color: AppColors.textMuted, fontSize: 14), + hintStyle: const TextStyle( + color: AppColors.textMuted, + fontSize: 14, + ), prefixIcon: widget.prefixIcon != null ? Icon(widget.prefixIcon, color: AppColors.textSoft, size: 20) : null, @@ -1309,4 +1319,3 @@ class _RFTextFieldState extends State { ); } } - diff --git a/workout-logger/lib/screens/workout_summary_screen.dart b/workout-logger/lib/screens/workout_summary_screen.dart index ac3f015..8d9f640 100644 --- a/workout-logger/lib/screens/workout_summary_screen.dart +++ b/workout-logger/lib/screens/workout_summary_screen.dart @@ -89,9 +89,9 @@ class WorkoutSummaryScreen extends StatelessWidget { ], const SizedBox(height: AppSpacing.lg), _buildExerciseSummary( - session, - provider, - settings, + session: session, + provider: provider, + settings: settings, ), const SizedBox(height: AppSpacing.md), ], @@ -160,10 +160,7 @@ class WorkoutSummaryScreen extends StatelessWidget { const SizedBox(height: 4), Text( dateStr, - style: const TextStyle( - color: AppColors.textMuted, - fontSize: 13, - ), + style: const TextStyle(color: AppColors.textMuted, fontSize: 13), ), ], ); @@ -299,21 +296,18 @@ class WorkoutSummaryScreen extends StatelessWidget { runSpacing: 6, children: muscles.map((m) { final name = provider.getMuscleGroupName(m); - return RFChip( - label: name, - color: AppColors.muscle(m), - ); + return RFChip(label: name, color: AppColors.muscle(m)); }).toList(), ), ], ); } - Widget _buildExerciseSummary( - WorkoutSession session, - WorkoutProvider provider, - SettingsProvider settings, - ) { + Widget _buildExerciseSummary({ + required WorkoutSession session, + required WorkoutProvider provider, + required SettingsProvider settings, + }) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -423,9 +417,10 @@ class _EffortChipRowState extends State<_EffortChipRow> { final previous = _selected; setState(() => _selected = value); try { - await context - .read() - .recordSessionEffort(widget.session.id, value); + await context.read().recordSessionEffort( + widget.session.id, + value, + ); } catch (e, st) { // Don't leave the chip showing a value that was never persisted. debugPrint('Failed to record session effort: $e\n$st'); diff --git a/workout-logger/test/screens/rf_action_sheet_text_scale_test.dart b/workout-logger/test/screens/rf_action_sheet_text_scale_test.dart new file mode 100644 index 0000000..4d6c90b --- /dev/null +++ b/workout-logger/test/screens/rf_action_sheet_text_scale_test.dart @@ -0,0 +1,112 @@ +// Guards showRFActionSheet against large system font sizes and short +// viewports. showModalBottomSheet defaults to isScrollControlled: false, which +// caps the sheet at 9/16 of the viewport — enough that a three-action sheet +// with descriptions clipped its last action, with no way to scroll to it. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/screens/widgets/rf_dialogs.dart'; +import 'package:repforge/theme/app_theme.dart'; + +/// 1.0 is the default; 2.0 is the largest Android's accessibility settings +/// offer. 400x640 is a short viewport, where the 9/16 cap bites hardest. +const _textScales = [1.0, 1.35, 2.0]; +const _viewport = Size(400, 640); + +enum _Choice { save, discard, cancel } + +void main() { + Future openSheet(WidgetTester tester, double textScale) async { + await tester.pumpWidget( + MediaQuery( + data: MediaQueryData( + size: _viewport, + textScaler: TextScaler.linear(textScale), + ), + child: MaterialApp( + theme: AppTheme.darkTheme, + home: Scaffold( + body: Builder( + builder: (context) => Center( + child: ElevatedButton( + onPressed: () => showRFActionSheet<_Choice>( + context, + title: 'Leave this workout?', + message: + 'You have unsaved sets in this session. Choose what ' + 'to do with them before you go.', + actions: const [ + RFAction( + label: 'Save and leave', + value: _Choice.save, + description: + 'Finish the workout here and keep every set you ' + 'have logged so far.', + isPrimary: true, + ), + RFAction( + label: 'Discard workout', + value: _Choice.discard, + description: + 'Throw away this session and everything logged ' + 'in it. This cannot be undone.', + isDanger: true, + ), + RFAction(label: 'Keep going', value: _Choice.cancel), + ], + ), + child: const Text('open'), + ), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + } + + for (final scale in _textScales) { + testWidgets('action sheet lays out without overflow at ${scale}x text', + (tester) async { + tester.view.physicalSize = _viewport; + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + await openSheet(tester, scale); + + // A RenderFlex overflow is reported as a FlutterError during layout, so + // reaching this point with a clean exception state is the assertion. + expect(tester.takeException(), isNull); + expect(find.text('Leave this workout?'), findsOneWidget); + }); + + testWidgets('every action stays reachable at ${scale}x text', + (tester) async { + tester.view.physicalSize = _viewport; + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + await openSheet(tester, scale); + + // The last action is the one the 9/16 cap used to cut off. Scroll it + // into view rather than asserting it is already visible: the sheet is + // legitimately taller than the viewport at 2.0x. + final lastAction = find.text('Keep going'); + await tester.scrollUntilVisible( + lastAction, + 80, + scrollable: find.byType(Scrollable).last, + ); + expect(lastAction, findsOneWidget); + + await tester.tap(lastAction); + await tester.pumpAndSettle(); + + // Tapping it dismissed the sheet, so it was genuinely hittable. + expect(find.text('Leave this workout?'), findsNothing); + }); + } +} From 4446188785857be6fbf039bcda6034987c14f56d Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:14:20 +0530 Subject: [PATCH 5/5] fix: count the badge in the centred-title counterweight RFScreenHeader renders RFGradientBadge plus an 8pt gap into the leading run, but leadingWidth was derived from onBack alone. With centreTitle and a badge set, the counterweight under-counted by 42pt and the title landed 21pt right of centre. Exposes RFGradientBadge.standardSize (mirroring RFIconButton.standardExtent) so the header can weigh a default-size badge without constructing one, and adds rf_shell_test.dart covering the badge, badge+back and no-badge cases. The two badge cases fail without this change; the no-badge control passes either way. Co-Authored-By: Claude Opus 5 --- .../lib/screens/widgets/rf_shell.dart | 13 +++- .../test/screens/widgets/rf_shell_test.dart | 76 +++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 workout-logger/test/screens/widgets/rf_shell_test.dart diff --git a/workout-logger/lib/screens/widgets/rf_shell.dart b/workout-logger/lib/screens/widgets/rf_shell.dart index a240d28..150f3fd 100644 --- a/workout-logger/lib/screens/widgets/rf_shell.dart +++ b/workout-logger/lib/screens/widgets/rf_shell.dart @@ -101,11 +101,15 @@ class RFGradientBadge extends StatelessWidget { const RFGradientBadge({ super.key, required this.icon, - this.size = 34, + this.size = standardSize, this.radius = AppRadius.md, this.glow = 0.35, }); + /// Painted extent of a default-size badge. Exposed so callers laying out + /// around one — the centred-title counterweight — need not construct it. + static const double standardSize = 34; + final IconData icon; final double size; final double radius; @@ -175,7 +179,12 @@ class RFScreenHeader extends StatelessWidget { // workout_header.dart does) with centreTitle: true will see the title sit // off centre. Measure the trailing cluster if that case ever needs to work. const cellWidth = RFIconButton.standardExtent + AppSpacing.sm; - final leadingWidth = onBack != null ? cellWidth : 0.0; + // The badge shares the leading run with the back button, so it carries + // its own weight on that side too. + const badgeWidth = RFGradientBadge.standardSize + AppSpacing.sm; + final leadingWidth = + (onBack != null ? cellWidth : 0.0) + + (badgeIcon != null ? badgeWidth : 0.0); final trailingWidth = actions.length * cellWidth; final titleBlock = Column( diff --git a/workout-logger/test/screens/widgets/rf_shell_test.dart b/workout-logger/test/screens/widgets/rf_shell_test.dart new file mode 100644 index 0000000..368d9ff --- /dev/null +++ b/workout-logger/test/screens/widgets/rf_shell_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/screens/widgets/rf_shell.dart'; + +void main() { + // The header cannot measure its children before layout, so a centred title + // is balanced by a counterweight computed from the leading/trailing widths. + // Every widget that sits in the leading run has to be counted, or the title + // drifts by half of whatever was missed. + group('RFScreenHeader centred title', () { + Future pumpHeader( + WidgetTester tester, { + IconData? badgeIcon, + VoidCallback? onBack, + List actions = const [], + }) { + return tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RFScreenHeader( + title: 'Coach', + badgeIcon: badgeIcon, + onBack: onBack, + actions: actions, + centreTitle: true, + ), + ), + ), + ); + } + + void expectTitleCentred(WidgetTester tester) { + final title = tester.getRect(find.text('Coach')); + final screenWidth = tester.view.physicalSize.width / tester.view.devicePixelRatio; + expect(title.center.dx, moreOrLessEquals(screenWidth / 2, epsilon: 0.5)); + } + + testWidgets('lands on true centre with a badge and one action', ( + tester, + ) async { + await pumpHeader( + tester, + badgeIcon: Icons.auto_awesome_rounded, + actions: [ + RFIconButton(icon: Icons.more_vert_rounded, tooltip: 'More', onTap: () {}), + ], + ); + expectTitleCentred(tester); + }); + + testWidgets('lands on true centre with a back button, badge and action', ( + tester, + ) async { + await pumpHeader( + tester, + badgeIcon: Icons.auto_awesome_rounded, + onBack: () {}, + actions: [ + RFIconButton(icon: Icons.more_vert_rounded, tooltip: 'More', onTap: () {}), + ], + ); + expectTitleCentred(tester); + }); + + testWidgets('lands on true centre with no badge', (tester) async { + await pumpHeader( + tester, + onBack: () {}, + actions: [ + RFIconButton(icon: Icons.more_vert_rounded, tooltip: 'More', onTap: () {}), + ], + ); + expectTitleCentred(tester); + }); + }); +}