From bfc2c86088d10193d3902e81f09bc70557cd7f83 Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Tue, 30 Jun 2026 13:29:48 -0700 Subject: [PATCH] fix: scale header controls responsively --- .../lib/widgets/required_banner.dart | 204 +++++++++++----- .../lib/widgets/top_bar.dart | 229 ++++++++++++++---- apps/focus_flow_flutter/test/widget_test.dart | 59 +++++ 3 files changed, 384 insertions(+), 108 deletions(-) diff --git a/apps/focus_flow_flutter/lib/widgets/required_banner.dart b/apps/focus_flow_flutter/lib/widgets/required_banner.dart index 3a2c83f..817df32 100644 --- a/apps/focus_flow_flutter/lib/widgets/required_banner.dart +++ b/apps/focus_flow_flutter/lib/widgets/required_banner.dart @@ -18,77 +18,153 @@ class RequiredBanner extends StatelessWidget { Widget build(BuildContext context) { final banner = model; if (banner == null) { - return const SizedBox(height: 72); + return const SizedBox(height: _RequiredBannerMetrics.referenceHeight); } - return Container( - height: 72, - padding: const EdgeInsets.symmetric(horizontal: 18), - decoration: BoxDecoration( - color: FocusFlowTokens.glassPanel, - borderRadius: BorderRadius.circular(FocusFlowTokens.bannerRadius), - border: Border.all( + return LayoutBuilder( + builder: (context, constraints) { + final metrics = _RequiredBannerMetrics.fromWidth(constraints.maxWidth); + return Container( + height: _RequiredBannerMetrics.referenceHeight, + padding: EdgeInsets.symmetric(horizontal: metrics.horizontalPadding), + decoration: BoxDecoration( + color: FocusFlowTokens.glassPanel, + borderRadius: BorderRadius.circular(metrics.bannerRadius), + border: Border.all( + color: FocusFlowTokens.navBorder.withValues(alpha: 0.55), + width: metrics.borderWidth, + ), + ), + child: Row( + children: [ + Container( + width: metrics.iconCircleSize, + height: metrics.iconCircleSize, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: FocusFlowTokens.glassPanelStrong, + ), + child: Icon( + Icons.auto_awesome, + color: FocusFlowTokens.accentMagenta, + size: metrics.iconSize, + ), + ), + SizedBox(width: metrics.iconGap), + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: FittedBox( + alignment: Alignment.centerLeft, + fit: BoxFit.scaleDown, + child: RichText( + text: TextSpan( + style: TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: metrics.textSize, + fontWeight: FontWeight.w700, + ), + children: [ + const TextSpan(text: 'Next required task: '), + TextSpan( + text: banner.title, + style: const TextStyle( + color: FocusFlowTokens.accentMagenta, + ), + ), + TextSpan( + text: ' at ${banner.timeText}', + style: TextStyle( + fontWeight: FontWeight.w500, + fontSize: metrics.textSize, + ), + ), + ], + ), + ), + ), + ), + ), + SizedBox(width: metrics.buttonGap), + _UpcomingButton(metrics: metrics), + ], + ), + ); + }, + ); + } +} + +class _RequiredBannerMetrics { + const _RequiredBannerMetrics(this.scale); + + static const referenceHeight = 72.0; + static const _referenceWidth = 760.0; + static const _minimumScale = 0.5; + + factory _RequiredBannerMetrics.fromWidth(double width) { + final safeWidth = width.isFinite ? width : _referenceWidth; + final scale = (safeWidth / _referenceWidth).clamp(_minimumScale, 1.0); + return _RequiredBannerMetrics(scale.toDouble()); + } + + final double scale; + + double get horizontalPadding => 18 * scale; + double get bannerRadius => FocusFlowTokens.bannerRadius * scale; + double get borderWidth => scale < 0.7 ? 0.8 : 1; + double get iconCircleSize => 48 * scale; + double get iconSize => 24 * scale; + double get iconGap => 26 * scale; + double get buttonGap => 20 * scale; + double get textSize => 19 * scale; + double get buttonWidth => 176 * scale; + double get buttonHeight => 46 * scale; + double get buttonRadius => FocusFlowTokens.smallButtonRadius * scale; + double get buttonTextSize => 16 * scale; + double get buttonIconSize => 20 * scale; + double get buttonTextGap => 6 * scale; + EdgeInsets get buttonPadding => EdgeInsets.symmetric(horizontal: 12 * scale); +} + +class _UpcomingButton extends StatelessWidget { + const _UpcomingButton({required this.metrics}); + + final _RequiredBannerMetrics metrics; + + @override + Widget build(BuildContext context) { + return OutlinedButton( + onPressed: () {}, + style: OutlinedButton.styleFrom( + foregroundColor: FocusFlowTokens.textPrimary, + side: BorderSide( color: FocusFlowTokens.navBorder.withValues(alpha: 0.55), + width: metrics.borderWidth, + ), + fixedSize: Size(metrics.buttonWidth, metrics.buttonHeight), + minimumSize: Size.zero, + padding: metrics.buttonPadding, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(metrics.buttonRadius), ), ), - child: Row( - children: [ - Container( - width: 48, - height: 48, - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: FocusFlowTokens.glassPanelStrong, - ), - child: const Icon( - Icons.auto_awesome, - color: FocusFlowTokens.accentMagenta, - ), - ), - const SizedBox(width: 26), - RichText( - text: TextSpan( - style: const TextStyle( - color: FocusFlowTokens.textPrimary, - fontSize: 19, + child: FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Show upcoming', + style: TextStyle( + fontSize: metrics.buttonTextSize, fontWeight: FontWeight.w700, ), - children: [ - const TextSpan(text: 'Next required task: '), - TextSpan( - text: banner.title, - style: const TextStyle(color: FocusFlowTokens.accentMagenta), - ), - TextSpan( - text: ' at ${banner.timeText}', - style: const TextStyle(fontWeight: FontWeight.w500), - ), - ], ), - ), - const Spacer(), - OutlinedButton( - onPressed: () {}, - style: OutlinedButton.styleFrom( - foregroundColor: FocusFlowTokens.textPrimary, - side: BorderSide( - color: FocusFlowTokens.navBorder.withValues(alpha: 0.55), - ), - fixedSize: const Size(176, 46), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - FocusFlowTokens.smallButtonRadius, - ), - ), - ), - child: const FittedBox( - fit: BoxFit.scaleDown, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [Text('Show upcoming'), Icon(Icons.chevron_right)], - ), - ), - ), - ], + SizedBox(width: metrics.buttonTextGap), + Icon(Icons.chevron_right, size: metrics.buttonIconSize), + ], + ), ), ); } diff --git a/apps/focus_flow_flutter/lib/widgets/top_bar.dart b/apps/focus_flow_flutter/lib/widgets/top_bar.dart index f779190..2e6c7af 100644 --- a/apps/focus_flow_flutter/lib/widgets/top_bar.dart +++ b/apps/focus_flow_flutter/lib/widgets/top_bar.dart @@ -15,73 +15,196 @@ class TopBar extends StatelessWidget { @override Widget build(BuildContext context) { - return Row( - children: [ - Text('Today', style: Theme.of(context).textTheme.headlineLarge), - const SizedBox(width: 30), - _IconButton(icon: Icons.calendar_today, onPressed: () {}), - _IconButton(icon: Icons.chevron_left, onPressed: () {}), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Text( - dateLabel, - style: const TextStyle( - color: FocusFlowTokens.textPrimary, - fontSize: 17, - ), + return LayoutBuilder( + builder: (context, constraints) { + final metrics = _TopBarMetrics.fromWidth(constraints.maxWidth); + return SizedBox( + height: _TopBarMetrics.referenceHeight, + child: Row( + children: [ + SizedBox( + width: metrics.titleWidth, + height: _TopBarMetrics.referenceHeight, + child: FittedBox( + alignment: Alignment.centerLeft, + fit: BoxFit.scaleDown, + child: Text( + 'Today', + style: Theme.of(context).textTheme.headlineLarge?.copyWith( + fontSize: metrics.titleSize, + ), + ), + ), + ), + SizedBox(width: metrics.titleGap), + _IconButton( + icon: Icons.calendar_today, + metrics: metrics, + onPressed: () {}, + ), + _IconButton( + icon: Icons.chevron_left, + metrics: metrics, + onPressed: () {}, + ), + SizedBox( + width: metrics.dateWidth, + height: metrics.controlHeight, + child: Center( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + dateLabel, + style: TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: metrics.dateSize, + ), + ), + ), + ), + ), + _IconButton( + icon: Icons.chevron_right, + metrics: metrics, + onPressed: () {}, + ), + const Spacer(), + _ModeToggle(metrics: metrics), + SizedBox(width: metrics.settingsGap), + _SettingsButton(metrics: metrics), + ], ), + ); + }, + ); + } +} + +class _TopBarMetrics { + const _TopBarMetrics(this.scale); + + static const referenceHeight = 48.0; + static const _referenceWidth = 900.0; + static const _minimumScale = 0.44; + + factory _TopBarMetrics.fromWidth(double width) { + final safeWidth = width.isFinite ? width : _referenceWidth; + final scale = (safeWidth / _referenceWidth).clamp(_minimumScale, 1.0); + return _TopBarMetrics(scale.toDouble()); + } + + final double scale; + + double get titleWidth => 112 * scale; + double get titleSize => FocusFlowTokens.pageTitleSize * scale; + double get titleGap => 30 * scale; + double get controlHeight => referenceHeight * scale; + double get iconButtonSize => referenceHeight * scale; + double get iconSize => 24 * scale; + double get dateWidth => 144 * scale; + double get dateSize => 17 * scale; + double get modeWidth => 220 * scale; + double get modeHeight => referenceHeight * scale; + double get modeLabelSize => 17 * scale; + double get settingsGap => 28 * scale; + double get settingsWidth => 136 * scale; + double get settingsHeight => referenceHeight * scale; + double get settingsIconSize => 20 * scale; + double get settingsTextSize => 15 * scale; + double get settingsTextGap => 8 * scale; + double get borderRadius => FocusFlowTokens.smallButtonRadius * scale; + double get borderWidth => scale < 0.7 ? 0.8 : 1; + EdgeInsets get buttonPadding => EdgeInsets.symmetric(horizontal: 12 * scale); +} + +class _SettingsButton extends StatelessWidget { + const _SettingsButton({required this.metrics}); + + final _TopBarMetrics metrics; + + @override + Widget build(BuildContext context) { + return OutlinedButton( + onPressed: () {}, + style: OutlinedButton.styleFrom( + foregroundColor: FocusFlowTokens.textPrimary, + side: BorderSide( + color: FocusFlowTokens.subtleBorder, + width: metrics.borderWidth, ), - _IconButton(icon: Icons.chevron_right, onPressed: () {}), - const Spacer(), - const _ModeToggle(), - const SizedBox(width: 28), - OutlinedButton.icon( - onPressed: () {}, - style: OutlinedButton.styleFrom( - foregroundColor: FocusFlowTokens.textPrimary, - side: const BorderSide(color: FocusFlowTokens.subtleBorder), - fixedSize: const Size(136, 48), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - FocusFlowTokens.smallButtonRadius, + fixedSize: Size(metrics.settingsWidth, metrics.settingsHeight), + minimumSize: Size.zero, + padding: metrics.buttonPadding, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(metrics.borderRadius), + ), + ), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.wb_sunny_outlined, size: metrics.settingsIconSize), + SizedBox(width: metrics.settingsTextGap), + Text( + 'Settings', + style: TextStyle( + fontSize: metrics.settingsTextSize, + fontWeight: FontWeight.w600, ), ), - ), - icon: const Icon(Icons.wb_sunny_outlined), - label: const Text('Settings'), + ], ), - ], + ), ); } } class _IconButton extends StatelessWidget { - const _IconButton({required this.icon, required this.onPressed}); + const _IconButton({ + required this.icon, + required this.metrics, + required this.onPressed, + }); final IconData icon; + final _TopBarMetrics metrics; final VoidCallback onPressed; @override Widget build(BuildContext context) { return IconButton( onPressed: onPressed, + constraints: BoxConstraints.tightFor( + width: metrics.iconButtonSize, + height: metrics.iconButtonSize, + ), color: FocusFlowTokens.textPrimary, + iconSize: metrics.iconSize, + padding: EdgeInsets.zero, icon: Icon(icon), + visualDensity: VisualDensity.compact, ); } } class _ModeToggle extends StatelessWidget { - const _ModeToggle(); + const _ModeToggle({required this.metrics}); + + final _TopBarMetrics metrics; @override Widget build(BuildContext context) { return Container( - height: 48, - width: 220, + height: metrics.modeHeight, + width: metrics.modeWidth, decoration: BoxDecoration( - borderRadius: BorderRadius.circular(FocusFlowTokens.smallButtonRadius), - border: Border.all(color: FocusFlowTokens.subtleBorder), + borderRadius: BorderRadius.circular(metrics.borderRadius), + border: Border.all( + color: FocusFlowTokens.subtleBorder, + width: metrics.borderWidth, + ), ), child: Row( children: [ @@ -90,20 +213,38 @@ class _ModeToggle extends StatelessWidget { alignment: Alignment.center, decoration: BoxDecoration( color: FocusFlowTokens.glassPanelStrong, - borderRadius: BorderRadius.circular( - FocusFlowTokens.smallButtonRadius, + borderRadius: BorderRadius.circular(metrics.borderRadius), + border: Border.all( + color: FocusFlowTokens.navBorder, + width: metrics.borderWidth, + ), + ), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + 'Compact', + style: TextStyle(fontSize: metrics.modeLabelSize), ), - border: Border.all(color: FocusFlowTokens.navBorder), ), - child: const Text('Compact'), ), ), Expanded( child: TextButton( onPressed: () {}, - child: const Text( - 'Normal', - style: TextStyle(color: FocusFlowTokens.textPrimary), + style: TextButton.styleFrom( + minimumSize: Size.zero, + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + 'Normal', + style: TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: metrics.modeLabelSize, + ), + ), ), ), ), diff --git a/apps/focus_flow_flutter/test/widget_test.dart b/apps/focus_flow_flutter/test/widget_test.dart index f3b3096..fd89a94 100644 --- a/apps/focus_flow_flutter/test/widget_test.dart +++ b/apps/focus_flow_flutter/test/widget_test.dart @@ -9,8 +9,11 @@ import 'package:scheduler_core/scheduler_core.dart'; import 'package:focus_flow_flutter/app/demo_scheduler_composition.dart'; import 'package:focus_flow_flutter/app/focus_flow_app.dart'; import 'package:focus_flow_flutter/models/today_screen_models.dart'; +import 'package:focus_flow_flutter/theme/focus_flow_theme.dart'; +import 'package:focus_flow_flutter/widgets/required_banner.dart'; import 'package:focus_flow_flutter/widgets/timeline/difficulty_bars.dart'; import 'package:focus_flow_flutter/widgets/timeline/task_timeline_card.dart'; +import 'package:focus_flow_flutter/widgets/top_bar.dart'; /// Runs FocusFlow widget and visual adapter tests. void main() { @@ -112,6 +115,42 @@ void main() { expect(find.text('Pay bill'), findsOneWidget); }); + testWidgets('top bar controls scale inside a compact header width', ( + tester, + ) async { + await _pumpConstrainedWidget( + tester, + size: const Size(520, 72), + child: const TopBar(dateLabel: 'June 30, 2026'), + ); + + expect(tester.takeException(), isNull); + expect(find.text('Compact'), findsOneWidget); + expect(find.text('Normal'), findsOneWidget); + expect(find.text('Settings'), findsOneWidget); + expect(tester.getSize(find.byType(OutlinedButton)).width, lessThan(136)); + }); + + testWidgets('required banner scales inside a compact header width', ( + tester, + ) async { + await _pumpConstrainedWidget( + tester, + size: const Size(440, 72), + child: const RequiredBanner( + model: RequiredBannerModel(title: 'Pay bill', timeText: '6:00 PM'), + ), + ); + + expect(tester.takeException(), isNull); + expect( + find.textContaining('Next required task:', findRichText: true), + findsOneWidget, + ); + expect(find.text('Show upcoming'), findsOneWidget); + expect(tester.getSize(find.byType(OutlinedButton)).width, lessThan(176)); + }); + testWidgets('task card controls scale inside a narrow task bubble', ( tester, ) async { @@ -221,6 +260,26 @@ Future _pumpTimelineCard( await tester.pumpAndSettle(); } +Future _pumpConstrainedWidget( + WidgetTester tester, { + required Size size, + required Widget child, +}) async { + await tester.binding.setSurfaceSize(Size(size.width + 40, size.height + 40)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MaterialApp( + theme: FocusFlowTheme.dark(), + home: Scaffold( + body: Center( + child: SizedBox(width: size.width, height: size.height, child: child), + ), + ), + ), + ); + await tester.pumpAndSettle(); +} + TimelineCardModel _timelineCard({ required String id, required String title,