feat: Completed with Starter MainLifts

This commit is contained in:
Patryk Hegenberg 2025-12-07 12:03:42 +01:00
parent 311d764a4d
commit 2609446e9a
15 changed files with 642 additions and 491 deletions

View file

@ -44,6 +44,7 @@
<!-- </queries>-->
<!--</manifest>-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="streetlifting_rpg"
android:name="${applicationName}"
@ -70,4 +71,4 @@
android:name="flutterEmbedding"
android:value="2" />
</application>
</manifest>
</manifest>

View file

@ -2,7 +2,8 @@ import 'package:flutter/material.dart';
class AppConstants {
// API Configuration
static const String apiBaseUrl = 'http://10.0.2.2:8090'; // Android emulator
// static const String apiBaseUrl = 'http://10.0.2.2:8090'; // Android emulator
static const String apiBaseUrl = 'https://slift.patanix.de';
static const String apiVersion = 'v1';
// Wendler 5/3/1 Constants

View file

@ -1,10 +1,10 @@
import 'dart:convert';
// import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/constants/asset_paths.dart';
// import '../../../../core/constants/asset_paths.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../shared/data/local/app_database.dart';
import '../../../../shared/data/repositories/user_repository.dart';
@ -54,27 +54,64 @@ class _HubScreenState extends ConsumerState<HubScreen> {
required int day,
required Map<String, double> trainingMaxes,
required double bodyweight,
required UserCollection user,
}) {
final exercises = <Exercise>[];
final variants = user.exerciseVariants ?? {};
void addExercise(String id, String name, ExerciseType type, bool isMain) {
final tm = trainingMaxes[id] ?? 0.0;
(String, String, ExerciseType) resolveVariant(String slot, String defaultId,
String defaultName, ExerciseType defaultType) {
final variant = variants[slot];
if (slot == 'pull') {
if (variant == 'row') return ('row', 'Pendlay Row', ExerciseType.row);
return ('pullup', 'Weighted Pull-up', ExerciseType.pullup);
}
if (slot == 'push') {
if (variant == 'bench') {
return ('bench', 'Bench Press', ExerciseType.bench);
}
return ('dip', 'Weighted Dip', ExerciseType.dip);
}
return (defaultId, defaultName, defaultType);
}
void addExercise(String slot, String defaultId, String defaultName,
ExerciseType defaultType, bool isMain) {
final (id, name, type) =
resolveVariant(slot, defaultId, defaultName, defaultType);
final tmKey = defaultId;
final tm = trainingMaxes[tmKey] ?? 0.0;
List<WorkoutSet> sets;
if (isMain) {
sets = WendlerCalculator.generateSets(
week: week,
trainingMax: tm,
exerciseType: type,
currentBodyweight: bodyweight,
);
if (type == ExerciseType.row || type == ExerciseType.bench) {
sets = WendlerCalculator.generateLinearSets(
trainingMax: tm,
exerciseType: type,
currentBodyweight: user.currentBodyweight);
} else {
sets = WendlerCalculator.generateSets(
week: week,
trainingMax: tm,
exerciseType: type,
currentBodyweight: user.currentBodyweight,
);
}
} else {
if (week == 4) return;
if (week == 4) {
return;
}
if (type == ExerciseType.row || type == ExerciseType.bench) return;
sets = WendlerCalculator.generateFSLSets(
trainingMax: tm,
exerciseType: type,
currentBodyweight: bodyweight,
currentBodyweight: user.currentBodyweight,
);
}
@ -82,21 +119,23 @@ class _HubScreenState extends ConsumerState<HubScreen> {
exercises.add(Exercise(
exerciseId: id,
exerciseName: isMain ? name : '$name (FSL)',
bodyweightAtSession: bodyweight,
bodyweightAtSession: user.currentBodyweight,
sets: sets,
));
}
}
if (day == 1) {
addExercise('squat', 'Back Squat', ExerciseType.squat, true);
addExercise('pullup', 'Weighted Pull-up', ExerciseType.pullup, false);
addExercise('legs', 'squat', 'Back Squat', ExerciseType.squat, true);
addExercise(
'pull', 'pullup', 'Weighted Pull-up', ExerciseType.pullup, false);
} else if (day == 2) {
addExercise('dip', 'Weighted Dip', ExerciseType.dip, true);
addExercise('squat', 'Back Squat', ExerciseType.squat, false);
addExercise('push', 'dip', 'Weighted Dip', ExerciseType.dip, true);
addExercise('legs', 'squat', 'Back Squat', ExerciseType.squat, false);
} else if (day == 3) {
addExercise('pullup', 'Weighted Pull-up', ExerciseType.pullup, true);
addExercise('dip', 'Weighted Dip', ExerciseType.dip, false);
addExercise(
'pull', 'pullup', 'Weighted Pull-up', ExerciseType.pullup, true);
addExercise('push', 'dip', 'Weighted Dip', ExerciseType.dip, false);
}
return exercises;
@ -152,11 +191,11 @@ class _HubScreenState extends ConsumerState<HubScreen> {
if (workout == null) {
final exercises = _generateExercises(
week: targetWeek,
day: targetDay,
trainingMaxes: trainingMaxes,
bodyweight: user.currentBodyweight,
);
week: targetWeek,
day: targetDay,
trainingMaxes: trainingMaxes,
bodyweight: user.currentBodyweight,
user: user);
final userId = user.serverId ?? user.id.toString();
@ -231,16 +270,9 @@ class _HubScreenState extends ConsumerState<HubScreen> {
child: Image.asset(
bgItem.assetPath,
fit: BoxFit.cover,
// Key hinzufügen, damit Flutter einen sanften Übergang animieren kann (optional)
key: ValueKey(bgItem.assetPath),
),
),
// Positioned.fill(
// child: Image.asset(
// AssetPaths.bgStreetParkDay,
// fit: BoxFit.cover,
// ),
// ),
Positioned.fill(
child: Container(
decoration: BoxDecoration(
@ -248,8 +280,10 @@ class _HubScreenState extends ConsumerState<HubScreen> {
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withOpacity(0.6),
Colors.black.withOpacity(0.85),
// Colors.black.withOpacity(0.6),
Colors.black.withValues(alpha: 0.6),
// Colors.black.withOpacity(0.85),
Colors.black.withValues(alpha: 0.85),
],
),
),
@ -355,7 +389,8 @@ class _HubScreenState extends ConsumerState<HubScreen> {
top: Radius.circular(24)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
// color: Colors.black.withOpacity(0.2),
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 10,
offset: const Offset(0, -5),
),
@ -400,6 +435,10 @@ class _HubScreenState extends ConsumerState<HubScreen> {
}
}
// extension on Object {
// operator [](String other) {}
// }
class _StatBox extends StatelessWidget {
final String label;
final String value;
@ -417,7 +456,8 @@ class _StatBox extends StatelessWidget {
color: AppTheme.surfaceColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppTheme.primaryColor.withOpacity(0.3),
// color: AppTheme.primaryColor.withOpacity(0.3),
color: AppTheme.primaryColor.withValues(alpha: 0.3),
),
),
child: Column(

View file

@ -16,19 +16,19 @@ class QuestBoardWidget extends ConsumerWidget {
stream: questRepo.watchQuests(),
builder: (context, snapshot) {
final allQuests = snapshot.data ?? [];
// Nur aktive Dailies anzeigen, max 3
final dailies = allQuests
.where((q) => q.type == 'daily' && !q.isClaimed)
final activeQuests = allQuests
.where(
(q) => !q.isClaimed && (q.type == 'daily' || q.type == 'story'))
.take(3)
.toList();
if (dailies.isEmpty) return const SizedBox.shrink(); // Ausblenden wenn leer
if (activeQuests.isEmpty) return const SizedBox.shrink();
return Container(
margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.surfaceColor, // Oder ein "Holz" Texture für RPG Look
color: AppTheme.surfaceColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white10),
),
@ -41,19 +41,19 @@ class QuestBoardWidget extends ConsumerWidget {
Text(
'DAILY BOUNTIES',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: AppTheme.secondaryColor,
letterSpacing: 1.5,
fontWeight: FontWeight.bold
),
color: AppTheme.secondaryColor,
letterSpacing: 1.5,
fontWeight: FontWeight.bold),
),
GestureDetector(
onTap: () => context.go('/quests'),
child: const Text('VIEW ALL >', style: TextStyle(fontSize: 10, color: Colors.grey)),
child: const Text('VIEW ALL >',
style: TextStyle(fontSize: 10, color: Colors.grey)),
),
],
),
const SizedBox(height: 12),
...dailies.map((q) => _MiniQuestRow(quest: q)).toList(),
...activeQuests.map((q) => _MiniQuestRow(quest: q)).toList(),
],
),
);

