feat: added pullup journey

This commit is contained in:
Patryk Hegenberg 2026-01-04 17:21:24 +01:00
parent 9ebc70ad27
commit a5efbf8dad
8 changed files with 406 additions and 95 deletions

View file

@ -25,6 +25,22 @@ final routerProvider = Provider<GoRouter>((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(

View file

@ -356,6 +356,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
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<ProfileScreen> {
if (newTemplate == AccessoryTemplate.conditioning) {
templateKey = 'conditioning';
}
if (newTemplate == AccessoryTemplate.journey_pullup) {
templateKey = 'journey_pullup';
}
final currentSettings =
Map<String, dynamic>.from(_user!.inventorySettings ?? {});
@ -622,6 +626,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
final current = _getTemplateFromSettings(_user?.inventorySettings ?? {});
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_RadioTile<AccessoryTemplate>(
value: AccessoryTemplate.none,
@ -646,6 +651,24 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
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<AccessoryTemplate>(
value: AccessoryTemplate.journey_pullup,
groupValue: current,
title: 'Quest: The First Pull-Up',
subtitle: 'Specific progression to master your bodyweight.',
onChanged: (val) => _updateTemplate(val!),
),
],
);
}

View file

@ -16,16 +16,16 @@ class RegisterScreen extends ConsumerStatefulWidget {
class _RegisterScreenState extends ConsumerState<RegisterScreen> {
final _formKey = GlobalKey<FormState>();
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<RegisterScreen> {
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<RegisterScreen> {
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,

View file

@ -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<AvatarSetupScreen> {
bool _isLoading = false;
Future<void> _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<String, dynamic>;
(onboardingData['inventory_settings'] as Map<String, dynamic>?) ?? {};
final exerciseVariants =
onboardingData['exercise_variants'] as Map<String, dynamic>?;
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<String, dynamic>?;
final trainingMaxes =
onboardingData['training_maxes'] as Map<String, dynamic>?;
if (trainingMaxes != null) {
final cycleRepo = ref.read(cycleRepositoryProvider);
final tmMap = <String, double>{
'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 = <String, double>{
'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<AvatarSetupScreen> {
),
);
}
Future<String?> _showPasswordDialog() async {
final passwordController = TextEditingController();
final confirmController = TextEditingController();
final formKey = GlobalKey<FormState>();
return showDialog<String>(
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'),
),
],
),
);
}
}

View file

@ -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<String, dynamic> build() => {};
void update(Map<String, dynamic> Function(Map<String, dynamic> 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<String, dynamic> newValue) {
state = {...state, ...newValue};
}
void clear() {
state = {};
}
void removeKey(String key) {
final newState = Map<String, dynamic>.from(state);
newState.remove(key);
state = newState;
}
}
class BodyweightInputScreen extends ConsumerStatefulWidget {

View file

@ -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<Exercise> _generatePullUpJourney(
int day, Map<String, double> trainingMaxes) {
final exercises = <Exercise>[];
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<Exercise> _generateConditioning(int day, int targetSets) {
final accessories = <Exercise>[];

View file

@ -10,6 +10,9 @@ class ApiClient {
final FlutterSecureStorage _storage;
final Logger _logger;
bool _isRefreshing = false;
final List<Function> _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<void> _queueRequest(
Future<Response> 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<String?> 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<Map<String, dynamic>> login(String email, String password) async {
try {
final response = await _dio.post(

View file

@ -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<int, List<double>> weekPercentages = {