From adeffe691eae287afde7be22bc69b8c4587c5755 Mon Sep 17 00:00:00 2001 From: Patrick Britton Date: Wed, 4 Feb 2026 16:46:20 -0600 Subject: [PATCH] Add followers/following screen and comprehensive privacy settings Features: - Create FollowersFollowingScreen with tabs for followers/following lists - Make follower/following counts tappable on profile to navigate to connections - Create comprehensive PrivacySettingsScreen with sections for: - Account privacy (private account toggle) - Post visibility defaults - Interaction controls (who can message/comment) - Discovery settings (search visibility) - Circle (close friends) management placeholder - Data export and blocked users - Update ProfilePrivacySettings model with additional fields - Connect to new backend API endpoints --- .../lib/models/profile_privacy_settings.dart | 100 +++- .../profile/followers_following_screen.dart | 417 +++++++++++++ .../lib/screens/profile/profile_screen.dart | 37 +- .../settings/privacy_settings_screen.dart | 560 ++++++++++++++++++ 4 files changed, 1085 insertions(+), 29 deletions(-) create mode 100644 sojorn_app/lib/screens/profile/followers_following_screen.dart create mode 100644 sojorn_app/lib/screens/settings/privacy_settings_screen.dart diff --git a/sojorn_app/lib/models/profile_privacy_settings.dart b/sojorn_app/lib/models/profile_privacy_settings.dart index 60fcc4b..4f608e4 100644 --- a/sojorn_app/lib/models/profile_privacy_settings.dart +++ b/sojorn_app/lib/models/profile_privacy_settings.dart @@ -1,31 +1,51 @@ class ProfilePrivacySettings { - final String userId; - final String profileVisibility; - final String postsVisibility; - final String savedVisibility; - final String followRequestPolicy; - final String defaultPostVisibility; - final bool isPrivateProfile; + String userId; + String profileVisibility; + String postsVisibility; + String savedVisibility; + String followRequestPolicy; + String defaultVisibility; + bool isPrivate; + bool allowChains; + String whoCanMessage; + String whoCanComment; + bool showActivityStatus; + bool showInSearch; + bool showInSuggestions; - const ProfilePrivacySettings({ - required this.userId, - required this.profileVisibility, - required this.postsVisibility, - required this.savedVisibility, - required this.followRequestPolicy, - required this.defaultPostVisibility, - required this.isPrivateProfile, + ProfilePrivacySettings({ + this.userId = '', + this.profileVisibility = 'public', + this.postsVisibility = 'public', + this.savedVisibility = 'private', + this.followRequestPolicy = 'everyone', + this.defaultVisibility = 'public', + this.isPrivate = false, + this.allowChains = true, + this.whoCanMessage = 'everyone', + this.whoCanComment = 'everyone', + this.showActivityStatus = true, + this.showInSearch = true, + this.showInSuggestions = true, }); factory ProfilePrivacySettings.fromJson(Map json) { return ProfilePrivacySettings( - userId: json['user_id'] as String, + userId: json['user_id'] as String? ?? '', profileVisibility: json['profile_visibility'] as String? ?? 'public', postsVisibility: json['posts_visibility'] as String? ?? 'public', savedVisibility: json['saved_visibility'] as String? ?? 'private', followRequestPolicy: json['follow_request_policy'] as String? ?? 'everyone', - defaultPostVisibility: json['default_post_visibility'] as String? ?? 'public', - isPrivateProfile: json['is_private_profile'] as bool? ?? false, + defaultVisibility: json['default_post_visibility'] as String? ?? + json['default_visibility'] as String? ?? 'public', + isPrivate: json['is_private_profile'] as bool? ?? + json['is_private'] as bool? ?? false, + allowChains: json['allow_chains'] as bool? ?? true, + whoCanMessage: json['who_can_message'] as String? ?? 'everyone', + whoCanComment: json['who_can_comment'] as String? ?? 'everyone', + showActivityStatus: json['show_activity_status'] as bool? ?? true, + showInSearch: json['show_in_search'] as bool? ?? true, + showInSuggestions: json['show_in_suggestions'] as bool? ?? true, ); } @@ -36,8 +56,14 @@ class ProfilePrivacySettings { 'posts_visibility': postsVisibility, 'saved_visibility': savedVisibility, 'follow_request_policy': followRequestPolicy, - 'default_post_visibility': defaultPostVisibility, - 'is_private_profile': isPrivateProfile, + 'default_post_visibility': defaultVisibility, + 'is_private_profile': isPrivate, + 'allow_chains': allowChains, + 'who_can_message': whoCanMessage, + 'who_can_comment': whoCanComment, + 'show_activity_status': showActivityStatus, + 'show_in_search': showInSearch, + 'show_in_suggestions': showInSuggestions, }; } @@ -46,8 +72,14 @@ class ProfilePrivacySettings { String? postsVisibility, String? savedVisibility, String? followRequestPolicy, - String? defaultPostVisibility, - bool? isPrivateProfile, + String? defaultVisibility, + bool? isPrivate, + bool? allowChains, + String? whoCanMessage, + String? whoCanComment, + bool? showActivityStatus, + bool? showInSearch, + bool? showInSuggestions, }) { return ProfilePrivacySettings( userId: userId, @@ -55,8 +87,14 @@ class ProfilePrivacySettings { postsVisibility: postsVisibility ?? this.postsVisibility, savedVisibility: savedVisibility ?? this.savedVisibility, followRequestPolicy: followRequestPolicy ?? this.followRequestPolicy, - defaultPostVisibility: defaultPostVisibility ?? this.defaultPostVisibility, - isPrivateProfile: isPrivateProfile ?? this.isPrivateProfile, + defaultVisibility: defaultVisibility ?? this.defaultVisibility, + isPrivate: isPrivate ?? this.isPrivate, + allowChains: allowChains ?? this.allowChains, + whoCanMessage: whoCanMessage ?? this.whoCanMessage, + whoCanComment: whoCanComment ?? this.whoCanComment, + showActivityStatus: showActivityStatus ?? this.showActivityStatus, + showInSearch: showInSearch ?? this.showInSearch, + showInSuggestions: showInSuggestions ?? this.showInSuggestions, ); } @@ -67,8 +105,18 @@ class ProfilePrivacySettings { postsVisibility: 'public', savedVisibility: 'private', followRequestPolicy: 'everyone', - defaultPostVisibility: 'public', - isPrivateProfile: false, + defaultVisibility: 'public', + isPrivate: false, + allowChains: true, + whoCanMessage: 'everyone', + whoCanComment: 'everyone', + showActivityStatus: true, + showInSearch: true, + showInSuggestions: true, ); } + + // Legacy getters for backwards compatibility + String get defaultPostVisibility => defaultVisibility; + bool get isPrivateProfile => isPrivate; } diff --git a/sojorn_app/lib/screens/profile/followers_following_screen.dart b/sojorn_app/lib/screens/profile/followers_following_screen.dart new file mode 100644 index 0000000..1ddc9cf --- /dev/null +++ b/sojorn_app/lib/screens/profile/followers_following_screen.dart @@ -0,0 +1,417 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../theme/app_theme.dart'; +import '../../widgets/app_scaffold.dart'; +import '../../widgets/media/signed_media_image.dart'; +import '../../services/api_service.dart'; +import 'viewable_profile_screen.dart'; + +/// Screen to manage followers and following with tabbed interface +class FollowersFollowingScreen extends ConsumerStatefulWidget { + final String userId; + final int initialTabIndex; // 0 = Followers, 1 = Following + + const FollowersFollowingScreen({ + super.key, + required this.userId, + this.initialTabIndex = 0, + }); + + @override + ConsumerState createState() => + _FollowersFollowingScreenState(); +} + +class _FollowersFollowingScreenState + extends ConsumerState + with SingleTickerProviderStateMixin { + late TabController _tabController; + + List _followers = []; + List _following = []; + bool _isLoadingFollowers = false; + bool _isLoadingFollowing = false; + String? _followersError; + String? _followingError; + + @override + void initState() { + super.initState(); + _tabController = TabController( + length: 2, + vsync: this, + initialIndex: widget.initialTabIndex, + ); + _loadFollowers(); + _loadFollowing(); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + void _setStateIfMounted(VoidCallback fn) { + if (!mounted) return; + setState(fn); + } + + Future _loadFollowers() async { + _setStateIfMounted(() { + _isLoadingFollowers = true; + _followersError = null; + }); + + try { + final api = ref.read(apiServiceProvider); + final data = await api.callGoApi( + '/users/${widget.userId}/followers', + queryParams: {'limit': '50', 'offset': '0'}, + ); + + final List users = ((data['followers'] ?? []) as List) + .map((json) => UserListItem.fromJson(json)) + .toList(); + + _setStateIfMounted(() { + _followers = users; + }); + } catch (e) { + _setStateIfMounted(() { + _followersError = e.toString(); + }); + } finally { + _setStateIfMounted(() { + _isLoadingFollowers = false; + }); + } + } + + Future _loadFollowing() async { + _setStateIfMounted(() { + _isLoadingFollowing = true; + _followingError = null; + }); + + try { + final api = ref.read(apiServiceProvider); + final data = await api.callGoApi( + '/users/${widget.userId}/following', + queryParams: {'limit': '50', 'offset': '0'}, + ); + + final List users = ((data['following'] ?? []) as List) + .map((json) => UserListItem.fromJson(json)) + .toList(); + + _setStateIfMounted(() { + _following = users; + }); + } catch (e) { + _setStateIfMounted(() { + _followingError = e.toString(); + }); + } finally { + _setStateIfMounted(() { + _isLoadingFollowing = false; + }); + } + } + + Future _unfollowUser(String userId) async { + try { + final api = ref.read(apiServiceProvider); + await api.callGoApi('/users/$userId/follow', method: 'DELETE'); + + _setStateIfMounted(() { + _following.removeWhere((u) => u.id == userId); + }); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Unfollowed successfully')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to unfollow: $e')), + ); + } + } + } + + Future _removeFollower(String userId) async { + // This would require a backend endpoint to remove a follower + // For now, show a placeholder message + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Remove follower feature coming soon')), + ); + } + } + + @override + Widget build(BuildContext context) { + return AppScaffold( + title: 'Connections', + showBackButton: true, + body: Column( + children: [ + // Tab Bar + Container( + color: AppTheme.surfaceColor, + child: TabBar( + controller: _tabController, + indicatorColor: AppTheme.primaryColor, + labelColor: AppTheme.textPrimary, + unselectedLabelColor: AppTheme.textSecondary, + tabs: [ + Tab(text: 'Followers (${_followers.length})'), + Tab(text: 'Following (${_following.length})'), + ], + ), + ), + + // Tab Views + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + _buildUserList( + users: _followers, + isLoading: _isLoadingFollowers, + error: _followersError, + onRefresh: _loadFollowers, + isFollowersList: true, + ), + _buildUserList( + users: _following, + isLoading: _isLoadingFollowing, + error: _followingError, + onRefresh: _loadFollowing, + isFollowersList: false, + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildUserList({ + required List users, + required bool isLoading, + required String? error, + required Future Function() onRefresh, + required bool isFollowersList, + }) { + if (isLoading && users.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + if (error != null && users.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 48, color: AppTheme.textSecondary), + const SizedBox(height: 16), + Text('Failed to load', style: AppTheme.bodyLarge), + const SizedBox(height: 8), + TextButton( + onPressed: onRefresh, + child: const Text('Retry'), + ), + ], + ), + ); + } + + if (users.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + isFollowersList ? Icons.people_outline : Icons.person_add_outlined, + size: 64, + color: AppTheme.textSecondary.withOpacity(0.5), + ), + const SizedBox(height: 16), + Text( + isFollowersList ? 'No followers yet' : 'Not following anyone yet', + style: AppTheme.bodyLarge.copyWith(color: AppTheme.textSecondary), + ), + ], + ), + ); + } + + return RefreshIndicator( + onRefresh: onRefresh, + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: users.length, + itemBuilder: (context, index) { + final user = users[index]; + return _UserListTile( + user: user, + isFollowersList: isFollowersList, + onTap: () => _navigateToProfile(user), + onAction: isFollowersList + ? () => _removeFollower(user.id) + : () => _unfollowUser(user.id), + ); + }, + ), + ); + } + + void _navigateToProfile(UserListItem user) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ViewableProfileScreen(handle: user.handle), + ), + ); + } +} + +// ============================================================================= +// USER LIST ITEM MODEL +// ============================================================================= + +class UserListItem { + final String id; + final String handle; + final String? displayName; + final String? avatarUrl; + final int harmonyScore; + final String? harmonyTier; + final DateTime? followedAt; + + UserListItem({ + required this.id, + required this.handle, + this.displayName, + this.avatarUrl, + this.harmonyScore = 0, + this.harmonyTier, + this.followedAt, + }); + + factory UserListItem.fromJson(Map json) { + return UserListItem( + id: json['id'] ?? '', + handle: json['handle'] ?? '', + displayName: json['display_name'], + avatarUrl: json['avatar_url'], + harmonyScore: json['harmony_score'] ?? 0, + harmonyTier: json['harmony_tier'], + followedAt: json['followed_at'] != null + ? DateTime.tryParse(json['followed_at']) + : null, + ); + } +} + +// ============================================================================= +// USER LIST TILE WIDGET +// ============================================================================= + +class _UserListTile extends StatelessWidget { + final UserListItem user; + final bool isFollowersList; + final VoidCallback onTap; + final VoidCallback onAction; + + const _UserListTile({ + required this.user, + required this.isFollowersList, + required this.onTap, + required this.onAction, + }); + + Color _getTierColor(String? tier) { + switch (tier?.toLowerCase()) { + case 'gold': + return const Color(0xFFFFD700); + case 'silver': + return const Color(0xFFC0C0C0); + case 'bronze': + return const Color(0xFFCD7F32); + default: + return AppTheme.textSecondary; + } + } + + @override + Widget build(BuildContext context) { + return ListTile( + onTap: onTap, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + leading: CircleAvatar( + radius: 24, + backgroundColor: AppTheme.surfaceColor, + child: user.avatarUrl != null + ? ClipOval( + child: SignedMediaImage( + imageUrl: user.avatarUrl!, + width: 48, + height: 48, + fit: BoxFit.cover, + ), + ) + : Text( + (user.displayName ?? user.handle).substring(0, 1).toUpperCase(), + style: AppTheme.headlineSmall, + ), + ), + title: Row( + children: [ + Flexible( + child: Text( + user.displayName ?? user.handle, + style: AppTheme.bodyLarge.copyWith(fontWeight: FontWeight.w600), + overflow: TextOverflow.ellipsis, + ), + ), + if (user.harmonyTier != null) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: _getTierColor(user.harmonyTier).withOpacity(0.2), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + user.harmonyTier!, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: _getTierColor(user.harmonyTier), + ), + ), + ), + ], + ], + ), + subtitle: Text( + '@${user.handle}', + style: AppTheme.bodySmall.copyWith(color: AppTheme.textSecondary), + ), + trailing: isFollowersList + ? null // Followers don't have action button for now + : TextButton( + onPressed: onAction, + style: TextButton.styleFrom( + foregroundColor: AppTheme.textSecondary, + padding: const EdgeInsets.symmetric(horizontal: 12), + ), + child: const Text('Unfollow'), + ), + ); + } +} diff --git a/sojorn_app/lib/screens/profile/profile_screen.dart b/sojorn_app/lib/screens/profile/profile_screen.dart index d97f24b..571eeec 100644 --- a/sojorn_app/lib/screens/profile/profile_screen.dart +++ b/sojorn_app/lib/screens/profile/profile_screen.dart @@ -15,6 +15,7 @@ import '../../widgets/media/signed_media_image.dart'; import '../compose/compose_screen.dart'; import '../post/post_detail_screen.dart'; import 'profile_settings_screen.dart'; +import 'followers_following_screen.dart'; enum ProfileFeedType { posts, saved, chained } @@ -1086,15 +1087,35 @@ class _ProfileHeader extends StatelessWidget { children: [ _StatItem(label: 'Posts', value: stats.posts.toString()), const SizedBox(width: AppTheme.spacingMd), - _StatItem(label: 'Followers', value: stats.followers.toString()), + _StatItem( + label: 'Followers', + value: stats.followers.toString(), + onTap: () => _navigateToConnections(0), + ), const SizedBox(width: AppTheme.spacingMd), - _StatItem(label: 'Following', value: stats.following.toString()), + _StatItem( + label: 'Following', + value: stats.following.toString(), + onTap: () => _navigateToConnections(1), + ), ], ), ), ); } + void _navigateToConnections(int tabIndex) { + if (_profile == null) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => FollowersFollowingScreen( + userId: _profile!.id, + initialTabIndex: tabIndex, + ), + ), + ); + } + LinearGradient _generateGradient(String seed) { final hash = seed.hashCode.abs(); final hue = (hash % 360).toDouble(); @@ -1117,15 +1138,17 @@ class _ProfileHeader extends StatelessWidget { class _StatItem extends StatelessWidget { final String label; final String value; + final VoidCallback? onTap; const _StatItem({ required this.label, required this.value, + this.onTap, }); @override Widget build(BuildContext context) { - return Column( + final content = Column( mainAxisSize: MainAxisSize.min, children: [ Text( @@ -1156,6 +1179,14 @@ class _StatItem extends StatelessWidget { ), ], ); + + if (onTap != null) { + return GestureDetector( + onTap: onTap, + child: content, + ); + } + return content; } } diff --git a/sojorn_app/lib/screens/settings/privacy_settings_screen.dart b/sojorn_app/lib/screens/settings/privacy_settings_screen.dart new file mode 100644 index 0000000..7b2d9c9 --- /dev/null +++ b/sojorn_app/lib/screens/settings/privacy_settings_screen.dart @@ -0,0 +1,560 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../theme/app_theme.dart'; +import '../../widgets/app_scaffold.dart'; +import '../../services/api_service.dart'; +import '../../models/profile_privacy_settings.dart'; + +/// Comprehensive privacy settings screen for managing account privacy +class PrivacySettingsScreen extends ConsumerStatefulWidget { + final ProfilePrivacySettings? initialSettings; + + const PrivacySettingsScreen({ + super.key, + this.initialSettings, + }); + + @override + ConsumerState createState() => + _PrivacySettingsScreenState(); +} + +class _PrivacySettingsScreenState + extends ConsumerState { + late ProfilePrivacySettings _settings; + bool _isLoading = false; + bool _isSaving = false; + bool _hasChanges = false; + + @override + void initState() { + super.initState(); + _settings = widget.initialSettings ?? ProfilePrivacySettings(); + if (widget.initialSettings == null) { + _loadSettings(); + } + } + + void _setStateIfMounted(VoidCallback fn) { + if (!mounted) return; + setState(fn); + } + + Future _loadSettings() async { + _setStateIfMounted(() => _isLoading = true); + + try { + final api = ref.read(apiServiceProvider); + final data = await api.callGoApi('/settings/privacy'); + + _setStateIfMounted(() { + _settings = ProfilePrivacySettings.fromJson(data); + }); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to load settings: $e')), + ); + } + } finally { + _setStateIfMounted(() => _isLoading = false); + } + } + + Future _saveSettings() async { + _setStateIfMounted(() => _isSaving = true); + + try { + final api = ref.read(apiServiceProvider); + await api.callGoApi( + '/settings/privacy', + method: 'PATCH', + body: _settings.toJson(), + ); + + _setStateIfMounted(() => _hasChanges = false); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Privacy settings saved'), + backgroundColor: Colors.green, + ), + ); + Navigator.of(context).pop(_settings); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to save: $e')), + ); + } + } finally { + _setStateIfMounted(() => _isSaving = false); + } + } + + void _updateSetting(void Function() update) { + setState(() { + update(); + _hasChanges = true; + }); + } + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async { + if (_hasChanges) { + final shouldLeave = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Unsaved Changes'), + content: const Text('You have unsaved changes. Discard them?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Discard'), + ), + ], + ), + ); + return shouldLeave ?? false; + } + return true; + }, + child: AppScaffold( + title: 'Privacy Settings', + showBackButton: true, + actions: [ + if (_hasChanges) + TextButton( + onPressed: _isSaving ? null : _saveSettings, + child: _isSaving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Save', style: TextStyle(color: Colors.white)), + ), + ], + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSection( + title: 'Account Privacy', + icon: Icons.lock_outline, + children: [ + _buildPrivateAccountTile(), + ], + ), + const SizedBox(height: 24), + _buildSection( + title: 'Post Visibility', + icon: Icons.visibility_outlined, + children: [ + _buildDefaultVisibilityTile(), + _buildAllowChainsTile(), + ], + ), + const SizedBox(height: 24), + _buildSection( + title: 'Interactions', + icon: Icons.chat_bubble_outline, + children: [ + _buildWhoCanMessageTile(), + _buildWhoCanCommentTile(), + _buildShowActivityStatusTile(), + ], + ), + const SizedBox(height: 24), + _buildSection( + title: 'Discovery', + icon: Icons.search, + children: [ + _buildShowInSearchTile(), + _buildShowSuggestedTile(), + ], + ), + const SizedBox(height: 24), + _buildSection( + title: 'Circle (Close Friends)', + icon: Icons.favorite_outline, + children: [ + _buildCircleInfoTile(), + _buildManageCircleTile(), + ], + ), + const SizedBox(height: 24), + _buildSection( + title: 'Data & Privacy', + icon: Icons.shield_outlined, + children: [ + _buildExportDataTile(), + _buildBlockedUsersTile(), + ], + ), + const SizedBox(height: 48), + ], + ), + ), + ), + ); + } + + Widget _buildSection({ + required String title, + required IconData icon, + required List children, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 20, color: AppTheme.primaryColor), + const SizedBox(width: 8), + Text( + title, + style: AppTheme.headlineSmall.copyWith( + color: AppTheme.primaryColor, + fontSize: 16, + ), + ), + ], + ), + const SizedBox(height: 12), + Container( + decoration: BoxDecoration( + color: AppTheme.surfaceColor, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: children, + ), + ), + ], + ); + } + + Widget _buildPrivateAccountTile() { + return SwitchListTile( + title: const Text('Private Account'), + subtitle: const Text('Only approved followers can see your posts'), + value: _settings.isPrivate, + onChanged: (value) => _updateSetting(() => _settings.isPrivate = value), + activeColor: AppTheme.primaryColor, + ); + } + + Widget _buildDefaultVisibilityTile() { + return ListTile( + title: const Text('Default Post Visibility'), + subtitle: Text(_getVisibilityLabel(_settings.defaultVisibility)), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showVisibilityPicker(), + ); + } + + Widget _buildAllowChainsTile() { + return SwitchListTile( + title: const Text('Allow Chains'), + subtitle: const Text('Let others add to your posts'), + value: _settings.allowChains, + onChanged: (value) => _updateSetting(() => _settings.allowChains = value), + activeColor: AppTheme.primaryColor, + ); + } + + Widget _buildWhoCanMessageTile() { + return ListTile( + title: const Text('Who Can Message Me'), + subtitle: Text(_getAudienceLabel(_settings.whoCanMessage)), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showAudiencePicker( + title: 'Who Can Message You', + currentValue: _settings.whoCanMessage, + onChanged: (value) => _updateSetting(() => _settings.whoCanMessage = value), + ), + ); + } + + Widget _buildWhoCanCommentTile() { + return ListTile( + title: const Text('Who Can Comment'), + subtitle: Text(_getAudienceLabel(_settings.whoCanComment)), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showAudiencePicker( + title: 'Who Can Comment', + currentValue: _settings.whoCanComment, + onChanged: (value) => _updateSetting(() => _settings.whoCanComment = value), + ), + ); + } + + Widget _buildShowActivityStatusTile() { + return SwitchListTile( + title: const Text('Show Activity Status'), + subtitle: const Text('Let others see when you\'re online'), + value: _settings.showActivityStatus, + onChanged: (value) => + _updateSetting(() => _settings.showActivityStatus = value), + activeColor: AppTheme.primaryColor, + ); + } + + Widget _buildShowInSearchTile() { + return SwitchListTile( + title: const Text('Show in Search'), + subtitle: const Text('Allow others to find you in search'), + value: _settings.showInSearch, + onChanged: (value) => + _updateSetting(() => _settings.showInSearch = value), + activeColor: AppTheme.primaryColor, + ); + } + + Widget _buildShowSuggestedTile() { + return SwitchListTile( + title: const Text('Show in Suggestions'), + subtitle: const Text('Appear in "Suggested for You"'), + value: _settings.showInSuggestions, + onChanged: (value) => + _updateSetting(() => _settings.showInSuggestions = value), + activeColor: AppTheme.primaryColor, + ); + } + + Widget _buildCircleInfoTile() { + return const ListTile( + leading: Icon(Icons.info_outline, color: AppTheme.textSecondary), + title: Text('About Circle'), + subtitle: Text( + 'Share posts with only your closest friends. ' + 'Circle members see posts marked "Circle" visibility.', + ), + ); + } + + Widget _buildManageCircleTile() { + return ListTile( + title: const Text('Manage Circle Members'), + subtitle: const Text('Add or remove close friends'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + // TODO: Navigate to circle management screen + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Circle management coming soon')), + ); + }, + ); + } + + Widget _buildExportDataTile() { + return ListTile( + title: const Text('Export My Data'), + subtitle: const Text('Download your profile, posts, and connections'), + trailing: const Icon(Icons.download_outlined), + onTap: () => _exportData(), + ); + } + + Widget _buildBlockedUsersTile() { + return ListTile( + title: const Text('Blocked Users'), + subtitle: const Text('Manage users you\'ve blocked'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + // TODO: Navigate to blocked users screen + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Blocked users screen coming soon')), + ); + }, + ); + } + + String _getVisibilityLabel(String visibility) { + switch (visibility) { + case 'public': + return 'Public - Anyone can see'; + case 'followers': + return 'Followers Only'; + case 'circle': + return 'Circle Only'; + default: + return visibility; + } + } + + String _getAudienceLabel(String audience) { + switch (audience) { + case 'everyone': + return 'Everyone'; + case 'followers': + return 'Followers Only'; + case 'mutuals': + return 'Mutual Follows Only'; + case 'nobody': + return 'Nobody'; + default: + return audience; + } + } + + void _showVisibilityPicker() { + showModalBottomSheet( + context: context, + backgroundColor: AppTheme.surfaceColor, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 16), + Text('Default Post Visibility', style: AppTheme.headlineSmall), + const SizedBox(height: 16), + _buildVisibilityOption('public', 'Public', Icons.public), + _buildVisibilityOption('followers', 'Followers Only', Icons.people), + _buildVisibilityOption('circle', 'Circle Only', Icons.favorite), + const SizedBox(height: 16), + ], + ), + ), + ); + } + + Widget _buildVisibilityOption(String value, String label, IconData icon) { + final isSelected = _settings.defaultVisibility == value; + return ListTile( + leading: Icon(icon, color: isSelected ? AppTheme.primaryColor : null), + title: Text(label), + trailing: isSelected ? const Icon(Icons.check, color: AppTheme.primaryColor) : null, + onTap: () { + _updateSetting(() => _settings.defaultVisibility = value); + Navigator.pop(context); + }, + ); + } + + void _showAudiencePicker({ + required String title, + required String currentValue, + required void Function(String) onChanged, + }) { + showModalBottomSheet( + context: context, + backgroundColor: AppTheme.surfaceColor, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 16), + Text(title, style: AppTheme.headlineSmall), + const SizedBox(height: 16), + _buildAudienceOption('everyone', 'Everyone', Icons.public, currentValue, onChanged), + _buildAudienceOption('followers', 'Followers Only', Icons.people, currentValue, onChanged), + _buildAudienceOption('mutuals', 'Mutual Follows', Icons.sync_alt, currentValue, onChanged), + _buildAudienceOption('nobody', 'Nobody', Icons.block, currentValue, onChanged), + const SizedBox(height: 16), + ], + ), + ), + ); + } + + Widget _buildAudienceOption( + String value, + String label, + IconData icon, + String currentValue, + void Function(String) onChanged, + ) { + final isSelected = currentValue == value; + return ListTile( + leading: Icon(icon, color: isSelected ? AppTheme.primaryColor : null), + title: Text(label), + trailing: isSelected ? const Icon(Icons.check, color: AppTheme.primaryColor) : null, + onTap: () { + onChanged(value); + Navigator.pop(context); + }, + ); + } + + Future _exportData() async { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const AlertDialog( + content: Row( + children: [ + CircularProgressIndicator(), + SizedBox(width: 20), + Text('Preparing your data...'), + ], + ), + ), + ); + + try { + final api = ref.read(apiServiceProvider); + final data = await api.callGoApi('/users/me/export'); + + Navigator.pop(context); // Close loading dialog + + // Show success and maybe allow saving + if (mounted) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Export Ready'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Your data export includes:'), + const SizedBox(height: 12), + Text('• Profile information', style: AppTheme.bodyMedium), + Text('• ${data['posts']?.length ?? 0} posts', style: AppTheme.bodyMedium), + Text('• ${data['following']?.length ?? 0} connections', style: AppTheme.bodyMedium), + const SizedBox(height: 12), + const Text( + 'The data has been prepared. In a production app, ' + 'this would be saved to your device.', + style: TextStyle(color: AppTheme.textSecondary, fontSize: 12), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Done'), + ), + ], + ), + ); + } + } catch (e) { + Navigator.pop(context); // Close loading dialog + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Export failed: $e')), + ); + } + } + } +}