Add breakpoint-responsive smoke tests and overflow fixes

This commit is contained in:
Rijad Zuzo
2026-02-15 18:34:11 +01:00
parent 90c1a21d71
commit 2a7d039475
4 changed files with 297 additions and 143 deletions
+19
View File
@@ -27,6 +27,25 @@ Validation for this milestone:
- `flutter analyze` -> pass - `flutter analyze` -> pass
- `flutter test` -> 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 ## 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: Fixed the `People` view so it is mobile-friendly and no longer overflows on iOS narrow screens:
+150 -140
View File
@@ -39,151 +39,161 @@ class _SignInViewState extends ConsumerState<SignInView> {
final bool busy = session.isLoading; final bool busy = session.isLoading;
final Object? error = session.asError?.error; final Object? error = session.asError?.error;
return Center( return LayoutBuilder(
child: ConstrainedBox( builder: (BuildContext context, BoxConstraints viewport) {
constraints: const BoxConstraints(maxWidth: 620), return SingleChildScrollView(
child: Padding( padding: const EdgeInsets.all(16),
padding: const EdgeInsets.all(24), child: ConstrainedBox(
child: FrostedCard( constraints: BoxConstraints(minHeight: viewport.maxHeight - 32),
child: LayoutBuilder( child: Center(
builder: (BuildContext context, BoxConstraints constraints) { child: ConstrainedBox(
final bool compact = constraints.maxWidth < 480; constraints: const BoxConstraints(maxWidth: 620),
return Column( child: FrostedCard(
mainAxisSize: MainAxisSize.min, child: LayoutBuilder(
crossAxisAlignment: CrossAxisAlignment.start, builder: (BuildContext context, BoxConstraints constraints) {
children: <Widget>[ final bool compact = constraints.maxWidth < 480;
Text( return Column(
'Sign in to continue', mainAxisSize: MainAxisSize.min,
style: Theme.of(context).textTheme.headlineMedium, crossAxisAlignment: CrossAxisAlignment.start,
), children: <Widget>[
const SizedBox(height: 8), Text(
Text( 'Sign in to continue',
'Your local data remains available offline. Backend unlocks sync and signals.', style: Theme.of(context).textTheme.headlineMedium,
style: Theme.of(context).textTheme.bodyLarge?.copyWith( ),
color: AppTheme.textSecondary, const SizedBox(height: 8),
), Text(
), 'Your local data remains available offline. Backend unlocks sync and signals.',
const SizedBox(height: 18), style: Theme.of(context).textTheme.bodyLarge
if (compact) ?.copyWith(color: AppTheme.textSecondary),
DropdownButtonFormField<SignInMethod>( ),
initialValue: _method, const SizedBox(height: 18),
items: SignInMethod.values if (compact)
.map( DropdownButtonFormField<SignInMethod>(
(SignInMethod method) => initialValue: _method,
DropdownMenuItem<SignInMethod>( items: SignInMethod.values
value: method, .map(
child: Text(_labelForMethod(method)), (SignInMethod method) =>
), DropdownMenuItem<SignInMethod>(
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), else
onChanged: busy SegmentedButton<SignInMethod>(
? null segments: const <ButtonSegment<SignInMethod>>[
: (SignInMethod? value) { ButtonSegment<SignInMethod>(
if (value == null) { value: SignInMethod.password,
return; label: Text('Password'),
} ),
setState(() { ButtonSegment<SignInMethod>(
_method = value; value: SignInMethod.emailMagicLink,
}); label: Text('Magic Link'),
}, ),
decoration: const InputDecoration(labelText: 'Method'), ButtonSegment<SignInMethod>(
) value: SignInMethod.oidc,
else label: Text('OIDC'),
SegmentedButton<SignInMethod>( ),
segments: const <ButtonSegment<SignInMethod>>[ ],
ButtonSegment<SignInMethod>( selected: <SignInMethod>{_method},
value: SignInMethod.password, onSelectionChanged: busy
label: Text('Password'), ? null
), : (Set<SignInMethod> value) {
ButtonSegment<SignInMethod>( setState(() {
value: SignInMethod.emailMagicLink, _method = value.first;
label: Text('Magic Link'), });
), },
ButtonSegment<SignInMethod>( ),
value: SignInMethod.oidc, const SizedBox(height: 14),
label: Text('OIDC'), 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: <Widget>[
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: <SignInMethod>{_method}, );
onSelectionChanged: busy },
? null ),
: (Set<SignInMethod> 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: <Widget>[
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),
),
],
),
],
);
},
), ),
), ),
), );
), },
); );
} }
+6 -3
View File
@@ -199,9 +199,12 @@ class _Sidebar extends StatelessWidget {
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
Text( Expanded(
'Relationship Saver', child: Text(
style: Theme.of(context).textTheme.titleMedium, 'Relationship Saver',
style: Theme.of(context).textTheme.titleMedium,
overflow: TextOverflow.ellipsis,
),
), ),
], ],
), ),
@@ -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<Breakpoint> breakpoints = <Breakpoint>[
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(<String, Object>{});
});
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<Widget> views = <Widget>[
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<void> 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<Object> errors = <Object>[];
while ((error = tester.takeException()) != null) {
errors.add(error!);
}
expect(
errors,
isEmpty,
reason: 'Found Flutter exceptions for $context: $errors',
);
}