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
This commit is contained in:
parent
9fec6754d9
commit
adeffe691e
|
|
@ -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<String, dynamic> 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;
|
||||
}
|
||||
|
|
|
|||
417
sojorn_app/lib/screens/profile/followers_following_screen.dart
Normal file
417
sojorn_app/lib/screens/profile/followers_following_screen.dart
Normal file
|
|
@ -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<FollowersFollowingScreen> createState() =>
|
||||
_FollowersFollowingScreenState();
|
||||
}
|
||||
|
||||
class _FollowersFollowingScreenState
|
||||
extends ConsumerState<FollowersFollowingScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
|
||||
List<UserListItem> _followers = [];
|
||||
List<UserListItem> _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<void> _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<UserListItem> 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<void> _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<UserListItem> 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<void> _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<void> _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<UserListItem> users,
|
||||
required bool isLoading,
|
||||
required String? error,
|
||||
required Future<void> 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<String, dynamic> 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'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
560
sojorn_app/lib/screens/settings/privacy_settings_screen.dart
Normal file
560
sojorn_app/lib/screens/settings/privacy_settings_screen.dart
Normal file
|
|
@ -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<PrivacySettingsScreen> createState() =>
|
||||
_PrivacySettingsScreenState();
|
||||
}
|
||||
|
||||
class _PrivacySettingsScreenState
|
||||
extends ConsumerState<PrivacySettingsScreen> {
|
||||
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<void> _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<void> _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<bool>(
|
||||
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<Widget> 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<void> _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')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue