feat: add connectivity check for multiplayer training and retry logic for sync
This commit is contained in:
parent
1e573904f2
commit
2e6052e69d
3 changed files with 267 additions and 200 deletions
12
lib/src/core/utils/connectivity_helper.dart
Normal file
12
lib/src/core/utils/connectivity_helper.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue