diff --git a/workout-logger/lib/main.dart b/workout-logger/lib/main.dart index d0dc09d..a6d2f4f 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 48bc5da..41bc8c3 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,34 +212,19 @@ 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( - 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, @@ -311,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, @@ -329,7 +254,7 @@ class _AiCoachViewState extends State<_AiCoachView> { 'Am I progressing on bench?', 'Suggest a deload week', ]) - _SuggestionChip( + RFOptionChip( label: s, onTap: () { _controller.text = s; @@ -354,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( @@ -373,44 +299,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 +348,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 +363,75 @@ 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', + // 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, + ), + ), + ), ), - child: Icon(icon, color: AppColors.textSoft, size: 18), ), ); } @@ -518,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, @@ -532,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, @@ -551,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, ), @@ -617,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, @@ -644,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,37 +617,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 +626,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: const 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 +651,123 @@ 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, + ), ), - ), ], ), ); @@ -852,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, @@ -865,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, @@ -892,6 +848,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 +880,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: [ @@ -963,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, @@ -973,27 +932,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..923d222 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,21 @@ 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, + inMutuallyExclusiveGroup: true, + 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 +289,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 +349,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 +358,7 @@ class _InputRow extends StatelessWidget { this.isAssistedBW = false, }); + final double contentWidth; final double currentWeight; final int currentReps; final SettingsProvider settings; @@ -392,27 +372,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 +416,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 +424,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 +502,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 +530,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 +546,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 +610,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 +690,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 +858,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 +971,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 +985,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 +1050,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..74d8729 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,180 @@ 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), + // 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: SingleChildScrollView( + 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..150f3fd --- /dev/null +++ b/workout-logger/lib/screens/widgets/rf_shell.dart @@ -0,0 +1,440 @@ +// 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 = 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; + + /// 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 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: 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); + } +} + +// ── 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 = 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; + 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 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; + // 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( + 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, + this.inMutuallyExclusiveGroup = false, + }); + + 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; + + /// Set when this chip is one of a single-choice group (handle picker, + /// effort rating) so a screen reader announces it as a radio-style choice + /// rather than a standalone button. Left false for chips that are just + /// actions, e.g. the coach's suggested prompts. + final bool inMutuallyExclusiveGroup; + + @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, + enabled: enabled, + inMutuallyExclusiveGroup: inMutuallyExclusiveGroup, + selected: selected, + label: label, + // InkWell, not a bare GestureDetector: these need to be reachable by + // keyboard/switch access and to show a focus + press response, not just + // fire a haptic. + child: InkWell( + onTap: enabled + ? () { + HapticFeedback.selectionClick(); + onTap!(); + } + : null, + borderRadius: BorderRadius.circular(AppRadius.full), + 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..b3efb5c 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. @@ -44,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; @@ -52,18 +54,22 @@ 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,59 +81,365 @@ 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, label: semanticsLabel, - child: GestureDetector( - onTap: onTap, - child: content, - ), + child: GestureDetector(onTap: onTap, child: content), + ); + } +} + +/// 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, + }) { + // 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: _Wash(size: box, opacity: opacity * fade), + ) + : _Wash(size: box, opacity: opacity), + ); + } + + 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], ), ), ); @@ -138,7 +450,6 @@ class AmbientGlow 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 { @@ -212,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, @@ -249,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) ...[ @@ -261,13 +571,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, + ), ), ), ], @@ -391,18 +705,7 @@ class RFSectionHeader extends StatelessWidget { padding: EdgeInsets.only(bottom: bottomPad ? AppSpacing.sm : 0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - title.toUpperCase(), - style: const TextStyle( - color: AppColors.textMuted, - fontSize: 11, - fontWeight: FontWeight.w700, - letterSpacing: 1.2, - ), - ), - ?trailing, - ], + children: [RFLabel(title), ?trailing], ), ); } @@ -498,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, @@ -732,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, ), ), @@ -766,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, @@ -987,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, @@ -1004,4 +1319,3 @@ class _RFTextFieldState extends State { ); } } - 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 d0863a4..8d9f640 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: session, + provider: provider, + settings: 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), + ), + ), + ], + ), + ], ), ); } @@ -141,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), ), ], ); @@ -210,7 +226,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,34 +289,36 @@ 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, 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, - ) { + Widget _buildExerciseSummary({ + required WorkoutSession session, + required WorkoutProvider provider, + required 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 +329,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 +379,7 @@ class _ExerciseSummaryRow extends StatelessWidget { ), ), Text( - '$volStr kg', + '$volStr ${settings.unitLabel}', style: const TextStyle( color: AppColors.success, fontSize: 13, @@ -396,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'); @@ -426,45 +448,12 @@ class _EffortChipRowState extends State<_EffortChipRow> { } Widget _buildChip(({int value, String label, Color color}) option) { - final isSelected = _selected == option.value; - // Semantics + InkWell rather than a bare GestureDetector: these are the - // only way to answer "how did that feel?", so they need to be focusable - // and to announce which one is selected. - return Semantics( - button: true, - inMutuallyExclusiveGroup: true, - selected: isSelected, + return RFOptionChip( label: option.label, - child: Material( - color: Colors.transparent, - borderRadius: BorderRadius.circular(AppRadius.md), - child: InkWell( - onTap: () => _select(option.value), - borderRadius: BorderRadius.circular(AppRadius.md), - child: Ink( - padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), - 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: Align( - alignment: Alignment.center, - child: Text( - option.label, - style: TextStyle( - color: isSelected ? option.color : AppColors.textMuted, - fontSize: 13, - fontWeight: isSelected ? FontWeight.w700 : FontWeight.w600, - ), - ), - ), - ), - ), - ), + color: option.color, + selected: _selected == option.value, + inMutuallyExclusiveGroup: true, + onTap: () => _select(option.value), ); } } 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/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); + }); + } +} 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); + }); + }); +} 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();