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

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

View File

@@ -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();
}
}

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:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/api_service.dart';

View File

@@ -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<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 {
@@ -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),
);

View File

@@ -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<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
?.whereType<Map<String, dynamic>>()
.map(UserNotification.fromJson)
.where((n) => !n.dismissed)
.where((n) => !n.dismissed && n.channel.toLowerCase() != 'web')
.toList();
if (parsed != null) {

View File

@@ -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<MyApp> {
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<MyHomePage> {
bool _fetched = false;
bool _railCollapsed = false;
Timer? _notificationsTimer;
@override
void didChangeDependencies() {
@@ -343,10 +350,28 @@ class _MyHomePageState extends State<MyHomePage> {
if (data.notifications.isEmpty) {
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
Widget build(BuildContext context) {
final uri = GoRouterState.of(context).uri;
@@ -895,4 +920,10 @@ class _MyHomePageState extends State<MyHomePage> {
_forwardHistory.clear();
context.go(tabDestinations[index]);
}
@override
void dispose() {
_notificationsTimer?.cancel();
super.dispose();
}
}