diff --git a/lib/src/core/utils/connectivity_helper.dart b/lib/src/core/utils/connectivity_helper.dart new file mode 100644 index 0000000..127ddba --- /dev/null +++ b/lib/src/core/utils/connectivity_helper.dart @@ -0,0 +1,12 @@ +import 'dart:io'; + +class ConnectivityHelper { + static Future hasConnection() async { + try { + final result = await InternetAddress.lookup('8.8.8.8'); + return result.isNotEmpty && result[0].rawAddress.isNotEmpty; + } on SocketException catch (_) { + return false; + } + } +} diff --git a/lib/src/features/dashboard/presentation/screens/hub_screen.dart b/lib/src/features/dashboard/presentation/screens/hub_screen.dart index 15a53aa..b6de5df 100644 --- a/lib/src/features/dashboard/presentation/screens/hub_screen.dart +++ b/lib/src/features/dashboard/presentation/screens/hub_screen.dart @@ -25,6 +25,7 @@ import '../widgets/level_display.dart'; import '../widgets/start_raid_button.dart'; import '../../../gamification/application/quest_service.dart'; import '../../../workout_runner/application/workout_generator_service.dart'; +import '../../../../core/utils/connectivity_helper.dart'; class HubScreen extends ConsumerStatefulWidget { const HubScreen({super.key}); @@ -51,6 +52,26 @@ class _HubScreenState extends ConsumerState { }); } + Future _handleMultiplayerPress() async { + final hasInternet = await ConnectivityHelper.hasConnection(); + + if (!hasInternet) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'FΓΌr Multiplayer wird eine aktive Internetverbindung benΓΆtigt.'), //TODO: make this in l10n + backgroundColor: AppTheme.errorColor, + behavior: SnackBarBehavior.floating, + ), + ); + } + return; + } + + if (mounted) _showMultiplayerDialog(); + } + Future _startNextWorkout( CycleCollection cycle, UserCollection user) async { try { @@ -422,7 +443,7 @@ class _HubScreenState extends ConsumerState { icon: const Icon(Icons.groups, color: AppTheme.secondaryColor), tooltip: 'Multiplayer Lobby', - onPressed: _showMultiplayerDialog, + onPressed: _handleMultiplayerPress, ), ], ), diff --git a/lib/src/shared/data/remote/sync_service.dart b/lib/src/shared/data/remote/sync_service.dart index 5f125db..938fa55 100644 --- a/lib/src/shared/data/remote/sync_service.dart +++ b/lib/src/shared/data/remote/sync_service.dart @@ -1,10 +1,14 @@ +import 'dart:async'; import 'dart:developer'; +import 'dart:io'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:drift/drift.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import '../../../../main.dart'; import '../../../core/constants/app_constants.dart'; +import '../../../core/utils/connectivity_helper.dart'; import '../local/app_database.dart'; import '../repositories/user_repository.dart'; import 'api_client.dart'; @@ -25,211 +29,241 @@ class SyncService { Future sync() async { if (_isSyncing) return; + + if (!await ConnectivityHelper.hasConnection()) { + log('Sync skipped: No internet connection'); + return; + } + _isSyncing = true; try { - log('πŸ”„ Starting Sync...'); - - final dirtyCycles = await (db.select(db.cycles) - ..where((c) => c.isDirty.equals(true))) - .get(); - - for (var cycle in dirtyCycles) { - try { - if (cycle.serverId == null) { - log('πŸ“€ Pushing new cycle ${cycle.cycleNumber}...'); - final tmsMap = cycle.trainingMaxes - .map((k, v) => MapEntry(k, (v as num).toDouble())); - - final response = await apiClient.createCycle(tmsMap); - final newServerId = response['id']; - - await db.transaction(() async { - await (db.update(db.cycles)..where((c) => c.id.equals(cycle.id))) - .write( - CyclesCompanion( - serverId: Value(newServerId), - isDirty: const Value(false), - ), - ); - - final oldLocalIdRef = cycle.id.toString(); - await (db.update(db.workouts) - ..where((w) => w.cycleId.equals(oldLocalIdRef))) - .write( - WorkoutsCompanion( - cycleId: Value(newServerId), - isDirty: const Value(true), - ), - ); - }); - } else { - await (db.update(db.cycles)..where((c) => c.id.equals(cycle.id))) - .write(const CyclesCompanion(isDirty: Value(false))); - } - } catch (e) { - log('❌ Failed to sync cycle ${cycle.id}: $e'); - } - } - - final dirtyUser = await (db.select(db.users) - ..where((u) => u.isDirty.equals(true))) - .getSingleOrNull(); - final dirtyWorkouts = await (db.select(db.workouts) - ..where((w) => w.isDirty.equals(true))) - .get(); - - final validWorkouts = - dirtyWorkouts.where((w) => w.cycleId.length > 5).toList(); - - final pushData = { - '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, - 'exercise_variants': dirtyUser.exerciseVariants, - } - : null, - }; - - final lastSync = await _storage.read(key: AppConstants.keyLastSync); - - if ((pushData['workouts'] as List).isNotEmpty || - pushData['user_stats'] != null) { - log('πŸ“€ Pushing data...'); - final response = await apiClient.sync( - lastSyncTimestamp: lastSync ?? '', - pushData: pushData, - ); - - await db.transaction(() async { - if (dirtyUser != null) { - await (db.update(db.users)..where((u) => u.id.equals(dirtyUser.id))) - .write(const UsersCompanion(isDirty: Value(false))); - } - for (var w in validWorkouts) { - await (db.update(db.workouts)..where((dw) => dw.id.equals(w.id))) - .write(const WorkoutsCompanion(isDirty: Value(false))); - } - - if (response['pull_data'] != null) { - if (response['pull_data']['cycles'] != null) { - final pulledCycles = response['pull_data']['cycles'] as List; - for (var cJson in pulledCycles) { - final serverId = cJson['id'] as String; - final existing = await (db.select(db.cycles) - ..where((c) => c.serverId.equals(serverId))) - .getSingleOrNull(); - - final tms = cJson['training_maxes'] as Map; - final companion = CyclesCompanion( - serverId: Value(serverId), - userId: Value(cJson['user_id']), - cycleNumber: Value(cJson['cycle_number']), - startDate: Value(DateTime.parse(cJson['start_date'])), - endDate: Value(DateTime.tryParse(cJson['end_date'] ?? '')), - isActive: Value(cJson['is_active'] ?? false), - trainingMaxes: Value(tms), - isDirty: const Value(false), - updatedAt: Value(DateTime.now()), - createdAt: existing == null - ? Value(DateTime.now()) - : const Value.absent(), - ); - - if (existing != null) { - await (db.update(db.cycles) - ..where((c) => c.id.equals(existing.id))) - .write(companion); - } else { - await db.into(db.cycles).insert(companion); - } - } - } - - if (response['pull_data']['workouts'] != null) { - final pulledWorkouts = response['pull_data']['workouts'] as List; - log('πŸ“₯ Pulled ${pulledWorkouts.length} workouts.'); - - for (var wJson in pulledWorkouts) { - final serverId = wJson['id'] as String; - final cycleId = wJson['cycle_id'] as String; - final week = wJson['week'] as int; - final day = wJson['day'] as int; - - var existing = await (db.select(db.workouts) - ..where((w) => w.serverId.equals(serverId))) - .getSingleOrNull(); - - if (existing == null) { - final candidates = await (db.select(db.workouts) - ..where((w) => - w.cycleId.equals(cycleId) & - w.week.equals(week) & - w.day.equals(day))) - .get(); - if (candidates.isNotEmpty) { - existing = candidates.first; - log('πŸ”„ Merging local workout ${existing.id} with server ID $serverId'); - } - } - - final companion = WorkoutsCompanion( - serverId: Value(serverId), - cycleId: Value(cycleId), - userId: Value(wJson['user_id']), - week: Value(week), - day: Value(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']); - } - } + await _retry(_performSync, maxAttempts: 3); log('βœ… Sync completed successfully'); - } catch (e, stack) { - log('❌ Sync failed: $e'); - log(stack.toString()); + } catch (e) { + log('❌ Sync failed finally: $e'); } finally { _isSyncing = false; } } + + Future _performSync() async { + log('πŸ”„ Starting Sync...'); + + final dirtyCycles = await (db.select(db.cycles) + ..where((c) => c.isDirty.equals(true))) + .get(); + + for (var cycle in dirtyCycles) { + if (cycle.serverId == null) { + log('πŸ“€ Pushing new cycle ${cycle.cycleNumber}...'); + final tmsMap = cycle.trainingMaxes + .map((k, v) => MapEntry(k, (v as num).toDouble())); + + final response = await apiClient.createCycle(tmsMap); + final newServerId = response['id']; + + await db.transaction(() async { + await (db.update(db.cycles)..where((c) => c.id.equals(cycle.id))) + .write( + CyclesCompanion( + serverId: Value(newServerId), + isDirty: const Value(false), + ), + ); + + final oldLocalIdRef = cycle.id.toString(); + await (db.update(db.workouts) + ..where((w) => w.cycleId.equals(oldLocalIdRef))) + .write( + WorkoutsCompanion( + cycleId: Value(newServerId), + isDirty: const Value(true), + ), + ); + }); + } else { + await (db.update(db.cycles)..where((c) => c.id.equals(cycle.id))) + .write(const CyclesCompanion(isDirty: Value(false))); + } + } + + final dirtyUser = await (db.select(db.users) + ..where((u) => u.isDirty.equals(true))) + .getSingleOrNull(); + final dirtyWorkouts = await (db.select(db.workouts) + ..where((w) => w.isDirty.equals(true))) + .get(); + + final validWorkouts = + dirtyWorkouts.where((w) => w.cycleId.length > 5).toList(); + + final pushData = { + '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, + 'exercise_variants': dirtyUser.exerciseVariants, + } + : null, + }; + + final lastSync = await _storage.read(key: AppConstants.keyLastSync); + + // if ((pushData['workouts'] as List).isNotEmpty || pushData['user_stats'] != null) { ... } + + log('Checking for updates...'); + final response = await apiClient.sync( + lastSyncTimestamp: lastSync ?? '', + pushData: pushData, + ); + + await db.transaction(() async { + if (dirtyUser != null) { + await (db.update(db.users)..where((u) => u.id.equals(dirtyUser.id))) + .write(const UsersCompanion(isDirty: Value(false))); + } + for (var w in validWorkouts) { + await (db.update(db.workouts)..where((dw) => dw.id.equals(w.id))) + .write(const WorkoutsCompanion(isDirty: Value(false))); + } + + if (response['pull_data'] != null) { + if (response['pull_data']['cycles'] != null) { + final pulledCycles = response['pull_data']['cycles'] as List; + for (var cJson in pulledCycles) { + final serverId = cJson['id'] as String; + final existing = await (db.select(db.cycles) + ..where((c) => c.serverId.equals(serverId))) + .getSingleOrNull(); + + final tms = cJson['training_maxes'] as Map; + final companion = CyclesCompanion( + serverId: Value(serverId), + userId: Value(cJson['user_id']), + cycleNumber: Value(cJson['cycle_number']), + startDate: Value(DateTime.parse(cJson['start_date'])), + endDate: Value(DateTime.tryParse(cJson['end_date'] ?? '')), + isActive: Value(cJson['is_active'] ?? false), + trainingMaxes: Value(tms), + isDirty: const Value(false), + updatedAt: Value(DateTime.now()), + createdAt: existing == null + ? Value(DateTime.now()) + : const Value.absent(), + ); + + if (existing != null) { + await (db.update(db.cycles) + ..where((c) => c.id.equals(existing.id))) + .write(companion); + } else { + await db.into(db.cycles).insert(companion); + } + } + } + + if (response['pull_data']['workouts'] != null) { + final pulledWorkouts = response['pull_data']['workouts'] as List; + log('πŸ“₯ Pulled ${pulledWorkouts.length} workouts.'); + + for (var wJson in pulledWorkouts) { + final serverId = wJson['id'] as String; + final cycleId = wJson['cycle_id'] as String; + final week = wJson['week'] as int; + final day = wJson['day'] as int; + + var existing = await (db.select(db.workouts) + ..where((w) => w.serverId.equals(serverId))) + .getSingleOrNull(); + + if (existing == null) { + final candidates = await (db.select(db.workouts) + ..where((w) => + w.cycleId.equals(cycleId) & + w.week.equals(week) & + w.day.equals(day))) + .get(); + if (candidates.isNotEmpty) { + existing = candidates.first; + log('πŸ”„ Merging local workout ${existing.id} with server ID $serverId'); + } + } + + final companion = WorkoutsCompanion( + serverId: Value(serverId), + cycleId: Value(cycleId), + userId: Value(wJson['user_id']), + week: Value(week), + day: Value(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']); + } + } + + Future _retry( + Future Function() function, { + int maxAttempts = 3, + Duration initialDelay = const Duration(seconds: 2), + }) async { + int attempts = 0; + while (true) { + try { + attempts++; + return await function(); + } catch (e) { + final shouldRetry = (e is SocketException) || + (e.toString().contains('500')) || + (e.toString().contains('502')) || + (e.toString().contains('timeout')); + + if (attempts >= maxAttempts || !shouldRetry) { + rethrow; + } + + final delay = initialDelay * (1 << (attempts - 1)); // 2s, 4s, 8s + log('⚠️ Sync attempt $attempts failed. Retrying in ${delay.inSeconds}s... Error: $e'); + await Future.delayed(delay); + } + } + } }