From 257acb0e515a89811a33f1c73c5cc00ea7e8893e Mon Sep 17 00:00:00 2001 From: Patrick Britton Date: Mon, 9 Feb 2026 17:55:39 -0600 Subject: [PATCH] refactor: unify post widgets, profile screens, and tree-shake dead code Phase 1 - Post Widget Unification: - Added PostViewMode.thread for compact thread styling - Updated SojornPostCard, PostBody, PostLinkPreview, PostMedia for thread mode - Migrated feed_personal_screen, profile_screen, viewable_profile_screen to sojornPostCard - Deleted deprecated: post_card.dart, post_item.dart, reading_post_card.dart Phase 2 - Profile Screen Unification: - Renamed ViewableProfileScreen to UnifiedProfileScreen - Made handle optional (null = own profile mode) - Added auth listener, auto-create profile, avatar actions for own profile - Added connections navigation to stats for own profile - Updated all routes and references across codebase - Deleted old profile_screen.dart Phase 3 - Tree Shaking: - Deleted 9 orphan files: compose_and_chat_fab, sojorn_top_bar, sojorn_app_bar, sojorn_dialog, sojorn_card, glassmorphic_quips_sheet, kinetic_thread_widget, reaction_strip, smart_reaction_button --- sojorn_app/lib/routes/app_routes.dart | 5 +- .../lib/screens/discover/discover_screen.dart | 2 +- .../screens/home/feed_personal_screen.dart | 5 +- .../lib/screens/home/feed_sojorn_screen.dart | 2 +- .../notifications/notifications_screen.dart | 2 +- .../profile/followers_following_screen.dart | 2 +- .../lib/screens/profile/following_screen.dart | 2 +- .../lib/screens/profile/profile_screen.dart | 1461 ---------------- .../profile/viewable_profile_screen.dart | 326 +++- .../lib/screens/search/search_screen.dart | 2 +- .../lib/widgets/compose_and_chat_fab.dart | 60 - .../lib/widgets/glassmorphic_quips_sheet.dart | 906 ---------- .../lib/widgets/kinetic_thread_widget.dart | 1530 ----------------- sojorn_app/lib/widgets/post/post_body.dart | 2 + .../lib/widgets/post/post_link_preview.dart | 2 + sojorn_app/lib/widgets/post/post_media.dart | 2 + .../lib/widgets/post/post_view_mode.dart | 4 + sojorn_app/lib/widgets/post_card.dart | 107 -- sojorn_app/lib/widgets/post_item.dart | 139 -- .../lib/widgets/reactions/reaction_strip.dart | 377 ---- .../reactions/smart_reaction_button.dart | 125 -- sojorn_app/lib/widgets/reading_post_card.dart | 338 ---- sojorn_app/lib/widgets/sojorn_app_bar.dart | 54 - sojorn_app/lib/widgets/sojorn_card.dart | 139 -- sojorn_app/lib/widgets/sojorn_dialog.dart | 241 --- sojorn_app/lib/widgets/sojorn_post_card.dart | 47 +- sojorn_app/lib/widgets/sojorn_top_bar.dart | 37 - 27 files changed, 335 insertions(+), 5584 deletions(-) delete mode 100644 sojorn_app/lib/screens/profile/profile_screen.dart delete mode 100644 sojorn_app/lib/widgets/compose_and_chat_fab.dart delete mode 100644 sojorn_app/lib/widgets/glassmorphic_quips_sheet.dart delete mode 100644 sojorn_app/lib/widgets/kinetic_thread_widget.dart delete mode 100644 sojorn_app/lib/widgets/post_card.dart delete mode 100644 sojorn_app/lib/widgets/post_item.dart delete mode 100644 sojorn_app/lib/widgets/reactions/reaction_strip.dart delete mode 100644 sojorn_app/lib/widgets/reactions/smart_reaction_button.dart delete mode 100644 sojorn_app/lib/widgets/reading_post_card.dart delete mode 100644 sojorn_app/lib/widgets/sojorn_app_bar.dart delete mode 100644 sojorn_app/lib/widgets/sojorn_card.dart delete mode 100644 sojorn_app/lib/widgets/sojorn_dialog.dart delete mode 100644 sojorn_app/lib/widgets/sojorn_top_bar.dart diff --git a/sojorn_app/lib/routes/app_routes.dart b/sojorn_app/lib/routes/app_routes.dart index f700a32..6f00ecd 100644 --- a/sojorn_app/lib/routes/app_routes.dart +++ b/sojorn_app/lib/routes/app_routes.dart @@ -14,7 +14,6 @@ import '../screens/home/feed_personal_screen.dart'; import '../screens/home/home_shell.dart'; import '../screens/quips/create/quip_creation_flow.dart'; import '../screens/quips/feed/quips_feed_screen.dart'; -import '../screens/profile/profile_screen.dart'; import '../screens/profile/viewable_profile_screen.dart'; import '../screens/profile/blocked_users_screen.dart'; import '../screens/auth/auth_gate.dart'; @@ -55,7 +54,7 @@ class AppRoutes { GoRoute( path: '$userPrefix/:username', parentNavigatorKey: rootNavigatorKey, - builder: (_, state) => ViewableProfileScreen( + builder: (_, state) => UnifiedProfileScreen( handle: state.pathParameters['username'] ?? '', ), ), @@ -121,7 +120,7 @@ class AppRoutes { routes: [ GoRoute( path: profile, - builder: (_, __) => const ProfileScreen(), + builder: (_, __) => const UnifiedProfileScreen(), routes: [ GoRoute( path: 'blocked', diff --git a/sojorn_app/lib/screens/discover/discover_screen.dart b/sojorn_app/lib/screens/discover/discover_screen.dart index ffc120c..b7a4ae5 100644 --- a/sojorn_app/lib/screens/discover/discover_screen.dart +++ b/sojorn_app/lib/screens/discover/discover_screen.dart @@ -253,7 +253,7 @@ class _DiscoverScreenState extends ConsumerState { void _navigateToProfile(String handle) { Navigator.of(context).push( MaterialPageRoute( - builder: (_) => ViewableProfileScreen(handle: handle), + builder: (_) => UnifiedProfileScreen(handle: handle), ), ); } diff --git a/sojorn_app/lib/screens/home/feed_personal_screen.dart b/sojorn_app/lib/screens/home/feed_personal_screen.dart index f066e25..39fdd7c 100644 --- a/sojorn_app/lib/screens/home/feed_personal_screen.dart +++ b/sojorn_app/lib/screens/home/feed_personal_screen.dart @@ -4,7 +4,7 @@ import '../../providers/api_provider.dart'; import '../../providers/feed_refresh_provider.dart'; import '../../models/post.dart'; import '../../theme/app_theme.dart'; -import '../../widgets/post_item.dart'; +import '../../widgets/sojorn_post_card.dart'; import '../../widgets/app_scaffold.dart'; import '../compose/compose_screen.dart'; import '../post/post_detail_screen.dart'; @@ -142,11 +142,10 @@ class _FeedPersonalScreenState extends ConsumerState { } final post = _posts[index]; - return UnifiedPostTile( + return sojornPostCard( post: post, onTap: () => _openPostDetail(post), onChain: () => _openChainComposer(post), - showDivider: index != _posts.length - 1, ); }, childCount: _posts.length, diff --git a/sojorn_app/lib/screens/home/feed_sojorn_screen.dart b/sojorn_app/lib/screens/home/feed_sojorn_screen.dart index 3328cf6..4a70a33 100644 --- a/sojorn_app/lib/screens/home/feed_sojorn_screen.dart +++ b/sojorn_app/lib/screens/home/feed_sojorn_screen.dart @@ -141,7 +141,7 @@ class _FeedsojornScreenState extends ConsumerState { if (post.author != null && post.author!.handle.isNotEmpty) { Navigator.of(context).push( MaterialPageRoute( - builder: (_) => ViewableProfileScreen(handle: post.author!.handle), + builder: (_) => UnifiedProfileScreen(handle: post.author!.handle), ), ); } diff --git a/sojorn_app/lib/screens/notifications/notifications_screen.dart b/sojorn_app/lib/screens/notifications/notifications_screen.dart index 736d733..a55eb64 100644 --- a/sojorn_app/lib/screens/notifications/notifications_screen.dart +++ b/sojorn_app/lib/screens/notifications/notifications_screen.dart @@ -272,7 +272,7 @@ class _NotificationsScreenState extends ConsumerState { if (notification.actor != null) { Navigator.of(context).push( MaterialPageRoute( - builder: (_) => ViewableProfileScreen(handle: notification.actor!.handle), + builder: (_) => UnifiedProfileScreen(handle: notification.actor!.handle), ), ); } diff --git a/sojorn_app/lib/screens/profile/followers_following_screen.dart b/sojorn_app/lib/screens/profile/followers_following_screen.dart index a68d193..af2d38f 100644 --- a/sojorn_app/lib/screens/profile/followers_following_screen.dart +++ b/sojorn_app/lib/screens/profile/followers_following_screen.dart @@ -272,7 +272,7 @@ class _FollowersFollowingScreenState void _navigateToProfile(UserListItem user) { Navigator.of(context).push( MaterialPageRoute( - builder: (context) => ViewableProfileScreen(handle: user.handle), + builder: (context) => UnifiedProfileScreen(handle: user.handle), ), ); } diff --git a/sojorn_app/lib/screens/profile/following_screen.dart b/sojorn_app/lib/screens/profile/following_screen.dart index f96422c..452e45b 100644 --- a/sojorn_app/lib/screens/profile/following_screen.dart +++ b/sojorn_app/lib/screens/profile/following_screen.dart @@ -296,7 +296,7 @@ class _FollowingScreenState extends ConsumerState { onTap: () { Navigator.of(context).push( MaterialPageRoute( - builder: (_) => ViewableProfileScreen(handle: user.handle), + builder: (_) => UnifiedProfileScreen(handle: user.handle), ), ); }, diff --git a/sojorn_app/lib/screens/profile/profile_screen.dart b/sojorn_app/lib/screens/profile/profile_screen.dart deleted file mode 100644 index bc6e752..0000000 --- a/sojorn_app/lib/screens/profile/profile_screen.dart +++ /dev/null @@ -1,1461 +0,0 @@ -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 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 - 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 _posts = []; - bool _isProfileLoading = false; - String? _profileError; - bool _isCreatingProfile = false; - - bool _isPostsLoading = false; - bool _isPostsLoadingMore = false; - bool _hasMorePosts = true; - String? _postsError; - - - - List _savedPosts = []; - bool _isSavedLoading = false; - bool _isSavedLoadingMore = false; - bool _hasMoreSaved = true; - String? _savedError; - - List _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 _refreshAll() async { - await _loadProfile(refreshFeeds: false); - await _loadActiveFeed(refresh: true); - } - - Future _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 _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 _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 _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 _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 _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 _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 _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( - MaterialPageRoute( - builder: (_) => ProfileSettingsScreen( - profile: profile, - settings: settings, - ), - ), - ); - - if (result != null && mounted) { - setState(() { - _profile = result.profile; - _privacySettings = result.settings; - }); - } - } - - Future _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( - context: context, - useSafeArea: true, - isScrollControlled: true, - builder: (context) { - bool isSaving = false; - bool limitOldPosts = false; - return StatefulBuilder( - builder: (context, setModalState) { - Future 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( - 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) async { - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ComposeScreen(chainParentPost: post), - fullscreenDialog: true, - ), - ); - FocusManager.instance.primaryFocus?.unfocus(); - } - - List _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 onChanged; - - const _PrivacyDropdown({ - required this.label, - required this.value, - required this.onChanged, - }); - - static const Map _options = { - 'public': 'Public', - 'followers': 'Followers only', - 'private': 'Private', - }; - - @override - Widget build(BuildContext context) { - return DropdownButtonFormField( - 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; - } -} diff --git a/sojorn_app/lib/screens/profile/viewable_profile_screen.dart b/sojorn_app/lib/screens/profile/viewable_profile_screen.dart index 2543575..21d71ec 100644 --- a/sojorn_app/lib/screens/profile/viewable_profile_screen.dart +++ b/sojorn_app/lib/screens/profile/viewable_profile_screen.dart @@ -13,7 +13,7 @@ import '../../providers/api_provider.dart'; import '../../theme/app_theme.dart'; import '../../utils/country_flag.dart'; import '../../utils/url_launcher_helper.dart'; -import '../../widgets/post_item.dart'; +import '../../widgets/sojorn_post_card.dart'; import '../../widgets/media/signed_media_image.dart'; import '../compose/compose_screen.dart'; import '../secure_chat/secure_chat_screen.dart'; @@ -21,25 +21,37 @@ import '../../services/auth_service.dart'; import '../../services/secure_chat_service.dart'; import '../post/post_detail_screen.dart'; import 'profile_settings_screen.dart'; -import 'profile_screen.dart'; +import 'followers_following_screen.dart'; -/// Screen for viewing another user's profile -class ViewableProfileScreen extends ConsumerStatefulWidget { - final String handle; +/// Unified profile screen - handles both own profile and viewing others. +/// +/// When [handle] is null, loads the current user's own profile with +/// edit controls, privacy settings, and avatar actions. +/// When [handle] is provided, loads the target user's profile with +/// follow/message actions. +class UnifiedProfileScreen extends ConsumerStatefulWidget { + final String? handle; - const ViewableProfileScreen({ + const UnifiedProfileScreen({ super.key, - required this.handle, + this.handle, }); @override - ConsumerState createState() => - _ViewableProfileScreenState(); + ConsumerState createState() => + _UnifiedProfileScreenState(); } -class _ViewableProfileScreenState extends ConsumerState +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 _UnifiedProfileScreenState extends ConsumerState with SingleTickerProviderStateMixin { static const int _postsPageSize = 20; + StreamSubscription? _authSubscription; Profile? _profile; ProfileStats? _stats; @@ -52,9 +64,13 @@ class _ViewableProfileScreenState extends ConsumerState String? _profileError; bool _isPrivate = false; bool _isOwnProfile = false; + bool _isCreatingProfile = false; ProfilePrivacySettings? _privacySettings; bool _isPrivacyLoading = false; + /// True when no handle was provided (bottom-nav profile tab) + bool get _isOwnProfileMode => widget.handle == null; + late TabController _tabController; int _activeTab = 0; @@ -79,7 +95,8 @@ class _ViewableProfileScreenState extends ConsumerState @override void initState() { super.initState(); - _tabController = TabController(length: 4, vsync: this); + // Own profile gets 3 tabs (Posts, Saved, Chains), others get 4 (+About) + _tabController = TabController(length: _isOwnProfileMode ? 3 : 4, vsync: this); _tabController.addListener(() { if (!_tabController.indexIsChanging) { setState(() { @@ -90,10 +107,21 @@ class _ViewableProfileScreenState extends ConsumerState }); _loadProfile(); + + // Listen for auth changes when viewing own profile + if (_isOwnProfileMode) { + _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(); } @@ -106,7 +134,10 @@ class _ViewableProfileScreenState extends ConsumerState try { final apiService = ref.read(apiServiceProvider); - final data = await apiService.getProfile(handle: widget.handle); + // Own profile: no handle arg; other profile: pass handle + final data = _isOwnProfileMode + ? await apiService.getProfile() + : await apiService.getProfile(handle: widget.handle); final profile = data['profile'] as Profile; final stats = data['stats'] as ProfileStats; final followStatus = data['follow_status'] as String?; @@ -115,8 +146,9 @@ class _ViewableProfileScreenState extends ConsumerState final isFriend = data['is_friend'] as bool? ?? false; final isPrivate = data['is_private'] as bool? ?? false; final currentUserId = AuthService.instance.currentUser?.id; - final isOwnProfile = currentUserId != null && - currentUserId.toLowerCase() == profile.id.toLowerCase(); + final isOwnProfile = _isOwnProfileMode || + (currentUserId != null && + currentUserId.toLowerCase() == profile.id.toLowerCase()); if (!mounted) return; @@ -137,6 +169,13 @@ class _ViewableProfileScreenState extends ConsumerState await _loadPosts(refresh: true); } catch (error) { if (!mounted) return; + + // Auto-create profile if own profile and profile not found + if (_isOwnProfileMode && _shouldAutoCreateProfile(error)) { + await _createProfileIfMissing(); + return; + } + setState(() { _profileError = error.toString().replaceAll('Exception: ', ''); }); @@ -149,6 +188,54 @@ class _ViewableProfileScreenState extends ConsumerState } } + bool _shouldAutoCreateProfile(dynamic error) { + final errorStr = error.toString().toLowerCase(); + return errorStr.contains('profile not found') || + errorStr.contains('no profile'); + } + + Future _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'); + } + + 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 _loadActiveFeed() async { switch (_activeTab) { case 0: @@ -636,35 +723,164 @@ class _ViewableProfileScreenState extends ConsumerState } } + Future _refreshAll() async { + await _loadProfile(); + } + + void _navigateToConnections(int tabIndex) { + if (_profile == null) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => FollowersFollowingScreen( + userId: _profile!.id, + initialTabIndex: tabIndex, + ), + ), + ); + } + + 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.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'), + ), + ], + ), + ), + ); + }, + ); + } + @override Widget build(BuildContext context) { + final titleText = _isOwnProfileMode ? 'Profile' : '@${widget.handle ?? ''}'; + if (_profileError != null && _profile == null && !_isLoadingProfile) { return Scaffold( - appBar: AppBar( - title: Text('@${widget.handle}'), - ), + appBar: _isOwnProfileMode ? null : AppBar(title: Text(titleText)), body: _buildErrorState(), ); } if (_isLoadingProfile && _profile == null) { return Scaffold( - appBar: AppBar( - title: Text('@${widget.handle}'), - ), + appBar: _isOwnProfileMode ? null : AppBar(title: Text(titleText)), body: const Center(child: CircularProgressIndicator()), ); } if (_profile == null) { return Scaffold( - appBar: AppBar( - title: Text('@${widget.handle}'), - ), - body: const Center(child: Text('Profile not found')), + appBar: _isOwnProfileMode ? null : AppBar(title: Text(titleText)), + body: const Center(child: Text('No profile found')), ); } + // Own profile: no top AppBar (SliverAppBar only, like the old ProfileScreen) + if (_isOwnProfileMode) { + return Scaffold( + backgroundColor: AppTheme.scaffoldBg, + body: NestedScrollView( + headerSliverBuilder: (context, innerBoxIsScrolled) { + return [ + _buildSliverAppBar(_profile!), + _buildSliverTabBar(), + ]; + }, + body: _buildTabBarView(), + ), + ); + } + + // Viewing another user: AppBar with back button return Scaffold( backgroundColor: AppTheme.scaffoldBg, appBar: AppBar( @@ -675,7 +891,6 @@ class _ViewableProfileScreenState extends ConsumerState if (Navigator.of(context).canPop()) { Navigator.of(context).pop(); } else { - // Safely return to home/feed instead of pushing a redundant ProfileScreen context.go(AppRoutes.homeAlias); } }, @@ -720,7 +935,7 @@ class _ViewableProfileScreenState extends ConsumerState Widget _buildSliverAppBar(Profile profile) { return SliverAppBar( - expandedHeight: 295, + expandedHeight: _isOwnProfile ? 255 : 295, pinned: true, toolbarHeight: 0, collapsedHeight: 0, @@ -741,6 +956,8 @@ class _ViewableProfileScreenState extends ConsumerState onMessageTap: _openMessage, onSettingsTap: _openSettings, onPrivacyTap: _openPrivacyMenu, + onAvatarTap: _isOwnProfile ? _showAvatarActions : null, + onConnectionsTap: _isOwnProfile ? _navigateToConnections : null, ), ), ); @@ -757,11 +974,11 @@ class _ViewableProfileScreenState extends ConsumerState indicatorColor: AppTheme.royalPurple, indicatorWeight: 3, labelStyle: AppTheme.labelMedium, - tabs: const [ - Tab(text: 'Posts'), - Tab(text: 'Saved'), - Tab(text: 'Chains'), - Tab(text: 'About'), + tabs: [ + const Tab(text: 'Posts'), + const Tab(text: 'Saved'), + const Tab(text: 'Chains'), + if (!_isOwnProfileMode) const Tab(text: 'About'), ], ), ), @@ -866,7 +1083,7 @@ class _ViewableProfileScreenState extends ConsumerState ? 0 : AppTheme.spacingSm, ), - child: PostItem( + child: sojornPostCard( post: post, onTap: () => _openPostDetail(post), onChain: () => _openChainComposer(post), @@ -1146,7 +1363,7 @@ class _ViewableProfileScreenState extends ConsumerState } // ============================================================================== -// PROFILE HEADER FOR VIEWABLE PROFILE +// UNIFIED PROFILE HEADER // ============================================================================== class _ProfileHeader extends StatelessWidget { @@ -1162,6 +1379,8 @@ class _ProfileHeader extends StatelessWidget { final VoidCallback onMessageTap; final VoidCallback onSettingsTap; final VoidCallback onPrivacyTap; + final VoidCallback? onAvatarTap; + final void Function(int tabIndex)? onConnectionsTap; const _ProfileHeader({ required this.profile, @@ -1176,6 +1395,8 @@ class _ProfileHeader extends StatelessWidget { required this.onMessageTap, required this.onSettingsTap, required this.onPrivacyTap, + this.onAvatarTap, + this.onConnectionsTap, }); @override @@ -1222,10 +1443,20 @@ class _ProfileHeader extends StatelessWidget { ), ), ), - _HarmonyAvatar( - profile: profile, - radius: avatarRadius, - ), + if (onAvatarTap != null) + InkResponse( + onTap: onAvatarTap, + radius: 40, + child: _HarmonyAvatar( + profile: profile, + radius: avatarRadius, + ), + ) + else + _HarmonyAvatar( + profile: profile, + radius: avatarRadius, + ), SizedBox(height: isCompact ? 4 : 6), Text( profile.displayName, @@ -1412,9 +1643,17 @@ 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: onConnectionsTap != null ? () => onConnectionsTap!(0) : null, + ), const SizedBox(width: AppTheme.spacingMd), - _StatItem(label: 'Following', value: stats.following.toString()), + _StatItem( + label: 'Following', + value: stats.following.toString(), + onTap: onConnectionsTap != null ? () => onConnectionsTap!(1) : null, + ), ], ), ), @@ -1443,15 +1682,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( @@ -1482,6 +1723,11 @@ class _StatItem extends StatelessWidget { ), ], ); + + if (onTap != null) { + return GestureDetector(onTap: onTap, child: content); + } + return content; } } diff --git a/sojorn_app/lib/screens/search/search_screen.dart b/sojorn_app/lib/screens/search/search_screen.dart index a321aa0..d272443 100644 --- a/sojorn_app/lib/screens/search/search_screen.dart +++ b/sojorn_app/lib/screens/search/search_screen.dart @@ -385,7 +385,7 @@ class _SearchScreenState extends ConsumerState { onTap: () => Navigator.of(context).push( MaterialPageRoute( builder: (_) => - ViewableProfileScreen(handle: user.username)), + UnifiedProfileScreen(handle: user.username)), ), ); }, diff --git a/sojorn_app/lib/widgets/compose_and_chat_fab.dart b/sojorn_app/lib/widgets/compose_and_chat_fab.dart deleted file mode 100644 index ebd59d1..0000000 --- a/sojorn_app/lib/widgets/compose_and_chat_fab.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:flutter/material.dart'; -import '../theme/app_theme.dart'; -import '../screens/compose/compose_screen.dart'; -import '../screens/secure_chat/secure_chat_full_screen.dart'; - -/// Floating action buttons for composing new posts and accessing chat -class ComposeAndChatFab extends StatelessWidget { - final String? composeHeroTag; - final String? chatHeroTag; - - const ComposeAndChatFab({ - super.key, - this.composeHeroTag, - this.chatHeroTag, - }); - - @override - Widget build(BuildContext context) { - return Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - // Chat Button - Opens unified secure chat - Padding( - padding: const EdgeInsets.only(right: 16.0), - child: FloatingActionButton( - heroTag: chatHeroTag, - tooltip: 'Messages', - onPressed: () { - Navigator.of(context, rootNavigator: true).push( - MaterialPageRoute( - builder: (_) => const SecureChatFullScreen(), - fullscreenDialog: true, - ), - ); - }, - backgroundColor: AppTheme.queenPink, - child: const Icon(Icons.chat_bubble_outline, color: AppTheme.white), - ), - ), - // Compose Button - FloatingActionButton( - heroTag: composeHeroTag, - tooltip: 'Compose', - onPressed: () async { - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const ComposeScreen(), - fullscreenDialog: true, - ), - ); - // Reset focus so the underlying screen is interactive on web - FocusManager.instance.primaryFocus?.unfocus(); - }, - backgroundColor: AppTheme.brightNavy, - child: const Icon(Icons.edit_outlined, color: AppTheme.white), - ), - ], - ); - } -} diff --git a/sojorn_app/lib/widgets/glassmorphic_quips_sheet.dart b/sojorn_app/lib/widgets/glassmorphic_quips_sheet.dart deleted file mode 100644 index 6ba5cad..0000000 --- a/sojorn_app/lib/widgets/glassmorphic_quips_sheet.dart +++ /dev/null @@ -1,906 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'dart:ui'; -import 'package:google_fonts/google_fonts.dart'; -import 'package:timeago/timeago.dart' as timeago; -import '../models/post.dart'; -import '../models/thread_node.dart'; -import '../providers/api_provider.dart'; -import '../theme/app_theme.dart'; -import '../widgets/media/signed_media_image.dart'; -import 'kinetic_thread_widget.dart'; - -/// Glassmorphic TikTok-style quips sheet with HUD design -class GlassmorphicQuipsSheet extends ConsumerStatefulWidget { - final String postId; - final int initialQuipCount; - final VoidCallback? onQuipPosted; - - const GlassmorphicQuipsSheet({ - super.key, - required this.postId, - this.initialQuipCount = 0, - this.onQuipPosted, - }); - - @override - ConsumerState createState() => _GlassmorphicQuipsSheetState(); -} - -class _GlassmorphicQuipsSheetState extends ConsumerState - with SingleTickerProviderStateMixin { - - late AnimationController _glassController; - late Animation _glassAnimation; - late Animation _blurAnimation; - - List _quips = []; - ThreadNode? _threadTree; - bool _isLoading = true; - String? _error; - final TextEditingController _quipController = TextEditingController(); - bool _isPostingQuip = false; - - // Expanded quip state - String? _expandedQuipId; - ThreadNode? _expandedThreadNode; - - @override - void initState() { - super.initState(); - - _glassController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 500), - ); - - _glassAnimation = Tween( - begin: 0.0, - end: 1.0, - ).animate(CurvedAnimation( - parent: _glassController, - curve: Curves.easeOutCubic, - )); - - _blurAnimation = Tween( - begin: 0.0, - end: 10.0, - ).animate(CurvedAnimation( - parent: _glassController, - curve: Curves.easeOutCubic, - )); - - _loadQuips(); - } - - @override - void dispose() { - _glassController.dispose(); - _quipController.dispose(); - super.dispose(); - } - - Future _loadQuips() async { - setState(() { - _isLoading = true; - _error = null; - }); - - try { - final api = ref.read(apiServiceProvider); - final quips = await api.getPostChain(widget.postId); - - if (mounted) { - setState(() { - _quips = quips; - _threadTree = quips.isEmpty ? null : ThreadNode.buildTree(quips); - _isLoading = false; - }); - _glassController.forward(); - } - } catch (e) { - if (mounted) { - setState(() { - _error = e.toString(); - _isLoading = false; - }); - } - } - } - - Future _postQuip() async { - if (_quipController.text.trim().isEmpty) return; - - setState(() => _isPostingQuip = true); - - try { - final api = ref.read(apiServiceProvider); - await api.publishPost( - body: _quipController.text.trim(), - chainParentId: widget.postId, - allowChain: true, - ); - - _quipController.clear(); - - // Refresh quips - await _loadQuips(); - - widget.onQuipPosted?.call(); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Quip posted!'), - backgroundColor: Colors.green, - duration: const Duration(seconds: 2), - ), - ); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Failed to post quip: $e'), - backgroundColor: Colors.red, - ), - ); - } - } finally { - if (mounted) { - setState(() => _isPostingQuip = false); - } - } - } - - void _expandQuip(ThreadNode node) { - setState(() { - _expandedQuipId = node.post.id; - _expandedThreadNode = node; - }); - } - - void _collapseQuip() { - setState(() { - _expandedQuipId = null; - _expandedThreadNode = null; - }); - } - - @override - Widget build(BuildContext context) { - return DraggableScrollableSheet( - initialChildSize: 0.6, - minChildSize: 0.4, - maxChildSize: 0.95, - snap: true, - snapSizes: const [0.6, 0.8, 0.95], - builder: (context, scrollController) { - return AnimatedBuilder( - animation: _glassController, - builder: (context, child) { - return Container( - decoration: BoxDecoration( - color: AppTheme.scaffoldBg.withValues(alpha: 0.9 + (0.1 * _glassAnimation.value)), - borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.3 * _glassAnimation.value), - blurRadius: 30, - offset: const Offset(0, -10), - ), - ], - border: Border.all( - color: Colors.white.withValues(alpha: 0.1 * _glassAnimation.value), - width: 1, - ), - ), - child: ClipRRect( - borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), - child: BackdropFilter( - filter: ImageFilter.blur( - sigmaX: _blurAnimation.value, - sigmaY: _blurAnimation.value, - ), - child: Column( - children: [ - // Glassmorphic drag handle - _buildGlassDragHandle(), - - // HUD-style header - _buildHUDHeader(), - - // Main content area - Expanded( - child: _buildMainContent(scrollController), - ), - - // Glassmorphic quip input - _buildGlassQuipInput(), - ], - ), - ), - ), - ); - }, - ); - }, - ); - } - - Widget _buildGlassDragHandle() { - return Container( - margin: const EdgeInsets.symmetric(vertical: 16), - child: Container( - width: 48, - height: 5, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(3), - boxShadow: [ - BoxShadow( - color: Colors.white.withValues(alpha: 0.2), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - ), - ); - } - - Widget _buildHUDHeader() { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: Colors.white.withValues(alpha: 0.1), - width: 1, - ), - ), - ), - child: Row( - children: [ - // HUD-style title - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: AppTheme.brightNavy.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: AppTheme.brightNavy.withValues(alpha: 0.3), - width: 1, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.chat_bubble_outline, - size: 16, - color: AppTheme.brightNavy, - ), - const SizedBox(width: 6), - Text( - 'QUIPS', - style: GoogleFonts.inter( - color: AppTheme.brightNavy, - fontSize: 12, - fontWeight: FontWeight.w700, - letterSpacing: 1.2, - ), - ), - ], - ), - ), - - const Spacer(), - - // Animated count - AnimatedContainer( - duration: const Duration(milliseconds: 300), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Colors.white.withValues(alpha: 0.2), - width: 1, - ), - ), - child: Text( - '${(_threadTree?.totalCount ?? widget.initialQuipCount)}', - style: GoogleFonts.inter( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - ), - - const SizedBox(width: 12), - - // Refresh button - GestureDetector( - onTap: _loadQuips, - child: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Icon( - Icons.refresh, - size: 18, - color: Colors.white.withValues(alpha: 0.8), - ), - ), - ), - ], - ), - ); - } - - Widget _buildMainContent(ScrollController scrollController) { - if (_isLoading) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(16), - ), - child: CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(AppTheme.brightNavy), - ), - ), - const SizedBox(height: 16), - Text( - 'Loading quips...', - style: GoogleFonts.inter( - color: Colors.white.withValues(alpha: 0.7), - fontSize: 14, - ), - ), - ], - ), - ); - } - - if (_error != null) { - return Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.red.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: Colors.red.withValues(alpha: 0.3), - width: 1, - ), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.error_outline, - size: 48, - color: Colors.red.withValues(alpha: 0.8), - ), - const SizedBox(height: 16), - Text( - 'Failed to load quips', - style: GoogleFonts.inter( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), - const SizedBox(height: 8), - Text( - _error!, - textAlign: TextAlign.center, - style: GoogleFonts.inter( - fontSize: 14, - color: Colors.white.withValues(alpha: 0.7), - ), - ), - const SizedBox(height: 16), - ElevatedButton( - onPressed: _loadQuips, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red.withValues(alpha: 0.8), - foregroundColor: Colors.white, - ), - child: Text('Try Again'), - ), - ], - ), - ), - ), - ); - } - - if (_threadTree == null) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(16), - ), - child: Icon( - Icons.chat_bubble_outline, - size: 48, - color: Colors.white.withValues(alpha: 0.5), - ), - ), - const SizedBox(height: 16), - Text( - 'No quips yet', - style: GoogleFonts.inter( - fontSize: 18, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), - const SizedBox(height: 8), - Text( - 'Be the first to drop a quip!', - style: GoogleFonts.inter( - fontSize: 14, - color: Colors.white.withValues(alpha: 0.7), - ), - ), - ], - ), - ); - } - - // If we have an expanded quip, show the kinetic thread widget - if (_expandedThreadNode != null) { - return Column( - children: [ - // Back button - Container( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), - child: Row( - children: [ - GestureDetector( - onTap: _collapseQuip, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.arrow_back, - size: 18, - color: Colors.white, - ), - const SizedBox(width: 6), - Text( - 'Back to Quips', - style: GoogleFonts.inter( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ), - ], - ), - ), - - // Kinetic thread view - Expanded( - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: Colors.white.withValues(alpha: 0.1), - width: 1, - ), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: KineticThreadWidget( - rootNode: _expandedThreadNode!, - onReplyPosted: _loadQuips, - ), - ), - ), - ), - ], - ); - } - - // Show glassmorphic quip cards - return FadeTransition( - opacity: _glassAnimation, - child: ListView.builder( - controller: scrollController, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - itemCount: _threadTree!.children.length, - itemBuilder: (context, index) { - final child = _threadTree!.children[index]; - return _buildGlassQuipCard(child, index); - }, - ), - ); - } - - Widget _buildGlassQuipCard(ThreadNode node, int index) { - final isExpanded = _expandedQuipId == node.post.id; - - return AnimatedContainer( - duration: const Duration(milliseconds: 300), - margin: const EdgeInsets.only(bottom: 12), - transform: Matrix4.translationValues(0, 0, 0), - child: GestureDetector( - onTap: () => _expandQuip(node), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: Colors.white.withValues(alpha: 0.15), - width: 1, - ), - boxShadow: [ - BoxShadow( - color: Colors.white.withValues(alpha: 0.05), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Author row - Row( - children: [ - // Glassmorphic avatar - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - AppTheme.brightNavy.withValues(alpha: 0.3), - AppTheme.egyptianBlue.withValues(alpha: 0.3), - ], - ), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: Colors.white.withValues(alpha: 0.2), - width: 1, - ), - ), - child: Center( - child: Text( - 'L${node.depth}', - style: GoogleFonts.inter( - color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.w700, - ), - ), - ), - ), - const SizedBox(width: 12), - - // Author info - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - node.post.author?.displayName ?? 'Anonymous', - style: GoogleFonts.inter( - color: Colors.white, - fontSize: 15, - fontWeight: FontWeight.w600, - ), - ), - Text( - timeago.format(node.post.createdAt), - style: GoogleFonts.inter( - color: Colors.white.withValues(alpha: 0.6), - fontSize: 12, - ), - ), - ], - ), - ), - - // Reply count indicator - if (node.hasChildren) - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: AppTheme.egyptianBlue.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: AppTheme.egyptianBlue.withValues(alpha: 0.4), - width: 1, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.subdirectory_arrow_right, - size: 12, - color: AppTheme.egyptianBlue, - ), - const SizedBox(width: 4), - Text( - '${node.totalDescendants}', - style: GoogleFonts.inter( - color: AppTheme.egyptianBlue, - fontSize: 11, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ], - ), - - const SizedBox(height: 12), - - // Quip content - Text( - node.post.body, - style: GoogleFonts.inter( - color: Colors.white.withValues(alpha: 0.9), - fontSize: 15, - height: 1.4, - ), - maxLines: isExpanded ? null : 3, - overflow: isExpanded ? TextOverflow.visible : TextOverflow.ellipsis, - ), - - // Media if present - if (node.post.imageUrl != null) ...[ - const SizedBox(height: 12), - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: SignedMediaImage( - url: node.post.imageUrl!, - width: double.infinity, - height: 150, - fit: BoxFit.cover, - ), - ), - ], - - if (isExpanded && node.hasChildren) ...[ - const SizedBox(height: 16), - - // Nested replies preview - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Colors.white.withValues(alpha: 0.1), - width: 1, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Thread Replies', - style: GoogleFonts.inter( - color: Colors.white.withValues(alpha: 0.7), - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - ...node.children.take(3).map((child) => - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Row( - children: [ - Container( - width: 20, - height: 20, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(4), - ), - child: Center( - child: Text( - 'L${child.depth}', - style: GoogleFonts.inter( - color: Colors.white.withValues(alpha: 0.7), - fontSize: 8, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - child.post.body, - style: GoogleFonts.inter( - color: Colors.white.withValues(alpha: 0.6), - fontSize: 12, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - ).toList(), - - if (node.children.length > 3) - Padding( - padding: const EdgeInsets.only(top: 4), - child: Text( - '+${node.children.length - 3} more replies', - style: GoogleFonts.inter( - color: AppTheme.egyptianBlue, - fontSize: 11, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - ), - ], - - const SizedBox(height: 8), - - // Action buttons - Row( - children: [ - GestureDetector( - onTap: () => _expandQuip(node), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - isExpanded ? Icons.expand_less : Icons.expand_more, - size: 16, - color: Colors.white.withValues(alpha: 0.8), - ), - const SizedBox(width: 4), - Text( - isExpanded ? 'Collapse' : 'Expand Thread', - style: GoogleFonts.inter( - color: Colors.white.withValues(alpha: 0.8), - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ), - ], - ), - ], - ), - ), - ), - ); - } - - Widget _buildGlassQuipInput() { - return Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.05), - border: Border( - top: BorderSide( - color: Colors.white.withValues(alpha: 0.1), - width: 1, - ), - ), - ), - child: SafeArea( - child: Row( - children: [ - Expanded( - child: Container( - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(25), - border: Border.all( - color: Colors.white.withValues(alpha: 0.2), - width: 1, - ), - ), - child: TextField( - controller: _quipController, - maxLines: 2, - minLines: 1, - style: GoogleFonts.inter( - color: Colors.white, - fontSize: 14, - ), - decoration: InputDecoration( - hintText: 'Drop a quip...', - hintStyle: GoogleFonts.inter( - color: Colors.white.withValues(alpha: 0.5), - fontSize: 14, - ), - border: InputBorder.none, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - ), - ), - ), - ), - const SizedBox(width: 12), - AnimatedContainer( - duration: const Duration(milliseconds: 200), - child: FloatingActionButton( - onPressed: (_quipController.text.trim().isNotEmpty && !_isPostingQuip) - ? _postQuip - : null, - backgroundColor: _quipController.text.isNotEmpty - ? AppTheme.brightNavy.withValues(alpha: 0.9) - : Colors.white.withValues(alpha: 0.2), - mini: true, - child: _isPostingQuip - ? SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), - ), - ) - : Icon( - Icons.send, - size: 16, - color: Colors.white, - ), - ), - ), - ], - ), - ), - ); - } -} diff --git a/sojorn_app/lib/widgets/kinetic_thread_widget.dart b/sojorn_app/lib/widgets/kinetic_thread_widget.dart deleted file mode 100644 index a65f19e..0000000 --- a/sojorn_app/lib/widgets/kinetic_thread_widget.dart +++ /dev/null @@ -1,1530 +0,0 @@ - -import 'dart:ui'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_animate/flutter_animate.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:google_fonts/google_fonts.dart'; -import 'package:timeago/timeago.dart' as timeago; -import '../models/post.dart'; -import '../models/thread_node.dart'; -import '../providers/api_provider.dart'; -import '../theme/app_theme.dart'; -import '../widgets/media/signed_media_image.dart'; -import '../widgets/reactions/reactions_display.dart'; - -/// Kinetic Spatial Engine widget for layer-based thread navigation -class KineticThreadWidget extends ConsumerStatefulWidget { - final ThreadNode rootNode; - final Function(ThreadNode)? onLayerChanged; - final Function()? onReplyPosted; - final VoidCallback? onRefreshRequested; - final bool isLoading; - - const KineticThreadWidget({ - super.key, - required this.rootNode, - this.onLayerChanged, - this.onReplyPosted, - this.onRefreshRequested, - this.isLoading = false, - }); - - @override - ConsumerState createState() => _KineticThreadWidgetState(); -} - -class _KineticThreadWidgetState extends ConsumerState - with TickerProviderStateMixin { - ThreadNode? _currentFocusNode; - List _layerStack = []; - int _currentLayerIndex = 0; - - final FocusNode _focusNode = FocusNode(); - final FocusNode _replyFocusNode = FocusNode(); - - late AnimationController _impactController; - late AnimationController _replyRevealController; - late AnimationController _leapController; - - late Animation _replyRevealAnimation; - late Animation _leapSlideAnimation; - - late final PageController _layerPageController; - - bool _showInlineReply = false; - final TextEditingController _replyController = TextEditingController(); - bool _isPostingReply = false; - - final Set _collapsedRails = {}; - String? _expandedSatelliteId; - String? _hoveredSatelliteId; - bool _useVerticalImpact = false; - double _scrubIntensity = 0.0; - - @override - void initState() { - super.initState(); - _currentFocusNode = widget.rootNode; - _layerStack = [widget.rootNode]; - _layerPageController = PageController(initialPage: 0); - - _initializeAnimations(); - _replyController.addListener(_onReplyChanged); - - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _focusNode.requestFocus(); - } - }); - } - void _initializeAnimations() { - _impactController = AnimationController( - duration: const Duration(milliseconds: 520), - vsync: this, - ); - - _replyRevealController = AnimationController( - duration: const Duration(milliseconds: 240), - vsync: this, - ); - - _leapController = AnimationController( - duration: const Duration(milliseconds: 360), - vsync: this, - ); - - _replyRevealAnimation = CurvedAnimation( - parent: _replyRevealController, - curve: Curves.easeOutCubic, - reverseCurve: Curves.easeInCubic, - ); - - _leapSlideAnimation = Tween( - begin: const Offset(0, 0.25), - end: Offset.zero, - ).animate(CurvedAnimation( - parent: _leapController, - curve: Curves.easeOutBack, - )); - } - - @override - void dispose() { - _impactController.dispose(); - _replyRevealController.dispose(); - _leapController.dispose(); - _replyController.removeListener(_onReplyChanged); - _replyController.dispose(); - _focusNode.dispose(); - _replyFocusNode.dispose(); - _layerPageController.dispose(); - super.dispose(); - } - - bool get _supportsHover { - if (kIsWeb) return true; - switch (defaultTargetPlatform) { - case TargetPlatform.macOS: - case TargetPlatform.windows: - case TargetPlatform.linux: - return true; - case TargetPlatform.android: - case TargetPlatform.iOS: - case TargetPlatform.fuchsia: - return false; - } - } - - void _onReplyChanged() { - if (mounted) { - setState(() {}); - } - } - - void _triggerLayerTransition() { - HapticFeedback.heavyImpact(); - } - void _drillDownToLayer(ThreadNode targetNode) { - if (_currentFocusNode?.post.id == targetNode.post.id) return; - - setState(() { - _currentFocusNode = targetNode; - _layerStack.add(targetNode); - _currentLayerIndex = _layerStack.length - 1; - _showInlineReply = false; - _expandedSatelliteId = null; - _replyRevealController.reverse(); - _useVerticalImpact = false; - }); - - _triggerLayerTransition(); - widget.onLayerChanged?.call(targetNode); - - _layerPageController.animateToPage( - _currentLayerIndex, - duration: const Duration(milliseconds: 420), - curve: Curves.easeOutCubic, - ); - } - - void _scrubToLayer(int layerIndex, {bool animate = true}) { - if (layerIndex < 0 || layerIndex >= _layerStack.length) return; - - setState(() { - _currentFocusNode = _layerStack[layerIndex]; - _layerStack = _layerStack.sublist(0, layerIndex + 1); - _currentLayerIndex = layerIndex; - _showInlineReply = false; - _expandedSatelliteId = null; - _replyRevealController.reverse(); - }); - - _triggerLayerTransition(); - widget.onLayerChanged?.call(_currentFocusNode!); - - if (animate) { - _layerPageController.animateToPage( - _currentLayerIndex, - duration: const Duration(milliseconds: 300), - curve: Curves.easeOutCubic, - ); - } else { - _layerPageController.jumpToPage(_currentLayerIndex); - } - } - - void _toggleRailCollapse(ThreadNode node) { - setState(() { - if (_collapsedRails.contains(node.post.id)) { - _collapsedRails.remove(node.post.id); - } else { - _collapsedRails.add(node.post.id); - _impactController.forward().then((_) { - _impactController.reset(); - }); - } - }); - - HapticFeedback.heavyImpact(); - } - - bool _isRailCollapsed(ThreadNode node) { - return _collapsedRails.contains(node.post.id); - } - - void _warpToNextSibling() { - final current = _currentFocusNode; - final parent = current?.parent; - if (current == null || parent == null) return; - - final currentIndex = parent.children.indexWhere((child) => child.post.id == current.post.id); - if (currentIndex == -1) return; - - final nextIndex = currentIndex + 1; - if (nextIndex >= parent.children.length) return; - - final nextSibling = parent.children[nextIndex]; - - setState(() { - _currentFocusNode = nextSibling; - _layerStack[_layerStack.length - 1] = nextSibling; - _currentLayerIndex = _layerStack.length - 1; - _expandedSatelliteId = null; - _useVerticalImpact = true; - }); - - _leapController.forward(from: 0); - _impactController.forward().then((_) => _impactController.reset()); - _triggerLayerTransition(); - widget.onLayerChanged?.call(nextSibling); - - Future.delayed(const Duration(milliseconds: 420), () { - if (mounted) { - setState(() => _useVerticalImpact = false); - } - }); - } - - @override - void didUpdateWidget(covariant KineticThreadWidget oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.rootNode != widget.rootNode) { - _currentFocusNode = widget.rootNode; - _layerStack = [widget.rootNode]; - _currentLayerIndex = 0; - _expandedSatelliteId = null; - _layerPageController.jumpToPage(0); - } - } - - void _toggleInlineReply() { - setState(() { - _showInlineReply = !_showInlineReply; - if (_showInlineReply) { - _replyRevealController.forward(); - _replyController.clear(); - _replyFocusNode.requestFocus(); - } else { - _replyRevealController.reverse(); - _replyFocusNode.unfocus(); - } - }); - } - Future _submitInlineReply() async { - if (_replyController.text.trim().isEmpty || _currentFocusNode == null) return; - - setState(() => _isPostingReply = true); - - try { - final api = ref.read(apiServiceProvider); - final replyPost = await api.publishPost( - body: _replyController.text.trim(), - chainParentId: _currentFocusNode!.post.id, - allowChain: true, - ); - - _insertReplyPost(replyPost); - - _replyController.clear(); - setState(() { - _showInlineReply = false; - _isPostingReply = false; - }); - - _replyRevealController.reverse(); - _impactController.forward().then((_) { - _impactController.reset(); - }); - - widget.onReplyPosted?.call(); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Reply posted!'), - backgroundColor: Colors.green, - duration: Duration(seconds: 2), - ), - ); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Failed to post reply: $e'), - backgroundColor: Colors.red, - ), - ); - } - setState(() => _isPostingReply = false); - } - } - - void _insertReplyPost(Post replyPost) { - final focusNode = _currentFocusNode; - if (focusNode == null) return; - - final replyNode = ThreadNode( - post: replyPost, - children: [], - depth: focusNode.depth + 1, - parent: focusNode, - ); - - setState(() { - focusNode.children.add(replyNode); - focusNode.children.sort((a, b) => a.post.createdAt.compareTo(b.post.createdAt)); - }); - - _impactController.forward().then((_) { - _impactController.reset(); - }); - } - - @override - Widget build(BuildContext context) { - return Focus( - focusNode: _focusNode, - child: Stack( - children: [ - PageView.builder( - controller: _layerPageController, - itemCount: _layerStack.length, - onPageChanged: (index) { - if (index != _currentLayerIndex) { - _scrubToLayer(index, animate: false); - } - }, - itemBuilder: (context, index) { - return _buildLayerPage(_layerStack[index], index); - }, - ), - Positioned( - right: 20, - bottom: 24, - child: _buildLeapButton(), - ), - Positioned.fill( - child: IgnorePointer( - child: AnimatedOpacity( - opacity: _scrubIntensity, - duration: const Duration(milliseconds: 160), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8), - child: Container( - color: AppTheme.scaffoldBg.withValues(alpha: 0.05), - ), - ), - ), - ), - ), - ], - ), - ); - } - - Widget _buildLayerPage(ThreadNode focusNode, int index) { - final hasChildren = focusNode.hasChildren; - - return CustomScrollView( - physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()), - slivers: [ - SliverPersistentHeader( - pinned: true, - delegate: _KineticScrubberHeader( - layerStack: _layerStack, - currentIndex: _currentLayerIndex, - totalCount: _layerStack.isNotEmpty ? _layerStack.first.totalCount : null, - onRefreshRequested: widget.onRefreshRequested, - onScrubStart: _handleScrubStart, - onScrubEnd: _handleScrubEnd, - onScrubIndex: (value) => _scrubToLayer(value, animate: true), - isLoading: widget.isLoading, - ), - ), - SliverToBoxAdapter( - child: Column( - children: [ - if (focusNode.parent != null) _buildPreviousChainJump(focusNode.parent!), - _buildFocusPostAnimated(focusNode), - ], - ), - ), - if (hasChildren) - _buildSatelliteListSliver(focusNode.children) - else - SliverFillRemaining( - hasScrollBody: false, - child: _buildEmptyDiscoveryState(focusNode), - ), - ], - ); - } - void _handleScrubStart() { - if (!mounted) return; - setState(() => _scrubIntensity = 1.0); - } - - void _handleScrubEnd() { - if (!mounted) return; - setState(() => _scrubIntensity = 0.0); - } - - Widget _buildLeapButton() { - return GestureDetector( - onTap: _warpToNextSibling, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), - decoration: BoxDecoration( - color: AppTheme.brightNavy, - borderRadius: BorderRadius.circular(24), - boxShadow: [ - BoxShadow( - color: AppTheme.brightNavy.withValues(alpha: 0.4), - blurRadius: 18, - offset: const Offset(0, 8), - ), - ], - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.keyboard_double_arrow_down, color: Colors.white, size: 18), - const SizedBox(width: 8), - Text( - 'Leap', - style: GoogleFonts.inter( - color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.w700, - letterSpacing: 0.6, - ), - ), - ], - ), - ), - ).animate().fadeIn(duration: 240.ms).scale(begin: const Offset(0.9, 0.9)); - } - - Widget _buildFocusPostAnimated(ThreadNode node) { - return AnimatedSwitcher( - duration: const Duration(milliseconds: 360), - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, - transitionBuilder: (child, animation) { - final slideTween = _useVerticalImpact - ? Tween(begin: const Offset(0, 0.22), end: Offset.zero) - : Tween(begin: const Offset(0.15, 0), end: Offset.zero); - return SlideTransition( - position: slideTween.animate(animation), - child: FadeTransition( - opacity: animation, - child: child, - ), - ); - }, - child: KeyedSubtree( - key: ValueKey('sun_${node.post.id}'), - child: SlideTransition( - position: _leapSlideAnimation, - child: _buildFocusPost(node), - ), - ), - ); - } - - Widget _buildPreviousChainJump(ThreadNode parentNode) { - return Container( - margin: const EdgeInsets.fromLTRB(16, 4, 16, 0), - child: GestureDetector( - onTap: () { - if (_layerStack.length > 1) { - _scrubToLayer(_layerStack.length - 2); - } - }, - child: Container( - width: double.infinity, - decoration: BoxDecoration( - // Rounded top corners, flat bottom - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(20), - topRight: Radius.circular(20), - ), - // Temporary debug background color - color: Colors.red.withValues(alpha: 0.1), - // More prominent gradient from darker top to lighter bottom - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - AppTheme.navyBlue.withValues(alpha: 0.25), // Darker at top - AppTheme.navyBlue.withValues(alpha: 0.12), // Lighter at bottom - Colors.transparent, // Fade to transparent - ], - stops: const [0.0, 0.6, 1.0], - ), - // More prominent border at the top - border: Border( - top: BorderSide( - color: AppTheme.brightNavy.withValues(alpha: 0.5), - width: 2, - ), - ), - ), - child: Container( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), - child: Row( - children: [ - _buildMiniAvatar(parentNode), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Previous chain', - style: GoogleFonts.inter( - color: AppTheme.textSecondary.withValues(alpha: 0.9), - fontSize: 10, - fontWeight: FontWeight.w700, - letterSpacing: 0.6, - ), - ), - const SizedBox(height: 4), - Text( - parentNode.post.author?.displayName ?? 'Anonymous', - style: GoogleFonts.inter( - color: AppTheme.navyBlue.withValues(alpha: 0.95), - fontSize: 13, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 3), - Text( - parentNode.post.body, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: GoogleFonts.inter( - color: AppTheme.navyText.withValues(alpha: 0.8), - fontSize: 12, - height: 1.3, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: AppTheme.brightNavy.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(10), - ), - child: Icon( - Icons.arrow_upward, - size: 18, - color: AppTheme.brightNavy, - ), - ), - ], - ), - ), - ), - ), - ).animate().fadeIn(duration: 220.ms).slideY(begin: -0.08, end: 0); - } - Widget _buildMiniAvatar(ThreadNode node) { - final avatarUrl = node.post.author?.avatarUrl; - return Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: AppTheme.brightNavy.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(10), - ), - child: avatarUrl == null - ? Center( - child: Text( - _initialForName(node.post.author?.displayName), - style: GoogleFonts.inter( - color: AppTheme.brightNavy, - fontSize: 12, - fontWeight: FontWeight.w700, - ), - ), - ) - : ClipRRect( - borderRadius: BorderRadius.circular(10), - child: SignedMediaImage( - url: avatarUrl, - width: 32, - height: 32, - fit: BoxFit.cover, - ), - ), - ); - } - - Widget _buildEmptyDiscoveryState(ThreadNode focusNode) { - final canChain = focusNode.post.allowChain; - return Center( - child: Text( - canChain ? 'Be the first to reply' : 'Replies are disabled', - style: GoogleFonts.inter( - color: AppTheme.textSecondary, - fontSize: 14, - ), - ), - ); - } - - Widget _buildFocusPost(ThreadNode node) { - final isLoading = widget.isLoading; - return Hero( - tag: 'thread_post_${node.post.id}', - child: Container( - margin: const EdgeInsets.fromLTRB(16, 0, 16, 10), - decoration: BoxDecoration( - color: AppTheme.cardSurface, - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: AppTheme.brightNavy, - width: 2.5, - ), - boxShadow: [ - BoxShadow( - color: AppTheme.brightNavy.withValues(alpha: 0.18), - blurRadius: 24, - offset: const Offset(0, 8), - ), - ], - ), - child: Stack( - children: [ - Opacity( - opacity: isLoading ? 0.6 : 1, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Row( - children: [ - _buildAuthorAvatar(node), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - node.post.author?.displayName ?? 'Anonymous', - style: GoogleFonts.inter( - color: AppTheme.textPrimary, - fontSize: 16, - fontWeight: FontWeight.w700, - ), - ), - Text( - timeago.format(node.post.createdAt), - style: GoogleFonts.inter( - color: AppTheme.textSecondary, - fontSize: 12, - ), - ), - ], - ), - ), - if (node.hasChildren) - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: AppTheme.egyptianBlue.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.chat_bubble_outline, - size: 16, - color: AppTheme.egyptianBlue, - ), - const SizedBox(width: 4), - Text( - '${node.totalDescendants}', - style: GoogleFonts.inter( - color: AppTheme.egyptianBlue, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 12), - child: isLoading - ? _buildSkeletonBlock(lines: 4) - : Text( - node.post.body, - style: GoogleFonts.inter( - fontSize: 20, - color: AppTheme.navyText, - height: 1.7, - fontWeight: FontWeight.w500, - ), - ), - ), - if (!isLoading && node.post.imageUrl != null) ...[ - const SizedBox(height: 14), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: ClipRRect( - borderRadius: BorderRadius.circular(14), - child: SignedMediaImage( - url: node.post.imageUrl!, - width: double.infinity, - height: 220, - fit: BoxFit.cover, - ), - ), - ), - ], - Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), - child: Row( - children: [ - Expanded( - child: ElevatedButton.icon( - onPressed: node.post.allowChain ? _toggleInlineReply : null, - icon: const Icon(Icons.reply, size: 18), - label: Text(_showInlineReply ? 'Close' : 'Reply'), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.brightNavy, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - ), - ), - ], - ), - ), - _buildInlineReplyComposer(), - ], - ), - ), - if (isLoading) - Positioned.fill( - child: IgnorePointer( - child: AnimatedOpacity( - opacity: 0.6, - duration: const Duration(milliseconds: 300), - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - Colors.white.withValues(alpha: 0.0), - Colors.white.withValues(alpha: 0.35), - Colors.white.withValues(alpha: 0.0), - ], - stops: const [0.2, 0.5, 0.8], - ), - ), - ), - ), - ), - ), - ], - ), - ), - ).animate().fadeIn(duration: 180.ms).scale(begin: const Offset(0.98, 0.98), end: const Offset(1, 1)); - } - Widget _buildSkeletonBlock({int lines = 3}) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: List.generate(lines, (index) { - final widthFactor = 1 - (index * 0.12).clamp(0.0, 0.35); - return Container( - height: 12, - margin: const EdgeInsets.only(bottom: 10), - width: MediaQuery.of(context).size.width * widthFactor, - decoration: BoxDecoration( - color: AppTheme.navyBlue.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(8), - ), - ); - }), - ); - } - - Widget _buildAuthorAvatar(ThreadNode node) { - final avatarUrl = node.post.author?.avatarUrl; - return Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: AppTheme.brightNavy.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - ), - child: avatarUrl == null - ? Center( - child: Text( - _initialForName(node.post.author?.displayName), - style: GoogleFonts.inter( - color: AppTheme.brightNavy, - fontSize: 12, - fontWeight: FontWeight.w700, - ), - ), - ) - : ClipRRect( - borderRadius: BorderRadius.circular(12), - child: SignedMediaImage( - url: avatarUrl, - width: 36, - height: 36, - fit: BoxFit.cover, - ), - ), - ); - } - - String _initialForName(String? name) { - final trimmed = name?.trim() ?? ''; - if (trimmed.isEmpty) return 'S'; - return trimmed.characters.first.toUpperCase(); - } - - List _rankChildren(List children) { - final ranked = List.from(children); - ranked.sort((a, b) { - final engagementCompare = b.totalDescendants.compareTo(a.totalDescendants); - if (engagementCompare != 0) return engagementCompare; - return a.post.createdAt.compareTo(b.post.createdAt); - }); - return ranked; - } - - SliverPadding _buildSatelliteListSliver(List children) { - final rankedChildren = _rankChildren(children); - - return SliverPadding( - padding: const EdgeInsets.fromLTRB(16, 6, 16, 24), - sliver: SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - final child = rankedChildren[index]; - return _buildChildRailItem(child, index); - }, - childCount: rankedChildren.length, - ), - ), - ); - } - void _handleSatelliteTap(ThreadNode child) { - if (_expandedSatelliteId != child.post.id) { - setState(() => _expandedSatelliteId = child.post.id); - return; - } - _drillDownToLayer(child); - } - - Widget _buildChildRailItem(ThreadNode child, int index) { - final isCollapsed = _isRailCollapsed(child); - final engagementScore = child.totalDescendants; - final isExpanded = _expandedSatelliteId == child.post.id; - final showPeek = _supportsHover && child.hasChildren && _hoveredSatelliteId == child.post.id; - - final card = AnimatedContainer( - duration: const Duration(milliseconds: 300), - curve: Curves.easeOutBack, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppTheme.cardSurface, - borderRadius: BorderRadius.circular(18), - border: Border.all( - color: isExpanded - ? AppTheme.brightNavy.withValues(alpha: 0.6) - : AppTheme.navyBlue.withValues(alpha: 0.12), - width: isExpanded ? 1.6 : 1.2, - ), - boxShadow: [ - BoxShadow( - color: AppTheme.navyBlue.withValues(alpha: isExpanded ? 0.14 : 0.06), - blurRadius: isExpanded ? 20 : 14, - offset: const Offset(0, 6), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (engagementScore > 0) - Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: _getEngagementColor(engagementScore).withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.trending_up, - size: 12, - color: _getEngagementColor(engagementScore), - ), - const SizedBox(width: 4), - Text( - '$engagementScore ${engagementScore == 1 ? 'reply' : 'replies'}', - style: GoogleFonts.inter( - color: _getEngagementColor(engagementScore), - fontSize: 10, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - Text( - child.post.body, - style: GoogleFonts.inter( - color: AppTheme.navyText, - fontSize: 14, - height: 1.4, - fontWeight: FontWeight.w500, - ), - maxLines: isExpanded ? 8 : 3, - overflow: TextOverflow.ellipsis, - ), - if (isExpanded && child.post.imageUrl != null) ...[ - const SizedBox(height: 12), - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: SignedMediaImage( - url: child.post.imageUrl!, - width: double.infinity, - height: 160, - fit: BoxFit.cover, - ), - ), - ], - if (isExpanded && child.hasChildren) ...[ - const SizedBox(height: 12), - _buildInlineReplyPreview(child), - ], - const SizedBox(height: 14), - Row( - children: [ - _buildRail(child), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - child.post.author?.displayName ?? 'Anonymous', - style: GoogleFonts.inter( - color: AppTheme.textPrimary, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - overflow: TextOverflow.ellipsis, - ), - Text( - timeago.format(child.post.createdAt), - style: GoogleFonts.inter( - color: AppTheme.textSecondary, - fontSize: 10, - ), - ), - if (child.post.reactions != null && child.post.reactions!.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 4), - child: ReactionsDisplay( - reactionCounts: child.post.reactions!, - myReactions: child.post.myReactions?.toSet() ?? {}, - mode: ReactionsDisplayMode.compact, - padding: EdgeInsets.zero, - ), - ), - ], - ), - ), - AnimatedScale( - duration: const Duration(milliseconds: 220), - scale: isExpanded ? 1.05 : 1, - child: Icon( - isExpanded ? Icons.north : Icons.expand_more, - color: AppTheme.brightNavy, - size: 18, - ), - ), - ], - ), - ], - ), - ); - - final cardBody = GestureDetector( - onTap: () => _handleSatelliteTap(child), - child: card, - ); - - final cardStack = Stack( - clipBehavior: Clip.none, - children: [ - cardBody, - if (showPeek) _buildPeekFlyout(child), - ], - ); - - final animatedCard = AnimatedSize( - duration: const Duration(milliseconds: 260), - curve: Curves.easeOutBack, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 200), - opacity: isCollapsed ? 0.0 : 1.0, - child: isCollapsed ? const SizedBox.shrink() : cardStack, - ), - ); - - final content = Hero( - tag: 'thread_post_${child.post.id}', - child: Material( - color: Colors.transparent, - child: animatedCard, - ), - ); - - return MouseRegion( - onEnter: _supportsHover ? (_) => setState(() => _hoveredSatelliteId = child.post.id) : null, - onExit: _supportsHover ? (_) => setState(() => _hoveredSatelliteId = null) : null, - child: Container( - margin: const EdgeInsets.only(bottom: 14), - child: content, - ) - .animate(delay: (40 * index).ms) - .fadeIn(duration: 200.ms) - .slideX(begin: 0.08, end: 0, curve: Curves.easeOutCubic), - ); - } - Widget _buildInlineReplyPreview(ThreadNode child) { - final previewReplies = child.children.take(2).toList(); - if (previewReplies.isEmpty) return const SizedBox.shrink(); - - return Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: AppTheme.navyBlue.withValues(alpha: 0.04), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.12)), - ), - child: Column( - children: previewReplies - .map( - (reply) => Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 6, - height: 6, - margin: const EdgeInsets.only(top: 6), - decoration: BoxDecoration( - color: AppTheme.brightNavy, - borderRadius: BorderRadius.circular(4), - ), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - reply.post.body, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: GoogleFonts.inter( - fontSize: 12, - color: AppTheme.navyText.withValues(alpha: 0.7), - height: 1.4, - ), - ), - ), - ], - ), - ), - ) - .toList(), - ), - ); - } - - Widget _buildPeekFlyout(ThreadNode child) { - final previewReplies = child.children.take(3).toList(); - if (previewReplies.isEmpty) return const SizedBox.shrink(); - - return Positioned( - top: 20, - right: -220, - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), - child: Container( - width: 200, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.7), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: AppTheme.brightNavy.withValues(alpha: 0.2), - ), - boxShadow: [ - BoxShadow( - color: AppTheme.navyBlue.withValues(alpha: 0.12), - blurRadius: 16, - offset: const Offset(0, 8), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Peek replies', - style: GoogleFonts.inter( - fontSize: 11, - fontWeight: FontWeight.w600, - color: AppTheme.brightNavy, - letterSpacing: 0.3, - ), - ), - const SizedBox(height: 8), - ...previewReplies.map((reply) { - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text( - reply.post.body, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: GoogleFonts.inter( - fontSize: 11, - color: AppTheme.navyText.withValues(alpha: 0.8), - height: 1.4, - ), - ), - ); - }).toList(), - ], - ), - ), - ), - ), - ).animate().fadeIn(duration: 180.ms).slideX(begin: 0.05, end: 0); - } - - Color _getEngagementColor(int engagementScore) { - if (engagementScore >= 10) return Colors.red; - if (engagementScore >= 5) return Colors.orange; - if (engagementScore >= 2) return AppTheme.egyptianBlue; - return AppTheme.brightNavy; - } - - Widget _buildRail(ThreadNode child) { - final isCollapsed = _isRailCollapsed(child); - return GestureDetector( - onTap: () => _toggleRailCollapse(child), - child: Container( - width: 10, - height: 48, - decoration: BoxDecoration( - color: isCollapsed - ? Colors.red.withValues(alpha: 0.55) - : AppTheme.brightNavy.withValues(alpha: 0.35), - borderRadius: BorderRadius.circular(6), - ), - ) - .animate(target: isCollapsed ? 1 : 0) - .shake(duration: 240.ms, hz: 16) - .fade(begin: 1.0, end: 0.7), - ); - } - Widget _buildInlineReplyComposer() { - final bottomInset = MediaQuery.of(context).viewInsets.bottom; - return SizeTransition( - sizeFactor: _replyRevealAnimation, - child: FadeTransition( - opacity: _replyRevealAnimation, - child: AnimatedPadding( - duration: const Duration(milliseconds: 180), - curve: Curves.easeOut, - padding: EdgeInsets.only(bottom: bottomInset > 0 ? bottomInset + 16 : 16), - child: Container( - margin: const EdgeInsets.fromLTRB(16, 8, 16, 0), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppTheme.navyBlue.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: AppTheme.navyBlue.withValues(alpha: 0.3), - width: 2, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.reply, size: 20, color: AppTheme.navyBlue), - const SizedBox(width: 8), - Expanded( - child: Text( - 'Replying to ${_currentFocusNode?.post.author?.displayName ?? 'Anonymous'}', - style: GoogleFonts.inter( - fontSize: 14, - color: AppTheme.navyBlue, - fontWeight: FontWeight.w600, - ), - ), - ), - GestureDetector( - onTap: _toggleInlineReply, - child: Icon(Icons.close, size: 20, color: AppTheme.navyBlue), - ), - ], - ), - const SizedBox(height: 12), - TextField( - focusNode: _replyFocusNode, - controller: _replyController, - maxLines: 3, - minLines: 1, - textInputAction: TextInputAction.send, - onSubmitted: (_) { - if (_replyController.text.trim().isNotEmpty && !_isPostingReply) { - _submitInlineReply(); - } - }, - style: GoogleFonts.inter( - fontSize: 14, - color: AppTheme.navyText, - ), - decoration: InputDecoration( - hintText: 'Write your reply... ', - hintStyle: GoogleFonts.inter( - fontSize: 14, - color: AppTheme.textSecondary, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - filled: true, - fillColor: AppTheme.cardSurface, - contentPadding: const EdgeInsets.all(12), - ), - ), - const SizedBox(height: 12), - Row( - children: [ - Text( - '${_replyController.text.length}/500', - style: GoogleFonts.inter( - fontSize: 12, - color: AppTheme.textSecondary, - ), - ), - const Spacer(), - ElevatedButton( - onPressed: (_replyController.text.trim().isNotEmpty && !_isPostingReply) - ? _submitInlineReply - : null, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.brightNavy, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - ), - child: _isPostingReply - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), - ), - ) - : Text( - 'Reply', - style: GoogleFonts.inter( - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - ], - ), - ), - ), - ), - ); - } -} - -class _KineticScrubberHeader extends SliverPersistentHeaderDelegate { - final List layerStack; - final int currentIndex; - final int? totalCount; - final VoidCallback? onRefreshRequested; - final VoidCallback onScrubStart; - final VoidCallback onScrubEnd; - final ValueChanged onScrubIndex; - final bool isLoading; - - _KineticScrubberHeader({ - required this.layerStack, - required this.currentIndex, - required this.totalCount, - required this.onRefreshRequested, - required this.onScrubStart, - required this.onScrubEnd, - required this.onScrubIndex, - required this.isLoading, - }); - - @override - double get maxExtent => 132; - - @override - double get minExtent => 110; - - @override - Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { - final showCount = totalCount != null && totalCount! > 0; - - return Container( - padding: const EdgeInsets.fromLTRB(16, 10, 16, 12), - decoration: BoxDecoration( - color: AppTheme.scaffoldBg.withValues(alpha: overlapsContent ? 0.96 : 0.9), - border: Border( - bottom: BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.06)), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Thread', - style: GoogleFonts.literata( - fontWeight: FontWeight.w600, - color: AppTheme.navyBlue, - fontSize: 18, - ), - ), - if (showCount) - Text( - '${totalCount!} ${totalCount == 1 ? 'comment' : 'comments'}', - style: GoogleFonts.inter( - color: AppTheme.textDisabled, - fontSize: 12, - ), - ), - ], - ), - const Spacer(), - IconButton( - onPressed: isLoading ? null : onRefreshRequested, - icon: Icon(Icons.refresh, color: AppTheme.navyBlue), - tooltip: 'Refresh thread', - ), - ], - ), - const SizedBox(height: 10), - Expanded( - child: LayoutBuilder( - builder: (context, constraints) { - final width = constraints.maxWidth; - final count = layerStack.isEmpty ? 1 : layerStack.length; - - return GestureDetector( - behavior: HitTestBehavior.translucent, - onHorizontalDragStart: (_) => onScrubStart(), - onHorizontalDragEnd: (_) => onScrubEnd(), - onHorizontalDragCancel: onScrubEnd, - onHorizontalDragUpdate: (details) { - final index = _scrubIndexFromOffset(details.localPosition.dx, width, count); - onScrubIndex(index); - }, - onTapDown: (details) { - final index = _scrubIndexFromOffset(details.localPosition.dx, width, count); - onScrubIndex(index); - }, - child: Row( - children: List.generate(count, (index) { - final node = layerStack[index]; - final isActive = index == currentIndex; - return Expanded( - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - curve: Curves.easeOutCubic, - height: 44, - decoration: BoxDecoration( - color: isActive - ? AppTheme.brightNavy.withValues(alpha: 0.12) - : AppTheme.navyBlue.withValues(alpha: 0.04), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: isActive - ? AppTheme.brightNavy.withValues(alpha: 0.5) - : AppTheme.navyBlue.withValues(alpha: 0.08), - ), - ), - margin: EdgeInsets.only(right: index == count - 1 ? 0 : 8), - child: Center( - child: _buildScrubberAvatar(node, isActive), - ), - ), - ); - }), - ), - ); - }, - ), - ), - ], - ), - ); - } - - int _scrubIndexFromOffset(double dx, double width, int count) { - if (count <= 1 || width <= 0) return 0; - final slot = width / count; - return (dx / slot).floor().clamp(0, count - 1); - } - - Widget _buildScrubberAvatar(ThreadNode node, bool isActive) { - final avatarUrl = node.post.author?.avatarUrl; - return AnimatedScale( - duration: const Duration(milliseconds: 180), - scale: isActive ? 1.08 : 0.98, - child: Container( - width: 32, - height: 32, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: AppTheme.brightNavy.withValues(alpha: 0.2), - ), - child: avatarUrl == null - ? Center( - child: Text( - (node.post.author?.displayName ?? 'S').characters.first.toUpperCase(), - style: GoogleFonts.inter( - color: AppTheme.brightNavy, - fontWeight: FontWeight.w700, - fontSize: 12, - ), - ), - ) - : ClipOval( - child: SignedMediaImage( - url: avatarUrl, - width: 32, - height: 32, - fit: BoxFit.cover, - ), - ), - ), - ); - } - - @override - bool shouldRebuild(covariant _KineticScrubberHeader oldDelegate) { - return oldDelegate.layerStack != layerStack || - oldDelegate.currentIndex != currentIndex || - oldDelegate.totalCount != totalCount || - oldDelegate.isLoading != isLoading; - } -} diff --git a/sojorn_app/lib/widgets/post/post_body.dart b/sojorn_app/lib/widgets/post/post_body.dart index 9d5f202..05538f2 100644 --- a/sojorn_app/lib/widgets/post/post_body.dart +++ b/sojorn_app/lib/widgets/post/post_body.dart @@ -51,6 +51,8 @@ class PostBody extends StatelessWidget { return null; // Show all in detail case PostViewMode.compact: return 6; // More compact in profile lists + case PostViewMode.thread: + return 4; // Very compact in thread replies } } diff --git a/sojorn_app/lib/widgets/post/post_link_preview.dart b/sojorn_app/lib/widgets/post/post_link_preview.dart index 499abab..44acec8 100644 --- a/sojorn_app/lib/widgets/post/post_link_preview.dart +++ b/sojorn_app/lib/widgets/post/post_link_preview.dart @@ -25,6 +25,8 @@ class PostLinkPreview extends StatelessWidget { return 280.0; case PostViewMode.compact: return 160.0; + case PostViewMode.thread: + return 120.0; } } diff --git a/sojorn_app/lib/widgets/post/post_media.dart b/sojorn_app/lib/widgets/post/post_media.dart index bf153e5..1010118 100644 --- a/sojorn_app/lib/widgets/post/post_media.dart +++ b/sojorn_app/lib/widgets/post/post_media.dart @@ -37,6 +37,8 @@ class PostMedia extends StatelessWidget { return 600.0; case PostViewMode.compact: return 200.0; + case PostViewMode.thread: + return 150.0; } } diff --git a/sojorn_app/lib/widgets/post/post_view_mode.dart b/sojorn_app/lib/widgets/post/post_view_mode.dart index 88ba6a4..d26817e 100644 --- a/sojorn_app/lib/widgets/post/post_view_mode.dart +++ b/sojorn_app/lib/widgets/post/post_view_mode.dart @@ -10,4 +10,8 @@ enum PostViewMode { /// Compact view for profile lists - minimal header, reduced spacing compact, + + /// Thread view - reduced padding, no card elevation, smaller avatars, + /// connecting lines align correctly, media collapsed + thread, } diff --git a/sojorn_app/lib/widgets/post_card.dart b/sojorn_app/lib/widgets/post_card.dart deleted file mode 100644 index 3902a03..0000000 --- a/sojorn_app/lib/widgets/post_card.dart +++ /dev/null @@ -1,107 +0,0 @@ -import 'package:flutter/material.dart'; -import '../models/post.dart'; -import '../theme/app_theme.dart'; -import 'chain_quote_widget.dart'; -import 'post/post_actions.dart'; -import 'post/post_body.dart'; -import 'post/post_header.dart'; -import 'post/post_media.dart'; - -enum PostCardVariant { - feed, - detail, - profile, -} - -/// @deprecated -/// Use [UnifiedPostTile] from post_item.dart instead. -/// PostCard is deprecated in favor of the "Strict Flat" design system. -@Deprecated('Use UnifiedPostTile from post_item.dart instead. PostCard is deprecated.') -class PostCard extends StatelessWidget { - final Post post; - final PostCardVariant variant; - final VoidCallback? onTap; - final VoidCallback? onChain; - final VoidCallback? onChainParentTap; - final bool isAlternate; - final bool showChainContext; - - const PostCard({ - super.key, - required this.post, - this.variant = PostCardVariant.feed, - this.onTap, - this.onChain, - this.onChainParentTap, - this.isAlternate = false, - this.showChainContext = true, - }); - - @override - Widget build(BuildContext context) { - final padding = switch (variant) { - PostCardVariant.detail => - const EdgeInsets.symmetric(vertical: AppTheme.spacingMd), - _ => EdgeInsets.zero, - }; - - const contentPadding = EdgeInsets.symmetric( - horizontal: AppTheme.spacingLg, - vertical: AppTheme.spacingSm, - ); - - Widget content = Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Chain Context (The Quote Box) - if (showChainContext && post.chainParent != null) ...[ - ChainQuoteWidget( - parent: post.chainParent!, - onTap: onChainParentTap, - ), - const SizedBox(height: AppTheme.spacingSm), - ], - - // Main Post Content - PostHeader(post: post), - const SizedBox(height: AppTheme.spacingMd), - PostBody(text: post.body, bodyFormat: post.bodyFormat), - PostMedia(post: post), - const SizedBox(height: AppTheme.spacingMd), - PostActions( - post: post, - onChain: onChain, - ), - ], - ); - - if (isAlternate) { - content = Container( - padding: contentPadding, - decoration: BoxDecoration( - color: AppTheme.white, // Replaced AppTheme.surface - borderRadius: BorderRadius.circular(8.0), // Replaced AppTheme.radiusMd - ), - child: content, - ); - } else { - content = Padding( - padding: contentPadding, - child: content, - ); - } - - return Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - highlightColor: Colors.transparent, - splashColor: Colors.transparent, - child: Padding( - padding: padding, - child: content, - ), - ), - ); - } -} diff --git a/sojorn_app/lib/widgets/post_item.dart b/sojorn_app/lib/widgets/post_item.dart deleted file mode 100644 index ea1fa25..0000000 --- a/sojorn_app/lib/widgets/post_item.dart +++ /dev/null @@ -1,139 +0,0 @@ -import 'package:flutter/material.dart'; -import '../models/post.dart'; -import '../theme/app_theme.dart'; -import 'sojorn_post_card.dart'; -import 'post/post_view_mode.dart'; - -/// UnifiedPostTile - Backward-compatible wrapper around sojornPostCard. -/// -/// This class is now DEPRECATED in favor of sojornPostCard. -/// It exists only for backward compatibility with existing code. -/// -/// ## New Usage -/// ```dart -/// sojornPostCard( -/// post: post, -/// mode: PostViewMode.feed, -/// onTap: () {}, -/// ) -/// ``` -/// -/// ## Migration Guide -/// Replace `UnifiedPostTile(...)` with `sojornPostCard(...)` -class UnifiedPostTile extends StatelessWidget { - final Post post; - final VoidCallback? onTap; - final VoidCallback? onChain; - final bool showDivider; - final bool isDetailView; - final bool showThreadSpine; - final double? avatarSize; - final bool isThreadView; - - const UnifiedPostTile({ - super.key, - required this.post, - this.onTap, - this.onChain, - this.showDivider = true, - this.isDetailView = false, - this.showThreadSpine = false, - this.avatarSize, - this.isThreadView = false, - }); - - /// Convert legacy parameters to PostViewMode - PostViewMode get _viewMode { - if (isDetailView) return PostViewMode.detail; - if (avatarSize != null && avatarSize! < 32) return PostViewMode.compact; - return PostViewMode.feed; - } - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Thread spine line (if enabled in threaded view) - if (showThreadSpine) _buildThreadSpine(context), - - // Backward compatibility: wrap in divider if needed - if (showDivider) - Column( - children: [ - _buildPostContent(context), - _buildStrictDivider(), - ], - ) - else - _buildPostContent(context), - ], - ); - } - - Widget _buildPostContent(BuildContext context) { - return sojornPostCard( - post: post, - mode: _viewMode, - onTap: onTap, - onChain: onChain, - isThreadView: isThreadView, - ); - } - - /// STRICT SEPARATION: Full-width Divider at bottom of every post - Widget _buildStrictDivider() { - return Container( - height: 1.0, - width: double.infinity, - color: AppTheme.egyptianBlue.withOpacity(0.15), - margin: EdgeInsets.zero, - ); - } - - /// Thread spine: vertical line connecting parent to child in threaded view - Widget _buildThreadSpine(BuildContext context) { - return CustomPaint( - size: const Size(2, AppTheme.spacingMd), - painter: ThreadSpinePainter( - color: AppTheme.egyptianBlue.withOpacity(0.3), - width: 2, - ), - ); - } -} - -/// Thread Spine Painter - draws vertical line for threaded views -class ThreadSpinePainter extends CustomPainter { - final Color color; - final double width; - - ThreadSpinePainter({required this.color, required this.width}); - - @override - void paint(Canvas canvas, Size size) { - final paint = Paint() - ..color = color - ..strokeWidth = width - ..style = PaintingStyle.fill; - - // Draw vertical line from center-top to center-bottom - canvas.drawRect( - Rect.fromLTWH( - size.width / 2 - width / 2, - 0, - width, - size.height, - ), - paint, - ); - } - - @override - bool shouldRepaint(ThreadSpinePainter oldDelegate) { - return oldDelegate.color != color || oldDelegate.width != width; - } -} - -/// Backward-compatible alias for UnifiedPostTile -typedef PostItem = UnifiedPostTile; diff --git a/sojorn_app/lib/widgets/reactions/reaction_strip.dart b/sojorn_app/lib/widgets/reactions/reaction_strip.dart deleted file mode 100644 index 7b5d6cc..0000000 --- a/sojorn_app/lib/widgets/reactions/reaction_strip.dart +++ /dev/null @@ -1,377 +0,0 @@ -import 'dart:convert'; -import 'dart:ui'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_animate/flutter_animate.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import '../../theme/app_theme.dart'; - - - -class ReactionStrip extends StatelessWidget { - final Map reactions; - final Set myReactions; - final Map>? reactionUsers; - final ValueChanged onToggle; - final VoidCallback onAdd; - final bool dense; - - const ReactionStrip({ - super.key, - required this.reactions, - required this.myReactions, - required this.onToggle, - required this.onAdd, - this.reactionUsers, - this.dense = true, - }); - - @override - Widget build(BuildContext context) { - final keys = reactions.keys.toList()..sort(); - - return Wrap( - spacing: dense ? 6 : 8, - runSpacing: dense ? 6 : 8, - children: [ - for (final reaction in keys) - _ReactionChip( - reactionId: reaction, - count: reactions[reaction] ?? 0, - isSelected: myReactions.contains(reaction), - tooltipNames: reactionUsers?[reaction], - onTap: () => onToggle(reaction), - ), - _ReactionAddButton(onTap: onAdd), - ], - ); - } -} - -class _ReactionChip extends StatefulWidget { - final String reactionId; - final int count; - final bool isSelected; - final List? tooltipNames; - final VoidCallback onTap; - - const _ReactionChip({ - required this.reactionId, - required this.count, - required this.isSelected, - required this.onTap, - this.tooltipNames, - }); - - @override - State<_ReactionChip> createState() => _ReactionChipState(); -} - -class _ReactionChipState extends State<_ReactionChip> { - int _tapCount = 0; - - void _handleTap() { - HapticFeedback.selectionClick(); - setState(() => _tapCount += 1); - widget.onTap(); - } - - @override - Widget build(BuildContext context) { - final background = widget.isSelected - ? AppTheme.brightNavy.withValues(alpha: 0.14) - : AppTheme.navyBlue.withValues(alpha: 0.08); - final borderColor = widget.isSelected - ? AppTheme.brightNavy - : AppTheme.navyBlue.withValues(alpha: 0.2); - - final chip = InkWell( - onTap: _handleTap, - borderRadius: BorderRadius.circular(14), - child: AnimatedContainer( - duration: const Duration(milliseconds: 140), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: background, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: borderColor, width: 1.2), - boxShadow: widget.isSelected - ? [ - BoxShadow( - color: AppTheme.brightNavy.withValues(alpha: 0.22), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ] - : null, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _ReactionIcon(reactionId: widget.reactionId), - const SizedBox(width: 6), - Text( - widget.count.toString(), - style: TextStyle( - color: AppTheme.navyBlue, - fontSize: 12, - fontWeight: FontWeight.w700, - ), - ), - ], - ), - ), - ) - .animate(key: ValueKey(_tapCount)) - .scale( - begin: const Offset(1, 1), - end: const Offset(1.08, 1.08), - duration: 120.ms, - curve: Curves.easeOut, - ) - .then() - .scale( - begin: const Offset(1.08, 1.08), - end: const Offset(1, 1), - duration: 180.ms, - curve: Curves.easeOutBack, - ); - - final names = widget.tooltipNames; - if (names == null || names.isEmpty) { - return chip; - } - - return Tooltip( - message: names.take(3).join(', '), - child: chip, - ); - } -} - -class _ReactionAddButton extends StatelessWidget { - final VoidCallback onTap; - - const _ReactionAddButton({required this.onTap}); - - @override - Widget build(BuildContext context) { - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(14), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: AppTheme.navyBlue.withValues(alpha: 0.06), - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: AppTheme.navyBlue.withValues(alpha: 0.2), - width: 1.2, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.add, size: 16, color: AppTheme.navyBlue), - const SizedBox(width: 6), - Text( - 'Add', - style: TextStyle( - color: AppTheme.navyBlue, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ); - } -} - -class _ReactionIcon extends StatelessWidget { - final String reactionId; - - const _ReactionIcon({required this.reactionId}); - - @override - Widget build(BuildContext context) { - if (reactionId.startsWith('asset:') || reactionId.startsWith('assets/')) { - final assetPath = reactionId.startsWith('asset:') - ? reactionId.replaceFirst('asset:', '') - : reactionId; - - if (assetPath.endsWith('.svg')) { - return SvgPicture.asset( - assetPath, - width: 18, - height: 18, - placeholderBuilder: (_) => const SizedBox(width: 18, height: 18), - ); - } - return Image.asset( - assetPath, - width: 18, - height: 18, - fit: BoxFit.contain, - ); - } - return Text(reactionId, style: const TextStyle(fontSize: 16)); - } -} - -Future showReactionPicker( - BuildContext context, { - required List baseItems, -}) { - return showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) => _ReactionPickerSheet(baseItems: baseItems), - ); -} - -class _ReactionPickerSheet extends StatefulWidget { - final List baseItems; - - const _ReactionPickerSheet({required this.baseItems}); - - @override - State<_ReactionPickerSheet> createState() => _ReactionPickerSheetState(); -} - -class _ReactionPickerSheetState extends State<_ReactionPickerSheet> { - final TextEditingController _controller = TextEditingController(); - List _assetItems = []; - - @override - void initState() { - super.initState(); - _loadAssetReactions(); - } - - Future _loadAssetReactions() async { - try { - final manifest = await AssetManifest.loadFromAssetBundle(DefaultAssetBundle.of(context)); - final keys = manifest.listAssets() - .where((key) => key.startsWith('assets/reactions/')) - .toList() - ..sort(); - final items = keys - .map((path) => ReactionItem( - id: 'asset:$path', - label: _labelForAsset(path), - )) - .toList(); - if (mounted) { - setState(() => _assetItems = items); - } - } catch (_) { - // Ignore manifest parsing errors; picker will show base items only. - } - } - - String _labelForAsset(String path) { - final fileName = path.split('/').last; - final name = fileName.split('.').first; - return name.replaceAll('_', ' '); - } - - @override - Widget build(BuildContext context) { - final query = _controller.text.trim().toLowerCase(); - final items = [...widget.baseItems, ..._assetItems]; - final filtered = query.isEmpty - ? items - : items - .where((item) => - item.label.toLowerCase().contains(query) || - item.id.toLowerCase().contains(query)) - .toList(); - - return Container( - height: MediaQuery.of(context).size.height * 0.55, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppTheme.cardSurface.withValues(alpha: 0.75), - borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(20), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Pick a reaction', - style: TextStyle( - color: AppTheme.navyBlue, - fontSize: 16, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 12), - TextField( - controller: _controller, - decoration: InputDecoration( - hintText: 'Search reactions', - prefixIcon: Icon(Icons.search, color: AppTheme.navyBlue), - filled: true, - fillColor: Colors.white.withValues(alpha: 0.2), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(14), - borderSide: BorderSide.none, - ), - ), - onChanged: (_) => setState(() {}), - ), - const SizedBox(height: 12), - Expanded( - child: GridView.builder( - itemCount: filtered.length, - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 6, - mainAxisSpacing: 8, - crossAxisSpacing: 8, - ), - itemBuilder: (context, index) { - final item = filtered[index]; - return InkWell( - onTap: () => Navigator.of(context).pop(item.id), - borderRadius: BorderRadius.circular(12), - child: Container( - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: AppTheme.navyBlue.withValues(alpha: 0.15), - ), - ), - alignment: Alignment.center, - child: _ReactionIcon(reactionId: item.id), - ), - ); - }, - ), - ), - ], - ), - ), - ), - ); - } -} - -class ReactionItem { - final String id; - final String label; - - const ReactionItem({ - required this.id, - required this.label, - }); -} diff --git a/sojorn_app/lib/widgets/reactions/smart_reaction_button.dart b/sojorn_app/lib/widgets/reactions/smart_reaction_button.dart deleted file mode 100644 index 71914b9..0000000 --- a/sojorn_app/lib/widgets/reactions/smart_reaction_button.dart +++ /dev/null @@ -1,125 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:google_fonts/google_fonts.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import '../../theme/app_theme.dart'; - -class SmartReactionButton extends ConsumerWidget { - final Map reactionCounts; - final Set myReactions; - final VoidCallback onPressed; - - const SmartReactionButton({ - super.key, - required this.reactionCounts, - required this.myReactions, - required this.onPressed, - }); - - Widget _buildReactionContent(String reaction) { - if (reaction.startsWith('assets/') || reaction.startsWith('asset:')) { - final assetPath = reaction.startsWith('asset:') - ? reaction.replaceFirst('asset:', '') - : reaction; - - if (assetPath.endsWith('.svg')) { - return SvgPicture.asset( - assetPath, - width: 18, - height: 18, - ); - } - return Image.asset( - assetPath, - width: 18, - height: 18, - fit: BoxFit.contain, - ); - } - return Text( - reaction, - style: const TextStyle(fontSize: 18), - ); - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - // Determine what to show - if (myReactions.isNotEmpty) { - // Show user's reaction + total count - final myReaction = myReactions.first; - final totalCount = reactionCounts.values.fold(0, (a, b) => a + b); - - return IconButton( - onPressed: onPressed, - icon: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _buildReactionContent(myReaction), - const SizedBox(width: 4), - Text( - totalCount > 99 ? '99+' : '$totalCount', - style: GoogleFonts.inter( - color: AppTheme.brightNavy, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - style: IconButton.styleFrom( - backgroundColor: AppTheme.brightNavy.withValues(alpha: 0.15), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - ); - } else if (reactionCounts.isNotEmpty) { - // Show top reaction + total count - final topReaction = reactionCounts.entries - .reduce((a, b) => a.value > b.value ? a : b); - final totalCount = reactionCounts.values.fold(0, (a, b) => a + b); - - return IconButton( - onPressed: onPressed, - icon: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _buildReactionContent(topReaction.key), - const SizedBox(width: 4), - Text( - totalCount > 99 ? '99+' : '$totalCount', - style: GoogleFonts.inter( - color: AppTheme.textSecondary, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - style: IconButton.styleFrom( - backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.08), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - ); - } else { - // Show plus button - return IconButton( - onPressed: onPressed, - icon: Icon( - Icons.add_reaction_outlined, - color: AppTheme.textSecondary, - size: 20, - ), - style: IconButton.styleFrom( - backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.08), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - ); - } - } -} diff --git a/sojorn_app/lib/widgets/reading_post_card.dart b/sojorn_app/lib/widgets/reading_post_card.dart deleted file mode 100644 index f95369f..0000000 --- a/sojorn_app/lib/widgets/reading_post_card.dart +++ /dev/null @@ -1,338 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:timeago/timeago.dart' as timeago; -import '../models/post.dart'; -import '../theme/app_theme.dart'; -import 'media/signed_media_image.dart'; -import '../routes/app_routes.dart'; - -class ReadingPostCard extends StatefulWidget { - final Post post; - final VoidCallback? onTap; - final VoidCallback? onSave; - final bool isSaved; - final bool showDivider; - - const ReadingPostCard({ - super.key, - required this.post, - this.onTap, - this.onSave, - this.isSaved = false, - this.showDivider = true, - }); - - @override - State createState() => _ReadingPostCardState(); -} - -class _ReadingPostCardState extends State { - bool _isPressed = false; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildPostCard(), - if (widget.showDivider) _buildDivider(), - ], - ); - } - - Widget _buildPostCard() { - return Container( - constraints: const BoxConstraints(maxWidth: 680), - margin: _getMargin(), - decoration: BoxDecoration( - color: AppTheme.white, - borderRadius: BorderRadius.circular(AppTheme.radiusSm), - border: Border( - left: BorderSide( - color: _isPressed ? AppTheme.brightNavy : AppTheme.egyptianBlue, - width: AppTheme.flowLineWidth, - ), - right: BorderSide(color: AppTheme.egyptianBlue, width: 1), - top: BorderSide(color: AppTheme.egyptianBlue, width: 1), - bottom: BorderSide(color: AppTheme.egyptianBlue, width: 1), - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.03), - blurRadius: 8, - offset: Offset(0, 2), - ), - ], - ), - child: Material( - color: Colors.transparent, - child: Padding( - padding: const EdgeInsets.fromLTRB( - AppTheme.spacingMd, - AppTheme.spacingSm, - AppTheme.spacingLg, - AppTheme.spacingMd, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildAuthorRow(), - const SizedBox(height: AppTheme.spacingMd), - // White space area - clickable for post detail with full background coverage - InkWell( - onTap: widget.onTap, - onTapDown: (_) => setState(() => _isPressed = true), - onTapUp: (_) => setState(() => _isPressed = false), - onTapCancel: () => setState(() => _isPressed = false), - borderRadius: BorderRadius.circular(AppTheme.radiusSm), - splashColor: AppTheme.queenPink.withValues(alpha: 0.3), - highlightColor: Colors.transparent, - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 4), - child: _buildBodyText(), - ), - ), - const SizedBox(height: AppTheme.spacingLg), - _buildActionRow(), - ], - ), - ), - ), - ); - } - - Widget _buildDivider() { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: AppTheme.spacingMd), - child: Container( - height: AppTheme.dividerThickness, - decoration: BoxDecoration(color: AppTheme.egyptianBlue), - ), - ); - } - - EdgeInsets _getMargin() { - final charCount = widget.post.body.length; - if (charCount < 100) { - return EdgeInsets.only( - left: AppTheme.spacingMd, - right: AppTheme.spacingMd, - top: AppTheme.spacingPostShort); - } else if (charCount < 300) { - return EdgeInsets.only( - left: AppTheme.spacingMd, - right: AppTheme.spacingMd, - top: AppTheme.spacingPostMedium); - } - return EdgeInsets.only( - left: AppTheme.spacingMd, - right: AppTheme.spacingMd, - top: AppTheme.spacingPostLong); - } - - Widget _buildAuthorRow() { - final avatarUrl = widget.post.author?.avatarUrl; - final handle = widget.post.author?.handle ?? ''; - final fallbackColor = _getAvatarColor(handle); - - return InkWell( - onTap: () { - if (handle.isNotEmpty && handle != 'unknown') { - AppRoutes.navigateToProfile(context, handle); - } - }, - borderRadius: BorderRadius.circular(AppTheme.radiusSm), - child: Row( - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: fallbackColor, - borderRadius: BorderRadius.circular(10), - ), - child: avatarUrl != null && avatarUrl.isNotEmpty - ? ClipRRect( - borderRadius: BorderRadius.circular(9), - child: SignedMediaImage( - url: avatarUrl, - width: 36, - height: 36, - fit: BoxFit.cover, - ), - ) - : Center( - child: Text( - handle.isNotEmpty ? handle[0].toUpperCase() : '?', - style: AppTheme.textTheme.labelMedium?.copyWith( - color: Colors.white, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - const SizedBox(width: AppTheme.spacingSm), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Flexible( - child: Text( - widget.post.author?.displayName ?? 'Unknown', - style: AppTheme.textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.w700, - color: AppTheme.navyBlue, - ), - overflow: TextOverflow.ellipsis, - ), - ), - if (widget.post.author?.isOfficial == true) ...[ - const SizedBox(width: AppTheme.spacingXs), - _buildOfficialBadge(), - ], - if (widget.post.author?.isOfficial != true && - widget.post.author?.trustState != null) ...[ - const SizedBox(width: AppTheme.spacingXs), - _buildTrustBadge(), - ], - ], - ), - const SizedBox(height: 2), - Text( - timeago.format(widget.post.createdAt), - style: AppTheme.textTheme.labelSmall - ?.copyWith(color: AppTheme.egyptianBlue), - ), - ], - ), - ), - ], - ), - ); - } - - Widget _buildBodyText() { - final charCount = widget.post.body.length; - final style = charCount >= 10 ? AppTheme.postBodyLong : AppTheme.postBody; - return Text(widget.post.body, style: style); - } - - Widget _buildActionRow() { - return Row( - children: [ - if (widget.post.contentIntegrityScore < 1.0) ...[ - Container( - padding: const EdgeInsets.symmetric( - horizontal: AppTheme.spacingSm, vertical: 2), - decoration: BoxDecoration( - color: _getCISColor(widget.post.contentIntegrityScore) - .withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(AppTheme.radiusXs), - ), - child: Text( - 'CIS ${(widget.post.contentIntegrityScore * 100).toInt()}%', - style: AppTheme.textTheme.labelSmall?.copyWith( - color: _getCISColor(widget.post.contentIntegrityScore), - fontSize: 10, - fontWeight: FontWeight.w600, - ), - ), - ), - const SizedBox(width: AppTheme.spacingMd), - ], - const Spacer(), - _buildActionButton( - icon: widget.isSaved ? Icons.bookmark : Icons.bookmark_border, - isActive: widget.isSaved, - onPressed: widget.onSave, - color: AppTheme.brightNavy, - ), - const SizedBox(width: AppTheme.spacingMd), - _buildActionButton( - icon: Icons.share_outlined, - isActive: false, - onPressed: null, // TODO: Implement share functionality - color: AppTheme.brightNavy, - ), - ], - ); - } - - Widget _buildActionButton({ - required IconData icon, - required bool isActive, - VoidCallback? onPressed, - required Color color, - }) { - return InkWell( - onTap: onPressed, - borderRadius: BorderRadius.circular(AppTheme.radiusSm), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppTheme.spacingSm, vertical: AppTheme.spacingXs), - child: Icon(icon, - size: 18, color: isActive ? color : AppTheme.royalPurple), - ), - ); - } - - Widget _buildOfficialBadge() { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), - decoration: BoxDecoration( - color: AppTheme.info.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(AppTheme.radiusXs), - ), - child: Text('sojorn', - style: AppTheme.textTheme.labelSmall?.copyWith( - fontSize: 8, - fontWeight: FontWeight.w600, - color: AppTheme.info, - letterSpacing: 0.4, - )), - ); - } - - Widget _buildTrustBadge() { - final tier = widget.post.author!.trustState!.tier; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), - decoration: BoxDecoration( - color: _getTierColor(tier.value).withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(AppTheme.radiusXs), - ), - child: Text(tier.displayName.toUpperCase(), - style: AppTheme.textTheme.labelSmall?.copyWith( - fontSize: 8, - fontWeight: FontWeight.w600, - color: _getTierColor(tier.value), - letterSpacing: 0.4, - )), - ); - } - - Color _getAvatarColor(String handle) { - final hash = handle.hashCode; - final hue = (hash % 360).toDouble(); - return HSLColor.fromAHSL(1.0, hue, 0.45, 0.55).toColor(); - } - - Color _getTierColor(String tier) { - switch (tier) { - case 'established': - return AppTheme.tierEstablished; - case 'trusted': - return AppTheme.tierTrusted; - default: - return AppTheme.tierNew; - } - } - - Color _getCISColor(double cis) { - if (cis >= 0.8) return AppTheme.success; - if (cis >= 0.6) return AppTheme.warning; - return AppTheme.error; - } -} diff --git a/sojorn_app/lib/widgets/sojorn_app_bar.dart b/sojorn_app/lib/widgets/sojorn_app_bar.dart deleted file mode 100644 index 58fac5c..0000000 --- a/sojorn_app/lib/widgets/sojorn_app_bar.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'package:flutter/material.dart'; - -/// Custom app bar enforcing sojorn's visual system, leveraging AppTheme. -class sojornAppBar extends StatelessWidget implements PreferredSizeWidget { - final String? title; - final Widget? leading; - final List? actions; - final bool showBackButton; - final VoidCallback? onBackPressed; - final PreferredSizeWidget? bottom; - - const sojornAppBar({ - super.key, - this.title, - this.leading, - this.actions, - this.showBackButton = false, - this.onBackPressed, - this.bottom, - }); - - @override - Size get preferredSize => Size.fromHeight( - kToolbarHeight + (bottom?.preferredSize.height ?? 0), - ); - - @override - Widget build(BuildContext context) { - return AppBar( - // Rely on AppTheme.appBarTheme for background, foreground, elevation, - // scrolledUnderElevation, centerTitle, and titleTextStyle - leading: leading ?? - (showBackButton - ? IconButton( - icon: const Icon(Icons.arrow_back, size: 22), - onPressed: onBackPressed ?? () => Navigator.of(context).pop(), - ) - : null), - title: (title == null || title!.isEmpty) - ? Image.asset( - 'assets/images/toplogo.png', - height: 44, - ) - : Text( - title!, - ), - actions: actions, - bottom: bottom, - ); - } -} - -// sojornMinimalAppBar has been removed as its functionality is now -// better handled by a standard AppBar leveraging AppTheme.appBarTheme. diff --git a/sojorn_app/lib/widgets/sojorn_card.dart b/sojorn_app/lib/widgets/sojorn_card.dart deleted file mode 100644 index 8e67a95..0000000 --- a/sojorn_app/lib/widgets/sojorn_card.dart +++ /dev/null @@ -1,139 +0,0 @@ -import 'package:flutter/material.dart'; -import '../theme/app_theme.dart'; - -/// Custom card widget enforcing sojorn's visual system -class sojornCard extends StatelessWidget { - final Widget child; - final EdgeInsets? padding; - final EdgeInsets? margin; - final VoidCallback? onTap; - final Color? backgroundColor; - final bool showBorder; - // Removed final bool showShadow; to align with AppTheme.cardTheme - - const sojornCard({ - super.key, - required this.child, - this.padding, - this.margin, - this.onTap, - this.backgroundColor, - this.showBorder = true, - // Removed this.showShadow = false; - }); - - @override - Widget build(BuildContext context) { - final card = Container( - margin: margin ?? - const EdgeInsets.symmetric( - horizontal: AppTheme.spacingMd, - vertical: AppTheme.spacingSm, - ), - decoration: BoxDecoration( - color: backgroundColor ?? - AppTheme.white, // Replaced AppTheme.surfaceElevated - borderRadius: BorderRadius.circular(12.0), // Replaced AppTheme.radiusLg - border: showBorder - ? Border.all( - color: AppTheme.egyptianBlue, - width: 0.5) // Replaced AppTheme.border - : null, - // Removed boxShadow: showShadow ? AppTheme.shadowMd : null, - ), - child: Padding( - padding: padding ?? const EdgeInsets.all(AppTheme.spacingLg), - child: child, - ), - ); - - if (onTap != null) { - return Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - borderRadius: - BorderRadius.circular(12.0), // Replaced AppTheme.radiusLg - child: card, - ), - ); - } - - return card; - } -} - -/// Section card with optional header -class sojornSectionCard extends StatelessWidget { - final String? title; - final String? subtitle; - final Widget? trailing; - final Widget child; - final EdgeInsets? padding; - final EdgeInsets? margin; - - const sojornSectionCard({ - super.key, - this.title, - this.subtitle, - this.trailing, - required this.child, - this.padding, - this.margin, - }); - - @override - Widget build(BuildContext context) { - return sojornCard( - padding: EdgeInsets.zero, - margin: margin, - // Removed showShadow property as it's no longer supported by sojornCard - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (title != null || subtitle != null || trailing != null) ...[ - Padding( - padding: const EdgeInsets.all(AppTheme.spacingLg), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (title != null) - Text( - title!, - style: AppTheme.headlineSmall, - ), - if (subtitle != null) ...[ - const SizedBox(height: AppTheme.spacingXs), - Text( - subtitle!, - style: AppTheme.textTheme.bodyMedium?.copyWith( - // Replaced bodySmall - color: AppTheme.navyText - .withOpacity(0.8), // Replaced textSecondary - ), - ), - ], - ], - ), - ), - if (trailing != null) ...[ - const SizedBox(width: AppTheme.spacingMd), - trailing!, - ], - ], - ), - ), - const Divider(height: 1), - ], - Padding( - padding: padding ?? const EdgeInsets.all(AppTheme.spacingLg), - child: child, - ), - ], - ), - ); - } -} diff --git a/sojorn_app/lib/widgets/sojorn_dialog.dart b/sojorn_app/lib/widgets/sojorn_dialog.dart deleted file mode 100644 index 50135db..0000000 --- a/sojorn_app/lib/widgets/sojorn_dialog.dart +++ /dev/null @@ -1,241 +0,0 @@ -import 'package:flutter/material.dart'; -import '../theme/app_theme.dart'; -import 'sojorn_button.dart'; - -/// Custom dialog enforcing sojorn's visual system -class sojornDialog extends StatelessWidget { - final String title; - final String? message; - final Widget? content; - final String? primaryButtonLabel; - final VoidCallback? onPrimaryPressed; - final String? secondaryButtonLabel; - final VoidCallback? onSecondaryPressed; - final bool isDismissible; - final bool isDestructive; - - const sojornDialog({ - super.key, - required this.title, - this.message, - this.content, - this.primaryButtonLabel, - this.onPrimaryPressed, - this.secondaryButtonLabel, - this.onSecondaryPressed, - this.isDismissible = true, - this.isDestructive = false, - }); - - @override - Widget build(BuildContext context) { - return Dialog( - backgroundColor: AppTheme.white, // Replaced AppTheme.surfaceElevated - elevation: 8, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12.0), // Replaced AppTheme.radiusLg - ), - child: Padding( - padding: const EdgeInsets.all(AppTheme.spacingLg), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Title - Text( - title, - style: AppTheme.headlineSmall.copyWith( - color: AppTheme.navyBlue, // Replaced AppTheme.textPrimary - ), - ), - const SizedBox(height: AppTheme.spacingMd), - - // Message or custom content - if (message != null) - Text( - message!, - style: AppTheme.bodyMedium.copyWith( - color: AppTheme.navyText - .withOpacity(0.9), // Replaced AppTheme.textSecondary - height: 1.6, - ), - ), - if (content != null) content!, - - const SizedBox(height: AppTheme.spacingLg), - - // Action buttons - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (secondaryButtonLabel != null) ...[ - sojornButton( - label: secondaryButtonLabel!, - onPressed: - onSecondaryPressed ?? () => Navigator.of(context).pop(), - variant: sojornButtonVariant.tertiary, - size: sojornButtonSize.small, - ), - const SizedBox(width: AppTheme.spacingSm), - ], - if (primaryButtonLabel != null) - sojornButton( - label: primaryButtonLabel!, - onPressed: onPrimaryPressed, - variant: isDestructive - ? sojornButtonVariant.destructive - : sojornButtonVariant.primary, - size: sojornButtonSize.small, - ), - ], - ), - ], - ), - ), - ); - } - - /// Show a confirmation dialog - static Future showConfirmation({ - required BuildContext context, - required String title, - required String message, - String confirmLabel = 'Confirm', - String cancelLabel = 'Cancel', - bool isDestructive = false, - }) { - return showDialog( - context: context, - builder: (context) => sojornDialog( - title: title, - message: message, - primaryButtonLabel: confirmLabel, - onPrimaryPressed: () => Navigator.of(context).pop(true), - secondaryButtonLabel: cancelLabel, - onSecondaryPressed: () => Navigator.of(context).pop(false), - isDestructive: isDestructive, - ), - ); - } - - /// Show an informational dialog - static Future showInfo({ - required BuildContext context, - required String title, - required String message, - String buttonLabel = 'OK', - }) { - return showDialog( - context: context, - builder: (context) => sojornDialog( - title: title, - message: message, - primaryButtonLabel: buttonLabel, - onPrimaryPressed: () => Navigator.of(context).pop(), - ), - ); - } - - /// Show an error dialog - static Future showError({ - required BuildContext context, - required String title, - required String message, - String buttonLabel = 'OK', - }) { - return showDialog( - context: context, - builder: (context) => sojornDialog( - title: title, - message: message, - primaryButtonLabel: buttonLabel, - onPrimaryPressed: () => Navigator.of(context).pop(), - isDestructive: true, - ), - ); - } -} - -/// Bottom sheet variant for mobile-friendly actions -class sojornBottomSheet extends StatelessWidget { - final String? title; - final Widget child; - - const sojornBottomSheet({ - super.key, - this.title, - required this.child, - }); - - @override - Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: AppTheme.white, // Replaced AppTheme.surfaceElevated - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(12.0), // Replaced AppTheme.radiusLg - topRight: Radius.circular(12.0), // Replaced AppTheme.radiusLg - ), - ), - child: SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Handle - Center( - child: Container( - width: 40, - height: 4, - margin: const EdgeInsets.only(top: AppTheme.spacingMd), - decoration: BoxDecoration( - color: - AppTheme.egyptianBlue, // Replaced AppTheme.borderStrong - borderRadius: BorderRadius.circular(AppTheme.radiusFull), - ), - ), - ), - const SizedBox(height: AppTheme.spacingMd), - - // Title - if (title != null) ...[ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppTheme.spacingLg, - ), - child: Text( - title!, - style: AppTheme.headlineSmall, - ), - ), - const SizedBox(height: AppTheme.spacingMd), - ], - - // Content - Padding( - padding: const EdgeInsets.all(AppTheme.spacingLg), - child: child, - ), - ], - ), - ), - ); - } - - /// Show a bottom sheet - static Future show({ - required BuildContext context, - String? title, - required Widget child, - }) { - return showModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - isScrollControlled: true, - builder: (context) => sojornBottomSheet( - title: title, - child: child, - ), - ); - } -} diff --git a/sojorn_app/lib/widgets/sojorn_post_card.dart b/sojorn_app/lib/widgets/sojorn_post_card.dart index b53090e..9c5a961 100644 --- a/sojorn_app/lib/widgets/sojorn_post_card.dart +++ b/sojorn_app/lib/widgets/sojorn_post_card.dart @@ -90,6 +90,11 @@ class _sojornPostCardState extends ConsumerState { horizontal: AppTheme.spacingMd, vertical: AppTheme.spacingSm, ); + case PostViewMode.thread: + return const EdgeInsets.symmetric( + horizontal: AppTheme.spacingSm, + vertical: AppTheme.spacingXs, + ); } } @@ -117,9 +122,14 @@ class _sojornPostCardState extends ConsumerState { return 44.0; case PostViewMode.compact: return 28.0; + case PostViewMode.thread: + return 24.0; } } + bool get _isThread => mode == PostViewMode.thread; + bool get _effectiveThreadView => isThreadView || _isThread; + @override Widget build(BuildContext context) { // Completely hide NSFW posts when user hasn't enabled NSFW @@ -128,24 +138,25 @@ class _sojornPostCardState extends ConsumerState { return Material( color: Colors.transparent, child: Container( - margin: const EdgeInsets.only(bottom: 16), // Add spacing between cards + margin: EdgeInsets.only(bottom: _isThread ? 4 : 16), decoration: BoxDecoration( color: AppTheme.cardSurface, - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: AppTheme.navyBlue.withValues(alpha: 0.3), // Lighter border - width: 1.5, // Slightly thinner border - ), - boxShadow: [ - BoxShadow( - color: AppTheme.brightNavy.withValues(alpha: 0.12), // Lighter shadow - blurRadius: 20, - offset: const Offset(0, 6), - ), - ], + borderRadius: BorderRadius.circular(_isThread ? 12 : 20), + border: _isThread + ? Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.08), width: 1) + : Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.3), width: 1.5), + boxShadow: _isThread + ? [] + : [ + BoxShadow( + color: AppTheme.brightNavy.withValues(alpha: 0.12), + blurRadius: 20, + offset: const Offset(0, 6), + ), + ], ), child: ClipRRect( - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(_isThread ? 12 : 20), child: Container( padding: _padding.copyWith(left: 0, right: 0), child: Column( @@ -158,7 +169,7 @@ class _sojornPostCardState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ // Chain Context (The Quote Box) - only show in thread view - if (isThreadView && showChainContext && post.chainParent != null) ...[ + if (_effectiveThreadView && showChainContext && post.chainParent != null) ...[ ChainQuoteWidget( parent: post.chainParent!, onTap: onChainParentTap, @@ -167,7 +178,7 @@ class _sojornPostCardState extends ConsumerState { ], // Feed chain hint — subtle "replying to" for non-thread views - if (!isThreadView && post.chainParent != null) ...[ + if (!_effectiveThreadView && post.chainParent != null) ...[ GestureDetector( onTap: onTap, child: _ChainReplyHint(parent: post.chainParent!), @@ -372,8 +383,8 @@ class _sojornPostCardState extends ConsumerState { post: post, onChain: onChain, onPostChanged: onPostChanged, - isThreadView: isThreadView, - showReactions: isThreadView, + isThreadView: _effectiveThreadView, + showReactions: _effectiveThreadView, ), ), const SizedBox(height: 4), diff --git a/sojorn_app/lib/widgets/sojorn_top_bar.dart b/sojorn_app/lib/widgets/sojorn_top_bar.dart deleted file mode 100644 index 6b590f9..0000000 --- a/sojorn_app/lib/widgets/sojorn_top_bar.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:flutter/material.dart'; - -class sojornTopBar extends StatelessWidget implements PreferredSizeWidget { - final String title; - final Widget? leading; - final List? actions; - final bool centerTitle; - final Color? backgroundColor; - final double? elevation; - - const sojornTopBar({ - super.key, - required this.title, - this.leading, - this.actions, - this.centerTitle = false, - this.backgroundColor, - this.elevation, - }); - - @override - Size get preferredSize => const Size.fromHeight(kToolbarHeight); - - @override - Widget build(BuildContext context) { - return AppBar( - title: title.isEmpty ? null : Text(title), - leading: leading, - actions: actions, - centerTitle: centerTitle, - backgroundColor: backgroundColor, - elevation: elevation, - // The rest of the styling (background, border, titleTextStyle etc.) - // will be automatically applied from AppTheme.appBarTheme. - ); - } -}