import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:image_picker/image_picker.dart'; import 'package:intl/intl.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../core/theme.dart'; import '../core/permissions.dart'; import '../models/compliance.dart'; import '../state/compliance_controller.dart'; import '../state/session_controller.dart'; import '../widgets/common.dart'; IconData _sectionIcon(ComplianceSection s) => switch (s) { ComplianceSection.documents => LucideIcons.fileText, ComplianceSection.certificates => LucideIcons.award, ComplianceSection.taxForms => LucideIcons.receipt, ComplianceSection.wardrobe => LucideIcons.shirt, }; ChipTone _toneFor(ComplianceState state) => switch (state) { ComplianceState.verified => ChipTone.neutral, ComplianceState.pending => ChipTone.blue, ComplianceState.missing => ChipTone.yellow, }; /// Everything a business must see before you can work. class ComplianceHubScreen extends ConsumerWidget { const ComplianceHubScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final (done, total) = ref.watch(complianceProgressProvider); final remaining = total - done; return Scaffold( body: ListView( padding: EdgeInsets.zero, children: [ BrandHeader( padding: const EdgeInsets.fromLTRB(20, 8, 20, 22), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ HeaderIconButton( icon: LucideIcons.arrowLeft, onPressed: () => context.pop(), ), StatusChip( remaining == 0 ? 'All clear' : '$remaining left', tone: remaining == 0 ? ChipTone.neutral : ChipTone.yellow, ), ], ), const SizedBox(height: 18), const Text( 'Compliance', style: TextStyle( fontSize: 27, fontWeight: FontWeight.w700, letterSpacing: -0.9, color: Colors.white, ), ), const SizedBox(height: 5), Text( 'Everything a business must see before you work.', style: TextStyle( fontSize: 13.5, color: Colors.white.withValues(alpha: 0.6), ), ), ], ), ), Padding( padding: const EdgeInsets.fromLTRB(20, 18, 20, 28), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ KrowCard( child: Column( children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text( 'Overall', style: TextStyle( fontSize: 14.5, fontWeight: FontWeight.w600, ), ), Text( '$done of $total', style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w700, ), ), ], ), const SizedBox(height: 11), KrowProgressBar( total == 0 ? 1 : done / total, color: done == total ? KrowPalette.blue : KrowPalette.yellow, ), ], ), ), const SectionLabel('Sections'), TileGroup( children: [ for (final section in ComplianceSection.values) Consumer( builder: (context, ref, _) { final (d, t) = ref.watch( sectionProgressProvider(section), ); return NavTile( icon: _sectionIcon(section), title: section.title, subtitle: section.subtitle, badge: StatusChip( '$d of $t', tone: d == t ? ChipTone.neutral : ChipTone.yellow, ), onTap: () => context.push('/compliance/${section.name}'), ); }, ), ], ), const SizedBox(height: 16), const InfoBanner( 'Documents are shared with a business only when you accept ' 'one of their shifts.', ), ], ), ), ], ), ); } } /// One compliance section: documents, certificates, tax forms or attire. class ComplianceListScreen extends ConsumerStatefulWidget { const ComplianceListScreen({super.key, required this.section}); final ComplianceSection section; @override ConsumerState createState() => _ComplianceListScreenState(); } class _ComplianceListScreenState extends ConsumerState { String _filter = 'All'; String _query = ''; static const _filters = ['All', 'Verified', 'Pending', 'Missing']; @override Widget build(BuildContext context) { var items = ref.watch(complianceProvider).section(widget.section); final (done, total) = ref.watch(sectionProgressProvider(widget.section)); if (_query.isNotEmpty) { final q = _query.toLowerCase(); items = items.where((i) => i.title.toLowerCase().contains(q)).toList(); } items = switch (_filter) { 'Verified' => items.where((i) => i.state == ComplianceState.verified).toList(), 'Pending' => items.where((i) => i.state == ComplianceState.pending).toList(), 'Missing' => items.where((i) => i.state == ComplianceState.missing).toList(), _ => items, }; final required = items.where((i) => i.required_).toList(); final optional = items.where((i) => !i.required_).toList(); return Scaffold( appBar: AppBar( title: Text(widget.section.title), actions: [ Padding( padding: const EdgeInsets.only(right: 16), child: Center( child: StatusChip( '$done of $total', tone: done == total ? ChipTone.neutral : ChipTone.yellow, ), ), ), ], ), body: ListView( padding: const EdgeInsets.fromLTRB(20, 8, 20, 28), children: [ TextField( onChanged: (v) => setState(() => _query = v), decoration: InputDecoration( hintText: 'Search ${widget.section.title.toLowerCase()}', prefixIcon: const Icon(LucideIcons.search, size: 18), ), ), const SizedBox(height: 14), SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: [ for (final f in _filters) Padding( padding: const EdgeInsets.only(right: 8), child: ChoiceChip( label: Text(f), selected: f == _filter, showCheckmark: false, selectedColor: KrowPalette.blue, labelStyle: TextStyle( fontSize: 12.5, fontWeight: FontWeight.w500, color: f == _filter ? Colors.white : KrowPalette.mute, ), onSelected: (_) => setState(() => _filter = f), ), ), ], ), ), if (required.isNotEmpty) ...[ const SectionLabel('Required'), TileGroup( children: [ for (final item in required) _ItemTile(section: widget.section, item: item), ], ), ], if (optional.isNotEmpty) ...[ const SectionLabel('Unlocks more shifts'), TileGroup( children: [ for (final item in optional) _ItemTile(section: widget.section, item: item), ], ), ], if (required.isEmpty && optional.isEmpty) const Padding( padding: EdgeInsets.only(top: 24), child: EmptyState( icon: LucideIcons.search, title: 'Nothing here', message: 'Try a different filter.', ), ), ], ), ); } } class _ItemTile extends StatelessWidget { const _ItemTile({required this.section, required this.item}); final ComplianceSection section; final ComplianceItem item; @override Widget build(BuildContext context) { return NavTile( icon: _sectionIcon(section), title: item.title, subtitle: item.subtitle, badge: StatusChip(item.badge, tone: _toneFor(item.state)), onTap: () => context.push('/compliance/${section.name}/${item.id}'), ); } } /// A single item: the file on record, its expiry, and the actions on it. class ComplianceDetailScreen extends ConsumerWidget { const ComplianceDetailScreen({ super.key, required this.section, required this.itemId, }); final ComplianceSection section; final String itemId; /// Picks a real file: the camera for a new photo, the gallery for an /// existing one. Camera access is requested first, because image_picker /// surfaces a refusal as a plain null and the worker would be left tapping a /// button that silently does nothing. Future _upload(BuildContext context, WidgetRef ref) async { final source = await showModalBottomSheet( context: context, showDragHandle: true, builder: (context) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: const Icon(LucideIcons.camera), title: const Text('Take a photo'), subtitle: const Text('It has to be the real item, worn by you'), onTap: () => Navigator.pop(context, ImageSource.camera), ), ListTile( leading: const Icon(LucideIcons.image), title: const Text('Choose from library'), onTap: () => Navigator.pop(context, ImageSource.gallery), ), ], ), ), ); if (source == null || !context.mounted) return; if (source == ImageSource.camera) { final granted = await ref .read(sessionProvider.notifier) .request(AppPermission.camera); if (!context.mounted) return; if (!granted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: const Text('Camera access is off'), action: SnackBarAction( label: 'Settings', onPressed: ref.read(permissionServiceProvider).openSettings, ), ), ); return; } } final XFile? picked; try { picked = await ImagePicker().pickImage( source: source, // Documents only need to be legible, and a full-resolution phone // photo is a slow upload on a venue's wifi. maxWidth: 2000, imageQuality: 85, ); } on PlatformException catch (error) { if (!context.mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('Could not open: ${error.code}'))); return; } // Null means the picker was dismissed, which is not an error. if (picked == null || !context.mounted) return; final size = await picked.length(); if (!context.mounted) return; ref .read(complianceProvider.notifier) .uploadFile( section, itemId, picked.name, meta: '${(size / 1024 / 1024).toStringAsFixed(1)} MB ยท just now', ); ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('Uploaded - sent for review'))); } Future _remove(BuildContext context, WidgetRef ref) async { final ok = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Remove this file?'), content: const Text( 'Your account goes on hold for anything that needs it until a ' 'replacement is verified.', ), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('Keep it'), ), FilledButton( onPressed: () => Navigator.pop(context, true), style: FilledButton.styleFrom(minimumSize: const Size(0, 44)), child: const Text('Remove'), ), ], ), ); if (ok != true || !context.mounted) return; ref.read(complianceProvider.notifier).removeFile(section, itemId); ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('File removed'))); } @override Widget build(BuildContext context, WidgetRef ref) { ref.watch(complianceProvider); final item = ref.read(complianceProvider.notifier).find(section, itemId); if (item == null) { return Scaffold( appBar: AppBar(), body: const Center(child: Text('Item not found.')), ); } final d = DateFormat('d MMM yyyy'); return Scaffold( appBar: AppBar( title: Text(section.title), actions: [ Padding( padding: const EdgeInsets.only(right: 16), child: Center( child: StatusChip(item.badge, tone: _toneFor(item.state)), ), ), ], ), body: ListView( padding: const EdgeInsets.fromLTRB(20, 8, 20, 28), children: [ Text( item.title, style: const TextStyle( fontSize: 26, fontWeight: FontWeight.w700, letterSpacing: -0.9, ), ), const SizedBox(height: 6), Text( item.subtitle, style: const TextStyle(fontSize: 13.5, color: KrowPalette.mute), ), if (section == ComplianceSection.wardrobe) ...[ const SizedBox(height: 16), const InfoBanner( 'The photo has to be you wearing the item. Catalogue shots and ' 'images pulled from the web get rejected.', warning: true, ), ], const SectionLabel('File on record'), if (item.fileName == null) EmptyState( icon: LucideIcons.upload, title: 'Nothing uploaded', message: 'JPEG, PNG or PDF, up to 10 MB.', action: FilledButton.icon( onPressed: () => _upload(context, ref), icon: const Icon(LucideIcons.plus, size: 18), label: const Text('Add a file'), ), ) else KrowCard( padding: const EdgeInsets.all(14), child: Row( children: [ Container( width: 44, height: 44, decoration: BoxDecoration( color: const Color(0xFFDEEBE2), borderRadius: BorderRadius.circular(10), ), child: const Icon( LucideIcons.fileText, size: 20, color: Color(0xFF949FA5), ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( item.fileName!, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 12.5, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 3), Text( item.fileMeta ?? '', style: const TextStyle( fontSize: 11.5, color: Color(0xFF949FA5), ), ), ], ), ), IconButton( onPressed: () => ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Preview opens the file')), ), icon: const Icon(LucideIcons.eye, size: 18), ), ], ), ), const SectionLabel('Details'), KrowCard( padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( children: [ DetailRow('Status', item.badge), const Divider(height: 1), _ExpiryRow(section: section, item: item, format: d), const Divider(height: 1), const DetailRow('Shared with', '2 businesses'), ], ), ), const SizedBox(height: 16), InfoBanner( item.required_ ? 'Removing this puts your account on hold until a new file is ' 'verified. You cannot work in the meantime.' : 'Optional, but adding it opens up more shifts near you.', ), const SizedBox(height: 22), Row( children: [ if (item.fileName != null) ...[ Expanded( child: OutlinedButton.icon( onPressed: () => _remove(context, ref), icon: const Icon(LucideIcons.trash2, size: 18), label: const Text('Remove'), ), ), const SizedBox(width: 10), ], Expanded( child: FilledButton.icon( onPressed: () => _upload(context, ref), icon: const Icon(LucideIcons.camera, size: 18), label: Text(item.fileName == null ? 'Upload' : 'Replace'), ), ), ], ), ], ), ); } } class _ExpiryRow extends ConsumerWidget { const _ExpiryRow({ required this.section, required this.item, required this.format, }); final ComplianceSection section; final ComplianceItem item; final DateFormat format; @override Widget build(BuildContext context, WidgetRef ref) { return InkWell( onTap: () async { final now = DateTime.now(); final picked = await showDatePicker( context: context, initialDate: item.expires ?? now.add(const Duration(days: 365)), firstDate: now, lastDate: DateTime(now.year + 20), ); if (picked == null) return; ref .read(complianceProvider.notifier) .setExpiry(section, item.id, picked); }, child: DetailRow( 'Expires', item.expires == null ? 'Tap to set' : format.format(item.expires!), valueColor: item.expires == null ? KrowPalette.blue : null, ), ); } }