feat: add connectivity check for multiplayer training and retry logic for sync

This commit is contained in:
Patryk Hegenberg 2026-01-21 09:38:02 +01:00
parent 1e573904f2
commit 2e6052e69d
3 changed files with 267 additions and 200 deletions

View file

@ -0,0 +1,12 @@
import 'dart:io';
class ConnectivityHelper {
static Future<bool> hasConnection() async {
try {
final result = await InternetAddress.lookup('8.8.8.8');
return result.isNotEmpty && result[0].rawAddress.isNotEmpty;
} on SocketException catch (_) {
return false;
}
}
}

View file

@ -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<HubScreen> {
});
}
Future<void> _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<void> _startNextWorkout(
CycleCollection cycle, UserCollection user) async {
try {
@ -422,7 +443,7 @@ class _HubScreenState extends ConsumerState<HubScreen> {
icon: const Icon(Icons.groups,
color: AppTheme.secondaryColor),
tooltip: 'Multiplayer Lobby',
onPressed: _showMultiplayerDialog,
onPressed: _handleMultiplayerPress,
),
],
),

View file

@ -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<void> 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 = <String, dynamic>{
'workouts': validWorkouts.map((w) {
return {
'id': w.serverId,
'local_id': w.id,
'cycle_id': w.cycleId,
'week': w.week,
'day': w.day,
'completed_at': w.completedAt?.toIso8601String(),
'xp_earned': w.xpEarned,
'notes': w.notes,
'exercises': w.exercises,
};
}).toList(),
'user_stats': dirtyUser != null
? {
'xp': dirtyUser.xp,
'level': dirtyUser.level,
'current_bodyweight': dirtyUser.currentBodyweight,
'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<String, dynamic>;
final companion = CyclesCompanion(
serverId: Value(serverId),
userId: Value(cJson['user_id']),
cycleNumber: Value(cJson['cycle_number']),
startDate: Value(DateTime.parse(cJson['start_date'])),
endDate: Value(DateTime.tryParse(cJson['end_date'] ?? '')),
isActive: Value(cJson['is_active'] ?? false),
trainingMaxes: Value(tms),
isDirty: const Value(false),
updatedAt: Value(DateTime.now()),
createdAt: existing == null
? Value(DateTime.now())
: const Value.absent(),
);
if (existing != null) {
await (db.update(db.cycles)
..where((c) => c.id.equals(existing.id)))
.write(companion);
} else {
await db.into(db.cycles).insert(companion);
}
}
}
if (response['pull_data']['workouts'] != null) {
final pulledWorkouts = response['pull_data']['workouts'] as List;
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<void> _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 = <String, dynamic>{
'workouts': validWorkouts.map((w) {
return {
'id': w.serverId,
'local_id': w.id,
'cycle_id': w.cycleId,
'week': w.week,
'day': w.day,
'completed_at': w.completedAt?.toIso8601String(),
'xp_earned': w.xpEarned,
'notes': w.notes,
'exercises': w.exercises,
};
}).toList(),
'user_stats': dirtyUser != null
? {
'xp': dirtyUser.xp,
'level': dirtyUser.level,
'current_bodyweight': dirtyUser.currentBodyweight,
'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<String, dynamic>;
final companion = CyclesCompanion(
serverId: Value(serverId),
userId: Value(cJson['user_id']),
cycleNumber: Value(cJson['cycle_number']),
startDate: Value(DateTime.parse(cJson['start_date'])),
endDate: Value(DateTime.tryParse(cJson['end_date'] ?? '')),
isActive: Value(cJson['is_active'] ?? false),
trainingMaxes: Value(tms),
isDirty: const Value(false),
updatedAt: Value(DateTime.now()),
createdAt: existing == null
? Value(DateTime.now())
: const Value.absent(),
);
if (existing != null) {
await (db.update(db.cycles)
..where((c) => c.id.equals(existing.id)))
.write(companion);
} else {
await db.into(db.cycles).insert(companion);
}
}
}
if (response['pull_data']['workouts'] != null) {
final pulledWorkouts = response['pull_data']['workouts'] as List;
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<T> _retry<T>(
Future<T> 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);
}
}
}
}