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
+3 -4
View File
@@ -15,13 +15,13 @@ 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(
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.',
@@ -64,7 +64,6 @@ class About extends StatelessWidget {
),
],
),
),
);
}
}
+21 -7
View File
@@ -53,11 +53,18 @@ class _DeviceDetailState extends State<DeviceDetail> {
Widget build(BuildContext context) {
final device = _device;
return Scaffold(
appBar: AppBar(
title: const Text('Device Details'),
leading: BackButton(onPressed: () => context.pop()),
actions: [
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<String>(
icon: const Icon(Icons.more_vert),
@@ -65,7 +72,11 @@ class _DeviceDetailState extends State<DeviceDetail> {
if (value == 'forget') {
await confirmForgetDevice(context, device, _loadDevice);
} else if (value == 'register') {
await showRegisterDeviceDialog(context, device, _loadDevice);
await showRegisterDeviceDialog(
context,
device,
_loadDevice,
);
}
},
itemBuilder: (context) => [
@@ -80,7 +91,8 @@ class _DeviceDetailState extends State<DeviceDetail> {
),
],
),
body: _isLoading
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(
@@ -113,6 +125,8 @@ class _DeviceDetailState extends State<DeviceDetail> {
],
),
),
),
],
);
}
}
+13 -13
View File
@@ -119,11 +119,14 @@ class _DeviceListState extends State<DeviceList> {
@override
Widget build(BuildContext context) {
final formatter = FriendlyDateFormatter();
final textTheme = Theme.of(context).textTheme;
return Scaffold(
appBar: AppBar(
title: const Text('Devices'),
actions: [
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(child: Text('Devices', style: textTheme.headlineSmall)),
Badge(
isLabelVisible: _hasActiveDetailFilters,
child: IconButton(
@@ -133,11 +136,8 @@ class _DeviceListState extends State<DeviceList> {
),
),
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(48),
child: Align(
alignment: Alignment.centerLeft,
child: SingleChildScrollView(
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Wrap(
@@ -156,10 +156,8 @@ class _DeviceListState extends State<DeviceList> {
.toList(),
),
),
),
),
),
body: _isLoading
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(child: Text('Error: $_error'))
@@ -177,6 +175,8 @@ class _DeviceListState extends State<DeviceList> {
),
),
),
),
],
);
}
}
+82 -46
View File
@@ -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) {
final selectedIndex = _calculateSelectedIndex(context);
final width = constraints.maxWidth;
if (width < _mediumBreakpoint) {
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),
appBar: _buildAppBar(context),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: child,
),
child: Text(
'OOTT',
style: GoogleFonts.barlowCondensed(
color: Theme.of(context).colorScheme.onPrimary,
fontWeight: FontWeight.bold,
fontSize: 26,
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(),
),
),
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerLowest,
),
);
}
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'),
extended: width >= _expandedBreakpoint,
destinations: _destinations
.map(
(d) => NavigationRailDestination(
icon: Icon(d.icon),
selectedIcon: Icon(d.activeIcon),
label: Text(d.label),
),
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),
)
.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() {
+5 -3
View File
@@ -79,13 +79,15 @@ class _SettingsState extends State<Settings> {
Widget build(BuildContext context) {
final appColors = Theme.of(context).extension<AppColorExtension>()!;
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,