small fix, don't worry about it

This commit is contained in:
Tomas Dvorak
2026-04-10 12:05:40 +02:00
parent 7b7ed0083f
commit 5ab2773f98
55 changed files with 3240 additions and 483 deletions
@@ -49,7 +49,7 @@ class AchievementsState {
}
class AchievementsController extends StateNotifier<AchievementsState> {
final AchievementsRepository _repository;
final AchievementsRepository? _repository;
final AuthController _authController;
AchievementsController(
@@ -60,14 +60,16 @@ class AchievementsController extends StateNotifier<AchievementsState> {
}
Future<void> _loadAchievements() async {
if (_repository == null) return;
final userId = _authController.currentUserId;
if (userId == null) return;
state = state.copyWith(isLoading: true);
try {
final available = await _repository.getAvailableAchievements();
final unlocked = await _repository.getUserAchievements(userId);
final available = await _repository!.getAvailableAchievements();
final unlocked = await _repository!.getUserAchievements(userId);
state = state.copyWith(
isLoading: false,
@@ -86,11 +88,13 @@ class AchievementsController extends StateNotifier<AchievementsState> {
AchievementType type,
int currentValue,
) async {
if (_repository == null) return null;
final userId = _authController.currentUserId;
if (userId == null) return null;
try {
final newlyUnlocked = await _repository.checkAndUnlockAchievement(
final newlyUnlocked = await _repository!.checkAndUnlockAchievement(
userId,
type,
currentValue,
@@ -135,6 +139,9 @@ final achievementsControllerProvider =
);
});
final achievementsRepositoryProvider = Provider<AchievementsRepository>((ref) {
return AchievementsRepository(supabaseClient);
final achievementsRepositoryProvider = Provider<AchievementsRepository?>((ref) {
final client = supabaseClient;
if (client == null) return null;
return AchievementsRepository(client);
});
@@ -27,7 +27,7 @@ class AIChatState {
this.isRecording = false,
this.error,
this.currentTranscription,
this.privacyModeEnabled = true,
this.privacyModeEnabled = false,
});
AIChatState copyWith({
@@ -153,6 +153,13 @@ class AIChatController extends StateNotifier<AIChatState> {
buffer.writeln(
'User privacy mode is DISABLED. Use the following personal context to personalise your coaching:');
buffer.writeln('Username: ${user.username}.');
if (user.heightCm != null) {
buffer.writeln('Height: ${user.heightCm!.toStringAsFixed(1)} cm.');
}
if (user.weightKg != null) {
buffer.writeln('Weight: ${user.weightKg!.toStringAsFixed(1)} kg.');
}
if (countdownSummary != null) {
buffer.writeln(countdownSummary);
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import '../application/ai_chat_controller.dart';
class AIChatScreen extends ConsumerStatefulWidget {
@@ -64,7 +65,7 @@ class _AIChatScreenState extends ConsumerState<AIChatScreen> {
Expanded(
child: state.messages.isEmpty
? _buildEmptyState(context)
: _buildMessagesList(state.messages),
: _buildMessagesList(state.messages, state.isLoading),
),
// Error message
@@ -87,7 +88,7 @@ class _AIChatScreenState extends ConsumerState<AIChatScreen> {
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha:0.7),
.withValues(alpha: 0.7),
),
),
),
@@ -128,7 +129,7 @@ class _AIChatScreenState extends ConsumerState<AIChatScreen> {
Text(
'Ask for goal inspiration, motivation, or life advice',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha:0.7),
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
),
textAlign: TextAlign.center,
),
@@ -174,12 +175,16 @@ class _AIChatScreenState extends ConsumerState<AIChatScreen> {
);
}
Widget _buildMessagesList(List messages) {
Widget _buildMessagesList(List messages, bool isLoading) {
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(16),
itemCount: messages.length,
itemCount: messages.length + (isLoading ? 1 : 0),
itemBuilder: (context, index) {
if (index == messages.length && isLoading) {
return _buildLoadingIndicator();
}
final message = messages[index];
final isUser = message.role == 'user';
@@ -218,14 +223,29 @@ class _AIChatScreenState extends ConsumerState<AIChatScreen> {
: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Text(
message.content,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: isUser
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
child: isUser
? Text(
message.content,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onPrimary,
),
)
: MarkdownBody(
data: message.content,
styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)).copyWith(
p: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
code: Theme.of(context).textTheme.bodySmall?.copyWith(
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
codeblockDecoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(8),
),
),
),
),
),
if (isUser) ...[
@@ -245,6 +265,59 @@ class _AIChatScreenState extends ConsumerState<AIChatScreen> {
);
}
Widget _buildLoadingIndicator() {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
CircleAvatar(
radius: 16,
backgroundColor: Theme.of(context).colorScheme.primary,
child: Icon(
Icons.psychology,
size: 20,
color: Theme.of(context).colorScheme.onPrimary,
),
),
const SizedBox(width: 8),
Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.75,
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: 8),
Text(
'AI is thinking...',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
);
}
Widget _buildErrorMessage(String error, controller) {
return Container(
margin: const EdgeInsets.all(16),
@@ -317,7 +390,7 @@ class _AIChatScreenState extends ConsumerState<AIChatScreen> {
color: Theme.of(context).colorScheme.surface,
boxShadow: [
BoxShadow(
color: Theme.of(context).shadowColor.withValues(alpha:0.1),
color: Theme.of(context).shadowColor.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, -2),
),
@@ -362,8 +435,8 @@ class _AIChatScreenState extends ConsumerState<AIChatScreen> {
decoration: BoxDecoration(
shape: BoxShape.circle,
color: state.isRecording
? Theme.of(context).colorScheme.error.withValues(alpha:0.12)
: Theme.of(context).colorScheme.primary.withValues(alpha:0.08),
? Theme.of(context).colorScheme.error.withValues(alpha: 0.12)
: Theme.of(context).colorScheme.primary.withValues(alpha: 0.08),
),
child: IconButton(
onPressed: state.isRecording
@@ -429,7 +502,7 @@ class _AIChatScreenState extends ConsumerState<AIChatScreen> {
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha:state.isLoading ||
.withValues(alpha: state.isLoading ||
_textController.text.trim().isEmpty
? 0.06
: 0.12),
@@ -271,9 +271,9 @@ final insightsControllerProvider =
});
final goalsRepositoryProvider = Provider<GoalsRepository>((ref) {
return GoalsRepository(supabaseClient);
return GoalsRepository(supabaseClient ?? (throw Exception("Supabase not initialized")));
});
final countdownRepositoryProvider = Provider<CountdownRepository>((ref) {
return CountdownRepository(supabaseClient);
return CountdownRepository(supabaseClient ?? (throw Exception("Supabase not initialized")));
});
@@ -1,18 +1,23 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../data/repositories/auth_repository.dart';
import '../../../data/models/user_model.dart';
import '../../../data/services/biometric_service.dart';
import '../../../core/services/analytics_service.dart';
import '../../../bootstrap/supabase_client.dart';
import '../../../core/utils/unit_conversion_utils.dart';
import 'package:local_auth/local_auth.dart' as local_auth;
final authControllerProvider = StateNotifierProvider<AuthController, User?>((ref) {
return AuthController(ref.read(authRepositoryProvider));
});
final authRepositoryProvider = Provider<AuthRepository>((ref) {
return AuthRepository();
return AuthRepository(supabaseClient);
});
class AuthController extends StateNotifier<User?> {
final AuthRepository _authRepository;
final BiometricService _biometricService = BiometricService();
final AnalyticsService _analytics = AnalyticsService();
AuthController(this._authRepository) : super(null) {
@@ -46,8 +51,8 @@ class AuthController extends StateNotifier<User?> {
_analytics.logSignIn(method: 'email');
}
Future<void> signUpWithEmail(String email, String password, String username) async {
await _authRepository.signUpWithEmail(email, password, username);
Future<void> signUpWithEmail(String email, String password, String username, {double? heightCm, double? weightKg, int? age, Gender? gender, HeightUnit? heightUnit, WeightUnit? weightUnit}) async {
await _authRepository.signUpWithEmail(email, password, username, heightCm: heightCm, weightKg: weightKg, age: age, gender: gender, heightUnit: heightUnit, weightUnit: weightUnit);
_analytics.logSignUp(method: 'email');
}
@@ -77,6 +82,12 @@ class AuthController extends StateNotifier<User?> {
String? bio,
String? avatarUrl,
bool? isPublicProfile,
double? heightCm,
double? weightKg,
int? age,
Gender? gender,
HeightUnit? heightUnit,
WeightUnit? weightUnit,
}) async {
final updatedFields = <String>[];
if (username != null) updatedFields.add('username');
@@ -86,12 +97,24 @@ class AuthController extends StateNotifier<User?> {
updatedFields.add('visibility');
_analytics.logProfileVisibilityChanged(isPublic: isPublicProfile);
}
if (heightCm != null) updatedFields.add('height');
if (weightKg != null) updatedFields.add('weight');
if (age != null) updatedFields.add('age');
if (gender != null) updatedFields.add('gender');
if (heightUnit != null) updatedFields.add('height_unit');
if (weightUnit != null) updatedFields.add('weight_unit');
await _authRepository.updateProfile(
username: username,
bio: bio,
avatarUrl: avatarUrl,
isPublicProfile: isPublicProfile,
heightCm: heightCm,
weightKg: weightKg,
age: age,
gender: gender,
heightUnit: heightUnit,
weightUnit: weightUnit,
);
if (updatedFields.isNotEmpty) {
@@ -99,6 +122,98 @@ class AuthController extends StateNotifier<User?> {
}
}
// Biometric Authentication Methods
/// Check if biometric authentication is available
Future<BiometricAvailability> checkBiometricAvailability() async {
return await _biometricService.checkAvailability();
}
/// Check if biometric login is enabled
Future<bool> isBiometricEnabled() async {
return await _biometricService.isBiometricEnabled();
}
/// Enable biometric login for current user
Future<bool> enableBiometric() async {
final userId = currentUserId;
if (userId == null) return false;
final success = await _biometricService.enableBiometric(userId);
if (success) {
_analytics.logEvent('biometric_enabled');
}
return success;
}
/// Disable biometric login
Future<bool> disableBiometric() async {
final success = await _biometricService.disableBiometric();
if (success) {
_analytics.logEvent('biometric_disabled');
}
return success;
}
/// Authenticate with biometrics and sign in
Future<bool> signInWithBiometric() async {
try {
// Check if biometric is enabled
final isEnabled = await _biometricService.isBiometricEnabled();
if (!isEnabled) {
return false;
}
// Get the stored user ID
final biometricUserId = await _biometricService.getBiometricUserId();
if (biometricUserId == null) {
return false;
}
// Authenticate with biometrics
final authenticated = await _biometricService.authenticate(
reason: 'Sign in to your 1356 day challenge',
localizedReason: 'Use your biometric to quickly access your challenge',
);
if (authenticated) {
// Try to restore session for the stored user
await _authRepository.refreshSession();
// Verify the current user matches the stored biometric user
final currentUser = _authRepository.currentUser;
final currentUserId = currentUser?.id;
if (currentUserId == biometricUserId) {
_analytics.logSignIn(method: 'biometric');
return true;
} else {
// User mismatch, disable biometric
await disableBiometric();
return false;
}
}
return false;
} catch (e) {
return false;
}
}
/// Get biometric status message
Future<String> getBiometricStatusMessage() async {
return await _biometricService.getBiometricStatusMessage();
}
/// Get available biometric types
Future<List<local_auth.BiometricType>> getAvailableBiometrics() async {
return await _biometricService.getAvailableBiometrics();
}
/// Get primary biometric type
Future<local_auth.BiometricType?> getPrimaryBiometricType() async {
return await _biometricService.getPrimaryBiometricType();
}
@override
void dispose() {
_authRepository.dispose();
@@ -2,18 +2,38 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../application/auth_controller.dart';
import '../../onboarding/application/onboarding_controller.dart';
import '../../profile/application/profile_controller.dart';
import 'auth_showcase_screen.dart';
import '../../onboarding/presentation/onboarding_intro_screen.dart';
import '../../profile/presentation/profile_setup_screen.dart';
import '../../countdown/presentation/home_countdown_screen.dart';
import '../../../bootstrap/supabase_client.dart';
class AuthGate extends ConsumerWidget {
class AuthGate extends ConsumerStatefulWidget {
const AuthGate({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<AuthGate> createState() => _AuthGateState();
}
class _AuthGateState extends ConsumerState<AuthGate> {
bool _isCheckingProfile = false;
bool _profileSetupComplete = false;
@override
Widget build(BuildContext context) {
final authState = ref.watch(authControllerProvider);
final onboardingState = ref.watch(onboardingControllerProvider);
// If no backend is configured and there is no overridden auth state,
// keep the app usable by continuing through the local onboarding flow.
if (supabaseClient == null && authState == null) {
if (!onboardingState) {
return const OnboardingIntroScreen();
}
return const HomeCountdownScreen();
}
if (authState == null) {
return const AuthShowcaseScreen();
}
@@ -23,7 +43,29 @@ class AuthGate extends ConsumerWidget {
return const OnboardingIntroScreen();
}
// User is authenticated and has completed onboarding
// Check if profile setup is complete
if (!_isCheckingProfile && !_profileSetupComplete) {
_isCheckingProfile = true;
_checkProfileSetup(authState.id);
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
// If profile setup is not complete, show profile setup screen
if (!_profileSetupComplete) {
return const ProfileSetupScreen();
}
// User is authenticated and has completed onboarding and profile setup
return const HomeCountdownScreen();
}
Future<void> _checkProfileSetup(String userId) async {
final isComplete = await ref.read(profileControllerProvider.notifier).isProfileSetupComplete(userId);
if (mounted) {
setState(() {
_profileSetupComplete = isComplete;
_isCheckingProfile = false;
});
}
}
}
@@ -3,10 +3,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../application/auth_controller.dart';
import '../../../data/services/biometric_service.dart';
import '../../../core/widgets/app_scaffold.dart';
import '../../../core/widgets/primary_button.dart';
import '../../../core/utils/validators.dart';
import '../application/auth_controller.dart';
class SignInScreen extends ConsumerStatefulWidget {
const SignInScreen({super.key});
@@ -19,8 +21,86 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final BiometricService _biometricService = BiometricService();
bool _isLoading = false;
bool _obscurePassword = true;
bool _isBiometricAvailable = false;
bool _isBiometricEnabled = false;
bool _isBiometricLoading = false;
bool _showEmailVerificationMessage = false;
@override
void initState() {
super.initState();
_checkBiometricStatus();
_checkIfComingFromRegistration();
}
void _checkIfComingFromRegistration() async {
// Check if user navigated from registration by checking shared preferences
final prefs = await SharedPreferences.getInstance();
final justRegistered = prefs.getBool('just_registered') ?? false;
final registrationTime = prefs.getInt('registration_time') ?? 0;
if (mounted) {
// Show message if registration happened in the last 5 minutes
final now = DateTime.now().millisecondsSinceEpoch;
final fiveMinutesAgo = now - (5 * 60 * 1000);
if (justRegistered && registrationTime > fiveMinutesAgo) {
setState(() {
_showEmailVerificationMessage = true;
});
// Clear the flag so it doesn't show again
await prefs.remove('just_registered');
await prefs.remove('registration_time');
}
}
}
Future<void> _checkBiometricStatus() async {
try {
final availability = await _biometricService.checkAvailability();
final isEnabled = await _biometricService.isBiometricEnabled();
if (mounted) {
setState(() {
_isBiometricAvailable = availability == BiometricAvailability.available;
_isBiometricEnabled = isEnabled;
});
}
} catch (e) {
// Biometric not available, ignore
}
}
Future<void> _handleBiometricSignIn() async {
setState(() => _isBiometricLoading = true);
try {
final authController = ref.read(authControllerProvider.notifier);
final success = await authController.signInWithBiometric();
if (success) {
// Navigation will be handled by AuthGate
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Biometric login failed')),
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Biometric login error: ${e.toString()}')),
);
}
} finally {
if (mounted) {
setState(() => _isBiometricLoading = false);
}
}
}
Future<void> _handleSignIn() async {
if (!_formKey.currentState!.validate()) return;
@@ -172,6 +252,45 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
.withOpacity(0.7),
),
),
const SizedBox(height: 16),
// Email verification reminder message
if (_showEmailVerificationMessage)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.withOpacity(0.3)),
),
child: Row(
children: [
Icon(Icons.info_outline, color: Colors.blue.shade700, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
'Please verify your email before signing in. Check your inbox for the verification link.',
style: TextStyle(
color: Colors.blue.shade700,
fontSize: 12,
),
),
),
IconButton(
icon: const Icon(Icons.close, size: 16),
onPressed: () {
setState(() {
_showEmailVerificationMessage = false;
});
},
color: Colors.blue.shade700,
),
],
),
),
if (_showEmailVerificationMessage)
const SizedBox(height: 16),
const SizedBox(height: 24),
Semantics(
label: 'Email address field',
@@ -240,6 +359,39 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
),
),
const SizedBox(height: 24),
// Biometric Login Button
if (_isBiometricAvailable && _isBiometricEnabled)
Container(
width: double.infinity,
height: 48,
child: ElevatedButton.icon(
onPressed: _isBiometricLoading ? null : _handleBiometricSignIn,
icon: _isBiometricLoading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.fingerprint),
label: Text(_isBiometricLoading ? 'Authenticating...' : 'Sign in with Biometric'),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.surface,
foregroundColor: Theme.of(context).colorScheme.onSurface,
elevation: 0,
side: BorderSide(
color: Theme.of(context).colorScheme.outline,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
if (_isBiometricAvailable && _isBiometricEnabled)
const SizedBox(height: 12),
PrimaryButton(
onPressed: _handleSignIn,
text: _isLoading ? 'Signing in...' : 'Sign In',
@@ -3,10 +3,13 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../core/widgets/app_scaffold.dart';
import '../../../core/widgets/primary_button.dart';
import '../../../core/widgets/unit_input_field.dart';
import '../../../core/utils/validators.dart';
import '../application/auth_controller.dart';
import '../../../core/utils/unit_conversion_utils.dart';
class SignUpScreen extends ConsumerStatefulWidget {
const SignUpScreen({super.key});
@@ -21,20 +24,57 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
final _usernameController = TextEditingController();
final _ageController = TextEditingController();
Gender? _selectedGender;
bool _isLoading = false;
bool _obscurePassword = true;
bool _obscureConfirmPassword = true;
bool _showAdditionalInfo = false;
double? _heightCm;
double? _weightKg;
HeightUnit _selectedHeightUnit = HeightUnit.metric;
WeightUnit _selectedWeightUnit = WeightUnit.metric;
Future<void> _handleSignUp() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
final age = _ageController.text.trim().isNotEmpty
? int.tryParse(_ageController.text.trim())
: null;
await ref.read(authControllerProvider.notifier).signUpWithEmail(
_emailController.text.trim(),
_passwordController.text,
_usernameController.text.trim(),
heightCm: _heightCm,
weightKg: _weightKg,
age: age,
gender: _selectedGender,
heightUnit: _selectedHeightUnit,
weightUnit: _selectedWeightUnit,
);
// Show success message and navigate to login screen
if (mounted) {
// Mark that registration just happened for the login screen
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('just_registered', true);
await prefs.setInt('registration_time', DateTime.now().millisecondsSinceEpoch);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Account created successfully! Please check your email and verify it before signing in.'),
backgroundColor: Colors.green,
duration: Duration(seconds: 4),
),
);
// Navigate to login screen after successful registration
context.pushReplacement('/sign-in');
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -54,6 +94,7 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
_passwordController.dispose();
_confirmPasswordController.dispose();
_usernameController.dispose();
_ageController.dispose();
super.dispose();
}
@@ -234,6 +275,121 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
enabled: !_isLoading,
onFieldSubmitted: (_) => _handleSignUp(),
),
const SizedBox(height: 24),
// Additional optional info section
GestureDetector(
onTap: () {
setState(() {
_showAdditionalInfo = !_showAdditionalInfo;
});
},
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
),
),
child: Row(
children: [
Icon(
_showAdditionalInfo
? Icons.expand_less
: Icons.expand_more,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Add more info for better recommendations',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
fontWeight: FontWeight.w500,
),
),
),
],
),
),
),
if (_showAdditionalInfo) ...[
const SizedBox(height: 16),
UnitInputField(
labelText: 'Height',
prefixIcon: Icons.height_outlined,
helperText: 'Optional: For personalized recommendations',
enabled: !_isLoading,
onValueChanged: (value) {
setState(() {
_heightCm = value;
});
},
onUnitChanged: (unit) {
setState(() {
_selectedHeightUnit = unit as HeightUnit;
});
},
isHeight: true,
),
const SizedBox(height: 16),
UnitInputField(
labelText: 'Weight',
prefixIcon: Icons.monitor_weight_outlined,
helperText: 'Optional: For personalized recommendations',
enabled: !_isLoading,
onValueChanged: (value) {
setState(() {
_weightKg = value;
});
},
onUnitChanged: (unit) {
setState(() {
_selectedWeightUnit = unit as WeightUnit;
});
},
isHeight: false,
),
const SizedBox(height: 16),
TextFormField(
controller: _ageController,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
labelText: 'Age',
prefixIcon: Icon(Icons.cake_outlined),
helperText: 'Optional: For age-appropriate recommendations',
),
enabled: !_isLoading,
),
const SizedBox(height: 16),
DropdownButtonFormField<Gender>(
value: _selectedGender,
decoration: const InputDecoration(
labelText: 'Gender',
prefixIcon: Icon(Icons.person_outline),
helperText: 'Optional: For personalized recommendations',
),
items: const [
DropdownMenuItem(
value: Gender.male,
child: Text('Male'),
),
DropdownMenuItem(
value: Gender.female,
child: Text('Female'),
),
],
onChanged: _isLoading ? null : (Gender? value) {
setState(() {
_selectedGender = value;
});
},
),
],
const SizedBox(height: 24),
PrimaryButton(
onPressed: _handleSignUp,
@@ -93,7 +93,7 @@ class CalendarController extends StateNotifier<CalendarState> {
}
final calendarRepositoryProvider = Provider<CalendarRepository>((ref) {
return CalendarRepository(supabaseClient);
return CalendarRepository(supabaseClient ?? (throw Exception("Supabase not initialized")));
});
final calendarControllerProvider =
@@ -522,6 +522,7 @@ Future<void> _showAddCalendarEntrySheet(
],
ElevatedButton(
onPressed: () async {
final navigator = Navigator.of(sheetContext);
await ref
.read(calendarControllerProvider.notifier)
.addEntry(
@@ -529,8 +530,8 @@ Future<void> _showAddCalendarEntrySheet(
note: noteController.text,
goalId: selectedGoalId,
);
if (Navigator.of(sheetContext).canPop()) {
Navigator.of(sheetContext).pop();
if (navigator.canPop()) {
navigator.pop();
}
},
child: const Text('Save to calendar'),
@@ -149,7 +149,7 @@ class CountdownLoaded extends CountdownState {
}
final countdownRepositoryProvider = Provider<CountdownRepository>((ref) {
return CountdownRepository(supabaseClient);
return CountdownRepository(supabaseClient ?? (throw Exception("Supabase not initialized")));
});
final countdownControllerProvider = StateNotifierProvider<CountdownController, CountdownState>((ref) {
@@ -28,32 +28,35 @@ class _HomeCountdownScreenState extends ConsumerState<HomeCountdownScreen> {
? achievementsState.level
: null;
final child = countdownState.isLoading
? const Center(child: LoadingIndicator())
: countdownState.error != null
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Error: ${countdownState.error}'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => context.go('/'),
child: const Text('Retry'),
),
],
),
)
: countdownState.user == null || !countdownState.user!.hasCountdownStarted
? _CountdownNotStartedScreen()
: _CountdownActiveScreen(
user: countdownState.user!,
level: level,
);
return AppScaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.only(top: 30.0, bottom: 30.0),
child: countdownState.isLoading
? const Center(child: LoadingIndicator())
: countdownState.error != null
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Error: ${countdownState.error}'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => context.go('/'),
child: const Text('Retry'),
),
],
),
)
: countdownState.user == null || !countdownState.user!.hasCountdownStarted
? _CountdownNotStartedScreen()
: _CountdownActiveScreen(
user: countdownState.user!,
level: level,
),
child: child,
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => context.push('/ai-chat'),
@@ -154,7 +154,11 @@ class GoalsState {
}
final goalsRepositoryProvider = Provider<GoalsRepository>((ref) {
return GoalsRepository(supabaseClient);
final client = supabaseClient;
if (client == null) {
throw Exception('Supabase not initialized - goals repository unavailable');
}
return GoalsRepository(client);
});
final goalsControllerProvider = StateNotifierProvider<GoalsController, GoalsState>((ref) {
@@ -7,10 +7,10 @@ class OnboardingController extends StateNotifier<bool> {
static const String _onboardingKey = 'onboarding_completed';
OnboardingController() : super(false) {
_loadOnboardingStatus();
loadOnboardingStatus();
}
Future<void> _loadOnboardingStatus() async {
Future<void> loadOnboardingStatus() async {
try {
final box = await Hive.openBox('app_settings');
final completed = box.get(_onboardingKey, defaultValue: false);
@@ -22,7 +22,7 @@ class OnboardingHowItWorksScreen extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Progress Bar and Navigation
_OnboardingProgress(currentStep: 2, totalSteps: 3),
const _OnboardingProgress(currentStep: 2, totalSteps: 3),
const SizedBox(height: 24),
Text(
'How It Works',
@@ -16,13 +16,14 @@ class OnboardingIntroScreen extends ConsumerWidget {
return AppScaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.only(top: 30.0, left: 24.0, right: 24.0, bottom: 30.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.only(top: 20.0, left: 24.0, right: 24.0, bottom: 20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Progress Bar and Navigation
_OnboardingProgress(currentStep: 1, totalSteps: 3),
const _OnboardingProgress(currentStep: 1, totalSteps: 3),
const SizedBox(height: 48),
const Icon(
Icons.timer_outlined,
@@ -64,7 +65,7 @@ class OnboardingIntroScreen extends ConsumerWidget {
title: 'Track Progress',
description: 'Watch yourself grow day by day',
),
const Spacer(),
const SizedBox(height: 48),
Row(
children: [
Expanded(
@@ -95,6 +96,7 @@ class OnboardingIntroScreen extends ConsumerWidget {
],
),
),
),
),
);
}
@@ -19,82 +19,69 @@ class OnboardingMotivationScreen extends ConsumerWidget {
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.only(top: 30.0, left: 24.0, right: 24.0, bottom: 30.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Progress Bar and Navigation
_OnboardingProgress(currentStep: 3, totalSteps: 3),
const SizedBox(height: 24),
const Icon(
Icons.psychology_outlined,
size: 80,
color: Colors.amber,
),
const SizedBox(height: 24),
Text(
'Your Time is Now',
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.bold,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Progress Bar and Navigation
const _OnboardingProgress(currentStep: 3, totalSteps: 3),
const SizedBox(height: 24),
const Icon(
Icons.psychology_outlined,
size: 80,
color: Colors.amber,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
'1356 days is approximately 3 years and 8 months.\n\n'
'That\'s enough time to transform your life, learn new skills, '
'build meaningful relationships, and achieve your biggest dreams.\n\n'
'Every day counts. Every step matters. Your journey begins now.',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
height: 1.5,
const SizedBox(height: 24),
Text(
'Your Time is Now',
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
const _MotivationCard(
icon: Icons.trending_up,
title: 'Track Progress',
description: 'Watch yourself grow as you complete goals and milestones.',
),
const SizedBox(height: 16),
const _MotivationCard(
icon: Icons.people,
title: 'Join Community',
description: 'Connect with others on similar journeys (optional).',
),
const SizedBox(height: 16),
const _MotivationCard(
icon: Icons.celebration,
title: 'Celebrate Wins',
description: 'Every achievement is worth celebrating.',
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextButton(
onPressed: () => context.pop(),
child: const Text('Back'),
),
const SizedBox(height: 16),
Text(
'1356 days is approximately 3 years and 8 months.\n\n'
'That\'s enough time to transform your life, learn new skills, '
'build meaningful relationships, and achieve your biggest dreams.\n\n'
'Every day counts. Every step matters. Your journey begins now.',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
height: 1.5,
),
const SizedBox(width: 16),
Expanded(
flex: 2,
child: PrimaryButton(
onPressed: () async {
controller.completeStep('motivation');
await controller.completeOnboarding();
if (context.mounted) {
context.push('/profile/create');
}
},
text: 'Get Started',
),
),
],
),
const SizedBox(height: 16),
],
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
const _MotivationCard(
icon: Icons.trending_up,
title: 'Track Progress',
description: 'Watch yourself grow as you complete goals and milestones.',
),
const SizedBox(height: 16),
const _MotivationCard(
icon: Icons.people,
title: 'Join Community',
description: 'Connect with others on similar journeys (optional).',
),
const SizedBox(height: 16),
const _MotivationCard(
icon: Icons.celebration,
title: 'Celebrate Wins',
description: 'Every achievement is worth celebrating.',
),
const SizedBox(height: 24),
PrimaryButton(
onPressed: () async {
controller.completeStep('motivation');
await controller.completeOnboarding();
if (context.mounted) {
context.push('/profile/create');
}
},
text: 'Get Started',
),
const SizedBox(height: 16),
],
),
),
),
),
@@ -1,24 +1,30 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
import '../../../data/models/user_model.dart' as app;
import '../../../data/repositories/user_repository.dart';
import '../../../bootstrap/supabase_client.dart';
import '../../../core/errors/failure.dart';
import '../../../core/utils/unit_conversion_utils.dart';
final profileControllerProvider = StateNotifierProvider<ProfileController, ProfileState>((ref) {
final client = supabase.Supabase.instance.client;
final repository = UserRepository(client);
final client = supabaseClient;
final repository = client != null ? UserRepository(client) : null;
return ProfileController(repository);
});
class ProfileController extends StateNotifier<ProfileState> {
final UserRepository _repository;
final UserRepository? _repository;
ProfileController(this._repository) : super(const ProfileState.initial());
Future<void> loadProfile(String userId) async {
if (_repository == null) {
state = const ProfileState.error('Profile is unavailable while offline.');
return;
}
state = const ProfileState.loading();
try {
final user = await _repository.getProfile(userId);
final user = await _repository!.getProfile(userId);
state = ProfileState.loaded(user);
} on Failure catch (failure) {
state = ProfileState.error(failure.message);
@@ -28,15 +34,20 @@ class ProfileController extends StateNotifier<ProfileState> {
}
Future<void> updateUsername(String userId, String username) async {
if (_repository == null) {
state = const ProfileState.error('Profile is unavailable while offline.');
return;
}
state = const ProfileState.loading();
try {
final isAvailable = await _repository.isUsernameAvailable(username);
final isAvailable = await _repository!.isUsernameAvailable(username);
if (!isAvailable) {
state = const ProfileState.error('Username is already taken');
return;
}
final updatedUser = await _repository.updateProfile(
final updatedUser = await _repository!.updateProfile(
userId: userId,
username: username,
);
@@ -49,9 +60,14 @@ class ProfileController extends StateNotifier<ProfileState> {
}
Future<void> updateBio(String userId, String bio) async {
if (_repository == null) {
state = const ProfileState.error('Profile is unavailable while offline.');
return;
}
state = const ProfileState.loading();
try {
final updatedUser = await _repository.updateProfile(
final updatedUser = await _repository!.updateProfile(
userId: userId,
bio: bio,
);
@@ -64,9 +80,14 @@ class ProfileController extends StateNotifier<ProfileState> {
}
Future<void> updateAvatarUrl(String userId, String avatarUrl) async {
if (_repository == null) {
state = const ProfileState.error('Profile is unavailable while offline.');
return;
}
state = const ProfileState.loading();
try {
final updatedUser = await _repository.updateProfile(
final updatedUser = await _repository!.updateProfile(
userId: userId,
avatarUrl: avatarUrl,
);
@@ -79,12 +100,17 @@ class ProfileController extends StateNotifier<ProfileState> {
}
Future<void> toggleProfileVisibility(String userId) async {
if (_repository == null) {
state = const ProfileState.error('Profile is unavailable while offline.');
return;
}
final currentState = state;
if (currentState.user == null) return;
state = const ProfileState.loading();
try {
final updatedUser = await _repository.updateProfile(
final updatedUser = await _repository!.updateProfile(
userId: userId,
isPublicProfile: !currentState.user!.isPublicProfile,
);
@@ -105,16 +131,27 @@ class ProfileController extends StateNotifier<ProfileState> {
String? instagramHandle,
String? tiktokHandle,
String? websiteUrl,
Gender? gender,
DateTime? birthDate,
double? heightCm,
double? weightKg,
HeightUnit heightUnit = HeightUnit.metric,
WeightUnit weightUnit = WeightUnit.metric,
}) async {
if (_repository == null) {
state = const ProfileState.error('Profile is unavailable while offline.');
return;
}
state = const ProfileState.loading();
try {
final isAvailable = await _repository.isUsernameAvailable(username);
final isAvailable = await _repository!.isUsernameAvailable(username);
if (!isAvailable) {
state = const ProfileState.error('Username is already taken');
return;
}
final updatedUser = await _repository.updateProfile(
final updatedUser = await _repository!.updateProfile(
userId: userId,
username: username,
bio: bio,
@@ -123,6 +160,12 @@ class ProfileController extends StateNotifier<ProfileState> {
instagramHandle: instagramHandle,
tiktokHandle: tiktokHandle,
websiteUrl: websiteUrl,
gender: gender,
birthDate: birthDate,
heightCm: heightCm,
weightKg: weightKg,
heightUnit: heightUnit,
weightUnit: weightUnit,
);
state = ProfileState.loaded(updatedUser);
} on Failure catch (failure) {
@@ -131,6 +174,19 @@ class ProfileController extends StateNotifier<ProfileState> {
state = ProfileState.error(e.toString());
}
}
Future<bool> isProfileSetupComplete(String userId) async {
if (_repository == null) {
return false;
}
try {
final user = await _repository!.getProfile(userId);
return user.username.isNotEmpty;
} catch (e) {
return false;
}
}
}
class ProfileState {
@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
import '../../../bootstrap/supabase_client.dart';
import '../../../core/widgets/app_scaffold.dart';
import '../../../core/widgets/loading_indicator.dart';
import '../../../core/widgets/empty_state.dart';
@@ -23,7 +23,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
@override
void initState() {
super.initState();
final userId = supabase.Supabase.instance.client.auth.currentUser?.id;
final userId = currentSupabaseUserId;
if (userId != null) {
ref.read(profileControllerProvider.notifier).loadProfile(userId);
}
@@ -33,10 +33,11 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
Widget build(BuildContext context) {
final profileState = ref.watch(profileControllerProvider);
final achievementsState = ref.watch(achievementsControllerProvider);
final userId = supabase.Supabase.instance.client.auth.currentUser?.id;
final userId = currentSupabaseUserId;
if (userId == null) {
return AppScaffold(
title: 'Profile',
body: Semantics(
label: 'Not signed in',
child: const EmptyState(
@@ -105,6 +106,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
SliverAppBar(
expandedHeight: 200,
pinned: true,
title: const Text('Profile'),
flexibleSpace: FlexibleSpaceBar(
background: Container(
decoration: BoxDecoration(
@@ -408,7 +410,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
);
if (confirmed == true && context.mounted) {
await supabase.Supabase.instance.client.auth.signOut();
await signOutCurrentSupabaseUser();
if (context.mounted) {
context.go('/');
}
@@ -8,6 +8,7 @@ import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
import '../../../core/widgets/app_scaffold.dart';
import '../../../core/widgets/primary_button.dart';
import '../../../core/utils/validators.dart';
import '../../../core/utils/unit_conversion_utils.dart';
import '../application/profile_controller.dart';
class ProfileSetupScreen extends ConsumerStatefulWidget {
@@ -25,6 +26,8 @@ class _ProfileSetupScreenState extends ConsumerState<ProfileSetupScreen> {
final _instagramController = TextEditingController();
final _tiktokController = TextEditingController();
final _websiteController = TextEditingController();
final _heightController = TextEditingController();
final _weightController = TextEditingController();
dynamic _avatarFile;
String? _avatarUrl;
@@ -32,6 +35,11 @@ class _ProfileSetupScreenState extends ConsumerState<ProfileSetupScreen> {
bool _isCheckingUsername = false;
bool _isUsernameAvailable = true;
String? _usernameError;
Gender? _selectedGender;
DateTime? _selectedBirthDate;
HeightUnit _selectedHeightUnit = HeightUnit.metric;
WeightUnit _selectedWeightUnit = WeightUnit.metric;
final ImagePicker _imagePicker = ImagePicker();
@@ -152,6 +160,18 @@ class _ProfileSetupScreenState extends ConsumerState<ProfileSetupScreen> {
uploadedAvatarUrl = await _uploadAvatar();
}
// Parse height and weight values
double? heightCm;
double? weightKg;
if (_heightController.text.isNotEmpty) {
heightCm = UnitConversionUtils.parseHeight(_heightController.text, _selectedHeightUnit);
}
if (_weightController.text.isNotEmpty) {
weightKg = UnitConversionUtils.parseWeight(_weightController.text, _selectedWeightUnit);
}
await ref.read(profileControllerProvider.notifier).completeProfileSetup(
userId: userId,
username: _usernameController.text.trim(),
@@ -169,6 +189,12 @@ class _ProfileSetupScreenState extends ConsumerState<ProfileSetupScreen> {
websiteUrl: _websiteController.text.trim().isEmpty
? null
: _websiteController.text.trim(),
gender: _selectedGender,
birthDate: _selectedBirthDate,
heightCm: heightCm,
weightKg: weightKg,
heightUnit: _selectedHeightUnit,
weightUnit: _selectedWeightUnit,
);
if (mounted) {
@@ -195,6 +221,8 @@ class _ProfileSetupScreenState extends ConsumerState<ProfileSetupScreen> {
_instagramController.dispose();
_tiktokController.dispose();
_websiteController.dispose();
_heightController.dispose();
_weightController.dispose();
super.dispose();
}
@@ -341,6 +369,170 @@ class _ProfileSetupScreenState extends ConsumerState<ProfileSetupScreen> {
),
enabled: !_isLoading,
),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 16),
Text(
'Biometric Information (Optional)',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 16),
// Gender Field
DropdownButtonFormField<Gender>(
initialValue: _selectedGender,
decoration: const InputDecoration(
labelText: 'Gender',
prefixIcon: Icon(Icons.person_outline),
border: OutlineInputBorder(),
),
items: Gender.values.map((gender) {
return DropdownMenuItem(
value: gender,
child: Row(
children: [
Text(gender.emoji),
const SizedBox(width: 8),
Text(gender.displayName),
],
),
);
}).toList(),
onChanged: !_isLoading ? (Gender? value) {
setState(() {
_selectedGender = value;
});
} : null,
),
const SizedBox(height: 16),
// Birth Date Field
InkWell(
onTap: !_isLoading ? () async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _selectedBirthDate ?? DateTime.now().subtract(const Duration(days: 365 * 25)),
firstDate: DateTime.now().subtract(const Duration(days: 365 * 120)),
lastDate: DateTime.now().subtract(const Duration(days: 365 * 13)),
);
if (picked != null) {
setState(() {
_selectedBirthDate = picked;
});
}
} : null,
child: InputDecorator(
decoration: const InputDecoration(
labelText: 'Birth Date',
prefixIcon: Icon(Icons.cake_outlined),
border: OutlineInputBorder(),
),
child: Text(
_selectedBirthDate != null
? '${_selectedBirthDate!.day}/${_selectedBirthDate!.month}/${_selectedBirthDate!.year}'
: 'Select your birth date',
style: TextStyle(
color: _selectedBirthDate != null
? null
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
),
),
const SizedBox(height: 16),
// Height and Weight Row
Row(
children: [
Expanded(
child: TextFormField(
controller: _heightController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: 'Height (${_selectedHeightUnit == HeightUnit.metric ? 'cm' : 'ft/in'})',
prefixIcon: const Icon(Icons.height_outlined),
border: const OutlineInputBorder(),
suffixIcon: PopupMenuButton<HeightUnit>(
icon: const Icon(Icons.tune),
onSelected: (HeightUnit unit) {
setState(() {
_selectedHeightUnit = unit;
// Convert existing value if needed
if (_heightController.text.isNotEmpty) {
final currentValue = double.tryParse(_heightController.text);
if (currentValue != null) {
double convertedValue;
if (unit == HeightUnit.imperial && _selectedHeightUnit == HeightUnit.metric) {
convertedValue = UnitConversionUtils.cmToInches(currentValue);
_heightController.text = convertedValue.toStringAsFixed(1);
} else if (unit == HeightUnit.metric && _selectedHeightUnit == HeightUnit.imperial) {
convertedValue = UnitConversionUtils.inchesToCm(currentValue);
_heightController.text = convertedValue.toStringAsFixed(1);
}
}
}
});
},
itemBuilder: (context) => [
const PopupMenuItem(value: HeightUnit.metric, child: Text('Metric (cm)')),
const PopupMenuItem(value: HeightUnit.imperial, child: Text('Imperial (ft/in)')),
],
),
),
enabled: !_isLoading,
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _weightController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: 'Weight (${_selectedWeightUnit == WeightUnit.metric ? 'kg' : 'lbs'})',
prefixIcon: const Icon(Icons.monitor_weight_outlined),
border: const OutlineInputBorder(),
suffixIcon: PopupMenuButton<WeightUnit>(
icon: const Icon(Icons.tune),
onSelected: (WeightUnit unit) {
setState(() {
_selectedWeightUnit = unit;
// Convert existing value if needed
if (_weightController.text.isNotEmpty) {
final currentValue = double.tryParse(_weightController.text);
if (currentValue != null) {
double convertedValue;
if (unit == WeightUnit.imperial && _selectedWeightUnit == WeightUnit.metric) {
convertedValue = UnitConversionUtils.kgToLbs(currentValue);
_weightController.text = convertedValue.toStringAsFixed(1);
} else if (unit == WeightUnit.metric && _selectedWeightUnit == WeightUnit.imperial) {
convertedValue = UnitConversionUtils.lbsToKg(currentValue);
_weightController.text = convertedValue.toStringAsFixed(1);
}
}
}
});
},
itemBuilder: (context) => [
const PopupMenuItem(value: WeightUnit.metric, child: Text('Metric (kg)')),
const PopupMenuItem(value: WeightUnit.imperial, child: Text('Imperial (lbs)')),
],
),
),
enabled: !_isLoading,
),
),
],
),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 16),
Text(
'Social Links (Optional)',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 16),
TextFormField(
controller: _twitterController,
@@ -5,7 +5,11 @@ import '../../../bootstrap/supabase_client.dart';
import '../../auth/application/auth_controller.dart';
final userRepositoryProvider = Provider<UserRepository>((ref) {
return UserRepository(supabaseClient);
final client = supabaseClient;
if (client == null) {
throw Exception('Supabase not initialized - user repository unavailable');
}
return UserRepository(client);
});
final notificationsRepositoryProvider = Provider<NotificationsRepository>((ref) {
@@ -202,13 +202,14 @@ class AboutChallengeScreen extends StatelessWidget {
Future<void> _openLink(BuildContext context, String url) async {
final uri = Uri.parse(url);
final scaffoldMessenger = ScaffoldMessenger.of(context);
final launched = await launchUrl(
uri,
mode: LaunchMode.externalApplication,
);
if (!launched) {
ScaffoldMessenger.of(context).showSnackBar(
scaffoldMessenger.showSnackBar(
const SnackBar(content: Text('Could not open link')),
);
}
@@ -0,0 +1,347 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/widgets/app_scaffold.dart';
import '../../../data/services/biometric_service.dart';
import '../../auth/application/auth_controller.dart';
import 'package:local_auth/local_auth.dart' as local_auth;
class BiometricSettingsScreen extends ConsumerStatefulWidget {
const BiometricSettingsScreen({super.key});
@override
ConsumerState<BiometricSettingsScreen> createState() => _BiometricSettingsScreenState();
}
class _BiometricSettingsScreenState extends ConsumerState<BiometricSettingsScreen> {
final BiometricService _biometricService = BiometricService();
bool _isLoading = false;
BiometricAvailability? _availability;
bool _isEnabled = false;
String _statusMessage = '';
List<local_auth.BiometricType> _availableBiometrics = [];
local_auth.BiometricType? _primaryBiometricType;
@override
void initState() {
super.initState();
_loadBiometricStatus();
}
Future<void> _loadBiometricStatus() async {
setState(() => _isLoading = true);
try {
final availability = await _biometricService.checkAvailability();
final isEnabled = await _biometricService.isBiometricEnabled();
final statusMessage = await _biometricService.getBiometricStatusMessage();
final availableBiometrics = await _biometricService.getAvailableBiometrics();
final primaryBiometricType = await _biometricService.getPrimaryBiometricType();
setState(() {
_availability = availability;
_isEnabled = isEnabled;
_statusMessage = statusMessage;
_availableBiometrics = availableBiometrics;
_primaryBiometricType = primaryBiometricType;
_isLoading = false;
});
} catch (e) {
setState(() => _isLoading = false);
}
}
Future<void> _toggleBiometric() async {
if (_availability != BiometricAvailability.available) {
return;
}
setState(() => _isLoading = true);
try {
final authController = ref.read(authControllerProvider.notifier);
if (_isEnabled) {
// Disable biometric
final success = await authController.disableBiometric();
if (success) {
setState(() => _isEnabled = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Biometric login disabled')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to disable biometric login')),
);
}
} else {
// Enable biometric
final success = await authController.enableBiometric();
if (success) {
setState(() => _isEnabled = true);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Biometric login enabled successfully')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to enable biometric login')),
);
}
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: ${e.toString()}')),
);
} finally {
setState(() => _isLoading = false);
}
}
Future<void> _testBiometric() async {
if (!_isEnabled) return;
setState(() => _isLoading = true);
try {
final authController = ref.read(authControllerProvider.notifier);
final success = await authController.signInWithBiometric();
if (success) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Biometric login successful')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Biometric login failed')),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: ${e.toString()}')),
);
} finally {
setState(() => _isLoading = false);
}
}
String _getBiometricTypeEmoji(local_auth.BiometricType type) {
switch (type) {
case local_auth.BiometricType.fingerprint:
return '👆';
case local_auth.BiometricType.face:
return '👤';
case local_auth.BiometricType.iris:
return '👁️';
default:
return '🔒';
}
}
String _getBiometricTypeName(local_auth.BiometricType type) {
switch (type) {
case local_auth.BiometricType.fingerprint:
return 'Fingerprint';
case local_auth.BiometricType.face:
return 'Face ID';
case local_auth.BiometricType.iris:
return 'Iris Scanner';
default:
return 'Biometric';
}
}
Color _getStatusColor() {
switch (_availability) {
case BiometricAvailability.available:
return _isEnabled ? Colors.green : Colors.orange;
case BiometricAvailability.notAvailable:
return Colors.grey;
case BiometricAvailability.notEnrolled:
return Colors.orange;
case BiometricAvailability.lockedOut:
return Colors.red;
case BiometricAvailability.permanentlyUnavailable:
return Colors.red;
case null:
return Colors.grey;
}
}
@override
Widget build(BuildContext context) {
return AppScaffold(
title: 'Biometric Login',
body: _isLoading
? const Center(child: CircularProgressIndicator())
: ListView(
padding: const EdgeInsets.all(16),
children: [
// Status Card
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
_primaryBiometricType != null
? Icons.fingerprint
: Icons.lock,
size: 32,
color: _getStatusColor(),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Biometric Status',
style: Theme.of(context).textTheme.titleMedium,
),
Text(
_statusMessage,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: _getStatusColor(),
),
),
],
),
),
],
),
const SizedBox(height: 16),
if (_availability == BiometricAvailability.available)
SwitchListTile(
title: const Text('Enable Biometric Login'),
subtitle: const Text('Use fingerprint or face ID for quick access'),
value: _isEnabled,
onChanged: (value) => _toggleBiometric(),
secondary: Icon(
_isEnabled ? Icons.lock_open : Icons.lock,
color: _getStatusColor(),
),
),
],
),
),
),
const SizedBox(height: 16),
// Available Biometrics
if (_availableBiometrics.isNotEmpty)
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Available Biometrics',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
..._availableBiometrics.map((type) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Text(
_getBiometricTypeEmoji(type),
style: const TextStyle(fontSize: 24),
),
const SizedBox(width: 12),
Text(
_getBiometricTypeName(type),
style: Theme.of(context).textTheme.bodyLarge,
),
const Spacer(),
if (type == _primaryBiometricType)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'Primary',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onPrimary,
),
),
),
],
),
)),
],
),
),
),
const SizedBox(height: 16),
// Test Biometric (if enabled)
if (_isEnabled)
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Test Biometric Login',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'Test your biometric authentication to make sure it\'s working properly.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _testBiometric,
icon: const Icon(Icons.fingerprint),
label: const Text('Test Biometric Login'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
],
),
),
),
const SizedBox(height: 16),
// Information Card
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'About Biometric Login',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
Text(
'• Biometric login allows you to sign in quickly using your fingerprint or face ID.\n'
'• Your biometric data is stored securely on your device and never sent to our servers.\n'
'• You can disable biometric login at any time in these settings.\n'
'• If you change your password, you may need to re-enable biometric login.',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
],
),
);
}
}
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
import '../../../bootstrap/supabase_client.dart';
import '../../../core/widgets/app_scaffold.dart';
class SettingsHomeScreen extends ConsumerWidget {
@@ -10,6 +10,7 @@ class SettingsHomeScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return AppScaffold(
title: 'Settings',
body: ListView(
children: [
_buildSection(
@@ -25,7 +26,7 @@ class SettingsHomeScreen extends ConsumerWidget {
_SettingsTile(
icon: Icons.email,
title: 'Email',
subtitle: supabase.Supabase.instance.client.auth.currentUser?.email ?? '',
subtitle: currentSupabaseUserEmail ?? 'Not signed in',
onTap: () => context.push('/settings/account'),
),
_SettingsTile(
@@ -64,6 +65,12 @@ class SettingsHomeScreen extends ConsumerWidget {
subtitle: 'Public or Private profile',
onTap: () => context.push('/settings/privacy'),
),
_SettingsTile(
icon: Icons.fingerprint,
title: 'Biometric Login',
subtitle: 'Use fingerprint or face ID for quick access',
onTap: () => context.push('/settings/biometric'),
),
_SettingsTile(
icon: Icons.block,
title: 'Blocked Users',
@@ -145,7 +145,7 @@ class SocialState {
}
final socialRepositoryProvider = Provider<SocialRepository>((ref) {
return SocialRepository(supabaseClient);
return SocialRepository(supabaseClient ?? (throw Exception("Supabase not initialized")));
});
final socialControllerProvider = StateNotifierProvider<SocialController, SocialState>((ref) {
@@ -162,7 +162,7 @@ final socialNotificationsControllerProvider =
});
final socialRepositoryProvider = Provider<SocialRepository>((ref) {
return SocialRepository(supabaseClient);
return SocialRepository(supabaseClient ?? (throw Exception("Supabase not initialized")));
});
final notificationsRepositoryProvider = Provider<NotificationsRepository>((ref) {
@@ -1,10 +1,13 @@
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';
@@ -22,12 +25,19 @@ class PublicProfileScreen extends ConsumerStatefulWidget {
}
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();
});
}
@@ -39,6 +49,37 @@ class _PublicProfileScreenState extends ConsumerState<PublicProfileScreen> {
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);
@@ -90,6 +131,8 @@ class _PublicProfileScreenState extends ConsumerState<PublicProfileScreen> {
onRefresh: () async {
await _loadProfile();
await _checkFollowingStatus();
await _loadUserStats();
await _loadUserGoals();
},
child: CustomScrollView(
slivers: [
@@ -101,7 +144,116 @@ class _PublicProfileScreenState extends ConsumerState<PublicProfileScreen> {
),
),
SliverToBoxAdapter(
child: _StatsSection(user: user),
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),
),
],
),
@@ -109,6 +261,217 @@ class _PublicProfileScreenState extends ConsumerState<PublicProfileScreen> {
}
}
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;
@@ -219,51 +582,6 @@ class _FollowButton extends StatelessWidget {
}
}
class _StatsSection extends StatelessWidget {
final app.User user;
const _StatsSection({required this.user});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Journey Stats',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
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 _StatCard extends StatelessWidget {
final IconData icon;
final String title;