add profile page, privacy options
All checks were successful
Release / meta (push) Successful in 11s
Release / linux-build (push) Successful in 1m0s
Release / web-build (push) Successful in 2m29s
Release / android-build (push) Successful in 10m26s
Release / release-master (push) Successful in 37s
Release / release-dev (push) Successful in 49s

This commit is contained in:
2026-01-04 19:50:06 +00:00
parent af37e25692
commit 42ac7a97e1
11 changed files with 1327 additions and 169 deletions

View File

@@ -1,6 +1,8 @@
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';
@@ -13,15 +15,29 @@ class ProfilePage extends StatefulWidget {
class _ProfilePageState extends State<ProfilePage> {
final TextEditingController _searchController = TextEditingController();
final TextEditingController _currentPasswordController =
TextEditingController();
final TextEditingController _newPasswordController = TextEditingController();
final TextEditingController _confirmPasswordController =
TextEditingController();
final _passwordFormKey = GlobalKey<FormState>();
List<UserSummary> _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<String> _visibilityOptions = ['private', 'friends', 'public'];
UserSummary? _selectedUser;
Friendship? _status;
bool _statusLoading = false;
bool _actionLoading = false;
String _entriesVisibility = 'private';
String _mileageVisibility = 'private';
@override
void initState() {
@@ -35,9 +51,23 @@ class _ProfilePageState extends State<ProfilePage> {
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final auth = context.watch<AuthService>();
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();
}
@@ -188,6 +218,134 @@ class _ProfilePageState extends State<ProfilePage> {
}
}
Future<void> _loadPrivacySettings() async {
final data = context.read<DataService>();
final auth = context.read<AuthService>();
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<void> _savePrivacy() async {
final messenger = ScaffoldMessenger.of(context);
final data = context.read<DataService>();
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<void> _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<ApiService>();
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)));
}
@@ -211,6 +369,7 @@ class _ProfilePageState extends State<ProfilePage> {
onRefresh: () async {
await data.fetchFriendships();
await data.fetchPendingFriendships();
await _loadPrivacySettings();
if (_selectedUser != null) {
await _loadStatus(_selectedUser!);
}
@@ -225,6 +384,8 @@ class _ProfilePageState extends State<ProfilePage> {
_buildSelectedUserSection(auth),
const SizedBox(height: 16),
_buildFriendsList(auth),
const SizedBox(height: 16),
_buildAccountSection(),
],
),
),
@@ -297,18 +458,22 @@ class _ProfilePageState extends State<ProfilePage> {
),
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,
..._searchResults.map(
(user) => ListTile(
leading: const Icon(Icons.person),
title: Text(user.displayName),
subtitle:
user.username.isNotEmpty ? Text('@${user.username}') : null,
trailing: TextButton(
onPressed: () => _loadStatus(user),
onPressed: () => context.goNamed(
'user-profile',
extra: user,
queryParameters: {'user_id': user.userId},
),
child: const Text('View'),
),
),
),
),
],
),
),
@@ -596,6 +761,241 @@ class _ProfilePageState extends State<ProfilePage> {
);
}
Widget _buildAccountSection() {
final data = context.watch<DataService>();
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<String>(
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<String>(
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;