import 'package:flutter/material.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'; import 'package:mileograph_flutter/services/data_service.dart'; class ProfilePage extends StatefulWidget { const ProfilePage({super.key}); @override State createState() => _ProfilePageState(); } class _ProfilePageState extends State { final TextEditingController _searchController = TextEditingController(); final TextEditingController _currentPasswordController = TextEditingController(); final TextEditingController _newPasswordController = TextEditingController(); final TextEditingController _confirmPasswordController = TextEditingController(); final _passwordFormKey = GlobalKey(); List _searchResults = []; bool _searching = false; String? _searchError; bool _fetched = false; bool _privacyLoaded = false; String? _privacyForUserId; bool _privacyDirty = false; bool _showAccountSettings = false; bool _changingPassword = false; static const List _visibilityOptions = ['private', 'friends', 'public']; UserSummary? _selectedUser; Friendship? _status; bool _statusLoading = false; bool _actionLoading = false; String _entriesVisibility = 'private'; String _mileageVisibility = 'private'; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || _fetched) return; _fetched = true; final data = context.read(); data.fetchFriendships(); data.fetchPendingFriendships(); }); } @override void didChangeDependencies() { super.didChangeDependencies(); final auth = context.watch(); final userId = auth.userId; if (userId != null && userId != _privacyForUserId) { _privacyForUserId = userId; _loadPrivacySettings(); } } @override void dispose() { _searchController.dispose(); _currentPasswordController.dispose(); _newPasswordController.dispose(); _confirmPasswordController.dispose(); super.dispose(); } Future _searchUsers() async { final query = _searchController.text.trim(); if (query.isEmpty) { setState(() { _searchResults = []; _searchError = null; }); return; } setState(() { _searching = true; _searchError = null; }); try { final results = await context.read().searchUsers(query); if (!mounted) return; setState(() { _searchResults = results; }); } catch (e) { if (!mounted) return; setState(() { _searchError = 'Search failed'; }); } finally { if (mounted) setState(() => _searching = false); } } Future _loadStatus(UserSummary user) async { setState(() { _selectedUser = user; _statusLoading = true; }); try { final status = await context.read().fetchFriendshipStatus(user.userId); if (!mounted) return; setState(() => _status = status); } catch (_) { if (!mounted) return; setState(() => _status = null); } finally { if (mounted) setState(() => _statusLoading = false); } } Future _sendRequest(UserSummary user) async { setState(() => _actionLoading = true); try { final status = await context.read().requestFriendship( user.userId, targetUser: user, ); if (!mounted) return; setState(() => _status = status); _showSnack('Friend request sent'); } catch (e) { _showSnack('Failed to send request: $e'); } finally { if (mounted) setState(() => _actionLoading = false); } } Future _cancelRequest(Friendship status) async { final id = status.id; if (id == null || id.isEmpty) return; setState(() => _actionLoading = true); try { await context.read().cancelFriendship(id); if (!mounted) return; setState(() => _status = status.copyWith(status: 'none')); _showSnack('Request cancelled'); } catch (e) { _showSnack('Failed to cancel: $e'); } finally { if (mounted) setState(() => _actionLoading = false); } } Future _accept(Friendship status) async { final id = status.id; if (id == null || id.isEmpty) return; setState(() => _actionLoading = true); try { final updated = await context.read().acceptFriendship(id); if (!mounted) return; setState(() => _status = updated); _showSnack('Friend request accepted'); } catch (e) { _showSnack('Failed to accept: $e'); } finally { if (mounted) setState(() => _actionLoading = false); } } Future _reject(Friendship status) async { final id = status.id; if (id == null || id.isEmpty) return; setState(() => _actionLoading = true); try { final updated = await context.read().rejectFriendship(id); if (!mounted) return; setState(() => _status = updated); _showSnack('Friend request rejected'); } catch (e) { _showSnack('Failed to reject: $e'); } finally { if (mounted) setState(() => _actionLoading = false); } } Future _unfriend(Friendship status) async { final id = status.id; if (id == null || id.isEmpty) return; final confirm = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Remove friend'), content: const Text('Are you sure you want to remove this friend?'), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Cancel'), ), TextButton( onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Remove'), ), ], ), ); if (confirm != true) return; if (!mounted) return; setState(() => _actionLoading = true); try { await context.read().deleteFriendship(id); if (!mounted) return; setState(() => _status = status.copyWith(status: 'none')); _showSnack('Friend removed'); } catch (e) { _showSnack('Failed to remove friend: $e'); } finally { if (mounted) setState(() => _actionLoading = false); } } Future _loadPrivacySettings() async { final data = context.read(); final auth = context.read(); setState(() { _entriesVisibility = auth.entriesVisibility.isNotEmpty ? auth.entriesVisibility : data.userEntriesVisibility; _mileageVisibility = auth.mileageVisibility.isNotEmpty ? auth.mileageVisibility : data.userMileageVisibility; _privacyDirty = false; _privacyLoaded = true; }); await data.fetchPrivacySettings(); if (!mounted) return; setState(() { _entriesVisibility = data.userEntriesVisibility; _mileageVisibility = data.userMileageVisibility; _privacyDirty = false; _privacyLoaded = true; }); } int _visibilityRank(String value) { switch (value.toLowerCase()) { case 'public': return 2; case 'friends': return 1; default: return 0; } } String _visibilityLabel(String value) { switch (value) { case 'friends': return 'Friends'; case 'public': return 'Public'; default: return 'Private'; } } void _setEntriesVisibility(String value) { setState(() { _entriesVisibility = value; if (_visibilityRank(_mileageVisibility) < _visibilityRank(value)) { _mileageVisibility = value; } _privacyDirty = true; }); } void _setMileageVisibility(String value) { if (_visibilityRank(value) < _visibilityRank(_entriesVisibility)) { value = _entriesVisibility; } setState(() { _mileageVisibility = value; _privacyDirty = true; }); } Future _savePrivacy() async { final messenger = ScaffoldMessenger.of(context); final data = context.read(); final entries = _entriesVisibility; final mileage = _mileageVisibility; try { await data.updatePrivacySettings( entriesVisibility: entries, mileageVisibility: mileage, ); if (!mounted) return; setState(() { _entriesVisibility = data.userEntriesVisibility; _mileageVisibility = data.userMileageVisibility; _privacyDirty = false; }); messenger.showSnackBar( const SnackBar(content: Text('Privacy settings updated.')), ); } catch (e) { if (!mounted) return; setState(() => _privacyDirty = true); messenger.showSnackBar( SnackBar(content: Text('Failed to update privacy settings: $e')), ); } } Future _changePassword() async { final messenger = ScaffoldMessenger.of(context); final formState = _passwordFormKey.currentState; if (formState == null || !formState.validate()) return; FocusScope.of(context).unfocus(); setState(() => _changingPassword = true); try { final api = context.read(); await api.post('/user/password/change', { 'old_password': _currentPasswordController.text, 'new_password': _newPasswordController.text, }); if (!mounted) return; messenger.showSnackBar( const SnackBar(content: Text('Password updated successfully.')), ); formState.reset(); _currentPasswordController.clear(); _newPasswordController.clear(); _confirmPasswordController.clear(); } catch (e) { if (mounted) { messenger.showSnackBar( SnackBar(content: Text('Failed to change password: $e')), ); } } finally { if (mounted) { setState(() => _changingPassword = false); } } } void _showSnack(String message) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); } @override Widget build(BuildContext context) { final auth = context.watch(); final data = context.watch(); final statsUser = data.homepageStats?.user; final name = auth.fullName?.isNotEmpty == true ? auth.fullName! : (statsUser?.fullName ?? ''); final username = auth.username ?? statsUser?.username ?? ''; final email = statsUser?.email ?? ''; return Scaffold( appBar: AppBar( leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () { final nav = Navigator.of(context); if (nav.canPop()) { nav.maybePop(); } else { context.go('/more'); } }, tooltip: 'Back', ), title: const Text('Profile'), ), body: RefreshIndicator( onRefresh: () async { await data.fetchFriendships(); await data.fetchPendingFriendships(); await _loadPrivacySettings(); if (_selectedUser != null) { await _loadStatus(_selectedUser!); } }, child: ListView( padding: const EdgeInsets.all(16), children: [ _buildUserCard(name: name, username: username, email: email), const SizedBox(height: 16), _buildSearchSection(), const SizedBox(height: 16), _buildSelectedUserSection(auth), const SizedBox(height: 16), _buildFriendsList(auth), const SizedBox(height: 16), _buildAccountSection(), ], ), ), ); } Widget _buildUserCard({ required String name, required String username, required String email, }) { return Card( child: ListTile( leading: const CircleAvatar(child: Icon(Icons.person)), title: Text(name.isNotEmpty ? name : 'You'), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (username.isNotEmpty) Text('@$username'), if (email.isNotEmpty) Text(email), ], ), ), ); } Widget _buildSearchSection() { return Card( child: Padding( padding: const EdgeInsets.all(12.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Find a user', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), Row( children: [ Expanded( child: TextField( controller: _searchController, decoration: const InputDecoration( labelText: 'Search by username, email, or name', border: OutlineInputBorder(), ), onSubmitted: (_) => _searchUsers(), ), ), const SizedBox(width: 8), ElevatedButton( onPressed: _searching ? null : _searchUsers, child: _searching ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Text('Search'), ), ], ), if (_searchError != null) Padding( padding: const EdgeInsets.only(top: 8.0), child: Text( _searchError!, style: TextStyle(color: Theme.of(context).colorScheme.error), ), ), if (_searchResults.isNotEmpty) const SizedBox(height: 12), if (_searchResults.isNotEmpty) ..._searchResults.map( (user) => ListTile( leading: const Icon(Icons.person), title: Text(user.displayName), subtitle: user.username.isNotEmpty ? Text('@${user.username}') : null, trailing: TextButton( onPressed: () => context.pushNamed( 'user-profile', extra: user, queryParameters: {'user_id': user.userId}, ), child: const Text('View'), ), ), ), ], ), ), ); } Widget _buildSelectedUserSection(AuthService auth) { final user = _selectedUser; if (user == null) return const SizedBox.shrink(); final status = _status; final loading = _statusLoading; final isSelf = auth.userId == user.userId; return Card( child: Padding( padding: const EdgeInsets.all(12.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( user.displayName, style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(width: 8), if (loading) const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ), if (!loading && status != null) _buildStatusChip(status, auth), ], ), if (user.username.isNotEmpty) Text('@${user.username}'), const SizedBox(height: 8), if (!isSelf) _buildActions(status, user, auth), if (isSelf) const Text('This is you.', style: TextStyle(fontStyle: FontStyle.italic)), ], ), ), ); } Widget _buildStatusChip(Friendship status, AuthService auth) { String label = status.status; Color color = Colors.grey; switch (status.status.toLowerCase()) { case 'accepted': label = 'Friends'; color = Colors.green; break; case 'pending': final isRequester = status.requesterId == auth.userId; label = isRequester ? 'Pending (you sent)' : 'Pending (waiting on you)'; color = Colors.orange; break; case 'blocked': color = Colors.red; label = 'Blocked'; break; case 'declined': case 'rejected': label = 'Declined'; break; default: label = 'Not friends'; } final bg = Color.alphaBlend( color.withValues(alpha: 0.15), Theme.of(context).colorScheme.surface, ); return Container( margin: const EdgeInsets.only(left: 6), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: bg, borderRadius: BorderRadius.circular(20), ), child: Text(label), ); } Widget _buildActions( Friendship? status, UserSummary user, AuthService auth, ) { if (status == null) { return const SizedBox.shrink(); } final isRequester = status.requesterId == auth.userId; final id = status.id; final buttons = []; if (status.isNone || status.isDeclined) { buttons.add( ElevatedButton.icon( onPressed: _actionLoading ? null : () => _sendRequest(user), icon: const Icon(Icons.person_add), label: const Text('Send friend request'), ), ); } else if (status.isPending) { if (isRequester) { buttons.add( OutlinedButton( onPressed: _actionLoading || id == null || id.isEmpty ? null : () => _cancelRequest(status), child: const Text('Cancel request'), ), ); } else { buttons.add( ElevatedButton( onPressed: _actionLoading || id == null || id.isEmpty ? null : () => _accept(status), child: const Text('Accept'), ), ); buttons.add( OutlinedButton( onPressed: _actionLoading || id == null || id.isEmpty ? null : () => _reject(status), child: const Text('Reject'), ), ); } } else if (status.isAccepted) { buttons.add( ElevatedButton.icon( onPressed: _actionLoading || id == null || id.isEmpty ? null : () => _unfriend(status), icon: const Icon(Icons.person_remove), label: const Text('Unfriend'), ), ); // Block action temporarily removed until backend support exists. } else if (status.isBlocked) { buttons.add(const Text('User is blocked.')); } if (buttons.isEmpty) return const SizedBox.shrink(); return Wrap( spacing: 8, runSpacing: 8, children: buttons, ); } Widget _buildFriendsList(AuthService auth) { final data = context.watch(); final friends = data.friendships; final incoming = data.pendingIncoming; final outgoing = data.pendingOutgoing; final loading = data.isFriendshipsLoading; final pendingLoading = data.isPendingFriendshipsLoading; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (pendingLoading && incoming.isEmpty && outgoing.isEmpty) const Padding( padding: EdgeInsets.symmetric(vertical: 12.0), child: Center(child: CircularProgressIndicator()), ), if (incoming.isNotEmpty || outgoing.isNotEmpty) Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Pending requests', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), if (outgoing.isNotEmpty) ...outgoing.map((f) { final otherUser = _otherUser(f, auth.userId); return Card( child: ListTile( leading: const Icon(Icons.person), title: Text(otherUser?.displayName ?? 'User'), subtitle: otherUser?.username.isNotEmpty == true ? Text('@${otherUser!.username}') : null, trailing: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.north_east, size: 18), const SizedBox(width: 6), TextButton( onPressed: f.id == null || _actionLoading ? null : () => _cancelRequest(f), child: const Text('Cancel'), ), ], ), ), ); }), if (incoming.isNotEmpty && outgoing.isNotEmpty) const SizedBox(height: 8), if (incoming.isNotEmpty) ...incoming.map((f) { final otherUser = _otherUser(f, auth.userId); return Card( child: ListTile( leading: const Icon(Icons.person), title: Text(otherUser?.displayName ?? 'User'), subtitle: otherUser?.username.isNotEmpty == true ? Text('@${otherUser!.username}') : null, trailing: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.south_west, size: 18), const SizedBox(width: 6), Wrap( spacing: 8, children: [ TextButton( onPressed: f.id == null || _actionLoading ? null : () => _accept(f), child: const Text('Accept'), ), TextButton( onPressed: f.id == null || _actionLoading ? null : () => _reject(f), child: const Text('Reject'), ), ], ), ], ), ), ); }), const SizedBox(height: 12), ], ), Text( 'Friends', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), if (loading && friends.isEmpty) const Padding( padding: EdgeInsets.symmetric(vertical: 12.0), child: Center(child: CircularProgressIndicator()), ) else if (friends.isEmpty) const Text('No friends yet.') else ...friends.map((f) { final otherUser = _otherUser(f, auth.userId); return Card( child: ListTile( leading: const Icon(Icons.person), title: Text(otherUser?.displayName ?? 'User'), subtitle: otherUser?.username.isNotEmpty == true ? Text('@${otherUser!.username}') : null, trailing: TextButton( onPressed: () { final user = otherUser; if (user != null) { final auth = context.read(); final isSelf = auth.userId == user.userId; if (isSelf) { context.go('/more/profile'); } else { context.pushNamed( 'user-profile', extra: user, queryParameters: {'user_id': user.userId}, ); } } }, child: const Text('View'), ), ), ); }), ], ); } Widget _buildAccountSection() { final data = context.watch(); final theme = Theme.of(context); final privacySaving = data.isPrivacySaving; final showPrivacySpinner = data.isPrivacyLoading && !_privacyLoaded; final privacyInputsDisabled = privacySaving || showPrivacySpinner; return Card( child: Padding( padding: const EdgeInsets.all(12.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( 'Account & privacy', style: theme.textTheme.titleMedium, ), TextButton.icon( onPressed: () => setState( () => _showAccountSettings = !_showAccountSettings, ), icon: Icon( _showAccountSettings ? Icons.expand_less : Icons.expand_more, ), label: Text( _showAccountSettings ? 'Hide settings' : 'More settings', ), ), ], ), AnimatedCrossFade( crossFadeState: _showAccountSettings ? CrossFadeState.showFirst : CrossFadeState.showSecond, duration: const Duration(milliseconds: 200), firstChild: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (showPrivacySpinner) const Padding( padding: EdgeInsets.symmetric(vertical: 8.0), child: Center( child: CircularProgressIndicator(strokeWidth: 2), ), ) else ...[ Text( 'Privacy settings', style: theme.textTheme.titleMedium ?.copyWith(fontWeight: FontWeight.w700), ), const SizedBox(height: 8), DropdownButtonFormField( value: _entriesVisibility, decoration: const InputDecoration( labelText: 'Entry privacy', border: OutlineInputBorder(), ), items: _visibilityOptions .map( (option) => DropdownMenuItem( value: option, child: Text(_visibilityLabel(option)), ), ) .toList(), onChanged: privacyInputsDisabled ? null : (value) { if (value == null) return; _setEntriesVisibility(value); }, ), const SizedBox(height: 12), DropdownButtonFormField( value: _mileageVisibility, decoration: const InputDecoration( labelText: 'Mileage privacy', border: OutlineInputBorder(), ), items: _visibilityOptions .map((option) { final enabled = _visibilityRank(option) >= _visibilityRank(_entriesVisibility); final textColor = enabled ? null : theme.disabledColor; return DropdownMenuItem( value: option, enabled: enabled, child: Text( _visibilityLabel(option), style: textColor == null ? null : TextStyle(color: textColor), ), ); }) .toList(), onChanged: privacyInputsDisabled ? null : (value) { if (value == null) return; _setMileageVisibility(value); }, ), const SizedBox(height: 6), Text( 'Mileage visibility cannot be more restrictive than entry visibility.', style: theme.textTheme.bodySmall, ), const SizedBox(height: 12), FilledButton.icon( onPressed: (privacySaving || !_privacyDirty || showPrivacySpinner) ? null : _savePrivacy, icon: privacySaving ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.save), label: Text( privacySaving ? 'Saving...' : 'Save privacy', ), ), ], const Divider(height: 28), Text( 'Change password', style: theme.textTheme.titleMedium ?.copyWith(fontWeight: FontWeight.w700), ), const SizedBox(height: 8), Text( 'Change your password for this account.', style: theme.textTheme.bodySmall, ), const SizedBox(height: 12), Form( key: _passwordFormKey, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TextFormField( controller: _currentPasswordController, decoration: const InputDecoration( labelText: 'Current password', border: OutlineInputBorder(), ), obscureText: true, enableSuggestions: false, autocorrect: false, autofillHints: const [AutofillHints.password], validator: (value) { if (value == null || value.isEmpty) { return 'Please enter your current password.'; } return null; }, ), const SizedBox(height: 12), TextFormField( controller: _newPasswordController, decoration: const InputDecoration( labelText: 'New password', border: OutlineInputBorder(), ), obscureText: true, enableSuggestions: false, autocorrect: false, autofillHints: const [AutofillHints.newPassword], validator: (value) { if (value == null || value.isEmpty) { return 'Please enter a new password.'; } return null; }, ), const SizedBox(height: 12), TextFormField( controller: _confirmPasswordController, decoration: const InputDecoration( labelText: 'Confirm new password', border: OutlineInputBorder(), ), obscureText: true, enableSuggestions: false, autocorrect: false, autofillHints: const [AutofillHints.newPassword], validator: (value) { if (value == null || value.isEmpty) { return 'Please confirm the new password.'; } if (value != _newPasswordController.text) { return 'New passwords do not match.'; } return null; }, ), const SizedBox(height: 16), FilledButton.icon( onPressed: _changingPassword ? null : _changePassword, icon: _changingPassword ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.lock_reset), label: Text( _changingPassword ? 'Updating...' : 'Change password', ), ), ], ), ), ], ), secondChild: const SizedBox.shrink(), ), ], ), ), ); } UserSummary? _otherUser(Friendship friendship, String? currentUserId) { final selfId = currentUserId ?? ''; if (friendship.requester?.userId == selfId) return friendship.addressee; if (friendship.addressee?.userId == selfId) return friendship.requester; if (friendship.addresseeId == selfId && friendship.requester != null) { return friendship.requester; } if (friendship.requesterId == selfId && friendship.addressee != null) { return friendship.addressee; } if (friendship.addressee != null) return friendship.addressee; if (friendship.requester != null) return friendship.requester; return null; } }