mirror of
https://github.com/Dvorinka/1356.git
synced 2026-06-05 04:22:55 +00:00
small fix, don't worry about it
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user