mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
Rapidly switching filter chips, sort columns, or pagination on the devices and notifications lists could overlap fetches, and an older response landing after a newer one would silently overwrite the list with results that no longer match the active query. Thread an optional CancelToken into listDevices and listNotifications. Each State now keeps one active token, cancels it on every new fetch and on dispose, and bails from both success and catch branches when its token has been superseded. DioExceptionType.cancel is silenced so internal cancellations don't surface as user-visible errors. The notifications list's prior `if (_isLoading) return;` guard is removed so the 1-min periodic refresh is no longer skipped during user activity. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
317 lines
8.4 KiB
Dart
317 lines
8.4 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:dio/dio.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../model/notification.dart' as oott_model;
|
|
import '../navigation.dart';
|
|
import '../utils/friendly_date_formatter.dart';
|
|
import '../utils/oott_api.dart';
|
|
import '../utils/ui_snackbars.dart';
|
|
import 'notification_card.dart';
|
|
|
|
const _pageSize = 5;
|
|
|
|
enum _NotificationFilter {
|
|
newOnly('New'),
|
|
oldOnly('Old'),
|
|
all('All');
|
|
|
|
const _NotificationFilter(this.label);
|
|
|
|
final String label;
|
|
|
|
bool? get isNew => switch (this) {
|
|
_NotificationFilter.newOnly => true,
|
|
_NotificationFilter.oldOnly => false,
|
|
_NotificationFilter.all => null,
|
|
};
|
|
}
|
|
|
|
class NotificationsList extends StatefulWidget {
|
|
const NotificationsList({super.key, this.trailingSlivers = const []});
|
|
|
|
final List<Widget> trailingSlivers;
|
|
|
|
@override
|
|
State<NotificationsList> createState() => _NotificationsListState();
|
|
}
|
|
|
|
class _NotificationsListState extends State<NotificationsList> with RouteAware {
|
|
_NotificationFilter _filter = _NotificationFilter.newOnly;
|
|
Timer? _notificationTimer;
|
|
|
|
int _currentPage = 0;
|
|
List<oott_model.Notification> _items = [];
|
|
bool _isLoading = false;
|
|
bool _hasNextPage = false;
|
|
String? _error;
|
|
CancelToken? _fetchToken;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_fetchPage(0);
|
|
_notificationTimer = Timer.periodic(
|
|
const Duration(minutes: 1),
|
|
(_) => _fetchPage(_currentPage),
|
|
);
|
|
}
|
|
|
|
@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);
|
|
_notificationTimer?.cancel();
|
|
_fetchToken?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _fetchPage(int page) async {
|
|
_fetchToken?.cancel();
|
|
final token = CancelToken();
|
|
_fetchToken = token;
|
|
setState(() {
|
|
_isLoading = true;
|
|
_error = null;
|
|
});
|
|
try {
|
|
final results = await BackendAPI.instance.listNotifications(
|
|
_filter.isNew,
|
|
page * _pageSize,
|
|
limit: _pageSize + 1,
|
|
cancelToken: token,
|
|
);
|
|
if (!mounted || token != _fetchToken) return;
|
|
setState(() {
|
|
_currentPage = page;
|
|
_hasNextPage = results.length > _pageSize;
|
|
_items = _hasNextPage ? results.take(_pageSize).toList() : results;
|
|
_isLoading = false;
|
|
});
|
|
} catch (e) {
|
|
if (!mounted || token != _fetchToken) return;
|
|
if (e is DioException && e.type == DioExceptionType.cancel) return;
|
|
setState(() {
|
|
_error = dioErrorToUserMessage(e);
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _markAllAsRead(BuildContext context) async {
|
|
await BackendAPI.instance.markAllNotificationsAsRead();
|
|
_fetchPage(_currentPage);
|
|
if (context.mounted) {
|
|
UISnackbars.showSuccess(context, 'All notifications marked as read');
|
|
}
|
|
}
|
|
|
|
Future<bool> _setRead(
|
|
BuildContext context,
|
|
oott_model.Notification item,
|
|
bool read,
|
|
) async {
|
|
if (item.isNew == !read) {
|
|
UISnackbars.showWarning(
|
|
context,
|
|
'Notification was already marked as ${read ? 'read' : 'unread'}',
|
|
);
|
|
return false;
|
|
}
|
|
if (read) {
|
|
await BackendAPI.instance.markNotificationAsRead(item.id);
|
|
} else {
|
|
await BackendAPI.instance.markNotificationAsNew(item.id);
|
|
}
|
|
if (!context.mounted) return false;
|
|
UISnackbars.showSuccess(
|
|
context,
|
|
'Event marked as ${read ? 'read' : 'unread'}',
|
|
);
|
|
if (_filter != _NotificationFilter.all) {
|
|
setState(() => _items = _items.where((n) => n.id != item.id).toList());
|
|
return true;
|
|
}
|
|
setState(
|
|
() => _items = _items
|
|
.map((n) => n.id == item.id ? n.copyWith(isNew: !read) : n)
|
|
.toList(),
|
|
);
|
|
return false;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_buildNotificationsHeader(context),
|
|
Expanded(
|
|
child: CustomScrollView(
|
|
slivers: [
|
|
..._buildNotificationSlivers(context),
|
|
...widget.trailingSlivers,
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildNotificationsHeader(BuildContext context) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Text(
|
|
'Notifications',
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
const Spacer(),
|
|
if (_filter == _NotificationFilter.newOnly && _items.isNotEmpty)
|
|
IconButton(
|
|
onPressed: () => _markAllAsRead(context),
|
|
icon: const Icon(Icons.done_all),
|
|
tooltip: 'Mark all as read',
|
|
),
|
|
],
|
|
),
|
|
SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
|
child: Wrap(
|
|
spacing: 8.0,
|
|
children: _NotificationFilter.values
|
|
.map(
|
|
(f) => ChoiceChip(
|
|
label: Text(f.label),
|
|
selected: _filter == f,
|
|
onSelected: (_) {
|
|
setState(() => _filter = f);
|
|
_fetchPage(0);
|
|
},
|
|
),
|
|
)
|
|
.toList(),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
List<Widget> _buildNotificationSlivers(BuildContext context) {
|
|
if (_isLoading) {
|
|
return [
|
|
const SliverFillRemaining(
|
|
child: Center(child: CircularProgressIndicator()),
|
|
),
|
|
];
|
|
}
|
|
if (_error != null) {
|
|
return [
|
|
SliverFillRemaining(
|
|
child: Center(
|
|
child: Text(
|
|
'Error: $_error',
|
|
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
|
),
|
|
),
|
|
),
|
|
];
|
|
}
|
|
if (_items.isEmpty) {
|
|
return [
|
|
SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(top: 16, bottom: 12),
|
|
child: Center(
|
|
child: Text(
|
|
'No items found',
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
];
|
|
}
|
|
return [
|
|
_buildNotificationSliver(),
|
|
if (_currentPage > 0 || _hasNextPage) _buildPaginationControls(context),
|
|
];
|
|
}
|
|
|
|
Widget _buildNotificationSliver() {
|
|
final formatter = FriendlyDateFormatter();
|
|
return SliverList.builder(
|
|
itemCount: _items.length,
|
|
itemBuilder: (context, index) {
|
|
final item = _items[index];
|
|
return NotificationCard(
|
|
item: item,
|
|
formatter: formatter,
|
|
onSetRead: (ctx, read) => _setRead(ctx, item, read),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildPaginationControls(BuildContext context) {
|
|
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',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|