diff --git a/app/lib/widgets/auth/auth_page_layouts.dart b/app/lib/widgets/auth/auth_page_layouts.dart index 11160bfa..72a7d330 100644 --- a/app/lib/widgets/auth/auth_page_layouts.dart +++ b/app/lib/widgets/auth/auth_page_layouts.dart @@ -32,48 +32,51 @@ class MobileAuthLayout extends StatelessWidget { return Scaffold( backgroundColor: theme.colorScheme.surface, - body: Column( - children: [ - CompactAuthHeader(isDark: isDark, height: ComponentSizes.mobileHeroHeight), - Expanded( - child: CustomScrollView( - slivers: [ - SliverFillRemaining( - hasScrollBody: false, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: Spacing.lg), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(height: Spacing.xl), - if (showHeader) ...[ - // const AuthBranding(), - const SizedBox(height: Spacing.md), - Text( - heading, - style: theme.textTheme.headlineMedium?.copyWith( - fontWeight: FontWeight.w600, - color: theme.colorScheme.onSurface, + body: SafeArea( + top: false, + child: Column( + children: [ + CompactAuthHeader(isDark: isDark, height: ComponentSizes.mobileHeroHeight), + Expanded( + child: CustomScrollView( + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: Spacing.xl), + if (showHeader) ...[ + // const AuthBranding(), + const SizedBox(height: Spacing.md), + Text( + heading, + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + ), + Text( + subtitle, + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), ), - ), - Text( - subtitle, - style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), - ), + const SizedBox(height: Spacing.lg), + ], + form, + const Spacer(), + ...footer, const SizedBox(height: Spacing.lg), ], - form, - const Spacer(), - ...footer, - const SizedBox(height: Spacing.lg), - ], + ), ), ), - ), - ], + ], + ), ), - ), - ], + ], + ), ), ); } diff --git a/app/lib/widgets/goals/active_goal_details_sheet.dart b/app/lib/widgets/goals/active_goal_details_sheet.dart index 3a96122c..91728777 100644 --- a/app/lib/widgets/goals/active_goal_details_sheet.dart +++ b/app/lib/widgets/goals/active_goal_details_sheet.dart @@ -74,29 +74,8 @@ class _ActiveGoalDetailsSheetState extends State { final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; return AppBottomSheet( - header: Row( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: Icon(_getIconForType(widget.goal.type), size: 24, color: colorScheme.onPrimaryContainer), - ), - const SizedBox(width: Spacing.md), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(widget.goal.description, style: textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), - Text(_getGoalTypeLabel(), style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), - ], - ), - ), - ], - ), + title: widget.goal.description, + onClose: () => Navigator.of(context).pop(), footer: BottomSheetActions( secondary: OutlinedButton( onPressed: () { @@ -115,6 +94,8 @@ class _ActiveGoalDetailsSheetState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(_getGoalTypeLabel(), style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), + const SizedBox(height: Spacing.lg), // Progress section _buildProgressSection(context, colorScheme, textTheme), const SizedBox(height: Spacing.lg), @@ -168,7 +149,9 @@ class _ActiveGoalDetailsSheetState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text('Progress', style: textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)), + Expanded( + child: Text('Progress', style: textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)), + ), if (!_isEditingProgress) TextButton.icon( onPressed: () => setState(() => _isEditingProgress = true), @@ -289,7 +272,9 @@ class _ActiveGoalDetailsSheetState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text('Target', style: textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)), + Expanded( + child: Text('Target', style: textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)), + ), if (!_isEditingTarget) TextButton.icon( onPressed: () => setState(() => _isEditingTarget = true), @@ -381,7 +366,9 @@ class _ActiveGoalDetailsSheetState extends State { children: [ Icon(icon, size: 20, color: colorScheme.onSurfaceVariant), const SizedBox(width: Spacing.sm), - Text(label, style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), + Expanded( + child: Text(label, style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), + ), const SizedBox(width: Spacing.sm), Expanded( child: Text( @@ -560,17 +547,6 @@ class _ActiveGoalDetailsSheetState extends State { return '${months[date.month - 1]} ${date.year}'; } - IconData _getIconForType(GoalType type) { - switch (type) { - case GoalType.books: - return Icons.menu_book_outlined; - case GoalType.pages: - return Icons.article_outlined; - case GoalType.minutes: - return Icons.schedule_outlined; - } - } - IconData _getRecurrenceIcon() { if (widget.goal.isCustomPeriod) return Icons.date_range; if (widget.goal.isRecurring) return Icons.repeat; diff --git a/app/lib/widgets/goals/completed_goal_chip.dart b/app/lib/widgets/goals/completed_goal_chip.dart index 40748641..e6208f63 100644 --- a/app/lib/widgets/goals/completed_goal_chip.dart +++ b/app/lib/widgets/goals/completed_goal_chip.dart @@ -281,28 +281,10 @@ class _CompletedGoalDetailsSheet extends StatelessWidget { final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; return AppBottomSheet( - header: Row( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration(color: colorScheme.tertiaryContainer, shape: BoxShape.circle), - child: Icon(Icons.emoji_events, size: 24, color: colorScheme.onTertiaryContainer), - ), - const SizedBox(width: Spacing.md), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Goal completed!', style: textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), - Text(goal.description, style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), - ], - ), - ), - ], - ), + title: 'Goal completed', + onClose: () => Navigator.of(context).pop(), footer: BottomSheetActions( - primary: OutlinedButton( + secondary: OutlinedButton( onPressed: () { Navigator.of(context).pop(); _showDeleteConfirmation(context); @@ -313,11 +295,14 @@ class _CompletedGoalDetailsSheet extends StatelessWidget { ), child: const Text('Delete goal'), ), + primary: FilledButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Done')), ), body: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(goal.description, style: textTheme.titleMedium), + const SizedBox(height: Spacing.lg), // Stats grid Container( padding: const EdgeInsets.all(Spacing.md), @@ -377,7 +362,9 @@ class _CompletedGoalDetailsSheet extends StatelessWidget { children: [ Icon(icon, size: 20, color: colorScheme.onSurfaceVariant), const SizedBox(width: Spacing.sm), - Text(label, style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), + Expanded( + child: Text(label, style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), + ), const SizedBox(width: Spacing.sm), Expanded( child: Text( diff --git a/app/pubspec.lock b/app/pubspec.lock index a934c70a..313c97e4 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -555,8 +555,8 @@ packages: dependency: "direct main" description: path: "." - ref: 7992c8b518fcb900f941217a7ea8455d001c1aea - resolved-ref: 7992c8b518fcb900f941217a7ea8455d001c1aea + ref: "7992c8b518fcb900f941217a7ea8455d001c1aea" + resolved-ref: "7992c8b518fcb900f941217a7ea8455d001c1aea" url: "https://github.com/PapyrusReader/reader.git" source: git version: "0.0.1" diff --git a/app/test/widgets/auth/auth_page_layouts_test.dart b/app/test/widgets/auth/auth_page_layouts_test.dart index c2e4a033..46ee360b 100644 --- a/app/test/widgets/auth/auth_page_layouts_test.dart +++ b/app/test/widgets/auth/auth_page_layouts_test.dart @@ -57,6 +57,38 @@ void main() { expect(tester.getTopLeft(branding).dy, Spacing.lg); }); + for (final keyboard in [false, true]) { + testWidgets('mobile auth footer stays above ${keyboard ? 'the keyboard' : 'system navigation'}', (tester) async { + setViewport(tester, const Size(390, 700)); + tester.view.viewPadding = const FakeViewPadding(bottom: 48); + tester.view.padding = FakeViewPadding(bottom: keyboard ? 0 : 48); + tester.view.viewInsets = FakeViewPadding(bottom: keyboard ? 300 : 0); + addTearDown(tester.view.resetViewPadding); + addTearDown(tester.view.resetPadding); + addTearDown(tester.view.resetViewInsets); + var continued = false; + + await tester.pumpWidget( + MaterialApp( + home: MobileAuthLayout( + heading: 'Welcome back', + subtitle: 'Sign in to continue', + form: const SizedBox(height: 300), + footer: [TextButton(onPressed: () => continued = true, child: const Text('Continue offline'))], + ), + ), + ); + await tester.drag(find.byType(CustomScrollView), const Offset(0, -700)); + await tester.pumpAndSettle(); + + final button = find.widgetWithText(TextButton, 'Continue offline'); + expect(tester.getRect(button).bottom, lessThanOrEqualTo(keyboard ? 400 : 652)); + expect(tester.takeException(), isNull); + await tester.tap(button); + expect(continued, isTrue); + }); + } + testWidgets('desktop swap button is focused after form controls', (tester) async { setViewport(tester, const Size(1200, 800)); diff --git a/app/test/widgets/goals/goal_details_sheets_test.dart b/app/test/widgets/goals/goal_details_sheets_test.dart new file mode 100644 index 00000000..463fc460 --- /dev/null +++ b/app/test/widgets/goals/goal_details_sheets_test.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/models/reading_goal.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus/widgets/goals/active_goal_details_sheet.dart'; +import 'package:papyrus/widgets/goals/completed_goal_chip.dart'; +import 'package:papyrus/widgets/shared/app_bottom_sheet.dart'; + +void main() { + final goal = ReadingGoal( + id: 'goal', + type: GoalType.books, + targetValue: 12, + currentValue: 4, + period: GoalPeriod.yearly, + startDate: DateTime(2026), + endDate: DateTime(2026, 12, 31), + ); + + Future openSheet(WidgetTester tester, {required bool completed, double width = 390}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = Size(width, 700); + tester.view.padding = const FakeViewPadding(bottom: 48); + tester.view.viewPadding = const FakeViewPadding(bottom: 48); + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark, + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () { + if (completed) { + CompletedGoalChip.showDetailsSheet(context, goal: goal); + } else { + ActiveGoalDetailsSheet.show(context, goal: goal); + } + }, + child: const Text('Open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + } + + for (final completed in [false, true]) { + for (final width in [320.0, 390.0]) { + testWidgets('${completed ? 'completed' : 'active'} goal actions fit above system navigation at $width', ( + tester, + ) async { + await openSheet(tester, completed: completed, width: width); + final done = find.widgetWithText(FilledButton, 'Done'); + final delete = find.widgetWithText(OutlinedButton, completed ? 'Delete goal' : 'Delete'); + expect(done.hitTestable(), findsOneWidget); + expect(delete.hitTestable(), findsOneWidget); + expect(find.byTooltip('Close').hitTestable(), findsOneWidget); + expect(tester.getRect(done).bottom, lessThanOrEqualTo(652)); + expect(tester.getRect(delete).top, tester.getRect(done).top); + expect(tester.getRect(delete).height, tester.getRect(done).height); + expect(tester.takeException(), isNull); + await tester.tap(done); + await tester.pumpAndSettle(); + expect(find.byType(AppBottomSheet), findsNothing); + }); + } + + testWidgets('${completed ? 'completed' : 'active'} goal header closes the sheet', (tester) async { + await openSheet(tester, completed: completed); + await tester.tap(find.byTooltip('Close')); + await tester.pumpAndSettle(); + expect(find.byType(AppBottomSheet), findsNothing); + }); + } +}