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 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),
),
],
),
);
}
+72 -58
View File
@@ -53,66 +53,80 @@ 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: [
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'),
),
],
),
],
),
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<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
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,
),
),
),
),
),
),
],
);
}
}
+86 -50
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) {
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() {
+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,