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