/opt/canhelp/apps/mobile/lib/screens/admin
NameSizeModeActions
admin_backup_screen.dart279650644editdlrm
admin_offers_moderation_screen.dart114610644editdlrm
admin_reports_screen.dart110280644editdlrm
admin_screen.dart111740644editdlrm
admin_support_screen.dart244800644editdlrm
admin_tasks_screen.dart174450644editdlrm
admin_users_screen.dart134730644editdlrm
admin_user_detail_screen.dart192530644editdlrm
Edit: /opt/canhelp/apps/mobile/lib/screens/admin/admin_support_screen.dart (24480B)
import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../providers/locale_provider.dart'; import '../../services/api_client.dart'; import '../../theme/app_theme.dart'; int _toInt(dynamic v) { if (v == null) return 0; if (v is int) return v; if (v is num) return v.toInt(); return int.tryParse(v.toString()) ?? 0; } class AdminSupportScreen extends StatefulWidget { const AdminSupportScreen({super.key}); @override State createState() => _AdminSupportScreenState(); } class _AdminSupportScreenState extends State { final _scrollCtrl = ScrollController(); List> _tickets = []; int _total = 0; int _page = 1; bool _isLoading = false; bool _hasMore = true; String? _filterStatus; static const _statuses = ['open', 'in_progress', 'resolved', 'closed']; @override void initState() { super.initState(); _load(reset: true); _scrollCtrl.addListener(_onScroll); } @override void dispose() { _scrollCtrl.dispose(); super.dispose(); } void _onScroll() { if (_scrollCtrl.position.pixels >= _scrollCtrl.position.maxScrollExtent - 200) { if (!_isLoading && _hasMore) _load(); } } Future _load({bool reset = false}) async { if (_isLoading) return; if (reset) { _page = 1; _hasMore = true; } if (!_hasMore && !reset) return; setState(() => _isLoading = true); try { final result = await ApiClient().getAdminSupportTickets( page: _page, status: _filterStatus, ); final rows = (result['data'] as List) .map((e) => Map.from(e as Map)) .toList(); final total = _toInt(result['total']); const limit = 20; setState(() { if (reset) { _tickets = rows; } else { _tickets.addAll(rows); } _total = total; _hasMore = _tickets.length < total && rows.length >= limit; if (!reset) _page++; }); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(e.toString()), backgroundColor: AppTheme.danger, ), ); } } finally { if (mounted) setState(() => _isLoading = false); } } void _setFilter(String? status) { setState(() => _filterStatus = status); _load(reset: true); } Future _openTicket(Map ticket) async { final updated = await showModalBottomSheet>( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (_) => _TicketDetailSheet(ticket: ticket), ); if (updated != null) { setState(() { final idx = _tickets.indexWhere((t) => t['id'] == updated['id']); if (idx != -1) _tickets[idx] = updated; }); } } @override Widget build(BuildContext context) { final locale = context.watch(); final t = locale.t; return Scaffold( backgroundColor: AppTheme.background, appBar: AppBar( backgroundColor: AppTheme.background, foregroundColor: AppTheme.textPrimary, iconTheme: const IconThemeData(color: Colors.black), elevation: 0, title: Text( t('admin.support', 'Support'), style: const TextStyle( fontSize: 17, fontWeight: FontWeight.w700, color: AppTheme.textPrimary, ), ), actions: [ Padding( padding: const EdgeInsets.only(right: 16), child: Text( t( 'admin.total', 'Total: $_total', ).replaceAll('_total', '$_total'), style: const TextStyle( fontSize: 13, color: AppTheme.textSecondary, ), ), ), ], ), body: Column( children: [ _FilterBar( selected: _filterStatus, statuses: _statuses, onSelect: _setFilter, locale: locale, ), Expanded( child: RefreshIndicator( onRefresh: () => _load(reset: true), color: AppTheme.primary, child: _tickets.isEmpty && !_isLoading ? ListView( children: [ const SizedBox(height: 80), Center( child: Text( t('admin.support.empty', 'No support tickets'), style: const TextStyle( color: AppTheme.textSecondary, fontSize: 15, ), ), ), ], ) : ListView.builder( controller: _scrollCtrl, padding: const EdgeInsets.fromLTRB(16, 0, 16, 20), itemCount: _tickets.length + (_isLoading ? 1 : 0), itemBuilder: (context, i) { if (i == _tickets.length) { return const Padding( padding: EdgeInsets.all(20), child: Center( child: CircularProgressIndicator( color: AppTheme.primary, ), ), ); } return _TicketCard( ticket: _tickets[i], onTap: () => _openTicket(_tickets[i]), locale: locale, ); }, ), ), ), ], ), ); } } // ── Filter Bar ─────────────────────────────────────────────────────────────── class _FilterBar extends StatelessWidget { const _FilterBar({ required this.selected, required this.statuses, required this.onSelect, required this.locale, }); final String? selected; final List statuses; final void Function(String?) onSelect; final LocaleProvider locale; @override Widget build(BuildContext context) { final t = locale.t; return Container( height: 48, color: AppTheme.background, child: ListView( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), children: [ _Chip( label: t('admin.allStatuses', 'All'), selected: selected == null, onTap: () => onSelect(null), ), ...statuses.map( (s) => _Chip( label: t('admin.support.status.$s', s), selected: selected == s, onTap: () => onSelect(s), ), ), ], ), ); } } class _Chip extends StatelessWidget { const _Chip({ required this.label, required this.selected, required this.onTap, }); final String label; final bool selected; final VoidCallback onTap; @override Widget build(BuildContext context) => GestureDetector( onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 150), margin: const EdgeInsets.only(right: 8), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4), decoration: BoxDecoration( color: selected ? AppTheme.primary : AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.chipRadius), border: Border.all( color: selected ? AppTheme.primary : AppTheme.border, ), ), child: Text( label, style: TextStyle( fontSize: 13, fontWeight: selected ? FontWeight.w600 : FontWeight.w400, color: selected ? Colors.white : AppTheme.textSecondary, ), ), ), ); } // ── Ticket Card ────────────────────────────────────────────────────────────── Color _statusColor(String status) => switch (status) { 'open' => AppTheme.danger, 'in_progress' => AppTheme.warning, 'resolved' => AppTheme.success, 'closed' => AppTheme.textHint, _ => AppTheme.textSecondary, }; Color _typeColor(String type) => switch (type) { 'bug' => AppTheme.danger, 'suggestion' => AppTheme.primary, _ => AppTheme.textSecondary, }; class _TicketCard extends StatelessWidget { const _TicketCard({ required this.ticket, required this.onTap, required this.locale, }); final Map ticket; final VoidCallback onTap; final LocaleProvider locale; @override Widget build(BuildContext context) { final t = locale.t; final status = ticket['status'] as String? ?? 'open'; final type = ticket['type'] as String? ?? 'other'; final hasReply = ticket['adminReply'] != null && (ticket['adminReply'] as String).isNotEmpty; return Container( margin: const EdgeInsets.only(bottom: 10), decoration: BoxDecoration( color: AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.cardRadius), border: Border.all(color: AppTheme.border), ), child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(AppTheme.cardRadius), child: Padding( padding: const EdgeInsets.all(14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ _Badge( label: t('admin.support.type.$type', type), color: _typeColor(type), ), const SizedBox(width: 8), _Badge( label: t('admin.support.status.$status', status), color: _statusColor(status), ), const Spacer(), if (hasReply) Icon( Icons.reply_rounded, size: 16, color: AppTheme.success, ), ], ), const SizedBox(height: 8), Text( ticket['subject'] as String? ?? '', style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: AppTheme.textPrimary, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 4), Text( ticket['body'] as String? ?? '', style: const TextStyle( fontSize: 13, color: AppTheme.textSecondary, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 8), Row( children: [ const Icon( Icons.person_outline, size: 13, color: AppTheme.textHint, ), const SizedBox(width: 4), Text( ticket['userName'] as String? ?? ticket['userEmail'] as String? ?? '', style: const TextStyle( fontSize: 12, color: AppTheme.textHint, ), ), const Spacer(), Text( _formatDate(ticket['createdAt']), style: const TextStyle( fontSize: 12, color: AppTheme.textHint, ), ), ], ), ], ), ), ), ); } } class _Badge extends StatelessWidget { const _Badge({required this.label, required this.color}); final String label; final Color color; @override Widget build(BuildContext context) => Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: color.withOpacity(0.12), borderRadius: BorderRadius.circular(AppTheme.chipRadius), ), child: Text( label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: color), ), ); } String _formatDate(dynamic raw) { if (raw == null) return ''; try { final dt = DateTime.parse(raw.toString()).toLocal(); final now = DateTime.now(); final diff = now.difference(dt); if (diff.inDays == 0) { return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; } return '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year}'; } catch (_) { return ''; } } // ── Detail Sheet ───────────────────────────────────────────────────────────── class _TicketDetailSheet extends StatefulWidget { const _TicketDetailSheet({required this.ticket}); final Map ticket; @override State<_TicketDetailSheet> createState() => _TicketDetailSheetState(); } class _TicketDetailSheetState extends State<_TicketDetailSheet> { final _replyCtrl = TextEditingController(); late String _status; bool _isSending = false; static const _statuses = ['open', 'in_progress', 'resolved', 'closed']; @override void initState() { super.initState(); _status = widget.ticket['status'] as String? ?? 'open'; final existing = widget.ticket['adminReply'] as String?; if (existing != null) _replyCtrl.text = existing; } @override void dispose() { _replyCtrl.dispose(); super.dispose(); } Future _send() async { final reply = _replyCtrl.text.trim(); setState(() => _isSending = true); try { final Map updated; if (reply.isNotEmpty) { updated = await ApiClient().adminReplyToTicket( widget.ticket['id'] as String, reply: reply, status: _status, ); } else { updated = await ApiClient().adminUpdateTicketStatus( widget.ticket['id'] as String, _status, ); } if (mounted) Navigator.of(context).pop(updated); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(e.toString()), backgroundColor: AppTheme.danger, ), ); } } finally { if (mounted) setState(() => _isSending = false); } } @override Widget build(BuildContext context) { final locale = context.watch(); final t = locale.t; final ticket = widget.ticket; final type = ticket['type'] as String? ?? 'other'; final userName = ticket['userName'] as String? ?? ticket['userEmail'] as String? ?? ''; return DraggableScrollableSheet( initialChildSize: 0.85, minChildSize: 0.5, maxChildSize: 0.95, builder: (context, scrollCtrl) => Container( decoration: const BoxDecoration( color: AppTheme.surface, borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), child: Column( children: [ const SizedBox(height: 12), Center( child: Container( width: 36, height: 4, decoration: BoxDecoration( color: AppTheme.border, borderRadius: BorderRadius.circular(2), ), ), ), const SizedBox(height: 12), Expanded( child: ListView( controller: scrollCtrl, padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), children: [ // Header Row( children: [ _Badge( label: t('admin.support.type.$type', type), color: _typeColor(type), ), const SizedBox(width: 8), _Badge( label: t('admin.support.status.$_status', _status), color: _statusColor(_status), ), ], ), const SizedBox(height: 12), Text( ticket['subject'] as String? ?? '', style: const TextStyle( fontSize: 17, fontWeight: FontWeight.w700, color: AppTheme.textPrimary, ), ), const SizedBox(height: 4), Text( '$userName • ${_formatDate(ticket['createdAt'])}', style: const TextStyle( fontSize: 12, color: AppTheme.textHint, ), ), const SizedBox(height: 14), Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: AppTheme.background, borderRadius: BorderRadius.circular(AppTheme.smallRadius), ), child: Text( ticket['body'] as String? ?? '', style: const TextStyle( fontSize: 14, color: AppTheme.textPrimary, height: 1.5, ), ), ), const SizedBox(height: 20), // Status picker Text( t('admin.status', 'Status'), style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: AppTheme.textSecondary, ), ), const SizedBox(height: 8), Wrap( spacing: 8, children: _statuses .map( (s) => GestureDetector( onTap: () => setState(() => _status = s), child: AnimatedContainer( duration: const Duration(milliseconds: 150), padding: const EdgeInsets.symmetric( horizontal: 14, vertical: 6, ), decoration: BoxDecoration( color: _status == s ? _statusColor(s).withOpacity(0.15) : AppTheme.background, borderRadius: BorderRadius.circular( AppTheme.chipRadius, ), border: Border.all( color: _status == s ? _statusColor(s) : AppTheme.border, ), ), child: Text( t('admin.support.status.$s', s), style: TextStyle( fontSize: 13, fontWeight: FontWeight.w500, color: _status == s ? _statusColor(s) : AppTheme.textSecondary, ), ), ), ), ) .toList(), ), const SizedBox(height: 20), // Reply field Text( t('admin.support.reply', 'Reply'), style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: AppTheme.textSecondary, ), ), const SizedBox(height: 8), TextField( controller: _replyCtrl, maxLines: 5, style: const TextStyle( fontSize: 14, color: AppTheme.textPrimary, ), decoration: InputDecoration( hintText: t( 'admin.support.replyHint', 'Write your reply...', ), hintStyle: const TextStyle( color: AppTheme.textHint, fontSize: 14, ), filled: true, fillColor: AppTheme.background, contentPadding: const EdgeInsets.all(14), border: OutlineInputBorder( borderRadius: BorderRadius.circular( AppTheme.inputRadius, ), borderSide: const BorderSide(color: AppTheme.border), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular( AppTheme.inputRadius, ), borderSide: const BorderSide(color: AppTheme.border), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular( AppTheme.inputRadius, ), borderSide: const BorderSide(color: AppTheme.primary), ), ), ), const SizedBox(height: 20), SizedBox( width: double.infinity, child: ElevatedButton( onPressed: _isSending ? null : _send, style: ElevatedButton.styleFrom( backgroundColor: AppTheme.primary, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular( AppTheme.buttonRadius, ), ), ), child: _isSending ? const SizedBox( height: 18, width: 18, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white, ), ) : Text( t('admin.support.save', 'Save'), style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600, ), ), ), ), ], ), ), ], ), ), ); } }