add admin page, fix no legs infinite load on dashboard
All checks were successful
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 6m49s
Release / web-build (push) Successful in 6m23s
Release / android-build (push) Successful in 16m56s
Release / release-master (push) Successful in 30s
Release / release-dev (push) Successful in 32s

This commit is contained in:
2026-01-02 18:49:14 +00:00
parent 29cecf0ded
commit 2fa66ff9c6
11 changed files with 691 additions and 89 deletions

View File

@@ -45,7 +45,7 @@ android {
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). // 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. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion minSdk = flutter.minSdkVersion

View File

@@ -27,7 +27,7 @@ class _DashboardState extends State<Dashboard> {
final distanceUnits = context.watch<DistanceUnitService>(); final distanceUnits = context.watch<DistanceUnitService>();
final stats = data.homepageStats; final stats = data.homepageStats;
final isInitialLoading = data.isHomepageLoading || stats == null; final isInitialLoading = data.isHomepageLoading && stats == null;
return RefreshIndicator( return RefreshIndicator(
onRefresh: () async { onRefresh: () async {

View File

@@ -1,83 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:mileograph_flutter/components/pages/profile.dart'; import 'package:mileograph_flutter/components/pages/more/more_home_page.dart';
import 'package:mileograph_flutter/components/pages/settings.dart';
import 'package:mileograph_flutter/components/pages/stats.dart'; export 'more/admin_page.dart';
class MorePage extends StatelessWidget { class MorePage extends StatelessWidget {
const MorePage({super.key}); const MorePage({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Navigator( return const MoreHomePage();
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'),
),
],
),
),
],
);
} }
} }

View File

@@ -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<AdminPage> createState() => _AdminPageState();
}
class _AdminPageState extends State<AdminPage> {
final TextEditingController _titleController = TextEditingController();
final TextEditingController _bodyController = TextEditingController();
final List<UserSummary> _selectedUsers = [];
List<UserSummary> _userOptions = [];
List<String> _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<void> _loadChannels() async {
setState(() {
_loadingChannels = true;
_channelError = null;
});
try {
final api = context.read<ApiService>();
final json = await api.get('/notifications/channels');
List<dynamic>? 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<List<UserSummary>> _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<dynamic>? 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>()
.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<void> _openUserPicker() async {
final api = context.read<ApiService>();
var tempSelected = List<UserSummary>.from(_selectedUsers);
var options = List<UserSummary>.from(_userOptions);
String query = '';
bool loading = false;
String? error = _userError;
Future<void> 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<void>(
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<void> _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<ApiService>();
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<AuthService, bool>((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<String>(
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),
),
),
],
);
}
}

View File

@@ -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<AuthService, bool>((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'),
),
],
),
),
],
);
}
}

View File

@@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/api_service.dart'; import 'package:mileograph_flutter/services/api_service.dart';

View File

