Add breakpoint-responsive smoke tests and overflow fixes
This commit is contained in:
@@ -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
@@ -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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
),
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user