sojorn/sojorn_app/lib/screens/profile/profile_screen.dart
Patrick Britton 69358b016f Fix compilation errors in followers/following and privacy screens
- Fix FollowersFollowingScreen: correct imports, AppTheme references, SignedMediaImage param
- Fix profile_screen.dart: move _navigateToConnections to _ProfileScreenState, pass callback to _ProfileHeader
- Fix profile_settings_screen.dart: update property names (isPrivate, defaultVisibility)
2026-02-04 17:21:08 -06:00

1461 lines
41 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/post.dart';
import '../../models/profile.dart';
import '../../models/profile_privacy_settings.dart';
import '../../models/trust_state.dart';
import '../../models/trust_tier.dart';
import '../../providers/api_provider.dart';
import '../../services/auth_service.dart';
import '../../theme/app_theme.dart';
import '../../utils/country_flag.dart';
import '../../widgets/post_item.dart';
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 }
/// Premium profile screen with NestedScrollView and SliverAppBar
class ProfileScreen extends ConsumerStatefulWidget {
const ProfileScreen({super.key});
@override
ConsumerState<ProfileScreen> createState() => _ProfileScreenState();
}
String _resolveAvatar(String? url) {
if (url == null || url.isEmpty) return '';
if (url.startsWith('http://') || url.startsWith('https://')) return url;
return 'https://img.sojorn.net/${url.replaceFirst(RegExp('^/'), '')}';
}
class _ProfileScreenState extends ConsumerState<ProfileScreen>
with SingleTickerProviderStateMixin {
static const int _postsPageSize = 20;
StreamSubscription? _authSubscription;
Profile? _profile;
ProfileStats? _stats;
ProfilePrivacySettings? _privacySettings;
bool _isPrivacyLoading = false;
late TabController _tabController;
ProfileFeedType _activeFeed = ProfileFeedType.posts;
List<Post> _posts = [];
bool _isProfileLoading = false;
String? _profileError;
bool _isCreatingProfile = false;
bool _isPostsLoading = false;
bool _isPostsLoadingMore = false;
bool _hasMorePosts = true;
String? _postsError;
List<Post> _savedPosts = [];
bool _isSavedLoading = false;
bool _isSavedLoadingMore = false;
bool _hasMoreSaved = true;
String? _savedError;
List<Post> _chainedPosts = [];
bool _isChainedLoading = false;
bool _isChainedLoadingMore = false;
bool _hasMoreChained = true;
String? _chainedError;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
_tabController.addListener(() {
if (!_tabController.indexIsChanging) {
setState(() {
_activeFeed = ProfileFeedType.values[_tabController.index];
});
_loadActiveFeed(refresh: true);
}
});
_loadProfile();
_authSubscription = AuthService.instance.authStateChanges.listen((data) {
if (data.event == AuthChangeEvent.signedIn ||
data.event == AuthChangeEvent.tokenRefreshed) {
_loadProfile();
}
});
}
@override
void dispose() {
_authSubscription?.cancel();
_tabController.dispose();
super.dispose();
}
Future<void> _refreshAll() async {
await _loadProfile(refreshFeeds: false);
await _loadActiveFeed(refresh: true);
}
Future<void> _loadProfile({bool refreshFeeds = true}) async {
setState(() {
_isProfileLoading = true;
_profileError = null;
});
try {
final apiService = ref.read(apiServiceProvider);
final data = await apiService.getProfile();
final profile = data['profile'] as Profile;
final stats = data['stats'] as ProfileStats;
if (!mounted) return;
setState(() {
_profile = profile;
_stats = stats;
});
await _loadPrivacySettings();
if (refreshFeeds) {
await _loadActiveFeed(refresh: true);
}
} catch (error) {
if (!mounted) return;
if (_shouldAutoCreateProfile(error)) {
await _createProfileIfMissing();
return;
}
setState(() {
_profileError = error.toString().replaceAll('Exception: ', '');
});
} finally {
if (mounted) {
setState(() {
_isProfileLoading = false;
});
}
}
}
Future<void> _loadPrivacySettings() async {
if (_isPrivacyLoading) return;
setState(() {
_isPrivacyLoading = true;
});
try {
final apiService = ref.read(apiServiceProvider);
final settings = await apiService.getPrivacySettings();
if (!mounted) return;
setState(() {
_privacySettings = settings;
});
} catch (error) {
if (!mounted) return;
} finally {
if (mounted) {
setState(() {
_isPrivacyLoading = false;
});
}
}
}
bool _shouldAutoCreateProfile(dynamic error) {
final errorStr = error.toString().toLowerCase();
return errorStr.contains('profile not found') ||
errorStr.contains('no profile');
}
Future<void> _createProfileIfMissing() async {
if (_isCreatingProfile) return;
setState(() {
_isCreatingProfile = true;
_profileError = null;
});
try {
final apiService = ref.read(apiServiceProvider);
final user = AuthService.instance.currentUser;
if (user == null) {
throw Exception('No authenticated user');
}
// Generate a default handle from email or user ID
final defaultHandle =
user.email?.split('@').first ?? 'user${user.id.substring(0, 8)}';
final defaultDisplayName = user.email?.split('@').first ?? 'User';
await apiService.createProfile(
handle: defaultHandle,
displayName: defaultDisplayName,
);
if (!mounted) return;
await _loadProfile();
} catch (error) {
if (!mounted) return;
setState(() {
_profileError =
'Could not create profile: ${error.toString().replaceAll('Exception: ', '')}';
});
} finally {
if (mounted) {
setState(() {
_isCreatingProfile = false;
});
}
}
}
Future<void> _loadActiveFeed({bool refresh = false}) async {
switch (_activeFeed) {
case ProfileFeedType.posts:
return _loadPosts(refresh: refresh);
case ProfileFeedType.saved:
return _loadSaved(refresh: refresh);
case ProfileFeedType.chained:
return _loadChained(refresh: refresh);
}
}
Future<void> _loadPosts({bool refresh = false}) async {
if (_profile == null) return;
if (refresh) {
setState(() {
_posts = [];
_hasMorePosts = true;
_postsError = null;
});
} else if (!_hasMorePosts || _isPostsLoadingMore) {
return;
}
setState(() {
if (refresh) {
_isPostsLoading = true;
} else {
_isPostsLoadingMore = true;
}
if (!refresh) {
_postsError = null;
}
});
try {
final apiService = ref.read(apiServiceProvider);
final posts = await apiService.getProfilePosts(
authorId: _profile!.id,
limit: _postsPageSize,
offset: refresh ? 0 : _posts.length,
);
if (!mounted) return;
setState(() {
if (refresh) {
_posts = posts;
} else {
_posts.addAll(posts);
}
_hasMorePosts = posts.length == _postsPageSize;
});
} catch (error) {
if (!mounted) return;
setState(() {
_postsError = error.toString().replaceAll('Exception: ', '');
});
} finally {
if (mounted) {
setState(() {
_isPostsLoading = false;
_isPostsLoadingMore = false;
});
}
}
}
Future<void> _loadSaved({bool refresh = false}) async {
if (_profile == null) return;
if (refresh) {
setState(() {
_savedPosts = [];
_hasMoreSaved = true;
_savedError = null;
});
} else if (!_hasMoreSaved || _isSavedLoadingMore) {
return;
}
setState(() {
if (refresh) {
_isSavedLoading = true;
} else {
_isSavedLoadingMore = true;
}
if (!refresh) {
_savedError = null;
}
});
try {
final apiService = ref.read(apiServiceProvider);
final posts = await apiService.getSavedPosts(
userId: _profile!.id,
limit: _postsPageSize,
offset: refresh ? 0 : _savedPosts.length,
);
if (!mounted) return;
setState(() {
if (refresh) {
_savedPosts = posts;
} else {
_savedPosts.addAll(posts);
}
_hasMoreSaved = posts.length == _postsPageSize;
});
} catch (error) {
if (!mounted) return;
setState(() {
_savedError = error.toString().replaceAll('Exception: ', '');
});
} finally {
if (mounted) {
setState(() {
_isSavedLoading = false;
_isSavedLoadingMore = false;
});
}
}
}
Future<void> _loadChained({bool refresh = false}) async {
if (_profile == null) return;
if (refresh) {
setState(() {
_chainedPosts = [];
_hasMoreChained = true;
_chainedError = null;
});
} else if (!_hasMoreChained || _isChainedLoadingMore) {
return;
}
setState(() {
if (refresh) {
_isChainedLoading = true;
} else {
_isChainedLoadingMore = true;
}
if (!refresh) {
_chainedError = null;
}
});
try {
final apiService = ref.read(apiServiceProvider);
final posts = await apiService.getChainedPostsForAuthor(
authorId: _profile!.id,
limit: _postsPageSize,
offset: refresh ? 0 : _chainedPosts.length,
);
if (!mounted) return;
setState(() {
if (refresh) {
_chainedPosts = posts;
} else {
_chainedPosts.addAll(posts);
}
_hasMoreChained = posts.length == _postsPageSize;
});
} catch (error) {
if (!mounted) return;
setState(() {
_chainedError = error.toString().replaceAll('Exception: ', '');
});
} finally {
if (mounted) {
setState(() {
_isChainedLoading = false;
_isChainedLoadingMore = false;
});
}
}
}
Future<void> _openSettings() async {
final profile = _profile;
if (profile == null) return;
final settings =
_privacySettings ?? ProfilePrivacySettings.defaults(profile.id);
final result = await Navigator.of(context, rootNavigator: true)
.push<ProfileSettingsResult>(
MaterialPageRoute(
builder: (_) => ProfileSettingsScreen(
profile: profile,
settings: settings,
),
),
);
if (result != null && mounted) {
setState(() {
_profile = result.profile;
_privacySettings = result.settings;
});
}
}
Future<void> _openPrivacyMenu() async {
final profile = _profile;
if (profile == null) return;
final apiService = ref.read(apiServiceProvider);
final currentSettings =
_privacySettings ?? ProfilePrivacySettings.defaults(profile.id);
ProfilePrivacySettings draft = currentSettings;
final result = await showModalBottomSheet<ProfilePrivacySettings>(
context: context,
useSafeArea: true,
isScrollControlled: true,
builder: (context) {
bool isSaving = false;
bool limitOldPosts = false;
return StatefulBuilder(
builder: (context, setModalState) {
Future<void> handleSave() async {
if (isSaving) return;
setModalState(() => isSaving = true);
try {
final saved = await apiService.updatePrivacySettings(draft);
if (limitOldPosts) {
await apiService
.updateAllPostVisibility(draft.postsVisibility);
}
if (!context.mounted) return;
Navigator.of(context).pop(saved);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Privacy settings updated.'),
),
);
}
} catch (error) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
error.toString().replaceAll('Exception: ', ''),
),
backgroundColor: AppTheme.error,
),
);
} finally {
if (context.mounted) {
setModalState(() => isSaving = false);
}
}
}
return Padding(
padding: EdgeInsets.only(
left: AppTheme.spacingLg,
right: AppTheme.spacingLg,
top: AppTheme.spacingLg,
bottom: MediaQuery.of(context).viewInsets.bottom +
AppTheme.spacingLg,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Privacy', style: AppTheme.headlineSmall),
const SizedBox(height: AppTheme.spacingSm),
Text(
'Control who can see your profile and posts.',
style: AppTheme.bodyMedium.copyWith(
color: AppTheme.navyText.withOpacity(0.7),
),
),
const SizedBox(height: AppTheme.spacingLg),
_PrivacyDropdown(
label: 'Profile visibility',
value: draft.profileVisibility,
onChanged: (value) {
setModalState(() {
draft = draft.copyWith(profileVisibility: value);
});
},
),
const SizedBox(height: AppTheme.spacingMd),
_PrivacyDropdown(
label: 'Posts visibility',
value: draft.postsVisibility,
onChanged: (value) {
setModalState(() {
draft = draft.copyWith(postsVisibility: value);
});
},
),
const SizedBox(height: AppTheme.spacingMd),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
value: limitOldPosts,
title: const Text('Limit old posts'),
subtitle: const Text(
'Apply this posts privacy setting to all existing posts.',
),
onChanged: (value) {
setModalState(() => limitOldPosts = value ?? false);
},
),
const SizedBox(height: AppTheme.spacingLg),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: isSaving ? null : handleSave,
child: isSaving
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
AppTheme.white,
),
),
)
: const Text('Save'),
),
),
const SizedBox(height: AppTheme.spacingSm),
],
),
);
},
);
},
);
if (result != null && mounted) {
setState(() {
_privacySettings = result;
});
}
}
void _showAvatarActions() {
final profile = _profile;
if (profile == null) return;
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (context) {
return Container(
padding: const EdgeInsets.all(AppTheme.spacingLg),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.visibility),
title: const Text('View profile photo'),
onTap: () {
Navigator.of(context).pop();
_showAvatarPreview(profile);
},
),
ListTile(
leading: const Icon(Icons.photo_camera),
title: const Text('Change profile photo'),
onTap: () {
Navigator.of(context).pop();
_openSettings();
},
),
],
),
);
},
);
}
void _showAvatarPreview(Profile profile) {
showDialog(
context: context,
builder: (context) {
final avatarUrl = _resolveAvatar(profile.avatarUrl);
return Dialog(
backgroundColor: Colors.transparent,
insetPadding: const EdgeInsets.all(AppTheme.spacingLg),
child: Container(
padding: const EdgeInsets.all(AppTheme.spacingLg),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(20),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Profile Photo',
style: AppTheme.headlineSmall,
),
const SizedBox(height: AppTheme.spacingLg),
CircleAvatar(
radius: 72,
backgroundColor: AppTheme.queenPink,
child: avatarUrl != null && avatarUrl.isNotEmpty
? ClipOval(
child: SizedBox(
width: 144,
height: 144,
child: SignedMediaImage(
url: avatarUrl,
width: 144,
height: 144,
fit: BoxFit.cover,
),
),
)
: Text(
profile.displayName.isNotEmpty
? profile.displayName[0].toUpperCase()
: '?',
style: AppTheme.headlineMedium.copyWith(
color: AppTheme.royalPurple,
),
),
),
const SizedBox(height: AppTheme.spacingLg),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Close'),
),
],
),
),
);
},
);
}
void _openPostDetail(Post post) {
Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute(
builder: (_) => PostDetailScreen(post: post),
),
);
}
void _openChainComposer(Post post) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ComposeScreen(chainParentPost: post),
fullscreenDialog: true,
),
);
}
List<Post> _getPostsFor(ProfileFeedType type) {
switch (type) {
case ProfileFeedType.posts:
return _posts;
case ProfileFeedType.saved:
return _savedPosts;
case ProfileFeedType.chained:
return _chainedPosts;
}
}
bool _isLoadingFor(ProfileFeedType type) {
switch (type) {
case ProfileFeedType.posts:
return _isPostsLoading;
case ProfileFeedType.saved:
return _isSavedLoading;
case ProfileFeedType.chained:
return _isChainedLoading;
}
}
bool _isLoadingMoreFor(ProfileFeedType type) {
switch (type) {
case ProfileFeedType.posts:
return _isPostsLoadingMore;
case ProfileFeedType.saved:
return _isSavedLoadingMore;
case ProfileFeedType.chained:
return _isChainedLoadingMore;
}
}
bool _hasMoreFor(ProfileFeedType type) {
switch (type) {
case ProfileFeedType.posts:
return _hasMorePosts;
case ProfileFeedType.saved:
return _hasMoreSaved;
case ProfileFeedType.chained:
return _hasMoreChained;
}
}
String? _errorFor(ProfileFeedType type) {
switch (type) {
case ProfileFeedType.posts:
return _postsError;
case ProfileFeedType.saved:
return _savedError;
case ProfileFeedType.chained:
return _chainedError;
}
}
@override
Widget build(BuildContext context) {
final profile = _profile;
if (_profileError != null && profile == null && !_isProfileLoading) {
return _buildErrorState();
}
if (_isProfileLoading && profile == null) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
if (profile == null) {
return const Scaffold(
body: Center(child: Text('No profile found')),
);
}
return Scaffold(
backgroundColor: AppTheme.scaffoldBg,
body: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) {
return [
_buildSliverAppBar(profile),
_buildSliverTabBar(),
];
},
body: _buildTabBarView(),
),
);
}
Widget _buildErrorState() {
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: AppTheme.spacingLg),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_profileError ?? 'Something went wrong',
style: AppTheme.bodyMedium.copyWith(
color: AppTheme.error,
),
textAlign: TextAlign.center,
),
const SizedBox(height: AppTheme.spacingMd),
ElevatedButton(
onPressed: _loadProfile,
child: const Text('Retry'),
),
],
),
),
),
);
}
void _navigateToConnections(int tabIndex) {
if (_profile == null) return;
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => FollowersFollowingScreen(
userId: _profile!.id,
initialTabIndex: tabIndex,
),
),
);
}
Widget _buildSliverAppBar(Profile profile) {
return SliverAppBar(
expandedHeight: 255,
pinned: true,
toolbarHeight: 0,
collapsedHeight: 0,
automaticallyImplyLeading: false,
backgroundColor: Colors.transparent,
elevation: 0,
flexibleSpace: FlexibleSpaceBar(
background: _ProfileHeader(
profile: profile,
stats: _stats,
onSettingsTap: _openSettings,
onPrivacyTap: _openPrivacyMenu,
onAvatarTap: _showAvatarActions,
onConnectionsTap: _navigateToConnections,
),
),
);
}
Widget _buildSliverTabBar() {
return SliverPersistentHeader(
pinned: true,
delegate: _SliverTabBarDelegate(
TabBar(
controller: _tabController,
labelColor: AppTheme.navyText,
unselectedLabelColor: AppTheme.navyText.withOpacity(0.6),
indicatorColor: AppTheme.royalPurple,
indicatorWeight: 3,
labelStyle: AppTheme.labelMedium,
tabs: const [
Tab(text: 'Posts'),
Tab(text: 'Saved'),
Tab(text: 'Chains'),
],
),
),
);
}
Widget _buildTabBarView() {
final activePosts = _getPostsFor(_activeFeed);
final isLoading = _isLoadingFor(_activeFeed);
final isLoadingMore = _isLoadingMoreFor(_activeFeed);
final hasMore = _hasMoreFor(_activeFeed);
final error = _errorFor(_activeFeed);
return RefreshIndicator(
onRefresh: _refreshAll,
child: CustomScrollView(
slivers: [
if (error != null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(AppTheme.spacingLg),
child: Text(
error,
style: AppTheme.bodyMedium.copyWith(color: AppTheme.error),
textAlign: TextAlign.center,
),
),
),
if (isLoading && activePosts.isEmpty)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.symmetric(vertical: AppTheme.spacingLg),
child: Center(child: CircularProgressIndicator()),
),
),
if (activePosts.isEmpty && !isLoading)
SliverFillRemaining(
child: Center(
child: Text(
'No posts yet',
style: AppTheme.bodyMedium.copyWith(
color: AppTheme.navyText.withOpacity(0.7),
),
),
),
),
if (activePosts.isNotEmpty)
SliverPadding(
padding: const EdgeInsets.symmetric(
horizontal: AppTheme.spacingMd,
vertical: AppTheme.spacingMd,
),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final post = activePosts[index];
return Padding(
padding: EdgeInsets.only(
bottom: index == activePosts.length - 1
? 0
: AppTheme.spacingSm,
),
child: PostItem(
post: post,
onTap: () => _openPostDetail(post),
onChain: () => _openChainComposer(post),
),
);
},
childCount: activePosts.length,
),
),
),
if (isLoadingMore)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.symmetric(vertical: AppTheme.spacingLg),
child: Center(child: CircularProgressIndicator()),
),
),
if (!isLoadingMore && hasMore && activePosts.isNotEmpty)
SliverToBoxAdapter(
child: Center(
child: TextButton(
onPressed: () => _loadActiveFeed(refresh: false),
child: const Text('Load more'),
),
),
),
SliverToBoxAdapter(
child: SizedBox(height: AppTheme.spacingLg * 2),
),
],
),
);
}
}
// ==============================================================================
// PROFILE HEADER WITH HARMONY RING
// ==============================================================================
class _ProfileHeader extends StatelessWidget {
final Profile profile;
final ProfileStats? stats;
final VoidCallback onSettingsTap;
final VoidCallback onPrivacyTap;
final VoidCallback onAvatarTap;
final void Function(int tabIndex) onConnectionsTap;
const _ProfileHeader({
required this.profile,
required this.stats,
required this.onSettingsTap,
required this.onPrivacyTap,
required this.onAvatarTap,
required this.onConnectionsTap,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
gradient: _generateGradient(profile.handle),
),
child: SafeArea(
bottom: false,
child: LayoutBuilder(
builder: (context, constraints) {
final isCompact = constraints.maxHeight < 240;
final avatarRadius = isCompact ? 36.0 : 44.0;
return Padding(
padding: EdgeInsets.only(
top: 0,
bottom: isCompact ? 2 : 6,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
Align(
alignment: Alignment.topRight,
child: Padding(
padding: EdgeInsets.only(top: 0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: onPrivacyTap,
icon:
Icon(Icons.lock_outline, color: AppTheme.white),
tooltip: 'Privacy',
),
const SizedBox(width: 4),
IconButton(
onPressed: onSettingsTap,
icon: Icon(Icons.settings_outlined,
color: AppTheme.white),
tooltip: 'Settings',
),
],
),
),
),
InkResponse(
onTap: onAvatarTap,
radius: 40,
child: _HarmonyAvatar(
profile: profile,
radius: avatarRadius,
),
),
SizedBox(height: isCompact ? 4 : 6),
Text(
profile.displayName,
style: AppTheme.headlineMedium.copyWith(
color: AppTheme.white.withOpacity(0.95),
fontSize: isCompact ? 14 : 16,
shadows: [
Shadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 6,
),
],
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'@${profile.handle}',
style: AppTheme.bodyMedium.copyWith(
fontSize: 11,
color: AppTheme.white.withOpacity(0.85),
shadows: [
Shadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 4,
),
],
),
),
if (getCountryFlag(profile.originCountry ?? 'US') !=
null) ...[
const SizedBox(width: 4),
Text(
getCountryFlag(profile.originCountry ?? 'US')!,
style: const TextStyle(fontSize: 12),
),
],
],
),
if (stats != null && !isCompact) ...[
const SizedBox(height: 5),
_buildStats(stats!),
],
],
),
);
},
),
),
);
}
Widget _buildStats(ProfileStats stats) {
return FittedBox(
fit: BoxFit.scaleDown,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppTheme.spacingMd,
vertical: AppTheme.spacingXs,
),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.35),
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_StatItem(label: 'Posts', value: stats.posts.toString()),
const SizedBox(width: AppTheme.spacingMd),
_StatItem(
label: 'Followers',
value: stats.followers.toString(),
onTap: () => onConnectionsTap(0),
),
const SizedBox(width: AppTheme.spacingMd),
_StatItem(
label: 'Following',
value: stats.following.toString(),
onTap: () => onConnectionsTap(1),
),
],
),
),
);
}
LinearGradient _generateGradient(String seed) {
final hash = seed.hashCode.abs();
final hue = (hash % 360).toDouble();
return LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
HSLColor.fromAHSL(1.0, hue, 0.6, 0.55).toColor(),
HSLColor.fromAHSL(1.0, (hue + 60) % 360, 0.6, 0.45).toColor(),
],
);
}
}
// ==============================================================================
// STAT ITEM
// ==============================================================================
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) {
final content = Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
value,
style: AppTheme.headlineSmall.copyWith(
color: AppTheme.white,
fontSize: 16,
shadows: [
Shadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 4,
),
],
),
),
Text(
label,
style: AppTheme.labelSmall.copyWith(
color: AppTheme.white.withOpacity(0.8),
fontSize: 10,
shadows: [
Shadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 2,
),
],
),
),
],
);
if (onTap != null) {
return GestureDetector(
onTap: onTap,
child: content,
);
}
return content;
}
}
// ==============================================================================
// HARMONY AVATAR WITH RING
// ==============================================================================
class _HarmonyAvatar extends StatelessWidget {
final Profile profile;
final double radius;
const _HarmonyAvatar({
required this.profile,
required this.radius,
});
@override
Widget build(BuildContext context) {
final trustState = profile.trustState;
final avatarLetter =
profile.handle.isNotEmpty ? profile.handle[0].toUpperCase() : '?';
Color ringColor = AppTheme.egyptianBlue;
double ringWidth = 3;
if (trustState != null) {
final harmonyScore = trustState.harmonyScore / 100.0;
if (harmonyScore >= 0.8) {
ringColor = const Color(0xFFFFD700); // Gold
ringWidth = 5;
} else if (harmonyScore >= 0.5) {
ringColor = AppTheme.royalPurple;
ringWidth = 4;
} else if (harmonyScore >= 0.3) {
ringColor = AppTheme.egyptianBlue;
ringWidth = 3;
} else {
ringColor = Colors.grey;
ringWidth = 2;
}
}
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(radius * 0.45),
border: Border.all(
color: ringWidth >= 4 ? ringColor : ringColor.withOpacity(0.8),
width: ringWidth,
),
boxShadow: [
if (ringWidth >= 4)
BoxShadow(
color: ringColor.withOpacity(0.5),
blurRadius: 12,
spreadRadius: 2,
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(radius * 0.4),
child: SizedBox(
width: radius * 2,
height: radius * 2,
child: _resolveAvatar(profile.avatarUrl) != null &&
_resolveAvatar(profile.avatarUrl)!.isNotEmpty
? SignedMediaImage(
url: _resolveAvatar(profile.avatarUrl)!,
fit: BoxFit.cover,
)
: Container(
color: AppTheme.queenPink,
alignment: Alignment.center,
child: Text(
avatarLetter,
style: AppTheme.headlineMedium.copyWith(
fontSize: radius * 0.6,
color: AppTheme.royalPurple,
),
),
),
),
),
);
}
}
// ==============================================================================
// HARMONY BADGE
// ==============================================================================
class _HarmonyBadge extends StatelessWidget {
final TrustState trustState;
const _HarmonyBadge({
required this.trustState,
});
@override
Widget build(BuildContext context) {
final tier = trustState.tier;
Color badgeColor;
Color textColor;
switch (tier) {
case TrustTier.established:
badgeColor = const Color(0xFFFFD700); // Gold
break;
case TrustTier.trusted:
badgeColor = AppTheme.royalPurple;
break;
case TrustTier.new_user:
badgeColor = AppTheme.egyptianBlue;
break;
}
textColor = tier == TrustTier.new_user ? AppTheme.white : badgeColor;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppTheme.spacingMd,
vertical: AppTheme.spacingXs,
),
decoration: BoxDecoration(
color: badgeColor.withOpacity(0.2),
border: Border.all(color: badgeColor, width: 1.5),
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_getIconForTier(tier),
size: 14,
color: badgeColor,
),
const SizedBox(width: AppTheme.spacingXs),
Text(
tier.displayName,
style: AppTheme.labelSmall.copyWith(
color: textColor,
fontWeight: FontWeight.w700,
),
),
],
),
);
}
IconData _getIconForTier(TrustTier tier) {
switch (tier) {
case TrustTier.established:
return Icons.verified;
case TrustTier.trusted:
return Icons.check_circle;
case TrustTier.new_user:
return Icons.fiber_new;
}
}
}
// ==============================================================================
// HEADER ACTION BUTTON
// ==============================================================================
class _HeaderActionButton extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback onPressed;
const _HeaderActionButton({
required this.icon,
required this.label,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return OutlinedButton.icon(
onPressed: onPressed,
style: OutlinedButton.styleFrom(
foregroundColor: AppTheme.white,
side: BorderSide(color: AppTheme.white.withOpacity(0.7)),
padding: const EdgeInsets.symmetric(
horizontal: AppTheme.spacingMd,
vertical: AppTheme.spacingSm,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
),
),
icon: Icon(icon, size: 18),
label: Text(
label,
style: AppTheme.labelMedium.copyWith(color: AppTheme.white),
),
);
}
}
// ==============================================================================
// PRIVACY DROPDOWN
// ==============================================================================
class _PrivacyDropdown extends StatelessWidget {
final String label;
final String value;
final ValueChanged<String> onChanged;
const _PrivacyDropdown({
required this.label,
required this.value,
required this.onChanged,
});
static const Map<String, String> _options = {
'public': 'Public',
'followers': 'Followers only',
'private': 'Private',
};
@override
Widget build(BuildContext context) {
return DropdownButtonFormField<String>(
value: value,
decoration: InputDecoration(labelText: label),
isExpanded: true,
items: _options.entries
.map((e) => DropdownMenuItem(value: e.key, child: Text(e.value)))
.toList(),
onChanged: (v) => v == null ? null : onChanged(v),
);
}
}
// ==============================================================================
// SLIVER TAB BAR DELEGATE
// ==============================================================================
class _SliverTabBarDelegate extends SliverPersistentHeaderDelegate {
final TabBar tabBar;
_SliverTabBarDelegate(this.tabBar);
@override
double get minExtent => tabBar.preferredSize.height;
@override
double get maxExtent => tabBar.preferredSize.height;
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
return Container(
color: AppTheme.cardSurface,
child: tabBar,
);
}
@override
bool shouldRebuild(_SliverTabBarDelegate oldDelegate) {
return false;
}
}