// Picley — Join Album Screen // Shows: event info, codeword field, upload mode toggle (manual default — user opts in to auto). // Name/email pre-filled from account — not shown to user. import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:workmanager/workmanager.dart'; import '../../../shared/services/api_client.dart'; import '../../../shared/services/ios_background_sync.dart'; import '../../../shared/theme/app_theme.dart'; class JoinAlbumScreen extends ConsumerStatefulWidget { final String token; final String? prefillCodeword; const JoinAlbumScreen({super.key, required this.token, this.prefillCodeword}); @override ConsumerState createState() => _JoinAlbumScreenState(); } class _JoinAlbumScreenState extends ConsumerState { Map? _albumInfo; final _codewordCtrl = TextEditingController(); String _uploadMode = 'auto'; // default — transparent consent, user can switch bool _loading = true; bool _joining = false; String? _error; // Pre-filled from account — never shown to user String _firstName = ''; String _lastName = ''; String _email = ''; @override void initState() { super.initState(); if (widget.prefillCodeword != null) { _codewordCtrl.text = widget.prefillCodeword!; } _loadUserAndAlbum(); } @override void dispose() { _codewordCtrl.dispose(); super.dispose(); } Future _loadUserAndAlbum() async { final session = Supabase.instance.client.auth.currentSession; if (session != null) { _email = session.user.email ?? ''; final meta = session.user.userMetadata ?? {}; _firstName = (meta['first_name'] ?? meta['given_name'] ?? '') as String; _lastName = (meta['last_name'] ?? meta['family_name'] ?? '') as String; if (_firstName.isEmpty) _firstName = _email.split('@').first; try { final profile = await Supabase.instance.client .from('profiles') .select('first_name, last_name') .eq('id', session.user.id) .single(); if ((profile['first_name'] as String?)?.isNotEmpty == true) _firstName = profile['first_name'] as String; if ((profile['last_name'] as String?)?.isNotEmpty == true) _lastName = profile['last_name'] as String; } catch (_) {} } try { final data = await ref.read(apiClientProvider).get('/albums/join/${widget.token}'); if (mounted) setState(() { _albumInfo = data; _loading = false; }); } catch (e) { if (mounted) setState(() { _error = 'Event not found. Please check your invitation link.'; _loading = false; }); } } Future _join() async { final codeword = _codewordCtrl.text.trim(); if (codeword.isEmpty) { setState(() => _error = 'Please enter the codeword from your invitation.'); return; } setState(() { _joining = true; _error = null; }); try { final data = await ref.read(apiClientProvider).post('/albums/join/${widget.token}', { 'firstName': _firstName, 'lastName': _lastName.isEmpty ? ' ' : _lastName, 'codeword': codeword, 'role': 'contributor', 'uploadMode': _uploadMode, 'contact': _email.isNotEmpty ? _email : null, 'contactType': _email.isNotEmpty ? 'email' : null, }); // Register background sync immediately on join so photos start uploading // automatically without waiting for the user to open the album detail screen. // Without this, the WorkManager task is never registered for first-time joiners // who chose auto mode — it was only registered when toggling modes afterwards. if (_uploadMode == 'auto') { final token = Supabase.instance.client.auth.currentSession?.accessToken; if (token != null) { final albumId = data['album_id'] as String; try { await Workmanager().registerPeriodicTask( 'picley.sync.$albumId', 'picley.sync', frequency: const Duration(minutes: 15), inputData: {'albumId': albumId, 'authToken': token}, constraints: Constraints(networkType: NetworkType.connected), // keep — don't overwrite if somehow already registered existingWorkPolicy: ExistingWorkPolicy.keep, ); } catch (_) {} await IosBackgroundSync.registerAlbum(albumId, token); } } if (mounted) context.go('/albums/${data['album_id']}'); } catch (e) { final msg = e.toString(); setState(() { _error = msg.contains('Incorrect codeword') ? 'Incorrect codeword. Please check your invitation.' : msg.contains('limit') ? 'This event is full. Contact the organiser.' : msg.contains('already') ? "You're already a member of this event." : 'Something went wrong. Please try again.'; _joining = false; }); } } // Formats the auto-share subtitle with the event's exact time window. // Events are max 1 day so start and end are always the same date. // e.g. "Picley shares photos you take on Mar 22, 9:00 AM – 3:00 PM automatically. // You can pause or stop anytime." String _autoSubtitle(Map album) { try { final start = DateTime.tryParse(album['event_start'] as String? ?? ''); final end = DateTime.tryParse(album['event_end'] as String? ?? ''); if (start == null || end == null) { return 'Picley shares photos you take during this event automatically. ' 'You can pause or stop anytime.'; } final months = ['Jan','Feb','Mar','Apr','May','Jun', 'Jul','Aug','Sep','Oct','Nov','Dec']; final date = '${months[start.month - 1]} ${start.day}'; final startT = _fmt12(start); final endT = _fmt12(end); return 'Picley shares photos you take on $date, $startT – $endT automatically. ' 'You can pause or stop anytime.'; } catch (_) { return 'Picley shares photos you take during this event automatically. ' 'You can pause or stop anytime.'; } } String _fmt12(DateTime dt) { final h = dt.hour % 12 == 0 ? 12 : dt.hour % 12; final m = dt.minute.toString().padLeft(2, '0'); final ap = dt.hour < 12 ? 'AM' : 'PM'; return m == '00' ? '$h $ap' : '$h:$m $ap'; } @override Widget build(BuildContext context) { final c = ThemeColors.of(context); if (_loading) return ThemedBackground(child: Scaffold( backgroundColor: Colors.transparent, body: Center(child: CircularProgressIndicator(color: c.accent)), )); if (_albumInfo == null) return ThemedBackground(child: Scaffold( backgroundColor: Colors.transparent, body: Center(child: Padding( padding: const EdgeInsets.all(24), child: Column(mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.link_off_outlined, size: 48, color: c.textTertiary), const SizedBox(height: 16), Text(_error ?? 'Event not found', textAlign: TextAlign.center, style: TextStyle(color: c.textPrimary)), const SizedBox(height: 16), TextButton(onPressed: () => context.go('/albums'), child: const Text('Go home')), ]), )), )); final album = _albumInfo!; final joinOpen = album['join_open'] == true; final eventName = album['name'] as String? ?? 'Event'; return ThemedBackground( child: Scaffold( backgroundColor: Colors.transparent, appBar: AppBar( backgroundColor: Colors.transparent, leading: IconButton( icon: const Icon(Icons.close), onPressed: () => context.go('/albums'), ), title: Text('Join event', style: TextStyle(color: c.textPrimary, fontSize: 16)), ), body: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(24, 8, 24, 40), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ // Event card ThemedCard( padding: const EdgeInsets.all(16), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ if (album['organiser_name'] != null) Text('${album['organiser_name']} invited you to', style: AppTextStyles.bodySmall.copyWith(color: c.textSecondary)), const SizedBox(height: 4), Text(eventName, style: AppTextStyles.titleLarge.copyWith(color: c.textPrimary)), const SizedBox(height: 6), Row(children: [ Icon(Icons.people_outline, size: 14, color: c.textTertiary), const SizedBox(width: 4), Text('${album['current_members'] ?? 0} people joined', style: AppTextStyles.bodySmall.copyWith(color: c.textTertiary)), ]), ]), ), if (!joinOpen) ...[ const SizedBox(height: 20), Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: c.error.withOpacity(0.08), borderRadius: BorderRadius.circular(10), border: Border.all(color: c.error.withOpacity(0.2)), ), child: Row(children: [ Icon(Icons.lock_outline, color: c.error, size: 16), const SizedBox(width: 10), Expanded(child: Text( 'This event is no longer accepting new members.', style: TextStyle(color: c.error, fontSize: 13), )), ]), ), ] else ...[ const SizedBox(height: 24), // Codeword Text('Codeword', style: AppTextStyles.titleSmall.copyWith(color: c.textPrimary)), const SizedBox(height: 4), Text('From your invitation message', style: AppTextStyles.bodySmall.copyWith(color: c.textTertiary)), const SizedBox(height: 10), TextField( controller: _codewordCtrl, autofocus: widget.prefillCodeword == null, autocorrect: false, enableSuggestions: false, style: TextStyle(color: c.textPrimary), decoration: InputDecoration( hintText: 'Enter the codeword', prefixIcon: Icon(Icons.vpn_key_outlined, size: 18, color: c.textTertiary), ), onSubmitted: (_) => _join(), ), const SizedBox(height: 24), ThemedDivider(), const SizedBox(height: 20), // Upload mode Text('How would you like to share photos?', style: AppTextStyles.titleSmall.copyWith(color: c.textPrimary)), const SizedBox(height: 12), // Auto — default, shown first _UploadModeOption( selected: _uploadMode == 'auto', onTap: () => setState(() => _uploadMode = 'auto'), title: 'Auto-share during the event', subtitle: _autoSubtitle(album), icon: Icons.sync_rounded, badge: 'Recommended', c: c, ), const SizedBox(height: 10), // Manual — shown second, reminder included _UploadModeOption( selected: _uploadMode == 'manual', onTap: () => setState(() => _uploadMode = 'manual'), title: 'I\'ll upload manually', subtitle: 'Choose which photos to share yourself. ' 'We\'ll email you after the event ends so you don\'t forget.', icon: Icons.upload_outlined, badge: null, c: c, ), const SizedBox(height: 28), if (_error != null) ...[ Container( width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: c.error.withOpacity(0.08), borderRadius: BorderRadius.circular(10), border: Border.all(color: c.error.withOpacity(0.2)), ), child: Text(_error!, style: TextStyle(color: c.error, fontSize: 13)), ), const SizedBox(height: 16), ], SizedBox( width: double.infinity, child: FilledButton( onPressed: _joining ? null : _join, child: _joining ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) : const Text('Join Event'), ), ), const SizedBox(height: 12), Center(child: TextButton( onPressed: () => context.go('/albums'), child: Text('Not now', style: TextStyle(color: c.textTertiary)), )), ], ]), ), ), ); } } class _UploadModeOption extends StatelessWidget { final bool selected; final VoidCallback onTap; final String title, subtitle; final IconData icon; final String? badge; // optional label e.g. "Hands-free" final ThemeColors c; const _UploadModeOption({required this.selected, required this.onTap, required this.title, required this.subtitle, required this.icon, required this.c, this.badge}); @override Widget build(BuildContext context) => GestureDetector( onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 150), padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: selected ? c.accentSurface : Colors.transparent, borderRadius: BorderRadius.circular(12), border: Border.all( color: selected ? c.accent : c.border, width: selected ? 1.5 : 1, ), ), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.only(top: 2), child: Icon(icon, color: selected ? c.accent : c.textSecondary, size: 22), ), const SizedBox(width: 12), Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Text(title, style: TextStyle( fontSize: 14, fontWeight: FontWeight.w500, color: c.textPrimary, )), if (badge != null) ...[ const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), decoration: BoxDecoration( color: c.accent.withOpacity(0.15), borderRadius: BorderRadius.circular(10), ), child: Text(badge!, style: TextStyle( fontSize: 10, fontWeight: FontWeight.w600, color: c.accent)), ), ], ]), const SizedBox(height: 4), Text(subtitle, style: TextStyle(fontSize: 12, color: c.textSecondary)), ])), const SizedBox(width: 8), if (selected) Padding( padding: const EdgeInsets.only(top: 2), child: Icon(Icons.check_circle_rounded, color: c.accent, size: 18), ), ]), ), ); }