Files
oott/frontend/lib/home/notifications_list.dart
T
rzuastiandClaude Opus 4.7 7954a9c755 Add Dio timeouts and map backend errors to user-friendly messages
Set 5s/15s/15s connect/receive/send timeouts so calls fail fast instead of
hanging. Introduce dioErrorToUserMessage() and route DioException-throwing
call sites (device actions, devices list, notifications list, scanners
status card) through it, replacing raw e.toString() output. Notifications
list now surfaces load errors inline in the theme's error color, matching
the devices list pattern. Status dots show the mapped detail as a tooltip.
Stop logging the API key in cleartext.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 09:06:56 -04:00

310 lines
8.1 KiB
Dart

import 'dart:async';
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;
@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();
super.dispose();
}
Future<void> _fetchPage(int page) async {
if (_isLoading) return;
setState(() {
_isLoading = true;
_error = null;
});
try {
final results = await BackendAPI.instance.listNotifications(
_filter.isNew,
page * _pageSize,
limit: _pageSize + 1,
);
if (!mounted) return;
setState(() {
_currentPage = page;
_hasNextPage = results.length > _pageSize;
_items = _hasNextPage ? results.take(_pageSize).toList() : results;
_isLoading = false;
});
} catch (e) {
if (!mounted) 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',
),
],
),
),
);
}
}