Align navigation with Material 3 responsive recommendations

- 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 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-05-28 19:51:31 -04:00
co-authored by Claude Sonnet 4.6
parent ebcde0ff4a
commit e5c27f067c
5 changed files with 260 additions and 209 deletions
+46 -47
View File
@@ -15,55 +15,54 @@ class About extends StatelessWidget {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
return Scaffold( return SingleChildScrollView(
appBar: AppBar(title: const Text('About OOTT')), padding: const EdgeInsets.all(24),
body: SingleChildScrollView( child: Column(
padding: const EdgeInsets.all(24), crossAxisAlignment: CrossAxisAlignment.start,
child: Column( children: [
crossAxisAlignment: CrossAxisAlignment.start, Text('About OOTT', style: textTheme.headlineSmall),
children: [ const SizedBox(height: 16),
Text( Text(
'Easy to setup and use network device discovery and alert system. ' 'Easy to setup and use network device discovery and alert system. '
'Notifies you when new or unknown devices join your local area network.', 'Notifies you when new or unknown devices join your local area network.',
style: textTheme.bodyLarge, style: textTheme.bodyLarge,
),
const SizedBox(height: 8),
Text(
'v$_version - released $_releaseDate',
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
), ),
const SizedBox(height: 8), ),
Text( const SizedBox(height: 24),
'v$_version - released $_releaseDate', _SurfaceContainer(
style: textTheme.bodyMedium?.copyWith( colorScheme: colorScheme,
color: colorScheme.onSurfaceVariant, 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( const SizedBox(height: 16),
colorScheme: colorScheme, _SurfaceContainer(
child: Column( colorScheme: colorScheme,
children: [ padding: const EdgeInsets.all(16),
_LinkRow( child: _NoticesSection(textTheme: textTheme),
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),
),
],
),
), ),
); );
} }
+72 -58
View File
@@ -53,66 +53,80 @@ class _DeviceDetailState extends State<DeviceDetail> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final device = _device; final device = _device;
return Scaffold( return Column(
appBar: AppBar( crossAxisAlignment: CrossAxisAlignment.start,
title: const Text('Device Details'), children: [
leading: BackButton(onPressed: () => context.pop()), Row(
actions: [ children: [
if (device != null) BackButton(onPressed: () => context.pop()),
PopupMenuButton<String>( Expanded(
icon: const Icon(Icons.more_vert), child: Text(
onSelected: (value) async { 'Device Details',
if (value == 'forget') { style: Theme.of(context).textTheme.headlineSmall,
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),
],
), ),
), ),
if (device != null)
PopupMenuButton<String>(
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),
],
),
),
),
],
); );
} }
} }
+51 -51
View File
@@ -119,64 +119,64 @@ class _DeviceListState extends State<DeviceList> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final formatter = FriendlyDateFormatter(); final formatter = FriendlyDateFormatter();
final textTheme = Theme.of(context).textTheme;
return Scaffold( return Column(
appBar: AppBar( crossAxisAlignment: CrossAxisAlignment.start,
title: const Text('Devices'), children: [
actions: [ Row(
Badge( children: [
isLabelVisible: _hasActiveDetailFilters, Expanded(child: Text('Devices', style: textTheme.headlineSmall)),
child: IconButton( Badge(
icon: const Icon(Icons.filter_list), isLabelVisible: _hasActiveDetailFilters,
tooltip: 'Filter', child: IconButton(
onPressed: () => _showFilterSheet(context), 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(),
), ),
), ),
],
),
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(),
), ),
), ),
), Expanded(
body: _isLoading child: _isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _error != null : _error != null
? Center(child: Text('Error: $_error')) ? Center(child: Text('Error: $_error'))
: _devices.isEmpty : _devices.isEmpty
? Center(child: Text(_emptyMessage())) ? Center(child: Text(_emptyMessage()))
: RefreshIndicator( : RefreshIndicator(
onRefresh: _loadDevices,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _devices.length,
itemBuilder: (context, index) => _DeviceCard(
device: _devices[index],
formatter: formatter,
onRefresh: _loadDevices, onRefresh: _loadDevices,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _devices.length,
itemBuilder: (context, index) => _DeviceCard(
device: _devices[index],
formatter: formatter,
onRefresh: _loadDevices,
),
),
), ),
), ),
), ],
); );
} }
} }
+86 -50
View File
@@ -9,9 +9,34 @@ import 'home/home_screen.dart';
import 'status/status_screen.dart'; import 'status/status_screen.dart';
import 'utils/pref_utils.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 // Routes definitions
final GoRouter router = GoRouter( final GoRouter router = GoRouter(
// If there is no API base URL send the user to settings
initialLocation: '/notifications', initialLocation: '/notifications',
routes: [ routes: [
ShellRoute( ShellRoute(
@@ -72,27 +97,35 @@ class MainShell extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
return Scaffold( final selectedIndex = _calculateSelectedIndex(context);
appBar: AppBar( final width = constraints.maxWidth;
title: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2), if (width < _mediumBreakpoint) {
decoration: BoxDecoration( return Scaffold(
color: Theme.of(context).colorScheme.primary, appBar: _buildAppBar(context),
borderRadius: BorderRadius.circular(10), body: Padding(
), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Text( child: child,
'OOTT',
style: GoogleFonts.barlowCondensed(
color: Theme.of(context).colorScheme.onPrimary,
fontWeight: FontWeight.bold,
fontSize: 26,
),
),
), ),
backgroundColor: Theme.of( bottomNavigationBar: NavigationBar(
context, selectedIndex: selectedIndex,
).colorScheme.surfaceContainerLowest, 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( body: Row(
children: [ children: [
SafeArea( SafeArea(
@@ -100,35 +133,17 @@ class MainShell extends StatelessWidget {
backgroundColor: Theme.of( backgroundColor: Theme.of(
context, context,
).colorScheme.surfaceContainerLow, ).colorScheme.surfaceContainerLow,
extended: constraints.maxWidth >= 600, extended: width >= _expandedBreakpoint,
destinations: [ destinations: _destinations
NavigationRailDestination( .map(
icon: Icon(Icons.home_outlined), (d) => NavigationRailDestination(
selectedIcon: Icon(Icons.home), icon: Icon(d.icon),
label: Text('Home'), selectedIcon: Icon(d.activeIcon),
), label: Text(d.label),
NavigationRailDestination( ),
icon: Icon(Icons.devices_other_outlined), )
selectedIcon: Icon(Icons.devices_other), .toList(),
label: Text('Devices'), selectedIndex: selectedIndex,
),
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),
onDestinationSelected: (index) => onDestinationSelected: (index) =>
_onDestinationSelected(index, context), _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() { String? _redirectToSettings() {
+5 -3
View File
@@ -79,13 +79,15 @@ class _SettingsState extends State<Settings> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final appColors = Theme.of(context).extension<AppColorExtension>()!; final appColors = Theme.of(context).extension<AppColorExtension>()!;
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Scaffold( return SingleChildScrollView(
appBar: AppBar(title: const Text('Settings')), child: Form(
body: Form(
key: _formKey, key: _formKey,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('Settings', style: textTheme.headlineSmall),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
controller: _baseUrlController, controller: _baseUrlController,