diff --git a/docs/progress.md b/docs/progress.md index 8377843..7c5be23 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -27,6 +27,25 @@ Validation for this milestone: - `flutter analyze` -> pass - `flutter test` -> pass +### Breakpoint QA follow-up + +Ran targeted responsive smoke checks for: + +- iPhone SE (`320x568`) +- iPhone Pro Max (`430x932`) +- iPad portrait (`768x1024`) +- Web/desktop (`1280x800`) + +Follow-up fixes from QA: + +- `lib/features/auth/sign_in_view.dart` + - added scroll-safe container for short-height screens to prevent vertical overflow +- `lib/features/home/app_shell.dart` + - made sidebar brand text ellipsize-safe to prevent horizontal overflow +- Added automated responsive regression suite: + - `test/features/responsive/responsive_views_smoke_test.dart` + - validates `AppShell` + all primary views across the breakpoints above + ## Latest Milestone (2026-02-15): People Mobile Layout Fix Fixed the `People` view so it is mobile-friendly and no longer overflows on iOS narrow screens: diff --git a/lib/features/auth/sign_in_view.dart b/lib/features/auth/sign_in_view.dart index c8109fc..4ef716b 100644 --- a/lib/features/auth/sign_in_view.dart +++ b/lib/features/auth/sign_in_view.dart @@ -39,151 +39,161 @@ class _SignInViewState extends ConsumerState { final bool busy = session.isLoading; final Object? error = session.asError?.error; - return Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 620), - child: Padding( - padding: const EdgeInsets.all(24), - child: FrostedCard( - child: LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - final bool compact = constraints.maxWidth < 480; - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Sign in to continue', - style: Theme.of(context).textTheme.headlineMedium, - ), - const SizedBox(height: 8), - Text( - 'Your local data remains available offline. Backend unlocks sync and signals.', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: AppTheme.textSecondary, - ), - ), - const SizedBox(height: 18), - if (compact) - DropdownButtonFormField( - initialValue: _method, - items: SignInMethod.values - .map( - (SignInMethod method) => - DropdownMenuItem( - value: method, - child: Text(_labelForMethod(method)), - ), + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints viewport) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: viewport.maxHeight - 32), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 620), + child: FrostedCard( + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final bool compact = constraints.maxWidth < 480; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Sign in to continue', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 8), + Text( + 'Your local data remains available offline. Backend unlocks sync and signals.', + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 18), + if (compact) + DropdownButtonFormField( + initialValue: _method, + items: SignInMethod.values + .map( + (SignInMethod method) => + DropdownMenuItem( + value: method, + child: Text(_labelForMethod(method)), + ), + ) + .toList(growable: false), + onChanged: busy + ? null + : (SignInMethod? value) { + if (value == null) { + return; + } + setState(() { + _method = value; + }); + }, + decoration: const InputDecoration( + labelText: 'Method', + ), ) - .toList(growable: false), - onChanged: busy - ? null - : (SignInMethod? value) { - if (value == null) { - return; - } - setState(() { - _method = value; - }); - }, - decoration: const InputDecoration(labelText: 'Method'), - ) - else - SegmentedButton( - segments: const >[ - ButtonSegment( - value: SignInMethod.password, - label: Text('Password'), - ), - ButtonSegment( - value: SignInMethod.emailMagicLink, - label: Text('Magic Link'), - ), - ButtonSegment( - value: SignInMethod.oidc, - label: Text('OIDC'), + else + SegmentedButton( + segments: const >[ + ButtonSegment( + value: SignInMethod.password, + label: Text('Password'), + ), + ButtonSegment( + value: SignInMethod.emailMagicLink, + label: Text('Magic Link'), + ), + ButtonSegment( + value: SignInMethod.oidc, + label: Text('OIDC'), + ), + ], + selected: {_method}, + onSelectionChanged: busy + ? null + : (Set value) { + setState(() { + _method = value.first; + }); + }, + ), + const SizedBox(height: 14), + if (_method == SignInMethod.password || + _method == SignInMethod.emailMagicLink) + TextField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + enabled: !busy, + decoration: const InputDecoration( + labelText: 'Email', + ), + ), + if (_method == SignInMethod.password) + TextField( + controller: _passwordController, + obscureText: true, + enabled: !busy, + decoration: const InputDecoration( + labelText: 'Password', + ), + ), + if (_method == SignInMethod.oidc) + TextField( + controller: _idTokenController, + enabled: !busy, + decoration: const InputDecoration( + labelText: 'ID Token', + ), + ), + if (error != null) + Padding( + padding: const EdgeInsets.only(top: 10), + child: Text( + 'Sign-in failed: $error', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: const Color(0xFFC83030)), + ), + ), + const SizedBox(height: 16), + Wrap( + spacing: 12, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + FilledButton.icon( + onPressed: busy ? null : _submit, + icon: busy + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.login_rounded), + label: const Text('Sign In'), + ), + Text( + AppConfig.useFakeBackend + ? 'Fake mode enabled' + : 'REST mode (${AppConfig.backendBaseUrl})', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: AppTheme.textSecondary), + ), + ], ), ], - selected: {_method}, - onSelectionChanged: busy - ? null - : (Set value) { - setState(() { - _method = value.first; - }); - }, - ), - const SizedBox(height: 14), - if (_method == SignInMethod.password || - _method == SignInMethod.emailMagicLink) - TextField( - controller: _emailController, - keyboardType: TextInputType.emailAddress, - enabled: !busy, - decoration: const InputDecoration(labelText: 'Email'), - ), - if (_method == SignInMethod.password) - TextField( - controller: _passwordController, - obscureText: true, - enabled: !busy, - decoration: const InputDecoration( - labelText: 'Password', - ), - ), - if (_method == SignInMethod.oidc) - TextField( - controller: _idTokenController, - enabled: !busy, - decoration: const InputDecoration( - labelText: 'ID Token', - ), - ), - if (error != null) - Padding( - padding: const EdgeInsets.only(top: 10), - child: Text( - 'Sign-in failed: $error', - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith(color: const Color(0xFFC83030)), - ), - ), - const SizedBox(height: 16), - Wrap( - spacing: 12, - runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - FilledButton.icon( - onPressed: busy ? null : _submit, - icon: busy - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : const Icon(Icons.login_rounded), - label: const Text('Sign In'), - ), - Text( - AppConfig.useFakeBackend - ? 'Fake mode enabled' - : 'REST mode (${AppConfig.backendBaseUrl})', - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith(color: AppTheme.textSecondary), - ), - ], - ), - ], - ); - }, + ); + }, + ), + ), + ), ), ), - ), - ), + ); + }, ); } diff --git a/lib/features/home/app_shell.dart b/lib/features/home/app_shell.dart index 6b02b17..ff4b7a8 100644 --- a/lib/features/home/app_shell.dart +++ b/lib/features/home/app_shell.dart @@ -199,9 +199,12 @@ class _Sidebar extends StatelessWidget { ), ), const SizedBox(width: 10), - Text( - 'Relationship Saver', - style: Theme.of(context).textTheme.titleMedium, + Expanded( + child: Text( + 'Relationship Saver', + style: Theme.of(context).textTheme.titleMedium, + overflow: TextOverflow.ellipsis, + ), ), ], ), diff --git a/test/features/responsive/responsive_views_smoke_test.dart b/test/features/responsive/responsive_views_smoke_test.dart new file mode 100644 index 0000000..4905386 --- /dev/null +++ b/test/features/responsive/responsive_views_smoke_test.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:relationship_saver/core/auth/in_memory_token_store.dart'; +import 'package:relationship_saver/core/config/app_theme.dart'; +import 'package:relationship_saver/features/auth/sign_in_view.dart'; +import 'package:relationship_saver/features/dashboard/dashboard_view.dart'; +import 'package:relationship_saver/features/home/app_shell.dart'; +import 'package:relationship_saver/features/ideas/ideas_view.dart'; +import 'package:relationship_saver/features/moments/moments_view.dart'; +import 'package:relationship_saver/features/people/people_view.dart'; +import 'package:relationship_saver/features/reminders/reminders_view.dart'; +import 'package:relationship_saver/features/settings/settings_view.dart'; +import 'package:relationship_saver/features/signals/signals_view.dart'; +import 'package:relationship_saver/features/sync/sync_view.dart'; +import 'package:relationship_saver/integrations/backend/backend_gateway_fake.dart'; +import 'package:relationship_saver/integrations/backend/backend_gateway_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + final List breakpoints = [ + const Breakpoint('iphone-se', Size(320, 568)), + const Breakpoint('iphone-pro-max', Size(430, 932)), + const Breakpoint('ipad-portrait', Size(768, 1024)), + const Breakpoint('web-desktop', Size(1280, 800)), + ]; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + for (final Breakpoint breakpoint in breakpoints) { + testWidgets('AppShell renders across breakpoint: ${breakpoint.name}', ( + WidgetTester tester, + ) async { + await pumpResponsive( + tester, + size: breakpoint.size, + child: const AppShell(), + wrapInScaffold: false, + ); + + expect(find.byType(AppShell), findsOneWidget); + assertNoFlutterExceptions( + tester, + context: + 'AppShell at ${breakpoint.name} (${breakpoint.size.width}x${breakpoint.size.height})', + ); + }); + + testWidgets( + 'All primary views render across breakpoint: ${breakpoint.name}', + (WidgetTester tester) async { + final List views = [ + const SignInView(), + const DashboardView(), + const PeopleView(), + const MomentsView(), + const IdeasView(), + const RemindersView(), + const SignalsView(), + const SyncView(), + const SettingsView(), + ]; + + for (final Widget view in views) { + await pumpResponsive(tester, size: breakpoint.size, child: view); + assertNoFlutterExceptions( + tester, + context: + '${view.runtimeType} at ${breakpoint.name} (${breakpoint.size.width}x${breakpoint.size.height})', + ); + } + }, + ); + } +} + +class Breakpoint { + const Breakpoint(this.name, this.size); + + final String name; + final Size size; +} + +Future pumpResponsive( + WidgetTester tester, { + required Size size, + required Widget child, + bool wrapInScaffold = true, +}) async { + await tester.binding.setSurfaceSize(size); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final Widget home = wrapInScaffold ? Scaffold(body: child) : child; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + tokenStoreProvider.overrideWithValue(InMemoryTokenStore()), + backendGatewayProvider.overrideWithValue(BackendGatewayFake()), + ], + child: MaterialApp(theme: AppTheme.light(), home: home), + ), + ); + + await tester.pumpAndSettle(const Duration(milliseconds: 300)); +} + +void assertNoFlutterExceptions(WidgetTester tester, {required String context}) { + Object? error; + final List errors = []; + while ((error = tester.takeException()) != null) { + errors.add(error!); + } + + expect( + errors, + isEmpty, + reason: 'Found Flutter exceptions for $context: $errors', + ); +}