refactor: rebuild using drift instead of isar

This commit is contained in:
Patryk Hegenberg 2025-12-01 12:17:11 +01:00
parent 952e82eb08
commit ee89f327bd
25 changed files with 2182 additions and 6389 deletions

View file

@ -1,13 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:isar/isar.dart';
import 'package:path_provider/path_provider.dart';
import 'src/app.dart';
import 'src/shared/data/local/collections/user_collection.dart';
import 'src/shared/data/local/collections/cycle_collection.dart';
import 'src/shared/data/local/collections/workout_collection.dart';
import 'src/shared/data/local/app_database.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@ -17,19 +12,15 @@ void main() async {
DeviceOrientation.portraitDown,
]);
final dir = await getApplicationDocumentsDirectory();
final isar = await Isar.open(
[UserCollectionSchema, CycleCollectionSchema, WorkoutCollectionSchema],
directory: dir.path,
name: 'slrpg_db',
);
final database = AppDatabase();
runApp(
ProviderScope(
overrides: [isarProvider.overrideWithValue(isar)],
overrides: [appDatabaseProvider.overrideWithValue(database)],
child: const SLRPGApp(),
),
);
}
final isarProvider = Provider<Isar>((ref) => throw UnimplementedError());
final appDatabaseProvider =
Provider<AppDatabase>((ref) => throw UnimplementedError());

View file

