import 'package:flutter/material.dart'; import 'package:file_selector/file_selector.dart'; import 'package:go_router/go_router.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; List _routeFiles = []; bool _routeUploading = false; String? _routeStatus; String? _routeStatusMessage; String? _routeErrorMessage; int? _routeProcessed; int? _routeTotal; double? _routeProgress; Map? _routeResult; @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); } } int? _parseCount(dynamic value) { if (value is num) return value.toInt(); return int.tryParse(value?.toString() ?? ''); } double? _parsePercent( dynamic value, { required int? processed, required int? total, }) { if (value is num) { final raw = value.toDouble(); final normalized = raw > 1 ? raw / 100 : raw; return normalized.clamp(0, 1); } if (processed != null && total != null && total > 0) { return (processed / total).clamp(0, 1); } return null; } Duration _pollDelay(int attempt) { const delays = [ Duration(seconds: 1), Duration(seconds: 2), Duration(seconds: 2), Duration(seconds: 5), Duration(seconds: 5), Duration(seconds: 8), Duration(seconds: 10), ]; if (attempt < delays.length) return delays[attempt]; return const Duration(seconds: 10); } String _routeStatusLabel() { final status = _routeStatus ?? ''; final lower = status.toLowerCase(); final base = switch (lower) { 'queued' => 'Queued', 'running' => 'Processing', 'succeeded' => 'Completed', 'failed' => 'Failed', _ => status, }; final parts = [base]; if (_routeProcessed != null && _routeTotal != null) { parts.add('Files $_routeProcessed of $_routeTotal'); } if (_routeProgress != null) { parts.add('${(_routeProgress! * 100).toStringAsFixed(0)}%'); } return parts.join(' ยท '); } Future _pickRouteFiles() async { final files = await openFiles( acceptedTypeGroups: const [ XTypeGroup( label: 'XLSX spreadsheets', extensions: ['xlsx'], ), ], ); if (files.isEmpty) return; setState(() { _routeFiles = files; _routeStatus = null; _routeStatusMessage = null; _routeErrorMessage = null; _routeProcessed = null; _routeTotal = null; _routeProgress = null; _routeResult = null; }); } Future _uploadRouteFiles() async { if (_routeFiles.isEmpty || _routeUploading) return; setState(() { _routeUploading = true; _routeStatus = null; _routeStatusMessage = null; _routeErrorMessage = null; _routeProcessed = null; _routeTotal = null; _routeProgress = null; _routeResult = null; }); try { final api = context.read(); final payloads = []; for (final file in _routeFiles) { final bytes = await file.readAsBytes(); payloads.add( MultipartFilePayload( bytes: bytes, filename: file.name, ), ); } final response = await api.postMultipartFiles( '/route/update', files: payloads, headers: const {'accept': 'application/json'}, ); if (!mounted) return; final parsed = response is Map ? Map.from(response) : null; final jobId = parsed?['job_id']?.toString(); if (jobId == null || jobId.isEmpty) { setState(() { _routeErrorMessage = 'Upload failed to start.'; }); return; } setState(() { _routeStatus = parsed?['status']?.toString() ?? 'queued'; }); var attempt = 0; while (mounted) { final statusResponse = await api.get('/uploads/$jobId'); if (!mounted) return; final statusMap = statusResponse is Map ? Map.from(statusResponse) : null; if (statusMap == null) { setState(() { _routeErrorMessage = 'Upload status unavailable.'; }); return; } final status = statusMap['status']?.toString() ?? 'queued'; final processed = _parseCount(statusMap['processed']); final total = _parseCount(statusMap['total']); final percent = _parsePercent( statusMap['percent'], processed: processed, total: total, ); setState(() { _routeStatus = status; _routeProcessed = processed; _routeTotal = total; _routeProgress = percent; }); if (status == 'succeeded') { final result = statusMap['result']; setState(() { if (result is Map) { _routeResult = Map.from(result); } final message = _routeResult?['message']?.toString(); _routeStatusMessage = message != null && message.isNotEmpty ? message : 'Route update complete.'; }); return; } if (status == 'failed') { setState(() { _routeErrorMessage = statusMap['error']?.toString() ?? 'Route update failed.'; }); return; } await Future.delayed(_pollDelay(attempt)); attempt += 1; } } on ApiException catch (e) { if (!mounted) return; setState(() { _routeErrorMessage = e.message; }); } catch (e) { if (!mounted) return; setState(() { _routeErrorMessage = e.toString(); }); } finally { if (mounted) setState(() => _routeUploading = 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'), leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () { final navigator = Navigator.of(context); if (navigator.canPop()) { navigator.maybePop(); } else { context.go('/more'); } }, tooltip: 'Back', ), ), 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'), ), ), const SizedBox(height: 32), const Divider(height: 32), Text( 'Route update uploads', style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w700, ), ), const SizedBox(height: 8), Text( 'Upload one or more XLSX sheets to update route distances.', style: Theme.of(context).textTheme.bodyMedium, ), const SizedBox(height: 12), Row( children: [ Expanded( child: Text( _routeFiles.isEmpty ? 'No files selected' : '${_routeFiles.length} file(s) selected', overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: 8), OutlinedButton.icon( onPressed: _routeUploading ? null : _pickRouteFiles, icon: const Icon(Icons.upload_file), label: const Text('Choose files'), ), ], ), const SizedBox(height: 12), FilledButton.icon( onPressed: _routeFiles.isEmpty || _routeUploading ? null : _uploadRouteFiles, icon: _routeUploading ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.file_upload), label: Text(_routeUploading ? 'Uploading...' : 'Upload files'), ), if (_routeStatus != null) ...[ const SizedBox(height: 12), Text( _routeStatusLabel(), style: Theme.of(context).textTheme.bodyMedium, ), if (_routeProgress != null) ...[ const SizedBox(height: 6), LinearProgressIndicator(value: _routeProgress), ], ], if (_routeStatusMessage != null) ...[ const SizedBox(height: 12), Text( _routeStatusMessage!, style: Theme.of(context).textTheme.bodyMedium, ), ], if (_routeErrorMessage != null) ...[ const SizedBox(height: 12), Text( _routeErrorMessage!, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).colorScheme.error, ), ), ], if ((_routeStatus == 'failed' || _routeErrorMessage != null) && _routeFiles.isNotEmpty) ...[ const SizedBox(height: 8), OutlinedButton.icon( onPressed: _routeUploading ? null : _uploadRouteFiles, icon: const Icon(Icons.refresh), label: const Text('Retry upload'), ), ], ], ), ); } 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), ), ), ], ); } }