@@ -104,24 +104,64 @@ class DestinationObject {
} }
class UserData { 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 userId;
final String username; final String username;
final String fullName; final String fullName;
final String email; final String email;
final bool elevated;
final bool disabled;
} }
class AuthenticatedUserData extends UserData { class AuthenticatedUserData extends UserData {
const AuthenticatedUserData({ const AuthenticatedUserData({
required String userId, required super.userId,
required String username, required super.username,
required String fullName, required super.fullName,
required String email, required super.email,
bool? elevated,
bool? isElevated,
bool? disabled,
bool? isDisabled,
required this.accessToken, required this.accessToken,
}) : super(username, fullName, userId, email); }) : super(
elevated: (elevated ?? false) || (isElevated ?? false),
disabled: (disabled ?? false) || (isDisabled ?? false),
);
final String accessToken; 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<String, dynamic> 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 { class HomepageStats {
@@ -170,10 +210,13 @@ class HomepageStats {
user: userData == null user: userData == null
? null ? null
: UserData( : UserData(
userData['username'] ?? '', username: userData['username'] ?? '',
userData['full_name'] ?? '', fullName: userData['full_name'] ?? '',
userData['user_id'] ?? '', userId: userData['user_id'] ?? '',
userData['email'] ?? '', 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 int id;
final String title; final String title;
final String body; final String body;
final String channel;
final DateTime? createdAt; final DateTime? createdAt;
final bool dismissed; final bool dismissed;
@@ -1186,6 +1230,7 @@ class UserNotification {
required this.id, required this.id,
required this.title, required this.title,
required this.body, required this.body,
required this.channel,
required this.createdAt, required this.createdAt,
required this.dismissed, required this.dismissed,
}); });
@@ -1202,6 +1247,7 @@ class UserNotification {
id: _asInt(json['notification_id'] ?? json['id']), id: _asInt(json['notification_id'] ?? json['id']),
title: _asString(json['title']), title: _asString(json['title']),
body: _asString(json['body']), body: _asString(json['body']),
channel: _asString(json['channel']),
createdAt: createdAt, createdAt: createdAt,
dismissed: _asBool(json['dismissed'] ?? false, false), dismissed: _asBool(json['dismissed'] ?? false, false),
); );

View File

@@ -21,6 +21,9 @@ class AuthService extends ChangeNotifier {
String? get userId => _user?.userId; String? get userId => _user?.userId;
String? get username => _user?.username; String? get username => _user?.username;
String? get fullName => _user?.fullName; 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({ void setLoginData({
required String userId, required String userId,
@@ -28,6 +31,8 @@ class AuthService extends ChangeNotifier {
required String fullName, required String fullName,
required String accessToken, required String accessToken,
required String email, required String email,
bool isElevated = false,
bool isDisabled = false,
}) { }) {
_user = AuthenticatedUserData( _user = AuthenticatedUserData(
userId: userId, userId: userId,
@@ -35,6 +40,8 @@ class AuthService extends ChangeNotifier {
fullName: fullName, fullName: fullName,
accessToken: accessToken, accessToken: accessToken,
email: email, email: email,
isElevated: isElevated,
disabled: isDisabled,
); );
_persistToken(accessToken); _persistToken(accessToken);
notifyListeners(); notifyListeners();
@@ -70,6 +77,8 @@ class AuthService extends ChangeNotifier {
fullName: userResponse['full_name'], fullName: userResponse['full_name'],
accessToken: accessToken, accessToken: accessToken,
email: userResponse['email'], email: userResponse['email'],
isElevated: _parseIsElevated(userResponse),
isDisabled: _parseIsDisabled(userResponse),
); );
} }
@@ -95,6 +104,8 @@ class AuthService extends ChangeNotifier {
fullName: userResponse['full_name'], fullName: userResponse['full_name'],
accessToken: token, accessToken: token,
email: userResponse['email'], email: userResponse['email'],
isElevated: _parseIsElevated(userResponse),
isDisabled: _parseIsDisabled(userResponse),
); );
} catch (_) { } catch (_) {
await _clearToken(); await _clearToken();
@@ -157,4 +168,60 @@ class AuthService extends ChangeNotifier {
void logout() { void logout() {
handleTokenExpired(); // reuse handleTokenExpired(); // reuse
} }
bool _parseIsElevated(Map<String, dynamic> 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<String, dynamic> 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);
}
} }

View File

@@ -20,7 +20,7 @@ extension DataServiceNotifications on DataService {
final parsed = list final parsed = list
?.whereType<Map<String, dynamic>>() ?.whereType<Map<String, dynamic>>()
.map(UserNotification.fromJson) .map(UserNotification.fromJson)
.where((n) => !n.dismissed) .where((n) => !n.dismissed && n.channel.toLowerCase() != 'web')
.toList(); .toList();
if (parsed != null) { if (parsed != null) {

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:dynamic_color/dynamic_color.dart'; import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -221,6 +223,10 @@ class _MyAppState extends State<MyApp> {
path: '/more/settings', path: '/more/settings',
builder: (context, state) => const SettingsPage(), builder: (context, state) => const SettingsPage(),
), ),
GoRoute(
path: '/more/admin',
builder: (context, state) => const AdminPage(),
),
GoRoute( GoRoute(
path: '/legs/edit/:id', path: '/legs/edit/:id',
builder: (_, state) { builder: (_, state) {
@@ -307,6 +313,7 @@ class _MyHomePageState extends State<MyHomePage> {
bool _fetched = false; bool _fetched = false;
bool _railCollapsed = false; bool _railCollapsed = false;
Timer? _notificationsTimer;
@override @override
void didChangeDependencies() { void didChangeDependencies() {
@@ -343,10 +350,28 @@ class _MyHomePageState extends State<MyHomePage> {
if (data.notifications.isEmpty) { if (data.notifications.isEmpty) {
data.fetchNotifications(); data.fetchNotifications();
} }
_startNotificationPolling();
}); });
}); });
} }
void _startNotificationPolling() {
_notificationsTimer?.cancel();
final auth = context.read<AuthService>();
if (!auth.isLoggedIn) return;
_notificationsTimer = Timer.periodic(const Duration(minutes: 2), (_) async {
if (!mounted) return;
final auth = context.read<AuthService>();
if (!auth.isLoggedIn) return;
final data = context.read<DataService>();
try {
await data.fetchNotifications();
} catch (_) {
// Errors already logged inside data service.
}
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final uri = GoRouterState.of(context).uri; final uri = GoRouterState.of(context).uri;
@@ -895,4 +920,10 @@ class _MyHomePageState extends State<MyHomePage> {
_forwardHistory.clear(); _forwardHistory.clear();
context.go(tabDestinations[index]); context.go(tabDestinations[index]);
} }
@override
void dispose() {
_notificationsTimer?.cancel();
super.dispose();
}
} }

View File

@@ -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 # 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 # 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. # 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: environment:
sdk: ^3.8.1 sdk: ^3.8.1