@ -43,10 +43,22 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
}
} catch (e) {
if (mounted) {
String message = 'Login failed. Please try again.';
final errorStr = e.toString();
if (errorStr.contains('400')) {
message = 'Invalid email or password.';
} else if (errorStr.contains('SocketException') ||
errorStr.contains('Connection refused') ||
errorStr.contains('Network is unreachable')) {
message = 'Could not connect to server. Please check your internet.';
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Login failed: ${e.toString()}'),
content: Text(message),
backgroundColor: AppTheme.errorColor,
behavior: SnackBarBehavior.floating,
),
);
}
@ -70,7 +82,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Logo
Container(
width: 100,
height: 100,
@ -85,7 +96,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
),
),
const SizedBox(height: 24),
Text(
'WELCOME BACK',
style: Theme.of(context).textTheme.displayMedium,
@ -98,7 +108,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
@ -117,7 +126,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
@ -146,7 +154,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
},
),
const SizedBox(height: 32),
ElevatedButton(
onPressed: _isLoading ? null : _handleLogin,
child: _isLoading
@ -161,7 +168,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
: const Text('LOGIN'),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [

View file

@ -1,14 +1,14 @@
import 'package:drift/drift.dart' hide Column;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../shared/data/repositories/user_repository.dart';
import '../../../../shared/data/local/collections/user_collection.dart';
import '../../../../shared/data/local/app_database.dart';
import '../../../gamification/domain/entities/avatar_config.dart';
import '../../../gamification/presentation/widgets/avatar_editor.dart';
import '../../../gamification/presentation/widgets/avatar_renderer.dart';
import 'dart:convert';
class ProfileScreen extends ConsumerStatefulWidget {
const ProfileScreen({super.key});
@ -46,6 +46,11 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
await ref
.read(userRepositoryProvider)
.updateBodyweight(_currentBodyweight);
if (_user != null) {
_user = _user!.copyWith(currentBodyweight: _currentBodyweight);
}
if (mounted) {
setState(() => _hasChanges = false);
ScaffoldMessenger.of(context).showSnackBar(
@ -163,8 +168,8 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
}
void _showAvatarEditor() {
final currentConfig = _user?.avatarConfigJson != null
? AvatarConfig.fromJson(jsonDecode(_user!.avatarConfigJson!))
final currentConfig = _user?.avatarConfig != null
? AvatarConfig.fromJson(_user!.avatarConfig!)
: const AvatarConfig();
showModalBottomSheet(
@ -199,8 +204,13 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
).then((result) async {
if (result is AvatarConfig) {
setState(() => _isLoading = true);
_user!.avatarConfigJson = jsonEncode(result.toJson());
_user!.isDirty = true;
final updatedUser = _user!.copyWith(
avatarConfig: Value(result.toJson()),
isDirty: true,
);
_user = updatedUser;
await ref.read(userRepositoryProvider).saveLocalUser(_user!);
setState(() => _isLoading = false);
}
@ -210,8 +220,8 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
@override
Widget build(BuildContext context) {
final userRepo = ref.watch(userRepositoryProvider);
final avatarConfig = _user?.avatarConfigJson != null
? AvatarConfig.fromJson(jsonDecode(_user!.avatarConfigJson!))
final avatarConfig = _user?.avatarConfig != null
? AvatarConfig.fromJson(_user!.avatarConfig!)
: const AvatarConfig();
return Scaffold(

View file

@ -6,21 +6,20 @@ import 'package:go_router/go_router.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';
import '../../../../shared/data/repositories/cycle_repository.dart';
import '../../../../shared/domain/logic/xp_calculator.dart';
import '../widgets/xp_bar_widget.dart';
import '../widgets/level_display.dart';
import '../widgets/start_raid_button.dart';
import '../../../../shared/data/local/collections/user_collection.dart';
import '../../../../shared/data/local/collections/cycle_collection.dart';
import '../../../../shared/data/repositories/workout_repository.dart';
import '../../../../shared/domain/entities/exercise.dart';
import '../../../../shared/domain/logic/wendler_calculator.dart';
import '../../../../shared/data/remote/sync_service.dart';
import '../../../../shared/domain/logic/xp_calculator.dart';
import '../../../../shared/domain/logic/wendler_calculator.dart';
import '../../../../shared/domain/entities/exercise.dart';
import '../../../../shared/domain/entities/workout_set.dart';
import '../../../gamification/domain/entities/avatar_config.dart';
import '../../../gamification/presentation/widgets/avatar_renderer.dart';
import '../widgets/xp_bar_widget.dart';
import '../widgets/level_display.dart';
import '../widgets/start_raid_button.dart';
class HubScreen extends ConsumerStatefulWidget {
const HubScreen({super.key});
@ -41,7 +40,6 @@ class _HubScreenState extends ConsumerState<HubScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_runSync();
});
@ -104,7 +102,10 @@ class _HubScreenState extends ConsumerState<HubScreen> {
CycleCollection cycle, UserCollection user) async {
try {
final workoutRepo = ref.read(workoutRepositoryProvider);
final cycleRepo = ref.read(cycleRepositoryProvider);
final tmsDynamic = cycle.trainingMaxes;
final trainingMaxes = Map<String, double>.from(tmsDynamic
.map((key, value) => MapEntry(key, (value as num).toDouble())));
int targetWeek = 1;
int targetDay = 1;
@ -138,8 +139,6 @@ class _HubScreenState extends ConsumerState<HubScreen> {
return;
}
final trainingMaxes = cycleRepo.getCurrentTrainingMaxes();
var workout = await workoutRepo.getWorkoutByWeekDay(
cycleId: cycleRefId,
localCycleId: localCycleId,
@ -162,7 +161,7 @@ class _HubScreenState extends ConsumerState<HubScreen> {
cycleId: cycleRefId,
week: targetWeek,
day: targetDay,
exercisesJson: jsonEncode(exercises.map((e) => e.toJson()).toList()),
exercises: exercises.map((e) => e.toJson()).toList(),
);
}
@ -202,8 +201,9 @@ class _HubScreenState extends ConsumerState<HubScreen> {
final user = snapshot.data![0] as UserCollection?;
final cycle = snapshot.data![1] as CycleCollection?;
final avatarConfig = user?.avatarConfigJson != null
? AvatarConfig.fromJson(jsonDecode(user!.avatarConfigJson!))
final avatarConfig = user?.avatarConfig != null
? AvatarConfig.fromJson(user!.avatarConfig!)
: const AvatarConfig();
if (user == null) {

View file

@ -1,15 +1,13 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../shared/data/local/app_database.dart';
import '../../../../shared/data/repositories/user_repository.dart';
import '../../../../shared/data/repositories/workout_repository.dart';
import '../../../../shared/data/local/collections/workout_collection.dart';
import '../../../../shared/domain/entities/exercise.dart';
import '../../../../shared/domain/entities/workout_set.dart';
class HistoryScreen extends ConsumerStatefulWidget {
const HistoryScreen({super.key});
@ -27,13 +25,11 @@ class _HistoryScreenState extends ConsumerState<HistoryScreen> {
if (user == null) return [];
final userId = user.serverId ?? user.id.toString();
return workoutRepo.getCompletedWorkouts(userId); // ID übergeben
return workoutRepo.getCompletedWorkouts(userId);
}
@override
Widget build(BuildContext context) {
final workoutRepo = ref.watch(workoutRepositoryProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Quest Log'),
@ -74,7 +70,7 @@ class _HistoryScreenState extends ConsumerState<HistoryScreen> {
);
}
final workouts = snapshot.data!
final workouts = List<WorkoutCollection>.from(snapshot.data!)
..sort((a, b) => b.completedAt!.compareTo(a.completedAt!));
return ListView.builder(
@ -98,8 +94,10 @@ class _WorkoutHistoryCard extends StatelessWidget {
List<Exercise> _parseExercises() {
try {
final List<dynamic> jsonList = jsonDecode(workout.exercisesJson);
return jsonList.map((json) => Exercise.fromJson(json)).toList();
final List<dynamic> list = workout.exercises;
return list
.map((json) => Exercise.fromJson(json as Map<String, dynamic>))
.toList();
} catch (e) {
debugPrint('Error parsing workout history: $e');
return [];
@ -237,10 +235,10 @@ class _ExerciseDetailRow extends StatelessWidget {
Table(
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
columnWidths: const {
0: FlexColumnWidth(1), // Set
1: FlexColumnWidth(2), // Weight
2: FlexColumnWidth(2), // Reps
3: FlexColumnWidth(1), // Type (AMRAP/FSL)
0: FlexColumnWidth(1),
1: FlexColumnWidth(2),
2: FlexColumnWidth(2),
3: FlexColumnWidth(1),
},
children: exercise.sets.where((s) => s.completed).map((set) {
return TableRow(

View file

@ -29,7 +29,7 @@ class _InventoryScreenState extends ConsumerState<InventoryScreen> {
Future<void> _loadCurrentInventory() async {
final userRepo = ref.read(userRepositoryProvider);
final inventory = userRepo.getInventorySettings();
final inventory = await userRepo.getInventorySettingsAsync();
final barWeight = (inventory['bar_weight'] as num?)?.toDouble() ?? 20.0;
@ -62,7 +62,8 @@ class _InventoryScreenState extends ConsumerState<InventoryScreen> {
};
for (var b in bandsList) {
final color = b['color'] as String;
final band = b as Map<String, dynamic>;
final color = band['color'] as String;
if (bandMap.containsKey(color)) {
bandMap[color] = true;
}

View file

@ -1,12 +1,12 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:drift/drift.dart' hide Column;
import '../../../../core/theme/app_theme.dart';
import '../../../../core/constants/app_constants.dart';
import '../../../../shared/data/repositories/user_repository.dart';
import '../../../../shared/data/repositories/cycle_repository.dart';
import '../../../../shared/data/local/app_database.dart';
import '../../../gamification/domain/entities/avatar_config.dart';
import '../../../gamification/presentation/widgets/avatar_editor.dart';
import 'bodyweight_input_screen.dart';
@ -41,10 +41,12 @@ class _AvatarSetupScreenState extends ConsumerState<AvatarSetupScreen> {
inventorySettings: inventorySettings,
);
} else {
user.currentBodyweight =
onboardingData['bodyweight'] ?? user.currentBodyweight;
user.inventorySettingsJson = jsonEncode(inventorySettings);
user.isDirty = true;
user = user.copyWith(
currentBodyweight:
onboardingData['bodyweight'] ?? user.currentBodyweight,
inventorySettings: Value(inventorySettings),
isDirty: true,
);
await userRepo.saveLocalUser(user);
try {
@ -55,8 +57,10 @@ class _AvatarSetupScreenState extends ConsumerState<AvatarSetupScreen> {
}
}
user!.avatarConfigJson = jsonEncode(_config.toJson());
user.isDirty = true;
user = user.copyWith(
avatarConfig: Value(_config.toJson()),
isDirty: true,
);
await userRepo.saveLocalUser(user);
final trainingMaxes =

View file

@ -1,12 +1,10 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../shared/data/local/app_database.dart'; // Drift Models
import '../../../../shared/data/repositories/cycle_repository.dart';
import '../../../../shared/data/local/collections/cycle_collection.dart';
import '../../../../shared/data/remote/api_client.dart'; // Zugriff auf API
import '../../../../shared/data/repositories/user_repository.dart';
import '../../../../shared/data/repositories/workout_repository.dart';
import '../../../../shared/domain/entities/exercise.dart';
@ -55,19 +53,15 @@ class _StatsScreenState extends ConsumerState<StatsScreen> {
for (var workout in allWorkouts) {
if (workout.completedAt == null) continue;
List<dynamic> exercisesJson = [];
try {
exercisesJson = jsonDecode(workout.exercisesJson);
} catch (e) {
continue;
}
final exercisesList = workout.exercises;
double max1RM = 0.0;
double sessionVolume = 0.0;
bool foundExercise = false;
double trainingMax = 0.0;
for (var exJson in exercisesJson) {
for (var exDynamic in exercisesList) {
final exJson = exDynamic as Map<String, dynamic>;
final exercise = Exercise.fromJson(exJson);
if (exercise.exerciseId == _selectedExercise) {
@ -147,12 +141,10 @@ class _StatsScreenState extends ConsumerState<StatsScreen> {
try {
final cycleRepo = ref.read(cycleRepositoryProvider);
final oldTMs =
jsonDecode(currentCycle.trainingMaxesJson) as Map<String, dynamic>;
final oldTMs = currentCycle.trainingMaxes;
final newCycle = await cycleRepo.finishCycle();
final newTMs =
jsonDecode(newCycle.trainingMaxesJson) as Map<String, dynamic>;
final newTMs = newCycle.trainingMaxes;
if (mounted) {
await showDialog(
@ -215,7 +207,6 @@ class _StatsScreenState extends ConsumerState<StatsScreen> {
),
const SizedBox(height: 24),
],
Text(
'Progress Analysis',
style: Theme.of(context)
@ -224,7 +215,6 @@ class _StatsScreenState extends ConsumerState<StatsScreen> {
?.copyWith(color: AppTheme.textPrimary),
),
const SizedBox(height: 16),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
@ -250,14 +240,11 @@ class _StatsScreenState extends ConsumerState<StatsScreen> {
),
),
const SizedBox(height: 16),
_isChartLoading
? const SizedBox(
height: 250,
child: Center(child: CircularProgressIndicator()))
: ProgressChart(data: _chartData),
// (Optional: Range Selector unten drunter '1M', '3M', '1Y'...)
],
),
);
@ -275,7 +262,8 @@ class _CurrentCycleCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final tms = jsonDecode(cycle.trainingMaxesJson) as Map<String, dynamic>;
// Drift: Direct access
final tms = cycle.trainingMaxes;
return Card(
child: Padding(
@ -380,7 +368,7 @@ class _CycleFinishDialog extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'You have defeated the guardians of this cycle. But deeper in the dungeon, stronger foes await...'), // Story
'You have defeated the guardians of this cycle. But deeper in the dungeon, stronger foes await...'),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
@ -388,12 +376,17 @@ class _CycleFinishDialog extends StatelessWidget {
style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
_DiffRow(
name: 'Squat', oldVal: oldTMs['squat'], newVal: newTMs['squat']),
name: 'Squat',
oldVal: (oldTMs['squat'] as num).toDouble(),
newVal: (newTMs['squat'] as num).toDouble()),
_DiffRow(
name: 'Pull-up',
oldVal: oldTMs['pullup'],
newVal: newTMs['pullup']),
_DiffRow(name: 'Dip', oldVal: oldTMs['dip'], newVal: newTMs['dip']),
oldVal: (oldTMs['pullup'] as num).toDouble(),
newVal: (newTMs['pullup'] as num).toDouble()),
_DiffRow(
name: 'Dip',
oldVal: (oldTMs['dip'] as num).toDouble(),
newVal: (newTMs['dip'] as num).toDouble()),
],
),
actions: [

View file

@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'dart:async';
import 'dart:convert';
import '../../../../core/constants/asset_paths.dart';
import '../../../../core/theme/app_theme.dart';
@ -127,16 +126,14 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
final cycleRepo = ref.read(cycleRepositoryProvider);
final user = await userRepo.getLocalUser();
final cycle = await cycleRepo.getCurrentCycle();
final trainingMaxesMap = await cycleRepo.getCurrentTrainingMaxesAsync();
if (user == null || cycle == null) {
if (user == null) {
if (mounted) context.go('/hub');
return;
}
final trainingMaxes = cycleRepo.getCurrentTrainingMaxes();
final exercises = <Exercise>[];
final exerciseConfigs = _getExerciseConfig(widget.day);
for (final config in exerciseConfigs) {
@ -145,7 +142,7 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
final type = config['type'] as ExerciseType;
final isMain = config['isMain'] as bool;
final tm = trainingMaxes[id] ?? 0.0;
final tm = trainingMaxesMap[id] ?? 0.0;
List<WorkoutSet> sets = [];
if (isMain) {
@ -175,14 +172,16 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
}
}
setState(() {
_exercises = exercises;
_isLoading = false;
if (mounted) {
setState(() {
_exercises = exercises;
_isLoading = false;
if (exercises.isNotEmpty && exercises.first.sets.isNotEmpty) {
_repsCompleted = exercises.first.sets.first.repsTarget;
}
});
if (exercises.isNotEmpty && exercises.first.sets.isNotEmpty) {
_repsCompleted = exercises.first.sets.first.repsTarget;
}
});
}
}
void _completeSet() {
@ -286,9 +285,10 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
cycleId: cycleIdRef, week: widget.week, day: widget.day);
if (workout != null) {
workout.exercisesJson =
jsonEncode(_exercises.map((e) => e.toJson()).toList());
await workoutRepo.completeWorkout(workout, xpEarned: xpEarned);
final updatedExercises = _exercises.map((e) => e.toJson()).toList();
final updatedWorkout = workout.copyWith(exercises: updatedExercises);
await workoutRepo.completeWorkout(updatedWorkout, xpEarned: xpEarned);
ref.read(syncServiceProvider).sync();
}
@ -355,7 +355,7 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
),
const SizedBox(height: 8),
Text(
'The monsters tremble at your new power.', // Story Flavor
'The monsters tremble at your new power.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.black54, fontStyle: FontStyle.italic),
textAlign: TextAlign.center,
@ -381,6 +381,17 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
);
}
void _handleCompletePress(WorkoutSet currentSet) {
if (currentSet.isAmrap) {
_showAmrapDialog(currentSet);
} else {
setState(() {
_repsCompleted = currentSet.repsTarget;
});
_completeSet();
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
@ -396,101 +407,115 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
final currentExercise = _exercises[_currentExerciseIndex];
final currentSet = currentExercise.sets[_currentSetIndex];
final userRepo = ref.watch(userRepositoryProvider);
final totalHP = _exercises.fold<int>(
0,
(sum, ex) => sum + ex.sets.fold<int>(0, (s, set) => s + set.repsTarget),
);
return FutureBuilder<Map<String, dynamic>>(
future: ref.read(userRepositoryProvider).getInventorySettingsAsync(),
builder: (context, snapshot) {
if (!snapshot.hasData)
return const Scaffold(
body: Center(child: CircularProgressIndicator()));
final completedHP = _exercises.take(_currentExerciseIndex).fold<int>(
0,
(sum, ex) =>
sum + ex.sets.fold<int>(0, (s, set) => s + set.repsActual),
) +
currentExercise.sets
.take(_currentSetIndex)
.fold<int>(0, (sum, set) => sum + set.repsActual);
final inventory = snapshot.data!;
final isBodyweight = currentExercise.exerciseId != 'squat';
final barWeight = isBodyweight
? currentExercise.bodyweightAtSession
: userRepo.getBarWeight();
final availablePlates = userRepo.getAvailablePlates();
final inventory = userRepo.getInventorySettings();
final bandsList =
(inventory['bands'] as List?)?.cast<Map<String, dynamic>>() ?? [];
final totalHP = _exercises.fold<int>(
0,
(sum, ex) =>
sum + ex.sets.fold<int>(0, (s, set) => s + set.repsTarget),
);
final Map<String, double> availableBands = {};
for (var band in bandsList) {
final color = band['color'] as String;
final resistance = (band['resistance_kg'] as num).toDouble();
if (band['count'] as int > 0) {
availableBands[color] = resistance;
}
}
final completedHP = _exercises.take(_currentExerciseIndex).fold<int>(
0,
(sum, ex) =>
sum +
ex.sets.fold<int>(0, (s, set) => s + set.repsActual),
) +
currentExercise.sets
.take(_currentSetIndex)
.fold<int>(0, (sum, set) => sum + set.repsActual);
final plateResult = PlateCalculator.calculate(
targetWeight: currentSet.targetWeightTotal,
barWeight: barWeight,
availablePlates: availablePlates,
availableBands: availableBands,
isTwoSided: !isBodyweight,
);
final isBodyweight = currentExercise.exerciseId != 'squat';
final barWeight = isBodyweight
? currentExercise.bodyweightAtSession
: (inventory['bar_weight'] as num?)?.toDouble() ?? 20.0;
return Scaffold(
appBar: AppBar(
title: Text('Week ${widget.week} - Day ${widget.day}'),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Abandon Raid?'),
content: const Text('Your progress will not be saved.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('CANCEL'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
context.go('/hub');
},
style: TextButton.styleFrom(
foregroundColor: AppTheme.errorColor),
child: const Text('ABANDON'),
),
],
final platesList = (inventory['plates'] as List?)
?.map((e) => (e as num).toDouble())
.toList() ??
[];
final bandsList =
(inventory['bands'] as List?)?.cast<Map<String, dynamic>>() ?? [];
final Map<String, double> availableBands = {};
for (var band in bandsList) {
final color = band['color'] as String;
final resistance = (band['resistance_kg'] as num).toDouble();
if (band['count'] as int > 0) {
availableBands[color] = resistance;
}
}
final plateResult = PlateCalculator.calculate(
targetWeight: currentSet.targetWeightTotal,
barWeight: barWeight,
availablePlates: platesList,
availableBands: availableBands,
isTwoSided: !isBodyweight,
);
return Scaffold(
appBar: AppBar(
title: Text('Week ${widget.week} - Day ${widget.day}'),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Abandon Raid?'),
content: const Text('Your progress will not be saved.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('CANCEL'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
context.go('/hub');
},
style: TextButton.styleFrom(
foregroundColor: AppTheme.errorColor),
child: const Text('ABANDON'),
),
],
),
);
},
),
);
},
),
),
body: Stack(
children: [
Positioned.fill(
child: Image.asset(
AssetPaths.bgUndergroundGym,
fit: BoxFit.cover,
),
),
Positioned.fill(
child: Container(
color: Colors.black.withOpacity(0.7),
body: Stack(
children: [
Positioned.fill(
child: Image.asset(
AssetPaths.bgUndergroundGym,
fit: BoxFit.cover,
),
),
Positioned.fill(
child: Container(
color: Colors.black.withOpacity(0.7),
),
),
SafeArea(
child: _isResting
? _buildRestScreen()
: _buildWorkoutScreen(currentExercise, currentSet,
plateResult, completedHP, totalHP),
),
],
),
),
SafeArea(
child: _isResting
? _buildRestScreen()
: _buildWorkoutScreen(currentExercise, currentSet, plateResult,
completedHP, totalHP),
),
],
),
);
);
});
}
Widget _buildRestScreen() {
@ -759,17 +784,6 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
);
}
void _handleCompletePress(WorkoutSet currentSet) {
if (currentSet.isAmrap) {
_showAmrapDialog(currentSet);
} else {
setState(() {
_repsCompleted = currentSet.repsTarget;
});
_completeSet();
}
}
String _formatTime(int seconds) {
final minutes = seconds ~/ 60;
final secs = seconds % 60;

View file

@ -0,0 +1,32 @@
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
import 'tables.dart';
import 'converters/json_converter.dart';
part 'app_database.g.dart';
@DriftDatabase(tables: [Users, Cycles, Workouts])
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection());
@override
int get schemaVersion => 1;
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (Migrator m) async {
await m.createAll();
},
);
}
LazyDatabase _openConnection() {
return LazyDatabase(() async {
final dbFolder = await getApplicationDocumentsDirectory();
final file = File(p.join(dbFolder.path, 'slrpg.sqlite'));
return NativeDatabase.createInBackground(file);
});
}

File diff suppressed because it is too large Load diff

View file

@ -1,27 +0,0 @@
import 'package:isar/isar.dart';
part 'cycle_collection.g.dart';
@collection
class CycleCollection {
Id id = Isar.autoIncrement;
@Index(unique: true)
String? serverId;
String userId = ''; // Local reference
int cycleNumber = 1;
DateTime startDate = DateTime.now();
DateTime? endDate;
bool isActive = true;
// Training Maxes (stored as JSON string)
String trainingMaxesJson = '{}';
bool isDirty = false;
DateTime createdAt = DateTime.now();
DateTime updatedAt = DateTime.now();
}

File diff suppressed because it is too large Load diff

View file

@ -1,25 +0,0 @@
import 'package:isar/isar.dart';
part 'user_collection.g.dart';
@collection
class UserCollection {
Id id = Isar.autoIncrement;
@Index(unique: true)
String? serverId;
String email = '';
int xp = 0;
int level = 1;
double currentBodyweight = 70.0;
String? inventorySettingsJson;
String? avatarConfigJson;
DateTime? lastSyncAt;
bool isDirty = false;
DateTime createdAt = DateTime.now();
DateTime updatedAt = DateTime.now();
}

File diff suppressed because it is too large Load diff

View file

@ -1,32 +0,0 @@
import 'package:isar/isar.dart';
part 'workout_collection.g.dart';
@collection
class WorkoutCollection {
Id id = Isar.autoIncrement;
// @Index(unique: true)
@Index()
String? serverId;
String userId = '';
String cycleId = '';
int week = 1; // 1-4
int day = 1; // 1-3
DateTime? scheduledDate;
DateTime? completedAt;
int xpEarned = 0;
// Exercises data (JSON string)
String exercisesJson = '[]';
String notes = '';
bool isDirty = false;
DateTime createdAt = DateTime.now();
DateTime updatedAt = DateTime.now();
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,36 @@
import 'dart:convert';
import 'package:drift/drift.dart';
class MapConverter extends TypeConverter<Map<String, dynamic>, String> {
const MapConverter();
@override
Map<String, dynamic> fromSql(String fromDb) {
if (fromDb.isEmpty) return {};
try {
return json.decode(fromDb) as Map<String, dynamic>;
} catch (_) {
return {};
}
}
@override
String toSql(Map<String, dynamic> value) => json.encode(value);
}
class ListConverter extends TypeConverter<List<dynamic>, String> {
const ListConverter();
@override
List<dynamic> fromSql(String fromDb) {
if (fromDb.isEmpty) return [];
try {
return json.decode(fromDb) as List<dynamic>;
} catch (_) {
return [];
}
}
@override
String toSql(List<dynamic> value) => json.encode(value);
}

View file

@ -0,0 +1,66 @@
import 'package:drift/drift.dart';
import 'converters/json_converter.dart';
@DataClassName('UserCollection')
class Users extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get serverId => text().nullable().unique()();
TextColumn get email => text().withDefault(const Constant(''))();
IntColumn get xp => integer().withDefault(const Constant(0))();
IntColumn get level => integer().withDefault(const Constant(1))();
RealColumn get currentBodyweight =>
real().withDefault(const Constant(70.0))();
TextColumn get inventorySettings =>
text().map(const MapConverter()).nullable()();
TextColumn get avatarConfig => text().map(const MapConverter()).nullable()();
DateTimeColumn get lastSyncAt => dateTime().nullable()();
BoolColumn get isDirty => boolean().withDefault(const Constant(false))();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
}
@DataClassName('CycleCollection')
class Cycles extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get serverId => text().nullable().unique()();
TextColumn get userId => text()();
IntColumn get cycleNumber => integer()();
DateTimeColumn get startDate => dateTime()();
DateTimeColumn get endDate => dateTime().nullable()();
BoolColumn get isActive => boolean().withDefault(const Constant(true))();
TextColumn get trainingMaxes => text().map(const MapConverter())();
BoolColumn get isDirty => boolean().withDefault(const Constant(false))();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
}
@DataClassName('WorkoutCollection')
class Workouts extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get serverId => text().nullable().unique()();
TextColumn get userId => text()();
TextColumn get cycleId => text()();
IntColumn get week => integer()();
IntColumn get day => integer()();
DateTimeColumn get scheduledDate => dateTime().nullable()();
DateTimeColumn get completedAt => dateTime().nullable()();
IntColumn get xpEarned => integer().withDefault(const Constant(0))();
TextColumn get exercises => text().map(const ListConverter())();
TextColumn get notes => text().withDefault(const Constant(''))();
BoolColumn get isDirty => boolean().withDefault(const Constant(false))();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
}

View file

@ -1,30 +1,28 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:isar/isar.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/collections/user_collection.dart';
import '../local/collections/cycle_collection.dart';
import '../local/collections/workout_collection.dart';
import 'api_client.dart';
import '../local/app_database.dart';
import '../repositories/user_repository.dart';
import 'api_client.dart';
final syncServiceProvider = Provider<SyncService>((ref) {
final isar = ref.watch(isarProvider);
final db = ref.watch(appDatabaseProvider);
final apiClient = ref.watch(apiClientProvider);
return SyncService(isar: isar, apiClient: apiClient);
return SyncService(db: db, apiClient: apiClient);
});
class SyncService {
final Isar isar;
final AppDatabase db;
final ApiClient apiClient;
final _storage = const FlutterSecureStorage();
bool _isSyncing = false;
SyncService({required this.isar, required this.apiClient});
SyncService({required this.db, required this.apiClient});
Future<void> sync() async {
if (_isSyncing) return;
@ -32,160 +30,199 @@ class SyncService {
try {
debugPrint('🔄 Starting Sync...');
final dirtyCycles =
await isar.cycleCollections.filter().isDirtyEqualTo(true).findAll();
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...');
Map<String, double> tmsMap = {};
try {
final tms = jsonDecode(cycle.trainingMaxesJson);
tmsMap = Map<String, double>.from(
tms.map((k, v) => MapEntry(k, (v as num).toDouble())));
} catch (e) {
debugPrint('⚠️ Error parsing TMs for cycle ${cycle.id}: $e');
tmsMap = {'squat': 0.0, 'pullup': 0.0, 'dip': 0.0};
}
final tmsMap = cycle.trainingMaxes
.map((k, v) => MapEntry(k, (v as num).toDouble()));
final response = await apiClient.createCycle(tmsMap);
final newServerId = response['id'];
await isar.writeTxn(() async {
cycle.serverId = newServerId;
cycle.isDirty = false;
await isar.cycleCollections.put(cycle);
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();
final orphanWorkouts = await isar.workoutCollections
.filter()
.cycleIdEqualTo(oldLocalIdRef)
.findAll();
for (var w in orphanWorkouts) {
w.cycleId = newServerId;
w.isDirty = true;
await isar.workoutCollections.put(w);
debugPrint('🔗 Relinked workout ${w.id} to cycle $newServerId');
}
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 isar.writeTxn(() async {
cycle.isDirty = false;
await isar.cycleCollections.put(cycle);
});
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: $e');
return;
debugPrint('❌ Failed to sync cycle ${cycle.id}: $e');
}
}
final dirtyUser =
await isar.userCollections.filter().isDirtyEqualTo(true).findFirst();
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 dirtyWorkouts =
await isar.workoutCollections.filter().isDirtyEqualTo(true).findAll();
final validWorkouts =
dirtyWorkouts.where((w) => w.cycleId.length > 5).toList();
if (dirtyUser == null && dirtyWorkouts.isEmpty) {
debugPrint('✅ Nothing to push.');
} else {
final pushData = <String, dynamic>{
'workouts': dirtyWorkouts.where((w) {
return w.cycleId.length > 5;
}).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': jsonDecode(w.exercisesJson),
};
}).toList(),
'user_stats': dirtyUser != null
? {
'xp': dirtyUser.xp,
'level': dirtyUser.level,
'current_bodyweight': dirtyUser.currentBodyweight,
}
: null,
};
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,
};
if ((pushData['workouts'] as List).length < dirtyWorkouts.length) {
debugPrint(
'⚠️ Skipped some workouts because they lack a valid server cycle ID.');
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)),
);
}
final lastSync = await _storage.read(key: AppConstants.keyLastSync);
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();
if ((pushData['workouts'] as List).isNotEmpty ||
pushData['user_stats'] != null) {
debugPrint('📤 Pushing data...');
final response = await apiClient.sync(
lastSyncTimestamp: lastSync ?? '',
pushData: pushData,
);
final tms = cJson['training_maxes'] as Map<String, dynamic>;
await isar.writeTxn(() async {
if (dirtyUser != null) {
dirtyUser.isDirty = false;
await isar.userCollections.put(dirtyUser);
}
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(),
);
for (var w in dirtyWorkouts) {
w.isDirty = false;
await isar.workoutCollections.put(w);
}
if (response['pull_data'] != null &&
response['pull_data']['workouts'] != null) {
final pulledWorkouts = response['pull_data']['workouts'] as List;
for (var wJson in pulledWorkouts) {
final serverId = wJson['id'];
var workout = await isar.workoutCollections
.filter()
.serverIdEqualTo(serverId)
.findFirst();
workout ??= WorkoutCollection();
workout
..serverId = serverId
..cycleId = wJson['cycle_id']
..userId = wJson['user_id']
..week = wJson['week']
..day = wJson['day']
..completedAt = DateTime.tryParse(wJson['completed_at'] ?? '')
..xpEarned = wJson['xp_earned'] ?? 0
..exercisesJson = jsonEncode(wJson['exercises'])
..isDirty = false
..updatedAt = DateTime.now();
await isar.workoutCollections.put(workout);
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['server_timestamp'] != null) {
await _storage.write(
key: AppConstants.keyLastSync,
value: response['server_timestamp'],
);
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) {
} catch (e, stack) {
debugPrint('❌ Sync failed: $e');
debugPrint(stack.toString());
} finally {
_isSyncing = false;
}

View file

@ -1,45 +1,49 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:isar/isar.dart';
import 'package:drift/drift.dart';
import 'dart:convert';
import '../local/collections/cycle_collection.dart';
import '../local/app_database.dart';
import '../remote/api_client.dart';
import '../../../../main.dart';
import 'user_repository.dart';
import '../../../core/constants/app_constants.dart';
import '../local/collections/workout_collection.dart';
final cycleRepositoryProvider = Provider<CycleRepository>((ref) {
final isar = ref.watch(isarProvider);
final db = ref.watch(appDatabaseProvider);
final apiClient = ref.watch(apiClientProvider);
return CycleRepository(isar: isar, apiClient: apiClient);
return CycleRepository(db: db, apiClient: apiClient);
});
class CycleRepository {
final Isar isar;
final AppDatabase db;
final ApiClient apiClient;
CycleRepository({required this.isar, required this.apiClient});
CycleRepository({required this.db, required this.apiClient});
Future<CycleCollection?> getCurrentCycle() async {
return await isar.cycleCollections
.filter()
.isActiveEqualTo(true)
.findFirst();
return await (db.select(db.cycles)
..where((c) => c.isActive.equals(true))
..limit(1))
.getSingleOrNull();
}
Future<List<CycleCollection>> getAllCycles() async {
return await isar.cycleCollections.where().findAll();
return await db.select(db.cycles).get();
}
Future<CycleCollection> createCycle(Map<String, double> trainingMaxes) async {
try {
return await db.transaction(() async {
final currentCycle = await getCurrentCycle();
if (currentCycle != null) {
currentCycle.isActive = false;
currentCycle.endDate = DateTime.now();
await saveCycle(currentCycle);
final updateOld = CyclesCompanion(
isActive: const Value(false),
endDate: Value(DateTime.now()),
updatedAt: Value(DateTime.now()),
isDirty: const Value(true),
);
await (db.update(db.cycles)..where((c) => c.id.equals(currentCycle.id)))
.write(updateOld);
}
final allCycles = await getAllCycles();
@ -50,34 +54,44 @@ class CycleRepository {
.reduce((a, b) => a > b ? a : b) +
1;
final userRepo = UserRepository(isar: isar, apiClient: ApiClient());
final user = await userRepo.getLocalUser();
final user = await (db.select(db.users)..limit(1)).getSingleOrNull();
if (user == null) {
throw Exception('No user found for cycle creation');
}
final newCycle = CycleCollection()
..userId = user.serverId ?? user.id.toString()
..cycleNumber = nextNumber
..startDate = DateTime.now()
..isActive = true
..trainingMaxesJson = jsonEncode(trainingMaxes)
..isDirty = true;
final newCycleCompanion = CyclesCompanion(
userId: Value(user.serverId ?? user.id.toString()),
cycleNumber: Value(nextNumber),
startDate: Value(DateTime.now()),
isActive: const Value(true),
trainingMaxes: Value(trainingMaxes),
isDirty: const Value(true),
createdAt: Value(DateTime.now()),
updatedAt: Value(DateTime.now()),
);
await saveCycle(newCycle);
final newId = await db.into(db.cycles).insert(newCycleCompanion);
var newCycle = await (db.select(db.cycles)
..where((c) => c.id.equals(newId)))
.getSingle();
try {
final response = await apiClient.createCycle(trainingMaxes);
newCycle.serverId = response['id'];
newCycle.isDirty = false;
await saveCycle(newCycle);
} catch (e) {}
await (db.update(db.cycles)..where((c) => c.id.equals(newId))).write(
CyclesCompanion(
serverId: Value(response['id']),
isDirty: const Value(false),
),
);
newCycle = await (db.select(db.cycles)
..where((c) => c.id.equals(newId)))
.getSingle();
} catch (e) {
// API Fehler ignorieren, wird später gesynct
}
return newCycle;
} catch (e, stackTrace) {
rethrow;
}
});
}
Future<CycleCollection> finishCycle() async {
@ -87,24 +101,26 @@ class CycleRepository {
}
final cycleIdRef = currentCycle.serverId ?? currentCycle.id.toString();
final localCycleId = currentCycle.id.toString();
final completedMainWorkouts = await isar.workoutCollections
.filter()
.weekLessThan(4)
.completedAtIsNotNull()
.group((q) => q
.cycleIdEqualTo(cycleIdRef)
.or()
.cycleIdEqualTo(currentCycle.id.toString()))
.count();
final workoutsQuery = db.select(db.workouts)
..where((w) {
final weekCheck = w.week.isSmallerThanValue(4);
final completedCheck = w.completedAt.isNotNull();
final cycleCheck =
w.cycleId.equals(cycleIdRef) | w.cycleId.equals(localCycleId);
return weekCheck & completedCheck & cycleCheck;
});
final completedMainWorkouts = (await workoutsQuery.get()).length;
if (completedMainWorkouts < 9) {
final missing = 9 - completedMainWorkouts;
throw Exception(
'Cycle incomplete! You still have $missing workouts left in the main phase (Weeks 1-3). Finish them before leveling up.');
}
final currentTMs =
jsonDecode(currentCycle.trainingMaxesJson) as Map<String, dynamic>;
final currentTMs = currentCycle.trainingMaxes;
final newTMs = <String, double>{
'squat': (currentTMs['squat'] as num?)?.toDouble() ?? 0.0,
@ -112,23 +128,27 @@ class CycleRepository {
'dip': (currentTMs['dip'] as num?)?.toDouble() ?? 0.0,
};
final week3Workouts = await isar.workoutCollections
.filter()
.weekEqualTo(3)
.group((q) => q
.cycleIdEqualTo(cycleIdRef)
.or()
.cycleIdEqualTo(currentCycle.id.toString()))
.findAll();
final week3Workouts = await (db.select(db.workouts)
..where((w) {
final weekCheck = w.week.equals(3);
final cycleCheck =
w.cycleId.equals(cycleIdRef) | w.cycleId.equals(localCycleId);
return weekCheck & cycleCheck;
}))
.get();
bool checkSuccess(String exerciseId) {
for (var workout in week3Workouts) {
try {
final exercises = jsonDecode(workout.exercisesJson) as List;
for (var ex in exercises) {
final exercises = workout.exercises;
for (var exData in exercises) {
final ex = exData as Map<String, dynamic>;
if (ex['exerciseId'] == exerciseId) {
final sets = ex['sets'] as List;
for (var s in sets) {
for (var sData in sets) {
final s = sData as Map<String, dynamic>;
if (s['isAmrap'] == true) {
final reps = s['repsActual'] as int? ?? 0;
if (reps >= 1) {
@ -170,33 +190,23 @@ class CycleRepository {
try {
await apiClient.finishCycle(currentCycle.serverId!);
} catch (e) {
// Fehler ignorieren, wird später gesynct
// Fehler ignorieren
}
}
return await createCycle(newTMs);
}
Future<void> saveCycle(CycleCollection cycle) async {
cycle.updatedAt = DateTime.now();
await isar.writeTxn(() async {
await isar.cycleCollections.put(cycle);
});
}
Map<String, double> getCurrentTrainingMaxes() {
final cycle =
isar.cycleCollections.filter().isActiveEqualTo(true).findFirstSync();
Future<Map<String, double>> getCurrentTrainingMaxesAsync() async {
final cycle = await getCurrentCycle();
if (cycle != null) {
final tms = jsonDecode(cycle.trainingMaxesJson);
final tms = cycle.trainingMaxes;
return {
'squat': (tms['squat'] as num?)?.toDouble() ?? 0.0,
'pullup': (tms['pullup'] as num?)?.toDouble() ?? 0.0,
'dip': (tms['dip'] as num?)?.toDouble() ?? 0.0,
};
}
return {'squat': 0.0, 'pullup': 0.0, 'dip': 0.0};
}
}

View file

@ -1,62 +1,76 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:isar/isar.dart';
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:drift/drift.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../local/collections/cycle_collection.dart';
import '../local/collections/user_collection.dart';
import '../local/collections/workout_collection.dart';
import '../local/app_database.dart';
import '../remote/api_client.dart';
import '../../../../main.dart';
import '../../../core/constants/app_constants.dart';
final userRepositoryProvider = Provider<UserRepository>((ref) {
final isar = ref.watch(isarProvider);
final db = ref.watch(appDatabaseProvider);
final apiClient = ref.watch(apiClientProvider);
return UserRepository(isar: isar, apiClient: apiClient);
return UserRepository(db: db, apiClient: apiClient);
});
final apiClientProvider = Provider<ApiClient>((ref) => ApiClient());
class UserRepository {
final Isar isar;
final AppDatabase db;
final ApiClient apiClient;
final _storage = const FlutterSecureStorage(); // NEU: Instanz für Logout
UserRepository({required this.isar, required this.apiClient});
UserRepository({required this.db, required this.apiClient});
Future<UserCollection?> getLocalUser() async {
return await isar.userCollections.where().findFirst();
return await (db.select(db.users)..limit(1)).getSingleOrNull();
}
Future<void> saveLocalUser(UserCollection user) async {
user.updatedAt = DateTime.now();
await isar.writeTxn(() async {
await isar.userCollections.put(user);
});
final companion = user.toCompanion(true).copyWith(
updatedAt: Value(DateTime.now()),
);
await db.into(db.users).insertOnConflictUpdate(companion);
}
Future<void> updateXP(int xpToAdd) async {
final user = await getLocalUser();
if (user != null) {
user.xp += xpToAdd;
user.isDirty = true;
await saveLocalUser(user);
final newXp = user.xp + xpToAdd;
final companion = UsersCompanion(
xp: Value(newXp),
isDirty: const Value(true),
updatedAt: Value(DateTime.now()),
);
await (db.update(db.users)..where((u) => u.id.equals(user.id)))
.write(companion);
}
}
Future<void> updateLevel(int newLevel) async {
final user = await getLocalUser();
if (user != null) {
user.level = newLevel;
user.isDirty = true;
await saveLocalUser(user);
final companion = UsersCompanion(
level: Value(newLevel),
isDirty: const Value(true),
updatedAt: Value(DateTime.now()),
);
await (db.update(db.users)..where((u) => u.id.equals(user.id)))
.write(companion);
}
}
Future<void> updateBodyweight(double bodyweight) async {
final user = await getLocalUser();
if (user != null) {
user.currentBodyweight = bodyweight;
user.isDirty = true;
await saveLocalUser(user);
final companion = UsersCompanion(
currentBodyweight: Value(bodyweight),
isDirty: const Value(true),
updatedAt: Value(DateTime.now()),
);
await (db.update(db.users)..where((u) => u.id.equals(user.id)))
.write(companion);
try {
await apiClient.updateBodyweight(bodyweight);
@ -67,9 +81,13 @@ class UserRepository {
Future<void> updateInventory(Map<String, dynamic> inventory) async {
final user = await getLocalUser();
if (user != null) {
user.inventorySettingsJson = jsonEncode(inventory);
user.isDirty = true;
await saveLocalUser(user);
final companion = UsersCompanion(
inventorySettings: Value(inventory),
isDirty: const Value(true),
updatedAt: Value(DateTime.now()),
);
await (db.update(db.users)..where((u) => u.id.equals(user.id)))
.write(companion);
try {
await apiClient.updateInventory(inventory);
@ -79,21 +97,7 @@ class UserRepository {
Future<UserCollection> login(String email, String password) async {
final response = await apiClient.login(email, password);
final user = UserCollection()
..serverId = response['record']['id']
..email = response['record']['email']
..xp = response['record']['xp'] ?? 0
..level = response['record']['level'] ?? 1
..currentBodyweight =
(response['record']['current_bodyweight'] ?? 70.0).toDouble()
..inventorySettingsJson =
jsonEncode(response['record']['inventory_settings'] ?? {})
..avatarConfigJson = jsonEncode(response['record']['avatar_config'] ?? {})
..lastSyncAt = DateTime.now();
await saveLocalUser(user);
return user;
return _saveUserFromApi(response['record']);
}
Future<UserCollection> register({
@ -111,48 +115,57 @@ class UserRepository {
);
final record = response['record'] ?? response;
final user = UserCollection()
..serverId = record['id']?.toString()
..email = record['email']?.toString() ?? email
..xp = (record['xp'] as num?)?.toInt() ?? 0
..level = (record['level'] as num?)?.toInt() ?? 1
..currentBodyweight =
(record['current_bodyweight'] as num?)?.toDouble() ?? bodyweight
..inventorySettingsJson =
jsonEncode(record['inventory_settings'] ?? inventorySettings)
..avatarConfigJson = jsonEncode(record['avatar_config'] ??
{
'skin_tone': 'medium',
'hair_style': 'short_01',
'clothing': 'basic_tee',
'unlocked_items': ['basic_tee'],
})
..lastSyncAt = DateTime.now();
await saveLocalUser(user);
final user = await _saveUserFromApi(record);
try {
await apiClient.login(email, password);
} catch (e) {}
return user;
} catch (e, stackTrace) {
} catch (e) {
rethrow;
}
}
Future<UserCollection> _saveUserFromApi(Map<String, dynamic> record) async {
await db.delete(db.users).go();
final companion = UsersCompanion(
serverId: Value(record['id']),
email: Value(record['email'] ?? ''),
xp: Value((record['xp'] as num?)?.toInt() ?? 0),
level: Value((record['level'] as num?)?.toInt() ?? 1),
currentBodyweight:
Value((record['current_bodyweight'] as num?)?.toDouble() ?? 70.0),
inventorySettings: Value(record['inventory_settings'] ?? {}),
avatarConfig: Value(record['avatar_config'] ?? {}),
lastSyncAt: Value(DateTime.now()),
isDirty: const Value(false),
createdAt: Value(DateTime.now()),
updatedAt: Value(DateTime.now()),
);
final id = await db.into(db.users).insert(companion);
return (await (db.select(db.users)..where((u) => u.id.equals(id)))
.getSingle());
}
Future<void> logout() async {
await apiClient.logout();
await isar.writeTxn(() async {
await isar.userCollections.clear();
await _storage.delete(key: AppConstants.keyLastSync);
await db.transaction(() async {
await db.delete(db.users).go();
await db.delete(db.cycles).go();
await db.delete(db.workouts).go();
});
}
Map<String, dynamic> getInventorySettings() {
final user = isar.userCollections.where().findFirstSync();
if (user?.inventorySettingsJson != null) {
return jsonDecode(user!.inventorySettingsJson!);
Future<Map<String, dynamic>> getInventorySettingsAsync() async {
final user = await getLocalUser();
if (user?.inventorySettings != null) {
return user!.inventorySettings!;
}
return {
'bar_weight': 20.0,
@ -161,14 +174,14 @@ class UserRepository {
};
}
List<double> getAvailablePlates() {
final inventory = getInventorySettings();
Future<List<double>> getAvailablePlates() async {
final inventory = await getInventorySettingsAsync();
final plates = inventory['plates'] as List?;
return plates?.map((e) => (e as num).toDouble()).toList() ?? [];
}
double getBarWeight() {
final inventory = getInventorySettings();
Future<double> getBarWeight() async {
final inventory = await getInventorySettingsAsync();
return (inventory['bar_weight'] as num?)?.toDouble() ?? 20.0;
}
@ -204,17 +217,17 @@ class UserRepository {
"Server connection required to reset progress. Please try again when online.");
}
user.xp = 0;
user.level = 1;
final companion = UsersCompanion(
xp: const Value(0),
level: const Value(1),
isDirty: const Value(false),
updatedAt: Value(DateTime.now()),
);
await (db.update(db.users)..where((u) => u.id.equals(user.id)))
.write(companion);
user.isDirty = false;
await isar.writeTxn(() async {
await isar.userCollections.put(user);
await isar.cycleCollections.clear();
await isar.workoutCollections.clear();
});
await db.delete(db.cycles).go();
await db.delete(db.workouts).go();
}
}
}

View file

@ -1,49 +1,44 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:isar/isar.dart';
import 'dart:convert';
import '../local/collections/workout_collection.dart';
import 'package:drift/drift.dart';
import '../local/app_database.dart';
import '../remote/api_client.dart';
import '../../../../main.dart';
import 'user_repository.dart';
final workoutRepositoryProvider = Provider<WorkoutRepository>((ref) {
final isar = ref.watch(isarProvider);
final db = ref.watch(appDatabaseProvider);
final apiClient = ref.watch(apiClientProvider);
return WorkoutRepository(isar: isar, apiClient: apiClient);
return WorkoutRepository(db: db, apiClient: apiClient);
});
class WorkoutRepository {
final Isar isar;
final AppDatabase db;
final ApiClient apiClient;
WorkoutRepository({required this.isar, required this.apiClient});
WorkoutRepository({required this.db, required this.apiClient});
Future<List<WorkoutCollection>> getAllWorkouts() async {
return await isar.workoutCollections.where().findAll();
return await db.select(db.workouts).get();
}
Future<List<WorkoutCollection>> getWorkoutsForCycle(String cycleId) async {
return await isar.workoutCollections
.filter()
.cycleIdEqualTo(cycleId)
.findAll();
return await (db.select(db.workouts)
..where((w) => w.cycleId.equals(cycleId)))
.get();
}
Future<List<WorkoutCollection>> getCompletedWorkouts(String userId) async {
return await isar.workoutCollections
.filter()
.userIdEqualTo(userId)
.completedAtIsNotNull()
.findAll();
return await (db.select(db.workouts)
..where((w) => w.userId.equals(userId) & w.completedAt.isNotNull()))
.get();
}
Future<void> saveWorkout(WorkoutCollection workout) async {
workout.updatedAt = DateTime.now();
workout.isDirty = true;
await isar.writeTxn(() async {
await isar.workoutCollections.put(workout);
});
final companion = workout.toCompanion(true).copyWith(
updatedAt: Value(DateTime.now()),
isDirty: const Value(true),
);
await db.into(db.workouts).insertOnConflictUpdate(companion);
}
Future<WorkoutCollection> createWorkout({
@ -51,27 +46,42 @@ class WorkoutRepository {
required String cycleId,
required int week,
required int day,
required String exercisesJson,
required List<dynamic> exercises,
}) async {
final workout = WorkoutCollection()
..userId = userId
..cycleId = cycleId
..week = week
..day = day
..exercisesJson = exercisesJson
..scheduledDate = DateTime.now();
final companion = WorkoutsCompanion(
userId: Value(userId),
cycleId: Value(cycleId),
week: Value(week),
day: Value(day),
exercises: Value(exercises),
scheduledDate: Value(DateTime.now()),
xpEarned: const Value(0),
notes: const Value(''),
isDirty: const Value(true),
createdAt: Value(DateTime.now()),
updatedAt: Value(DateTime.now()),
);
await saveWorkout(workout);
return workout;
final id = await db.into(db.workouts).insert(companion);
return await (db.select(db.workouts)..where((w) => w.id.equals(id)))
.getSingle();
}
Future<void> completeWorkout(
WorkoutCollection workout, {
required int xpEarned,
}) async {
workout.completedAt = DateTime.now();
workout.xpEarned = xpEarned;
await saveWorkout(workout);
final companion = WorkoutsCompanion(
id: Value(workout.id),
completedAt: Value(DateTime.now()),
xpEarned: Value(xpEarned),
exercises: Value(workout.exercises),
isDirty: const Value(true),
updatedAt: Value(DateTime.now()),
);
await (db.update(db.workouts)..where((w) => w.id.equals(workout.id)))
.write(companion);
}
Future<WorkoutCollection?> getWorkoutByWeekDay({
@ -80,16 +90,17 @@ class WorkoutRepository {
required int week,
required int day,
}) async {
return await isar.workoutCollections
.filter()
.weekEqualTo(week)
.dayEqualTo(day)
.group((q) {
var query = q.cycleIdEqualTo(cycleId);
if (localCycleId != null) {
query = query.or().cycleIdEqualTo(localCycleId);
}
return query;
}).findFirst();
return await (db.select(db.workouts)
..where((w) {
final weekDayCheck = w.week.equals(week) & w.day.equals(day);
Expression<bool> cycleCheck = w.cycleId.equals(cycleId);
if (localCycleId != null) {
cycleCheck = cycleCheck | w.cycleId.equals(localCycleId);
}
return weekDayCheck & cycleCheck;
}))
.getSingleOrNull();
}
}

View file

@ -5,26 +5,26 @@ packages:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: ae92f5d747aee634b87f89d9946000c2de774be1d6ac3e58268224348cd0101a
sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7"
url: "https://pub.dev"
source: hosted
version: "61.0.0"
version: "67.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: ea3d8652bda62982addfd92fdc2d0214e5f82e43325104990d4f4c4a2a313562
sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d"
url: "https://pub.dev"
source: hosted
version: "5.13.0"
version: "6.4.1"
analyzer_plugin:
dependency: transitive
description:
name: analyzer_plugin
sha256: c1d5f167683de03d5ab6c3b53fc9aeefc5d59476e7810ba7bbddff50c6f4392d
sha256: "9661b30b13a685efaee9f02e5d01ed9f2b423bd889d28a304d02d704aee69161"
url: "https://pub.dev"
source: hosted
version: "0.11.2"
version: "0.11.3"
args:
dependency: transitive
description:
@ -145,6 +145,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
charcode:
dependency: transitive
description:
name: charcode
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
url: "https://pub.dev"
source: hosted
version: "1.4.0"
checked_yaml:
dependency: transitive
description:
@ -153,6 +161,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.4"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
clock:
dependency: transitive
description:
@ -217,14 +233,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.2"
dartx:
dependency: transitive
description:
name: dartx
sha256: "8b25435617027257d43e6508b5fe061012880ddfdaa75a71d607c3de2a13d244"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
dio:
dependency: "direct main"
description:
@ -241,6 +249,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.1"
drift:
dependency: "direct main"
description:
name: drift
sha256: df027d168a2985a2e9da900adeba2ab0136f0d84436592cf3cd5135f82c8579c
url: "https://pub.dev"
source: hosted
version: "2.21.0"
drift_dev:
dependency: "direct dev"
description:
name: drift_dev
sha256: "623649abe932fc17bd32e578e7e05f7ac5e7dd0b33e6c8669a0634105d1389bf"
url: "https://pub.dev"
source: hosted
version: "2.21.2"
drift_flutter:
dependency: "direct main"
description:
name: drift_flutter
sha256: "0bc2f1dde59e59cedde0df67a4ff7bd8f0d42274f18b50bf7e7dae7ca3d77801"
url: "https://pub.dev"
source: hosted
version: "0.1.0"
equatable:
dependency: "direct main"
description:
@ -480,30 +512,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.5"
isar:
dependency: "direct main"
description:
name: isar
sha256: "99165dadb2cf2329d3140198363a7e7bff9bbd441871898a87e26914d25cf1ea"
url: "https://pub.dev"
source: hosted
version: "3.1.0+1"
isar_flutter_libs:
dependency: "direct main"
description:
name: isar_flutter_libs
sha256: bc6768cc4b9c61aabff77152e7f33b4b17d2fc93134f7af1c3dd51500fe8d5e8
url: "https://pub.dev"
source: hosted
version: "3.1.0+1"
isar_generator:
dependency: "direct dev"
description:
name: isar_generator
sha256: "76c121e1295a30423604f2f819bc255bc79f852f3bc8743a24017df6068ad133"
url: "https://pub.dev"
source: hosted
version: "3.1.0+1"
js:
dependency: transitive
description:
@ -744,6 +752,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.5.0"
recase:
dependency: transitive
description:
name: recase
sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213
url: "https://pub.dev"
source: hosted
version: "4.1.0"
riverpod:
dependency: transitive
description:
@ -933,6 +949,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.0"
sqlite3:
dependency: transitive
description:
name: sqlite3
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
url: "https://pub.dev"
source: hosted
version: "2.9.4"
sqlite3_flutter_libs:
dependency: "direct main"
description:
name: sqlite3_flutter_libs
sha256: "1e800ebe7f85a80a66adacaa6febe4d5f4d8b75f244e9838a27cb2ffc7aec08d"
url: "https://pub.dev"
source: hosted
version: "0.5.41"
sqlparser:
dependency: transitive
description:
name: sqlparser
sha256: d77749237609784e337ec36c979d41f6f38a7b279df98622ae23929c8eb954a4
url: "https://pub.dev"
source: hosted
version: "0.39.2"
stack_trace:
dependency: transitive
description:
@ -997,14 +1037,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.7"
time:
dependency: transitive
description:
name: time
sha256: "370572cf5d1e58adcb3e354c47515da3f7469dac3a95b447117e728e7be6f461"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
timing:
dependency: transitive
description:
@ -1125,14 +1157,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.6.1"
xxh3:
dependency: transitive
description:
name: xxh3
sha256: "399a0438f5d426785723c99da6b16e136f4953fb1e9db0bf270bd41dd4619916"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
yaml:
dependency: transitive
description:

View file

@ -15,8 +15,9 @@ dependencies:
riverpod_annotation: ^2.3.5
# Local Database
isar: ^3.1.0+1
isar_flutter_libs: ^3.1.0+1
drift: ^2.16.0
drift_flutter: ^0.1.0
sqlite3_flutter_libs: ^0.5.20
path_provider: ^2.1.3
# Networking
@ -52,7 +53,7 @@ dev_dependencies:
# Code Generation
build_runner: ^2.4.9
riverpod_generator: ^2.4.0
isar_generator: ^3.1.0+1
drift_dev: ^2.16.0
freezed: ^2.5.2
json_serializable: ^6.8.0