diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 191b44e..f5cbe14 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -44,7 +44,7 @@ jobs: DEV_SUFFIX="-dev.${DEV_ITER}" VERSION="${BASE_VERSION}${DEV_SUFFIX}" - TAG="v${VERSION}" + TAG="${VERSION}" fi echo "base=${BASE_VERSION}" >> "$GITHUB_OUTPUT" diff --git a/lib/app.dart b/lib/app.dart index c721ea3..79f78ea 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -35,7 +35,11 @@ class App extends StatelessWidget { create: (context) => DataService(api: context.read()), update: (context, auth, data) { data ??= DataService(api: context.read()); - data.handleAuthChanged(auth.userId); + data.handleAuthChanged( + auth.userId, + entriesVisibility: auth.entriesVisibility, + mileageVisibility: auth.mileageVisibility, + ); return data; }, ), diff --git a/lib/components/pages/legs.dart b/lib/components/pages/legs.dart index fbec1ea..c3debbe 100644 --- a/lib/components/pages/legs.dart +++ b/lib/components/pages/legs.dart @@ -17,6 +17,8 @@ class _LegsPageState extends State { DateTime? _startDate; DateTime? _endDate; bool _initialised = false; + bool _unallocatedOnly = false; + bool _showMoreFilters = false; @override void didChangeDependencies() { @@ -33,6 +35,7 @@ class _LegsPageState extends State { sortDirection: _sortDirection, dateRangeStart: _formatDate(_startDate), dateRangeEnd: _formatDate(_endDate), + unallocatedOnly: _unallocatedOnly, ); } @@ -44,6 +47,7 @@ class _LegsPageState extends State { dateRangeEnd: _formatDate(_endDate), offset: data.legs.length, append: true, + unallocatedOnly: _unallocatedOnly, ); } @@ -84,6 +88,8 @@ class _LegsPageState extends State { _startDate = null; _endDate = null; _sortDirection = 0; + _unallocatedOnly = false; + _showMoreFilters = false; }); _refreshLegs(); } @@ -177,8 +183,46 @@ class _LegsPageState extends State { : _formatDate(_endDate!)!, ), ), + TextButton.icon( + onPressed: () => setState( + () => _showMoreFilters = !_showMoreFilters, + ), + icon: Icon( + _showMoreFilters + ? Icons.expand_less + : Icons.expand_more, + ), + label: Text( + _showMoreFilters ? 'Hide filters' : 'More filters', + ), + ), ], ), + AnimatedCrossFade( + crossFadeState: _showMoreFilters + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + duration: const Duration(milliseconds: 200), + firstChild: Padding( + padding: const EdgeInsets.only(top: 12.0), + child: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + FilterChip( + avatar: const Icon(Icons.flash_off), + label: const Text('Unallocated only'), + selected: _unallocatedOnly, + onSelected: (selected) async { + setState(() => _unallocatedOnly = selected); + await _refreshLegs(); + }, + ), + ], + ), + ), + secondChild: const SizedBox.shrink(), + ), ], ), ), diff --git a/lib/components/pages/more/user_profile_page.dart b/lib/components/pages/more/user_profile_page.dart new file mode 100644 index 0000000..62d21aa --- /dev/null +++ b/lib/components/pages/more/user_profile_page.dart @@ -0,0 +1,515 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; +import 'package:mileograph_flutter/components/legs/leg_card.dart'; +import 'package:mileograph_flutter/objects/objects.dart'; +import 'package:mileograph_flutter/services/authservice.dart'; +import 'package:mileograph_flutter/services/data_service.dart'; + +class UserProfilePage extends StatefulWidget { + const UserProfilePage({super.key, this.userId, this.initialUser}); + + final String? userId; + final UserSummary? initialUser; + + @override + State createState() => _UserProfilePageState(); +} + +class _UserProfilePageState extends State { + UserProfileDetail? _profile; + List _legs = const []; + bool _loading = false; + bool _loadingMore = false; + bool _hasMore = false; + Friendship? _friendship; + bool _actionsLoading = false; + + String? get _userId => widget.initialUser?.userId ?? widget.userId; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _loadProfile(); + }); + } + + Future _loadProfile() async { + final userId = _userId; + if (userId == null || userId.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('No user selected.')), + ); + context.pop(); + } + return; + } + setState(() { + _loading = true; + _hasMore = false; + _legs = const []; + }); + final data = context.read(); + try { + final profile = await data.fetchUserProfileDetail(userId); + final friendship = await data.fetchFriendshipStatus(userId); + if (!mounted) return; + if (profile == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Failed to load user profile.')), + ); + return; + } + final legs = profile.legs; + setState(() { + _profile = profile; + _legs = legs; + _hasMore = legs.length >= 25; + _friendship = friendship; + }); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _loadMore() async { + final userId = _userId; + if (userId == null || userId.isEmpty || _loadingMore || !_hasMore) return; + setState(() => _loadingMore = true); + final data = context.read(); + try { + final more = await data.fetchUserLegs( + userId: userId, + offset: _legs.length, + ); + if (!mounted) return; + setState(() { + _legs = [..._legs, ...more]; + _hasMore = more.length >= 25; + }); + } finally { + if (mounted) setState(() => _loadingMore = false); + } + } + + void _handleBack() { + final router = GoRouter.of(context); + if (router.canPop()) { + router.pop(); + } else { + router.go('/more/profile'); + } + } + + Widget _buildProfileHeader(ThemeData theme) { + final profile = _profile; + final username = profile?.username ?? widget.initialUser?.username ?? ''; + final fullName = profile?.fullName ?? widget.initialUser?.fullName ?? ''; + final mileage = profile?.mileage; + final privacy = profile?.privacyInfo; + final mileageHidden = + (mileage == null || mileage == 0) && privacy != null && privacy.isNotEmpty; + return Card( + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const CircleAvatar(child: Icon(Icons.person)), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + fullName.isNotEmpty ? fullName : username, + style: theme.textTheme.titleMedium, + ), + if (username.isNotEmpty) + Text('@$username', style: theme.textTheme.bodySmall), + ], + ), + ], + ), + const SizedBox(height: 12), + Text( + mileageHidden + ? 'Mileage hidden' + : 'Mileage: ${(mileage ?? 0).toStringAsFixed(1)}', + ), + ], + ), + ), + ); + } + + Widget _buildTopLocos() { + final profile = _profile; + if (profile == null || profile.topLocos.isEmpty) { + return const SizedBox.shrink(); + } + final topTen = [...profile.topLocos] + ..sort( + (a, b) => (b.mileage ?? 0).compareTo(a.mileage ?? 0), + ); + final displayLocos = topTen.take(10).toList(); + return Card( + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Top locos by mileage', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + ListView.separated( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: displayLocos.length, + separatorBuilder: (context, index) => const Divider(height: 1), + itemBuilder: (context, index) { + final loco = displayLocos[index]; + final mileage = loco.mileage ?? 0; + return ListTile( + leading: CircleAvatar( + backgroundColor: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.1), + child: Text( + '${index + 1}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + title: Text( + loco.number.isNotEmpty ? loco.number : 'Unknown', + style: Theme.of(context).textTheme.bodyLarge, + ), + subtitle: Text(loco.locoClass), + trailing: Text( + '${mileage.toStringAsFixed(1)} mi', + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + ); + }, + ), + ], + ), + ), + ); + } + + List _buildLegsWithDividers( + BuildContext context, + List legs, + ) { + final widgets = []; + String? currentDate; + final dayLegs = []; + + void flushDay() { + final date = currentDate; + if (date == null) return; + widgets.add( + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Text( + date, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + ); + widgets.add(const Divider()); + widgets.addAll( + dayLegs.map((leg) => LegCard(leg: leg, showDate: false)), + ); + dayLegs.clear(); + } + + for (final leg in legs) { + final dateStr = _formatDate(leg.beginTime) ?? ''; + if (currentDate != null && dateStr != currentDate) { + flushDay(); + } + currentDate = dateStr; + dayLegs.add(leg); + } + + flushDay(); + return widgets; + } + + String? _formatDate(DateTime? date) { + if (date == null) return null; + return '${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } + + Widget _buildFriendSection(AuthService auth) { + final friendship = _friendship; + if (friendship == null) { + return const SizedBox.shrink(); + } + return Card( + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Friendship', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(width: 8), + _buildStatusChip(friendship, auth), + ], + ), + const SizedBox(height: 8), + _buildActions(friendship, auth), + ], + ), + ), + ); + } + + 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 (needs your reply)'; + 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, AuthService auth) { + final isSelf = status.addresseeId == auth.userId || status.requesterId == auth.userId; + if (isSelf) { + return const Text('This is you.'); + } + final isRequester = status.requesterId == auth.userId; + final id = status.id; + final buttons = []; + + Future run(Future Function() action) async { + setState(() => _actionsLoading = true); + try { + await action(); + } finally { + if (mounted) setState(() => _actionsLoading = false); + } + } + + final data = context.read(); + + if (status.isNone || status.isDeclined) { + buttons.add( + ElevatedButton.icon( + onPressed: _actionsLoading + ? null + : () => run(() async { + final updated = await data.requestFriendship(status.addresseeId); + if (!mounted) return; + setState(() => _friendship = updated); + }), + icon: const Icon(Icons.person_add), + label: const Text('Send friend request'), + ), + ); + } else if (status.isPending) { + if (isRequester) { + buttons.add( + OutlinedButton( + onPressed: _actionsLoading || id == null || id.isEmpty + ? null + : () => run(() async { + await data.cancelFriendship(id); + if (!mounted) return; + setState(() => _friendship = status.copyWith(status: 'none')); + }), + child: const Text('Cancel request'), + ), + ); + } else { + buttons.add( + ElevatedButton( + onPressed: _actionsLoading || id == null || id.isEmpty + ? null + : () => run(() async { + final updated = await data.acceptFriendship(id); + if (!mounted) return; + setState(() => _friendship = updated); + }), + child: const Text('Accept'), + ), + ); + buttons.add( + OutlinedButton( + onPressed: _actionsLoading || id == null || id.isEmpty + ? null + : () => run(() async { + final updated = await data.rejectFriendship(id); + if (!mounted) return; + setState(() => _friendship = updated); + }), + child: const Text('Reject'), + ), + ); + } + } else if (status.isAccepted) { + buttons.add( + ElevatedButton.icon( + onPressed: _actionsLoading || id == null || id.isEmpty + ? null + : () => run(() async { + await data.deleteFriendship(id); + if (!mounted) return; + setState(() => _friendship = status.copyWith(status: 'none')); + }), + icon: const Icon(Icons.person_remove), + label: const Text('Unfriend'), + ), + ); + } + + if (buttons.isEmpty) return const SizedBox.shrink(); + + return Wrap( + spacing: 8, + runSpacing: 8, + children: buttons, + ); + } + + @override + Widget build(BuildContext context) { + final auth = context.watch(); + final theme = Theme.of(context); + return Scaffold( + appBar: AppBar( + title: const Text('User profile'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: _handleBack, + ), + ), + body: RefreshIndicator( + onRefresh: _loadProfile, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildProfileHeader(theme), + const SizedBox(height: 12), + _buildFriendSection(auth), + const SizedBox(height: 12), + _buildTopLocos(), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Entries', + style: theme.textTheme.titleMedium, + ), + if (_loading) + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ], + ), + const SizedBox(height: 8), + if (_loading && _legs.isEmpty) + const Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 16.0), + child: CircularProgressIndicator(), + ), + ) + else if (_legs.isEmpty) + Text( + (_profile?.privacyInfo.isNotEmpty ?? false) + ? 'Legs hidden due to privacy settings.' + : 'No entries found.', + ) + else ...[ + ..._buildLegsWithDividers(context, _legs), + const SizedBox(height: 8), + if (_hasMore || _loadingMore) + Align( + alignment: Alignment.center, + child: OutlinedButton.icon( + onPressed: _loadingMore ? null : _loadMore, + icon: _loadingMore + ? const SizedBox( + height: 14, + width: 14, + child: + CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.expand_more), + label: Text( + _loadingMore ? 'Loading...' : 'Load more', + ), + ), + ), + ], + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/components/pages/profile.dart b/lib/components/pages/profile.dart index 92fef75..2499d37 100644 --- a/lib/components/pages/profile.dart +++ b/lib/components/pages/profile.dart @@ -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 { 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() { @@ -35,9 +51,23 @@ class _ProfilePageState extends State { }); } + @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(); } @@ -188,6 +218,134 @@ class _ProfilePageState extends State { } } + 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))); } @@ -211,6 +369,7 @@ class _ProfilePageState extends State { onRefresh: () async { await data.fetchFriendships(); await data.fetchPendingFriendships(); + await _loadPrivacySettings(); if (_selectedUser != null) { await _loadStatus(_selectedUser!); } @@ -225,6 +384,8 @@ class _ProfilePageState extends State { _buildSelectedUserSection(auth), const SizedBox(height: 16), _buildFriendsList(auth), + const SizedBox(height: 16), + _buildAccountSection(), ], ), ), @@ -297,18 +458,22 @@ class _ProfilePageState extends State { ), 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 { ); } + 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; diff --git a/lib/components/pages/settings.dart b/lib/components/pages/settings.dart index e00218d..29f00a5 100644 --- a/lib/components/pages/settings.dart +++ b/lib/components/pages/settings.dart @@ -3,8 +3,6 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:http/http.dart' as http; -import 'package:mileograph_flutter/services/authservice.dart'; -import 'package:mileograph_flutter/services/api_service.dart'; import 'package:mileograph_flutter/services/distance_unit_service.dart'; import 'package:mileograph_flutter/services/endpoint_service.dart'; import 'package:mileograph_flutter/services/data_service.dart'; @@ -20,27 +18,16 @@ class SettingsPage extends StatefulWidget { class _SettingsPageState extends State { late final TextEditingController _endpointController; bool _saving = false; - bool _changingPassword = false; - final _passwordFormKey = GlobalKey(); - late final TextEditingController _currentPasswordController; - late final TextEditingController _newPasswordController; - late final TextEditingController _confirmPasswordController; @override void initState() { super.initState(); final endpoint = context.read().baseUrl; _endpointController = TextEditingController(text: endpoint); - _currentPasswordController = TextEditingController(); - _newPasswordController = TextEditingController(); - _confirmPasswordController = TextEditingController(); } @override void dispose() { - _currentPasswordController.dispose(); - _newPasswordController.dispose(); - _confirmPasswordController.dispose(); _endpointController.dispose(); super.dispose(); } @@ -139,47 +126,10 @@ class _SettingsPageState extends State { } } - 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); - } - } - } - @override Widget build(BuildContext context) { final endpointService = context.watch(); final distanceUnitService = context.watch(); - final loggedIn = context.select( - (auth) => auth.isLoggedIn, - ); if (!endpointService.isLoaded || !distanceUnitService.isLoaded) { return const Scaffold( body: Center(child: CircularProgressIndicator()), @@ -285,99 +235,6 @@ class _SettingsPageState extends State { 'Current: ${endpointService.baseUrl}', style: Theme.of(context).textTheme.labelSmall, ), - if (loggedIn) ...[ - const SizedBox(height: 32), - Text( - 'Account', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 8), - Text( - 'Change your password for this account.', - style: Theme.of(context).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', - ), - ), - ], - ), - ), - ], ], ), ), diff --git a/lib/objects/objects.dart b/lib/objects/objects.dart index 39db9bb..9bafdf9 100644 --- a/lib/objects/objects.dart +++ b/lib/objects/objects.dart @@ -109,15 +109,21 @@ class UserData { required this.fullName, required this.userId, required this.email, + String? entriesVisibility, + String? mileageVisibility, bool? elevated, bool? disabled, - }) : elevated = elevated ?? false, + }) : entriesVisibility = entriesVisibility ?? 'private', + mileageVisibility = mileageVisibility ?? 'private', + elevated = elevated ?? false, disabled = disabled ?? false; final String userId; final String username; final String fullName; final String email; + final String entriesVisibility; + final String mileageVisibility; final bool elevated; final bool disabled; } @@ -128,6 +134,8 @@ class AuthenticatedUserData extends UserData { required super.username, required super.fullName, required super.email, + super.entriesVisibility, + super.mileageVisibility, bool? elevated, bool? isElevated, bool? disabled, @@ -148,6 +156,8 @@ class UserSummary extends UserData { required super.fullName, required super.userId, required super.email, + super.entriesVisibility, + super.mileageVisibility, super.elevated = false, super.disabled = false, }); @@ -159,11 +169,71 @@ class UserSummary extends UserData { fullName: _asString(json['full_name'] ?? json['name']), userId: _asString(json['user_id'] ?? json['id']), email: _asString(json['email']), + entriesVisibility: _asString( + json['user_entries_visibility'] ?? json['entries_visibility'], + 'private', + ), + mileageVisibility: _asString( + json['user_mileage_visibility'] ?? json['mileage_visibility'], + 'private', + ), elevated: _asBool(json['elevated'] ?? json['is_elevated'], false), disabled: _asBool(json['disabled'], false), ); } +class UserProfileDetail { + final String username; + final String fullName; + final double mileage; + final List topLocos; + final List legs; + final Map privacyInfo; + final String friendshipStatus; + + UserProfileDetail({ + required this.username, + required this.fullName, + required this.mileage, + required this.topLocos, + required this.legs, + this.privacyInfo = const {}, + this.friendshipStatus = 'none', + }); + + factory UserProfileDetail.fromJson(Map json) { + List? topLocosRaw; + final tl = json['top_locos']; + if (tl is List) { + topLocosRaw = tl; + } + List? legsRaw; + final legData = json['user_legs']; + if (legData is List) { + legsRaw = legData; + } + return UserProfileDetail( + username: _asString(json['username']), + fullName: _asString(json['full_name']), + mileage: _asDouble(json['mileage']), + topLocos: (topLocosRaw ?? const []) + .whereType() + .map((e) => LocoSummary.fromJson( + e.map((k, v) => MapEntry(k.toString(), v)), + )) + .toList(), + legs: (legsRaw ?? const []) + .whereType() + .map((e) => Leg.fromJson(e.map((k, v) => MapEntry(k.toString(), v)))) + .toList(), + privacyInfo: json['privacy_info'] is Map + ? Map.from(json['privacy_info'] as Map) + : const {}, + friendshipStatus: _asString(json['friendship_status'], 'none'), + ); + } +} + class Friendship { final String? id; final String status; @@ -408,6 +478,16 @@ class HomepageStats { fullName: userData['full_name'] ?? '', userId: userData['user_id'] ?? '', email: userData['email'] ?? '', + entriesVisibility: _asString( + userData['user_entries_visibility'] ?? + userData['entries_visibility'], + 'private', + ), + mileageVisibility: _asString( + userData['user_mileage_visibility'] ?? + userData['mileage_visibility'], + 'private', + ), elevated: _asBool(userData['elevated'] ?? userData['is_elevated'], false), disabled: _asBool(userData['disabled'], false), diff --git a/lib/services/authservice.dart b/lib/services/authservice.dart index 4cfa4fa..e8cceee 100644 --- a/lib/services/authservice.dart +++ b/lib/services/authservice.dart @@ -21,6 +21,8 @@ class AuthService extends ChangeNotifier { String? get userId => _user?.userId; String? get username => _user?.username; String? get fullName => _user?.fullName; + String get entriesVisibility => _user?.entriesVisibility ?? 'private'; + String get mileageVisibility => _user?.mileageVisibility ?? 'private'; bool get isElevated => _user?.isElevated ?? false; bool get isAdmin => isElevated; // alias for old name bool get isDisabled => _user?.disabled ?? false; @@ -31,6 +33,8 @@ class AuthService extends ChangeNotifier { required String fullName, required String accessToken, required String email, + String entriesVisibility = 'private', + String mileageVisibility = 'private', bool isElevated = false, bool isDisabled = false, }) { @@ -40,6 +44,8 @@ class AuthService extends ChangeNotifier { fullName: fullName, accessToken: accessToken, email: email, + entriesVisibility: entriesVisibility, + mileageVisibility: mileageVisibility, isElevated: isElevated, disabled: isDisabled, ); @@ -77,6 +83,14 @@ class AuthService extends ChangeNotifier { fullName: userResponse['full_name'], accessToken: accessToken, email: userResponse['email'], + entriesVisibility: _parseVisibility( + userResponse['user_entries_visibility'] ?? userResponse['entries_visibility'], + 'private', + ), + mileageVisibility: _parseVisibility( + userResponse['user_mileage_visibility'] ?? userResponse['mileage_visibility'], + 'private', + ), isElevated: _parseIsElevated(userResponse), isDisabled: _parseIsDisabled(userResponse), ); @@ -98,16 +112,24 @@ class AuthService extends ChangeNotifier { }, ); - setLoginData( - userId: userResponse['user_id'], - username: userResponse['username'], - fullName: userResponse['full_name'], - accessToken: token, - email: userResponse['email'], - isElevated: _parseIsElevated(userResponse), - isDisabled: _parseIsDisabled(userResponse), - ); - } catch (_) { + setLoginData( + userId: userResponse['user_id'], + username: userResponse['username'], + fullName: userResponse['full_name'], + accessToken: token, + email: userResponse['email'], + entriesVisibility: _parseVisibility( + userResponse['user_entries_visibility'] ?? userResponse['entries_visibility'], + 'private', + ), + mileageVisibility: _parseVisibility( + userResponse['user_mileage_visibility'] ?? userResponse['mileage_visibility'], + 'private', + ), + isElevated: _parseIsElevated(userResponse), + isDisabled: _parseIsDisabled(userResponse), + ); + } catch (_) { await _clearToken(); } finally { _restoring = false; @@ -224,4 +246,11 @@ class AuthService extends ChangeNotifier { if (str == null || str.isEmpty) return false; return ['1', 'true', 'yes', 'y', 'disabled'].contains(str); } + + String _parseVisibility(dynamic value, String fallback) { + const allowed = ['private', 'friends', 'public']; + final str = value?.toString().toLowerCase().trim(); + if (str != null && allowed.contains(str)) return str; + return fallback; + } } diff --git a/lib/services/data_service/data_service_core.dart b/lib/services/data_service/data_service_core.dart index 565c15b..9d92c78 100644 --- a/lib/services/data_service/data_service_core.dart +++ b/lib/services/data_service/data_service_core.dart @@ -6,6 +6,7 @@ class _LegFetchOptions { final int sortDirection; final String? dateRangeStart; final String? dateRangeEnd; + final bool unallocatedOnly; const _LegFetchOptions({ this.limit = 100, @@ -13,6 +14,7 @@ class _LegFetchOptions { this.sortDirection = 0, this.dateRangeStart, this.dateRangeEnd, + this.unallocatedOnly = false, }); } @@ -119,6 +121,16 @@ class DataService extends ChangeNotifier { bool _isNotificationsLoading = false; bool get isNotificationsLoading => _isNotificationsLoading; + // Privacy + String _userEntriesVisibility = 'private'; + String _userMileageVisibility = 'private'; + bool _isPrivacyLoading = false; + bool _isPrivacySaving = false; + String get userEntriesVisibility => _userEntriesVisibility; + String get userMileageVisibility => _userMileageVisibility; + bool get isPrivacyLoading => _isPrivacyLoading; + bool get isPrivacySaving => _isPrivacySaving; + // Badges List _badgeAwards = []; List get badgeAwards => _badgeAwards; @@ -161,6 +173,127 @@ class DataService extends ChangeNotifier { }); } + int _visibilityRank(String value) { + switch (value.toLowerCase()) { + case 'public': + return 2; + case 'friends': + return 1; + default: + return 0; + } + } + + String _normaliseVisibility( + dynamic value, { + required String fallback, + }) { + const allowed = ['private', 'friends', 'public']; + final str = value?.toString().toLowerCase().trim(); + if (str != null && allowed.contains(str)) return str; + return fallback; + } + + String _clampMileageVisibility(String entries, String mileage) { + return _visibilityRank(mileage) < _visibilityRank(entries) + ? entries + : mileage; + } + + void _applyPrivacy(dynamic source) { + String entries = _userEntriesVisibility; + String mileage = _userMileageVisibility; + if (source is Map) { + entries = _normaliseVisibility( + source['user_entries_visibility'] ?? source['entries_visibility'], + fallback: entries, + ); + mileage = _normaliseVisibility( + source['user_mileage_visibility'] ?? source['mileage_visibility'], + fallback: mileage, + ); + } else if (source is UserData) { + entries = _normaliseVisibility( + source.entriesVisibility, + fallback: entries, + ); + mileage = _normaliseVisibility( + source.mileageVisibility, + fallback: mileage, + ); + } + _userEntriesVisibility = entries; + _userMileageVisibility = _clampMileageVisibility(entries, mileage); + } + + Future fetchPrivacySettings({String? targetUserId}) async { + _isPrivacyLoading = true; + _notifyAsync(); + try { + Map? payload; + final hasTarget = targetUserId?.isNotEmpty ?? false; + if (!hasTarget) { + try { + final json = await api.get('/users/me'); + if (json is Map) { + payload = json; + } else if (json is Map) { + payload = json.map((k, v) => MapEntry(k.toString(), v)); + } + } catch (e) { + debugPrint('Failed to fetch /users/me: $e'); + } + } + if (payload == null) { + final query = hasTarget ? '?target_user_id=$targetUserId' : ''; + try { + final json = await api.get('/users/privacy$query'); + if (json is Map) { + payload = json; + } else if (json is Map) { + payload = json.map((k, v) => MapEntry(k.toString(), v)); + } + } catch (e) { + debugPrint('Failed to fetch /users/privacy: $e'); + } + } + if (payload != null) { + _applyPrivacy(payload); + } + } catch (e) { + debugPrint('Failed to fetch privacy settings: $e'); + } finally { + _isPrivacyLoading = false; + _notifyAsync(); + } + } + + Future updatePrivacySettings({ + required String entriesVisibility, + required String mileageVisibility, + String? targetUserId, + }) async { + _isPrivacySaving = true; + _notifyAsync(); + try { + final query = (targetUserId?.isNotEmpty ?? false) + ? '?target_user_id=$targetUserId' + : ''; + await api.post('/users/privacy$query', { + 'user_entries_visibility': entriesVisibility, + 'user_mileage_visibility': mileageVisibility, + }); + _userEntriesVisibility = entriesVisibility; + _userMileageVisibility = mileageVisibility; + } catch (e) { + debugPrint('Failed to update privacy settings: $e'); + rethrow; + } finally { + _isPrivacySaving = false; + _notifyAsync(); + } + } + Future fetchHomepageStats() async { _isHomepageLoading = true; @@ -170,6 +303,9 @@ class DataService extends ChangeNotifier { _trips = [...(_homepageStats?.trips ?? const [])] ..sort(TripSummary.compareByDateDesc); _friendsLeaderboard = _homepageStats?.friendsLeaderboard ?? []; + if (_homepageStats?.user != null) { + _applyPrivacy(_homepageStats!.user!); + } } catch (e) { debugPrint('Failed to fetch homepage stats: $e'); _homepageStats = null; @@ -181,6 +317,53 @@ class DataService extends ChangeNotifier { } } + Future fetchUserProfileDetail(String userId) async { + try { + final json = await api.get('/user/$userId'); + if (json is Map) { + return UserProfileDetail.fromJson( + json.map((k, v) => MapEntry(k.toString(), v)), + ); + } + } catch (e) { + debugPrint('Failed to fetch user profile for $userId: $e'); + } + return null; + } + + Future> fetchUserLegs({ + required String userId, + int offset = 0, + int limit = 25, + }) async { + try { + final json = + await api.get('/legs/user/$userId?offset=$offset&limit=$limit'); + List? list; + if (json is List) { + list = json; + } else if (json is Map) { + for (final key in ['legs', 'data', 'results', 'items']) { + final value = json[key]; + if (value is List) { + list = value; + break; + } + } + } + if (list == null) return const []; + return list + .whereType() + .map((e) => Leg.fromJson( + e.map((k, v) => MapEntry(k.toString(), v)), + )) + .toList(); + } catch (e) { + debugPrint('Failed to fetch user legs for $userId: $e'); + return const []; + } + } + Future fetchLegs({ int offset = 0, int limit = 100, @@ -189,6 +372,7 @@ class DataService extends ChangeNotifier { String? dateRangeStart, String? dateRangeEnd, bool append = false, + bool unallocatedOnly = false, }) async { _isLegsLoading = true; if (!append) { @@ -198,6 +382,7 @@ class DataService extends ChangeNotifier { sortDirection: sortDirection, dateRangeStart: dateRangeStart, dateRangeEnd: dateRangeEnd, + unallocatedOnly: unallocatedOnly, ); } final buffer = StringBuffer( @@ -209,6 +394,9 @@ class DataService extends ChangeNotifier { if (dateRangeEnd != null && dateRangeEnd.isNotEmpty) { buffer.write('&date_range_end=$dateRangeEnd'); } + if (unallocatedOnly) { + buffer.write('&unallocated_only=true'); + } try { final json = await api.get('/user/legs${buffer.toString()}'); @@ -237,6 +425,7 @@ class DataService extends ChangeNotifier { sortDirection: _lastLegsFetch.sortDirection, dateRangeStart: _lastLegsFetch.dateRangeStart, dateRangeEnd: _lastLegsFetch.dateRangeEnd, + unallocatedOnly: _lastLegsFetch.unallocatedOnly, ); } @@ -431,6 +620,10 @@ class DataService extends ChangeNotifier { _stationFiltersFetchedAt = null; _notifications = []; _isNotificationsLoading = false; + _userEntriesVisibility = 'private'; + _userMileageVisibility = 'private'; + _isPrivacyLoading = false; + _isPrivacySaving = false; _badgeAwards = []; _badgeAwardsHasMore = false; _isBadgeAwardsLoading = false; @@ -443,11 +636,24 @@ class DataService extends ChangeNotifier { _notifyAsync(); } - void handleAuthChanged(String? userId) { - if (_currentUserId == userId) return; - _currentUserId = userId; - clear(); + void handleAuthChanged( + String? userId, { + String? entriesVisibility, + String? mileageVisibility, + }) { + final sameUser = _currentUserId == userId; _currentUserId = userId; + if (!sameUser) { + clear(); + _currentUserId = userId; + } + if (entriesVisibility != null || mileageVisibility != null) { + _applyPrivacy({ + 'user_entries_visibility': entriesVisibility, + 'user_mileage_visibility': mileageVisibility, + }); + _notifyAsync(); + } } double getMileageForCurrentYear() { diff --git a/lib/ui/app_shell.dart b/lib/ui/app_shell.dart index 7c845f8..a2241b6 100644 --- a/lib/ui/app_shell.dart +++ b/lib/ui/app_shell.dart @@ -20,6 +20,7 @@ import 'package:mileograph_flutter/components/pages/profile.dart'; import 'package:mileograph_flutter/components/pages/settings.dart'; import 'package:mileograph_flutter/components/pages/stats.dart'; import 'package:mileograph_flutter/components/pages/traction.dart'; +import 'package:mileograph_flutter/components/pages/more/user_profile_page.dart'; import 'package:mileograph_flutter/components/widgets/friend_request_notification_card.dart'; import 'package:mileograph_flutter/components/widgets/leg_share_notification_card.dart'; import 'package:mileograph_flutter/objects/objects.dart'; @@ -227,6 +228,28 @@ class _MyAppState extends State { path: '/more/profile', builder: (context, state) => const ProfilePage(), ), + GoRoute( + path: '/more/user-profile', + name: 'user-profile', + builder: (context, state) { + final extra = state.extra; + UserSummary? user; + String? userId; + if (extra is UserSummary) { + user = extra; + userId = extra.userId; + } else if (extra is Map) { + final value = extra['user']; + if (value is UserSummary) user = value; + userId = extra['userId']?.toString(); + } + userId ??= state.uri.queryParameters['user_id']; + return UserProfilePage( + userId: userId, + initialUser: user, + ); + }, + ), GoRoute( path: '/more/badges', builder: (context, state) => const BadgesPage(), diff --git a/pubspec.yaml b/pubspec.yaml index 76475aa..cd358cd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.6.3+6 +version: 0.6.4+7 environment: sdk: ^3.8.1