Simplify and modularize device, notification, and settings screens

Extract inline widget closures into named StatelessWidgets (_DeviceCard,
_DeviceFilterSheet, _NotificationCard), replace magic ints and hardcoded
repeated widgets with enum-driven loops, fix the InputDecorator/DropdownButton
hack in device_actions, merge symmetric _markAsRead/_markAsNew into _setRead,
and remove dead _isLoading/_isSaving state in settings where operations are
synchronous.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-05-28 11:14:29 -04:00
co-authored by Claude Sonnet 4.6
parent e1287e5a0f
commit e6ec771f3b
6 changed files with 535 additions and 586 deletions
+18 -14
View File
@@ -73,21 +73,25 @@ Future<void> showRegisterDeviceDialog(
onSaved: (value) => owner = value?.trim() ?? '',
),
const SizedBox(height: 16),
InputDecorator(
DropdownButtonFormField<DeviceType>(
initialValue: deviceType,
decoration: const InputDecoration(labelText: 'Device Type'),
child: DropdownButton<DeviceType>(
value: deviceType,
isExpanded: true,
underline: const SizedBox(),
items: DeviceType.values
.map(
(t) =>
DropdownMenuItem(value: t, child: Text(t.label)),
)
.toList(),
onChanged: (value) =>
setDialogState(() => deviceType = value ?? deviceType),
),
items: DeviceType.values
.map(
(t) => DropdownMenuItem(
value: t,
child: Row(
children: [
Icon(t.icon, size: 16),
const SizedBox(width: 4),
Text(t.label),
],
),
),
)
.toList(),
onChanged: (value) =>
setDialogState(() => deviceType = value ?? deviceType),
),
],
),
+18 -25
View File
@@ -124,10 +124,7 @@ class _SectionHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Text(
title,
style: Theme.of(context).textTheme.titleMedium,
);
return Text(title, style: Theme.of(context).textTheme.titleMedium);
}
}
@@ -141,7 +138,11 @@ class _DeviceHeader extends StatelessWidget {
final theme = Theme.of(context);
return Row(
children: [
Icon(device.deviceType.icon, size: 48, color: theme.colorScheme.onSurface),
Icon(
device.deviceType.icon,
size: 48,
color: theme.colorScheme.onSurface,
),
const SizedBox(width: 16),
Expanded(
child: Column(
@@ -172,30 +173,22 @@ class _DeviceInfoCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final formatter = FriendlyDateFormatter();
final rows = <(String, String)>[
('MAC Address', device.macAddress),
('IP Address', device.ipv4Address),
('Vendor', device.vendor.isEmpty ? '' : device.vendor),
('Last Seen', formatter.format(device.lastSeen)),
('Device Type', device.deviceType.label),
('Owner', device.owner.isEmpty ? '' : device.owner),
];
return Card(
child: Column(
children: [
_InfoRow(label: 'MAC Address', value: device.macAddress),
const Divider(height: 1),
_InfoRow(label: 'IP Address', value: device.ipv4Address),
const Divider(height: 1),
_InfoRow(
label: 'Vendor',
value: device.vendor.isEmpty ? '' : device.vendor,
),
const Divider(height: 1),
_InfoRow(
label: 'Last Seen',
value: formatter.format(device.lastSeen),
),
const Divider(height: 1),
_InfoRow(label: 'Device Type', value: device.deviceType.label),
const Divider(height: 1),
_InfoRow(
label: 'Owner',
value: device.owner.isEmpty ? '' : device.owner,
),
for (var i = 0; i < rows.length; i++) ...[
_InfoRow(label: rows[i].$1, value: rows[i].$2),
if (i < rows.length - 1) const Divider(height: 1),
],
],
),
);
+23 -24
View File
@@ -129,12 +129,9 @@ class _DeviceEventHistoryState extends State<DeviceEventHistory> {
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SegmentedButton<_TimeRange>(
segments:
_TimeRange.values
.map(
(r) => ButtonSegment(value: r, label: Text(r.label)),
)
.toList(),
segments: _TimeRange.values
.map((r) => ButtonSegment(value: r, label: Text(r.label)))
.toList(),
selected: {_selectedRange},
onSelectionChanged: (selection) {
setState(() => _selectedRange = selection.first);
@@ -187,18 +184,17 @@ class _EventChart extends StatelessWidget {
final minX = (cutoffMs / intervalMs).floor() * intervalMs;
final maxX = (nowMs / intervalMs).ceil() * intervalMs;
final spots =
events.map((e) {
final isNew = e.eventType == 'NewDevice';
return ScatterSpot(
e.createdOn.millisecondsSinceEpoch.toDouble(),
1.0,
dotPainter: FlDotCirclePainter(
radius: isNew ? 9.0 : 6.0,
color: theme.colorScheme.tertiary,
),
);
}).toList();
final spots = events.map((e) {
final isNew = e.eventType == 'NewDevice';
return ScatterSpot(
e.createdOn.millisecondsSinceEpoch.toDouble(),
1.0,
dotPainter: FlDotCirclePainter(
radius: isNew ? 9.0 : 6.0,
color: theme.colorScheme.tertiary,
),
);
}).toList();
return ScatterChart(
ScatterChartData(
@@ -259,8 +255,9 @@ class _EventChart extends StatelessWidget {
final event = events[idx];
final dt = event.createdOn.toLocal();
final dateStr = DateFormat('MMM d, yyyy HH:mm').format(dt);
final typeLabel =
event.eventType == 'NewDevice' ? 'First seen' : 'Device seen';
final typeLabel = event.eventType == 'NewDevice'
? 'First seen'
: 'Device seen';
final diffs = <TextSpan>[];
if (event.ipv4Address != device.ipv4Address) {
@@ -276,10 +273,12 @@ class _EventChart extends StatelessWidget {
);
}
if (event.vendor != device.vendor) {
final eventVendor =
event.vendor.isEmpty ? '(unknown)' : event.vendor;
final currentVendor =
device.vendor.isEmpty ? '(unknown)' : device.vendor;
final eventVendor = event.vendor.isEmpty
? '(unknown)'
: event.vendor;
final currentVendor = device.vendor.isEmpty
? '(unknown)'
: device.vendor;
diffs.add(
TextSpan(
text: '\nVendor: $eventVendor$currentVendor',
+203 -180
View File
@@ -10,7 +10,15 @@ import '../utils/oott_api.dart';
import '../widgets/status_badge.dart';
import 'device_actions.dart';
enum _DeviceFilter { newDevices, registered, all }
enum _DeviceFilter {
newDevices('Not registered'),
registered('Registered'),
all('All');
const _DeviceFilter(this.label);
final String label;
}
class DeviceList extends StatefulWidget {
const DeviceList({super.key});
@@ -76,16 +84,11 @@ class _DeviceListState extends State<DeviceList> {
}
}
String _emptyMessage() {
switch (_filter) {
case _DeviceFilter.newDevices:
return 'No unregistered devices';
case _DeviceFilter.registered:
return 'No registered devices';
case _DeviceFilter.all:
return 'No devices found';
}
}
String _emptyMessage() => switch (_filter) {
_DeviceFilter.newDevices => 'No unregistered devices',
_DeviceFilter.registered => 'No registered devices',
_DeviceFilter.all => 'No devices found',
};
bool get _hasActiveDetailFilters =>
_ownerController.text.isNotEmpty || _typeFilter != null;
@@ -94,78 +97,27 @@ class _DeviceListState extends State<DeviceList> {
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'),
),
],
),
);
},
builder: (_) => _DeviceFilterSheet(
ownerController: _ownerController,
typeFilter: _typeFilter,
hasActiveFilters: _hasActiveDetailFilters,
onTypeChanged: (value) {
setState(() => _typeFilter = value);
_loadDevices();
},
onClear: () {
setState(() {
_ownerController.clear();
_typeFilter = null;
});
_loadDevices();
},
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final formatter = FriendlyDateFormatter();
return Scaffold(
@@ -190,32 +142,18 @@ class _DeviceListState extends State<DeviceList> {
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();
},
),
],
children: _DeviceFilter.values
.map(
(f) => ChoiceChip(
label: Text(f.label),
selected: _filter == f,
onSelected: (_) {
setState(() => _filter = f);
_loadDevices();
},
),
)
.toList(),
),
),
),
@@ -232,83 +170,168 @@ class _DeviceListState extends State<DeviceList> {
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _devices.length,
itemBuilder: (context, index) {
final device = _devices[index];
return Card(
color: device.isRegistered
? null
: theme.colorScheme.secondaryContainer,
child: ListTile(
onTap: () =>
context.push('/devices/${device.macAddress}'),
leading: Tooltip(
message: device.deviceType == DeviceType.unknown
? 'Device type unknown'
: device.deviceType.label,
child: Icon(device.deviceType.icon),
),
title: Row(
children: [
Text(device.ipv4Address),
const SizedBox(width: 8),
if (device.isRegistered)
const StatusBadge(
label: 'Registered',
color: BadgeColor.success,
)
else
const StatusBadge(
label: 'Not registered',
color: BadgeColor.secondary,
),
],
),
subtitle: Text(
'${device.vendor} · ${device.macAddress}\n'
'Last seen: ${formatter.format(device.lastSeen)}',
),
isThreeLine: true,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (device.isRegistered) Text(device.owner),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: (value) async {
if (value == 'details') {
context.push(
'/devices/${device.macAddress}',
);
} else if (value == 'forget') {
await confirmForgetDevice(context, device, _loadDevices);
} else if (value == 'register') {
await showRegisterDeviceDialog(context, device, _loadDevices);
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'details',
child: Text('View details'),
),
if (device.isRegistered)
const PopupMenuItem(
value: 'forget',
child: Text('Forget'),
),
if (!device.isRegistered)
const PopupMenuItem(
value: 'register',
child: Text('Register'),
),
],
),
],
),
),
);
},
),
itemBuilder: (context, index) => _DeviceCard(
device: _devices[index],
formatter: formatter,
onRefresh: _loadDevices,
),
),
),
);
}
}
class _DeviceFilterSheet extends StatelessWidget {
final TextEditingController ownerController;
final DeviceType? typeFilter;
final bool hasActiveFilters;
final ValueChanged<DeviceType?> onTypeChanged;
final VoidCallback onClear;
const _DeviceFilterSheet({
required this.ownerController,
required this.typeFilter,
required this.hasActiveFilters,
required this.onTypeChanged,
required this.onClear,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 24,
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Filters', style: Theme.of(context).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: onTypeChanged,
),
const SizedBox(height: 16),
if (hasActiveFilters)
OutlinedButton(
onPressed: () {
onClear();
Navigator.of(context).pop();
},
child: const Text('Clear filters'),
),
],
),
);
}
}
class _DeviceCard extends StatelessWidget {
final Device device;
final FriendlyDateFormatter formatter;
final VoidCallback onRefresh;
const _DeviceCard({
required this.device,
required this.formatter,
required this.onRefresh,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
color: device.isRegistered ? null : theme.colorScheme.secondaryContainer,
child: ListTile(
onTap: () => context.push('/devices/${device.macAddress}'),
leading: Tooltip(
message: device.deviceType == DeviceType.unknown
? 'Device type unknown'
: device.deviceType.label,
child: Icon(device.deviceType.icon),
),
title: Row(
children: [
Text(device.ipv4Address),
const SizedBox(width: 8),
if (device.isRegistered)
const StatusBadge(label: 'Registered', color: BadgeColor.success)
else
const StatusBadge(
label: 'Not registered',
color: BadgeColor.secondary,
),
],
),
subtitle: Text(
'${device.vendor} · ${device.macAddress}\n'
'Last seen: ${formatter.format(device.lastSeen)}',
),
isThreeLine: true,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (device.isRegistered) Text(device.owner),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: (value) async {
if (value == 'details') {
context.push('/devices/${device.macAddress}');
} else if (value == 'forget') {
await confirmForgetDevice(context, device, onRefresh);
} else if (value == 'register') {
await showRegisterDeviceDialog(context, device, onRefresh);
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'details',
child: Text('View details'),
),
if (device.isRegistered)
const PopupMenuItem(value: 'forget', child: Text('Forget')),
if (!device.isRegistered)
const PopupMenuItem(
value: 'register',
child: Text('Register'),
),
],
),
],
),
),
);
}
}
+146 -153
View File
@@ -9,6 +9,22 @@ import '../utils/friendly_date_formatter.dart';
import '../utils/oott_api.dart';
import '../utils/ui_snackbars.dart';
enum _NotificationFilter {
newOnly('New'),
oldOnly('Old'),
all('All');
const _NotificationFilter(this.label);
final String label;
bool? get isNew => switch (this) {
_NotificationFilter.newOnly => true,
_NotificationFilter.oldOnly => false,
_NotificationFilter.all => null,
};
}
class NotificationList extends StatefulWidget {
const NotificationList({super.key});
@@ -17,7 +33,7 @@ class NotificationList extends StatefulWidget {
}
class _NotificationListState extends State<NotificationList> {
int _filterChoice = 1; // 1 => Only new, 2=> Only old, 3=> All
_NotificationFilter _filter = _NotificationFilter.newOnly;
Timer? _refreshTimer;
@override
@@ -33,17 +49,8 @@ class _NotificationListState extends State<NotificationList> {
getNextPageKey: (state) => state.lastPageIsEmpty
? null
: (state.items == null ? 0 : state.items?.length),
fetchPage: (pageKey) {
bool? isNew;
if (_filterChoice == 1) {
isNew = true;
} else if (_filterChoice == 2) {
isNew = false;
}
return BackendAPI.instance.listNotifications(isNew, pageKey);
},
fetchPage: (pageKey) =>
BackendAPI.instance.listNotifications(_filter.isNew, pageKey),
);
Future<void> _markAllAsRead(BuildContext context) async {
@@ -54,68 +61,52 @@ class _NotificationListState extends State<NotificationList> {
}
}
Future<bool> _markAsRead(
Future<bool> _setRead(
BuildContext context,
oott_model.Notification item,
bool read,
) async {
if (!item.isNew) {
if (item.isNew == !read) {
UISnackbars.showWarning(
context,
'Notification was already marked as read',
'Notification was already marked as ${read ? 'read' : 'unread'}',
);
return false;
}
await BackendAPI.instance.markNotificationAsRead(item.id);
if (!context.mounted) return false;
UISnackbars.showSuccess(context, 'Event marked as read');
if (_filterChoice != 3) {
_pagingController.value = _pagingController.value.filterItems(
(n) => n.id != item.id,
);
return true;
if (read) {
await BackendAPI.instance.markNotificationAsRead(item.id);
} else {
await BackendAPI.instance.markNotificationAsNew(item.id);
}
_pagingController.mapItems(
(n) => n.id == item.id ? n.copyWith(isNew: false) : n,
if (!context.mounted) return false;
UISnackbars.showSuccess(
context,
'Event marked as ${read ? 'read' : 'unread'}',
);
return false;
}
Future<bool> _markAsNew(
BuildContext context,
oott_model.Notification item,
) async {
if (item.isNew) {
UISnackbars.showWarning(
context,
'Notification was already marked as unread',
);
return false;
}
await BackendAPI.instance.markNotificationAsNew(item.id);
if (!context.mounted) return false;
UISnackbars.showSuccess(context, 'Event marked as unread');
if (_filterChoice != 3) {
if (_filter != _NotificationFilter.all) {
_pagingController.value = _pagingController.value.filterItems(
(n) => n.id != item.id,
);
return true;
}
_pagingController.mapItems(
(n) => n.id == item.id ? n.copyWith(isNew: true) : n,
(n) => n.id == item.id ? n.copyWith(isNew: !read) : n,
);
return false;
}
@override
Widget build(BuildContext context) {
// Paginated list start
final formatter = FriendlyDateFormatter();
return PagingListener(
controller: _pagingController,
builder: (context, state, fetchNextPage) => Scaffold(
appBar: AppBar(
title: const Text('Notifications'),
actions: [
if (_filterChoice == 1 && (state.items?.isNotEmpty ?? false))
if (_filter == _NotificationFilter.newOnly &&
(state.items?.isNotEmpty ?? false))
IconButton(
onPressed: () => _markAllAsRead(context),
icon: const Icon(Icons.done_all),
@@ -131,32 +122,18 @@ class _NotificationListState extends State<NotificationList> {
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Wrap(
spacing: 8.0,
children: [
ChoiceChip(
label: const Text('New'),
selected: _filterChoice == 1,
onSelected: (bool selected) {
_filterChoice = 1;
_pagingController.refresh();
},
),
ChoiceChip(
label: const Text('Old'),
selected: _filterChoice == 2,
onSelected: (bool selected) {
_filterChoice = 2;
_pagingController.refresh();
},
),
ChoiceChip(
label: const Text('All'),
selected: _filterChoice == 3,
onSelected: (bool selected) {
_filterChoice = 3;
_pagingController.refresh();
},
),
],
children: _NotificationFilter.values
.map(
(f) => ChoiceChip(
label: Text(f.label),
selected: _filter == f,
onSelected: (_) {
setState(() => _filter = f);
_pagingController.refresh();
},
),
)
.toList(),
),
),
),
@@ -168,93 +145,17 @@ class _NotificationListState extends State<NotificationList> {
state: state,
fetchNextPage: fetchNextPage,
builderDelegate: PagedChildBuilderDelegate(
itemBuilder: (context, item, index) {
return Card(
color: item.isNew
? Theme.of(context).colorScheme.secondaryContainer
: null,
child: Dismissible(
key: UniqueKey(),
confirmDismiss: (direction) =>
direction == DismissDirection.startToEnd
? _markAsNew(context, item)
: _markAsRead(context, item),
background: Container(
color: Theme.of(
context,
).colorScheme.tertiaryContainer,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.only(left: 16),
child: Icon(Icons.mark_email_unread),
),
secondaryBackground: Container(
color: Theme.of(context).colorScheme.primaryContainer,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 16),
child: Icon(Icons.done),
),
child: ListTile(
leading: Icon(
item.notificationType.icon,
color: item.isNew
? Theme.of(context).colorScheme.primary
: null,
),
title: Text(
'${FriendlyDateFormatter().format(item.createdOn)} - ${item.title}',
style: item.isNew
? const TextStyle(fontWeight: FontWeight.bold)
: null,
),
subtitle: Text(item.body, maxLines: 5),
trailing: PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: (value) async {
if (value == 'view_device') {
if (item.isNew) await _markAsRead(context, item);
if (context.mounted) context.push('/devices/${item.macAddress}');
} else if (value == 'mark_read') {
await _markAsRead(context, item);
} else if (value == 'mark_new') {
await _markAsNew(context, item);
}
},
itemBuilder: (context) => [
if (item.macAddress != null)
const PopupMenuItem(
value: 'view_device',
child: Text('View device'),
),
if (item.isNew)
const PopupMenuItem(
value: 'mark_read',
child: Text('Mark as read'),
),
if (!item.isNew)
const PopupMenuItem(
value: 'mark_new',
child: Text('Mark as unread'),
),
],
),
onTap: item.macAddress != null
? () async {
if (item.isNew) await _markAsRead(context, item);
if (context.mounted) context.push('/devices/${item.macAddress}');
}
: null,
isThreeLine: true,
),
),
);
},
itemBuilder: (context, item, index) => _NotificationCard(
item: item,
formatter: formatter,
onSetRead: (ctx, read) => _setRead(ctx, item, read),
),
),
),
],
),
),
);
// Paginated list end
}
@override
@@ -264,3 +165,95 @@ class _NotificationListState extends State<NotificationList> {
super.dispose();
}
}
class _NotificationCard extends StatelessWidget {
final oott_model.Notification item;
final FriendlyDateFormatter formatter;
final Future<bool> Function(BuildContext, bool read) onSetRead;
const _NotificationCard({
required this.item,
required this.formatter,
required this.onSetRead,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
color: item.isNew ? theme.colorScheme.secondaryContainer : null,
child: Dismissible(
key: UniqueKey(),
confirmDismiss: (direction) => direction == DismissDirection.startToEnd
? onSetRead(context, false)
: onSetRead(context, true),
background: Container(
color: theme.colorScheme.tertiaryContainer,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.only(left: 16),
child: const Icon(Icons.mark_email_unread),
),
secondaryBackground: Container(
color: theme.colorScheme.primaryContainer,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 16),
child: const Icon(Icons.done),
),
child: ListTile(
leading: Icon(
item.notificationType.icon,
color: item.isNew ? theme.colorScheme.primary : null,
),
title: Text(
'${formatter.format(item.createdOn)} - ${item.title}',
style: item.isNew
? const TextStyle(fontWeight: FontWeight.bold)
: null,
),
subtitle: Text(item.body, maxLines: 5),
trailing: PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: (value) async {
if (value == 'view_device') {
if (item.isNew) await onSetRead(context, true);
if (context.mounted) {
context.push('/devices/${item.macAddress}');
}
} else if (value == 'mark_read') {
await onSetRead(context, true);
} else if (value == 'mark_new') {
await onSetRead(context, false);
}
},
itemBuilder: (context) => [
if (item.macAddress != null)
const PopupMenuItem(
value: 'view_device',
child: Text('View device'),
),
if (item.isNew)
const PopupMenuItem(
value: 'mark_read',
child: Text('Mark as read'),
),
if (!item.isNew)
const PopupMenuItem(
value: 'mark_new',
child: Text('Mark as unread'),
),
],
),
onTap: item.macAddress != null
? () async {
if (item.isNew) await onSetRead(context, true);
if (context.mounted) {
context.push('/devices/${item.macAddress}');
}
}
: null,
isThreeLine: true,
),
),
);
}
}
+127 -190
View File
@@ -16,8 +16,6 @@ class Settings extends StatefulWidget {
}
class _SettingsState extends State<Settings> {
bool _isLoading = true;
bool _isSaving = false;
final _baseUrlController = TextEditingController();
final _apiKeyController = TextEditingController();
bool _apiKeyVisible = false;
@@ -30,7 +28,11 @@ class _SettingsState extends State<Settings> {
@override
void initState() {
super.initState();
_getData();
_baseUrlController.text = PrefUtil.getValue('base_url', '') as String;
_apiKeyController.text = XOR().xorDecode(
PrefUtil.getValue('api_key', '') as String,
);
_selectedTheme = context.read<AppState>().themeKey;
}
@override
@@ -40,203 +42,138 @@ class _SettingsState extends State<Settings> {
super.dispose();
}
void _getData() async {
_baseUrlController.text = PrefUtil.getValue("base_url", "") as String;
_apiKeyController.text = XOR().xorDecode(
PrefUtil.getValue("api_key", "") as String,
);
_selectedTheme = context.read<AppState>().themeKey;
_isLoading = false;
setState(() {});
void _onConnectionChanged(String _) {
setState(() {
_testOk = false;
_connectionModified = true;
});
}
void _saveData() {
PrefUtil.setValue("base_url", _baseUrlController.text);
PrefUtil.setValue("api_key", XOR().xorEncode(_apiKeyController.text));
Future<void> _testConnection() async {
if (!_formKey.currentState!.validate()) return;
final result = await BackendAPI.test(
_baseUrlController.text,
_apiKeyController.text,
);
if (!mounted) return;
setState(() {
_testOk = result == null;
if (result == null) _connectionModified = false;
});
if (result == null) {
UISnackbars.showSuccess(context, 'It works!');
} else {
UISnackbars.showError(context, result);
}
}
void _save() {
if (!_formKey.currentState!.validate()) return;
PrefUtil.setValue('base_url', _baseUrlController.text);
PrefUtil.setValue('api_key', XOR().xorEncode(_apiKeyController.text));
context.read<AppState>().setTheme(_selectedTheme);
UISnackbars.showSuccess(context, 'Settings saved successfully');
}
@override
Widget build(BuildContext context) {
final appColors = Theme.of(context).extension<AppColorExtension>()!;
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: Form(
key: _formKey,
child: Column(
children: <Widget>[
SizedBox(height: 16),
// Base URL
TextFormField(
controller: _baseUrlController,
onChanged: (text) {
setState(() {
_testOk = false;
_connectionModified = true;
});
},
validator: (value) {
if (value == null || value.isEmpty) {
return "The URL cannot be empty";
}
return null;
},
decoration: const InputDecoration(
border: UnderlineInputBorder(),
labelText: 'Base URL of your OOTT server\'s API',
hintText: "For example http://192.168.0.1:3000/api",
),
),
SizedBox(height: 16),
// API Key
TextFormField(
controller: _apiKeyController,
onChanged: (text) {
setState(() {
_testOk = false;
_connectionModified = true;
});
},
validator: (value) {
if (value == null || value.isEmpty) {
return "The API key cannot be empty";
}
return null;
},
obscureText: !_apiKeyVisible,
decoration: InputDecoration(
border: UnderlineInputBorder(),
labelText: 'API key',
suffixIcon: IconButton(
icon: Icon(
_apiKeyVisible
? Icons.visibility
: Icons.visibility_off,
),
onPressed: () {
setState(() {
_apiKeyVisible = !_apiKeyVisible;
});
},
),
),
),
SizedBox(height: 16),
// Theme selector
DropdownButtonFormField<String>(
initialValue: _selectedTheme,
decoration: const InputDecoration(
border: UnderlineInputBorder(),
labelText: 'Theme',
),
items: const [
DropdownMenuItem(
value: 'catppuccin_mocha',
child: Text('Catppuccin Mocha'),
),
DropdownMenuItem(
value: 'gruvbox_dark',
child: Text('Gruvbox Dark'),
),
],
onChanged: (value) {
if (value != null) {
setState(() {
_selectedTheme = value;
});
}
},
),
SizedBox(height: 16),
// Button row
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// Test button
ElevatedButton.icon(
onPressed: () async {
if (_formKey.currentState!.validate()) {
String? testResult = await BackendAPI.test(
_baseUrlController.text,
_apiKeyController.text,
);
if (!context.mounted) return;
setState(() {
_testOk = testResult == null;
if (testResult == null) {
_connectionModified = false;
}
});
if (testResult == null) {
UISnackbars.showSuccess(context, 'It works!');
} else {
UISnackbars.showError(context, testResult);
}
}
},
label: Text('Test'),
icon: _testOk
? Icon(Icons.check)
: Icon(Icons.play_arrow),
style: ElevatedButton.styleFrom(
backgroundColor: _testOk
? Theme.of(
context,
).extension<AppColorExtension>()!.success
: Theme.of(context).colorScheme.secondary,
foregroundColor: _testOk
? Theme.of(
context,
).extension<AppColorExtension>()!.onSuccess
: Theme.of(context).colorScheme.onSecondary,
),
),
SizedBox(width: 8),
// Save button
ElevatedButton.icon(
onPressed:
((_connectionModified && !_testOk) || _isSaving)
? null
: () async {
if (_formKey.currentState!.validate()) {
setState(() {
_isSaving = true;
});
_saveData();
context.read<AppState>().setTheme(
_selectedTheme,
);
setState(() {
_isSaving = false;
});
UISnackbars.showSuccess(
context,
'Settings saved successfully',
);
}
},
label: Text(_isSaving ? 'Saving...' : 'Save'),
icon: _isSaving ? null : Icon(Icons.save),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(
context,
).colorScheme.primary,
foregroundColor: Theme.of(
context,
).colorScheme.onPrimary,
),
),
],
),
],
body: Form(
key: _formKey,
child: Column(
children: [
const SizedBox(height: 16),
TextFormField(
controller: _baseUrlController,
onChanged: _onConnectionChanged,
validator: (value) => value == null || value.isEmpty
? 'The URL cannot be empty'
: null,
decoration: const InputDecoration(
border: UnderlineInputBorder(),
labelText: "Base URL of your OOTT server's API",
hintText: 'For example http://192.168.0.1:3000/api',
),
),
const SizedBox(height: 16),
TextFormField(
controller: _apiKeyController,
onChanged: _onConnectionChanged,
validator: (value) => value == null || value.isEmpty
? 'The API key cannot be empty'
: null,
obscureText: !_apiKeyVisible,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'API key',
suffixIcon: IconButton(
icon: Icon(
_apiKeyVisible ? Icons.visibility : Icons.visibility_off,
),
onPressed: () =>
setState(() => _apiKeyVisible = !_apiKeyVisible),
),
),
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
initialValue: _selectedTheme,
decoration: const InputDecoration(
border: UnderlineInputBorder(),
labelText: 'Theme',
),
items: const [
DropdownMenuItem(
value: 'catppuccin_mocha',
child: Text('Catppuccin Mocha'),
),
DropdownMenuItem(
value: 'gruvbox_dark',
child: Text('Gruvbox Dark'),
),
],
onChanged: (value) {
if (value != null) setState(() => _selectedTheme = value);
},
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ElevatedButton.icon(
onPressed: () {
_testConnection();
},
label: const Text('Test'),
icon: Icon(_testOk ? Icons.check : Icons.play_arrow),
style: ElevatedButton.styleFrom(
backgroundColor: _testOk
? appColors.success
: colorScheme.secondary,
foregroundColor: _testOk
? appColors.onSuccess
: colorScheme.onSecondary,
),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: (_connectionModified && !_testOk) ? null : _save,
label: const Text('Save'),
icon: const Icon(Icons.save),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
),
),
],
),
],
),
),
);
}
}