View file

@ -31,6 +31,8 @@ class _AvatarSetupScreenState extends ConsumerState<AvatarSetupScreen> {
final inventorySettings =
onboardingData['inventory_settings'] as Map<String, dynamic>;
final exerciseVariants =
onboardingData['exercise_variants'] as Map<String, dynamic>?;
var user = await userRepo.getLocalUser();
if (user == null) {
@ -39,6 +41,7 @@ class _AvatarSetupScreenState extends ConsumerState<AvatarSetupScreen> {
password: onboardingData['password'] ?? '',
bodyweight: onboardingData['bodyweight'] ?? 80.0,
inventorySettings: inventorySettings,
exerciseVariants: exerciseVariants,
);
} else {
user = user.copyWith(

View file

@ -17,12 +17,17 @@ class StrengthTestScreen extends ConsumerStatefulWidget {
class _StrengthTestScreenState extends ConsumerState<StrengthTestScreen> {
final _formKey = GlobalKey<FormState>();
final _squatWeightController = TextEditingController(text: '100');
final _squatWeightController = TextEditingController(text: '60');
final _squatRepsController = TextEditingController(text: '5');
final _pullupWeightController = TextEditingController(text: '0');
final _pullupRepsController = TextEditingController(text: '8');
bool _canDoPullup = true;
final _pullWeightController = TextEditingController(text: '0');
final _pullRepsController = TextEditingController(text: '5');
bool _canDoDip = true;
final _dipWeightController = TextEditingController(text: '0');
final _dipRepsController = TextEditingController(text: '10');
final _benchWeightController = TextEditingController(text: '40');
final _pushRepsController = TextEditingController(text: '5');
Map<String, double> _calculated1RMs = {};
Map<String, double> _calculatedTMs = {};
@ -37,10 +42,10 @@ class _StrengthTestScreenState extends ConsumerState<StrengthTestScreen> {
void dispose() {
_squatWeightController.dispose();
_squatRepsController.dispose();
_pullupWeightController.dispose();
_pullupRepsController.dispose();
_pullWeightController.dispose();
_pullRepsController.dispose();
_dipWeightController.dispose();
_dipRepsController.dispose();
_pushRepsController.dispose();
super.dispose();
}
@ -52,28 +57,40 @@ class _StrengthTestScreenState extends ConsumerState<StrengthTestScreen> {
final squat1RM = WendlerCalculator.calculate1RM(squatWeight, squatReps);
final squatTM = WendlerCalculator.calculateTrainingMax(squat1RM);
final pullupAdditional = double.tryParse(_pullupWeightController.text) ?? 0;
final pullupReps = int.tryParse(_pullupRepsController.text) ?? 1;
final pullupTotal = bodyweight + pullupAdditional;
final pullup1RM = WendlerCalculator.calculate1RM(pullupTotal, pullupReps);
final pullupTM = WendlerCalculator.calculateTrainingMax(pullup1RM);
double pull1RM = 0.0;
if (_canDoPullup) {
final added = double.tryParse(_pullWeightController.text) ?? 0;
final reps = int.tryParse(_pullRepsController.text) ?? 1;
pull1RM = WendlerCalculator.calculate1RM(bodyweight + added, reps);
} else {
final weight = double.tryParse(_pullWeightController.text) ?? 0;
final reps = int.tryParse(_pullRepsController.text) ?? 1;
pull1RM = WendlerCalculator.calculate1RM(weight, reps);
}
final pullTM = WendlerCalculator.calculateTrainingMax(pull1RM);
final dipAdditional = double.tryParse(_dipWeightController.text) ?? 0;
final dipReps = int.tryParse(_dipRepsController.text) ?? 1;
final dipTotal = bodyweight + dipAdditional;
final dip1RM = WendlerCalculator.calculate1RM(dipTotal, dipReps);
final dipTM = WendlerCalculator.calculateTrainingMax(dip1RM);
double push1RM = 0.0;
if (_canDoDip) {
final added = double.tryParse(_dipWeightController.text) ?? 0;
final reps = int.tryParse(_pushRepsController.text) ?? 1;
push1RM = WendlerCalculator.calculate1RM(bodyweight + added, reps);
} else {
final weight = double.tryParse(_benchWeightController.text) ?? 0;
final reps = int.tryParse(_pushRepsController.text) ?? 1;
push1RM = WendlerCalculator.calculate1RM(weight, reps);
}
final pushTM = WendlerCalculator.calculateTrainingMax(push1RM);
setState(() {
_calculated1RMs = {
'squat': squat1RM,
'pullup': pullup1RM,
'dip': dip1RM,
'pullup': pull1RM,
'dip': push1RM,
};
_calculatedTMs = {
'squat': squatTM,
'pullup': pullupTM,
'dip': dipTM,
'pullup': pullTM,
'dip': pushTM,
};
});
}
@ -81,9 +98,15 @@ class _StrengthTestScreenState extends ConsumerState<StrengthTestScreen> {
void _handleContinue() {
if (!_formKey.currentState!.validate()) return;
final variants = <String, String>{
'pull': _canDoPullup ? 'pullup' : 'row',
'push': _canDoDip ? 'dip' : 'bench',
};
ref.read(onboardingDataProvider.notifier).update((state) => {
...state,
'training_maxes': _calculatedTMs,
'exercise_variants': variants,
});
context.go('/onboarding/inventory');
@ -91,8 +114,6 @@ class _StrengthTestScreenState extends ConsumerState<StrengthTestScreen> {
@override
Widget build(BuildContext context) {
final bodyweight = ref.watch(onboardingDataProvider)['bodyweight'] ?? 80.0;
return Scaffold(
appBar: AppBar(
title: const Text('Strength Test'),
@ -121,47 +142,70 @@ class _StrengthTestScreenState extends ConsumerState<StrengthTestScreen> {
),
const SizedBox(height: 8),
Text(
'We need to assess your current power level to assign the correct monsters.', // Flavor
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 8),
Text(
'Enter your recent best performance for each exercise',
'We need to assess your current power level to assign the correct monsters.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 32),
_ExerciseCard(
title: 'Leg Strength',
exerciseName: 'Back Squat',
icon: Icons.accessibility_new,
weightController: _squatWeightController,
repsController: _squatRepsController,
isBodyweight: false,
calculated1RM: _calculated1RMs['squat'] ?? 0,
calculatedTM: _calculatedTMs['squat'] ?? 0,
onChanged: _calculateAll,
result1RM: _calculated1RMs['squat'] ?? 0,
resultTM: _calculatedTMs['squat'] ?? 0,
),
const SizedBox(height: 16),
_ExerciseCard(
exerciseName: 'Weighted Pull-up',
_AdaptiveExerciseCard(
slotTitle: 'Pull Strength',
primaryName: 'Weighted Pull-up',
secondaryName: 'Pendlay Row',
icon: Icons.north,
weightController: _pullupWeightController,
repsController: _pullupRepsController,
isBodyweight: true,
bodyweight: bodyweight,
calculated1RM: _calculated1RMs['pullup'] ?? 0,
calculatedTM: _calculatedTMs['pullup'] ?? 0,
isCapable: _canDoPullup,
onToggleCapability: (val) {
setState(() {
_canDoPullup = val;
_pullWeightController.text = '0';
_pullRepsController.text = '5';
_calculateAll();
});
},
weightController: _pullWeightController,
repsController: _pullRepsController,
weightLabel:
_canDoPullup ? 'Add. Weight (kg)' : 'Row Weight (kg)',
repsLabel: _canDoPullup ? 'Reps' : '5RM Reps (usually 5)',
showResults: _canDoPullup || true,
result1RM: _calculated1RMs['pullup'] ?? 0,
resultTM: _calculatedTMs['pullup'] ?? 0,
onChanged: _calculateAll,
),
const SizedBox(height: 16),
_ExerciseCard(
exerciseName: 'Weighted Dip',
_AdaptiveExerciseCard(
slotTitle: 'Push Strength',
primaryName: 'Weighted Dip',
secondaryName: 'Bench Press',
icon: Icons.south,
weightController: _dipWeightController,
repsController: _dipRepsController,
isBodyweight: true,
bodyweight: bodyweight,
calculated1RM: _calculated1RMs['dip'] ?? 0,
calculatedTM: _calculatedTMs['dip'] ?? 0,
isCapable: _canDoDip,
onToggleCapability: (val) {
setState(() {
_canDoDip = val;
_dipWeightController.text = '0';
_pushRepsController.text = '5';
_calculateAll();
});
},
weightController:
_canDoDip ? _dipWeightController : _benchWeightController,
repsController: _pushRepsController,
weightLabel: _canDoDip ? 'Add. Weight (kg)' : 'Weight (kg)',
repsLabel: 'Reps',
showWeightInput: true,
showResults: true,
result1RM: _calculated1RMs['dip'] ?? 0,
resultTM: _calculatedTMs['dip'] ?? 0,
onChanged: _calculateAll,
),
const SizedBox(height: 32),
@ -171,19 +215,16 @@ class _StrengthTestScreenState extends ConsumerState<StrengthTestScreen> {
color: AppTheme.primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppTheme.primaryColor.withOpacity(0.3),
),
color: AppTheme.primaryColor.withOpacity(0.3)),
),
child: Row(
children: [
const Icon(
Icons.info_outline,
color: AppTheme.primaryColor,
),
const Icon(Icons.info_outline,
color: AppTheme.primaryColor),
const SizedBox(width: 12),
Expanded(
child: Text(
'Your "Training Max" (TM) is your base combat power. We calculate it as 90% of your max potential to ensure long-term survival.', // Flavor
'Your "Training Max" (TM) is your base combat power (90% of 1RM). For bodyweight exercises, we adjust the strategy.',
style: Theme.of(context)
.textTheme
.bodySmall
@ -208,25 +249,25 @@ class _StrengthTestScreenState extends ConsumerState<StrengthTestScreen> {
}
class _ExerciseCard extends StatelessWidget {
final String title;
final String exerciseName;
final IconData icon;
final TextEditingController weightController;
final TextEditingController repsController;
final bool isBodyweight;
final double bodyweight;
final double calculated1RM;
final double calculatedTM;
final double result1RM;
final double resultTM;
final VoidCallback onChanged;
const _ExerciseCard({
required this.title,
required this.exerciseName,
required this.icon,
required this.weightController,
required this.repsController,
this.isBodyweight = false,
this.bodyweight = 0,
required this.calculated1RM,
required this.calculatedTM,
required this.isBodyweight,
required this.result1RM,
required this.resultTM,
required this.onChanged,
});
@ -238,27 +279,27 @@ class _ExerciseCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Text(title.toUpperCase(),
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Row(
children: [
Container(
width: 40,
height: 40,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
),
color: AppTheme.primaryColor.withOpacity(0.2),
borderRadius: BorderRadius.circular(8)),
child: Icon(icon, color: AppTheme.primaryColor),
),
const SizedBox(width: 12),
Text(
exerciseName,
style: Theme.of(context).textTheme.titleLarge,
),
Text(exerciseName,
style: Theme.of(context).textTheme.titleLarge),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
@ -268,21 +309,14 @@ class _ExerciseCard extends StatelessWidget {
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'^\d+\.?\d{0,2}')),
RegExp(r'^\d+\.?\d{0,2}'))
],
decoration: InputDecoration(
labelText: isBodyweight
? 'Additional Weight (kg)'
: 'Weight (kg)',
isDense: true,
),
labelText:
isBodyweight ? 'Add. Weight (kg)' : 'Weight (kg)',
isDense: true),
onChanged: (_) => onChanged(),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Required';
}
return null;
},
validator: (v) => v!.isEmpty ? 'Required' : null,
),
),
const SizedBox(width: 12),
@ -290,56 +324,17 @@ class _ExerciseCard extends StatelessWidget {
child: TextFormField(
controller: repsController,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
decoration: const InputDecoration(
labelText: 'Reps',
isDense: true,
),
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration:
const InputDecoration(labelText: 'Reps', isDense: true),
onChanged: (_) => onChanged(),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Required';
}
final reps = int.tryParse(value);
if (reps == null || reps < 1 || reps > 20) {
return '1-20';
}
return null;
},
validator: (v) => v!.isEmpty ? 'Required' : null,
),
),
],
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.surfaceColor,
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
if (isBodyweight)
_ResultRow(
label: 'Total Weight',
value:
'${(bodyweight + (double.tryParse(weightController.text) ?? 0)).toStringAsFixed(1)} kg',
),
_ResultRow(
label: 'Estimated 1RM',
value: '${calculated1RM.toStringAsFixed(1)} kg',
),
_ResultRow(
label: 'Training Max (90%)',
value: '${calculatedTM.toStringAsFixed(1)} kg',
highlight: true,
),
],
),
),
_ResultBox(rm: result1RM, tm: resultTM),
],
),
),
@ -347,36 +342,176 @@ class _ExerciseCard extends StatelessWidget {
}
}
class _ResultRow extends StatelessWidget {
final String label;
final String value;
final bool highlight;
class _AdaptiveExerciseCard extends StatelessWidget {
final String slotTitle;
final String primaryName;
final String secondaryName;
final IconData icon;
final bool isCapable;
final ValueChanged<bool> onToggleCapability;
final TextEditingController weightController;
final TextEditingController repsController;
final String weightLabel;
final String repsLabel;
final bool showWeightInput;
final bool showResults;
final double result1RM;
final double resultTM;
final VoidCallback onChanged;
const _ResultRow({
required this.label,
required this.value,
this.highlight = false,
const _AdaptiveExerciseCard({
required this.slotTitle,
required this.primaryName,
required this.secondaryName,
required this.icon,
required this.isCapable,
required this.onToggleCapability,
required this.weightController,
required this.repsController,
required this.weightLabel,
required this.repsLabel,
this.showWeightInput = true,
this.showResults = true,
required this.result1RM,
required this.resultTM,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(slotTitle.toUpperCase(),
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
fontWeight: FontWeight.bold)),
Row(
children: [
Text('Can do 1 rep?',
style: TextStyle(
fontSize: 12,
color: isCapable
? AppTheme.successColor
: Colors.grey)),
Switch(
value: isCapable,
activeColor: AppTheme.successColor,
onChanged: onToggleCapability,
),
],
),
],
),
const SizedBox(height: 4),
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.2),
borderRadius: BorderRadius.circular(8)),
child: Icon(icon, color: AppTheme.primaryColor),
),
const SizedBox(width: 12),
Text(isCapable ? primaryName : secondaryName,
style: Theme.of(context).textTheme.titleLarge),
],
),
if (!isCapable) ...[
const SizedBox(height: 8),
Text(
'Adjusted Strategy: ${isCapable ? "Wendler 5/3/1" : "Linear Progression (3x5)"}',
style: const TextStyle(
color: AppTheme.secondaryColor,
fontSize: 12,
fontStyle: FontStyle.italic),
),
],
const SizedBox(height: 16),
Row(
children: [
if (showWeightInput)
Expanded(
flex: 2,
child: TextFormField(
controller: weightController,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'^\d+\.?\d{0,2}'))
],
decoration: InputDecoration(
labelText: weightLabel, isDense: true),
onChanged: (_) => onChanged(),
validator: (v) => v!.isEmpty ? 'Required' : null,
),
)
else
const Spacer(flex: 2),
if (showWeightInput) const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: repsController,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration:
InputDecoration(labelText: repsLabel, isDense: true),
onChanged: (_) => onChanged(),
validator: (v) => v!.isEmpty ? 'Required' : null,
),
),
],
),
if (showResults) ...[
const SizedBox(height: 16),
_ResultBox(rm: result1RM, tm: resultTM),
],
],
),
),
);
}
}
class _ResultBox extends StatelessWidget {
final double rm;
final double tm;
const _ResultBox({required this.rm, required this.tm});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.surfaceColor, borderRadius: BorderRadius.circular(8)),
child: Column(
children: [
Text(
label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: highlight ? FontWeight.bold : null,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Est. 1RM'),
Text('${rm.toStringAsFixed(1)} kg',
style: Theme.of(context).textTheme.bodyLarge),
],
),
Text(
value,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: highlight ? AppTheme.primaryColor : null,
fontWeight: highlight ? FontWeight.bold : null,
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Training Max (90%)'),
Text('${tm.toStringAsFixed(1)} kg',
style: const TextStyle(
color: AppTheme.primaryColor,
fontWeight: FontWeight.bold)),
],
),
],
),

