**Major Features Added:** - **Inline Reply System**: Replace compose screen with inline reply boxes - **Thread Navigation**: Parent/child navigation with jump functionality - **Chain Flow UI**: Reply counts, expand/collapse animations, visual hierarchy - **Enhanced Animations**: Smooth transitions, hover effects, micro-interactions **Frontend Changes:** - **ThreadedCommentWidget**: Complete rewrite with animations and navigation - **ThreadNode Model**: Added parent references and descendant counting - **ThreadedConversationScreen**: Integrated navigation handlers - **PostDetailScreen**: Replaced with threaded conversation view - **ComposeScreen**: Added reply indicators and context - **PostActions**: Fixed visibility checks for chain buttons **Backend Changes:** - **API Route**: Added /posts/:id/thread endpoint - **Post Repository**: Include allow_chain and visibility fields in feed - **Thread Handler**: Support for fetching post chains **UI/UX Improvements:** - **Reply Context**: Clear indication when replying to specific posts - **Character Counting**: 500 character limit with live counter - **Visual Hierarchy**: Depth-based indentation and styling - **Smooth Animations**: SizeTransition, FadeTransition, hover states - **Chain Navigation**: Parent/child buttons with visual feedback **Technical Enhancements:** - **Animation Controllers**: Proper lifecycle management - **State Management**: Clean separation of concerns - **Navigation Callbacks**: Reusable navigation system - **Error Handling**: Graceful fallbacks and user feedback This creates a Reddit-style threaded conversation experience with smooth animations, inline replies, and intuitive navigation between posts in a chain.
1495 lines
42 KiB
Dart
1495 lines
42 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import '../../models/post.dart';
|
|
import '../../models/profile.dart';
|
|
import '../../models/profile_privacy_settings.dart';
|
|
import '../../models/trust_state.dart';
|
|
import '../../models/trust_tier.dart';
|
|
import '../../providers/api_provider.dart';
|
|
import '../../services/auth_service.dart';
|
|
import '../../theme/app_theme.dart';
|
|
import '../../utils/country_flag.dart';
|
|
import '../../widgets/post_item.dart';
|
|
import '../../widgets/media/signed_media_image.dart';
|
|
import '../compose/compose_screen.dart';
|
|
import '../post/post_detail_screen.dart';
|
|
import 'profile_settings_screen.dart';
|
|
|
|
enum ProfileFeedType { posts, appreciated, saved, chained }
|
|
|
|
/// Premium profile screen with NestedScrollView and SliverAppBar
|
|
class ProfileScreen extends ConsumerStatefulWidget {
|
|
const ProfileScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<ProfileScreen> createState() => _ProfileScreenState();
|
|
}
|
|
|
|
String _resolveAvatar(String? url) {
|
|
if (url == null || url.isEmpty) return '';
|
|
if (url.startsWith('http://') || url.startsWith('https://')) return url;
|
|
return 'https://img.gosojorn.com/${url.replaceFirst(RegExp('^/'), '')}';
|
|
}
|
|
|
|
class _ProfileScreenState extends ConsumerState<ProfileScreen>
|
|
with SingleTickerProviderStateMixin {
|
|
static const int _postsPageSize = 20;
|
|
StreamSubscription? _authSubscription;
|
|
|
|
Profile? _profile;
|
|
ProfileStats? _stats;
|
|
ProfilePrivacySettings? _privacySettings;
|
|
bool _isPrivacyLoading = false;
|
|
|
|
late TabController _tabController;
|
|
ProfileFeedType _activeFeed = ProfileFeedType.posts;
|
|
|
|
List<Post> _posts = [];
|
|
bool _isProfileLoading = false;
|
|
String? _profileError;
|
|
bool _isCreatingProfile = false;
|
|
|
|
bool _isPostsLoading = false;
|
|
bool _isPostsLoadingMore = false;
|
|
bool _hasMorePosts = true;
|
|
String? _postsError;
|
|
|
|
List<Post> _appreciatedPosts = [];
|
|
bool _isAppreciatedLoading = false;
|
|
bool _isAppreciatedLoadingMore = false;
|
|
bool _hasMoreAppreciated = true;
|
|
String? _appreciatedError;
|
|
|
|
List<Post> _savedPosts = [];
|
|
bool _isSavedLoading = false;
|
|
bool _isSavedLoadingMore = false;
|
|
bool _hasMoreSaved = true;
|
|
String? _savedError;
|
|
|
|
List<Post> _chainedPosts = [];
|
|
bool _isChainedLoading = false;
|
|
bool _isChainedLoadingMore = false;
|
|
bool _hasMoreChained = true;
|
|
String? _chainedError;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_tabController = TabController(length: 4, vsync: this);
|
|
_tabController.addListener(() {
|
|
if (!_tabController.indexIsChanging) {
|
|
setState(() {
|
|
_activeFeed = ProfileFeedType.values[_tabController.index];
|
|
});
|
|
_loadActiveFeed(refresh: true);
|
|
}
|
|
});
|
|
|
|
_loadProfile();
|
|
|
|
_authSubscription = AuthService.instance.authStateChanges.listen((data) {
|
|
if (data.event == AuthChangeEvent.signedIn ||
|
|
data.event == AuthChangeEvent.tokenRefreshed) {
|
|
_loadProfile();
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_authSubscription?.cancel();
|
|
_tabController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _refreshAll() async {
|
|
await _loadProfile(refreshFeeds: false);
|
|
await _loadActiveFeed(refresh: true);
|
|
}
|
|
|
|
Future<void> _loadProfile({bool refreshFeeds = true}) async {
|
|
setState(() {
|
|
_isProfileLoading = true;
|
|
_profileError = null;
|
|
});
|
|
|
|
try {
|
|
final apiService = ref.read(apiServiceProvider);
|
|
final data = await apiService.getProfile();
|
|
final profile = data['profile'] as Profile;
|
|
final stats = data['stats'] as ProfileStats;
|
|
|
|
if (!mounted) return;
|
|
|
|
setState(() {
|
|
_profile = profile;
|
|
_stats = stats;
|
|
});
|
|
|
|
await _loadPrivacySettings();
|
|
|
|
if (refreshFeeds) {
|
|
await _loadActiveFeed(refresh: true);
|
|
}
|
|
} catch (error) {
|
|
if (!mounted) return;
|
|
|
|
if (_shouldAutoCreateProfile(error)) {
|
|
await _createProfileIfMissing();
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_profileError = error.toString().replaceAll('Exception: ', '');
|
|
});
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isProfileLoading = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _loadPrivacySettings() async {
|
|
if (_isPrivacyLoading) return;
|
|
|
|
setState(() {
|
|
_isPrivacyLoading = true;
|
|
});
|
|
|
|
try {
|
|
final apiService = ref.read(apiServiceProvider);
|
|
final settings = await apiService.getPrivacySettings();
|
|
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_privacySettings = settings;
|
|
});
|
|
} catch (error) {
|
|
if (!mounted) return;
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isPrivacyLoading = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
bool _shouldAutoCreateProfile(dynamic error) {
|
|
final errorStr = error.toString().toLowerCase();
|
|
return errorStr.contains('profile not found') ||
|
|
errorStr.contains('no profile');
|
|
}
|
|
|
|
Future<void> _createProfileIfMissing() async {
|
|
if (_isCreatingProfile) return;
|
|
|
|
setState(() {
|
|
_isCreatingProfile = true;
|
|
_profileError = null;
|
|
});
|
|
|
|
try {
|
|
final apiService = ref.read(apiServiceProvider);
|
|
final user = AuthService.instance.currentUser;
|
|
|
|
if (user == null) {
|
|
throw Exception('No authenticated user');
|
|
}
|
|
|
|
// Generate a default handle from email or user ID
|
|
final defaultHandle =
|
|
user.email?.split('@').first ?? 'user${user.id.substring(0, 8)}';
|
|
final defaultDisplayName = user.email?.split('@').first ?? 'User';
|
|
|
|
await apiService.createProfile(
|
|
handle: defaultHandle,
|
|
displayName: defaultDisplayName,
|
|
);
|
|
|
|
if (!mounted) return;
|
|
await _loadProfile();
|
|
} catch (error) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_profileError =
|
|
'Could not create profile: ${error.toString().replaceAll('Exception: ', '')}';
|
|
});
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isCreatingProfile = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _loadActiveFeed({bool refresh = false}) async {
|
|
switch (_activeFeed) {
|
|
case ProfileFeedType.posts:
|
|
return _loadPosts(refresh: refresh);
|
|
case ProfileFeedType.appreciated:
|
|
return _loadAppreciated(refresh: refresh);
|
|
case ProfileFeedType.saved:
|
|
return _loadSaved(refresh: refresh);
|
|
case ProfileFeedType.chained:
|
|
return _loadChained(refresh: refresh);
|
|
}
|
|
}
|
|
|
|
Future<void> _loadPosts({bool refresh = false}) async {
|
|
if (_profile == null) return;
|
|
|
|
if (refresh) {
|
|
setState(() {
|
|
_posts = [];
|
|
_hasMorePosts = true;
|
|
_postsError = null;
|
|
});
|
|
} else if (!_hasMorePosts || _isPostsLoadingMore) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
if (refresh) {
|
|
_isPostsLoading = true;
|
|
} else {
|
|
_isPostsLoadingMore = true;
|
|
}
|
|
if (!refresh) {
|
|
_postsError = null;
|
|
}
|
|
});
|
|
|
|
try {
|
|
final apiService = ref.read(apiServiceProvider);
|
|
final posts = await apiService.getProfilePosts(
|
|
authorId: _profile!.id,
|
|
limit: _postsPageSize,
|
|
offset: refresh ? 0 : _posts.length,
|
|
);
|
|
|
|
if (!mounted) return;
|
|
|
|
setState(() {
|
|
if (refresh) {
|
|
_posts = posts;
|
|
} else {
|
|
_posts.addAll(posts);
|
|
}
|
|
_hasMorePosts = posts.length == _postsPageSize;
|
|
});
|
|
} catch (error) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_postsError = error.toString().replaceAll('Exception: ', '');
|
|
});
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isPostsLoading = false;
|
|
_isPostsLoadingMore = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _loadAppreciated({bool refresh = false}) async {
|
|
if (_profile == null) return;
|
|
|
|
if (refresh) {
|
|
setState(() {
|
|
_appreciatedPosts = [];
|
|
_hasMoreAppreciated = true;
|
|
_appreciatedError = null;
|
|
});
|
|
} else if (!_hasMoreAppreciated || _isAppreciatedLoadingMore) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
if (refresh) {
|
|
_isAppreciatedLoading = true;
|
|
} else {
|
|
_isAppreciatedLoadingMore = true;
|
|
}
|
|
if (!refresh) {
|
|
_appreciatedError = null;
|
|
}
|
|
});
|
|
|
|
try {
|
|
final apiService = ref.read(apiServiceProvider);
|
|
final posts = await apiService.getAppreciatedPosts(
|
|
userId: _profile!.id,
|
|
limit: _postsPageSize,
|
|
offset: refresh ? 0 : _appreciatedPosts.length,
|
|
);
|
|
|
|
if (!mounted) return;
|
|
|
|
setState(() {
|
|
if (refresh) {
|
|
_appreciatedPosts = posts;
|
|
} else {
|
|
_appreciatedPosts.addAll(posts);
|
|
}
|
|
_hasMoreAppreciated = posts.length == _postsPageSize;
|
|
});
|
|
} catch (error) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_appreciatedError = error.toString().replaceAll('Exception: ', '');
|
|
});
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isAppreciatedLoading = false;
|
|
_isAppreciatedLoadingMore = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _loadSaved({bool refresh = false}) async {
|
|
if (_profile == null) return;
|
|
|
|
if (refresh) {
|
|
setState(() {
|
|
_savedPosts = [];
|
|
_hasMoreSaved = true;
|
|
_savedError = null;
|
|
});
|
|
} else if (!_hasMoreSaved || _isSavedLoadingMore) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
if (refresh) {
|
|
_isSavedLoading = true;
|
|
} else {
|
|
_isSavedLoadingMore = true;
|
|
}
|
|
if (!refresh) {
|
|
_savedError = null;
|
|
}
|
|
});
|
|
|
|
try {
|
|
final apiService = ref.read(apiServiceProvider);
|
|
final posts = await apiService.getSavedPosts(
|
|
userId: _profile!.id,
|
|
limit: _postsPageSize,
|
|
offset: refresh ? 0 : _savedPosts.length,
|
|
);
|
|
|
|
if (!mounted) return;
|
|
|
|
setState(() {
|
|
if (refresh) {
|
|
_savedPosts = posts;
|
|
} else {
|
|
_savedPosts.addAll(posts);
|
|
}
|
|
_hasMoreSaved = posts.length == _postsPageSize;
|
|
});
|
|
} catch (error) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_savedError = error.toString().replaceAll('Exception: ', '');
|
|
});
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isSavedLoading = false;
|
|
_isSavedLoadingMore = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _loadChained({bool refresh = false}) async {
|
|
if (_profile == null) return;
|
|
|
|
if (refresh) {
|
|
setState(() {
|
|
_chainedPosts = [];
|
|
_hasMoreChained = true;
|
|
_chainedError = null;
|
|
});
|
|
} else if (!_hasMoreChained || _isChainedLoadingMore) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
if (refresh) {
|
|
_isChainedLoading = true;
|
|
} else {
|
|
_isChainedLoadingMore = true;
|
|
}
|
|
if (!refresh) {
|
|
_chainedError = null;
|
|
}
|
|
});
|
|
|
|
try {
|
|
final apiService = ref.read(apiServiceProvider);
|
|
final posts = await apiService.getChainedPostsForAuthor(
|
|
authorId: _profile!.id,
|
|
limit: _postsPageSize,
|
|
offset: refresh ? 0 : _chainedPosts.length,
|
|
);
|
|
|
|
if (!mounted) return;
|
|
|
|
setState(() {
|
|
if (refresh) {
|
|
_chainedPosts = posts;
|
|
} else {
|
|
_chainedPosts.addAll(posts);
|
|
}
|
|
_hasMoreChained = posts.length == _postsPageSize;
|
|
});
|
|
} catch (error) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_chainedError = error.toString().replaceAll('Exception: ', '');
|
|
});
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isChainedLoading = false;
|
|
_isChainedLoadingMore = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _openSettings() async {
|
|
final profile = _profile;
|
|
if (profile == null) return;
|
|
|
|
final settings =
|
|
_privacySettings ?? ProfilePrivacySettings.defaults(profile.id);
|
|
|
|
final result = await Navigator.of(context).push<ProfileSettingsResult>(
|
|
MaterialPageRoute(
|
|
builder: (_) => ProfileSettingsScreen(
|
|
profile: profile,
|
|
settings: settings,
|
|
),
|
|
),
|
|
);
|
|
|
|
if (result != null && mounted) {
|
|
setState(() {
|
|
_profile = result.profile;
|
|
_privacySettings = result.settings;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _openPrivacyMenu() async {
|
|
final profile = _profile;
|
|
if (profile == null) return;
|
|
|
|
final apiService = ref.read(apiServiceProvider);
|
|
final currentSettings =
|
|
_privacySettings ?? ProfilePrivacySettings.defaults(profile.id);
|
|
ProfilePrivacySettings draft = currentSettings;
|
|
|
|
final result = await showModalBottomSheet<ProfilePrivacySettings>(
|
|
context: context,
|
|
useSafeArea: true,
|
|
isScrollControlled: true,
|
|
builder: (context) {
|
|
bool isSaving = false;
|
|
bool limitOldPosts = false;
|
|
return StatefulBuilder(
|
|
builder: (context, setModalState) {
|
|
Future<void> handleSave() async {
|
|
if (isSaving) return;
|
|
setModalState(() => isSaving = true);
|
|
try {
|
|
final saved = await apiService.updatePrivacySettings(draft);
|
|
if (limitOldPosts) {
|
|
await apiService
|
|
.updateAllPostVisibility(draft.postsVisibility);
|
|
}
|
|
if (!context.mounted) return;
|
|
Navigator.of(context).pop(saved);
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Privacy settings updated.'),
|
|
),
|
|
);
|
|
}
|
|
} catch (error) {
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
error.toString().replaceAll('Exception: ', ''),
|
|
),
|
|
backgroundColor: AppTheme.error,
|
|
),
|
|
);
|
|
} finally {
|
|
if (context.mounted) {
|
|
setModalState(() => isSaving = false);
|
|
}
|
|
}
|
|
}
|
|
|
|
return Padding(
|
|
padding: EdgeInsets.only(
|
|
left: AppTheme.spacingLg,
|
|
right: AppTheme.spacingLg,
|
|
top: AppTheme.spacingLg,
|
|
bottom: MediaQuery.of(context).viewInsets.bottom +
|
|
AppTheme.spacingLg,
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('Privacy', style: AppTheme.headlineSmall),
|
|
const SizedBox(height: AppTheme.spacingSm),
|
|
Text(
|
|
'Control who can see your profile and posts.',
|
|
style: AppTheme.bodyMedium.copyWith(
|
|
color: AppTheme.navyText.withOpacity(0.7),
|
|
),
|
|
),
|
|
const SizedBox(height: AppTheme.spacingLg),
|
|
_PrivacyDropdown(
|
|
label: 'Profile visibility',
|
|
value: draft.profileVisibility,
|
|
onChanged: (value) {
|
|
setModalState(() {
|
|
draft = draft.copyWith(profileVisibility: value);
|
|
});
|
|
},
|
|
),
|
|
const SizedBox(height: AppTheme.spacingMd),
|
|
_PrivacyDropdown(
|
|
label: 'Posts visibility',
|
|
value: draft.postsVisibility,
|
|
onChanged: (value) {
|
|
setModalState(() {
|
|
draft = draft.copyWith(postsVisibility: value);
|
|
});
|
|
},
|
|
),
|
|
const SizedBox(height: AppTheme.spacingMd),
|
|
CheckboxListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
value: limitOldPosts,
|
|
title: const Text('Limit old posts'),
|
|
subtitle: const Text(
|
|
'Apply this posts privacy setting to all existing posts.',
|
|
),
|
|
onChanged: (value) {
|
|
setModalState(() => limitOldPosts = value ?? false);
|
|
},
|
|
),
|
|
const SizedBox(height: AppTheme.spacingLg),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton(
|
|
onPressed: isSaving ? null : handleSave,
|
|
child: isSaving
|
|
? const SizedBox(
|
|
height: 18,
|
|
width: 18,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
valueColor: AlwaysStoppedAnimation<Color>(
|
|
AppTheme.white,
|
|
),
|
|
),
|
|
)
|
|
: const Text('Save'),
|
|
),
|
|
),
|
|
const SizedBox(height: AppTheme.spacingSm),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
|
|
if (result != null && mounted) {
|
|
setState(() {
|
|
_privacySettings = result;
|
|
});
|
|
}
|
|
}
|
|
|
|
void _showAvatarActions() {
|
|
final profile = _profile;
|
|
if (profile == null) return;
|
|
|
|
showModalBottomSheet(
|
|
context: context,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (context) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(AppTheme.spacingLg),
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).cardColor,
|
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ListTile(
|
|
leading: const Icon(Icons.visibility),
|
|
title: const Text('View profile photo'),
|
|
onTap: () {
|
|
Navigator.of(context).pop();
|
|
_showAvatarPreview(profile);
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.photo_camera),
|
|
title: const Text('Change profile photo'),
|
|
onTap: () {
|
|
Navigator.of(context).pop();
|
|
_openSettings();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
void _showAvatarPreview(Profile profile) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) {
|
|
final avatarUrl = _resolveAvatar(profile.avatarUrl);
|
|
return Dialog(
|
|
backgroundColor: Colors.transparent,
|
|
insetPadding: const EdgeInsets.all(AppTheme.spacingLg),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(AppTheme.spacingLg),
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).cardColor,
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
'Profile Photo',
|
|
style: AppTheme.headlineSmall,
|
|
),
|
|
const SizedBox(height: AppTheme.spacingLg),
|
|
CircleAvatar(
|
|
radius: 72,
|
|
backgroundColor: AppTheme.queenPink,
|
|
child: avatarUrl != null && avatarUrl.isNotEmpty
|
|
? ClipOval(
|
|
child: SizedBox(
|
|
width: 144,
|
|
height: 144,
|
|
child: SignedMediaImage(
|
|
url: avatarUrl,
|
|
width: 144,
|
|
height: 144,
|
|
fit: BoxFit.cover,
|
|
),
|
|
),
|
|
)
|
|
: Text(
|
|
profile.displayName.isNotEmpty
|
|
? profile.displayName[0].toUpperCase()
|
|
: '?',
|
|
style: AppTheme.headlineMedium.copyWith(
|
|
color: AppTheme.royalPurple,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: AppTheme.spacingLg),
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: const Text('Close'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
void _openPostDetail(Post post) {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (_) => PostDetailScreen(post: post),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _openChainComposer(Post post) {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (_) => ComposeScreen(chainParentPost: post),
|
|
fullscreenDialog: true,
|
|
),
|
|
);
|
|
}
|
|
|
|
List<Post> _getPostsFor(ProfileFeedType type) {
|
|
switch (type) {
|
|
case ProfileFeedType.posts:
|
|
return _posts;
|
|
case ProfileFeedType.appreciated:
|
|
return _appreciatedPosts;
|
|
case ProfileFeedType.saved:
|
|
return _savedPosts;
|
|
case ProfileFeedType.chained:
|
|
return _chainedPosts;
|
|
}
|
|
}
|
|
|
|
bool _isLoadingFor(ProfileFeedType type) {
|
|
switch (type) {
|
|
case ProfileFeedType.posts:
|
|
return _isPostsLoading;
|
|
case ProfileFeedType.appreciated:
|
|
return _isAppreciatedLoading;
|
|
case ProfileFeedType.saved:
|
|
return _isSavedLoading;
|
|
case ProfileFeedType.chained:
|
|
return _isChainedLoading;
|
|
}
|
|
}
|
|
|
|
bool _isLoadingMoreFor(ProfileFeedType type) {
|
|
switch (type) {
|
|
case ProfileFeedType.posts:
|
|
return _isPostsLoadingMore;
|
|
case ProfileFeedType.appreciated:
|
|
return _isAppreciatedLoadingMore;
|
|
case ProfileFeedType.saved:
|
|
return _isSavedLoadingMore;
|
|
case ProfileFeedType.chained:
|
|
return _isChainedLoadingMore;
|
|
}
|
|
}
|
|
|
|
bool _hasMoreFor(ProfileFeedType type) {
|
|
switch (type) {
|
|
case ProfileFeedType.posts:
|
|
return _hasMorePosts;
|
|
case ProfileFeedType.appreciated:
|
|
return _hasMoreAppreciated;
|
|
case ProfileFeedType.saved:
|
|
return _hasMoreSaved;
|
|
case ProfileFeedType.chained:
|
|
return _hasMoreChained;
|
|
}
|
|
}
|
|
|
|
String? _errorFor(ProfileFeedType type) {
|
|
switch (type) {
|
|
case ProfileFeedType.posts:
|
|
return _postsError;
|
|
case ProfileFeedType.appreciated:
|
|
return _appreciatedError;
|
|
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'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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: 'Appreciated'),
|
|
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;
|
|
|
|
const _ProfileHeader({
|
|
required this.profile,
|
|
required this.stats,
|
|
required this.onSettingsTap,
|
|
required this.onPrivacyTap,
|
|
required this.onAvatarTap,
|
|
});
|
|
|
|
@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()),
|
|
const SizedBox(width: AppTheme.spacingMd),
|
|
_StatItem(label: 'Following', value: stats.following.toString()),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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;
|
|
|
|
const _StatItem({
|
|
required this.label,
|
|
required this.value,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return 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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// ==============================================================================
|
|
// HARMONY AVATAR WITH RING
|
|
// ==============================================================================
|
|
|
|
class _HarmonyAvatar extends StatelessWidget {
|
|
final Profile profile;
|
|
final double radius;
|
|
|
|
const _HarmonyAvatar({
|
|
required this.profile,
|
|
required this.radius,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final trustState = profile.trustState;
|
|
final avatarLetter =
|
|
profile.handle.isNotEmpty ? profile.handle[0].toUpperCase() : '?';
|
|
|
|
Color ringColor = AppTheme.egyptianBlue;
|
|
double ringWidth = 3;
|
|
|
|
if (trustState != null) {
|
|
final harmonyScore = trustState.harmonyScore / 100.0;
|
|
|
|
if (harmonyScore >= 0.8) {
|
|
ringColor = const Color(0xFFFFD700); // Gold
|
|
ringWidth = 5;
|
|
} else if (harmonyScore >= 0.5) {
|
|
ringColor = AppTheme.royalPurple;
|
|
ringWidth = 4;
|
|
} else if (harmonyScore >= 0.3) {
|
|
ringColor = AppTheme.egyptianBlue;
|
|
ringWidth = 3;
|
|
} else {
|
|
ringColor = Colors.grey;
|
|
ringWidth = 2;
|
|
}
|
|
}
|
|
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(radius * 0.45),
|
|
border: Border.all(
|
|
color: ringWidth >= 4 ? ringColor : ringColor.withOpacity(0.8),
|
|
width: ringWidth,
|
|
),
|
|
boxShadow: [
|
|
if (ringWidth >= 4)
|
|
BoxShadow(
|
|
color: ringColor.withOpacity(0.5),
|
|
blurRadius: 12,
|
|
spreadRadius: 2,
|
|
),
|
|
],
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(radius * 0.4),
|
|
child: SizedBox(
|
|
width: radius * 2,
|
|
height: radius * 2,
|
|
child: _resolveAvatar(profile.avatarUrl) != null &&
|
|
_resolveAvatar(profile.avatarUrl)!.isNotEmpty
|
|
? SignedMediaImage(
|
|
url: _resolveAvatar(profile.avatarUrl)!,
|
|
fit: BoxFit.cover,
|
|
)
|
|
: Container(
|
|
color: AppTheme.queenPink,
|
|
alignment: Alignment.center,
|
|
child: Text(
|
|
avatarLetter,
|
|
style: AppTheme.headlineMedium.copyWith(
|
|
fontSize: radius * 0.6,
|
|
color: AppTheme.royalPurple,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ==============================================================================
|
|
// HARMONY BADGE
|
|
// ==============================================================================
|
|
|
|
class _HarmonyBadge extends StatelessWidget {
|
|
final TrustState trustState;
|
|
|
|
const _HarmonyBadge({
|
|
required this.trustState,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final tier = trustState.tier;
|
|
Color badgeColor;
|
|
Color textColor;
|
|
|
|
switch (tier) {
|
|
case TrustTier.established:
|
|
badgeColor = const Color(0xFFFFD700); // Gold
|
|
break;
|
|
case TrustTier.trusted:
|
|
badgeColor = AppTheme.royalPurple;
|
|
break;
|
|
case TrustTier.new_user:
|
|
badgeColor = AppTheme.egyptianBlue;
|
|
break;
|
|
}
|
|
textColor = tier == TrustTier.new_user ? AppTheme.white : badgeColor;
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: AppTheme.spacingMd,
|
|
vertical: AppTheme.spacingXs,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: badgeColor.withOpacity(0.2),
|
|
border: Border.all(color: badgeColor, width: 1.5),
|
|
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
_getIconForTier(tier),
|
|
size: 14,
|
|
color: badgeColor,
|
|
),
|
|
const SizedBox(width: AppTheme.spacingXs),
|
|
Text(
|
|
tier.displayName,
|
|
style: AppTheme.labelSmall.copyWith(
|
|
color: textColor,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
IconData _getIconForTier(TrustTier tier) {
|
|
switch (tier) {
|
|
case TrustTier.established:
|
|
return Icons.verified;
|
|
case TrustTier.trusted:
|
|
return Icons.check_circle;
|
|
case TrustTier.new_user:
|
|
return Icons.fiber_new;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ==============================================================================
|
|
// HEADER ACTION BUTTON
|
|
// ==============================================================================
|
|
|
|
class _HeaderActionButton extends StatelessWidget {
|
|
final IconData icon;
|
|
final String label;
|
|
final VoidCallback onPressed;
|
|
|
|
const _HeaderActionButton({
|
|
required this.icon,
|
|
required this.label,
|
|
required this.onPressed,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return OutlinedButton.icon(
|
|
onPressed: onPressed,
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: AppTheme.white,
|
|
side: BorderSide(color: AppTheme.white.withOpacity(0.7)),
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: AppTheme.spacingMd,
|
|
vertical: AppTheme.spacingSm,
|
|
),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
|
),
|
|
),
|
|
icon: Icon(icon, size: 18),
|
|
label: Text(
|
|
label,
|
|
style: AppTheme.labelMedium.copyWith(color: AppTheme.white),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ==============================================================================
|
|
// PRIVACY DROPDOWN
|
|
// ==============================================================================
|
|
|
|
class _PrivacyDropdown extends StatelessWidget {
|
|
final String label;
|
|
final String value;
|
|
final ValueChanged<String> onChanged;
|
|
|
|
const _PrivacyDropdown({
|
|
required this.label,
|
|
required this.value,
|
|
required this.onChanged,
|
|
});
|
|
|
|
static const Map<String, String> _options = {
|
|
'public': 'Public',
|
|
'followers': 'Followers only',
|
|
'private': 'Private',
|
|
};
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return DropdownButtonFormField<String>(
|
|
value: value,
|
|
decoration: InputDecoration(labelText: label),
|
|
isExpanded: true,
|
|
items: _options.entries
|
|
.map((e) => DropdownMenuItem(value: e.key, child: Text(e.value)))
|
|
.toList(),
|
|
onChanged: (v) => v == null ? null : onChanged(v),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ==============================================================================
|
|
// SLIVER TAB BAR DELEGATE
|
|
// ==============================================================================
|
|
|
|
class _SliverTabBarDelegate extends SliverPersistentHeaderDelegate {
|
|
final TabBar tabBar;
|
|
|
|
_SliverTabBarDelegate(this.tabBar);
|
|
|
|
@override
|
|
double get minExtent => tabBar.preferredSize.height;
|
|
|
|
@override
|
|
double get maxExtent => tabBar.preferredSize.height;
|
|
|
|
@override
|
|
Widget build(
|
|
BuildContext context,
|
|
double shrinkOffset,
|
|
bool overlapsContent,
|
|
) {
|
|
return Container(
|
|
color: AppTheme.cardSurface,
|
|
child: tabBar,
|
|
);
|
|
}
|
|
|
|
@override
|
|
bool shouldRebuild(_SliverTabBarDelegate oldDelegate) {
|
|
return false;
|
|
}
|
|
}
|