mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
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:
co-authored by
Claude Sonnet 4.6
parent
ebcde0ff4a
commit
e5c27f067c
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user