Apply M3 filter placement recommendations to device and notification lists

Move status filter chips into AppBar bottom slot (left-aligned, sticky) on
both list screens. On the device list, relocate owner and type filters into
a modal bottom sheet behind a badged filter icon, and dismiss the sheet on
clear so fields always reflect the current filter state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-05-27 20:00:59 -04:00
co-authored by Claude Sonnet 4.6
parent e7d075ec29
commit 171e7516bc
2 changed files with 152 additions and 120 deletions
+138 -109
View File
@@ -87,120 +87,152 @@ class _DeviceListState extends State<DeviceList> {
}
}
bool get _hasActiveDetailFilters =>
_ownerController.text.isNotEmpty || _typeFilter != null;
void _showFilterSheet(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (sheetContext) {
return Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 24,
bottom: MediaQuery.of(sheetContext).viewInsets.bottom + 24,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Filters', style: Theme.of(sheetContext).textTheme.titleMedium),
const SizedBox(height: 16),
TextField(
controller: _ownerController,
decoration: const InputDecoration(
labelText: 'Owner',
prefixIcon: Icon(Icons.person_outline),
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
DropdownButtonFormField<DeviceType?>(
initialValue: _typeFilter,
decoration: const InputDecoration(
labelText: 'Type',
border: OutlineInputBorder(),
),
items: [
const DropdownMenuItem(value: null, child: Text('All types')),
...DeviceType.values.map(
(t) => DropdownMenuItem(
value: t,
child: Row(
children: [
Icon(t.icon, size: 16),
const SizedBox(width: 4),
Text(t.label),
],
),
),
),
],
onChanged: (value) {
setState(() => _typeFilter = value);
_loadDevices();
},
),
const SizedBox(height: 16),
if (_hasActiveDetailFilters)
OutlinedButton(
onPressed: () {
setState(() {
_ownerController.clear();
_typeFilter = null;
});
_loadDevices();
Navigator.of(sheetContext).pop();
},
child: const Text('Clear filters'),
),
],
),
);
},
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final formatter = FriendlyDateFormatter();
return Scaffold(
appBar: AppBar(title: const Text('Devices')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Wrap(
spacing: 8.0,
children: [
ChoiceChip(
label: const Text('Not registered'),
selected: _filter == _DeviceFilter.newDevices,
onSelected: (bool selected) {
setState(() => _filter = _DeviceFilter.newDevices);
_loadDevices();
},
),
ChoiceChip(
label: const Text('Registered'),
selected: _filter == _DeviceFilter.registered,
onSelected: (bool selected) {
setState(() => _filter = _DeviceFilter.registered);
_loadDevices();
},
),
ChoiceChip(
label: const Text('All'),
selected: _filter == _DeviceFilter.all,
onSelected: (bool selected) {
setState(() => _filter = _DeviceFilter.all);
_loadDevices();
},
),
],
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
flex: 2,
child: TextField(
controller: _ownerController,
decoration: const InputDecoration(
labelText: 'Owner',
prefixIcon: Icon(Icons.person_outline),
isDense: true,
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 8),
Expanded(
child: DropdownButtonFormField<DeviceType?>(
initialValue: _typeFilter,
decoration: const InputDecoration(
labelText: 'Type',
isDense: true,
border: OutlineInputBorder(),
),
items: [
const DropdownMenuItem(
value: null,
child: Text('All'),
),
...DeviceType.values.map(
(t) => DropdownMenuItem(
value: t,
child: Row(
children: [
Icon(t.icon, size: 16),
const SizedBox(width: 4),
Text(t.label),
],
),
),
),
],
onChanged: (value) {
setState(() => _typeFilter = value);
_loadDevices();
},
),
),
],
),
const SizedBox(height: 4),
],
appBar: AppBar(
title: const Text('Devices'),
actions: [
Badge(
isLabelVisible: _hasActiveDetailFilters,
child: IconButton(
icon: const Icon(Icons.filter_list),
tooltip: 'Filter',
onPressed: () => _showFilterSheet(context),
),
),
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) {
],
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: [
ChoiceChip(
label: const Text('Not registered'),
selected: _filter == _DeviceFilter.newDevices,
onSelected: (bool selected) {
setState(() => _filter = _DeviceFilter.newDevices);
_loadDevices();
},
),
ChoiceChip(
label: const Text('Registered'),
selected: _filter == _DeviceFilter.registered,
onSelected: (bool selected) {
setState(() => _filter = _DeviceFilter.registered);
_loadDevices();
},
),
ChoiceChip(
label: const Text('All'),
selected: _filter == _DeviceFilter.all,
onSelected: (bool selected) {
setState(() => _filter = _DeviceFilter.all);
_loadDevices();
},
),
],
),
),
),
),
),
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) {
final device = _devices[index];
return Card(
color: device.isRegistered
@@ -277,9 +309,6 @@ class _DeviceListState extends State<DeviceList> {
},
),
),
),
],
),
);
}
}
@@ -122,19 +122,18 @@ class _NotificationListState extends State<NotificationList> {
tooltip: 'Mark all as read',
),
],
),
body: CustomScrollView(
slivers: [
// Filters go here
SliverToBoxAdapter(
child: Container(
height: 50,
alignment: Alignment.centerRight,
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: [
ChoiceChip(
label: Text('New'),
label: const Text('New'),
selected: _filterChoice == 1,
onSelected: (bool selected) {
_filterChoice = 1;
@@ -142,7 +141,7 @@ class _NotificationListState extends State<NotificationList> {
},
),
ChoiceChip(
label: Text('Old'),
label: const Text('Old'),
selected: _filterChoice == 2,
onSelected: (bool selected) {
_filterChoice = 2;
@@ -150,7 +149,7 @@ class _NotificationListState extends State<NotificationList> {
},
),
ChoiceChip(
label: Text('All'),
label: const Text('All'),
selected: _filterChoice == 3,
onSelected: (bool selected) {
_filterChoice = 3;
@@ -161,6 +160,10 @@ class _NotificationListState extends State<NotificationList> {
),
),
),
),
),
body: CustomScrollView(
slivers: [
PagedSliverList<int, oott_model.Notification>(
state: state,
fetchNextPage: fetchNextPage,