From 2fa66ff9c6fb9c60fff30d6fefa1b2f18bb6b942 Mon Sep 17 00:00:00 2001 From: Pete Gregory Date: Fri, 2 Jan 2026 18:49:14 +0000 Subject: [PATCH] add admin page, fix no legs infinite load on dashboard --- android/app/build.gradle.kts | 2 +- lib/components/pages/dashboard.dart | 2 +- lib/components/pages/more.dart | 78 +-- lib/components/pages/more/admin_page.dart | 476 ++++++++++++++++++ lib/components/pages/more/more_home_page.dart | 53 ++ lib/components/traction/traction_card.dart | 1 - lib/objects/objects.dart | 66 ++- lib/services/authservice.dart | 67 +++ .../data_service_notifications.dart | 2 +- lib/ui/app_shell.dart | 31 ++ pubspec.yaml | 2 +- 11 files changed, 691 insertions(+), 89 deletions(-) create mode 100644 lib/components/pages/more/admin_page.dart create mode 100644 lib/components/pages/more/more_home_page.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 6ff2b6a..4c997c2 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -45,7 +45,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.petegregoryy.mileograph_flutter" + applicationId = "com.iwdac.mileograph_flutter" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion diff --git a/lib/components/pages/dashboard.dart b/lib/components/pages/dashboard.dart index 889d339..5f7df56 100644 --- a/lib/components/pages/dashboard.dart +++ b/lib/components/pages/dashboard.dart @@ -27,7 +27,7 @@ class _DashboardState extends State { final distanceUnits = context.watch(); final stats = data.homepageStats; - final isInitialLoading = data.isHomepageLoading || stats == null; + final isInitialLoading = data.isHomepageLoading && stats == null; return RefreshIndicator( onRefresh: () async { diff --git a/lib/components/pages/more.dart b/lib/components/pages/more.dart index ffae2e0..2abf9b6 100644 --- a/lib/components/pages/more.dart +++ b/lib/components/pages/more.dart @@ -1,83 +1,13 @@ import 'package:flutter/material.dart'; -import 'package:mileograph_flutter/components/pages/profile.dart'; -import 'package:mileograph_flutter/components/pages/settings.dart'; -import 'package:mileograph_flutter/components/pages/stats.dart'; +import 'package:mileograph_flutter/components/pages/more/more_home_page.dart'; + +export 'more/admin_page.dart'; class MorePage extends StatelessWidget { const MorePage({super.key}); @override Widget build(BuildContext context) { - return Navigator( - onGenerateRoute: (settings) { - final name = settings.name ?? '/'; - Widget page; - switch (name) { - case '/settings': - page = const SettingsPage(); - break; - case '/profile': - page = const ProfilePage(); - break; - case '/stats': - page = const StatsPage(); - break; - case '/more/settings': - page = const SettingsPage(); - break; - case '/more/profile': - page = const ProfilePage(); - break; - case '/more/stats': - page = const StatsPage(); - break; - case '/': - default: - page = _MoreHome(); - } - return MaterialPageRoute(builder: (_) => page, settings: settings); - }, - ); - } -} - -class _MoreHome extends StatelessWidget { - const _MoreHome({super.key}); - - @override - Widget build(BuildContext context) { - return ListView( - padding: const EdgeInsets.all(16), - children: [ - Text( - 'More', - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: 12), - Card( - child: Column( - children: [ - ListTile( - leading: const Icon(Icons.emoji_events), - title: const Text('Badges'), - onTap: () => Navigator.of(context).pushNamed('/more/profile'), - ), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.bar_chart), - title: const Text('Stats'), - onTap: () => Navigator.of(context).pushNamed('/more/stats'), - ), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.settings), - title: const Text('Settings'), - onTap: () => Navigator.of(context).pushNamed('/more/settings'), - ), - ], - ), - ), - ], - ); + return const MoreHomePage(); } } diff --git a/lib/components/pages/more/admin_page.dart b/lib/components/pages/more/admin_page.dart new file mode 100644 index 0000000..1070124 --- /dev/null +++ b/lib/components/pages/more/admin_page.dart @@ -0,0 +1,476 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:mileograph_flutter/objects/objects.dart'; +import 'package:mileograph_flutter/services/api_service.dart'; +import 'package:mileograph_flutter/services/authservice.dart'; + +class AdminPage extends StatefulWidget { + const AdminPage({super.key}); + + @override + State createState() => _AdminPageState(); +} + +class _AdminPageState extends State { + final TextEditingController _titleController = TextEditingController(); + final TextEditingController _bodyController = TextEditingController(); + + final List _selectedUsers = []; + List _userOptions = []; + + List _channels = []; + String? _selectedChannel; + String? _channelError; + bool _loadingChannels = false; + + String? _userError; + + bool _sending = false; + + @override + void initState() { + super.initState(); + _loadChannels(); + } + + @override + void dispose() { + _titleController.dispose(); + _bodyController.dispose(); + super.dispose(); + } + + Future _loadChannels() async { + setState(() { + _loadingChannels = true; + _channelError = null; + }); + try { + final api = context.read(); + final json = await api.get('/notifications/channels'); + List? list; + if (json is List) { + list = json; + } else if (json is Map) { + for (final key in ['channels', 'data']) { + final value = json[key]; + if (value is List) { + list = value; + break; + } + } + } + final parsed = + list?.map((e) => e.toString()).where((e) => e.isNotEmpty).toList() ?? + const []; + setState(() { + _channels = parsed; + _selectedChannel = parsed.isNotEmpty ? parsed.first : null; + }); + } catch (e) { + setState(() { + _channelError = 'Failed to load channels'; + }); + } finally { + if (mounted) setState(() => _loadingChannels = false); + } + } + + Future> _fetchUserSuggestions( + ApiService api, + String query, + ) async { + final encoded = Uri.encodeComponent(query); + final candidates = [ + '/users/search?q=$encoded', + '/users/search?query=$encoded', + '/users?search=$encoded', + ]; + for (final path in candidates) { + try { + final json = await api.get(path); + List? list; + if (json is List) { + list = json; + } else if (json is Map) { + for (final key in ['users', 'data', 'results', 'items']) { + final value = json[key]; + if (value is List) { + list = value; + break; + } + } + } + if (list != null) { + return list + .whereType() + .map((e) => UserSummary.fromJson( + e.map((k, v) => MapEntry(k.toString(), v)), + )) + .toList(); + } + } catch (_) { + // Try next endpoint + } + } + return const []; + } + + void _removeUser(UserSummary user) { + setState(() { + _selectedUsers.removeWhere((u) => u.userId == user.userId); + }); + } + + Future _openUserPicker() async { + final api = context.read(); + var tempSelected = List.from(_selectedUsers); + var options = List.from(_userOptions); + String query = ''; + bool loading = false; + String? error = _userError; + + Future runSearch(String q, void Function(void Function()) setModalState) async { + setModalState(() { + query = q; + loading = true; + error = null; + }); + try { + final results = await _fetchUserSuggestions(api, q); + setModalState(() { + options = results; + loading = false; + error = null; + }); + if (mounted) { + setState(() { + _userOptions = results; + _userError = null; + }); + } + } catch (e) { + setModalState(() { + loading = false; + error = 'Failed to search users'; + }); + if (mounted) { + setState(() { + _userError = 'Failed to search users'; + }); + } + } + } + + var initialFetchTriggered = false; + + await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (ctx) { + return StatefulBuilder( + builder: (ctx, setModalState) { + if (!initialFetchTriggered && !loading && options.isEmpty) { + initialFetchTriggered = true; + WidgetsBinding.instance.addPostFrameCallback( + (_) => runSearch('', setModalState), + ); + } + final lowerQuery = query.toLowerCase(); + final filtered = lowerQuery.isEmpty + ? options + : options.where((u) { + return u.displayName.toLowerCase().contains(lowerQuery) || + u.username.toLowerCase().contains(lowerQuery) || + u.email.toLowerCase().contains(lowerQuery); + }).toList(); + return SafeArea( + child: Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: MediaQuery.of(ctx).viewInsets.bottom + 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Select recipients', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const Spacer(), + TextButton( + onPressed: () { + setModalState(() { + tempSelected.clear(); + }); + setState(() => _selectedUsers.clear()); + }, + child: const Text('Clear'), + ), + ], + ), + const SizedBox(height: 8), + TextField( + decoration: const InputDecoration( + labelText: 'Search users', + border: OutlineInputBorder(), + ), + onChanged: (val) => runSearch(val, setModalState), + ), + if (loading) + const Padding( + padding: EdgeInsets.only(top: 8.0), + child: LinearProgressIndicator(minHeight: 2), + ), + if (error != null) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + error!, + style: + TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + const SizedBox(height: 12), + SizedBox( + height: 340, + child: filtered.isEmpty + ? const Center(child: Text('No users yet.')) + : ListView.builder( + itemCount: filtered.length, + itemBuilder: (_, index) { + final user = filtered[index]; + final selected = + tempSelected.any((u) => u.userId == user.userId); + return CheckboxListTile( + value: selected, + title: Text(user.displayName), + subtitle: user.email.isNotEmpty + ? Text(user.email) + : (user.username.isNotEmpty + ? Text(user.username) + : null), + onChanged: (val) { + setModalState(() { + if (val == true) { + if (!tempSelected + .any((u) => u.userId == user.userId)) { + tempSelected.add(user); + } + } else { + tempSelected.removeWhere( + (u) => u.userId == user.userId); + } + }); + if (mounted) { + setState(() { + _selectedUsers + ..clear() + ..addAll(tempSelected); + }); + } + }, + ); + }, + ), + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Done'), + ), + ), + ], + ), + ), + ); + }, + ); + }, + ); + } + + Future _sendNotification() async { + final channel = _selectedChannel; + if (channel == null || channel.isEmpty) { + _showSnack('Select a channel first.'); + return; + } + if (_selectedUsers.isEmpty) { + _showSnack('Select at least one user.'); + return; + } + final title = _titleController.text.trim(); + final body = _bodyController.text.trim(); + if (title.isEmpty || body.isEmpty) { + _showSnack('Title and body are required.'); + return; + } + setState(() => _sending = true); + try { + final api = context.read(); + await api.post('/notifications/new', { + 'user_ids': _selectedUsers.map((e) => e.userId).toList(), + 'channel': channel, + 'title': title, + 'body': body, + }); + if (!mounted) return; + _showSnack('Notification sent'); + setState(() { + _selectedUsers.clear(); + _titleController.clear(); + _bodyController.clear(); + _userOptions.clear(); + }); + } catch (e) { + _showSnack('Failed to send: $e'); + } finally { + if (mounted) setState(() => _sending = false); + } + } + + void _showSnack(String message) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + } + + @override + Widget build(BuildContext context) { + final isAdmin = context.select((auth) => auth.isElevated); + if (!isAdmin) { + return const Scaffold( + body: Center(child: Text('You do not have access to this page.')), + ); + } + return Scaffold( + appBar: AppBar( + title: const Text('Admin'), + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + 'Send notification', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 12), + _buildUserPicker(), + const SizedBox(height: 12), + DropdownButtonFormField( + value: _selectedChannel, + decoration: const InputDecoration( + labelText: 'Channel', + border: OutlineInputBorder(), + ), + items: _channels + .map( + (c) => DropdownMenuItem( + value: c, + child: Text(c), + ), + ) + .toList(), + onChanged: + _loadingChannels ? null : (val) => setState(() => _selectedChannel = val), + ), + if (_loadingChannels) + const Padding( + padding: EdgeInsets.only(top: 8.0), + child: LinearProgressIndicator(minHeight: 2), + ), + if (_channelError != null) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + _channelError!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _titleController, + decoration: const InputDecoration( + labelText: 'Title', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _bodyController, + minLines: 3, + maxLines: 6, + decoration: const InputDecoration( + labelText: 'Body', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _sending ? null : _sendNotification, + icon: _sending + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.send), + label: Text(_sending ? 'Sending...' : 'Send notification'), + ), + ), + ], + ), + ); + } + + Widget _buildUserPicker() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Recipients', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: _selectedUsers + .map( + (u) => InputChip( + label: Text(u.displayName), + onDeleted: () => _removeUser(u), + ), + ) + .toList(), + ), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: _openUserPicker, + icon: const Icon(Icons.person_search), + label: const Text('Select users'), + ), + if (_userError != null) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + _userError!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ], + ); + } +} diff --git a/lib/components/pages/more/more_home_page.dart b/lib/components/pages/more/more_home_page.dart new file mode 100644 index 0000000..fab1856 --- /dev/null +++ b/lib/components/pages/more/more_home_page.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; +import 'package:mileograph_flutter/services/authservice.dart'; + +class MoreHomePage extends StatelessWidget { + const MoreHomePage({super.key}); + + @override + Widget build(BuildContext context) { + final isAdmin = context.select((auth) => auth.isElevated); + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + 'More', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 12), + Card( + child: Column( + children: [ + ListTile( + leading: const Icon(Icons.emoji_events), + title: const Text('Badges'), + onTap: () => context.go('/more/profile'), + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.bar_chart), + title: const Text('Stats'), + onTap: () => context.go('/more/stats'), + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.settings), + title: const Text('Settings'), + onTap: () => context.go('/more/settings'), + ), + if (isAdmin) const Divider(height: 1), + if (isAdmin) + ListTile( + leading: const Icon(Icons.admin_panel_settings), + title: const Text('Admin'), + onTap: () => context.go('/more/admin'), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/components/traction/traction_card.dart b/lib/components/traction/traction_card.dart index 4dd84b1..1f013b5 100644 --- a/lib/components/traction/traction_card.dart +++ b/lib/components/traction/traction_card.dart @@ -1,4 +1,3 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/api_service.dart'; diff --git a/lib/objects/objects.dart b/lib/objects/objects.dart index c7b999e..17d9442 100644 --- a/lib/objects/objects.dart +++ b/lib/objects/objects.dart @@ -104,24 +104,64 @@ class DestinationObject { } class UserData { - const UserData(this.username, this.fullName, this.userId, this.email); + const UserData({ + required this.username, + required this.fullName, + required this.userId, + required this.email, + bool? elevated, + bool? disabled, + }) : elevated = elevated ?? false, + disabled = disabled ?? false; final String userId; final String username; final String fullName; final String email; + final bool elevated; + final bool disabled; } class AuthenticatedUserData extends UserData { const AuthenticatedUserData({ - required String userId, - required String username, - required String fullName, - required String email, + required super.userId, + required super.username, + required super.fullName, + required super.email, + bool? elevated, + bool? isElevated, + bool? disabled, + bool? isDisabled, required this.accessToken, - }) : super(username, fullName, userId, email); + }) : super( + elevated: (elevated ?? false) || (isElevated ?? false), + disabled: (disabled ?? false) || (isDisabled ?? false), + ); final String accessToken; + bool get isElevated => elevated; +} + +class UserSummary extends UserData { + const UserSummary({ + required super.username, + required super.fullName, + required super.userId, + required super.email, + super.elevated = false, + super.disabled = false, + }); + + String get displayName => fullName.isNotEmpty ? fullName : username; + + factory UserSummary.fromJson(Map json) => UserSummary( + username: _asString(json['username'] ?? json['user_name']), + fullName: _asString(json['full_name'] ?? json['name']), + userId: _asString(json['user_id'] ?? json['id']), + email: _asString(json['email']), + elevated: _asBool(json['elevated'] ?? json['is_elevated'], false), + disabled: _asBool(json['disabled'], false), + ); } class HomepageStats { @@ -170,10 +210,13 @@ class HomepageStats { user: userData == null ? null : UserData( - userData['username'] ?? '', - userData['full_name'] ?? '', - userData['user_id'] ?? '', - userData['email'] ?? '', + username: userData['username'] ?? '', + fullName: userData['full_name'] ?? '', + userId: userData['user_id'] ?? '', + email: userData['email'] ?? '', + elevated: + _asBool(userData['elevated'] ?? userData['is_elevated'], false), + disabled: _asBool(userData['disabled'], false), ), ); } @@ -1179,6 +1222,7 @@ class UserNotification { final int id; final String title; final String body; + final String channel; final DateTime? createdAt; final bool dismissed; @@ -1186,6 +1230,7 @@ class UserNotification { required this.id, required this.title, required this.body, + required this.channel, required this.createdAt, required this.dismissed, }); @@ -1202,6 +1247,7 @@ class UserNotification { id: _asInt(json['notification_id'] ?? json['id']), title: _asString(json['title']), body: _asString(json['body']), + channel: _asString(json['channel']), createdAt: createdAt, dismissed: _asBool(json['dismissed'] ?? false, false), ); diff --git a/lib/services/authservice.dart b/lib/services/authservice.dart index dd31f5c..4cfa4fa 100644 --- a/lib/services/authservice.dart +++ b/lib/services/authservice.dart @@ -21,6 +21,9 @@ class AuthService extends ChangeNotifier { String? get userId => _user?.userId; String? get username => _user?.username; String? get fullName => _user?.fullName; + bool get isElevated => _user?.isElevated ?? false; + bool get isAdmin => isElevated; // alias for old name + bool get isDisabled => _user?.disabled ?? false; void setLoginData({ required String userId, @@ -28,6 +31,8 @@ class AuthService extends ChangeNotifier { required String fullName, required String accessToken, required String email, + bool isElevated = false, + bool isDisabled = false, }) { _user = AuthenticatedUserData( userId: userId, @@ -35,6 +40,8 @@ class AuthService extends ChangeNotifier { fullName: fullName, accessToken: accessToken, email: email, + isElevated: isElevated, + disabled: isDisabled, ); _persistToken(accessToken); notifyListeners(); @@ -70,6 +77,8 @@ class AuthService extends ChangeNotifier { fullName: userResponse['full_name'], accessToken: accessToken, email: userResponse['email'], + isElevated: _parseIsElevated(userResponse), + isDisabled: _parseIsDisabled(userResponse), ); } @@ -95,6 +104,8 @@ class AuthService extends ChangeNotifier { fullName: userResponse['full_name'], accessToken: token, email: userResponse['email'], + isElevated: _parseIsElevated(userResponse), + isDisabled: _parseIsDisabled(userResponse), ); } catch (_) { await _clearToken(); @@ -157,4 +168,60 @@ class AuthService extends ChangeNotifier { void logout() { handleTokenExpired(); // reuse } + + bool _parseIsElevated(Map json) { + dynamic value = json['is_elevated'] ?? + json['elevated'] ?? + json['is_admin'] ?? + json['admin'] ?? + json['isAdmin'] ?? + json['admin_user'] ?? + json['role'] ?? + json['roles'] ?? + json['permissions'] ?? + json['scopes'] ?? + json['is_staff'] ?? + json['staff'] ?? + json['is_superuser'] ?? + json['superuser'] ?? + json['groups']; + + bool parseBoolish(dynamic v) { + if (v is bool) return v; + if (v is num) return v != 0; + final str = v?.toString().toLowerCase().trim(); + if (str == null || str.isEmpty) return false; + if (['1', 'true', 'yes', 'y', 'admin', 'superuser', 'staff', 'elevated'].contains(str)) { + return true; + } + return str.contains('admin') || str.contains('superuser') || str.contains('staff'); + } + + if (value is List) { + for (final entry in value) { + if (parseBoolish(entry)) return true; + final s = entry?.toString().toLowerCase(); + if (s != null && + (s.contains('admin') || + s.contains('superuser') || + s.contains('staff') || + s.contains('elevated') || + s == 'root')) { + return true; + } + } + return false; + } + + return parseBoolish(value); + } + + bool _parseIsDisabled(Map json) { + dynamic value = json['disabled'] ?? json['is_disabled']; + if (value is bool) return value; + if (value is num) return value != 0; + final str = value?.toString().toLowerCase().trim(); + if (str == null || str.isEmpty) return false; + return ['1', 'true', 'yes', 'y', 'disabled'].contains(str); + } } diff --git a/lib/services/data_service/data_service_notifications.dart b/lib/services/data_service/data_service_notifications.dart index d146294..bb5df4f 100644 --- a/lib/services/data_service/data_service_notifications.dart +++ b/lib/services/data_service/data_service_notifications.dart @@ -20,7 +20,7 @@ extension DataServiceNotifications on DataService { final parsed = list ?.whereType>() .map(UserNotification.fromJson) - .where((n) => !n.dismissed) + .where((n) => !n.dismissed && n.channel.toLowerCase() != 'web') .toList(); if (parsed != null) { diff --git a/lib/ui/app_shell.dart b/lib/ui/app_shell.dart index b4a28a0..a9e5f6d 100644 --- a/lib/ui/app_shell.dart +++ b/lib/ui/app_shell.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -221,6 +223,10 @@ class _MyAppState extends State { path: '/more/settings', builder: (context, state) => const SettingsPage(), ), + GoRoute( + path: '/more/admin', + builder: (context, state) => const AdminPage(), + ), GoRoute( path: '/legs/edit/:id', builder: (_, state) { @@ -307,6 +313,7 @@ class _MyHomePageState extends State { bool _fetched = false; bool _railCollapsed = false; + Timer? _notificationsTimer; @override void didChangeDependencies() { @@ -343,10 +350,28 @@ class _MyHomePageState extends State { if (data.notifications.isEmpty) { data.fetchNotifications(); } + _startNotificationPolling(); }); }); } + void _startNotificationPolling() { + _notificationsTimer?.cancel(); + final auth = context.read(); + if (!auth.isLoggedIn) return; + _notificationsTimer = Timer.periodic(const Duration(minutes: 2), (_) async { + if (!mounted) return; + final auth = context.read(); + if (!auth.isLoggedIn) return; + final data = context.read(); + try { + await data.fetchNotifications(); + } catch (_) { + // Errors already logged inside data service. + } + }); + } + @override Widget build(BuildContext context) { final uri = GoRouterState.of(context).uri; @@ -895,4 +920,10 @@ class _MyHomePageState extends State { _forwardHistory.clear(); context.go(tabDestinations[index]); } + + @override + void dispose() { + _notificationsTimer?.cancel(); + super.dispose(); + } } diff --git a/pubspec.yaml b/pubspec.yaml index dba6829..1b0b5e4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.5.4+1 +version: 0.5.5+1 environment: sdk: ^3.8.1