View file

@ -1,9 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:slrpg_app/src/shared/data/local/tables.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../shared/data/local/app_database.dart'; // Drift Models
import '../../../../shared/data/local/app_database.dart';
import '../../../../shared/data/repositories/cycle_repository.dart';
import '../../../../shared/data/repositories/user_repository.dart';
import '../../../../shared/data/repositories/workout_repository.dart';
@ -22,7 +23,7 @@ class StatsScreen extends ConsumerStatefulWidget {
class _StatsScreenState extends ConsumerState<StatsScreen> {
bool _isLoading = false;
String _selectedExercise = 'squat'; // squat, pullup, dip
String _selectedExercise = 'squat';
String _selectedRange = '3m'; // 1m, 3m, 1y, all
List<StatsDataPoint> _chartData = [];
bool _isChartLoading = true;
@ -175,6 +176,7 @@ class _StatsScreenState extends ConsumerState<StatsScreen> {
@override
Widget build(BuildContext context) {
final cycleRepo = ref.watch(cycleRepositoryProvider);
final userRepo = ref.watch(userRepositoryProvider);
return Scaffold(
appBar: AppBar(
@ -184,14 +186,38 @@ class _StatsScreenState extends ConsumerState<StatsScreen> {
onPressed: () => context.go('/hub'),
),
),
body: FutureBuilder<CycleCollection?>(
future: cycleRepo.getCurrentCycle(),
body: FutureBuilder<List<dynamic>>(
future: Future.wait([
cycleRepo.getCurrentCycle(),
userRepo.getLocalUser(),
]),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
final currentCycle = snapshot.data;
final currentCycle = snapshot.data?[0] as CycleCollection?;
final user = snapshot.data?[1] as UserCollection;
final variants = user.exerciseVariants ?? {};
final pullVariant = variants['pull'] ?? 'pullup';
final pushVariant = variants['push'] ?? 'dip';
String getLabel(String id) {
switch (id) {
case 'squat':
return 'Squat';
case 'pullup':
return 'Pull-up';
case 'row':
return 'Row';
case 'dip':
return 'Dip';
case 'bench':
return 'Bench';
default:
return id;
}
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
@ -201,6 +227,7 @@ class _StatsScreenState extends ConsumerState<StatsScreen> {
if (currentCycle != null) ...[
_CurrentCycleCard(
cycle: currentCycle,
user: user,
onFinish: _isLoading
? null
: () => _handleFinishCycle(currentCycle),
@ -226,15 +253,17 @@ class _StatsScreenState extends ConsumerState<StatsScreen> {
),
const SizedBox(width: 8),
_FilterChip(
label: 'Pull-up',
isSelected: _selectedExercise == 'pullup',
onTap: () => _onFilterChanged('pullup', _selectedRange),
label: getLabel(pullVariant),
isSelected: _selectedExercise == pullVariant,
onTap: () =>
_onFilterChanged(pullVariant, _selectedRange),
),
const SizedBox(width: 8),
_FilterChip(
label: 'Dip',
isSelected: _selectedExercise == 'dip',
onTap: () => _onFilterChanged('dip', _selectedRange),
label: getLabel(pushVariant),
isSelected: _selectedExercise == pushVariant,
onTap: () =>
_onFilterChanged(pushVariant, _selectedRange),
),
],
),
@ -256,14 +285,35 @@ class _StatsScreenState extends ConsumerState<StatsScreen> {
class _CurrentCycleCard extends StatelessWidget {
final CycleCollection cycle;
final UserCollection user;
final VoidCallback? onFinish;
const _CurrentCycleCard({required this.cycle, required this.onFinish});
const _CurrentCycleCard(
{required this.cycle, required this.user, required this.onFinish});
@override
Widget build(BuildContext context) {
// Drift: Direct access
final tms = cycle.trainingMaxes;
final variants = user.exerciseVariants ?? {};
final pullVariant = variants['pull'] ?? 'pullup';
final pushVariant = variants['push'] ?? 'dip';
String getLabel(String id) {
switch (id) {
case 'squat':
return 'Squat';
case 'pullup':
return 'Pull-up';
case 'row':
return 'Row';
case 'dip':
return 'Dip';
case 'bench':
return 'Bench';
default:
return id;
}
}
return Card(
child: Padding(
@ -303,8 +353,9 @@ class _CurrentCycleCard extends StatelessWidget {
style: Theme.of(context).textTheme.labelLarge),
const SizedBox(height: 16),
_StatRow(label: 'Squat', value: '${tms['squat']} kg'),
_StatRow(label: 'Pull-up', value: '${tms['pullup']} kg'),
_StatRow(label: 'Dip', value: '${tms['dip']} kg'),
_StatRow(
label: getLabel(pullVariant), value: '${tms['pullup']} kg'),
_StatRow(label: getLabel(pushVariant), value: '${tms['dip']} kg'),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,

View file

@ -5,6 +5,7 @@ import 'dart:async';
import '../../../../core/constants/asset_paths.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../shared/data/local/app_database.dart';
import '../../../../shared/domain/entities/exercise.dart';
import '../../../../shared/domain/entities/workout_set.dart';
import '../../../../shared/domain/logic/wendler_calculator.dart';
@ -62,17 +63,52 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
case 'squat':
return AssetPaths.enemyIronGolem;
case 'pullup':
case 'row':
return AssetPaths.enemyGravityDemon;
case 'dip':
case 'bench':
return AssetPaths.enemyPressurePhantom;
default:
return AssetPaths.enemyIronGolem;
}
}
List<Map<String, dynamic>> _getExerciseConfig(int day) {
List<Map<String, dynamic>> _getExerciseConfig(int day, UserCollection user) {
final variants = user.exerciseVariants ?? {};
Map<String, dynamic> getVariant(String slot, String defaultId,
String defaultName, ExerciseType defaultType) {
final variant = variants[slot];
if (slot == 'pull') {
if (variant == 'row') {
return {'id': 'row', 'name': 'Pendlay Row', 'type': ExerciseType.row};
}
return {
'id': 'pullup',
'name': 'Weighted Pull-up',
'type': ExerciseType.pullup
};
}
if (slot == 'push') {
if (variant == 'bench') {
return {
'id': 'bench',
'name': 'Bench Press',
'type': ExerciseType.bench
};
}
return {'id': 'dip', 'name': 'Weighted Dip', 'type': ExerciseType.dip};
}
return {'id': defaultId, 'name': defaultName, 'type': defaultType};
}
switch (day) {
case 1:
final pull = getVariant(
'pull', 'pullup', 'Weighted Pull-up', ExerciseType.pullup);
return [
{
'id': 'squat',
@ -80,21 +116,13 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
'type': ExerciseType.squat,
'isMain': true
},
{
'id': 'pullup',
'name': 'Weighted Pull-up',
'type': ExerciseType.pullup,
'isMain': false
},
{...pull, 'isMain': false},
];
case 2:
final push =
getVariant('push', 'dip', 'Weighted Dip', ExerciseType.dip);
return [
{
'id': 'dip',
'name': 'Weighted Dip',
'type': ExerciseType.dip,
'isMain': true
},
{...push, 'isMain': true},
{
'id': 'squat',
'name': 'Back Squat',
@ -103,19 +131,13 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
},
];
case 3:
final pull = getVariant(
'pull', 'pullup', 'Weighted Pull-up', ExerciseType.pullup);
final push =
getVariant('push', 'dip', 'Weighted Dip', ExerciseType.dip);
return [
{
'id': 'pullup',
'name': 'Weighted Pull-up',
'type': ExerciseType.pullup,
'isMain': true
},
{
'id': 'dip',
'name': 'Weighted Dip',
'type': ExerciseType.dip,
'isMain': false
},
{...pull, 'isMain': true},
{...push, 'isMain': false},
];
default:
return [];
@ -135,7 +157,7 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
}
final exercises = <Exercise>[];
final exerciseConfigs = _getExerciseConfig(widget.day);
final exerciseConfigs = _getExerciseConfig(widget.day, user);
for (final config in exerciseConfigs) {
final id = config['id'] as String;
@ -143,7 +165,11 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
final type = config['type'] as ExerciseType;
final isMain = config['isMain'] as bool;
final tm = trainingMaxesMap[id] ?? 0.0;
String tmKey = id;
if (id == 'bench') tmKey = 'dip';
if (id == 'row') tmKey = 'pullup';
final tm = trainingMaxesMap[tmKey] ?? 0.0;
List<WorkoutSet> sets = [];
if (isMain) {
@ -454,7 +480,10 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
.take(_currentSetIndex)
.fold<int>(0, (sum, set) => sum + set.repsActual);
final isBodyweight = currentExercise.exerciseId != 'squat';
final isTwoSided = currentExercise.exerciseId == 'squat' ||
currentExercise.exerciseId == 'row' ||
currentExercise.exerciseId == 'bench';
final isBodyweight = !isTwoSided;
final barWeight = isBodyweight
? currentExercise.bodyweightAtSession
: (inventory['bar_weight'] as num?)?.toDouble() ?? 20.0;
@ -480,7 +509,7 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
barWeight: barWeight,
availablePlates: platesList,
availableBands: availableBands,
isTwoSided: !isBodyweight,
isTwoSided: isTwoSided,
);
return Scaffold(
@ -764,7 +793,9 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
else
PlateVisualizer(
plateConfiguration: plateResult.plateConfiguration,
isTwoSided: currentExercise.exerciseId == 'squat',
isTwoSided: currentExercise.exerciseId == 'squat' ||
currentExercise.exerciseId == 'row' ||
currentExercise.exerciseId == 'bench',
exerciseName: currentExercise.exerciseName,
),
const SizedBox(height: 32),

View file

@ -13,17 +13,17 @@ class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection());
@override
int get schemaVersion => 2;
int get schemaVersion => 3;
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (Migrator m) async {
await m.createAll();
},
onUpgrade: (Migrator m, int from, int to) async {
if (from < 2) {
await m.createTable(quests);
}
if (from < 3) {
await m.addColumn(users, users.exerciseVariants);
}
},
);
}

View file

@ -55,6 +55,13 @@ class $UsersTable extends Users with TableInfo<$UsersTable, UserCollection> {
requiredDuringInsert: false,
defaultValue: const Constant(70.0));
@override
late final GeneratedColumnWithTypeConverter<Map<String, dynamic>?, String>
exerciseVariants = GeneratedColumn<String>(
'exercise_variants', aliasedName, true,
type: DriftSqlType.string, requiredDuringInsert: false)
.withConverter<Map<String, dynamic>?>(
$UsersTable.$converterexerciseVariantsn);
@override
late final GeneratedColumnWithTypeConverter<Map<String, dynamic>?, String>
inventorySettings = GeneratedColumn<String>(
'inventory_settings', aliasedName, true,
@ -107,6 +114,7 @@ class $UsersTable extends Users with TableInfo<$UsersTable, UserCollection> {
xp,
level,
currentBodyweight,
exerciseVariants,
inventorySettings,
avatarConfig,
lastSyncAt,
@ -187,6 +195,9 @@ class $UsersTable extends Users with TableInfo<$UsersTable, UserCollection> {
.read(DriftSqlType.int, data['${effectivePrefix}level'])!,
currentBodyweight: attachedDatabase.typeMapping.read(
DriftSqlType.double, data['${effectivePrefix}current_bodyweight'])!,
exerciseVariants: $UsersTable.$converterexerciseVariantsn.fromSql(
attachedDatabase.typeMapping.read(DriftSqlType.string,
data['${effectivePrefix}exercise_variants'])),
inventorySettings: $UsersTable.$converterinventorySettingsn.fromSql(
attachedDatabase.typeMapping.read(DriftSqlType.string,
data['${effectivePrefix}inventory_settings'])),
@ -209,6 +220,11 @@ class $UsersTable extends Users with TableInfo<$UsersTable, UserCollection> {
return $UsersTable(attachedDatabase, alias);
}
static TypeConverter<Map<String, dynamic>, String>
$converterexerciseVariants = const MapConverter();
static TypeConverter<Map<String, dynamic>?, String?>
$converterexerciseVariantsn =
NullAwareTypeConverter.wrap($converterexerciseVariants);
static TypeConverter<Map<String, dynamic>, String>
$converterinventorySettings = const MapConverter();
static TypeConverter<Map<String, dynamic>?, String?>
@ -227,6 +243,7 @@ class UserCollection extends DataClass implements Insertable<UserCollection> {
final int xp;
final int level;
final double currentBodyweight;
final Map<String, dynamic>? exerciseVariants;
final Map<String, dynamic>? inventorySettings;
final Map<String, dynamic>? avatarConfig;
final DateTime? lastSyncAt;
@ -240,6 +257,7 @@ class UserCollection extends DataClass implements Insertable<UserCollection> {
required this.xp,
required this.level,
required this.currentBodyweight,
this.exerciseVariants,
this.inventorySettings,
this.avatarConfig,
this.lastSyncAt,
@ -257,6 +275,10 @@ class UserCollection extends DataClass implements Insertable<UserCollection> {
map['xp'] = Variable<int>(xp);
map['level'] = Variable<int>(level);
map['current_bodyweight'] = Variable<double>(currentBodyweight);
if (!nullToAbsent || exerciseVariants != null) {
map['exercise_variants'] = Variable<String>(
$UsersTable.$converterexerciseVariantsn.toSql(exerciseVariants));
}
if (!nullToAbsent || inventorySettings != null) {
map['inventory_settings'] = Variable<String>(
$UsersTable.$converterinventorySettingsn.toSql(inventorySettings));
@ -284,6 +306,9 @@ class UserCollection extends DataClass implements Insertable<UserCollection> {
xp: Value(xp),
level: Value(level),
currentBodyweight: Value(currentBodyweight),
exerciseVariants: exerciseVariants == null && nullToAbsent
? const Value.absent()
: Value(exerciseVariants),
inventorySettings: inventorySettings == null && nullToAbsent
? const Value.absent()
: Value(inventorySettings),
@ -309,6 +334,8 @@ class UserCollection extends DataClass implements Insertable<UserCollection> {
xp: serializer.fromJson<int>(json['xp']),
level: serializer.fromJson<int>(json['level']),
currentBodyweight: serializer.fromJson<double>(json['currentBodyweight']),
exerciseVariants:
serializer.fromJson<Map<String, dynamic>?>(json['exerciseVariants']),
inventorySettings:
serializer.fromJson<Map<String, dynamic>?>(json['inventorySettings']),
avatarConfig:
@ -329,6 +356,8 @@ class UserCollection extends DataClass implements Insertable<UserCollection> {
'xp': serializer.toJson<int>(xp),
'level': serializer.toJson<int>(level),
'currentBodyweight': serializer.toJson<double>(currentBodyweight),
'exerciseVariants':
serializer.toJson<Map<String, dynamic>?>(exerciseVariants),
'inventorySettings':
serializer.toJson<Map<String, dynamic>?>(inventorySettings),
'avatarConfig': serializer.toJson<Map<String, dynamic>?>(avatarConfig),
@ -346,6 +375,7 @@ class UserCollection extends DataClass implements Insertable<UserCollection> {
int? xp,
int? level,
double? currentBodyweight,
Value<Map<String, dynamic>?> exerciseVariants = const Value.absent(),
Value<Map<String, dynamic>?> inventorySettings = const Value.absent(),
Value<Map<String, dynamic>?> avatarConfig = const Value.absent(),
Value<DateTime?> lastSyncAt = const Value.absent(),
@ -359,6 +389,9 @@ class UserCollection extends DataClass implements Insertable<UserCollection> {
xp: xp ?? this.xp,
level: level ?? this.level,
currentBodyweight: currentBodyweight ?? this.currentBodyweight,
exerciseVariants: exerciseVariants.present
? exerciseVariants.value
: this.exerciseVariants,
inventorySettings: inventorySettings.present
? inventorySettings.value
: this.inventorySettings,
@ -379,6 +412,9 @@ class UserCollection extends DataClass implements Insertable<UserCollection> {
currentBodyweight: data.currentBodyweight.present
? data.currentBodyweight.value
: this.currentBodyweight,
exerciseVariants: data.exerciseVariants.present
? data.exerciseVariants.value
: this.exerciseVariants,
inventorySettings: data.inventorySettings.present
? data.inventorySettings.value
: this.inventorySettings,
@ -402,6 +438,7 @@ class UserCollection extends DataClass implements Insertable<UserCollection> {
..write('xp: $xp, ')
..write('level: $level, ')
..write('currentBodyweight: $currentBodyweight, ')
..write('exerciseVariants: $exerciseVariants, ')
..write('inventorySettings: $inventorySettings, ')
..write('avatarConfig: $avatarConfig, ')
..write('lastSyncAt: $lastSyncAt, ')
@ -420,6 +457,7 @@ class UserCollection extends DataClass implements Insertable<UserCollection> {
xp,
level,
currentBodyweight,
exerciseVariants,
inventorySettings,
avatarConfig,
lastSyncAt,
@ -436,6 +474,7 @@ class UserCollection extends DataClass implements Insertable<UserCollection> {
other.xp == this.xp &&
other.level == this.level &&
other.currentBodyweight == this.currentBodyweight &&
other.exerciseVariants == this.exerciseVariants &&
other.inventorySettings == this.inventorySettings &&
other.avatarConfig == this.avatarConfig &&
other.lastSyncAt == this.lastSyncAt &&
@ -451,6 +490,7 @@ class UsersCompanion extends UpdateCompanion<UserCollection> {
final Value<int> xp;
final Value<int> level;
final Value<double> currentBodyweight;
final Value<Map<String, dynamic>?> exerciseVariants;
final Value<Map<String, dynamic>?> inventorySettings;
final Value<Map<String, dynamic>?> avatarConfig;
final Value<DateTime?> lastSyncAt;
@ -464,6 +504,7 @@ class UsersCompanion extends UpdateCompanion<UserCollection> {
this.xp = const Value.absent(),
this.level = const Value.absent(),
this.currentBodyweight = const Value.absent(),
this.exerciseVariants = const Value.absent(),
this.inventorySettings = const Value.absent(),
this.avatarConfig = const Value.absent(),
this.lastSyncAt = const Value.absent(),
@ -478,6 +519,7 @@ class UsersCompanion extends UpdateCompanion<UserCollection> {
this.xp = const Value.absent(),
this.level = const Value.absent(),
this.currentBodyweight = const Value.absent(),
this.exerciseVariants = const Value.absent(),
this.inventorySettings = const Value.absent(),
this.avatarConfig = const Value.absent(),
this.lastSyncAt = const Value.absent(),
@ -492,6 +534,7 @@ class UsersCompanion extends UpdateCompanion<UserCollection> {
Expression<int>? xp,
Expression<int>? level,
Expression<double>? currentBodyweight,
Expression<String>? exerciseVariants,
Expression<String>? inventorySettings,
Expression<String>? avatarConfig,
Expression<DateTime>? lastSyncAt,
@ -506,6 +549,7 @@ class UsersCompanion extends UpdateCompanion<UserCollection> {
if (xp != null) 'xp': xp,
if (level != null) 'level': level,
if (currentBodyweight != null) 'current_bodyweight': currentBodyweight,
if (exerciseVariants != null) 'exercise_variants': exerciseVariants,
if (inventorySettings != null) 'inventory_settings': inventorySettings,
if (avatarConfig != null) 'avatar_config': avatarConfig,
if (lastSyncAt != null) 'last_sync_at': lastSyncAt,
@ -522,6 +566,7 @@ class UsersCompanion extends UpdateCompanion<UserCollection> {
Value<int>? xp,
Value<int>? level,
Value<double>? currentBodyweight,
Value<Map<String, dynamic>?>? exerciseVariants,
Value<Map<String, dynamic>?>? inventorySettings,
Value<Map<String, dynamic>?>? avatarConfig,
Value<DateTime?>? lastSyncAt,
@ -535,6 +580,7 @@ class UsersCompanion extends UpdateCompanion<UserCollection> {
xp: xp ?? this.xp,
level: level ?? this.level,
currentBodyweight: currentBodyweight ?? this.currentBodyweight,
exerciseVariants: exerciseVariants ?? this.exerciseVariants,
inventorySettings: inventorySettings ?? this.inventorySettings,
avatarConfig: avatarConfig ?? this.avatarConfig,
lastSyncAt: lastSyncAt ?? this.lastSyncAt,
@ -565,6 +611,11 @@ class UsersCompanion extends UpdateCompanion<UserCollection> {
if (currentBodyweight.present) {
map['current_bodyweight'] = Variable<double>(currentBodyweight.value);
}
if (exerciseVariants.present) {
map['exercise_variants'] = Variable<String>($UsersTable
.$converterexerciseVariantsn
.toSql(exerciseVariants.value));
}
if (inventorySettings.present) {
map['inventory_settings'] = Variable<String>($UsersTable
.$converterinventorySettingsn
@ -598,6 +649,7 @@ class UsersCompanion extends UpdateCompanion<UserCollection> {
..write('xp: $xp, ')
..write('level: $level, ')
..write('currentBodyweight: $currentBodyweight, ')
..write('exerciseVariants: $exerciseVariants, ')
..write('inventorySettings: $inventorySettings, ')
..write('avatarConfig: $avatarConfig, ')
..write('lastSyncAt: $lastSyncAt, ')
@ -2452,6 +2504,7 @@ typedef $$UsersTableCreateCompanionBuilder = UsersCompanion Function({
Value<int> xp,
Value<int> level,
Value<double> currentBodyweight,
Value<Map<String, dynamic>?> exerciseVariants,
Value<Map<String, dynamic>?> inventorySettings,
Value<Map<String, dynamic>?> avatarConfig,
Value<DateTime?> lastSyncAt,
@ -2466,6 +2519,7 @@ typedef $$UsersTableUpdateCompanionBuilder = UsersCompanion Function({
Value<int> xp,
Value<int> level,
Value<double> currentBodyweight,
Value<Map<String, dynamic>?> exerciseVariants,
Value<Map<String, dynamic>?> inventorySettings,
Value<Map<String, dynamic>?> avatarConfig,
Value<DateTime?> lastSyncAt,
@ -2501,6 +2555,12 @@ class $$UsersTableFilterComposer extends Composer<_$AppDatabase, $UsersTable> {
column: $table.currentBodyweight,
builder: (column) => ColumnFilters(column));
ColumnWithTypeConverterFilters<Map<String, dynamic>?, Map<String, dynamic>,
String>
get exerciseVariants => $composableBuilder(
column: $table.exerciseVariants,
builder: (column) => ColumnWithTypeConverterFilters(column));
ColumnWithTypeConverterFilters<Map<String, dynamic>?, Map<String, dynamic>,
String>
get inventorySettings => $composableBuilder(
@ -2554,6 +2614,10 @@ class $$UsersTableOrderingComposer
column: $table.currentBodyweight,
builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get exerciseVariants => $composableBuilder(
column: $table.exerciseVariants,
builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get inventorySettings => $composableBuilder(
column: $table.inventorySettings,
builder: (column) => ColumnOrderings(column));
@ -2602,6 +2666,10 @@ class $$UsersTableAnnotationComposer
GeneratedColumn<double> get currentBodyweight => $composableBuilder(
column: $table.currentBodyweight, builder: (column) => column);
GeneratedColumnWithTypeConverter<Map<String, dynamic>?, String>
get exerciseVariants => $composableBuilder(
column: $table.exerciseVariants, builder: (column) => column);
GeneratedColumnWithTypeConverter<Map<String, dynamic>?, String>
get inventorySettings => $composableBuilder(
column: $table.inventorySettings, builder: (column) => column);
@ -2655,6 +2723,8 @@ class $$UsersTableTableManager extends RootTableManager<
Value<int> xp = const Value.absent(),
Value<int> level = const Value.absent(),
Value<double> currentBodyweight = const Value.absent(),
Value<Map<String, dynamic>?> exerciseVariants =
const Value.absent(),
Value<Map<String, dynamic>?> inventorySettings =
const Value.absent(),
Value<Map<String, dynamic>?> avatarConfig = const Value.absent(),
@ -2670,6 +2740,7 @@ class $$UsersTableTableManager extends RootTableManager<
xp: xp,
level: level,
currentBodyweight: currentBodyweight,
exerciseVariants: exerciseVariants,
inventorySettings: inventorySettings,
avatarConfig: avatarConfig,
lastSyncAt: lastSyncAt,
@ -2684,6 +2755,8 @@ class $$UsersTableTableManager extends RootTableManager<
Value<int> xp = const Value.absent(),
Value<int> level = const Value.absent(),
Value<double> currentBodyweight = const Value.absent(),
Value<Map<String, dynamic>?> exerciseVariants =
const Value.absent(),
Value<Map<String, dynamic>?> inventorySettings =
const Value.absent(),
Value<Map<String, dynamic>?> avatarConfig = const Value.absent(),
@ -2699,6 +2772,7 @@ class $$UsersTableTableManager extends RootTableManager<
xp: xp,
level: level,
currentBodyweight: currentBodyweight,
exerciseVariants: exerciseVariants,
inventorySettings: inventorySettings,
avatarConfig: avatarConfig,
lastSyncAt: lastSyncAt,

View file

@ -11,6 +11,8 @@ class Users extends Table {
IntColumn get level => integer().withDefault(const Constant(1))();
RealColumn get currentBodyweight =>
real().withDefault(const Constant(70.0))();
TextColumn get exerciseVariants =>
text().map(const MapConverter()).nullable()();
TextColumn get inventorySettings =>
text().map(const MapConverter()).nullable()();

View file

@ -85,6 +85,7 @@ class ApiClient {
required String password,
required double bodyweight,
required Map<String, dynamic> inventorySettings,
Map<String, dynamic>? exerciseVariants,
}) async {
try {
final response = await _dio.post(
@ -97,6 +98,7 @@ class ApiClient {
'level': 1,
'current_bodyweight': bodyweight,
'inventory_settings': inventorySettings,
'exercise_variants': exerciseVariants ?? {},
'avatar_config': {
'skin_tone': 'medium',
'hair_style': 'short_01',

View file

@ -1,233 +1,3 @@
// import 'dart:convert';
// import 'package:flutter/foundation.dart';
// import 'package:flutter_riverpod/flutter_riverpod.dart';
// import 'package:drift/drift.dart';
// import 'package:flutter_secure_storage/flutter_secure_storage.dart';
// import '../../../../main.dart';
// import '../../../core/constants/app_constants.dart';
// import '../local/app_database.dart';
// import '../repositories/user_repository.dart';
// import 'api_client.dart';
// final syncServiceProvider = Provider<SyncService>((ref) {
// final db = ref.watch(appDatabaseProvider);
// final apiClient = ref.watch(apiClientProvider);
// return SyncService(db: db, apiClient: apiClient);
// });
// class SyncService {
// final AppDatabase db;
// final ApiClient apiClient;
// final _storage = const FlutterSecureStorage();
// bool _isSyncing = false;
// SyncService({required this.db, required this.apiClient});
// Future<void> sync() async {
// if (_isSyncing) return;
// _isSyncing = true;
// try {
// debugPrint('🔄 Starting Sync...');
// final dirtyCycles = await (db.select(db.cycles)
// ..where((c) => c.isDirty.equals(true)))
// .get();
// for (var cycle in dirtyCycles) {
// try {
// if (cycle.serverId == null) {
// debugPrint(
// '📤 Pushing new cycle ${cycle.cycleNumber} to server...');
// final tmsMap = cycle.trainingMaxes
// .map((k, v) => MapEntry(k, (v as num).toDouble()));
// final response = await apiClient.createCycle(tmsMap);
// final newServerId = response['id'];
// await db.transaction(() async {
// await (db.update(db.cycles)..where((c) => c.id.equals(cycle.id)))
// .write(
// CyclesCompanion(
// serverId: Value(newServerId),
// isDirty: const Value(false),
// ),
// );
// final oldLocalIdRef = cycle.id.toString();
// await (db.update(db.workouts)
// ..where((w) => w.cycleId.equals(oldLocalIdRef)))
// .write(
// WorkoutsCompanion(
// cycleId: Value(newServerId),
// isDirty: const Value(true),
// ),
// );
// debugPrint(
// '🔗 Relinked workouts from local cycle $oldLocalIdRef to server $newServerId');
// });
// } else {
// await (db.update(db.cycles)..where((c) => c.id.equals(cycle.id)))
// .write(
// const CyclesCompanion(isDirty: Value(false)),
// );
// }
// } catch (e) {
// debugPrint('❌ Failed to sync cycle ${cycle.id}: $e');
// }
// }
// final dirtyUser = await (db.select(db.users)
// ..where((u) => u.isDirty.equals(true)))
// .getSingleOrNull();
// final dirtyWorkouts = await (db.select(db.workouts)
// ..where((w) => w.isDirty.equals(true)))
// .get();
// final validWorkouts =
// dirtyWorkouts.where((w) => w.cycleId.length > 5).toList();
// final pushData = <String, dynamic>{
// 'workouts': validWorkouts.map((w) {
// return {
// 'id': w.serverId,
// 'local_id': w.id,
// 'cycle_id': w.cycleId,
// 'week': w.week,
// 'day': w.day,
// 'completed_at': w.completedAt?.toIso8601String(),
// 'xp_earned': w.xpEarned,
// 'notes': w.notes,
// 'exercises': w.exercises,
// };
// }).toList(),
// 'user_stats': dirtyUser != null
// ? {
// 'xp': dirtyUser.xp,
// 'level': dirtyUser.level,
// 'current_bodyweight': dirtyUser.currentBodyweight,
// }
// : null,
// };
// final lastSync = await _storage.read(key: AppConstants.keyLastSync);
// debugPrint(
// '☁️ Contacting server (Push: ${validWorkouts.length} workouts)...');
// final response = await apiClient.sync(
// lastSyncTimestamp: lastSync ?? '',
// pushData: pushData,
// );
// await db.transaction(() async {
// if (dirtyUser != null) {
// await (db.update(db.users)..where((u) => u.id.equals(dirtyUser.id)))
// .write(
// const UsersCompanion(isDirty: Value(false)),
// );
// }
// for (var w in validWorkouts) {
// await (db.update(db.workouts)..where((dw) => dw.id.equals(w.id)))
// .write(
// const WorkoutsCompanion(isDirty: Value(false)),
// );
// }
// if (response['pull_data'] != null) {
// if (response['pull_data']['cycles'] != null) {
// final pulledCycles = response['pull_data']['cycles'] as List;
// for (var cJson in pulledCycles) {
// final serverId = cJson['id'] as String;
// final existing = await (db.select(db.cycles)
// ..where((c) => c.serverId.equals(serverId)))
// .getSingleOrNull();
// final tms = cJson['training_maxes'] as Map<String, dynamic>;
// final companion = CyclesCompanion(
// serverId: Value(serverId),
// userId: Value(cJson['user_id']),
// cycleNumber: Value(cJson['cycle_number']),
// startDate: Value(DateTime.parse(cJson['start_date'])),
// endDate: Value(DateTime.tryParse(cJson['end_date'] ?? '')),
// isActive: Value(cJson['is_active'] ?? false),
// trainingMaxes: Value(tms),
// isDirty: const Value(false),
// updatedAt: Value(DateTime.now()),
// createdAt: existing == null
// ? Value(DateTime.now())
// : const Value.absent(),
// );
// if (existing != null) {
// await (db.update(db.cycles)
// ..where((c) => c.id.equals(existing.id)))
// .write(companion);
// } else {
// await db.into(db.cycles).insert(companion);
// }
// }
// }
// if (response['pull_data']['workouts'] != null) {
// final pulledWorkouts = response['pull_data']['workouts'] as List;
// debugPrint(
// '📥 Pulled ${pulledWorkouts.length} workouts from server.');
// for (var wJson in pulledWorkouts) {
// final serverId = wJson['id'] as String;
// final existing = await (db.select(db.workouts)
// ..where((w) => w.serverId.equals(serverId)))
// .getSingleOrNull();
// final companion = WorkoutsCompanion(
// serverId: Value(serverId),
// cycleId: Value(wJson['cycle_id']),
// userId: Value(wJson['user_id']),
// week: Value(wJson['week']),
// day: Value(wJson['day']),
// completedAt:
// Value(DateTime.tryParse(wJson['completed_at'] ?? '')),
// xpEarned: Value(wJson['xp_earned'] ?? 0),
// exercises: Value(wJson['exercises'] ?? []),
// notes: Value(wJson['notes'] ?? ''),
// isDirty: const Value(false),
// updatedAt: Value(DateTime.now()),
// createdAt: existing == null
// ? Value(DateTime.now())
// : const Value.absent(),
// );
// if (existing != null) {
// await (db.update(db.workouts)
// ..where((w) => w.id.equals(existing.id)))
// .write(companion);
// } else {
// await db.into(db.workouts).insert(companion);
// }
// }
// }
// }
// });
// if (response['server_timestamp'] != null) {
// await _storage.write(
// key: AppConstants.keyLastSync,
// value: response['server_timestamp'],
// );
// }
// debugPrint('✅ Sync completed successfully');
// } catch (e, stack) {
// debugPrint('❌ Sync failed: $e');
// debugPrint(stack.toString());
// } finally {
// _isSyncing = false;
// }
// }
// }
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -261,7 +31,6 @@ class SyncService {
try {
debugPrint('🔄 Starting Sync...');
// 1. CYCLES SYNC
final dirtyCycles = await (db.select(db.cycles)
..where((c) => c.isDirty.equals(true)))
.get();
@ -285,7 +54,6 @@ class SyncService {
),
);
// Relink workouts
final oldLocalIdRef = cycle.id.toString();
await (db.update(db.workouts)
..where((w) => w.cycleId.equals(oldLocalIdRef)))
@ -305,7 +73,6 @@ class SyncService {
}
}
// 2. USER & WORKOUTS SYNC
final dirtyUser = await (db.select(db.users)
..where((u) => u.isDirty.equals(true)))
.getSingleOrNull();
@ -335,6 +102,7 @@ class SyncService {
'xp': dirtyUser.xp,
'level': dirtyUser.level,
'current_bodyweight': dirtyUser.currentBodyweight,
'exercise_variants': dirtyUser.exerciseVariants,
}
: null,
};
@ -350,7 +118,6 @@ class SyncService {
);
await db.transaction(() async {
// Clean dirty flags
if (dirtyUser != null) {
await (db.update(db.users)..where((u) => u.id.equals(dirtyUser.id)))
.write(const UsersCompanion(isDirty: Value(false)));
@ -360,9 +127,7 @@ class SyncService {
.write(const WorkoutsCompanion(isDirty: Value(false)));
}
// PROCESS PULL DATA
if (response['pull_data'] != null) {
// Cycles Pull
if (response['pull_data']['cycles'] != null) {
final pulledCycles = response['pull_data']['cycles'] as List;
for (var cJson in pulledCycles) {
@ -397,7 +162,6 @@ class SyncService {
}
}
// Workouts Pull - MIT DUPLIKAT-SCHUTZ
if (response['pull_data']['workouts'] != null) {
final pulledWorkouts = response['pull_data']['workouts'] as List;
debugPrint('📥 Pulled ${pulledWorkouts.length} workouts.');
@ -408,13 +172,10 @@ class SyncService {
final week = wJson['week'] as int;
final day = wJson['day'] as int;
// 1. Versuch: Match über Server ID
var existing = await (db.select(db.workouts)
..where((w) => w.serverId.equals(serverId)))
.getSingleOrNull();
// 2. Versuch: Match über Logik (Cycle + Week + Day)
// Das verhindert Duplikate, wenn ServerID lokal noch fehlt
if (existing == null) {
final candidates = await (db.select(db.workouts)
..where((w) =>

View file

@ -105,6 +105,7 @@ class UserRepository {
required String password,
required double bodyweight,
required Map<String, dynamic> inventorySettings,
Map<String, dynamic>? exerciseVariants,
}) async {
try {
final response = await apiClient.register(
@ -112,10 +113,27 @@ class UserRepository {
password: password,
bodyweight: bodyweight,
inventorySettings: inventorySettings,
exerciseVariants: exerciseVariants,
);
final record = response['record'] ?? response;
final user = await _saveUserFromApi(record);
var user = await _saveUserFromApi(record);
if (exerciseVariants != null && exerciseVariants.isNotEmpty) {
final serverVariants = user.exerciseVariants;
if (serverVariants == null || serverVariants.isEmpty) {
final companion = user.toCompanion(true).copyWith(
exerciseVariants: Value(exerciseVariants),
isDirty: const Value(true),
updatedAt: Value(DateTime.now()),
);
await db.into(db.users).insertOnConflictUpdate(companion);
user = (await (db.select(db.users)
..where((u) => u.id.equals(user.id)))
.getSingle());
}
}
try {
await apiClient.login(email, password);
@ -138,6 +156,7 @@ class UserRepository {
currentBodyweight:
Value((record['current_bodyweight'] as num?)?.toDouble() ?? 70.0),
inventorySettings: Value(record['inventory_settings'] ?? {}),
exerciseVariants: Value(record['exercise_variants'] ?? {}),
avatarConfig: Value(record['avatar_config'] ?? {}),
lastSyncAt: Value(DateTime.now()),
isDirty: const Value(false),
@ -159,6 +178,7 @@ class UserRepository {
await db.delete(db.users).go();
await db.delete(db.cycles).go();
await db.delete(db.workouts).go();
await db.delete(db.quests).go();
});
}

View file

@ -2,7 +2,7 @@ import 'dart:math';
import '../entities/workout_set.dart';
import '../../../core/constants/app_constants.dart';
enum ExerciseType { squat, pullup, dip }
enum ExerciseType { squat, pullup, dip, row, bench }
class WendlerCalculator {
static const Map<int, List<double>> weekPercentages = {
@ -51,8 +51,38 @@ class WendlerCalculator {
return sets;
}
static List<WorkoutSet> generateLinearSets({
required double trainingMax,
required ExerciseType exerciseType,
required double currentBodyweight,
int setsCount = 3,
int repsCount = 5,
}) {
final sets = <WorkoutSet>[];
final targetTotal = _roundWeight(trainingMax, exerciseType);
double plateWeight = 0;
for (int i = 0; i < setsCount; i++) {
sets.add(WorkoutSet(
setNumber: i + 1,
targetPercentage: 100,
targetWeightTotal: targetTotal,
plateWeight: plateWeight,
repsTarget: repsCount,
repsActual: 0,
isAmrap: (i == setsCount - 1),
));
}
return sets;
}
static double _roundWeight(double weight, ExerciseType type) {
final step = type == ExerciseType.squat
final step = (type == ExerciseType.squat ||
type == ExerciseType.row ||
type == ExerciseType.bench)
? AppConstants.squatRoundingStep
: AppConstants.calisthenicsRoundingStep;
return (weight / step).floor() * step;