From e5c27f067c3ba8ce3816b2e0bc7805efb36afc23 Mon Sep 17 00:00:00 2001 From: rzuasti Date: Thu, 28 May 2026 19:51:31 -0400 Subject: [PATCH] Align navigation with Material 3 responsive recommendations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace single NavigationRail with adaptive navigation: NavigationBar (bottom) for compact <600dp, icon-only NavigationRail for medium 600–840dp, and extended NavigationRail for expanded ≥840dp - Remove nested Scaffold/AppBar from About, Settings, DeviceList, and DeviceDetail — all screens now render as plain widgets inside the single shell Scaffold; page titles surface as inline headings Co-Authored-By: Claude Sonnet 4.6 --- frontend/lib/about/about.dart | 93 ++++++++-------- frontend/lib/devices/device_detail.dart | 130 ++++++++++++---------- frontend/lib/devices/device_list.dart | 102 +++++++++--------- frontend/lib/navigation.dart | 136 +++++++++++++++--------- frontend/lib/settings/settings.dart | 8 +- 5 files changed, 260 insertions(+), 209 deletions(-) diff --git a/frontend/lib/about/about.dart b/frontend/lib/about/about.dart index 5e0ef46..e3153d9 100644 --- a/frontend/lib/about/about.dart +++ b/frontend/lib/about/about.dart @@ -15,55 +15,54 @@ class About extends StatelessWidget { final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; - return Scaffold( - appBar: AppBar(title: const Text('About OOTT')), - body: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Easy to setup and use network device discovery and alert system. ' - 'Notifies you when new or unknown devices join your local area network.', - style: textTheme.bodyLarge, + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('About OOTT', style: textTheme.headlineSmall), + const SizedBox(height: 16), + Text( + 'Easy to setup and use network device discovery and alert system. ' + 'Notifies you when new or unknown devices join your local area network.', + style: textTheme.bodyLarge, + ), + const SizedBox(height: 8), + Text( + 'v$_version - released $_releaseDate', + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, ), - const SizedBox(height: 8), - Text( - 'v$_version - released $_releaseDate', - style: textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + ), + const SizedBox(height: 24), + _SurfaceContainer( + colorScheme: colorScheme, + child: Column( + children: [ + _LinkRow( + icon: Icons.code, + label: 'Source code', + url: _repoUrl, + colorScheme: colorScheme, + textTheme: textTheme, + ), + Divider( + height: 1, + color: colorScheme.outlineVariant, + indent: 16, + endIndent: 16, + ), + _LicenseRow(colorScheme: colorScheme, textTheme: textTheme), + ], ), - const SizedBox(height: 24), - _SurfaceContainer( - colorScheme: colorScheme, - child: Column( - children: [ - _LinkRow( - icon: Icons.code, - label: 'Source code', - url: _repoUrl, - colorScheme: colorScheme, - textTheme: textTheme, - ), - Divider( - height: 1, - color: colorScheme.outlineVariant, - indent: 16, - endIndent: 16, - ), - _LicenseRow(colorScheme: colorScheme, textTheme: textTheme), - ], - ), - ), - const SizedBox(height: 16), - _SurfaceContainer( - colorScheme: colorScheme, - padding: const EdgeInsets.all(16), - child: _NoticesSection(textTheme: textTheme), - ), - ], - ), + ), + const SizedBox(height: 16), + _SurfaceContainer( + colorScheme: colorScheme, + padding: const EdgeInsets.all(16), + child: _NoticesSection(textTheme: textTheme), + ), + ], ), ); } diff --git a/frontend/lib/devices/device_detail.dart b/frontend/lib/devices/device_detail.dart index db0f316..e346dab 100644 --- a/frontend/lib/devices/device_detail.dart +++ b/frontend/lib/devices/device_detail.dart @@ -53,66 +53,80 @@ class _DeviceDetailState extends State { Widget build(BuildContext context) { final device = _device; - return Scaffold( - appBar: AppBar( - title: const Text('Device Details'), - leading: BackButton(onPressed: () => context.pop()), - actions: [ - if (device != null) - PopupMenuButton( - icon: const Icon(Icons.more_vert), - onSelected: (value) async { - if (value == 'forget') { - await confirmForgetDevice(context, device, _loadDevice); - } else if (value == 'register') { - await showRegisterDeviceDialog(context, device, _loadDevice); - } - }, - itemBuilder: (context) => [ - if (device.isRegistered) - const PopupMenuItem(value: 'forget', child: Text('Forget')), - if (!device.isRegistered) - const PopupMenuItem( - value: 'register', - child: Text('Register'), - ), - ], - ), - ], - ), - body: _isLoading - ? const Center(child: CircularProgressIndicator()) - : _error != null - ? Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text('Error: $_error'), - const SizedBox(height: 16), - FilledButton( - onPressed: _loadDevice, - child: const Text('Retry'), - ), - ], - ), - ) - : device == null - ? const Center(child: Text('Device not found')) - : SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _DeviceHeader(device: device), - const SizedBox(height: 24), - _DeviceInfoCard(device: device), - const SizedBox(height: 24), - _SectionHeader(title: 'Event History'), - const SizedBox(height: 12), - DeviceEventHistory(device: device), - ], + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + BackButton(onPressed: () => context.pop()), + Expanded( + child: Text( + 'Device Details', + style: Theme.of(context).textTheme.headlineSmall, ), ), + if (device != null) + PopupMenuButton( + icon: const Icon(Icons.more_vert), + onSelected: (value) async { + if (value == 'forget') { + await confirmForgetDevice(context, device, _loadDevice); + } else if (value == 'register') { + await showRegisterDeviceDialog( + context, + device, + _loadDevice, + ); + } + }, + itemBuilder: (context) => [ + if (device.isRegistered) + const PopupMenuItem(value: 'forget', child: Text('Forget')), + if (!device.isRegistered) + const PopupMenuItem( + value: 'register', + child: Text('Register'), + ), + ], + ), + ], + ), + Expanded( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _error != null + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Error: $_error'), + const SizedBox(height: 16), + FilledButton( + onPressed: _loadDevice, + child: const Text('Retry'), + ), + ], + ), + ) + : device == null + ? const Center(child: Text('Device not found')) + : SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _DeviceHeader(device: device), + const SizedBox(height: 24), + _DeviceInfoCard(device: device), + const SizedBox(height: 24), + _SectionHeader(title: 'Event History'), + const SizedBox(height: 12), + DeviceEventHistory(device: device), + ], + ), + ), + ), + ], ); } } diff --git a/frontend/lib/devices/device_list.dart b/frontend/lib/devices/device_list.dart index a6824e9..46277ad 100644 --- a/frontend/lib/devices/device_list.dart +++ b/frontend/lib/devices/device_list.dart @@ -119,64 +119,64 @@ class _DeviceListState extends State { @override Widget build(BuildContext context) { final formatter = FriendlyDateFormatter(); + final textTheme = Theme.of(context).textTheme; - return Scaffold( - appBar: AppBar( - title: const Text('Devices'), - actions: [ - Badge( - isLabelVisible: _hasActiveDetailFilters, - child: IconButton( - icon: const Icon(Icons.filter_list), - tooltip: 'Filter', - onPressed: () => _showFilterSheet(context), - ), - ), - ], - bottom: PreferredSize( - preferredSize: const Size.fromHeight(48), - child: Align( - alignment: Alignment.centerLeft, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - child: Wrap( - spacing: 8.0, - children: _DeviceFilter.values - .map( - (f) => ChoiceChip( - label: Text(f.label), - selected: _filter == f, - onSelected: (_) { - setState(() => _filter = f); - _loadDevices(); - }, - ), - ) - .toList(), + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded(child: Text('Devices', style: textTheme.headlineSmall)), + Badge( + isLabelVisible: _hasActiveDetailFilters, + child: IconButton( + icon: const Icon(Icons.filter_list), + tooltip: 'Filter', + onPressed: () => _showFilterSheet(context), ), ), + ], + ), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Wrap( + spacing: 8.0, + children: _DeviceFilter.values + .map( + (f) => ChoiceChip( + label: Text(f.label), + selected: _filter == f, + onSelected: (_) { + setState(() => _filter = f); + _loadDevices(); + }, + ), + ) + .toList(), ), ), - ), - body: _isLoading - ? const Center(child: CircularProgressIndicator()) - : _error != null - ? Center(child: Text('Error: $_error')) - : _devices.isEmpty - ? Center(child: Text(_emptyMessage())) - : RefreshIndicator( - onRefresh: _loadDevices, - child: ListView.builder( - physics: const AlwaysScrollableScrollPhysics(), - itemCount: _devices.length, - itemBuilder: (context, index) => _DeviceCard( - device: _devices[index], - formatter: formatter, + Expanded( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _error != null + ? Center(child: Text('Error: $_error')) + : _devices.isEmpty + ? Center(child: Text(_emptyMessage())) + : RefreshIndicator( onRefresh: _loadDevices, + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + itemCount: _devices.length, + itemBuilder: (context, index) => _DeviceCard( + device: _devices[index], + formatter: formatter, + onRefresh: _loadDevices, + ), + ), ), - ), - ), + ), + ], ); } } diff --git a/frontend/lib/navigation.dart b/frontend/lib/navigation.dart index 7ac9829..e75894b 100644 --- a/frontend/lib/navigation.dart +++ b/frontend/lib/navigation.dart @@ -9,9 +9,34 @@ import 'home/home_screen.dart'; import 'status/status_screen.dart'; import 'utils/pref_utils.dart'; +// M3 window size class breakpoints +const _mediumBreakpoint = 600.0; +const _expandedBreakpoint = 840.0; + +typedef _NavDest = ({IconData icon, IconData activeIcon, String label}); + +const List<_NavDest> _destinations = [ + (icon: Icons.home_outlined, activeIcon: Icons.home, label: 'Home'), + ( + icon: Icons.devices_other_outlined, + activeIcon: Icons.devices_other, + label: 'Devices', + ), + ( + icon: Icons.monitor_heart_outlined, + activeIcon: Icons.monitor_heart, + label: 'Status', + ), + ( + icon: Icons.settings_outlined, + activeIcon: Icons.settings, + label: 'Settings', + ), + (icon: Icons.info_outline, activeIcon: Icons.info, label: 'About'), +]; + // Routes definitions final GoRouter router = GoRouter( - // If there is no API base URL send the user to settings initialLocation: '/notifications', routes: [ ShellRoute( @@ -72,27 +97,35 @@ class MainShell extends StatelessWidget { Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { - return Scaffold( - appBar: AppBar( - title: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, - borderRadius: BorderRadius.circular(10), - ), - child: Text( - 'OOTT', - style: GoogleFonts.barlowCondensed( - color: Theme.of(context).colorScheme.onPrimary, - fontWeight: FontWeight.bold, - fontSize: 26, - ), - ), + final selectedIndex = _calculateSelectedIndex(context); + final width = constraints.maxWidth; + + if (width < _mediumBreakpoint) { + return Scaffold( + appBar: _buildAppBar(context), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: child, ), - backgroundColor: Theme.of( - context, - ).colorScheme.surfaceContainerLowest, - ), + bottomNavigationBar: NavigationBar( + selectedIndex: selectedIndex, + onDestinationSelected: (index) => + _onDestinationSelected(index, context), + destinations: _destinations + .map( + (d) => NavigationDestination( + icon: Icon(d.icon), + selectedIcon: Icon(d.activeIcon), + label: d.label, + ), + ) + .toList(), + ), + ); + } + + return Scaffold( + appBar: _buildAppBar(context), body: Row( children: [ SafeArea( @@ -100,35 +133,17 @@ class MainShell extends StatelessWidget { backgroundColor: Theme.of( context, ).colorScheme.surfaceContainerLow, - extended: constraints.maxWidth >= 600, - destinations: [ - NavigationRailDestination( - icon: Icon(Icons.home_outlined), - selectedIcon: Icon(Icons.home), - label: Text('Home'), - ), - NavigationRailDestination( - icon: Icon(Icons.devices_other_outlined), - selectedIcon: Icon(Icons.devices_other), - label: Text('Devices'), - ), - NavigationRailDestination( - icon: Icon(Icons.monitor_heart_outlined), - selectedIcon: Icon(Icons.monitor_heart), - label: Text('Status'), - ), - NavigationRailDestination( - icon: Icon(Icons.settings_outlined), - selectedIcon: Icon(Icons.settings), - label: Text('Settings'), - ), - NavigationRailDestination( - icon: Icon(Icons.info_outline), - selectedIcon: Icon(Icons.info), - label: Text('About'), - ), - ], - selectedIndex: _calculateSelectedIndex(context), + extended: width >= _expandedBreakpoint, + destinations: _destinations + .map( + (d) => NavigationRailDestination( + icon: Icon(d.icon), + selectedIcon: Icon(d.activeIcon), + label: Text(d.label), + ), + ) + .toList(), + selectedIndex: selectedIndex, onDestinationSelected: (index) => _onDestinationSelected(index, context), ), @@ -146,6 +161,27 @@ class MainShell extends StatelessWidget { }, ); } + + AppBar _buildAppBar(BuildContext context) { + return AppBar( + title: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + 'OOTT', + style: GoogleFonts.barlowCondensed( + color: Theme.of(context).colorScheme.onPrimary, + fontWeight: FontWeight.bold, + fontSize: 26, + ), + ), + ), + backgroundColor: Theme.of(context).colorScheme.surfaceContainerLowest, + ); + } } String? _redirectToSettings() { diff --git a/frontend/lib/settings/settings.dart b/frontend/lib/settings/settings.dart index b99a5b7..1868c6c 100644 --- a/frontend/lib/settings/settings.dart +++ b/frontend/lib/settings/settings.dart @@ -79,13 +79,15 @@ class _SettingsState extends State { Widget build(BuildContext context) { final appColors = Theme.of(context).extension()!; final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; - return Scaffold( - appBar: AppBar(title: const Text('Settings')), - body: Form( + return SingleChildScrollView( + child: Form( key: _formKey, child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('Settings', style: textTheme.headlineSmall), const SizedBox(height: 16), TextFormField( controller: _baseUrlController,