mirror of
https://github.com/Dvorinka/1356.git
synced 2026-06-04 12:02:56 +00:00
637 lines
19 KiB
Dart
637 lines
19 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:cached_network_image/cached_network_image.dart';
|
|
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
|
|
import '../../../core/widgets/app_scaffold.dart';
|
|
import '../../../core/widgets/loading_indicator.dart';
|
|
import '../../../core/utils/date_time_utils.dart';
|
|
import '../../../core/utils/unit_conversion_utils.dart';
|
|
import '../../../data/models/user_model.dart' as app;
|
|
import '../../../data/models/goal_model.dart';
|
|
import '../../auth/application/auth_controller.dart';
|
|
import '../application/social_controller.dart';
|
|
import '../../profile/application/profile_controller.dart';
|
|
|
|
class PublicProfileScreen extends ConsumerStatefulWidget {
|
|
final String userId;
|
|
|
|
const PublicProfileScreen({
|
|
super.key,
|
|
required this.userId,
|
|
});
|
|
|
|
@override
|
|
ConsumerState<PublicProfileScreen> createState() => _PublicProfileScreenState();
|
|
}
|
|
|
|
class _PublicProfileScreenState extends ConsumerState<PublicProfileScreen> {
|
|
Map<String, dynamic>? _userStats;
|
|
List<Goal>? _userGoals;
|
|
bool _isLoadingStats = false;
|
|
bool _isLoadingGoals = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_loadProfile();
|
|
_checkFollowingStatus();
|
|
_loadUserStats();
|
|
_loadUserGoals();
|
|
});
|
|
}
|
|
|
|
Future<void> _loadProfile() async {
|
|
await ref.read(profileControllerProvider.notifier).loadProfile(widget.userId);
|
|
}
|
|
|
|
Future<void> _checkFollowingStatus() async {
|
|
await ref.read(socialControllerProvider.notifier).isFollowing(widget.userId);
|
|
}
|
|
|
|
Future<void> _loadUserStats() async {
|
|
setState(() => _isLoadingStats = true);
|
|
try {
|
|
final client = supabase.Supabase.instance.client;
|
|
final response = await client.rpc('get_user_stats', params: {'user_uuid': widget.userId});
|
|
setState(() {
|
|
_userStats = response as Map<String, dynamic>;
|
|
_isLoadingStats = false;
|
|
});
|
|
} catch (e) {
|
|
setState(() => _isLoadingStats = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _loadUserGoals() async {
|
|
setState(() => _isLoadingGoals = true);
|
|
try {
|
|
final client = supabase.Supabase.instance.client;
|
|
final response = await client.rpc('get_public_user_goals', params: {
|
|
'user_uuid': widget.userId,
|
|
'limit_count': 10
|
|
});
|
|
setState(() {
|
|
_userGoals = (response as List).map((json) => Goal.fromJson(json)).toList();
|
|
_isLoadingGoals = false;
|
|
});
|
|
} catch (e) {
|
|
setState(() => _isLoadingGoals = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _toggleFollow() async {
|
|
final controller = ref.read(socialControllerProvider.notifier);
|
|
final isFollowing = await controller.isFollowing(widget.userId);
|
|
|
|
if (isFollowing) {
|
|
await controller.unfollowUser(widget.userId);
|
|
} else {
|
|
await controller.followUser(widget.userId);
|
|
}
|
|
setState(() {});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final profileState = ref.watch(profileControllerProvider);
|
|
final authController = ref.watch(authControllerProvider);
|
|
final isOwnProfile = authController?.id == widget.userId;
|
|
|
|
return AppScaffold(
|
|
title: 'Profile',
|
|
body: _buildBody(profileState, isOwnProfile),
|
|
);
|
|
}
|
|
|
|
Widget _buildBody(ProfileState state, bool isOwnProfile) {
|
|
if (state.isLoading) {
|
|
return const Center(child: LoadingIndicator());
|
|
}
|
|
|
|
if (state.errorMessage != null) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const Icon(Icons.error_outline, size: 48, color: Colors.red),
|
|
const SizedBox(height: 16),
|
|
Text('Error: ${state.errorMessage}'),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
final user = state.user;
|
|
if (user == null) {
|
|
return const Center(child: Text('User not found'));
|
|
}
|
|
|
|
return RefreshIndicator(
|
|
onRefresh: () async {
|
|
await _loadProfile();
|
|
await _checkFollowingStatus();
|
|
await _loadUserStats();
|
|
await _loadUserGoals();
|
|
},
|
|
child: CustomScrollView(
|
|
slivers: [
|
|
SliverToBoxAdapter(
|
|
child: _ProfileHeader(
|
|
user: user,
|
|
isOwnProfile: isOwnProfile,
|
|
onToggleFollow: isOwnProfile ? () {} : _toggleFollow,
|
|
),
|
|
),
|
|
SliverToBoxAdapter(
|
|
child: _EnhancedStatsSection(
|
|
user: user,
|
|
userStats: _userStats,
|
|
isLoadingStats: _isLoadingStats,
|
|
),
|
|
),
|
|
if (_userGoals != null && _userGoals!.isNotEmpty)
|
|
SliverToBoxAdapter(
|
|
child: _UserGoalsSection(
|
|
goals: _userGoals!,
|
|
isLoadingGoals: _isLoadingGoals,
|
|
),
|
|
),
|
|
if (user.age != null || user.formattedHeight.isNotEmpty || user.formattedWeight.isNotEmpty)
|
|
SliverToBoxAdapter(
|
|
child: _BiometricSection(user: user),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _EnhancedStatsSection extends StatelessWidget {
|
|
final app.User user;
|
|
final Map<String, dynamic>? userStats;
|
|
final bool isLoadingStats;
|
|
|
|
const _EnhancedStatsSection({
|
|
required this.user,
|
|
this.userStats,
|
|
required this.isLoadingStats,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Profile Stats',
|
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
if (isLoadingStats)
|
|
const Center(child: CircularProgressIndicator())
|
|
else
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _StatCard(
|
|
icon: Icons.flag,
|
|
title: 'Goals',
|
|
value: '${userStats?['goals_count'] ?? 0}',
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: _StatCard(
|
|
icon: Icons.check_circle,
|
|
title: 'Completed',
|
|
value: '${userStats?['completed_goals_count'] ?? 0}',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _StatCard(
|
|
icon: Icons.people,
|
|
title: 'Followers',
|
|
value: '${userStats?['followers_count'] ?? 0}',
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: _StatCard(
|
|
icon: Icons.person_add,
|
|
title: 'Following',
|
|
value: '${userStats?['following_count'] ?? 0}',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
if (user.countdownStartDate != null) ...[
|
|
_StatCard(
|
|
icon: Icons.timer,
|
|
title: 'Challenge Started',
|
|
value: DateTimeUtils.formatShortDate(user.countdownStartDate!),
|
|
),
|
|
const SizedBox(height: 12),
|
|
if (user.daysRemaining != null)
|
|
_StatCard(
|
|
icon: Icons.hourglass_empty,
|
|
title: 'Days Remaining',
|
|
value: '${user.daysRemaining} days',
|
|
),
|
|
const SizedBox(height: 12),
|
|
],
|
|
_StatCard(
|
|
icon: Icons.calendar_today,
|
|
title: 'Member Since',
|
|
value: DateTimeUtils.formatShortDate(user.createdAt),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _UserGoalsSection extends StatelessWidget {
|
|
final List<Goal> goals;
|
|
final bool isLoadingGoals;
|
|
|
|
const _UserGoalsSection({
|
|
required this.goals,
|
|
required this.isLoadingGoals,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Public Goals',
|
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
if (isLoadingGoals)
|
|
const Center(child: CircularProgressIndicator())
|
|
else
|
|
...goals.map((goal) => Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: _GoalCard(goal: goal),
|
|
)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _BiometricSection extends StatelessWidget {
|
|
final app.User user;
|
|
|
|
const _BiometricSection({required this.user});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Biometric Information',
|
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
children: [
|
|
if (user.gender != null)
|
|
Expanded(
|
|
child: _BiometricCard(
|
|
icon: user.gender!.emoji,
|
|
title: 'Gender',
|
|
value: user.gender!.displayName,
|
|
),
|
|
),
|
|
if (user.age != null)
|
|
Expanded(
|
|
child: _BiometricCard(
|
|
icon: '🎂',
|
|
title: 'Age',
|
|
value: '${user.age} years',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (user.gender != null && user.age != null)
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
if (user.formattedHeight.isNotEmpty)
|
|
Expanded(
|
|
child: _BiometricCard(
|
|
icon: '📏',
|
|
title: 'Height',
|
|
value: user.formattedHeight,
|
|
),
|
|
),
|
|
if (user.formattedWeight.isNotEmpty)
|
|
Expanded(
|
|
child: _BiometricCard(
|
|
icon: '⚖️',
|
|
title: 'Weight',
|
|
value: user.formattedWeight,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (user.bmi != null)
|
|
Column(
|
|
children: [
|
|
const SizedBox(height: 12),
|
|
_BiometricCard(
|
|
icon: '💪',
|
|
title: 'BMI',
|
|
value: '${user.bmi!.toStringAsFixed(1)} - ${user.bmiCategory}',
|
|
valueColor: UnitConversionUtils.getBmiColor(user.bmi!),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _BiometricCard extends StatelessWidget {
|
|
final String icon;
|
|
final String title;
|
|
final String value;
|
|
final Color? valueColor;
|
|
|
|
const _BiometricCard({
|
|
required this.icon,
|
|
required this.title,
|
|
required this.value,
|
|
this.valueColor,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
title,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Row(
|
|
children: [
|
|
Text(
|
|
icon,
|
|
style: const TextStyle(fontSize: 20),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
value,
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
color: valueColor ?? null,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _GoalCard extends StatelessWidget {
|
|
final Goal goal;
|
|
|
|
const _GoalCard({required this.goal});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Card(
|
|
child: ListTile(
|
|
leading: CircleAvatar(
|
|
backgroundColor: goal.completed
|
|
? Theme.of(context).colorScheme.primaryContainer
|
|
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
child: Icon(
|
|
goal.completed ? Icons.check : Icons.flag_outlined,
|
|
color: goal.completed
|
|
? Theme.of(context).colorScheme.onPrimaryContainer
|
|
: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
title: Text(
|
|
goal.title,
|
|
style: TextStyle(
|
|
decoration: goal.completed ? TextDecoration.lineThrough : null,
|
|
),
|
|
),
|
|
subtitle: goal.description != null && goal.description!.isNotEmpty
|
|
? Text(
|
|
goal.description!,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
)
|
|
: null,
|
|
trailing: goal.progress > 0
|
|
? Text('${goal.progress}%')
|
|
: null,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ProfileHeader extends ConsumerWidget {
|
|
final app.User user;
|
|
final bool isOwnProfile;
|
|
final VoidCallback onToggleFollow;
|
|
|
|
const _ProfileHeader({
|
|
required this.user,
|
|
required this.isOwnProfile,
|
|
required this.onToggleFollow,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
children: [
|
|
CircleAvatar(
|
|
radius: 50,
|
|
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
|
backgroundImage: user.avatarUrl != null
|
|
? CachedNetworkImageProvider(user.avatarUrl!)
|
|
: null,
|
|
child: user.avatarUrl == null
|
|
? Text(
|
|
user.username.substring(0, 2).toUpperCase(),
|
|
style: TextStyle(
|
|
fontSize: 24,
|
|
fontWeight: FontWeight.bold,
|
|
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
|
),
|
|
)
|
|
: null,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
user.username,
|
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
if (user.bio != null && user.bio!.isNotEmpty)
|
|
Text(
|
|
user.bio!,
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 8),
|
|
if (user.isPublicProfile &&
|
|
((user.twitterHandle != null && user.twitterHandle!.isNotEmpty) ||
|
|
(user.instagramHandle != null && user.instagramHandle!.isNotEmpty) ||
|
|
(user.tiktokHandle != null && user.tiktokHandle!.isNotEmpty) ||
|
|
(user.websiteUrl != null && user.websiteUrl!.isNotEmpty)))
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 4,
|
|
alignment: WrapAlignment.center,
|
|
children: [
|
|
if (user.twitterHandle != null && user.twitterHandle!.isNotEmpty)
|
|
Chip(
|
|
avatar: const Icon(Icons.alternate_email, size: 16),
|
|
label: Text(user.twitterHandle!),
|
|
),
|
|
if (user.instagramHandle != null && user.instagramHandle!.isNotEmpty)
|
|
Chip(
|
|
avatar: const Icon(Icons.camera_alt_outlined, size: 16),
|
|
label: Text(user.instagramHandle!),
|
|
),
|
|
if (user.tiktokHandle != null && user.tiktokHandle!.isNotEmpty)
|
|
Chip(
|
|
avatar: const Icon(Icons.music_note_outlined, size: 16),
|
|
label: Text(user.tiktokHandle!),
|
|
),
|
|
if (user.websiteUrl != null && user.websiteUrl!.isNotEmpty)
|
|
Chip(
|
|
avatar: const Icon(Icons.link, size: 16),
|
|
label: Text(user.websiteUrl!),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
if (!isOwnProfile)
|
|
_FollowButton(onToggleFollow: onToggleFollow),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _FollowButton extends StatelessWidget {
|
|
final VoidCallback onToggleFollow;
|
|
|
|
const _FollowButton({required this.onToggleFollow});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ElevatedButton.icon(
|
|
onPressed: onToggleFollow,
|
|
icon: const Icon(Icons.person_add),
|
|
label: const Text('Follow'),
|
|
style: ElevatedButton.styleFrom(
|
|
minimumSize: const Size(120, 40),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StatCard extends StatelessWidget {
|
|
final IconData icon;
|
|
final String title;
|
|
final String value;
|
|
|
|
const _StatCard({
|
|
required this.icon,
|
|
required this.title,
|
|
required this.value,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
icon,
|
|
color: Theme.of(context).colorScheme.primary,
|
|
size: 32,
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
title,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
value,
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|