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/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/app.dart';
import 'src/shared/data/local/collections/user_collection.dart'; import 'src/shared/data/local/app_database.dart';
import 'src/shared/data/local/collections/cycle_collection.dart';
import 'src/shared/data/local/collections/workout_collection.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@ -17,19 +12,15 @@ void main() async {
DeviceOrientation.portraitDown, DeviceOrientation.portraitDown,
]); ]);
final dir = await getApplicationDocumentsDirectory(); final database = AppDatabase();
final isar = await Isar.open(
[UserCollectionSchema, CycleCollectionSchema, WorkoutCollectionSchema],
directory: dir.path,
name: 'slrpg_db',
);
runApp( runApp(
ProviderScope( ProviderScope(
overrides: [isarProvider.overrideWithValue(isar)], overrides: [appDatabaseProvider.overrideWithValue(database)],
child: const SLRPGApp(), 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) { } catch (e) {
if (mounted) { 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( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Login failed: ${e.toString()}'), content: Text(message),
backgroundColor: AppTheme.errorColor, backgroundColor: AppTheme.errorColor,
behavior: SnackBarBehavior.floating,
), ),
); );
} }
@ -70,7 +82,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// Logo
Container( Container(
width: 100, width: 100,
height: 100, height: 100,
@ -85,7 +96,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
'WELCOME BACK', 'WELCOME BACK',
style: Theme.of(context).textTheme.displayMedium, style: Theme.of(context).textTheme.displayMedium,
@ -98,7 +108,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 48), const SizedBox(height: 48),
TextFormField( TextFormField(
controller: _emailController, controller: _emailController,
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
@ -117,7 +126,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
controller: _passwordController, controller: _passwordController,
obscureText: _obscurePassword, obscureText: _obscurePassword,
@ -146,7 +154,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
}, },
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
ElevatedButton( ElevatedButton(
onPressed: _isLoading ? null : _handleLogin, onPressed: _isLoading ? null : _handleLogin,
child: _isLoading child: _isLoading
@ -161,7 +168,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
: const Text('LOGIN'), : const Text('LOGIN'),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [

View file

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

View file

@ -6,21 +6,20 @@ 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 '../../../../core/theme/app_theme.dart';
import '../../../../shared/data/local/app_database.dart';
import '../../../../shared/data/repositories/user_repository.dart'; import '../../../../shared/data/repositories/user_repository.dart';
import '../../../../shared/data/repositories/cycle_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/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/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 '../../../../shared/domain/entities/workout_set.dart';
import '../../../gamification/domain/entities/avatar_config.dart'; import '../../../gamification/domain/entities/avatar_config.dart';
import '../../../gamification/presentation/widgets/avatar_renderer.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 { class HubScreen extends ConsumerStatefulWidget {
const HubScreen({super.key}); const HubScreen({super.key});
@ -41,7 +40,6 @@ class _HubScreenState extends ConsumerState<HubScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_runSync(); _runSync();
}); });
@ -104,7 +102,10 @@ class _HubScreenState extends ConsumerState<HubScreen> {
CycleCollection cycle, UserCollection user) async { CycleCollection cycle, UserCollection user) async {
try { try {
final workoutRepo = ref.read(workoutRepositoryProvider); 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 targetWeek = 1;
int targetDay = 1; int targetDay = 1;
@ -138,8 +139,6 @@ class _HubScreenState extends ConsumerState<HubScreen> {
return; return;
} }
final trainingMaxes = cycleRepo.getCurrentTrainingMaxes();
var workout = await workoutRepo.getWorkoutByWeekDay( var workout = await workoutRepo.getWorkoutByWeekDay(
cycleId: cycleRefId, cycleId: cycleRefId,
localCycleId: localCycleId, localCycleId: localCycleId,
@ -162,7 +161,7 @@ class _HubScreenState extends ConsumerState<HubScreen> {
cycleId: cycleRefId, cycleId: cycleRefId,
week: targetWeek, week: targetWeek,
day: targetDay, 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 user = snapshot.data![0] as UserCollection?;
final cycle = snapshot.data![1] as CycleCollection?; 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(); : const AvatarConfig();
if (user == null) { if (user == null) {

View file

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

View file

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

View file

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

View file

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

View file

@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import '../../../../core/constants/asset_paths.dart'; import '../../../../core/constants/asset_paths.dart';
import '../../../../core/theme/app_theme.dart'; import '../../../../core/theme/app_theme.dart';
@ -127,16 +126,14 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
final cycleRepo = ref.read(cycleRepositoryProvider); final cycleRepo = ref.read(cycleRepositoryProvider);
final user = await userRepo.getLocalUser(); 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'); if (mounted) context.go('/hub');
return; return;
} }
final trainingMaxes = cycleRepo.getCurrentTrainingMaxes();
final exercises = <Exercise>[]; final exercises = <Exercise>[];
final exerciseConfigs = _getExerciseConfig(widget.day); final exerciseConfigs = _getExerciseConfig(widget.day);
for (final config in exerciseConfigs) { for (final config in exerciseConfigs) {
@ -145,7 +142,7 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
final type = config['type'] as ExerciseType; final type = config['type'] as ExerciseType;
final isMain = config['isMain'] as bool; final isMain = config['isMain'] as bool;
final tm = trainingMaxes[id] ?? 0.0; final tm = trainingMaxesMap[id] ?? 0.0;
List<WorkoutSet> sets = []; List<WorkoutSet> sets = [];
if (isMain) { if (isMain) {
@ -175,6 +172,7 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
} }
} }
if (mounted) {
setState(() { setState(() {
_exercises = exercises; _exercises = exercises;
_isLoading = false; _isLoading = false;
@ -184,6 +182,7 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
} }
}); });
} }
}
void _completeSet() { void _completeSet() {
final currentExercise = _exercises[_currentExerciseIndex]; final currentExercise = _exercises[_currentExerciseIndex];
@ -286,9 +285,10 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
cycleId: cycleIdRef, week: widget.week, day: widget.day); cycleId: cycleIdRef, week: widget.week, day: widget.day);
if (workout != null) { if (workout != null) {
workout.exercisesJson = final updatedExercises = _exercises.map((e) => e.toJson()).toList();
jsonEncode(_exercises.map((e) => e.toJson()).toList()); final updatedWorkout = workout.copyWith(exercises: updatedExercises);
await workoutRepo.completeWorkout(workout, xpEarned: xpEarned);
await workoutRepo.completeWorkout(updatedWorkout, xpEarned: xpEarned);
ref.read(syncServiceProvider).sync(); ref.read(syncServiceProvider).sync();
} }
@ -355,7 +355,7 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'The monsters tremble at your new power.', // Story Flavor 'The monsters tremble at your new power.',
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.black54, fontStyle: FontStyle.italic), color: Colors.black54, fontStyle: FontStyle.italic),
textAlign: TextAlign.center, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_isLoading) { if (_isLoading) {
@ -396,17 +407,27 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
final currentExercise = _exercises[_currentExerciseIndex]; final currentExercise = _exercises[_currentExerciseIndex];
final currentSet = currentExercise.sets[_currentSetIndex]; final currentSet = currentExercise.sets[_currentSetIndex];
final userRepo = ref.watch(userRepositoryProvider);
return FutureBuilder<Map<String, dynamic>>(
future: ref.read(userRepositoryProvider).getInventorySettingsAsync(),
builder: (context, snapshot) {
if (!snapshot.hasData)
return const Scaffold(
body: Center(child: CircularProgressIndicator()));
final inventory = snapshot.data!;
final totalHP = _exercises.fold<int>( final totalHP = _exercises.fold<int>(
0, 0,
(sum, ex) => sum + ex.sets.fold<int>(0, (s, set) => s + set.repsTarget), (sum, ex) =>
sum + ex.sets.fold<int>(0, (s, set) => s + set.repsTarget),
); );
final completedHP = _exercises.take(_currentExerciseIndex).fold<int>( final completedHP = _exercises.take(_currentExerciseIndex).fold<int>(
0, 0,
(sum, ex) => (sum, ex) =>
sum + ex.sets.fold<int>(0, (s, set) => s + set.repsActual), sum +
ex.sets.fold<int>(0, (s, set) => s + set.repsActual),
) + ) +
currentExercise.sets currentExercise.sets
.take(_currentSetIndex) .take(_currentSetIndex)
@ -415,12 +436,15 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
final isBodyweight = currentExercise.exerciseId != 'squat'; final isBodyweight = currentExercise.exerciseId != 'squat';
final barWeight = isBodyweight final barWeight = isBodyweight
? currentExercise.bodyweightAtSession ? currentExercise.bodyweightAtSession
: userRepo.getBarWeight(); : (inventory['bar_weight'] as num?)?.toDouble() ?? 20.0;
final availablePlates = userRepo.getAvailablePlates();
final inventory = userRepo.getInventorySettings(); final platesList = (inventory['plates'] as List?)
?.map((e) => (e as num).toDouble())
.toList() ??
[];
final bandsList = final bandsList =
(inventory['bands'] as List?)?.cast<Map<String, dynamic>>() ?? []; (inventory['bands'] as List?)?.cast<Map<String, dynamic>>() ?? [];
final Map<String, double> availableBands = {}; final Map<String, double> availableBands = {};
for (var band in bandsList) { for (var band in bandsList) {
final color = band['color'] as String; final color = band['color'] as String;
@ -433,7 +457,7 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
final plateResult = PlateCalculator.calculate( final plateResult = PlateCalculator.calculate(
targetWeight: currentSet.targetWeightTotal, targetWeight: currentSet.targetWeightTotal,
barWeight: barWeight, barWeight: barWeight,
availablePlates: availablePlates, availablePlates: platesList,
availableBands: availableBands, availableBands: availableBands,
isTwoSided: !isBodyweight, isTwoSided: !isBodyweight,
); );
@ -485,12 +509,13 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
SafeArea( SafeArea(
child: _isResting child: _isResting
? _buildRestScreen() ? _buildRestScreen()
: _buildWorkoutScreen(currentExercise, currentSet, plateResult, : _buildWorkoutScreen(currentExercise, currentSet,
completedHP, totalHP), plateResult, completedHP, totalHP),
), ),
], ],
), ),
); );
});
} }
Widget _buildRestScreen() { 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) { String _formatTime(int seconds) {
final minutes = seconds ~/ 60; final minutes = seconds ~/ 60;
final secs = 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 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.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 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../../../main.dart'; import '../../../../main.dart';
import '../../../core/constants/app_constants.dart'; import '../../../core/constants/app_constants.dart';
import '../local/collections/user_collection.dart'; import '../local/app_database.dart';
import '../local/collections/cycle_collection.dart';
import '../local/collections/workout_collection.dart';
import 'api_client.dart';
import '../repositories/user_repository.dart'; import '../repositories/user_repository.dart';
import 'api_client.dart';
final syncServiceProvider = Provider<SyncService>((ref) { final syncServiceProvider = Provider<SyncService>((ref) {
final isar = ref.watch(isarProvider); final db = ref.watch(appDatabaseProvider);
final apiClient = ref.watch(apiClientProvider); final apiClient = ref.watch(apiClientProvider);
return SyncService(isar: isar, apiClient: apiClient); return SyncService(db: db, apiClient: apiClient);
}); });
class SyncService { class SyncService {
final Isar isar; final AppDatabase db;
final ApiClient apiClient; final ApiClient apiClient;
final _storage = const FlutterSecureStorage(); final _storage = const FlutterSecureStorage();
bool _isSyncing = false; bool _isSyncing = false;
SyncService({required this.isar, required this.apiClient}); SyncService({required this.db, required this.apiClient});
Future<void> sync() async { Future<void> sync() async {
if (_isSyncing) return; if (_isSyncing) return;
@ -32,72 +30,66 @@ class SyncService {
try { try {
debugPrint('🔄 Starting Sync...'); 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) { for (var cycle in dirtyCycles) {
try { try {
if (cycle.serverId == null) { if (cycle.serverId == null) {
debugPrint( debugPrint(
'📤 Pushing new cycle ${cycle.cycleNumber} to server...'); '📤 Pushing new cycle ${cycle.cycleNumber} to server...');
final tmsMap = cycle.trainingMaxes
Map<String, double> tmsMap = {}; .map((k, v) => MapEntry(k, (v as num).toDouble()));
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 response = await apiClient.createCycle(tmsMap); final response = await apiClient.createCycle(tmsMap);
final newServerId = response['id']; final newServerId = response['id'];
await isar.writeTxn(() async { await db.transaction(() async {
cycle.serverId = newServerId; await (db.update(db.cycles)..where((c) => c.id.equals(cycle.id)))
cycle.isDirty = false; .write(
await isar.cycleCollections.put(cycle); CyclesCompanion(
serverId: Value(newServerId),
isDirty: const Value(false),
),
);
final oldLocalIdRef = cycle.id.toString(); final oldLocalIdRef = cycle.id.toString();
await (db.update(db.workouts)
final orphanWorkouts = await isar.workoutCollections ..where((w) => w.cycleId.equals(oldLocalIdRef)))
.filter() .write(
.cycleIdEqualTo(oldLocalIdRef) WorkoutsCompanion(
.findAll(); cycleId: Value(newServerId),
isDirty: const Value(true),
for (var w in orphanWorkouts) { ),
w.cycleId = newServerId; );
w.isDirty = true; debugPrint(
await isar.workoutCollections.put(w); '🔗 Relinked workouts from local cycle $oldLocalIdRef to server $newServerId');
debugPrint('🔗 Relinked workout ${w.id} to cycle $newServerId');
}
}); });
} else { } else {
await isar.writeTxn(() async { await (db.update(db.cycles)..where((c) => c.id.equals(cycle.id)))
cycle.isDirty = false; .write(
await isar.cycleCollections.put(cycle); const CyclesCompanion(isDirty: Value(false)),
}); );
} }
} catch (e) { } catch (e) {
debugPrint('❌ Failed to sync cycle: $e'); debugPrint('❌ Failed to sync cycle ${cycle.id}: $e');
return;
} }
} }
final dirtyUser = final dirtyUser = await (db.select(db.users)
await isar.userCollections.filter().isDirtyEqualTo(true).findFirst(); ..where((u) => u.isDirty.equals(true)))
.getSingleOrNull();
final dirtyWorkouts = await (db.select(db.workouts)
..where((w) => w.isDirty.equals(true)))
.get();
final dirtyWorkouts = final validWorkouts =
await isar.workoutCollections.filter().isDirtyEqualTo(true).findAll(); dirtyWorkouts.where((w) => w.cycleId.length > 5).toList();
if (dirtyUser == null && dirtyWorkouts.isEmpty) {
debugPrint('✅ Nothing to push.');
} else {
final pushData = <String, dynamic>{ final pushData = <String, dynamic>{
'workouts': dirtyWorkouts.where((w) { 'workouts': validWorkouts.map((w) {
return w.cycleId.length > 5;
}).map((w) {
return { return {
'id': w.serverId, 'id': w.serverId,
'local_id': w.id, 'local_id': w.id,
@ -107,7 +99,7 @@ class SyncService {
'completed_at': w.completedAt?.toIso8601String(), 'completed_at': w.completedAt?.toIso8601String(),
'xp_earned': w.xpEarned, 'xp_earned': w.xpEarned,
'notes': w.notes, 'notes': w.notes,
'exercises': jsonDecode(w.exercisesJson), 'exercises': w.exercises,
}; };
}).toList(), }).toList(),
'user_stats': dirtyUser != null 'user_stats': dirtyUser != null
@ -119,57 +111,103 @@ class SyncService {
: null, : 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); final lastSync = await _storage.read(key: AppConstants.keyLastSync);
if ((pushData['workouts'] as List).isNotEmpty || debugPrint(
pushData['user_stats'] != null) { '☁️ Contacting server (Push: ${validWorkouts.length} workouts)...');
debugPrint('📤 Pushing data...');
final response = await apiClient.sync( final response = await apiClient.sync(
lastSyncTimestamp: lastSync ?? '', lastSyncTimestamp: lastSync ?? '',
pushData: pushData, pushData: pushData,
); );
await isar.writeTxn(() async { await db.transaction(() async {
if (dirtyUser != null) { if (dirtyUser != null) {
dirtyUser.isDirty = false; await (db.update(db.users)..where((u) => u.id.equals(dirtyUser.id)))
await isar.userCollections.put(dirtyUser); .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)),
);
} }
for (var w in dirtyWorkouts) { if (response['pull_data'] != null) {
w.isDirty = false; if (response['pull_data']['cycles'] != null) {
await isar.workoutCollections.put(w); 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'] != null && if (response['pull_data']['workouts'] != null) {
response['pull_data']['workouts'] != null) {
final pulledWorkouts = response['pull_data']['workouts'] as List; final pulledWorkouts = response['pull_data']['workouts'] as List;
debugPrint(
'📥 Pulled ${pulledWorkouts.length} workouts from server.');
for (var wJson in pulledWorkouts) { for (var wJson in pulledWorkouts) {
final serverId = wJson['id']; final serverId = wJson['id'] as String;
var workout = await isar.workoutCollections final existing = await (db.select(db.workouts)
.filter() ..where((w) => w.serverId.equals(serverId)))
.serverIdEqualTo(serverId) .getSingleOrNull();
.findFirst();
workout ??= WorkoutCollection(); 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(),
);
workout if (existing != null) {
..serverId = serverId await (db.update(db.workouts)
..cycleId = wJson['cycle_id'] ..where((w) => w.id.equals(existing.id)))
..userId = wJson['user_id'] .write(companion);
..week = wJson['week'] } else {
..day = wJson['day'] await db.into(db.workouts).insert(companion);
..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);
} }
} }
}); });
@ -180,12 +218,11 @@ class SyncService {
value: response['server_timestamp'], value: response['server_timestamp'],
); );
} }
}
}
debugPrint('✅ Sync completed successfully'); debugPrint('✅ Sync completed successfully');
} catch (e) { } catch (e, stack) {
debugPrint('❌ Sync failed: $e'); debugPrint('❌ Sync failed: $e');
debugPrint(stack.toString());
} finally { } finally {
_isSyncing = false; _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:flutter_riverpod/flutter_riverpod.dart';
import 'package:isar/isar.dart'; import 'package:drift/drift.dart';
import 'dart:convert'; import 'dart:convert';
import '../local/collections/cycle_collection.dart'; import '../local/app_database.dart';
import '../remote/api_client.dart'; import '../remote/api_client.dart';
import '../../../../main.dart'; import '../../../../main.dart';
import 'user_repository.dart'; import 'user_repository.dart';
import '../../../core/constants/app_constants.dart'; import '../../../core/constants/app_constants.dart';
import '../local/collections/workout_collection.dart';
final cycleRepositoryProvider = Provider<CycleRepository>((ref) { final cycleRepositoryProvider = Provider<CycleRepository>((ref) {
final isar = ref.watch(isarProvider); final db = ref.watch(appDatabaseProvider);
final apiClient = ref.watch(apiClientProvider); final apiClient = ref.watch(apiClientProvider);
return CycleRepository(isar: isar, apiClient: apiClient); return CycleRepository(db: db, apiClient: apiClient);
}); });
class CycleRepository { class CycleRepository {
final Isar isar; final AppDatabase db;
final ApiClient apiClient; final ApiClient apiClient;
CycleRepository({required this.isar, required this.apiClient}); CycleRepository({required this.db, required this.apiClient});
Future<CycleCollection?> getCurrentCycle() async { Future<CycleCollection?> getCurrentCycle() async {
return await isar.cycleCollections return await (db.select(db.cycles)
.filter() ..where((c) => c.isActive.equals(true))
.isActiveEqualTo(true) ..limit(1))
.findFirst(); .getSingleOrNull();
} }
Future<List<CycleCollection>> getAllCycles() async { 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 { Future<CycleCollection> createCycle(Map<String, double> trainingMaxes) async {
try { return await db.transaction(() async {
final currentCycle = await getCurrentCycle(); final currentCycle = await getCurrentCycle();
if (currentCycle != null) { if (currentCycle != null) {
currentCycle.isActive = false; final updateOld = CyclesCompanion(
currentCycle.endDate = DateTime.now(); isActive: const Value(false),
await saveCycle(currentCycle); 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(); final allCycles = await getAllCycles();
@ -50,34 +54,44 @@ class CycleRepository {
.reduce((a, b) => a > b ? a : b) + .reduce((a, b) => a > b ? a : b) +
1; 1;
final userRepo = UserRepository(isar: isar, apiClient: ApiClient()); final user = await (db.select(db.users)..limit(1)).getSingleOrNull();
final user = await userRepo.getLocalUser();
if (user == null) { if (user == null) {
throw Exception('No user found for cycle creation'); throw Exception('No user found for cycle creation');
} }
final newCycle = CycleCollection() final newCycleCompanion = CyclesCompanion(
..userId = user.serverId ?? user.id.toString() userId: Value(user.serverId ?? user.id.toString()),
..cycleNumber = nextNumber cycleNumber: Value(nextNumber),
..startDate = DateTime.now() startDate: Value(DateTime.now()),
..isActive = true isActive: const Value(true),
..trainingMaxesJson = jsonEncode(trainingMaxes) trainingMaxes: Value(trainingMaxes),
..isDirty = true; 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 { try {
final response = await apiClient.createCycle(trainingMaxes); final response = await apiClient.createCycle(trainingMaxes);
newCycle.serverId = response['id']; await (db.update(db.cycles)..where((c) => c.id.equals(newId))).write(
newCycle.isDirty = false; CyclesCompanion(
await saveCycle(newCycle); serverId: Value(response['id']),
} catch (e) {} 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; return newCycle;
} catch (e, stackTrace) { });
rethrow;
}
} }
Future<CycleCollection> finishCycle() async { Future<CycleCollection> finishCycle() async {
@ -87,24 +101,26 @@ class CycleRepository {
} }
final cycleIdRef = currentCycle.serverId ?? currentCycle.id.toString(); final cycleIdRef = currentCycle.serverId ?? currentCycle.id.toString();
final localCycleId = currentCycle.id.toString();
final completedMainWorkouts = await isar.workoutCollections final workoutsQuery = db.select(db.workouts)
.filter() ..where((w) {
.weekLessThan(4) final weekCheck = w.week.isSmallerThanValue(4);
.completedAtIsNotNull() final completedCheck = w.completedAt.isNotNull();
.group((q) => q final cycleCheck =
.cycleIdEqualTo(cycleIdRef) w.cycleId.equals(cycleIdRef) | w.cycleId.equals(localCycleId);
.or() return weekCheck & completedCheck & cycleCheck;
.cycleIdEqualTo(currentCycle.id.toString())) });
.count();
final completedMainWorkouts = (await workoutsQuery.get()).length;
if (completedMainWorkouts < 9) { if (completedMainWorkouts < 9) {
final missing = 9 - completedMainWorkouts; final missing = 9 - completedMainWorkouts;
throw Exception( throw Exception(
'Cycle incomplete! You still have $missing workouts left in the main phase (Weeks 1-3). Finish them before leveling up.'); '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>{ final newTMs = <String, double>{
'squat': (currentTMs['squat'] as num?)?.toDouble() ?? 0.0, 'squat': (currentTMs['squat'] as num?)?.toDouble() ?? 0.0,
@ -112,23 +128,27 @@ class CycleRepository {
'dip': (currentTMs['dip'] as num?)?.toDouble() ?? 0.0, 'dip': (currentTMs['dip'] as num?)?.toDouble() ?? 0.0,
}; };
final week3Workouts = await isar.workoutCollections final week3Workouts = await (db.select(db.workouts)
.filter() ..where((w) {
.weekEqualTo(3) final weekCheck = w.week.equals(3);
.group((q) => q final cycleCheck =
.cycleIdEqualTo(cycleIdRef) w.cycleId.equals(cycleIdRef) | w.cycleId.equals(localCycleId);
.or() return weekCheck & cycleCheck;
.cycleIdEqualTo(currentCycle.id.toString())) }))
.findAll(); .get();
bool checkSuccess(String exerciseId) { bool checkSuccess(String exerciseId) {
for (var workout in week3Workouts) { for (var workout in week3Workouts) {
try { try {
final exercises = jsonDecode(workout.exercisesJson) as List; final exercises = workout.exercises;
for (var ex in exercises) {
for (var exData in exercises) {
final ex = exData as Map<String, dynamic>;
if (ex['exerciseId'] == exerciseId) { if (ex['exerciseId'] == exerciseId) {
final sets = ex['sets'] as List; 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) { if (s['isAmrap'] == true) {
final reps = s['repsActual'] as int? ?? 0; final reps = s['repsActual'] as int? ?? 0;
if (reps >= 1) { if (reps >= 1) {
@ -170,33 +190,23 @@ class CycleRepository {
try { try {
await apiClient.finishCycle(currentCycle.serverId!); await apiClient.finishCycle(currentCycle.serverId!);
} catch (e) { } catch (e) {
// Fehler ignorieren, wird später gesynct // Fehler ignorieren
} }
} }
return await createCycle(newTMs); return await createCycle(newTMs);
} }
Future<void> saveCycle(CycleCollection cycle) async { Future<Map<String, double>> getCurrentTrainingMaxesAsync() async {
cycle.updatedAt = DateTime.now(); final cycle = await getCurrentCycle();
await isar.writeTxn(() async {
await isar.cycleCollections.put(cycle);
});
}
Map<String, double> getCurrentTrainingMaxes() {
final cycle =
isar.cycleCollections.filter().isActiveEqualTo(true).findFirstSync();
if (cycle != null) { if (cycle != null) {
final tms = jsonDecode(cycle.trainingMaxesJson); final tms = cycle.trainingMaxes;
return { return {
'squat': (tms['squat'] as num?)?.toDouble() ?? 0.0, 'squat': (tms['squat'] as num?)?.toDouble() ?? 0.0,
'pullup': (tms['pullup'] as num?)?.toDouble() ?? 0.0, 'pullup': (tms['pullup'] as num?)?.toDouble() ?? 0.0,
'dip': (tms['dip'] as num?)?.toDouble() ?? 0.0, 'dip': (tms['dip'] as num?)?.toDouble() ?? 0.0,
}; };
} }
return {'squat': 0.0, 'pullup': 0.0, 'dip': 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 '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/app_database.dart';
import '../local/collections/user_collection.dart';
import '../local/collections/workout_collection.dart';
import '../remote/api_client.dart'; import '../remote/api_client.dart';
import '../../../../main.dart'; import '../../../../main.dart';
import '../../../core/constants/app_constants.dart';
final userRepositoryProvider = Provider<UserRepository>((ref) { final userRepositoryProvider = Provider<UserRepository>((ref) {
final isar = ref.watch(isarProvider); final db = ref.watch(appDatabaseProvider);
final apiClient = ref.watch(apiClientProvider); final apiClient = ref.watch(apiClientProvider);
return UserRepository(isar: isar, apiClient: apiClient); return UserRepository(db: db, apiClient: apiClient);
}); });
final apiClientProvider = Provider<ApiClient>((ref) => ApiClient()); final apiClientProvider = Provider<ApiClient>((ref) => ApiClient());
class UserRepository { class UserRepository {
final Isar isar; final AppDatabase db;
final ApiClient apiClient; 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 { 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 { Future<void> saveLocalUser(UserCollection user) async {
user.updatedAt = DateTime.now(); final companion = user.toCompanion(true).copyWith(
await isar.writeTxn(() async { updatedAt: Value(DateTime.now()),
await isar.userCollections.put(user); );
}); await db.into(db.users).insertOnConflictUpdate(companion);
} }
Future<void> updateXP(int xpToAdd) async { Future<void> updateXP(int xpToAdd) async {
final user = await getLocalUser(); final user = await getLocalUser();
if (user != null) { if (user != null) {
user.xp += xpToAdd; final newXp = user.xp + xpToAdd;
user.isDirty = true; final companion = UsersCompanion(
await saveLocalUser(user); 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 { Future<void> updateLevel(int newLevel) async {
final user = await getLocalUser(); final user = await getLocalUser();
if (user != null) { if (user != null) {
user.level = newLevel; final companion = UsersCompanion(
user.isDirty = true; level: Value(newLevel),
await saveLocalUser(user); 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 { Future<void> updateBodyweight(double bodyweight) async {
final user = await getLocalUser(); final user = await getLocalUser();
if (user != null) { if (user != null) {
user.currentBodyweight = bodyweight; final companion = UsersCompanion(
user.isDirty = true; currentBodyweight: Value(bodyweight),
await saveLocalUser(user); isDirty: const Value(true),
updatedAt: Value(DateTime.now()),
);
await (db.update(db.users)..where((u) => u.id.equals(user.id)))
.write(companion);
try { try {
await apiClient.updateBodyweight(bodyweight); await apiClient.updateBodyweight(bodyweight);
@ -67,9 +81,13 @@ class UserRepository {
Future<void> updateInventory(Map<String, dynamic> inventory) async { Future<void> updateInventory(Map<String, dynamic> inventory) async {
final user = await getLocalUser(); final user = await getLocalUser();
if (user != null) { if (user != null) {
user.inventorySettingsJson = jsonEncode(inventory); final companion = UsersCompanion(
user.isDirty = true; inventorySettings: Value(inventory),
await saveLocalUser(user); isDirty: const Value(true),
updatedAt: Value(DateTime.now()),
);
await (db.update(db.users)..where((u) => u.id.equals(user.id)))
.write(companion);
try { try {
await apiClient.updateInventory(inventory); await apiClient.updateInventory(inventory);
@ -79,21 +97,7 @@ class UserRepository {
Future<UserCollection> login(String email, String password) async { Future<UserCollection> login(String email, String password) async {
final response = await apiClient.login(email, password); final response = await apiClient.login(email, password);
return _saveUserFromApi(response['record']);
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;
} }
Future<UserCollection> register({ Future<UserCollection> register({
@ -111,48 +115,57 @@ class UserRepository {
); );
final record = response['record'] ?? response; final record = response['record'] ?? response;
final user = await _saveUserFromApi(record);
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);
try { try {
await apiClient.login(email, password); await apiClient.login(email, password);
} catch (e) {} } catch (e) {}
return user; return user;
} catch (e, stackTrace) { } catch (e) {
rethrow; 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 { Future<void> logout() async {
await apiClient.logout(); 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() { Future<Map<String, dynamic>> getInventorySettingsAsync() async {
final user = isar.userCollections.where().findFirstSync(); final user = await getLocalUser();
if (user?.inventorySettingsJson != null) { if (user?.inventorySettings != null) {
return jsonDecode(user!.inventorySettingsJson!); return user!.inventorySettings!;
} }
return { return {
'bar_weight': 20.0, 'bar_weight': 20.0,
@ -161,14 +174,14 @@ class UserRepository {
}; };
} }
List<double> getAvailablePlates() { Future<List<double>> getAvailablePlates() async {
final inventory = getInventorySettings(); final inventory = await getInventorySettingsAsync();
final plates = inventory['plates'] as List?; final plates = inventory['plates'] as List?;
return plates?.map((e) => (e as num).toDouble()).toList() ?? []; return plates?.map((e) => (e as num).toDouble()).toList() ?? [];
} }
double getBarWeight() { Future<double> getBarWeight() async {
final inventory = getInventorySettings(); final inventory = await getInventorySettingsAsync();
return (inventory['bar_weight'] as num?)?.toDouble() ?? 20.0; 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."); "Server connection required to reset progress. Please try again when online.");
} }
user.xp = 0; final companion = UsersCompanion(
user.level = 1; 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 db.delete(db.cycles).go();
await db.delete(db.workouts).go();
await isar.writeTxn(() async {
await isar.userCollections.put(user);
await isar.cycleCollections.clear();
await isar.workoutCollections.clear();
});
} }
} }
} }

View file

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

View file

@ -5,26 +5,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: _fe_analyzer_shared name: _fe_analyzer_shared
sha256: ae92f5d747aee634b87f89d9946000c2de774be1d6ac3e58268224348cd0101a sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "61.0.0" version: "67.0.0"
analyzer: analyzer:
dependency: transitive dependency: transitive
description: description:
name: analyzer name: analyzer
sha256: ea3d8652bda62982addfd92fdc2d0214e5f82e43325104990d4f4c4a2a313562 sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.13.0" version: "6.4.1"
analyzer_plugin: analyzer_plugin:
dependency: transitive dependency: transitive
description: description:
name: analyzer_plugin name: analyzer_plugin
sha256: c1d5f167683de03d5ab6c3b53fc9aeefc5d59476e7810ba7bbddff50c6f4392d sha256: "9661b30b13a685efaee9f02e5d01ed9f2b423bd889d28a304d02d704aee69161"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.11.2" version: "0.11.3"
args: args:
dependency: transitive dependency: transitive
description: description:
@ -145,6 +145,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.0"
charcode:
dependency: transitive
description:
name: charcode
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
url: "https://pub.dev"
source: hosted
version: "1.4.0"
checked_yaml: checked_yaml:
dependency: transitive dependency: transitive
description: description:
@ -153,6 +161,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.4" 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: clock:
dependency: transitive dependency: transitive
description: description:
@ -217,14 +233,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.2" version: "2.3.2"
dartx:
dependency: transitive
description:
name: dartx
sha256: "8b25435617027257d43e6508b5fe061012880ddfdaa75a71d607c3de2a13d244"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
dio: dio:
dependency: "direct main" dependency: "direct main"
description: description:
@ -241,6 +249,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.1" 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: equatable:
dependency: "direct main" dependency: "direct main"
description: description:
@ -480,30 +512,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.5" 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: js:
dependency: transitive dependency: transitive
description: description:
@ -744,6 +752,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.5.0" version: "1.5.0"
recase:
dependency: transitive
description:
name: recase
sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213
url: "https://pub.dev"
source: hosted
version: "4.1.0"
riverpod: riverpod:
dependency: transitive dependency: transitive
description: description:
@ -933,6 +949,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.0" 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: stack_trace:
dependency: transitive dependency: transitive
description: description:
@ -997,14 +1037,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.7" version: "0.7.7"
time:
dependency: transitive
description:
name: time
sha256: "370572cf5d1e58adcb3e354c47515da3f7469dac3a95b447117e728e7be6f461"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
timing: timing:
dependency: transitive dependency: transitive
description: description:
@ -1125,14 +1157,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.6.1" version: "6.6.1"
xxh3:
dependency: transitive
description:
name: xxh3
sha256: "399a0438f5d426785723c99da6b16e136f4953fb1e9db0bf270bd41dd4619916"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
yaml: yaml:
dependency: transitive dependency: transitive
description: description:

View file

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