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
@@ -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,