diff --git a/lib/src/core/routing/app_router.dart b/lib/src/core/routing/app_router.dart index d01db62..8deb418 100644 --- a/lib/src/core/routing/app_router.dart +++ b/lib/src/core/routing/app_router.dart @@ -25,6 +25,22 @@ final routerProvider = Provider((ref) { return GoRouter( initialLocation: '/splash', + redirect: (context, state) async { + final user = await userRepo.getLocalUser(); + final isAuthenticated = user != null; + + final isOnAuthPage = state.matchedLocation == '/login' || + state.matchedLocation == '/register' || + state.matchedLocation.startsWith('/onboarding'); + + if (!isAuthenticated && + !isOnAuthPage && + state.matchedLocation != '/splash') { + return '/login'; + } + + return null; + }, routes: [ // Splash / Initial Route GoRoute( diff --git a/lib/src/features/authentication/presentation/screens/profile_screen.dart b/lib/src/features/authentication/presentation/screens/profile_screen.dart index 6205cd7..43eaa5e 100644 --- a/lib/src/features/authentication/presentation/screens/profile_screen.dart +++ b/lib/src/features/authentication/presentation/screens/profile_screen.dart @@ -356,6 +356,7 @@ class _ProfileScreenState extends ConsumerState { final key = settings['accessory_template'] as String?; if (key == 'hypertrophy') return AccessoryTemplate.hypertrophy; if (key == 'conditioning') return AccessoryTemplate.conditioning; + if (key == 'journey_pullup') return AccessoryTemplate.journey_pullup; return AccessoryTemplate.none; } @@ -369,6 +370,9 @@ class _ProfileScreenState extends ConsumerState { if (newTemplate == AccessoryTemplate.conditioning) { templateKey = 'conditioning'; } + if (newTemplate == AccessoryTemplate.journey_pullup) { + templateKey = 'journey_pullup'; + } final currentSettings = Map.from(_user!.inventorySettings ?? {}); @@ -622,6 +626,7 @@ class _ProfileScreenState extends ConsumerState { final current = _getTemplateFromSettings(_user?.inventorySettings ?? {}); return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ _RadioTile( value: AccessoryTemplate.none, @@ -646,6 +651,24 @@ class _ProfileScreenState extends ConsumerState { subtitle: '15 min Kettlebell intervals to boost stamina.', onChanged: (val) => _updateTemplate(val!), ), + const Padding( + padding: EdgeInsets.fromLTRB(16, 24, 16, 8), + child: Text( + 'ACTIVE JOURNEYS', + style: TextStyle( + color: Colors.grey, + fontSize: 12, + fontWeight: FontWeight.bold, + letterSpacing: 1.5), + ), + ), + _RadioTile( + value: AccessoryTemplate.journey_pullup, + groupValue: current, + title: 'Quest: The First Pull-Up', + subtitle: 'Specific progression to master your bodyweight.', + onChanged: (val) => _updateTemplate(val!), + ), ], ); } diff --git a/lib/src/features/authentication/presentation/screens/register_screen.dart b/lib/src/features/authentication/presentation/screens/register_screen.dart index 5278ae5..cb46ff7 100644 --- a/lib/src/features/authentication/presentation/screens/register_screen.dart +++ b/lib/src/features/authentication/presentation/screens/register_screen.dart @@ -16,16 +16,16 @@ class RegisterScreen extends ConsumerStatefulWidget { class _RegisterScreenState extends ConsumerState { final _formKey = GlobalKey(); final _emailController = TextEditingController(); - final _passwordController = TextEditingController(); - final _confirmPasswordController = TextEditingController(); - bool _obscurePassword = true; - bool _obscureConfirmPassword = true; + // final _passwordController = TextEditingController(); + // final _confirmPasswordController = TextEditingController(); + // bool _obscurePassword = true; + // bool _obscureConfirmPassword = true; @override void dispose() { _emailController.dispose(); - _passwordController.dispose(); - _confirmPasswordController.dispose(); + // _passwordController.dispose(); + // _confirmPasswordController.dispose(); super.dispose(); } @@ -34,7 +34,7 @@ class _RegisterScreenState extends ConsumerState { ref.read(onboardingDataProvider.notifier).updateData({ 'email': _emailController.text.trim(), - 'password': _passwordController.text, + // 'password': _passwordController.text, }); context.go('/onboarding/welcome'); @@ -87,60 +87,60 @@ class _RegisterScreenState extends ConsumerState { return null; }, ), - const SizedBox(height: 16), - TextFormField( - controller: _passwordController, - obscureText: _obscurePassword, - decoration: InputDecoration( - labelText: 'Password', - prefixIcon: const Icon(Icons.lock_outline), - suffixIcon: IconButton( - icon: Icon( - _obscurePassword - ? Icons.visibility_outlined - : Icons.visibility_off_outlined, - ), - onPressed: () { - setState(() => _obscurePassword = !_obscurePassword); - }, - ), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter a password'; - } - if (value.length < 8) { - return 'Password must be at least 8 characters'; - } - return null; - }, - ), - const SizedBox(height: 16), - TextFormField( - controller: _confirmPasswordController, - obscureText: _obscureConfirmPassword, - decoration: InputDecoration( - labelText: 'Confirm Password', - prefixIcon: const Icon(Icons.lock_outline), - suffixIcon: IconButton( - icon: Icon( - _obscureConfirmPassword - ? Icons.visibility_outlined - : Icons.visibility_off_outlined, - ), - onPressed: () { - setState(() => _obscureConfirmPassword = - !_obscureConfirmPassword); - }, - ), - ), - validator: (value) { - if (value != _passwordController.text) { - return 'Passwords do not match'; - } - return null; - }, - ), + // const SizedBox(height: 16), + // TextFormField( + // controller: _passwordController, + // obscureText: _obscurePassword, + // decoration: InputDecoration( + // labelText: 'Password', + // prefixIcon: const Icon(Icons.lock_outline), + // suffixIcon: IconButton( + // icon: Icon( + // _obscurePassword + // ? Icons.visibility_outlined + // : Icons.visibility_off_outlined, + // ), + // onPressed: () { + // setState(() => _obscurePassword = !_obscurePassword); + // }, + // ), + // ), + // validator: (value) { + // if (value == null || value.isEmpty) { + // return 'Please enter a password'; + // } + // if (value.length < 8) { + // return 'Password must be at least 8 characters'; + // } + // return null; + // }, + // ), + // const SizedBox(height: 16), + // TextFormField( + // controller: _confirmPasswordController, + // obscureText: _obscureConfirmPassword, + // decoration: InputDecoration( + // labelText: 'Confirm Password', + // prefixIcon: const Icon(Icons.lock_outline), + // suffixIcon: IconButton( + // icon: Icon( + // _obscureConfirmPassword + // ? Icons.visibility_outlined + // : Icons.visibility_off_outlined, + // ), + // onPressed: () { + // setState(() => _obscureConfirmPassword = + // !_obscureConfirmPassword); + // }, + // ), + // ), + // validator: (value) { + // if (value != _passwordController.text) { + // return 'Passwords do not match'; + // } + // return null; + // }, + // ), const SizedBox(height: 32), ElevatedButton( onPressed: _handleRegister, diff --git a/lib/src/features/onboarding/presentation/screens/avatar_setup_screen.dart b/lib/src/features/onboarding/presentation/screens/avatar_setup_screen.dart index 1f631ff..f2be1d5 100644 --- a/lib/src/features/onboarding/presentation/screens/avatar_setup_screen.dart +++ b/lib/src/features/onboarding/presentation/screens/avatar_setup_screen.dart @@ -1,3 +1,5 @@ +import 'dart:developer'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -23,71 +25,96 @@ class _AvatarSetupScreenState extends ConsumerState { bool _isLoading = false; Future _handleFinish() async { + final password = await _showPasswordDialog(); + if (password == null) return; + setState(() => _isLoading = true); try { final onboardingData = ref.read(onboardingDataProvider); final userRepo = ref.read(userRepositoryProvider); final inventorySettings = - onboardingData['inventory_settings'] as Map; + (onboardingData['inventory_settings'] as Map?) ?? {}; final exerciseVariants = onboardingData['exercise_variants'] as Map?; var user = await userRepo.getLocalUser(); if (user == null) { + final email = onboardingData['email'] as String? ?? ''; + final bodyweight = + (onboardingData['bodyweight'] as num?)?.toDouble() ?? 80.0; + + if (email.isEmpty || password.isEmpty) { + throw Exception('Email or password is missing!'); + } + user = await userRepo.register( - email: onboardingData['email'] ?? '', - password: onboardingData['password'] ?? '', - bodyweight: onboardingData['bodyweight'] ?? 80.0, + email: email, + password: password, + bodyweight: bodyweight, inventorySettings: inventorySettings, exerciseVariants: exerciseVariants, ); + await Future.delayed(const Duration(milliseconds: 100)); + user = await userRepo.getLocalUser(); + + if (user == null) { + throw Exception( + 'User registration succeeded but user not found in DB'); + } } else { user = user.copyWith( currentBodyweight: - onboardingData['bodyweight'] ?? user.currentBodyweight, + (onboardingData['bodyweight'] as num?)?.toDouble() ?? + user.currentBodyweight, inventorySettings: Value(inventorySettings), isDirty: true, ); await userRepo.saveLocalUser(user); - - try { - await userRepo.updateBodyweight(user.currentBodyweight); - await userRepo.updateInventory(inventorySettings); - } catch (e) { - // Sync macht das später - } } + final avatarJson = _config.toJson(); + user = user.copyWith( - avatarConfig: Value(_config.toJson()), + avatarConfig: Value(avatarJson), isDirty: true, ); await userRepo.saveLocalUser(user); + try { + final trainingMaxes = + onboardingData['training_maxes'] as Map?; - final trainingMaxes = - onboardingData['training_maxes'] as Map?; - if (trainingMaxes != null) { - final cycleRepo = ref.read(cycleRepositoryProvider); - final tmMap = { - 'squat': (trainingMaxes['squat'] as num?)?.toDouble() ?? 100.0, - 'pullup': (trainingMaxes['pullup'] as num?)?.toDouble() ?? 80.0, - 'dip': (trainingMaxes['dip'] as num?)?.toDouble() ?? 90.0, - }; - await cycleRepo.createCycle(tmMap); + if (trainingMaxes != null && trainingMaxes.isNotEmpty) { + final cycleRepo = ref.read(cycleRepositoryProvider); + + final tmMap = { + 'squat': (trainingMaxes['squat'] as num?)?.toDouble() ?? 100.0, + 'pullup': (trainingMaxes['pullup'] as num?)?.toDouble() ?? 80.0, + 'dip': (trainingMaxes['dip'] as num?)?.toDouble() ?? 90.0, + }; + + final cycle = await cycleRepo.createCycle(tmMap); + } + } catch (e, stackTrace) { + log('❌ CYCLE ERROR (non-critical): $e'); + log(' Error type: ${e.runtimeType}'); + log(' Stack:\n$stackTrace'); } if (mounted) { - ref.read(onboardingDataProvider.notifier).state = {}; + ref.read(onboardingDataProvider.notifier).clear(); + context.go('/hub'); } - } catch (e) { + } catch (e, stackTrace) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Setup failed: $e'), - backgroundColor: AppTheme.errorColor), + content: Text('Setup failed: $e'), + backgroundColor: AppTheme.errorColor, + duration: const Duration(seconds: 5), + ), ); } } finally { @@ -137,4 +164,65 @@ class _AvatarSetupScreenState extends ConsumerState { ), ); } + + Future _showPasswordDialog() async { + final passwordController = TextEditingController(); + final confirmController = TextEditingController(); + final formKey = GlobalKey(); + + return showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + title: const Text('Secure Your Account'), + content: Form( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Choose a strong password to protect your progress'), + const SizedBox(height: 16), + TextFormField( + controller: passwordController, + obscureText: true, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Password', + prefixIcon: Icon(Icons.lock), + ), + validator: (v) => + (v?.length ?? 0) < 8 ? 'Min 8 characters' : null, + ), + const SizedBox(height: 16), + TextFormField( + controller: confirmController, + obscureText: true, + decoration: const InputDecoration( + labelText: 'Confirm Password', + prefixIcon: Icon(Icons.lock_outline), + ), + validator: (v) => v != passwordController.text + ? 'Passwords do not match' + : null, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('CANCEL'), + ), + ElevatedButton( + onPressed: () { + if (formKey.currentState!.validate()) { + Navigator.pop(context, passwordController.text); + } + }, + child: const Text('CONFIRM'), + ), + ], + ), + ); + } } diff --git a/lib/src/features/onboarding/presentation/screens/bodyweight_input_screen.dart b/lib/src/features/onboarding/presentation/screens/bodyweight_input_screen.dart index 41a6234..bca8f04 100644 --- a/lib/src/features/onboarding/presentation/screens/bodyweight_input_screen.dart +++ b/lib/src/features/onboarding/presentation/screens/bodyweight_input_screen.dart @@ -9,19 +9,29 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'bodyweight_input_screen.g.dart'; // Provider to store onboarding data -@riverpod +@Riverpod(keepAlive: true) class OnboardingData extends _$OnboardingData { @override Map build() => {}; void update(Map Function(Map state) cb) { - state = cb(state); + final newData = cb(state); + state = {...state, ...newData}; } - // 2. Eigene Methode für Updates hinzufügen, um die Syntax im UI sauber zu halten void updateData(Map newValue) { state = {...state, ...newValue}; } + + void clear() { + state = {}; + } + + void removeKey(String key) { + final newState = Map.from(state); + newState.remove(key); + state = newState; + } } class BodyweightInputScreen extends ConsumerStatefulWidget { diff --git a/lib/src/features/workout_runner/application/workout_generator_service.dart b/lib/src/features/workout_runner/application/workout_generator_service.dart index c35e558..756d2b9 100644 --- a/lib/src/features/workout_runner/application/workout_generator_service.dart +++ b/lib/src/features/workout_runner/application/workout_generator_service.dart @@ -30,6 +30,8 @@ class WorkoutGeneratorService { ? conditioningSets : 15; exercises.addAll(_generateConditioning(day, sets)); + } else if (template == AccessoryTemplate.journey_pullup) { + exercises.addAll(_generatePullUpJourney(day, trainingMaxes)); } return exercises; @@ -187,6 +189,68 @@ class WorkoutGeneratorService { return accessories; } + List _generatePullUpJourney( + int day, Map trainingMaxes) { + final exercises = []; + + Exercise createAccessory( + String id, String name, ExerciseType type, int sets, int reps, + {double weight = 0.0}) { + return Exercise( + exerciseId: id, + exerciseName: name, + bodyweightAtSession: 0, + sets: List.generate( + sets, + (i) => WorkoutSet( + setNumber: i + 1, + repsTarget: reps, + targetWeightTotal: weight, + repsActual: 0, + isAmrap: false, + completed: false, + )), + ); + } + + double calculateWeight(double referenceTm, double percentage) { + final raw = referenceTm * percentage; + return (raw / 2.5).round() * 2.5; + } + + switch (day) { + case 1: + exercises.add(createAccessory('scap_pull', 'Scapular Pull-Ups', + ExerciseType.scapular_pull, 3, 10)); + + exercises.add(createAccessory( + 'plank', 'Core Plank (45s)', ExerciseType.plank, 3, 1)); + break; + + case 2: + exercises.add(createAccessory( + 'inv_row', 'Australian Pull-Ups', ExerciseType.inverted_row, 4, 8)); + + exercises.add(createAccessory( + 'face_pull', 'Band Face Pull', ExerciseType.face_pull, 3, 15)); + break; + + case 3: + exercises.add(createAccessory('neg_pull', 'Negative Pull-Ups (5s slow)', + ExerciseType.negative_pullup, 3, 4)); + + final rowTm = trainingMaxes['row'] ?? 0.0; + final curlWeight = rowTm > 0 ? calculateWeight(rowTm, 0.3) : 0.0; + + exercises.add(createAccessory( + 'curl', 'Barbell Curl', ExerciseType.curl_barbell, 3, 10, + weight: curlWeight)); + break; + } + + return exercises; + } + List _generateConditioning(int day, int targetSets) { final accessories = []; diff --git a/lib/src/shared/data/remote/api_client.dart b/lib/src/shared/data/remote/api_client.dart index f3746d6..46a4ac7 100644 --- a/lib/src/shared/data/remote/api_client.dart +++ b/lib/src/shared/data/remote/api_client.dart @@ -10,6 +10,9 @@ class ApiClient { final FlutterSecureStorage _storage; final Logger _logger; + bool _isRefreshing = false; + final List _requestsQueue = []; + ApiClient({ FlutterSecureStorage? storage, Logger? logger, @@ -49,15 +52,117 @@ class ApiClient { }, onError: (error, handler) async { if (error.response?.statusCode == 401) { - _logger.w('Unauthorized - clearing token'); - await _storage.delete(key: AppConstants.keyAuthToken); + final token = await _storage.read(key: AppConstants.keyAuthToken); + + if (token != null && !_isRefreshing) { + _isRefreshing = true; + _logger.w('🔄 Token expired, attempting refresh...'); + + try { + final newToken = await refreshToken(); + + if (newToken != null) { + error.requestOptions.headers['Authorization'] = + 'Bearer $newToken'; + + final response = await _dio.fetch(error.requestOptions); + _isRefreshing = false; + + _processQueue(newToken); + + return handler.resolve(response); + } else { + _logger.e('❌ Token refresh failed - logging out'); + await _storage.delete(key: AppConstants.keyAuthToken); + _isRefreshing = false; + _clearQueue(); + return handler.next(error); + } + } catch (e) { + _logger.e('❌ Refresh error: $e'); + await _storage.delete(key: AppConstants.keyAuthToken); + _isRefreshing = false; + _clearQueue(); + return handler.next(error); + } + } else if (_isRefreshing) { + _logger.i('⏳ Waiting for token refresh...'); + return _queueRequest(() async { + final newToken = + await _storage.read(key: AppConstants.keyAuthToken); + if (newToken != null) { + error.requestOptions.headers['Authorization'] = + 'Bearer $newToken'; + return await _dio.fetch(error.requestOptions); + } + throw error; + }, handler); + } else { + await _storage.delete(key: AppConstants.keyAuthToken); + return handler.next(error); + } } + // onError: (error, handler) async { + // if (error.response?.statusCode == 401) { + // _logger.w('Unauthorized - clearing token'); + // await _storage.delete(key: AppConstants.keyAuthToken); + // } return handler.next(error); }, ), ); } + Future _queueRequest( + Future Function() request, + ErrorInterceptorHandler handler, + ) async { + _requestsQueue.add(() async { + try { + final response = await request(); + handler.resolve(response); + } catch (e) { + handler.reject(e as DioException); + } + }); + } + + void _processQueue(String newToken) { + for (final request in _requestsQueue) { + request(); + } + _requestsQueue.clear(); + } + + void _clearQueue() { + _requestsQueue.clear(); + } + + Future refreshToken() async { + try { + final token = await _storage.read(key: AppConstants.keyAuthToken); + if (token == null) return null; + + final response = await _dio.post( + '/api/collections/users/auth-refresh', + options: Options( + headers: {'Authorization': 'Bearer $token'}, + ), + ); + + final newToken = response.data['token']; + if (newToken != null) { + await _storage.write(key: AppConstants.keyAuthToken, value: newToken); + _logger.i('✅ Token refreshed successfully'); + return newToken; + } + return null; + } catch (e) { + _logger.e('❌ Token refresh failed', error: e); + return null; + } + } + Future> login(String email, String password) async { try { final response = await _dio.post( diff --git a/lib/src/shared/domain/logic/wendler_calculator.dart b/lib/src/shared/domain/logic/wendler_calculator.dart index 9a1eb59..1533b06 100644 --- a/lib/src/shared/domain/logic/wendler_calculator.dart +++ b/lib/src/shared/domain/logic/wendler_calculator.dart @@ -22,10 +22,15 @@ enum ExerciseType { kb_swing, kb_snatch, kb_thruster, - kb_clean_press + kb_clean_press, + + // pullup journey + scapular_pull, + inverted_row, + negative_pullup, } -enum AccessoryTemplate { none, hypertrophy, conditioning } +enum AccessoryTemplate { none, hypertrophy, conditioning, journey_pullup } class WendlerCalculator { static const Map> weekPercentages = {