mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
Set 5s/15s/15s connect/receive/send timeouts so calls fail fast instead of hanging. Introduce dioErrorToUserMessage() and route DioException-throwing call sites (device actions, devices list, notifications list, scanners status card) through it, replacing raw e.toString() output. Notifications list now surfaces load errors inline in the theme's error color, matching the devices list pattern. Status dots show the mapped detail as a tooltip. Stop logging the API key in cleartext. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
341 lines
9.6 KiB
Dart
341 lines
9.6 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../model/device.dart';
|
|
import '../model/device_type.dart';
|
|
import '../navigation.dart';
|
|
import '../utils/friendly_date_formatter.dart';
|
|
import '../utils/oott_api.dart';
|
|
import 'device_list_filter.dart';
|
|
import 'device_list_rows.dart';
|
|
import 'device_list_sort.dart';
|
|
|
|
const _pageSize = 10;
|
|
const _wideLayoutBreakpoint = 600.0;
|
|
|
|
class DeviceList extends StatefulWidget {
|
|
const DeviceList({super.key});
|
|
|
|
@override
|
|
State<DeviceList> createState() => _DeviceListState();
|
|
}
|
|
|
|
class _DeviceListState extends State<DeviceList> with RouteAware {
|
|
DeviceFilter _filter = DeviceFilter.newDevices;
|
|
List<Device> _devices = [];
|
|
bool _isLoading = true;
|
|
String? _error;
|
|
final TextEditingController _ownerController = TextEditingController();
|
|
DeviceType? _typeFilter;
|
|
Timer? _ownerDebounce;
|
|
|
|
DeviceSortColumn _sortColumn = DeviceSortColumn.lastSeen;
|
|
bool _sortAscending = false;
|
|
|
|
int _currentPage = 0;
|
|
bool _hasNextPage = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_ownerController.addListener(_onOwnerChanged);
|
|
_fetchPage(0);
|
|
}
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
final route = ModalRoute.of(context);
|
|
if (route is ModalRoute<void>) {
|
|
routeObserver.subscribe(this, route);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void didPopNext() {
|
|
_fetchPage(_currentPage);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
routeObserver.unsubscribe(this);
|
|
_ownerDebounce?.cancel();
|
|
_ownerController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onOwnerChanged() {
|
|
_ownerDebounce?.cancel();
|
|
_ownerDebounce = Timer(
|
|
const Duration(milliseconds: 500),
|
|
() => _fetchPage(0),
|
|
);
|
|
}
|
|
|
|
Future<void> _fetchPage(int page) async {
|
|
setState(() {
|
|
_isLoading = _devices.isEmpty;
|
|
_error = null;
|
|
});
|
|
try {
|
|
bool? isRegistered;
|
|
if (_filter == DeviceFilter.newDevices) isRegistered = false;
|
|
if (_filter == DeviceFilter.registered) isRegistered = true;
|
|
|
|
final results = await BackendAPI.instance.listDevices(
|
|
isRegistered: isRegistered,
|
|
owner: _ownerController.text.isEmpty ? null : _ownerController.text,
|
|
deviceType: _typeFilter,
|
|
sortBy: _sortColumn.apiName,
|
|
sortAscending: _sortAscending,
|
|
offset: page * _pageSize,
|
|
limit: _pageSize + 1,
|
|
);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_currentPage = page;
|
|
_hasNextPage = results.length > _pageSize;
|
|
_devices = _hasNextPage ? results.take(_pageSize).toList() : results;
|
|
_isLoading = false;
|
|
});
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_error = dioErrorToUserMessage(e);
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
void _onSortHeaderTapped(DeviceSortColumn column) {
|
|
setState(() {
|
|
if (_sortColumn == column) {
|
|
_sortAscending = !_sortAscending;
|
|
} else {
|
|
_sortColumn = column;
|
|
_sortAscending = true;
|
|
}
|
|
});
|
|
_fetchPage(0);
|
|
}
|
|
|
|
void _onSortSheetChanged(DeviceSortColumn column, bool ascending) {
|
|
if (_sortColumn == column && _sortAscending == ascending) return;
|
|
setState(() {
|
|
_sortColumn = column;
|
|
_sortAscending = ascending;
|
|
});
|
|
_fetchPage(0);
|
|
}
|
|
|
|
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;
|
|
|
|
void _showFilterSheet() {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
builder: (_) => DeviceFilterSheet(
|
|
ownerController: _ownerController,
|
|
typeFilter: _typeFilter,
|
|
hasActiveFilters: _hasActiveDetailFilters,
|
|
onTypeChanged: (value) {
|
|
setState(() => _typeFilter = value);
|
|
_fetchPage(0);
|
|
},
|
|
onClear: () {
|
|
setState(() {
|
|
_ownerController.clear();
|
|
_typeFilter = null;
|
|
});
|
|
_fetchPage(0);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showSortSheet() {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
builder: (_) => DeviceSortSheet(
|
|
currentColumn: _sortColumn,
|
|
ascending: _sortAscending,
|
|
onChanged: _onSortSheetChanged,
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final textTheme = Theme.of(context).textTheme;
|
|
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final isWide = constraints.maxWidth >= _wideLayoutBreakpoint;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text('Devices', style: textTheme.headlineSmall),
|
|
),
|
|
if (!isWide)
|
|
IconButton(
|
|
icon: const Icon(Icons.sort),
|
|
tooltip: 'Sort',
|
|
onPressed: _showSortSheet,
|
|
),
|
|
Badge(
|
|
isLabelVisible: _hasActiveDetailFilters,
|
|
child: IconButton(
|
|
icon: const Icon(Icons.filter_list),
|
|
tooltip: 'Filter',
|
|
onPressed: _showFilterSheet,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
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);
|
|
_fetchPage(0);
|
|
},
|
|
),
|
|
)
|
|
.toList(),
|
|
),
|
|
),
|
|
Expanded(child: _buildBody(context, isWide)),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildBody(BuildContext context, bool isWide) {
|
|
if (_isLoading) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
if (_error != null) {
|
|
return Center(
|
|
child: Text(
|
|
'Error: $_error',
|
|
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
|
),
|
|
);
|
|
}
|
|
if (_devices.isEmpty) {
|
|
final theme = Theme.of(context);
|
|
return Padding(
|
|
padding: const EdgeInsets.only(top: 16, bottom: 12),
|
|
child: Center(
|
|
child: Text(
|
|
_emptyMessage(),
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
final formatter = FriendlyDateFormatter();
|
|
return RefreshIndicator(
|
|
onRefresh: () => _fetchPage(_currentPage),
|
|
child: CustomScrollView(
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
|
slivers: [
|
|
if (isWide)
|
|
SliverPersistentHeader(
|
|
pinned: true,
|
|
delegate: DeviceListHeaderDelegate(
|
|
sortColumn: _sortColumn,
|
|
ascending: _sortAscending,
|
|
onTap: _onSortHeaderTapped,
|
|
),
|
|
),
|
|
SliverList.separated(
|
|
itemCount: _devices.length,
|
|
itemBuilder: (context, index) {
|
|
final device = _devices[index];
|
|
return isWide
|
|
? DeviceRowWide(
|
|
device: device,
|
|
formatter: formatter,
|
|
onRefresh: () => _fetchPage(_currentPage),
|
|
)
|
|
: DeviceRowCompact(
|
|
device: device,
|
|
formatter: formatter,
|
|
onRefresh: () => _fetchPage(_currentPage),
|
|
);
|
|
},
|
|
separatorBuilder: (_, _) => const Divider(height: 1),
|
|
),
|
|
if (_currentPage > 0 || _hasNextPage) _buildPaginationControls(),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPaginationControls() {
|
|
return SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
IconButton.outlined(
|
|
onPressed: _currentPage > 0 && !_isLoading
|
|
? () => _fetchPage(0)
|
|
: null,
|
|
icon: const Icon(Icons.first_page),
|
|
tooltip: 'First page',
|
|
),
|
|
const SizedBox(width: 8),
|
|
IconButton.outlined(
|
|
onPressed: _currentPage > 0 && !_isLoading
|
|
? () => _fetchPage(_currentPage - 1)
|
|
: null,
|
|
icon: const Icon(Icons.chevron_left),
|
|
tooltip: 'Previous page',
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Text(
|
|
'Page ${_currentPage + 1}',
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
),
|
|
),
|
|
IconButton.outlined(
|
|
onPressed: _hasNextPage && !_isLoading
|
|
? () => _fetchPage(_currentPage + 1)
|
|
: null,
|
|
icon: const Icon(Icons.chevron_right),
|
|
tooltip: 'Next page',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|