Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 78 additions & 59 deletions apps/client/lib/src/screens/settings_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,11 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
SettingsSection _selectedSection = SettingsSection.profile;
SettingsSection? _mobileDetailSection;

/// Layout-tier picker with hysteresis. Mirrors [HomeScreen] so dragging the
/// window across the 600 px seam doesn't tear down the scaffold — that would
/// drop the in-flight mobile detail-page state every frame the user resizes.
final StableLayoutDecision _layoutDecision = StableLayoutDecision();

Future<void> _logout() async {
final confirmed = await showEchoConfirmDialog(
context,
Expand Down Expand Up @@ -424,81 +429,37 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
context.push('/saved');
}

bool get _isMobile => Responsive.isMobile(context);

@override
Widget build(BuildContext context) {
if (_isMobile) {
final width = MediaQuery.of(context).size.width;
final tier = _layoutDecision.next(width);
if (tier == LayoutTier.narrow) {
return _buildMobileLayout();
}
return _buildDesktopLayout();
}

Widget _buildMobileLayout() {
if (_mobileDetailSection != null) {
return Scaffold(
backgroundColor: context.mainBg,
appBar: AppBar(
backgroundColor: context.mainBg,
title: Text(
settingsSectionLabel(_mobileDetailSection!),
style: TextStyle(
color: context.textPrimary,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
leading: IconButton(
icon: Icon(Icons.arrow_back, color: context.textSecondary),
onPressed: () => setState(() => _mobileDetailSection = null),
),
),
body: SettingsContent(
key: ValueKey(_mobileDetailSection),
section: _mobileDetailSection!,
),
);
final detail = _mobileDetailSection;
if (detail != null) {
return _buildMobileDetailPage(detail);
}
return _buildMobileRootPage();
}

/// Mobile root: full-screen section list. Tapping a row pushes into the
/// detail page (see [_buildMobileDetailPage]) by setting state — we keep
/// navigation in-Scaffold rather than pushing a route so that the parent
/// route's bottom tab bar (when this screen is embedded in HomeScreen on
/// mobile) doesn't disappear on detail entry.
Widget _buildMobileRootPage() {
return Scaffold(
backgroundColor: context.mainBg,
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
if (widget.onBack != null || Navigator.of(context).canPop())
Padding(
padding: const EdgeInsets.only(right: 4),
child: IconButton(
icon: Icon(
Icons.arrow_back,
color: context.textSecondary,
),
onPressed: () {
if (widget.onBack != null) {
widget.onBack!();
} else {
context.pop();
}
},
),
),
Text(
'Settings',
style: TextStyle(
color: context.textPrimary,
fontSize: 28,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
),
),
],
),
),
_buildMobileHeader(),
Expanded(
child: SettingsRootView(
onTap: (section) =>
Expand All @@ -513,6 +474,64 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
);
}

Widget _buildMobileHeader() {
final canPop = widget.onBack != null || Navigator.of(context).canPop();
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
if (canPop)
Padding(
padding: const EdgeInsets.only(right: 4),
child: IconButton(
icon: Icon(Icons.arrow_back, color: context.textSecondary),
onPressed: _handleMobileBack,
),
),
Text(
'Settings',
style: TextStyle(
color: context.textPrimary,
fontSize: 28,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
),
),
],
),
);
}

void _handleMobileBack() {
if (widget.onBack != null) {
widget.onBack!();
} else {
context.pop();
}
}

Widget _buildMobileDetailPage(SettingsSection detail) {
return Scaffold(
backgroundColor: context.mainBg,
appBar: AppBar(
backgroundColor: context.mainBg,
title: Text(
settingsSectionLabel(detail),
style: TextStyle(
color: context.textPrimary,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
leading: IconButton(
icon: Icon(Icons.arrow_back, color: context.textSecondary),
onPressed: () => setState(() => _mobileDetailSection = null),
),
),
body: SettingsContent(key: ValueKey(detail), section: detail),
);
}

Widget _buildDesktopLayout() {
return Scaffold(
backgroundColor: context.mainBg,
Expand Down
79 changes: 79 additions & 0 deletions apps/client/test/widgets/settings_screen_test.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';

import 'package:echo_app/src/providers/auth_provider.dart';
import 'package:echo_app/src/screens/settings_screen.dart';
Expand Down Expand Up @@ -199,4 +200,82 @@ void main() {
expect(find.text('Admin dashboard'), findsOneWidget);
});
});

group('SettingsScreen mobile layout', () {
setUp(() {
TestWidgetsFlutterBinding.ensureInitialized();
});

testWidgets(
'at 360x640 shows the section list and not the desktop right pane',
(tester) async {
tester.view.physicalSize = const Size(360, 640);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);

await tester.pumpWidget(_settingsScreenApp());
await tester.pumpAndSettle();

// Section list is visible -- mobile root page.
expect(find.text('Account preferences'), findsOneWidget);
expect(find.text('Privacy'), findsOneWidget);
expect(find.text('Log out'), findsOneWidget);

// Desktop sidebar has a fixed 320 px width Container with the
// sidebar background. The mobile layout must not render that --
// there's nowhere near enough room on a 360 px screen for both a
// 320 px nav rail AND a content pane.
final sidebarFinder = find.byWidgetPredicate(
(w) => w is Container && w.constraints?.maxWidth == 320,
);
expect(sidebarFinder, findsNothing);
},
);

testWidgets('tapping a section on mobile pushes into the detail page', (
tester,
) async {
tester.view.physicalSize = const Size(360, 640);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);

await tester.pumpWidget(_settingsScreenApp());
await tester.pumpAndSettle();

await tester.tap(find.text('Privacy'));
await tester.pumpAndSettle();

// Detail page has an AppBar with the section title. The root list's
// "Account preferences" group header should no longer be in the tree.
expect(find.text('Account preferences'), findsNothing);
// The AppBar title surfaces the section name.
expect(find.text('Privacy'), findsWidgets);
});
});
}

/// Pumps the full [SettingsScreen] (not just [SettingsRootView]) so the
/// mobile/desktop branch is exercised. GoRouter is required because the
/// screen calls `context.pop()` / `context.push()`.
Widget _settingsScreenApp() {
final router = GoRouter(
initialLocation: '/settings',
routes: [
GoRoute(path: '/settings', builder: (_, _) => const SettingsScreen()),
GoRoute(path: '/saved', builder: (_, _) => const SizedBox.shrink()),
GoRoute(path: '/login', builder: (_, _) => const SizedBox.shrink()),
GoRoute(path: '/admin', builder: (_, _) => const SizedBox.shrink()),
],
);
return ProviderScope(
overrides: [authOverride(loggedInAuthState), serverUrlOverride()],
child: MaterialApp.router(
theme: EchoTheme.darkTheme,
darkTheme: EchoTheme.darkTheme,
themeMode: ThemeMode.dark,
routerConfig: router,
),
);
}
Loading