mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
Consolidate frontend scanner, pagination, and tick-timer duplication
Collapse the five scanner status models into two shared shapes, ActiveScannerStatus and PassiveScannerStatus, mirroring the backend's active/passive vocabulary. Replace the five near-identical per-scanner detail card files with a single scanner_status_cards.dart (two shared resolvers plus a config list), and rebuild the combined home card to iterate a list of scanners with two shape resolvers instead of five copy-pasted resolve methods. Extract two reusable mixins: - PeriodicRebuild: the shared once-a-second "rebuild to refresh elapsed text" timer used by the scanner cards and the stale indicator. - PaginatedListState: the shared pagination state, page-size/page-count getters, cancel-token-aware fetch orchestration, and disposal used by the device and notification lists. No behaviour change; ~900 lines removed. Tests and analyzer pass.
This commit is contained in:
@@ -1,6 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
@@ -10,21 +9,15 @@ import '../navigation.dart';
|
|||||||
import '../theme/dimens.dart';
|
import '../theme/dimens.dart';
|
||||||
import '../utils/friendly_date_formatter.dart';
|
import '../utils/friendly_date_formatter.dart';
|
||||||
import '../utils/oott_api.dart';
|
import '../utils/oott_api.dart';
|
||||||
|
import '../utils/paginated_list_state.dart';
|
||||||
import '../widgets/empty_state.dart';
|
import '../widgets/empty_state.dart';
|
||||||
import '../widgets/filter_selector.dart';
|
import '../widgets/filter_selector.dart';
|
||||||
import '../widgets/pagination_bar.dart';
|
import '../widgets/pagination_bar.dart';
|
||||||
import '../widgets/pagination_progress.dart';
|
|
||||||
import '../widgets/skeleton.dart';
|
import '../widgets/skeleton.dart';
|
||||||
import 'device_list_filter.dart';
|
import 'device_list_filter.dart';
|
||||||
import 'device_list_rows.dart';
|
import 'device_list_rows.dart';
|
||||||
import 'device_list_sort.dart';
|
import 'device_list_sort.dart';
|
||||||
|
|
||||||
// Phones show fewer devices so the list and its pagination controls fit on
|
|
||||||
// screen at once on the common current phones (e.g. iPhone 15, Pixel 8); the
|
|
||||||
// wider table layout has the vertical room for a full page.
|
|
||||||
const _phonePageSize = 5;
|
|
||||||
const _widePageSize = 10;
|
|
||||||
|
|
||||||
class DeviceList extends StatefulWidget {
|
class DeviceList extends StatefulWidget {
|
||||||
const DeviceList({super.key});
|
const DeviceList({super.key});
|
||||||
|
|
||||||
@@ -32,12 +25,10 @@ class DeviceList extends StatefulWidget {
|
|||||||
State<DeviceList> createState() => _DeviceListState();
|
State<DeviceList> createState() => _DeviceListState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DeviceListState extends State<DeviceList> with RouteAware {
|
class _DeviceListState extends State<DeviceList>
|
||||||
|
with RouteAware, PaginatedListState<DeviceList> {
|
||||||
DeviceFilter _filter = DeviceFilter.newDevices;
|
DeviceFilter _filter = DeviceFilter.newDevices;
|
||||||
List<Device> _devices = [];
|
List<Device> _devices = [];
|
||||||
bool _isLoading = true;
|
|
||||||
bool _isPaging = false;
|
|
||||||
String? _error;
|
|
||||||
final TextEditingController _ownerController = TextEditingController();
|
final TextEditingController _ownerController = TextEditingController();
|
||||||
DeviceType? _typeFilter;
|
DeviceType? _typeFilter;
|
||||||
Timer? _ownerDebounce;
|
Timer? _ownerDebounce;
|
||||||
@@ -45,22 +36,20 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
|
|||||||
DeviceSortColumn _sortColumn = DeviceSortColumn.lastSeen;
|
DeviceSortColumn _sortColumn = DeviceSortColumn.lastSeen;
|
||||||
bool _sortAscending = false;
|
bool _sortAscending = false;
|
||||||
|
|
||||||
int _currentPage = 0;
|
// Phones show fewer devices so the list and its pagination controls fit on
|
||||||
// Total devices matching the current filters, used to show how many pages
|
// screen at once on the common current phones (e.g. iPhone 15, Pixel 8); the
|
||||||
// exist and to offer "go to last page".
|
// wider table layout has the vertical room for a full page.
|
||||||
int _totalCount = 0;
|
@override
|
||||||
bool _didInitialFetch = false;
|
int get phonePageSize => 5;
|
||||||
CancelToken? _fetchToken;
|
@override
|
||||||
final ScrollController _scrollController = ScrollController();
|
int get widePageSize => 10;
|
||||||
|
@override
|
||||||
int get _pageSize => MediaQuery.sizeOf(context).width < Breakpoints.medium
|
bool get isListEmpty => _devices.isEmpty;
|
||||||
? _phonePageSize
|
|
||||||
: _widePageSize;
|
|
||||||
int get _totalPages => (_totalCount / _pageSize).ceil().clamp(1, 1 << 30);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
isLoading = true;
|
||||||
_ownerController.addListener(_onOwnerChanged);
|
_ownerController.addListener(_onOwnerChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,26 +62,22 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
|
|||||||
}
|
}
|
||||||
// Deferred from initState so the page size can read the screen width from
|
// Deferred from initState so the page size can read the screen width from
|
||||||
// MediaQuery, which is only available once dependencies are in place.
|
// MediaQuery, which is only available once dependencies are in place.
|
||||||
if (!_didInitialFetch) {
|
if (!didInitialFetch) {
|
||||||
_didInitialFetch = true;
|
didInitialFetch = true;
|
||||||
_fetchPage(0);
|
_fetchPage(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didPopNext() {
|
void didPopNext() {
|
||||||
_fetchPage(_currentPage);
|
_fetchPage(currentPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
routeObserver.unsubscribe(this);
|
routeObserver.unsubscribe(this);
|
||||||
_ownerDebounce?.cancel();
|
_ownerDebounce?.cancel();
|
||||||
_fetchToken?.cancel();
|
|
||||||
_ownerController.dispose();
|
_ownerController.dispose();
|
||||||
_scrollController.dispose();
|
|
||||||
// Clear any in-flight cue so it doesn't linger after leaving the page.
|
|
||||||
paginationLoading.value = false;
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,57 +98,34 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
|
|||||||
int page, {
|
int page, {
|
||||||
bool scrollToTop = false,
|
bool scrollToTop = false,
|
||||||
bool paging = false,
|
bool paging = false,
|
||||||
}) async {
|
}) {
|
||||||
if (scrollToTop && _scrollController.hasClients) {
|
bool? isRegistered;
|
||||||
_scrollController.animateTo(
|
if (_filter == DeviceFilter.newDevices) isRegistered = false;
|
||||||
0,
|
if (_filter == DeviceFilter.registered) isRegistered = true;
|
||||||
duration: const Duration(milliseconds: 300),
|
return runFetch(
|
||||||
curve: Curves.easeOut,
|
page,
|
||||||
);
|
scrollToTop: scrollToTop,
|
||||||
}
|
paging: paging,
|
||||||
_fetchToken?.cancel();
|
fetch: (page, perPage, token) => BackendAPI.instance.listDevices(
|
||||||
final token = CancelToken();
|
|
||||||
_fetchToken = token;
|
|
||||||
setState(() {
|
|
||||||
_isLoading = _devices.isEmpty;
|
|
||||||
_isPaging = paging;
|
|
||||||
_error = null;
|
|
||||||
});
|
|
||||||
paginationLoading.value = paging;
|
|
||||||
try {
|
|
||||||
bool? isRegistered;
|
|
||||||
if (_filter == DeviceFilter.newDevices) isRegistered = false;
|
|
||||||
if (_filter == DeviceFilter.registered) isRegistered = true;
|
|
||||||
|
|
||||||
final result = await BackendAPI.instance.listDevices(
|
|
||||||
isRegistered: isRegistered,
|
isRegistered: isRegistered,
|
||||||
owner: _ownerController.text.isEmpty ? null : _ownerController.text,
|
owner: _ownerController.text.isEmpty ? null : _ownerController.text,
|
||||||
deviceType: _typeFilter,
|
deviceType: _typeFilter,
|
||||||
sortBy: _sortColumn.apiName,
|
sortBy: _sortColumn.apiName,
|
||||||
sortAscending: _sortAscending,
|
sortAscending: _sortAscending,
|
||||||
page: page,
|
page: page,
|
||||||
perPage: _pageSize,
|
perPage: perPage,
|
||||||
cancelToken: token,
|
cancelToken: token,
|
||||||
);
|
),
|
||||||
if (!mounted || token != _fetchToken) return;
|
onResult: (result) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentPage = page;
|
currentPage = page;
|
||||||
_totalCount = result.totalCount;
|
totalCount = result.totalCount;
|
||||||
_devices = result.items;
|
_devices = result.items;
|
||||||
_isLoading = false;
|
isLoading = false;
|
||||||
_isPaging = false;
|
isPaging = false;
|
||||||
});
|
});
|
||||||
paginationLoading.value = false;
|
},
|
||||||
} catch (e) {
|
);
|
||||||
if (!mounted || token != _fetchToken) return;
|
|
||||||
if (e is DioException && e.type == DioExceptionType.cancel) return;
|
|
||||||
setState(() {
|
|
||||||
_error = dioErrorToUserMessage(e);
|
|
||||||
_isLoading = false;
|
|
||||||
_isPaging = false;
|
|
||||||
});
|
|
||||||
paginationLoading.value = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onSortHeaderTapped(DeviceSortColumn column) {
|
void _onSortHeaderTapped(DeviceSortColumn column) {
|
||||||
@@ -295,13 +257,13 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBody(BuildContext context, bool isWide) {
|
Widget _buildBody(BuildContext context, bool isWide) {
|
||||||
if (_isLoading) {
|
if (isLoading) {
|
||||||
return const ListSkeleton();
|
return const ListSkeleton();
|
||||||
}
|
}
|
||||||
if (_error != null) {
|
if (error != null) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Error: $_error',
|
'Error: $error',
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -317,9 +279,9 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
|
|||||||
|
|
||||||
final formatter = FriendlyDateFormatter();
|
final formatter = FriendlyDateFormatter();
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
onRefresh: () => _fetchPage(_currentPage),
|
onRefresh: () => _fetchPage(currentPage),
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
controller: _scrollController,
|
controller: scrollController,
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
slivers: [
|
slivers: [
|
||||||
if (isWide)
|
if (isWide)
|
||||||
@@ -340,24 +302,24 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
|
|||||||
key: ValueKey(device.macAddress),
|
key: ValueKey(device.macAddress),
|
||||||
device: device,
|
device: device,
|
||||||
formatter: formatter,
|
formatter: formatter,
|
||||||
onRefresh: () => _fetchPage(_currentPage),
|
onRefresh: () => _fetchPage(currentPage),
|
||||||
)
|
)
|
||||||
: DeviceRowCompact(
|
: DeviceRowCompact(
|
||||||
key: ValueKey(device.macAddress),
|
key: ValueKey(device.macAddress),
|
||||||
device: device,
|
device: device,
|
||||||
formatter: formatter,
|
formatter: formatter,
|
||||||
onRefresh: () => _fetchPage(_currentPage),
|
onRefresh: () => _fetchPage(currentPage),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
separatorBuilder: (_, _) =>
|
separatorBuilder: (_, _) =>
|
||||||
isWide ? const Divider(height: 1) : const SizedBox.shrink(),
|
isWide ? const Divider(height: 1) : const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
if (_currentPage > 0 || _totalPages > 1)
|
if (currentPage > 0 || totalPages > 1)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: PaginationBar(
|
child: PaginationBar(
|
||||||
currentPage: _currentPage,
|
currentPage: currentPage,
|
||||||
totalPages: _totalPages,
|
totalPages: totalPages,
|
||||||
isLoading: _isPaging,
|
isLoading: isPaging,
|
||||||
onPageChanged: (page) =>
|
onPageChanged: (page) =>
|
||||||
_fetchPage(page, scrollToTop: true, paging: true),
|
_fetchPage(page, scrollToTop: true, paging: true),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,27 +1,19 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../model/notification.dart' as oott_model;
|
import '../model/notification.dart' as oott_model;
|
||||||
import '../navigation.dart';
|
import '../navigation.dart';
|
||||||
import '../theme/dimens.dart';
|
|
||||||
import '../utils/friendly_date_formatter.dart';
|
import '../utils/friendly_date_formatter.dart';
|
||||||
import '../utils/oott_api.dart';
|
import '../utils/oott_api.dart';
|
||||||
|
import '../utils/paginated_list_state.dart';
|
||||||
import '../utils/ui_snackbars.dart';
|
import '../utils/ui_snackbars.dart';
|
||||||
import '../widgets/empty_state.dart';
|
import '../widgets/empty_state.dart';
|
||||||
import '../widgets/filter_selector.dart';
|
import '../widgets/filter_selector.dart';
|
||||||
import '../widgets/pagination_bar.dart';
|
import '../widgets/pagination_bar.dart';
|
||||||
import '../widgets/pagination_progress.dart';
|
|
||||||
import '../widgets/skeleton.dart';
|
import '../widgets/skeleton.dart';
|
||||||
import 'notification_card.dart';
|
import 'notification_card.dart';
|
||||||
|
|
||||||
// Phones show fewer notifications so the list and its pagination controls fit
|
|
||||||
// on screen at once on the common current phones (e.g. iPhone 15, Pixel 8);
|
|
||||||
// wider layouts have the vertical room for a couple more.
|
|
||||||
const _phonePageSize = 4;
|
|
||||||
const _widePageSize = 5;
|
|
||||||
|
|
||||||
// Durations for the list's enter/exit animations.
|
// Durations for the list's enter/exit animations.
|
||||||
const _insertDuration = Duration(milliseconds: 300);
|
const _insertDuration = Duration(milliseconds: 300);
|
||||||
const _removeDuration = Duration(milliseconds: 250);
|
const _removeDuration = Duration(milliseconds: 250);
|
||||||
@@ -52,15 +44,14 @@ class NotificationsList extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _NotificationsListState extends State<NotificationsList>
|
class _NotificationsListState extends State<NotificationsList>
|
||||||
with RouteAware, WidgetsBindingObserver {
|
with
|
||||||
|
RouteAware,
|
||||||
|
WidgetsBindingObserver,
|
||||||
|
PaginatedListState<NotificationsList> {
|
||||||
_NotificationFilter _filter = _NotificationFilter.newOnly;
|
_NotificationFilter _filter = _NotificationFilter.newOnly;
|
||||||
Timer? _notificationTimer;
|
Timer? _notificationTimer;
|
||||||
|
|
||||||
int _currentPage = 0;
|
|
||||||
bool _didInitialFetch = false;
|
|
||||||
List<oott_model.Notification> _items = [];
|
List<oott_model.Notification> _items = [];
|
||||||
bool _isLoading = false;
|
|
||||||
bool _isPaging = false;
|
|
||||||
|
|
||||||
// Drives the animated list. Recreated on every reset (filter/page change,
|
// Drives the animated list. Recreated on every reset (filter/page change,
|
||||||
// initial load) so the new dataset mounts fresh without per-row animations;
|
// initial load) so the new dataset mounts fresh without per-row animations;
|
||||||
@@ -70,16 +61,15 @@ class _NotificationsListState extends State<NotificationsList>
|
|||||||
final Set<int> _flashIds = {};
|
final Set<int> _flashIds = {};
|
||||||
final FriendlyDateFormatter _formatter = FriendlyDateFormatter();
|
final FriendlyDateFormatter _formatter = FriendlyDateFormatter();
|
||||||
|
|
||||||
int get _pageSize => MediaQuery.sizeOf(context).width < Breakpoints.medium
|
// Phones show fewer notifications so the list and its pagination controls fit
|
||||||
? _phonePageSize
|
// on screen at once on the common current phones (e.g. iPhone 15, Pixel 8);
|
||||||
: _widePageSize;
|
// wider layouts have the vertical room for a couple more.
|
||||||
// Total notifications matching the current filter, used to show how many pages
|
@override
|
||||||
// exist and to offer "go to last page".
|
int get phonePageSize => 4;
|
||||||
int _totalCount = 0;
|
@override
|
||||||
int get _totalPages => (_totalCount / _pageSize).ceil().clamp(1, 1 << 30);
|
int get widePageSize => 5;
|
||||||
String? _error;
|
@override
|
||||||
CancelToken? _fetchToken;
|
bool get isListEmpty => _items.isEmpty;
|
||||||
final ScrollController _scrollController = ScrollController();
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -97,8 +87,8 @@ class _NotificationsListState extends State<NotificationsList>
|
|||||||
}
|
}
|
||||||
// Deferred from initState so the page size can read the screen width from
|
// Deferred from initState so the page size can read the screen width from
|
||||||
// MediaQuery, which is only available once dependencies are in place.
|
// MediaQuery, which is only available once dependencies are in place.
|
||||||
if (!_didInitialFetch) {
|
if (!didInitialFetch) {
|
||||||
_didInitialFetch = true;
|
didInitialFetch = true;
|
||||||
_fetchPage(0);
|
_fetchPage(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,7 +101,7 @@ class _NotificationsListState extends State<NotificationsList>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void didPopNext() {
|
void didPopNext() {
|
||||||
_fetchPage(_currentPage, animateDiff: true);
|
_fetchPage(currentPage, animateDiff: true);
|
||||||
_startTimer();
|
_startTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +111,7 @@ class _NotificationsListState extends State<NotificationsList>
|
|||||||
_notificationTimer?.cancel();
|
_notificationTimer?.cancel();
|
||||||
_notificationTimer = null;
|
_notificationTimer = null;
|
||||||
} else if (state == AppLifecycleState.resumed) {
|
} else if (state == AppLifecycleState.resumed) {
|
||||||
_fetchPage(_currentPage, animateDiff: true);
|
_fetchPage(currentPage, animateDiff: true);
|
||||||
_startTimer();
|
_startTimer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,7 +120,7 @@ class _NotificationsListState extends State<NotificationsList>
|
|||||||
_notificationTimer?.cancel();
|
_notificationTimer?.cancel();
|
||||||
_notificationTimer = Timer.periodic(
|
_notificationTimer = Timer.periodic(
|
||||||
const Duration(minutes: 1),
|
const Duration(minutes: 1),
|
||||||
(_) => _fetchPage(_currentPage, animateDiff: true),
|
(_) => _fetchPage(currentPage, animateDiff: true),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,10 +129,6 @@ class _NotificationsListState extends State<NotificationsList>
|
|||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
routeObserver.unsubscribe(this);
|
routeObserver.unsubscribe(this);
|
||||||
_notificationTimer?.cancel();
|
_notificationTimer?.cancel();
|
||||||
_fetchToken?.cancel();
|
|
||||||
_scrollController.dispose();
|
|
||||||
// Clear any in-flight cue so it doesn't linger after leaving the page.
|
|
||||||
paginationLoading.value = false;
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,63 +147,42 @@ class _NotificationsListState extends State<NotificationsList>
|
|||||||
bool scrollToTop = false,
|
bool scrollToTop = false,
|
||||||
bool paging = false,
|
bool paging = false,
|
||||||
bool animateDiff = false,
|
bool animateDiff = false,
|
||||||
}) async {
|
}) {
|
||||||
if (scrollToTop && _scrollController.hasClients) {
|
return runFetch(
|
||||||
_scrollController.animateTo(
|
page,
|
||||||
0,
|
scrollToTop: scrollToTop,
|
||||||
duration: const Duration(milliseconds: 300),
|
paging: paging,
|
||||||
curve: Curves.easeOut,
|
fetch: (page, perPage, token) => BackendAPI.instance.listNotifications(
|
||||||
);
|
|
||||||
}
|
|
||||||
_fetchToken?.cancel();
|
|
||||||
final token = CancelToken();
|
|
||||||
_fetchToken = token;
|
|
||||||
setState(() {
|
|
||||||
_isLoading = _items.isEmpty;
|
|
||||||
_isPaging = paging;
|
|
||||||
_error = null;
|
|
||||||
});
|
|
||||||
paginationLoading.value = paging;
|
|
||||||
try {
|
|
||||||
final result = await BackendAPI.instance.listNotifications(
|
|
||||||
_filter.isNew,
|
_filter.isNew,
|
||||||
page: page,
|
page: page,
|
||||||
perPage: _pageSize,
|
perPage: perPage,
|
||||||
cancelToken: token,
|
cancelToken: token,
|
||||||
);
|
),
|
||||||
if (!mounted || token != _fetchToken) return;
|
onResult: (result) {
|
||||||
paginationLoading.value = false;
|
if (animateDiff && !isLoading) {
|
||||||
if (animateDiff && !_isLoading) {
|
// Merge into the live list so changes animate. Scalars are updated
|
||||||
// Merge into the live list so changes animate. Scalars are updated
|
// without setState here; _reconcile schedules the rebuild itself so
|
||||||
// without setState here; _reconcile schedules the rebuild itself so it
|
// it can defer swapping in the empty state until exit animations
|
||||||
// can defer swapping in the empty state until exit animations finish.
|
// finish.
|
||||||
_currentPage = page;
|
currentPage = page;
|
||||||
_totalCount = result.totalCount;
|
totalCount = result.totalCount;
|
||||||
_isLoading = false;
|
isLoading = false;
|
||||||
_isPaging = false;
|
isPaging = false;
|
||||||
_reconcile(result.items);
|
_reconcile(result.items);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Reset: a fresh dataset. Recreate the list key so the animated list
|
// Reset: a fresh dataset. Recreate the list key so the animated list
|
||||||
// mounts anew and shows the rows immediately, without insert animations.
|
// mounts anew and shows the rows immediately, without insert animations.
|
||||||
setState(() {
|
setState(() {
|
||||||
_listKey = GlobalKey();
|
_listKey = GlobalKey();
|
||||||
_currentPage = page;
|
currentPage = page;
|
||||||
_totalCount = result.totalCount;
|
totalCount = result.totalCount;
|
||||||
_items = result.items;
|
_items = result.items;
|
||||||
_isLoading = false;
|
isLoading = false;
|
||||||
_isPaging = false;
|
isPaging = false;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
},
|
||||||
if (!mounted || token != _fetchToken) return;
|
);
|
||||||
if (e is DioException && e.type == DioExceptionType.cancel) return;
|
|
||||||
setState(() {
|
|
||||||
_error = dioErrorToUserMessage(e);
|
|
||||||
_isLoading = false;
|
|
||||||
_isPaging = false;
|
|
||||||
});
|
|
||||||
paginationLoading.value = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _markAllAsRead() async {
|
Future<void> _markAllAsRead() async {
|
||||||
@@ -229,7 +194,7 @@ class _NotificationsListState extends State<NotificationsList>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_fetchPage(_currentPage, animateDiff: true);
|
_fetchPage(currentPage, animateDiff: true);
|
||||||
UISnackbars.showSuccess(context, 'All notifications marked as read');
|
UISnackbars.showSuccess(context, 'All notifications marked as read');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +269,7 @@ class _NotificationsListState extends State<NotificationsList>
|
|||||||
/// [_removeItem] directly so they don't double-count against a fresh total.
|
/// [_removeItem] directly so they don't double-count against a fresh total.
|
||||||
void _removeAndSettle(int id, {required bool animated}) {
|
void _removeAndSettle(int id, {required bool animated}) {
|
||||||
_removeItem(id, animated: animated);
|
_removeItem(id, animated: animated);
|
||||||
if (_totalCount > 0) _totalCount--;
|
if (totalCount > 0) totalCount--;
|
||||||
_afterStructuralChange();
|
_afterStructuralChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,9 +334,9 @@ class _NotificationsListState extends State<NotificationsList>
|
|||||||
_buildNotificationsHeader(context),
|
_buildNotificationsHeader(context),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: RefreshIndicator(
|
child: RefreshIndicator(
|
||||||
onRefresh: () => _fetchPage(_currentPage, animateDiff: true),
|
onRefresh: () => _fetchPage(currentPage, animateDiff: true),
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
controller: _scrollController,
|
controller: scrollController,
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
slivers: [
|
slivers: [
|
||||||
..._buildNotificationSlivers(context),
|
..._buildNotificationSlivers(context),
|
||||||
@@ -417,15 +382,15 @@ class _NotificationsListState extends State<NotificationsList>
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<Widget> _buildNotificationSlivers(BuildContext context) {
|
List<Widget> _buildNotificationSlivers(BuildContext context) {
|
||||||
if (_isLoading) {
|
if (isLoading) {
|
||||||
return [const SliverToBoxAdapter(child: ListSkeleton(rows: 4))];
|
return [const SliverToBoxAdapter(child: ListSkeleton(rows: 4))];
|
||||||
}
|
}
|
||||||
if (_error != null) {
|
if (error != null) {
|
||||||
return [
|
return [
|
||||||
SliverFillRemaining(
|
SliverFillRemaining(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Error: $_error',
|
'Error: $error',
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -444,12 +409,12 @@ class _NotificationsListState extends State<NotificationsList>
|
|||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
_buildNotificationSliver(),
|
_buildNotificationSliver(),
|
||||||
if (_currentPage > 0 || _totalPages > 1)
|
if (currentPage > 0 || totalPages > 1)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: PaginationBar(
|
child: PaginationBar(
|
||||||
currentPage: _currentPage,
|
currentPage: currentPage,
|
||||||
totalPages: _totalPages,
|
totalPages: totalPages,
|
||||||
isLoading: _isPaging,
|
isLoading: isPaging,
|
||||||
onPageChanged: (page) =>
|
onPageChanged: (page) =>
|
||||||
_fetchPage(page, scrollToTop: true, paging: true),
|
_fetchPage(page, scrollToTop: true, paging: true),
|
||||||
),
|
),
|
||||||
|
|||||||
+6
-4
@@ -1,11 +1,13 @@
|
|||||||
class ArpScannerStatus {
|
/// Status of an active scanner (one that runs on an interval, e.g. ARP, SNMP).
|
||||||
|
/// Mirrors the backend's `ActiveSnapshot` / `ActiveScannerStatusResponse`.
|
||||||
|
class ActiveScannerStatus {
|
||||||
final bool isRunning;
|
final bool isRunning;
|
||||||
final double? runningForSeconds;
|
final double? runningForSeconds;
|
||||||
final double? nextRunInSeconds;
|
final double? nextRunInSeconds;
|
||||||
final int? lastScanDevicesSeen;
|
final int? lastScanDevicesSeen;
|
||||||
final double? lastScanSecondsAgo;
|
final double? lastScanSecondsAgo;
|
||||||
|
|
||||||
const ArpScannerStatus({
|
const ActiveScannerStatus({
|
||||||
required this.isRunning,
|
required this.isRunning,
|
||||||
this.runningForSeconds,
|
this.runningForSeconds,
|
||||||
this.nextRunInSeconds,
|
this.nextRunInSeconds,
|
||||||
@@ -13,8 +15,8 @@ class ArpScannerStatus {
|
|||||||
this.lastScanSecondsAgo,
|
this.lastScanSecondsAgo,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory ArpScannerStatus.fromJson(Map<String, dynamic> json) {
|
factory ActiveScannerStatus.fromJson(Map<String, dynamic> json) {
|
||||||
return ArpScannerStatus(
|
return ActiveScannerStatus(
|
||||||
isRunning: json['is_running'] as bool,
|
isRunning: json['is_running'] as bool,
|
||||||
runningForSeconds: (json['running_for_seconds'] as num?)?.toDouble(),
|
runningForSeconds: (json['running_for_seconds'] as num?)?.toDouble(),
|
||||||
nextRunInSeconds: (json['next_run_in_seconds'] as num?)?.toDouble(),
|
nextRunInSeconds: (json['next_run_in_seconds'] as num?)?.toDouble(),
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
class DhcpScannerStatus {
|
|
||||||
final bool isListening;
|
|
||||||
final double? listeningForSeconds;
|
|
||||||
final int devicesSeen;
|
|
||||||
final double? lastDeviceSeenSecondsAgo;
|
|
||||||
|
|
||||||
const DhcpScannerStatus({
|
|
||||||
required this.isListening,
|
|
||||||
this.listeningForSeconds,
|
|
||||||
required this.devicesSeen,
|
|
||||||
this.lastDeviceSeenSecondsAgo,
|
|
||||||
});
|
|
||||||
|
|
||||||
factory DhcpScannerStatus.fromJson(Map<String, dynamic> json) {
|
|
||||||
return DhcpScannerStatus(
|
|
||||||
isListening: json['is_listening'] as bool,
|
|
||||||
listeningForSeconds: (json['listening_for_seconds'] as num?)?.toDouble(),
|
|
||||||
devicesSeen: (json['devices_seen'] as num).toInt(),
|
|
||||||
lastDeviceSeenSecondsAgo: (json['last_device_seen_seconds_ago'] as num?)
|
|
||||||
?.toDouble(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
class MdnsScannerStatus {
|
|
||||||
final bool isListening;
|
|
||||||
final double? listeningForSeconds;
|
|
||||||
final int devicesSeen;
|
|
||||||
final double? lastDeviceSeenSecondsAgo;
|
|
||||||
|
|
||||||
const MdnsScannerStatus({
|
|
||||||
required this.isListening,
|
|
||||||
this.listeningForSeconds,
|
|
||||||
required this.devicesSeen,
|
|
||||||
this.lastDeviceSeenSecondsAgo,
|
|
||||||
});
|
|
||||||
|
|
||||||
factory MdnsScannerStatus.fromJson(Map<String, dynamic> json) {
|
|
||||||
return MdnsScannerStatus(
|
|
||||||
isListening: json['is_listening'] as bool,
|
|
||||||
listeningForSeconds: (json['listening_for_seconds'] as num?)?.toDouble(),
|
|
||||||
devicesSeen: (json['devices_seen'] as num).toInt(),
|
|
||||||
lastDeviceSeenSecondsAgo: (json['last_device_seen_seconds_ago'] as num?)
|
|
||||||
?.toDouble(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+6
-4
@@ -1,18 +1,20 @@
|
|||||||
class SsdpScannerStatus {
|
/// Status of a passive scanner (one that listens continuously, e.g. mDNS, SSDP,
|
||||||
|
/// DHCP). Mirrors the backend's `PassiveSnapshot` / `PassiveScannerStatusResponse`.
|
||||||
|
class PassiveScannerStatus {
|
||||||
final bool isListening;
|
final bool isListening;
|
||||||
final double? listeningForSeconds;
|
final double? listeningForSeconds;
|
||||||
final int devicesSeen;
|
final int devicesSeen;
|
||||||
final double? lastDeviceSeenSecondsAgo;
|
final double? lastDeviceSeenSecondsAgo;
|
||||||
|
|
||||||
const SsdpScannerStatus({
|
const PassiveScannerStatus({
|
||||||
required this.isListening,
|
required this.isListening,
|
||||||
this.listeningForSeconds,
|
this.listeningForSeconds,
|
||||||
required this.devicesSeen,
|
required this.devicesSeen,
|
||||||
this.lastDeviceSeenSecondsAgo,
|
this.lastDeviceSeenSecondsAgo,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory SsdpScannerStatus.fromJson(Map<String, dynamic> json) {
|
factory PassiveScannerStatus.fromJson(Map<String, dynamic> json) {
|
||||||
return SsdpScannerStatus(
|
return PassiveScannerStatus(
|
||||||
isListening: json['is_listening'] as bool,
|
isListening: json['is_listening'] as bool,
|
||||||
listeningForSeconds: (json['listening_for_seconds'] as num?)?.toDouble(),
|
listeningForSeconds: (json['listening_for_seconds'] as num?)?.toDouble(),
|
||||||
devicesSeen: (json['devices_seen'] as num).toInt(),
|
devicesSeen: (json['devices_seen'] as num).toInt(),
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
class SnmpScannerStatus {
|
|
||||||
final bool isRunning;
|
|
||||||
final double? runningForSeconds;
|
|
||||||
final double? nextRunInSeconds;
|
|
||||||
final int? lastScanDevicesSeen;
|
|
||||||
final double? lastScanSecondsAgo;
|
|
||||||
|
|
||||||
const SnmpScannerStatus({
|
|
||||||
required this.isRunning,
|
|
||||||
this.runningForSeconds,
|
|
||||||
this.nextRunInSeconds,
|
|
||||||
this.lastScanDevicesSeen,
|
|
||||||
this.lastScanSecondsAgo,
|
|
||||||
});
|
|
||||||
|
|
||||||
factory SnmpScannerStatus.fromJson(Map<String, dynamic> json) {
|
|
||||||
return SnmpScannerStatus(
|
|
||||||
isRunning: json['is_running'] as bool,
|
|
||||||
runningForSeconds: (json['running_for_seconds'] as num?)?.toDouble(),
|
|
||||||
nextRunInSeconds: (json['next_run_in_seconds'] as num?)?.toDouble(),
|
|
||||||
lastScanDevicesSeen: (json['last_scan_devices_seen'] as num?)?.toInt(),
|
|
||||||
lastScanSecondsAgo: (json['last_scan_seconds_ago'] as num?)?.toDouble(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +1,22 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../theme/dimens.dart';
|
import '../theme/dimens.dart';
|
||||||
import '../widgets/arp_scanner_card.dart';
|
import '../widgets/scanner_status_cards.dart';
|
||||||
import '../widgets/dhcp_scanner_card.dart';
|
|
||||||
import '../widgets/mdns_scanner_card.dart';
|
|
||||||
import '../widgets/snmp_scanner_card.dart';
|
|
||||||
import '../widgets/ssdp_scanner_card.dart';
|
|
||||||
|
|
||||||
class StatusScreen extends StatelessWidget {
|
class StatusScreen extends StatelessWidget {
|
||||||
const StatusScreen({super.key});
|
const StatusScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final cards = scannerStatusCards();
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const ArpScannerCard(),
|
for (var i = 0; i < cards.length; i++) ...[
|
||||||
const SizedBox(height: Insets.sm),
|
if (i > 0) const SizedBox(height: Insets.sm),
|
||||||
const MdnsScannerCard(),
|
cards[i],
|
||||||
const SizedBox(height: Insets.sm),
|
],
|
||||||
const SsdpScannerCard(),
|
|
||||||
const SizedBox(height: Insets.sm),
|
|
||||||
const DhcpScannerCard(),
|
|
||||||
const SizedBox(height: Insets.sm),
|
|
||||||
const SnmpScannerCard(),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,40 +1,45 @@
|
|||||||
part of '../oott_api.dart';
|
part of '../oott_api.dart';
|
||||||
|
|
||||||
/// Per-scanner status endpoints. Every scanner exposes the same
|
/// Per-scanner status endpoints. Active scanners (ARP, SNMP) share the
|
||||||
/// `/<scanner>_scanner/status` shape, so each getter just decodes its model.
|
/// [ActiveScannerStatus] shape; passive scanners (mDNS, SSDP, DHCP) share the
|
||||||
|
/// [PassiveScannerStatus] shape. Each getter just decodes its model.
|
||||||
extension ScannerApi on BackendAPI {
|
extension ScannerApi on BackendAPI {
|
||||||
Future<ArpScannerStatus> getArpScannerStatus({CancelToken? cancelToken}) =>
|
Future<ActiveScannerStatus> getArpScannerStatus({CancelToken? cancelToken}) =>
|
||||||
_getModel(
|
_getModel(
|
||||||
'/arp_scanner/status',
|
'/arp_scanner/status',
|
||||||
ArpScannerStatus.fromJson,
|
ActiveScannerStatus.fromJson,
|
||||||
cancelToken: cancelToken,
|
cancelToken: cancelToken,
|
||||||
);
|
);
|
||||||
|
|
||||||
Future<MdnsScannerStatus> getMdnsScannerStatus({CancelToken? cancelToken}) =>
|
Future<PassiveScannerStatus> getMdnsScannerStatus({
|
||||||
_getModel(
|
CancelToken? cancelToken,
|
||||||
'/mdns_scanner/status',
|
}) => _getModel(
|
||||||
MdnsScannerStatus.fromJson,
|
'/mdns_scanner/status',
|
||||||
cancelToken: cancelToken,
|
PassiveScannerStatus.fromJson,
|
||||||
);
|
cancelToken: cancelToken,
|
||||||
|
);
|
||||||
|
|
||||||
Future<SsdpScannerStatus> getSsdpScannerStatus({CancelToken? cancelToken}) =>
|
Future<PassiveScannerStatus> getSsdpScannerStatus({
|
||||||
_getModel(
|
CancelToken? cancelToken,
|
||||||
'/ssdp_scanner/status',
|
}) => _getModel(
|
||||||
SsdpScannerStatus.fromJson,
|
'/ssdp_scanner/status',
|
||||||
cancelToken: cancelToken,
|
PassiveScannerStatus.fromJson,
|
||||||
);
|
cancelToken: cancelToken,
|
||||||
|
);
|
||||||
|
|
||||||
Future<DhcpScannerStatus> getDhcpScannerStatus({CancelToken? cancelToken}) =>
|
Future<PassiveScannerStatus> getDhcpScannerStatus({
|
||||||
_getModel(
|
CancelToken? cancelToken,
|
||||||
'/dhcp_scanner/status',
|
}) => _getModel(
|
||||||
DhcpScannerStatus.fromJson,
|
'/dhcp_scanner/status',
|
||||||
cancelToken: cancelToken,
|
PassiveScannerStatus.fromJson,
|
||||||
);
|
cancelToken: cancelToken,
|
||||||
|
);
|
||||||
|
|
||||||
Future<SnmpScannerStatus> getSnmpScannerStatus({CancelToken? cancelToken}) =>
|
Future<ActiveScannerStatus> getSnmpScannerStatus({
|
||||||
_getModel(
|
CancelToken? cancelToken,
|
||||||
'/snmp_scanner/status',
|
}) => _getModel(
|
||||||
SnmpScannerStatus.fromJson,
|
'/snmp_scanner/status',
|
||||||
cancelToken: cancelToken,
|
ActiveScannerStatus.fromJson,
|
||||||
);
|
cancelToken: cancelToken,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,8 @@ import 'api/api_error.dart';
|
|||||||
import 'api/dio_config.dart';
|
import 'api/dio_config.dart';
|
||||||
import 'backend_reachability.dart';
|
import 'backend_reachability.dart';
|
||||||
import 'pref_utils.dart';
|
import 'pref_utils.dart';
|
||||||
import '../model/arp_scanner_status.dart';
|
import '../model/active_scanner_status.dart';
|
||||||
import '../model/dhcp_scanner_status.dart';
|
import '../model/passive_scanner_status.dart';
|
||||||
import '../model/mdns_scanner_status.dart';
|
|
||||||
import '../model/ssdp_scanner_status.dart';
|
|
||||||
import '../model/snmp_scanner_status.dart';
|
|
||||||
import '../model/device.dart';
|
import '../model/device.dart';
|
||||||
import '../model/device_event.dart';
|
import '../model/device_event.dart';
|
||||||
import '../model/device_summary.dart';
|
import '../model/device_summary.dart';
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../theme/dimens.dart';
|
||||||
|
import '../widgets/pagination_progress.dart';
|
||||||
|
import 'api/api_error.dart';
|
||||||
|
|
||||||
|
/// Shared state and orchestration for screens that show a paged, server-backed
|
||||||
|
/// list (devices, notifications). Provides the common pagination state, the
|
||||||
|
/// responsive page-size/page-count getters, the cancel-token-aware [runFetch]
|
||||||
|
/// helper, and disposal of the scroll controller and in-flight token.
|
||||||
|
///
|
||||||
|
/// Subclasses supply [phonePageSize], [widePageSize] and [isListEmpty], call
|
||||||
|
/// [runFetch] to load a page, and chain [dispose] via `super.dispose()`.
|
||||||
|
mixin PaginatedListState<W extends StatefulWidget> on State<W> {
|
||||||
|
int currentPage = 0;
|
||||||
|
// Total items matching the current filters, used to show how many pages exist
|
||||||
|
// and to offer "go to last page".
|
||||||
|
int totalCount = 0;
|
||||||
|
bool isLoading = false;
|
||||||
|
bool isPaging = false;
|
||||||
|
bool didInitialFetch = false;
|
||||||
|
String? error;
|
||||||
|
CancelToken? fetchToken;
|
||||||
|
final ScrollController scrollController = ScrollController();
|
||||||
|
|
||||||
|
/// Page sizes for the phone and wider layouts.
|
||||||
|
int get phonePageSize;
|
||||||
|
int get widePageSize;
|
||||||
|
|
||||||
|
/// Whether the list currently has no items. Controls whether a fetch shows
|
||||||
|
/// the full loading state or refreshes the current page in place.
|
||||||
|
bool get isListEmpty;
|
||||||
|
|
||||||
|
int get pageSize => MediaQuery.sizeOf(context).width < Breakpoints.medium
|
||||||
|
? phonePageSize
|
||||||
|
: widePageSize;
|
||||||
|
int get totalPages => (totalCount / pageSize).ceil().clamp(1, 1 << 30);
|
||||||
|
|
||||||
|
/// Runs a paged fetch with the orchestration shared by every paged list:
|
||||||
|
/// optional scroll-to-top, cancel-token swap, loading flags, the
|
||||||
|
/// unmounted/superseded guard, and error handling. [fetch] performs the
|
||||||
|
/// request for the given page; [onResult] applies a still-current result to
|
||||||
|
/// state (it is not called for cancelled, superseded, or failed fetches).
|
||||||
|
Future<void> runFetch<R>(
|
||||||
|
int page, {
|
||||||
|
bool scrollToTop = false,
|
||||||
|
bool paging = false,
|
||||||
|
required Future<R> Function(int page, int perPage, CancelToken token) fetch,
|
||||||
|
required void Function(R result) onResult,
|
||||||
|
}) async {
|
||||||
|
if (scrollToTop && scrollController.hasClients) {
|
||||||
|
scrollController.animateTo(
|
||||||
|
0,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fetchToken?.cancel();
|
||||||
|
final token = CancelToken();
|
||||||
|
fetchToken = token;
|
||||||
|
setState(() {
|
||||||
|
isLoading = isListEmpty;
|
||||||
|
isPaging = paging;
|
||||||
|
error = null;
|
||||||
|
});
|
||||||
|
paginationLoading.value = paging;
|
||||||
|
try {
|
||||||
|
final result = await fetch(page, pageSize, token);
|
||||||
|
if (!mounted || token != fetchToken) return;
|
||||||
|
paginationLoading.value = false;
|
||||||
|
onResult(result);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted || token != fetchToken) return;
|
||||||
|
if (e is DioException && e.type == DioExceptionType.cancel) return;
|
||||||
|
setState(() {
|
||||||
|
error = dioErrorToUserMessage(e);
|
||||||
|
isLoading = false;
|
||||||
|
isPaging = false;
|
||||||
|
});
|
||||||
|
paginationLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
fetchToken?.cancel();
|
||||||
|
scrollController.dispose();
|
||||||
|
// Clear any in-flight cue so it doesn't linger after leaving the page.
|
||||||
|
paginationLoading.value = false;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
/// Mixin for [State]s that display continuously-updating elapsed-time text
|
||||||
|
/// (e.g. "Running for 3m 12s"). It runs a once-per-second timer that calls
|
||||||
|
/// [setState] so those labels stay current, and cancels it automatically on
|
||||||
|
/// dispose.
|
||||||
|
///
|
||||||
|
/// Call [startRebuildTicker] from `initState` (or when becoming visible) and
|
||||||
|
/// [stopRebuildTicker] when the widget is paused/hidden. Both are idempotent.
|
||||||
|
mixin PeriodicRebuild<T extends StatefulWidget> on State<T> {
|
||||||
|
Timer? _rebuildTicker;
|
||||||
|
|
||||||
|
/// Starts the per-second rebuild ticker if it isn't already running.
|
||||||
|
void startRebuildTicker() {
|
||||||
|
_rebuildTicker ??= Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops the per-second rebuild ticker.
|
||||||
|
void stopRebuildTicker() {
|
||||||
|
_rebuildTicker?.cancel();
|
||||||
|
_rebuildTicker = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
stopRebuildTicker();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import '../model/arp_scanner_status.dart';
|
|
||||||
import '../theme/app_colors.dart';
|
|
||||||
import '../utils/duration_formatter.dart';
|
|
||||||
import '../utils/oott_api.dart';
|
|
||||||
import 'scanner_status_card.dart';
|
|
||||||
|
|
||||||
class ArpScannerCard extends StatelessWidget {
|
|
||||||
const ArpScannerCard({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return ScannerStatusCard<ArpScannerStatus>(
|
|
||||||
title: 'ARP Scanner',
|
|
||||||
fetch: ({cancelToken}) =>
|
|
||||||
BackendAPI.instance.getArpScannerStatus(cancelToken: cancelToken),
|
|
||||||
resolver: _resolve,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static ScannerStatus _resolve(
|
|
||||||
BuildContext context,
|
|
||||||
ArpScannerStatus status,
|
|
||||||
double elapsed,
|
|
||||||
) {
|
|
||||||
final successColor = Theme.of(
|
|
||||||
context,
|
|
||||||
).extension<AppColorExtension>()!.success;
|
|
||||||
final neutralColor = Theme.of(context).colorScheme.outline;
|
|
||||||
final lastScan = status.lastScanDevicesSeen != null
|
|
||||||
? '${status.lastScanDevicesSeen} devices on last scan'
|
|
||||||
: null;
|
|
||||||
if (status.isRunning) {
|
|
||||||
final sublabels = <String>[
|
|
||||||
if (status.runningForSeconds != null)
|
|
||||||
'Running for ${formatSeconds(status.runningForSeconds! + elapsed)}',
|
|
||||||
?lastScan,
|
|
||||||
];
|
|
||||||
return (color: successColor, label: 'Running', sublabels: sublabels);
|
|
||||||
}
|
|
||||||
if (status.nextRunInSeconds != null) {
|
|
||||||
final remaining = (status.nextRunInSeconds! - elapsed).clamp(
|
|
||||||
0.0,
|
|
||||||
double.infinity,
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
color: neutralColor,
|
|
||||||
label: 'Waiting for next run',
|
|
||||||
sublabels: ['Next run in ${formatSeconds(remaining)}', ?lastScan],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (color: neutralColor, label: 'Not started', sublabels: []);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import '../model/dhcp_scanner_status.dart';
|
|
||||||
import '../theme/app_colors.dart';
|
|
||||||
import '../utils/duration_formatter.dart';
|
|
||||||
import '../utils/oott_api.dart';
|
|
||||||
import 'scanner_status_card.dart';
|
|
||||||
|
|
||||||
class DhcpScannerCard extends StatelessWidget {
|
|
||||||
const DhcpScannerCard({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return ScannerStatusCard<DhcpScannerStatus>(
|
|
||||||
title: 'DHCP Scanner',
|
|
||||||
fetch: ({cancelToken}) =>
|
|
||||||
BackendAPI.instance.getDhcpScannerStatus(cancelToken: cancelToken),
|
|
||||||
resolver: _resolve,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static ScannerStatus _resolve(
|
|
||||||
BuildContext context,
|
|
||||||
DhcpScannerStatus status,
|
|
||||||
double elapsed,
|
|
||||||
) {
|
|
||||||
if (!status.isListening) {
|
|
||||||
return (
|
|
||||||
color: Theme.of(context).colorScheme.outline,
|
|
||||||
label: 'Not started',
|
|
||||||
sublabels: [],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final sublabels = <String>[
|
|
||||||
status.listeningForSeconds != null
|
|
||||||
? 'Listening for ${formatSeconds(status.listeningForSeconds! + elapsed)} · ${status.devicesSeen} devices in the last hour'
|
|
||||||
: '${status.devicesSeen} devices in the last hour',
|
|
||||||
if (status.lastDeviceSeenSecondsAgo != null)
|
|
||||||
'Last device ${formatSeconds(status.lastDeviceSeenSecondsAgo! + elapsed)} ago',
|
|
||||||
];
|
|
||||||
return (
|
|
||||||
color: Theme.of(context).extension<AppColorExtension>()!.success,
|
|
||||||
label: 'Listening',
|
|
||||||
sublabels: sublabels,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import '../model/mdns_scanner_status.dart';
|
|
||||||
import '../theme/app_colors.dart';
|
|
||||||
import '../utils/duration_formatter.dart';
|
|
||||||
import '../utils/oott_api.dart';
|
|
||||||
import 'scanner_status_card.dart';
|
|
||||||
|
|
||||||
class MdnsScannerCard extends StatelessWidget {
|
|
||||||
const MdnsScannerCard({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return ScannerStatusCard<MdnsScannerStatus>(
|
|
||||||
title: 'mDNS Scanner',
|
|
||||||
fetch: ({cancelToken}) =>
|
|
||||||
BackendAPI.instance.getMdnsScannerStatus(cancelToken: cancelToken),
|
|
||||||
resolver: _resolve,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static ScannerStatus _resolve(
|
|
||||||
BuildContext context,
|
|
||||||
MdnsScannerStatus status,
|
|
||||||
double elapsed,
|
|
||||||
) {
|
|
||||||
if (!status.isListening) {
|
|
||||||
return (
|
|
||||||
color: Theme.of(context).colorScheme.outline,
|
|
||||||
label: 'Not started',
|
|
||||||
sublabels: [],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final sublabels = <String>[
|
|
||||||
status.listeningForSeconds != null
|
|
||||||
? 'Listening for ${formatSeconds(status.listeningForSeconds! + elapsed)} · ${status.devicesSeen} devices in the last hour'
|
|
||||||
: '${status.devicesSeen} devices in the last hour',
|
|
||||||
if (status.lastDeviceSeenSecondsAgo != null)
|
|
||||||
'Last device ${formatSeconds(status.lastDeviceSeenSecondsAgo! + elapsed)} ago',
|
|
||||||
];
|
|
||||||
return (
|
|
||||||
color: Theme.of(context).extension<AppColorExtension>()!.success,
|
|
||||||
label: 'Listening',
|
|
||||||
sublabels: sublabels,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../utils/backend_reachability.dart';
|
import '../utils/backend_reachability.dart';
|
||||||
import '../utils/duration_formatter.dart';
|
import '../utils/duration_formatter.dart';
|
||||||
|
import '../utils/periodic_rebuild.dart';
|
||||||
import '../utils/polled_value.dart';
|
import '../utils/polled_value.dart';
|
||||||
|
|
||||||
class PolledStaleIndicator extends StatefulWidget {
|
class PolledStaleIndicator extends StatefulWidget {
|
||||||
@@ -15,21 +14,12 @@ class PolledStaleIndicator extends StatefulWidget {
|
|||||||
State<PolledStaleIndicator> createState() => _PolledStaleIndicatorState();
|
State<PolledStaleIndicator> createState() => _PolledStaleIndicatorState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PolledStaleIndicatorState extends State<PolledStaleIndicator> {
|
class _PolledStaleIndicatorState extends State<PolledStaleIndicator>
|
||||||
Timer? _ticker;
|
with PeriodicRebuild<PolledStaleIndicator> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
|
startRebuildTicker();
|
||||||
if (mounted) setState(() {});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_ticker?.cancel();
|
|
||||||
super.dispose();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../utils/backend_reachability.dart';
|
import '../utils/backend_reachability.dart';
|
||||||
|
import '../utils/periodic_rebuild.dart';
|
||||||
import '../utils/polled_value.dart';
|
import '../utils/polled_value.dart';
|
||||||
import 'polled_stale_indicator.dart';
|
import 'polled_stale_indicator.dart';
|
||||||
|
|
||||||
typedef ScannerStatus = ({Color color, String label, List<String> sublabels});
|
typedef ScannerStatus = ({Color color, String label, List<String> sublabels});
|
||||||
|
|
||||||
typedef ScannerStatusResolver<T> =
|
typedef ScannerStatusResolver<T> =
|
||||||
ScannerStatus Function(BuildContext context, T value, double elapsedSeconds);
|
ScannerStatus Function(
|
||||||
|
BuildContext context,
|
||||||
|
T value,
|
||||||
|
double elapsedSeconds,
|
||||||
|
);
|
||||||
|
|
||||||
/// Generic card that polls a scanner status endpoint and renders the result
|
/// Generic card that polls a scanner status endpoint and renders the result
|
||||||
/// using the provided [resolver]. The two scanner cards (ARP, mDNS) are thin
|
/// using the provided [resolver]. The per-scanner detail cards are configured
|
||||||
/// wrappers over this widget.
|
/// over this widget in `scanner_status_cards.dart`.
|
||||||
class ScannerStatusCard<T> extends StatefulWidget {
|
class ScannerStatusCard<T> extends StatefulWidget {
|
||||||
const ScannerStatusCard({
|
const ScannerStatusCard({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -32,9 +35,9 @@ class ScannerStatusCard<T> extends StatefulWidget {
|
|||||||
State<ScannerStatusCard<T>> createState() => _ScannerStatusCardState<T>();
|
State<ScannerStatusCard<T>> createState() => _ScannerStatusCardState<T>();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ScannerStatusCardState<T> extends State<ScannerStatusCard<T>> {
|
class _ScannerStatusCardState<T> extends State<ScannerStatusCard<T>>
|
||||||
|
with PeriodicRebuild<ScannerStatusCard<T>> {
|
||||||
late final PolledValue<T> _polled;
|
late final PolledValue<T> _polled;
|
||||||
Timer? _tickTimer;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -44,14 +47,11 @@ class _ScannerStatusCardState<T> extends State<ScannerStatusCard<T>> {
|
|||||||
pollInterval: const Duration(seconds: 5),
|
pollInterval: const Duration(seconds: 5),
|
||||||
staleErrorAfter: const Duration(seconds: 30),
|
staleErrorAfter: const Duration(seconds: 30),
|
||||||
);
|
);
|
||||||
_tickTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
startRebuildTicker();
|
||||||
if (mounted) setState(() {});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_tickTimer?.cancel();
|
|
||||||
_polled.dispose();
|
_polled.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -124,7 +124,10 @@ class _ScannerStatusCardState<T> extends State<ScannerStatusCard<T>> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ScannerStatus _resolveStatus(BuildContext context, PolledFreshness freshness) {
|
ScannerStatus _resolveStatus(
|
||||||
|
BuildContext context,
|
||||||
|
PolledFreshness freshness,
|
||||||
|
) {
|
||||||
if (freshness == PolledFreshness.error) {
|
if (freshness == PolledFreshness.error) {
|
||||||
return (
|
return (
|
||||||
color: Theme.of(context).colorScheme.error,
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../model/active_scanner_status.dart';
|
||||||
|
import '../model/passive_scanner_status.dart';
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
import '../utils/duration_formatter.dart';
|
||||||
|
import '../utils/oott_api.dart';
|
||||||
|
import 'scanner_status_card.dart';
|
||||||
|
|
||||||
|
/// Resolves the display status for an active (interval) scanner — ARP, SNMP.
|
||||||
|
ScannerStatus resolveActiveScanner(
|
||||||
|
BuildContext context,
|
||||||
|
ActiveScannerStatus status,
|
||||||
|
double elapsed,
|
||||||
|
) {
|
||||||
|
final successColor = Theme.of(
|
||||||
|
context,
|
||||||
|
).extension<AppColorExtension>()!.success;
|
||||||
|
final neutralColor = Theme.of(context).colorScheme.outline;
|
||||||
|
final lastScan = status.lastScanDevicesSeen != null
|
||||||
|
? '${status.lastScanDevicesSeen} devices on last scan'
|
||||||
|
: null;
|
||||||
|
if (status.isRunning) {
|
||||||
|
final sublabels = <String>[
|
||||||
|
if (status.runningForSeconds != null)
|
||||||
|
'Running for ${formatSeconds(status.runningForSeconds! + elapsed)}',
|
||||||
|
?lastScan,
|
||||||
|
];
|
||||||
|
return (color: successColor, label: 'Running', sublabels: sublabels);
|
||||||
|
}
|
||||||
|
if (status.nextRunInSeconds != null) {
|
||||||
|
final remaining = (status.nextRunInSeconds! - elapsed).clamp(
|
||||||
|
0.0,
|
||||||
|
double.infinity,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
color: neutralColor,
|
||||||
|
label: 'Waiting for next run',
|
||||||
|
sublabels: ['Next run in ${formatSeconds(remaining)}', ?lastScan],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (color: neutralColor, label: 'Not started', sublabels: []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the display status for a passive (listening) scanner — mDNS, SSDP,
|
||||||
|
/// DHCP.
|
||||||
|
ScannerStatus resolvePassiveScanner(
|
||||||
|
BuildContext context,
|
||||||
|
PassiveScannerStatus status,
|
||||||
|
double elapsed,
|
||||||
|
) {
|
||||||
|
if (!status.isListening) {
|
||||||
|
return (
|
||||||
|
color: Theme.of(context).colorScheme.outline,
|
||||||
|
label: 'Not started',
|
||||||
|
sublabels: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final sublabels = <String>[
|
||||||
|
status.listeningForSeconds != null
|
||||||
|
? 'Listening for ${formatSeconds(status.listeningForSeconds! + elapsed)} · ${status.devicesSeen} devices in the last hour'
|
||||||
|
: '${status.devicesSeen} devices in the last hour',
|
||||||
|
if (status.lastDeviceSeenSecondsAgo != null)
|
||||||
|
'Last device ${formatSeconds(status.lastDeviceSeenSecondsAgo! + elapsed)} ago',
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
color: Theme.of(context).extension<AppColorExtension>()!.success,
|
||||||
|
label: 'Listening',
|
||||||
|
sublabels: sublabels,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The scanner status detail cards, in display order. Each is a thin
|
||||||
|
/// configuration over the generic [ScannerStatusCard]: a title, the endpoint to
|
||||||
|
/// poll, and the resolver for its shape.
|
||||||
|
List<Widget> scannerStatusCards() => [
|
||||||
|
ScannerStatusCard<ActiveScannerStatus>(
|
||||||
|
title: 'ARP Scanner',
|
||||||
|
fetch: ({cancelToken}) =>
|
||||||
|
BackendAPI.instance.getArpScannerStatus(cancelToken: cancelToken),
|
||||||
|
resolver: resolveActiveScanner,
|
||||||
|
),
|
||||||
|
ScannerStatusCard<PassiveScannerStatus>(
|
||||||
|
title: 'mDNS Scanner',
|
||||||
|
fetch: ({cancelToken}) =>
|
||||||
|
BackendAPI.instance.getMdnsScannerStatus(cancelToken: cancelToken),
|
||||||
|
resolver: resolvePassiveScanner,
|
||||||
|
),
|
||||||
|
ScannerStatusCard<PassiveScannerStatus>(
|
||||||
|
title: 'SSDP/UPnP Scanner',
|
||||||
|
fetch: ({cancelToken}) =>
|
||||||
|
BackendAPI.instance.getSsdpScannerStatus(cancelToken: cancelToken),
|
||||||
|
resolver: resolvePassiveScanner,
|
||||||
|
),
|
||||||
|
ScannerStatusCard<PassiveScannerStatus>(
|
||||||
|
title: 'DHCP Scanner',
|
||||||
|
fetch: ({cancelToken}) =>
|
||||||
|
BackendAPI.instance.getDhcpScannerStatus(cancelToken: cancelToken),
|
||||||
|
resolver: resolvePassiveScanner,
|
||||||
|
),
|
||||||
|
ScannerStatusCard<ActiveScannerStatus>(
|
||||||
|
title: 'SNMP Scanner',
|
||||||
|
fetch: ({cancelToken}) =>
|
||||||
|
BackendAPI.instance.getSnmpScannerStatus(cancelToken: cancelToken),
|
||||||
|
resolver: resolveActiveScanner,
|
||||||
|
),
|
||||||
|
];
|
||||||
@@ -1,21 +1,28 @@
|
|||||||
import 'dart:async';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../model/arp_scanner_status.dart';
|
import '../model/active_scanner_status.dart';
|
||||||
import '../model/dhcp_scanner_status.dart';
|
import '../model/passive_scanner_status.dart';
|
||||||
import '../model/mdns_scanner_status.dart';
|
|
||||||
import '../model/snmp_scanner_status.dart';
|
|
||||||
import '../model/ssdp_scanner_status.dart';
|
|
||||||
import '../navigation.dart';
|
import '../navigation.dart';
|
||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
import '../utils/backend_reachability.dart';
|
import '../utils/backend_reachability.dart';
|
||||||
import '../utils/duration_formatter.dart';
|
import '../utils/duration_formatter.dart';
|
||||||
import '../utils/oott_api.dart';
|
import '../utils/oott_api.dart';
|
||||||
|
import '../utils/periodic_rebuild.dart';
|
||||||
import '../utils/polled_value.dart';
|
import '../utils/polled_value.dart';
|
||||||
import 'polled_stale_indicator.dart';
|
import 'polled_stale_indicator.dart';
|
||||||
|
|
||||||
|
/// One scanner shown in the combined card: its display name, the polled status,
|
||||||
|
/// and how to turn the current value into a (colour, one-line text) summary.
|
||||||
|
class _Scanner {
|
||||||
|
final String name;
|
||||||
|
final PolledValue polled;
|
||||||
|
final (Color, String) Function(BuildContext, PolledFreshness) resolve;
|
||||||
|
|
||||||
|
const _Scanner(this.name, this.polled, this.resolve);
|
||||||
|
}
|
||||||
|
|
||||||
class ScannersStatusCard extends StatefulWidget {
|
class ScannersStatusCard extends StatefulWidget {
|
||||||
const ScannersStatusCard({super.key});
|
const ScannersStatusCard({super.key});
|
||||||
|
|
||||||
@@ -24,51 +31,42 @@ class ScannersStatusCard extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ScannersStatusCardState extends State<ScannersStatusCard>
|
class _ScannersStatusCardState extends State<ScannersStatusCard>
|
||||||
with RouteAware, WidgetsBindingObserver {
|
with
|
||||||
late final PolledValue<ArpScannerStatus> _arp;
|
RouteAware,
|
||||||
late final PolledValue<MdnsScannerStatus> _mdns;
|
WidgetsBindingObserver,
|
||||||
late final PolledValue<SsdpScannerStatus> _ssdp;
|
PeriodicRebuild<ScannersStatusCard> {
|
||||||
late final PolledValue<DhcpScannerStatus> _dhcp;
|
late final List<_Scanner> _scanners;
|
||||||
late final PolledValue<SnmpScannerStatus> _snmp;
|
|
||||||
Timer? _tickTimer;
|
PolledValue<T> _poll<T>(
|
||||||
|
Future<T> Function({CancelToken? cancelToken}) fetch,
|
||||||
|
) => PolledValue<T>(
|
||||||
|
fetch: fetch,
|
||||||
|
pollInterval: const Duration(seconds: 5),
|
||||||
|
staleErrorAfter: const Duration(seconds: 30),
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_arp = PolledValue<ArpScannerStatus>(
|
final api = BackendAPI.instance;
|
||||||
fetch: ({cancelToken}) =>
|
final arp = _poll(api.getArpScannerStatus);
|
||||||
BackendAPI.instance.getArpScannerStatus(cancelToken: cancelToken),
|
final mdns = _poll(api.getMdnsScannerStatus);
|
||||||
pollInterval: const Duration(seconds: 5),
|
final ssdp = _poll(api.getSsdpScannerStatus);
|
||||||
staleErrorAfter: const Duration(seconds: 30),
|
final dhcp = _poll(api.getDhcpScannerStatus);
|
||||||
);
|
final snmp = _poll(api.getSnmpScannerStatus);
|
||||||
_mdns = PolledValue<MdnsScannerStatus>(
|
_scanners = [
|
||||||
fetch: ({cancelToken}) =>
|
_Scanner('ARP', arp, (c, f) => _resolve(c, arp, f, _activeStatus)),
|
||||||
BackendAPI.instance.getMdnsScannerStatus(cancelToken: cancelToken),
|
_Scanner('mDNS', mdns, (c, f) => _resolve(c, mdns, f, _passiveStatus)),
|
||||||
pollInterval: const Duration(seconds: 5),
|
_Scanner(
|
||||||
staleErrorAfter: const Duration(seconds: 30),
|
'SSDP/UPnP',
|
||||||
);
|
ssdp,
|
||||||
_ssdp = PolledValue<SsdpScannerStatus>(
|
(c, f) => _resolve(c, ssdp, f, _passiveStatus),
|
||||||
fetch: ({cancelToken}) =>
|
),
|
||||||
BackendAPI.instance.getSsdpScannerStatus(cancelToken: cancelToken),
|
_Scanner('DHCP', dhcp, (c, f) => _resolve(c, dhcp, f, _passiveStatus)),
|
||||||
pollInterval: const Duration(seconds: 5),
|
_Scanner('SNMP', snmp, (c, f) => _resolve(c, snmp, f, _activeStatus)),
|
||||||
staleErrorAfter: const Duration(seconds: 30),
|
];
|
||||||
);
|
startRebuildTicker();
|
||||||
_dhcp = PolledValue<DhcpScannerStatus>(
|
|
||||||
fetch: ({cancelToken}) =>
|
|
||||||
BackendAPI.instance.getDhcpScannerStatus(cancelToken: cancelToken),
|
|
||||||
pollInterval: const Duration(seconds: 5),
|
|
||||||
staleErrorAfter: const Duration(seconds: 30),
|
|
||||||
);
|
|
||||||
_snmp = PolledValue<SnmpScannerStatus>(
|
|
||||||
fetch: ({cancelToken}) =>
|
|
||||||
BackendAPI.instance.getSnmpScannerStatus(cancelToken: cancelToken),
|
|
||||||
pollInterval: const Duration(seconds: 5),
|
|
||||||
staleErrorAfter: const Duration(seconds: 30),
|
|
||||||
);
|
|
||||||
_tickTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
|
||||||
if (mounted) setState(() {});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -100,36 +98,26 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _pausePolling() {
|
void _pausePolling() {
|
||||||
_arp.pause();
|
for (final scanner in _scanners) {
|
||||||
_mdns.pause();
|
scanner.polled.pause();
|
||||||
_ssdp.pause();
|
}
|
||||||
_dhcp.pause();
|
stopRebuildTicker();
|
||||||
_snmp.pause();
|
|
||||||
_tickTimer?.cancel();
|
|
||||||
_tickTimer = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _resumePolling() {
|
void _resumePolling() {
|
||||||
_arp.resume();
|
for (final scanner in _scanners) {
|
||||||
_mdns.resume();
|
scanner.polled.resume();
|
||||||
_ssdp.resume();
|
}
|
||||||
_dhcp.resume();
|
startRebuildTicker();
|
||||||
_snmp.resume();
|
|
||||||
_tickTimer ??= Timer.periodic(const Duration(seconds: 1), (_) {
|
|
||||||
if (mounted) setState(() {});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
routeObserver.unsubscribe(this);
|
routeObserver.unsubscribe(this);
|
||||||
_tickTimer?.cancel();
|
for (final scanner in _scanners) {
|
||||||
_arp.dispose();
|
scanner.polled.dispose();
|
||||||
_mdns.dispose();
|
}
|
||||||
_ssdp.dispose();
|
|
||||||
_dhcp.dispose();
|
|
||||||
_snmp.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,24 +125,15 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListenableBuilder(
|
return ListenableBuilder(
|
||||||
listenable: Listenable.merge([
|
listenable: Listenable.merge([
|
||||||
_arp,
|
..._scanners.map((s) => s.polled),
|
||||||
_mdns,
|
|
||||||
_ssdp,
|
|
||||||
_dhcp,
|
|
||||||
_snmp,
|
|
||||||
BackendReachability.instance,
|
BackendReachability.instance,
|
||||||
]),
|
]),
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
final arpFreshness = effectiveFreshness(_arp);
|
final freshness = {
|
||||||
final mdnsFreshness = effectiveFreshness(_mdns);
|
for (final scanner in _scanners)
|
||||||
final ssdpFreshness = effectiveFreshness(_ssdp);
|
scanner: effectiveFreshness(scanner.polled),
|
||||||
final dhcpFreshness = effectiveFreshness(_dhcp);
|
};
|
||||||
final snmpFreshness = effectiveFreshness(_snmp);
|
if (freshness.values.any((f) => f == PolledFreshness.initialLoading)) {
|
||||||
if (arpFreshness == PolledFreshness.initialLoading ||
|
|
||||||
mdnsFreshness == PolledFreshness.initialLoading ||
|
|
||||||
ssdpFreshness == PolledFreshness.initialLoading ||
|
|
||||||
dhcpFreshness == PolledFreshness.initialLoading ||
|
|
||||||
snmpFreshness == PolledFreshness.initialLoading) {
|
|
||||||
return const Card(
|
return const Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.all(16),
|
padding: EdgeInsets.all(16),
|
||||||
@@ -163,12 +142,6 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final (arpColor, arpText) = _resolveArp(context, arpFreshness);
|
|
||||||
final (mdnsColor, mdnsText) = _resolveMdns(context, mdnsFreshness);
|
|
||||||
final (ssdpColor, ssdpText) = _resolveSsdp(context, ssdpFreshness);
|
|
||||||
final (dhcpColor, dhcpText) = _resolveDhcp(context, dhcpFreshness);
|
|
||||||
final (snmpColor, snmpText) = _resolveSnmp(context, snmpFreshness);
|
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
@@ -183,50 +156,14 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
|
|||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_scannerRow(
|
for (var i = 0; i < _scanners.length; i++) ...[
|
||||||
context,
|
if (i > 0) const SizedBox(height: 8),
|
||||||
arpColor,
|
_scannerRow(
|
||||||
'ARP',
|
context,
|
||||||
arpText,
|
_scanners[i],
|
||||||
_arp,
|
freshness[_scanners[i]]!,
|
||||||
arpFreshness,
|
),
|
||||||
),
|
],
|
||||||
const SizedBox(height: 8),
|
|
||||||
_scannerRow(
|
|
||||||
context,
|
|
||||||
mdnsColor,
|
|
||||||
'mDNS',
|
|
||||||
mdnsText,
|
|
||||||
_mdns,
|
|
||||||
mdnsFreshness,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_scannerRow(
|
|
||||||
context,
|
|
||||||
ssdpColor,
|
|
||||||
'SSDP/UPnP',
|
|
||||||
ssdpText,
|
|
||||||
_ssdp,
|
|
||||||
ssdpFreshness,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_scannerRow(
|
|
||||||
context,
|
|
||||||
dhcpColor,
|
|
||||||
'DHCP',
|
|
||||||
dhcpText,
|
|
||||||
_dhcp,
|
|
||||||
dhcpFreshness,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_scannerRow(
|
|
||||||
context,
|
|
||||||
snmpColor,
|
|
||||||
'SNMP',
|
|
||||||
snmpText,
|
|
||||||
_snmp,
|
|
||||||
snmpFreshness,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -238,15 +175,16 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
|
|||||||
|
|
||||||
Widget _scannerRow(
|
Widget _scannerRow(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Color color,
|
_Scanner scanner,
|
||||||
String name,
|
|
||||||
String statusText,
|
|
||||||
PolledValue polled,
|
|
||||||
PolledFreshness freshness,
|
PolledFreshness freshness,
|
||||||
) {
|
) {
|
||||||
|
final (color, statusText) = scanner.resolve(context, freshness);
|
||||||
Widget dot = Icon(Icons.circle, color: color, size: 12);
|
Widget dot = Icon(Icons.circle, color: color, size: 12);
|
||||||
if (freshness == PolledFreshness.error) {
|
if (freshness == PolledFreshness.error) {
|
||||||
dot = Tooltip(message: polled.lastErrorMessage ?? 'Error', child: dot);
|
dot = Tooltip(
|
||||||
|
message: scanner.polled.lastErrorMessage ?? 'Error',
|
||||||
|
child: dot,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -255,10 +193,10 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Text(name, style: Theme.of(context).textTheme.bodyMedium),
|
Text(scanner.name, style: Theme.of(context).textTheme.bodyMedium),
|
||||||
if (freshness == PolledFreshness.stale) ...[
|
if (freshness == PolledFreshness.stale) ...[
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
PolledStaleIndicator(polled: polled),
|
PolledStaleIndicator(polled: scanner.polled),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -274,18 +212,32 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
(Color, String) _resolveArp(BuildContext context, PolledFreshness freshness) {
|
/// Wraps the shape-specific [compute] with the shared error handling and
|
||||||
|
/// elapsed-time calculation common to every scanner row.
|
||||||
|
(Color, String) _resolve<T>(
|
||||||
|
BuildContext context,
|
||||||
|
PolledValue<T> polled,
|
||||||
|
PolledFreshness freshness,
|
||||||
|
(Color, String) Function(BuildContext, T, double) compute,
|
||||||
|
) {
|
||||||
|
if (freshness == PolledFreshness.error) {
|
||||||
|
return (Theme.of(context).colorScheme.error, 'Error');
|
||||||
|
}
|
||||||
|
final elapsed = DateTime.now()
|
||||||
|
.difference(polled.lastSuccessAt!)
|
||||||
|
.inSeconds
|
||||||
|
.toDouble();
|
||||||
|
return compute(context, polled.value as T, elapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
(Color, String) _activeStatus(
|
||||||
|
BuildContext context,
|
||||||
|
ActiveScannerStatus status,
|
||||||
|
double elapsed,
|
||||||
|
) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final successColor = theme.extension<AppColorExtension>()!.success;
|
final successColor = theme.extension<AppColorExtension>()!.success;
|
||||||
final neutralColor = theme.colorScheme.outline;
|
final neutralColor = theme.colorScheme.outline;
|
||||||
if (freshness == PolledFreshness.error) {
|
|
||||||
return (theme.colorScheme.error, 'Error');
|
|
||||||
}
|
|
||||||
final status = _arp.value!;
|
|
||||||
final elapsed = DateTime.now()
|
|
||||||
.difference(_arp.lastSuccessAt!)
|
|
||||||
.inSeconds
|
|
||||||
.toDouble();
|
|
||||||
if (status.isRunning) {
|
if (status.isRunning) {
|
||||||
final secs = (status.runningForSeconds ?? 0) + elapsed;
|
final secs = (status.runningForSeconds ?? 0) + elapsed;
|
||||||
return (successColor, 'Running for ${formatSeconds(secs)}');
|
return (successColor, 'Running for ${formatSeconds(secs)}');
|
||||||
@@ -300,21 +252,14 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
|
|||||||
return (neutralColor, 'Not started');
|
return (neutralColor, 'Not started');
|
||||||
}
|
}
|
||||||
|
|
||||||
(Color, String) _resolveMdns(
|
(Color, String) _passiveStatus(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
PolledFreshness freshness,
|
PassiveScannerStatus status,
|
||||||
|
double elapsed,
|
||||||
) {
|
) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final successColor = theme.extension<AppColorExtension>()!.success;
|
final successColor = theme.extension<AppColorExtension>()!.success;
|
||||||
final neutralColor = theme.colorScheme.outline;
|
final neutralColor = theme.colorScheme.outline;
|
||||||
if (freshness == PolledFreshness.error) {
|
|
||||||
return (theme.colorScheme.error, 'Error');
|
|
||||||
}
|
|
||||||
final status = _mdns.value!;
|
|
||||||
final elapsed = DateTime.now()
|
|
||||||
.difference(_mdns.lastSuccessAt!)
|
|
||||||
.inSeconds
|
|
||||||
.toDouble();
|
|
||||||
if (status.isListening) {
|
if (status.isListening) {
|
||||||
if (status.lastDeviceSeenSecondsAgo != null) {
|
if (status.lastDeviceSeenSecondsAgo != null) {
|
||||||
final secs = status.lastDeviceSeenSecondsAgo! + elapsed;
|
final secs = status.lastDeviceSeenSecondsAgo! + elapsed;
|
||||||
@@ -324,83 +269,4 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
|
|||||||
}
|
}
|
||||||
return (neutralColor, 'Not started');
|
return (neutralColor, 'Not started');
|
||||||
}
|
}
|
||||||
|
|
||||||
(Color, String) _resolveSsdp(
|
|
||||||
BuildContext context,
|
|
||||||
PolledFreshness freshness,
|
|
||||||
) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final successColor = theme.extension<AppColorExtension>()!.success;
|
|
||||||
final neutralColor = theme.colorScheme.outline;
|
|
||||||
if (freshness == PolledFreshness.error) {
|
|
||||||
return (theme.colorScheme.error, 'Error');
|
|
||||||
}
|
|
||||||
final status = _ssdp.value!;
|
|
||||||
final elapsed = DateTime.now()
|
|
||||||
.difference(_ssdp.lastSuccessAt!)
|
|
||||||
.inSeconds
|
|
||||||
.toDouble();
|
|
||||||
if (status.isListening) {
|
|
||||||
if (status.lastDeviceSeenSecondsAgo != null) {
|
|
||||||
final secs = status.lastDeviceSeenSecondsAgo! + elapsed;
|
|
||||||
return (successColor, 'Last device seen ${formatSeconds(secs)} ago');
|
|
||||||
}
|
|
||||||
return (successColor, 'No devices seen yet');
|
|
||||||
}
|
|
||||||
return (neutralColor, 'Not started');
|
|
||||||
}
|
|
||||||
|
|
||||||
(Color, String) _resolveDhcp(
|
|
||||||
BuildContext context,
|
|
||||||
PolledFreshness freshness,
|
|
||||||
) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final successColor = theme.extension<AppColorExtension>()!.success;
|
|
||||||
final neutralColor = theme.colorScheme.outline;
|
|
||||||
if (freshness == PolledFreshness.error) {
|
|
||||||
return (theme.colorScheme.error, 'Error');
|
|
||||||
}
|
|
||||||
final status = _dhcp.value!;
|
|
||||||
final elapsed = DateTime.now()
|
|
||||||
.difference(_dhcp.lastSuccessAt!)
|
|
||||||
.inSeconds
|
|
||||||
.toDouble();
|
|
||||||
if (status.isListening) {
|
|
||||||
if (status.lastDeviceSeenSecondsAgo != null) {
|
|
||||||
final secs = status.lastDeviceSeenSecondsAgo! + elapsed;
|
|
||||||
return (successColor, 'Last device seen ${formatSeconds(secs)} ago');
|
|
||||||
}
|
|
||||||
return (successColor, 'No devices seen yet');
|
|
||||||
}
|
|
||||||
return (neutralColor, 'Not started');
|
|
||||||
}
|
|
||||||
|
|
||||||
(Color, String) _resolveSnmp(
|
|
||||||
BuildContext context,
|
|
||||||
PolledFreshness freshness,
|
|
||||||
) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final successColor = theme.extension<AppColorExtension>()!.success;
|
|
||||||
final neutralColor = theme.colorScheme.outline;
|
|
||||||
if (freshness == PolledFreshness.error) {
|
|
||||||
return (theme.colorScheme.error, 'Error');
|
|
||||||
}
|
|
||||||
final status = _snmp.value!;
|
|
||||||
final elapsed = DateTime.now()
|
|
||||||
.difference(_snmp.lastSuccessAt!)
|
|
||||||
.inSeconds
|
|
||||||
.toDouble();
|
|
||||||
if (status.isRunning) {
|
|
||||||
final secs = (status.runningForSeconds ?? 0) + elapsed;
|
|
||||||
return (successColor, 'Running for ${formatSeconds(secs)}');
|
|
||||||
}
|
|
||||||
if (status.nextRunInSeconds != null) {
|
|
||||||
final remaining = (status.nextRunInSeconds! - elapsed).clamp(
|
|
||||||
0.0,
|
|
||||||
double.infinity,
|
|
||||||
);
|
|
||||||
return (neutralColor, 'Next run in ${formatSeconds(remaining)}');
|
|
||||||
}
|
|
||||||
return (neutralColor, 'Not started');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import '../model/snmp_scanner_status.dart';
|
|
||||||
import '../theme/app_colors.dart';
|
|
||||||
import '../utils/duration_formatter.dart';
|
|
||||||
import '../utils/oott_api.dart';
|
|
||||||
import 'scanner_status_card.dart';
|
|
||||||
|
|
||||||
class SnmpScannerCard extends StatelessWidget {
|
|
||||||
const SnmpScannerCard({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return ScannerStatusCard<SnmpScannerStatus>(
|
|
||||||
title: 'SNMP Scanner',
|
|
||||||
fetch: ({cancelToken}) =>
|
|
||||||
BackendAPI.instance.getSnmpScannerStatus(cancelToken: cancelToken),
|
|
||||||
resolver: _resolve,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static ScannerStatus _resolve(
|
|
||||||
BuildContext context,
|
|
||||||
SnmpScannerStatus status,
|
|
||||||
double elapsed,
|
|
||||||
) {
|
|
||||||
final successColor = Theme.of(
|
|
||||||
context,
|
|
||||||
).extension<AppColorExtension>()!.success;
|
|
||||||
final neutralColor = Theme.of(context).colorScheme.outline;
|
|
||||||
final lastScan = status.lastScanDevicesSeen != null
|
|
||||||
? '${status.lastScanDevicesSeen} devices on last scan'
|
|
||||||
: null;
|
|
||||||
if (status.isRunning) {
|
|
||||||
final sublabels = <String>[
|
|
||||||
if (status.runningForSeconds != null)
|
|
||||||
'Running for ${formatSeconds(status.runningForSeconds! + elapsed)}',
|
|
||||||
?lastScan,
|
|
||||||
];
|
|
||||||
return (color: successColor, label: 'Running', sublabels: sublabels);
|
|
||||||
}
|
|
||||||
if (status.nextRunInSeconds != null) {
|
|
||||||
final remaining = (status.nextRunInSeconds! - elapsed).clamp(
|
|
||||||
0.0,
|
|
||||||
double.infinity,
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
color: neutralColor,
|
|
||||||
label: 'Waiting for next run',
|
|
||||||
sublabels: ['Next run in ${formatSeconds(remaining)}', ?lastScan],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (color: neutralColor, label: 'Not started', sublabels: []);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import '../model/ssdp_scanner_status.dart';
|
|
||||||
import '../theme/app_colors.dart';
|
|
||||||
import '../utils/duration_formatter.dart';
|
|
||||||
import '../utils/oott_api.dart';
|
|
||||||
import 'scanner_status_card.dart';
|
|
||||||
|
|
||||||
class SsdpScannerCard extends StatelessWidget {
|
|
||||||
const SsdpScannerCard({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return ScannerStatusCard<SsdpScannerStatus>(
|
|
||||||
title: 'SSDP/UPnP Scanner',
|
|
||||||
fetch: ({cancelToken}) =>
|
|
||||||
BackendAPI.instance.getSsdpScannerStatus(cancelToken: cancelToken),
|
|
||||||
resolver: _resolve,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static ScannerStatus _resolve(
|
|
||||||
BuildContext context,
|
|
||||||
SsdpScannerStatus status,
|
|
||||||
double elapsed,
|
|
||||||
) {
|
|
||||||
if (!status.isListening) {
|
|
||||||
return (
|
|
||||||
color: Theme.of(context).colorScheme.outline,
|
|
||||||
label: 'Not started',
|
|
||||||
sublabels: [],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final sublabels = <String>[
|
|
||||||
status.listeningForSeconds != null
|
|
||||||
? 'Listening for ${formatSeconds(status.listeningForSeconds! + elapsed)} · ${status.devicesSeen} devices in the last hour'
|
|
||||||
: '${status.devicesSeen} devices in the last hour',
|
|
||||||
if (status.lastDeviceSeenSecondsAgo != null)
|
|
||||||
'Last device ${formatSeconds(status.lastDeviceSeenSecondsAgo! + elapsed)} ago',
|
|
||||||
];
|
|
||||||
return (
|
|
||||||
color: Theme.of(context).extension<AppColorExtension>()!.success,
|
|
||||||
label: 'Listening',
|
|
||||||
sublabels: sublabels,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,13 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:frontend/model/arp_scanner_status.dart';
|
import 'package:frontend/model/active_scanner_status.dart';
|
||||||
import 'package:frontend/model/dhcp_scanner_status.dart';
|
import 'package:frontend/model/passive_scanner_status.dart';
|
||||||
import 'package:frontend/model/mdns_scanner_status.dart';
|
|
||||||
import 'package:frontend/model/snmp_scanner_status.dart';
|
|
||||||
import 'package:frontend/model/ssdp_scanner_status.dart';
|
|
||||||
|
|
||||||
import '../helpers/fixtures.dart';
|
import '../helpers/fixtures.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('interval scanners (ARP, SNMP)', () {
|
group('active scanners (ARP, SNMP)', () {
|
||||||
test('ArpScannerStatus.fromJson maps populated fields', () {
|
test('ActiveScannerStatus.fromJson maps populated fields', () {
|
||||||
final status = ArpScannerStatus.fromJson(
|
final status = ActiveScannerStatus.fromJson(
|
||||||
intervalScannerJson(
|
intervalScannerJson(
|
||||||
isRunning: true,
|
isRunning: true,
|
||||||
runningForSeconds: 12.5,
|
runningForSeconds: 12.5,
|
||||||
@@ -27,28 +24,19 @@ void main() {
|
|||||||
expect(status.lastScanSecondsAgo, 5.0);
|
expect(status.lastScanSecondsAgo, 5.0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ArpScannerStatus.fromJson tolerates null optionals', () {
|
test('ActiveScannerStatus.fromJson tolerates null optionals', () {
|
||||||
final status = ArpScannerStatus.fromJson(intervalScannerJson());
|
final status = ActiveScannerStatus.fromJson(intervalScannerJson());
|
||||||
|
|
||||||
expect(status.isRunning, isFalse);
|
expect(status.isRunning, isFalse);
|
||||||
expect(status.runningForSeconds, isNull);
|
expect(status.runningForSeconds, isNull);
|
||||||
expect(status.nextRunInSeconds, isNull);
|
expect(status.nextRunInSeconds, isNull);
|
||||||
expect(status.lastScanDevicesSeen, isNull);
|
expect(status.lastScanDevicesSeen, isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('SnmpScannerStatus.fromJson maps fields', () {
|
|
||||||
final status = SnmpScannerStatus.fromJson(
|
|
||||||
intervalScannerJson(isRunning: true, runningForSeconds: 3),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(status.isRunning, isTrue);
|
|
||||||
expect(status.runningForSeconds, 3.0);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
group('listener scanners (mDNS, SSDP, DHCP)', () {
|
group('passive scanners (mDNS, SSDP, DHCP)', () {
|
||||||
test('MdnsScannerStatus.fromJson maps populated fields', () {
|
test('PassiveScannerStatus.fromJson maps populated fields', () {
|
||||||
final status = MdnsScannerStatus.fromJson(
|
final status = PassiveScannerStatus.fromJson(
|
||||||
listenerScannerJson(
|
listenerScannerJson(
|
||||||
isListening: true,
|
isListening: true,
|
||||||
listeningForSeconds: 99.0,
|
listeningForSeconds: 99.0,
|
||||||
@@ -63,9 +51,9 @@ void main() {
|
|||||||
expect(status.lastDeviceSeenSecondsAgo, 8.0);
|
expect(status.lastDeviceSeenSecondsAgo, 8.0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('SsdpScannerStatus.fromJson requires devicesSeen and tolerates nulls',
|
test('PassiveScannerStatus.fromJson requires devicesSeen and tolerates '
|
||||||
() {
|
'nulls', () {
|
||||||
final status = SsdpScannerStatus.fromJson(
|
final status = PassiveScannerStatus.fromJson(
|
||||||
listenerScannerJson(devicesSeen: 0),
|
listenerScannerJson(devicesSeen: 0),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -74,14 +62,5 @@ void main() {
|
|||||||
expect(status.listeningForSeconds, isNull);
|
expect(status.listeningForSeconds, isNull);
|
||||||
expect(status.lastDeviceSeenSecondsAgo, isNull);
|
expect(status.lastDeviceSeenSecondsAgo, isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('DhcpScannerStatus.fromJson maps fields', () {
|
|
||||||
final status = DhcpScannerStatus.fromJson(
|
|
||||||
listenerScannerJson(isListening: true, devicesSeen: 2),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(status.isListening, isTrue);
|
|
||||||
expect(status.devicesSeen, 2);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user