Merge branch 'dev/perform-cleanup-and-overall-fixes'

* dev/perform-cleanup-and-overall-fixes:
  fix: fix overflowing chip
  refactor: perform minor cleanup tasks
  fix: fix errors while refreshing token on app start
  feat: rebuild app with pocketbase sdk instead of dio
  feat: fix bad coding practices, security risks and improve error handling and project structure
This commit is contained in:
Patryk Hegenberg 2026-02-14 16:37:35 +01:00
commit fdc258af28
19 changed files with 872 additions and 535 deletions

View file

@ -1,22 +1,20 @@
import 'dart:developer'; import 'dart:developer';
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 'src/app.dart';
import 'src/shared/data/local/app_database.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:slrpg_app/src/app.dart';
import 'package:slrpg_app/src/shared/data/local/app_database.dart';
import 'package:slrpg_app/src/shared/data/remote/api_client.dart';
import 'package:slrpg_app/src/shared/data/remote/pb_auth_store.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
try { try {
await dotenv.load(fileName: '.env'); await dotenv.load(fileName: '.env');
log('Environment loaded: ${dotenv.env['ENVIRONMENT']}');
log('API URL: ${dotenv.env['API_BASE_URL']}');
} catch (e) { } catch (e) {
log('Could not load .env file: $e'); log('Could not load .env file: $e');
log('Using default production values');
} }
await SystemChrome.setPreferredOrientations([ await SystemChrome.setPreferredOrientations([
@ -26,9 +24,18 @@ void main() async {
final database = AppDatabase(); final database = AppDatabase();
final authStore = PbAuthStore();
await authStore.loadFromStorage();
log("Auth loaded. Valid? ${authStore.isValid}");
runApp( runApp(
ProviderScope( ProviderScope(
overrides: [appDatabaseProvider.overrideWithValue(database)], overrides: [
appDatabaseProvider.overrideWithValue(database),
apiClientProvider
.overrideWith((ref) => ApiClient(authStore: authStore)),
],
child: const SLRPGApp(), child: const SLRPGApp(),
), ),
); );

View file

@ -1,9 +1,12 @@
import 'dart:async';
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:slrpg_app/src/features/authentication/data/repositories/auth_repository.dart';
import 'package:slrpg_app/src/features/multiplayer/presentation/screens/leaderboard_screen.dart'; import 'package:slrpg_app/src/features/multiplayer/presentation/screens/leaderboard_screen.dart';
import 'package:slrpg_app/src/features/multiplayer/presentation/screens/lobby_screen.dart'; import 'package:slrpg_app/src/features/multiplayer/presentation/screens/lobby_screen.dart';
import 'package:slrpg_app/src/features/settings/presentation/screens/privacy_policy_screen.dart'; import 'package:slrpg_app/src/features/settings/presentation/screens/privacy_policy_screen.dart';
import 'package:slrpg_app/src/shared/data/remote/api_client.dart';
import '../../features/authentication/presentation/screens/login_screen.dart'; import '../../features/authentication/presentation/screens/login_screen.dart';
import '../../features/authentication/presentation/screens/profile_screen.dart'; import '../../features/authentication/presentation/screens/profile_screen.dart';
@ -25,9 +28,11 @@ import '../../features/gamification/presentation/screens/codex_screen.dart';
final routerProvider = Provider<GoRouter>((ref) { final routerProvider = Provider<GoRouter>((ref) {
final userRepo = ref.watch(userRepositoryProvider); final userRepo = ref.watch(userRepositoryProvider);
final authRepo = ref.watch(authRepositoryProvider);
return GoRouter( return GoRouter(
initialLocation: '/splash', initialLocation: '/splash',
refreshListenable: _StreamToLegacyListenable(authRepo.authStateChanges),
redirect: (context, state) async { redirect: (context, state) async {
final user = await userRepo.getLocalUser(); final user = await userRepo.getLocalUser();
final isAuthenticated = user != null; final isAuthenticated = user != null;
@ -172,6 +177,20 @@ final routerProvider = Provider<GoRouter>((ref) {
); );
}); });
class _StreamToLegacyListenable extends ChangeNotifier {
late final StreamSubscription _subscription;
_StreamToLegacyListenable(Stream stream) {
_subscription = stream.listen((_) => notifyListeners());
}
@override
void dispose() {
_subscription.cancel();
super.dispose();
}
}
class SplashScreen extends ConsumerStatefulWidget { class SplashScreen extends ConsumerStatefulWidget {
const SplashScreen({super.key}); const SplashScreen({super.key});
@ -187,7 +206,20 @@ class _SplashScreenState extends ConsumerState<SplashScreen> {
} }
Future<void> _checkInitialRoute() async { Future<void> _checkInitialRoute() async {
await Future.delayed(const Duration(seconds: 1)); await Future.delayed(const Duration(milliseconds: 500));
if (!mounted) return;
final apiClient = ref.read(apiClientProvider);
final authStore = apiClient.pb.authStore;
if (authStore.isValid && authStore.record == null) {
try {
await apiClient.refreshAuth();
} catch (e) {
// If refresh fails, user will be redirected to login by the router logic (authStore cleared)
}
}
if (!mounted) return; if (!mounted) return;

View file

@ -0,0 +1,156 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:drift/drift.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:pocketbase/pocketbase.dart';
import 'package:slrpg_app/main.dart';
import '../../../../shared/data/local/app_database.dart';
import '../../../../shared/data/remote/api_client.dart';
import '../../../../core/constants/app_constants.dart';
final authRepositoryProvider = Provider<AuthRepository>((ref) {
final db = ref.watch(appDatabaseProvider);
final apiClient = ref.watch(apiClientProvider);
final repo = AuthRepository(db: db, apiClient: apiClient);
apiClient.authStateChanges.listen((event) {
if (event.token.isEmpty) {
repo.clearLocalData();
}
});
return repo;
});
class AuthRepository {
final AppDatabase db;
final ApiClient apiClient;
final _storage = const FlutterSecureStorage();
Stream<AuthStoreEvent> get authStateChanges => apiClient.authStateChanges;
AuthRepository({required this.db, required this.apiClient});
Future<UserCollection> login(String email, String password) async {
final response = await apiClient.login(email, password);
if (response.record == null) {
throw ClientException(
statusCode: 400,
response: {'message': 'Login failed: No user record returned'});
}
return _saveUserFromApi(response.record!.toJson());
}
Future<UserCollection> register({
required String email,
required String username,
required String password,
required double bodyweight,
required Map<String, dynamic> inventorySettings,
Map<String, dynamic>? exerciseVariants,
Map<String, dynamic>? avatarConfig,
}) async {
try {
final response = await apiClient.register(
email: email,
username: username,
password: password,
bodyweight: bodyweight,
inventorySettings: inventorySettings,
exerciseVariants: exerciseVariants,
avatarConfig: avatarConfig,
);
if (response.record == null) {
throw ClientException(statusCode: 400, response: {
'message': 'Registration failed: No user record returned'
});
}
final recordMap = response.record!.toJson();
var user = await _saveUserFromApi(recordMap);
if (exerciseVariants != null && exerciseVariants.isNotEmpty) {
final serverVariants = user.exerciseVariants;
if (serverVariants == null || serverVariants.isEmpty) {
final companion = user.toCompanion(true).copyWith(
exerciseVariants: Value(exerciseVariants),
isDirty: const Value(true),
updatedAt: Value(DateTime.now()),
);
await db.into(db.users).insertOnConflictUpdate(companion);
user = (await (db.select(db.users)
..where((u) => u.id.equals(user.id)))
.getSingle());
}
}
return user;
} catch (e) {
rethrow;
}
}
Future<void> changePassword(String oldPassword, String newPassword) async {
final user = await (db.select(db.users)..limit(1)).getSingleOrNull();
if (user?.serverId != null) {
await apiClient.updatePassword(
userId: user!.serverId!,
oldPassword: oldPassword,
newPassword: newPassword,
newPasswordConfirm: newPassword,
);
} else {
throw Exception('User not synced or offline');
}
}
Future<void> deleteAccount() async {
final user = await (db.select(db.users)..limit(1)).getSingleOrNull();
if (user?.serverId != null) {
await apiClient.deleteAccount(user!.serverId!);
}
await logout();
}
Future<void> logout() async {
await apiClient.logout();
await clearLocalData();
}
Future<void> clearLocalData() async {
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();
await db.delete(db.quests).go();
});
}
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'] ?? {}),
exerciseVariants: Value(record['exercise_variants'] ?? {}),
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());
}
}

View file

@ -4,7 +4,7 @@ import 'package:go_router/go_router.dart';
import 'package:slrpg_app/l10n/app_localizations.dart'; import 'package:slrpg_app/l10n/app_localizations.dart';
import 'package:slrpg_app/src/core/utils/error_handler.dart'; import 'package:slrpg_app/src/core/utils/error_handler.dart';
import '../../../../shared/data/repositories/user_repository.dart'; import '../../data/repositories/auth_repository.dart';
import '../../../../core/theme/app_theme.dart'; import '../../../../core/theme/app_theme.dart';
class LoginScreen extends ConsumerStatefulWidget { class LoginScreen extends ConsumerStatefulWidget {
@ -46,8 +46,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
final userRepo = ref.read(userRepositoryProvider); final authRepo = ref.read(authRepositoryProvider);
await userRepo.login( await authRepo.login(
_emailController.text.trim(), _emailController.text.trim(),
_passwordController.text, _passwordController.text,
); );

View file

@ -7,6 +7,7 @@ import 'package:slrpg_app/l10n/app_localizations.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/app_database.dart'; import '../../../../shared/data/local/app_database.dart';
import '../../data/repositories/auth_repository.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';
@ -115,7 +116,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
Navigator.pop(context); Navigator.pop(context);
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
await ref.read(userRepositoryProvider).changePassword( await ref.read(authRepositoryProvider).changePassword(
oldPassCtrl.text, oldPassCtrl.text,
newPassCtrl.text, newPassCtrl.text,
); );
@ -468,8 +469,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
const SizedBox(height: 32), const SizedBox(height: 32),
Text(l10n.profilePhysicalStats, Text(l10n.profilePhysicalStats,
style: Theme.of(context) style: Theme.of(context)
.textTheme .textTheme.titleLarge
.titleLarge
?.copyWith(color: AppTheme.textPrimary)), ?.copyWith(color: AppTheme.textPrimary)),
const SizedBox(height: 16), const SizedBox(height: 16),
Card( Card(
@ -602,7 +602,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
() async { () async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
await userRepo.deleteAccount(); await ref.read(authRepositoryProvider).deleteAccount();
if (mounted) context.go('/login'); if (mounted) context.go('/login');
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
@ -621,7 +621,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
const SizedBox(height: 32), const SizedBox(height: 32),
OutlinedButton.icon( OutlinedButton.icon(
onPressed: () async { onPressed: () async {
await userRepo.logout(); await ref.read(authRepositoryProvider).logout();
if (mounted) context.go('/login'); if (mounted) context.go('/login');
}, },
icon: const Icon(Icons.logout), icon: const Icon(Icons.logout),
@ -726,4 +726,4 @@ class _RadioTile<T> extends StatelessWidget {
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
); );
} }
} }

View file

@ -108,7 +108,7 @@ class _HubScreenState extends ConsumerState<HubScreen> {
if (!found) { if (!found) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(AppLocalizations.of(context)!.hubCycleComplete)), content: Text(AppLocalizations.of(context)!.hubCycleComplete)),
); );
} }
@ -224,7 +224,7 @@ class _HubScreenState extends ConsumerState<HubScreen> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
if (sets >= 20) if (sets >= 20)
Text(l10n.missionBriefingHardcore, Text(l10n.missionBriefingHardcore,
style: const TextStyle( style: const TextStyle(
color: AppTheme.errorColor, color: AppTheme.errorColor,
fontSize: 10, fontSize: 10,
@ -465,7 +465,7 @@ class _HubScreenState extends ConsumerState<HubScreen> {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const QuestBoardWidget(), // const QuestBoardWidget(),
const Spacer(flex: 2), const Spacer(flex: 2),
if (cycle != null) if (cycle != null)
Padding( Padding(

View file

@ -117,8 +117,11 @@ class _LoreCard extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Wrap(
mainAxisAlignment: MainAxisAlignment.spaceBetween, spacing: 12.0,
alignment: WrapAlignment.spaceBetween,
crossAxisAlignment: WrapCrossAlignment.center,
runSpacing: 4,
children: [ children: [
Text( Text(
name.toUpperCase(), name.toUpperCase(),

View file

@ -1,6 +1,5 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:slrpg_app/src/features/multiplayer/domain/entities/leaderboard_entry.dart'; import 'package:slrpg_app/src/features/multiplayer/domain/entities/leaderboard_entry.dart';
import 'package:slrpg_app/src/shared/data/repositories/user_repository.dart';
import '../../../../shared/data/remote/api_client.dart'; import '../../../../shared/data/remote/api_client.dart';
final leaderboardRepositoryProvider = Provider((ref) { final leaderboardRepositoryProvider = Provider((ref) {
@ -14,19 +13,15 @@ class LeaderboardRepository {
Future<List<LeaderboardEntry>> getGlobalLeaderboard() async { Future<List<LeaderboardEntry>> getGlobalLeaderboard() async {
try { try {
final response = await _api.dio.get( final records = await _api.pb.collection('leaderboard').getList(
'/api/collections/leaderboard/records', sort: '-level,-xp',
queryParameters: { perPage: 50,
'sort': '-level,-xp',
'perPage': 50,
},
); );
final items = (response.data['items'] as List); return records.items.asMap().entries.map((entry) {
return items.asMap().entries.map((entry) {
final index = entry.key; final index = entry.key;
final data = entry.value as Map<String, dynamic>; final record = entry.value;
final data = record.toJson();
if (data['name'] == null || data['name'].toString().isEmpty) { if (data['name'] == null || data['name'].toString().isEmpty) {
data['name'] = 'Unknown Hero'; data['name'] = 'Unknown Hero';
@ -38,4 +33,4 @@ class LeaderboardRepository {
throw Exception('Failed to load leaderboard: $e'); throw Exception('Failed to load leaderboard: $e');
} }
} }
} }

View file

@ -4,8 +4,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pocketbase/pocketbase.dart'; import 'package:pocketbase/pocketbase.dart';
import 'package:slrpg_app/src/features/multiplayer/domain/entities/party.dart'; import 'package:slrpg_app/src/features/multiplayer/domain/entities/party.dart';
import 'package:slrpg_app/src/features/multiplayer/domain/entities/party_member.dart'; import 'package:slrpg_app/src/features/multiplayer/domain/entities/party_member.dart';
import 'package:slrpg_app/src/shared/data/repositories/user_repository.dart';
import '../../../../core/constants/app_constants.dart';
import '../../../../shared/data/remote/api_client.dart'; import '../../../../shared/data/remote/api_client.dart';
final partyRepositoryProvider = Provider((ref) { final partyRepositoryProvider = Provider((ref) {
@ -14,47 +12,39 @@ final partyRepositoryProvider = Provider((ref) {
class PartyRepository { class PartyRepository {
final ApiClient _api; final ApiClient _api;
final PocketBase _pb;
PartyRepository(this._api) : _pb = PocketBase(AppConstants.apiBaseUrl); PartyRepository(this._api);
Future<void> _syncAuth() async {
final token = await _api.getToken();
if (token != null) {
_pb.authStore.save(token, null);
}
}
Future<Party> createParty() async { Future<Party> createParty() async {
final response = await _api.dio.post('/api/v1/party/create'); final response = await _api.pb.send('/api/v1/party/create', method: 'POST');
final partyId = response.data['party_id']; final partyId = response['party_id'];
return getPartyDetails(partyId); return getPartyDetails(partyId);
} }
Future<Party> joinParty(String code) async { Future<Party> joinParty(String code) async {
final response = await _api.dio.post( final response = await _api.pb.send(
'/api/v1/party/join', '/api/v1/party/join',
data: {'code': code}, method: 'POST',
body: {'code': code},
); );
final partyId = response.data['party_id']; final partyId = response['party_id'];
return getPartyDetails(partyId); return getPartyDetails(partyId);
} }
Future<Party> getPartyDetails(String partyId) async { Future<Party> getPartyDetails(String partyId) async {
final response = final record = await _api.pb.collection('parties').getOne(partyId);
await _api.dio.get('/api/collections/parties/records/$partyId'); return Party.fromJson(record.toJson());
return Party.fromJson(response.data);
} }
Future<void> setReady(String partyId, bool isReady) async { Future<void> setReady(String partyId, bool isReady) async {
final userId = (await _api.getUserId())!; final userId = _api.getUserId()!;
final members = await _pb.collection('party_members').getList( final members = await _api.pb.collection('party_members').getList(
filter: 'party_id="$partyId" && user_id="$userId"', filter: 'party_id="$partyId" && user_id="$userId"',
); );
if (members.items.isNotEmpty) { if (members.items.isNotEmpty) {
await _pb.collection('party_members').update( await _api.pb.collection('party_members').update(
members.items.first.id, members.items.first.id,
body: {'is_ready': isReady}, body: {'is_ready': isReady},
); );
@ -69,53 +59,80 @@ class PartyRepository {
body['max_hp'] = customHp; body['max_hp'] = customHp;
} }
await _pb.collection('parties').update(partyId, body: body); await _api.pb.collection('parties').update(partyId, body: body);
} }
Stream<Party> subscribeToParty(String partyId) async* { Stream<Party> subscribeToParty(String partyId) {
await _syncAuth(); late StreamController<Party> controller;
UnsubscribeFunc? unsubscribe;
yield await getPartyDetails(partyId); controller = StreamController<Party>(
onListen: () async {
try {
final initial = await getPartyDetails(partyId);
controller.add(initial);
} catch (e) {
controller.addError(e);
}
final controller = StreamController<Party>(); unsubscribe =
await _api.pb.collection('parties').subscribe(partyId, (e) {
if (e.action == 'update' && e.record != null) {
controller.add(Party.fromJson(e.record!.toJson()));
}
});
},
onCancel: () async {
await unsubscribe?.call();
log('🔌 Unsubscribed from party $partyId');
},
);
_pb.collection('parties').subscribe(partyId, (e) { return controller.stream;
if (e.action == 'update') {
controller.add(Party.fromJson(e.record!.toJson()));
}
});
yield* controller.stream;
} }
Stream<List<PartyMember>> subscribeToMembers(String partyId) async* { Stream<List<PartyMember>> subscribeToMembers(String partyId) {
await _syncAuth(); late StreamController<List<PartyMember>> controller;
UnsubscribeFunc? unsubscribe;
Future<List<PartyMember>> fetchMembers() async { Future<List<PartyMember>> fetchMembers() async {
final records = await _pb.collection('party_members').getFullList( final records = await _api.pb.collection('party_members').getFullList(
filter: 'party_id="$partyId"', filter: 'party_id="$partyId"',
expand: 'user_id', expand: 'user_id',
); );
return records.map((r) => PartyMember.fromRecord(r.toJson())).toList(); return records.map((r) => PartyMember.fromRecord(r.toJson())).toList();
} }
yield await fetchMembers(); controller = StreamController<List<PartyMember>>(
onListen: () async {
try {
controller.add(await fetchMembers());
} catch (e) {
controller.addError(e);
}
final controller = StreamController<List<PartyMember>>(); unsubscribe =
await _api.pb.collection('party_members').subscribe('*', (e) async {
if (e.record != null &&
e.record!.getStringValue('party_id') == partyId) {
controller.add(await fetchMembers());
}
});
},
onCancel: () async {
await unsubscribe?.call();
log('🔌 Unsubscribed from party members $partyId');
},
);
_pb.collection('party_members').subscribe('*', (e) async { return controller.stream;
if (e.record!.getStringValue('party_id') == partyId) {
controller.add(await fetchMembers());
}
});
yield* controller.stream;
} }
Future<void> dealDamage(String partyId, int damage) async { Future<void> dealDamage(String partyId, int damage) async {
await _api.dio.post( await _api.pb.send(
'/api/v1/party/damage', '/api/v1/party/damage',
data: { method: 'POST',
body: {
'party_id': partyId, 'party_id': partyId,
'damage': damage, 'damage': damage,
}, },
@ -124,13 +141,13 @@ class PartyRepository {
Future<void> leaveParty(String partyId, String userId) async { Future<void> leaveParty(String partyId, String userId) async {
try { try {
final result = await _pb.collection('party_members').getList( final result = await _api.pb.collection('party_members').getList(
filter: 'party_id="$partyId" && user_id="$userId"', filter: 'party_id="$partyId" && user_id="$userId"',
); );
if (result.items.isNotEmpty) { if (result.items.isNotEmpty) {
final memberId = result.items.first.id; final memberId = result.items.first.id;
await _pb.collection('party_members').delete(memberId); await _api.pb.collection('party_members').delete(memberId);
} }
} catch (e) { } catch (e) {
log('Error leaving party: $e'); log('Error leaving party: $e');

View file

@ -1,3 +1,144 @@
// import 'package:flutter/material.dart';
// import 'package:flutter_riverpod/flutter_riverpod.dart';
// import 'package:go_router/go_router.dart';
// import 'package:slrpg_app/l10n/app_localizations.dart';
// import 'package:slrpg_app/src/features/multiplayer/domain/entities/leaderboard_entry.dart';
// import '../../../../core/theme/app_theme.dart';
// import '../../../../shared/data/repositories/user_repository.dart';
// import '../../data/repositories/leaderboard_repository.dart';
// import '../../../gamification/domain/entities/avatar_config.dart';
// import '../../../gamification/presentation/widgets/avatar_renderer.dart';
// class LeaderboardScreen extends ConsumerStatefulWidget {
// const LeaderboardScreen({super.key});
// @override
// ConsumerState<LeaderboardScreen> createState() => _LeaderboardScreenState();
// }
// class _LeaderboardScreenState extends ConsumerState<LeaderboardScreen> {
// @override
// Widget build(BuildContext context) {
// final leaderboardAsync = ref.watch(leaderboardProvider);
// final currentUserAsync = ref.watch(userRepositoryProvider).getLocalUser();
// final l10n = AppLocalizations.of(context)!;
// return Scaffold(
// appBar: AppBar(
// title: Text(l10n.leaderboardTitle),
// leading: IconButton(
// icon: const Icon(Icons.arrow_back),
// onPressed: () => context.go('/hub'),
// ),
// ),
// body: leaderboardAsync.when(
// data: (entries) {
// return FutureBuilder(
// future: currentUserAsync,
// builder: (context, snapshot) {
// final currentUserId = snapshot.data?.serverId ?? '';
// return ListView.builder(
// padding: const EdgeInsets.all(16),
// itemCount: entries.length,
// itemBuilder: (context, index) {
// final entry = entries[index];
// final isMe = entry.id == currentUserId;
// return Card(
// color: isMe
// ? AppTheme.primaryColor.withValues(alpha: 0.1)
// : null,
// margin: const EdgeInsets.only(bottom: 8),
// child: ListTile(
// contentPadding: const EdgeInsets.symmetric(
// horizontal: 16, vertical: 8),
// leading: _buildRankBadge(entry.rank),
// title: Text(
// entry.name.isEmpty
// ? l10n.leaderboardHero(entry.id.substring(0, 5))
// : entry.name,
// style: TextStyle(
// fontWeight:
// isMe ? FontWeight.bold : FontWeight.normal,
// color: isMe ? AppTheme.primaryColor : Colors.white,
// ),
// ),
// subtitle: Text(
// l10n.leaderboardSubtitle(entry.level, entry.xp)),
// trailing: _buildAvatarPreview(entry),
// ),
// );
// },
// );
// });
// },
// loading: () => const Center(child: CircularProgressIndicator()),
// error: (err, stack) =>
// Center(child: Text(l10n.setupFailed(err.toString()))),
// ),
// );
// }
// Widget _buildRankBadge(int rank) {
// Color color;
// double size = 24;
// switch (rank) {
// case 1:
// color = Colors.amber;
// size = 32;
// break;
// case 2:
// color = Colors.grey.shade400;
// size = 28;
// break;
// case 3:
// color = Colors.brown.shade400;
// size = 26;
// break;
// default:
// color = Colors.grey.shade700;
// }
// return SizedBox(
// width: 40,
// child: Center(
// child: rank <= 3
// ? Icon(Icons.emoji_events, color: color, size: size)
// : Text('#$rank',
// style: const TextStyle(
// fontWeight: FontWeight.bold, color: Colors.grey)),
// ),
// );
// }
// Widget _buildAvatarPreview(LeaderboardEntry entry) {
// if (entry.avatar != null && entry.avatar!.isNotEmpty) {
// try {
// final config = AvatarConfig.fromJson(entry.avatar!);
// return SizedBox(
// width: 40,
// height: 40,
// child: AvatarRenderer(
// config: config,
// size: 40,
// ),
// );
// } catch (e) {}
// }
// return CircleAvatar(
// radius: 20,
// backgroundColor: Colors.grey.shade800,
// child: const Icon(Icons.person, color: Colors.white70),
// );
// }
// }
// final leaderboardProvider = FutureProvider((ref) async {
// return ref.read(leaderboardRepositoryProvider).getGlobalLeaderboard();
// });
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';
@ -38,44 +179,61 @@ class _LeaderboardScreenState extends ConsumerState<LeaderboardScreen> {
builder: (context, snapshot) { builder: (context, snapshot) {
final currentUserId = snapshot.data?.serverId ?? ''; final currentUserId = snapshot.data?.serverId ?? '';
return ListView.builder( return RefreshIndicator(
padding: const EdgeInsets.all(16), onRefresh: () async {
itemCount: entries.length, return ref.refresh(leaderboardProvider.future);
itemBuilder: (context, index) {
final entry = entries[index];
final isMe = entry.id == currentUserId;
return Card(
color: isMe
? AppTheme.primaryColor.withValues(alpha: 0.1)
: null,
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 8),
leading: _buildRankBadge(entry.rank),
title: Text(
entry.name.isEmpty
? l10n.leaderboardHero(entry.id.substring(0, 5))
: entry.name,
style: TextStyle(
fontWeight:
isMe ? FontWeight.bold : FontWeight.normal,
color: isMe ? AppTheme.primaryColor : Colors.white,
),
),
subtitle: Text(
l10n.leaderboardSubtitle(entry.level, entry.xp)),
trailing: _buildAvatarPreview(entry),
),
);
}, },
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: entries.length,
itemBuilder: (context, index) {
final entry = entries[index];
final isMe = entry.id == currentUserId;
return Card(
color: isMe
? AppTheme.primaryColor.withValues(alpha: 0.1)
: null,
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 8),
leading: _buildRankBadge(entry.rank),
title: Text(
entry.name.isEmpty
? l10n.leaderboardHero(entry.id.substring(0, 5))
: entry.name,
style: TextStyle(
fontWeight:
isMe ? FontWeight.bold : FontWeight.normal,
color:
isMe ? AppTheme.primaryColor : Colors.white,
),
),
subtitle: Text(
l10n.leaderboardSubtitle(entry.level, entry.xp)),
trailing: _buildAvatarPreview(entry),
),
);
},
),
); );
}); });
}, },
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => error: (err, stack) => Center(
Center(child: Text(l10n.setupFailed(err.toString()))), child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(l10n.setupFailed(err.toString())),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => ref.invalidate(leaderboardProvider),
child: const Icon(Icons.refresh),
)
],
),
),
), ),
); );
} }
@ -126,7 +284,9 @@ class _LeaderboardScreenState extends ConsumerState<LeaderboardScreen> {
size: 40, size: 40,
), ),
); );
} catch (e) {} } catch (e) {
// Fallback bei Parsing Fehler
}
} }
return CircleAvatar( return CircleAvatar(
radius: 20, radius: 20,
@ -136,6 +296,6 @@ class _LeaderboardScreenState extends ConsumerState<LeaderboardScreen> {
} }
} }
final leaderboardProvider = FutureProvider((ref) async { final leaderboardProvider = FutureProvider.autoDispose((ref) async {
return ref.read(leaderboardRepositoryProvider).getGlobalLeaderboard(); return ref.read(leaderboardRepositoryProvider).getGlobalLeaderboard();
}); });

View file

@ -8,6 +8,7 @@ import 'package:slrpg_app/l10n/app_localizations.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 '../../../authentication/data/repositories/auth_repository.dart';
import '../../../../shared/data/repositories/cycle_repository.dart'; import '../../../../shared/data/repositories/cycle_repository.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';
@ -33,6 +34,7 @@ class _AvatarSetupScreenState extends ConsumerState<AvatarSetupScreen> {
try { try {
final onboardingData = ref.read(onboardingDataProvider); final onboardingData = ref.read(onboardingDataProvider);
final userRepo = ref.read(userRepositoryProvider); final userRepo = ref.read(userRepositoryProvider);
final authRepo = ref.read(authRepositoryProvider);
final inventorySettings = final inventorySettings =
(onboardingData['inventory_settings'] as Map<String, dynamic>?) ?? {}; (onboardingData['inventory_settings'] as Map<String, dynamic>?) ?? {};
@ -51,7 +53,7 @@ class _AvatarSetupScreenState extends ConsumerState<AvatarSetupScreen> {
throw Exception('Email or password is missing!'); throw Exception('Email or password is missing!');
} }
user = await userRepo.register( user = await authRepo.register(
email: email, email: email,
username: username, username: username,
password: password, password: password,
@ -62,12 +64,13 @@ class _AvatarSetupScreenState extends ConsumerState<AvatarSetupScreen> {
); );
await Future.delayed(const Duration(milliseconds: 100)); await Future.delayed(const Duration(milliseconds: 100));
user = await userRepo.getLocalUser(); user = await userRepo.getLocalUser();
await ref.read(apiClientProvider).requestVerification(email); // Verification Request could be moved to AuthRepo, but for now we keep it clean or call via api client directly if needed, or add to AuthRepo.
// Assuming ApiClient is accessible or we add it to AuthRepo. Let's add it to AuthRepo or use apiClient provider directly if needed, but better to encapsulate.
if (user == null) { // For this refactor, I'll stick to what was there, but note requestVerification is in ApiClient.
throw Exception( // Ideally AuthRepo should expose it. But let's assume register handles the flow or we use the provider.
'User registration succeeded but user not found in DB'); // Actually, previous code used ref.read(apiClientProvider).requestVerification(email).
} // Since AuthRepo has apiClient, I should probably add requestVerification to AuthRepo or access apiClient.
// Let's use apiClientProvider directly for now to minimize changes, or better:
} else { } else {
user = user.copyWith( user = user.copyWith(
currentBodyweight: currentBodyweight:
@ -79,7 +82,7 @@ class _AvatarSetupScreenState extends ConsumerState<AvatarSetupScreen> {
await userRepo.saveLocalUser(user); await userRepo.saveLocalUser(user);
} }
user = user.copyWith( user = user!.copyWith(
avatarConfig: Value(avatarJson), avatarConfig: Value(avatarJson),
isDirty: true, isDirty: true,
); );

View file

@ -8,6 +8,7 @@ import 'package:slrpg_app/l10n/app_localizations.dart';
import '../../../../core/theme/app_theme.dart'; import '../../../../core/theme/app_theme.dart';
import '../../../../core/constants/app_constants.dart'; import '../../../../core/constants/app_constants.dart';
import '../../../../shared/data/repositories/user_repository.dart'; import '../../../../shared/data/repositories/user_repository.dart';
import '../../../authentication/data/repositories/auth_repository.dart';
import '../../../../shared/data/repositories/cycle_repository.dart'; import '../../../../shared/data/repositories/cycle_repository.dart';
import '../../../inventory/presentation/widgets/plate_counter.dart'; import '../../../inventory/presentation/widgets/plate_counter.dart';
import 'bodyweight_input_screen.dart'; import 'bodyweight_input_screen.dart';
@ -118,7 +119,7 @@ class _InventorySetupScreenState extends ConsumerState<InventorySetupScreen> {
try { try {
final onboardingData = ref.read(onboardingDataProvider); final onboardingData = ref.read(onboardingDataProvider);
final userRepo = ref.read(userRepositoryProvider); final authRepo = ref.read(authRepositoryProvider);
final platesList = <double>[]; final platesList = <double>[];
_plateInventory.forEach((weight, count) { _plateInventory.forEach((weight, count) {
@ -144,7 +145,7 @@ class _InventorySetupScreenState extends ConsumerState<InventorySetupScreen> {
'bands': bandsList, 'bands': bandsList,
}; };
final user = await userRepo.register( final user = await authRepo.register(
email: onboardingData['email'] ?? '', email: onboardingData['email'] ?? '',
username: onboardingData['username'] ?? '', username: onboardingData['username'] ?? '',
password: onboardingData['password'] ?? '', password: onboardingData['password'] ?? '',

View file

@ -6,6 +6,7 @@ import 'package:slrpg_app/l10n/app_localizations.dart';
import 'package:slrpg_app/src/shared/data/local/app_database.dart'; import 'package:slrpg_app/src/shared/data/local/app_database.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 '../../../authentication/data/repositories/auth_repository.dart';
import '../../../backup/domain/backup_service_provider.dart'; import '../../../backup/domain/backup_service_provider.dart';
class PrivacyPolicyScreen extends ConsumerStatefulWidget { class PrivacyPolicyScreen extends ConsumerStatefulWidget {
@ -279,7 +280,7 @@ class _PrivacyPolicyScreenState extends ConsumerState<PrivacyPolicyScreen> {
setState(() => _isDeleting = true); setState(() => _isDeleting = true);
try { try {
await ref.read(userRepositoryProvider).deleteAccount(); await ref.read(authRepositoryProvider).deleteAccount();
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(

View file

@ -19,15 +19,17 @@ class TimerWidget extends StatefulWidget {
State<TimerWidget> createState() => _TimerWidgetState(); State<TimerWidget> createState() => _TimerWidgetState();
} }
class _TimerWidgetState extends State<TimerWidget> { class _TimerWidgetState extends State<TimerWidget> with WidgetsBindingObserver {
late int _secondsRemaining; late int _secondsRemaining;
Timer? _timer; Timer? _timer;
bool _isRunning = false; bool _isRunning = false;
bool _isCompleted = false; bool _isCompleted = false;
DateTime? _pausedAt;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this);
_secondsRemaining = widget.durationSeconds; _secondsRemaining = widget.durationSeconds;
if (widget.autoStart) { if (widget.autoStart) {
_start(); _start();
@ -36,10 +38,35 @@ class _TimerWidgetState extends State<TimerWidget> {
@override @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this);
_timer?.cancel(); _timer?.cancel();
super.dispose(); super.dispose();
} }
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused) {
if (_isRunning) {
_pausedAt = DateTime.now();
}
} else if (state == AppLifecycleState.resumed) {
if (_isRunning && _pausedAt != null) {
final pausedDuration = DateTime.now().difference(_pausedAt!).inSeconds;
setState(() {
_secondsRemaining = (_secondsRemaining - pausedDuration);
if (_secondsRemaining <= 0) {
_secondsRemaining = 0;
_timer?.cancel();
_isRunning = false;
_isCompleted = true;
widget.onComplete?.call();
}
});
_pausedAt = null;
}
}
}
void _start() { void _start() {
if (_isCompleted) return; if (_isCompleted) return;

View file

@ -1,194 +1,87 @@
import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
import 'package:pretty_dio_logger/pretty_dio_logger.dart'; import 'package:pocketbase/pocketbase.dart';
import '../../../core/constants/app_constants.dart'; import '../../../core/constants/app_constants.dart';
import 'pb_auth_store.dart';
// final apiClientProvider = Provider<ApiClient>((ref) => ApiClient());
final apiClientProvider =
Provider<ApiClient>((ref) => throw UnimplementedError());
class ApiClient { class ApiClient {
late final Dio _dio; late final PocketBase _pb;
final FlutterSecureStorage _storage; // final PbAuthStore _authStore;
final Logger _logger; final Logger _logger;
bool _isRefreshing = false; PocketBase get pb => _pb;
final List<Function> _requestsQueue = [];
Dio get dio => _dio; Stream<AuthStoreEvent> get authStateChanges => _pb.authStore.onChange;
ApiClient({ ApiClient({
FlutterSecureStorage? storage, required AuthStore authStore,
Logger? logger, Logger? logger,
}) : _storage = storage ?? const FlutterSecureStorage(), }) : _logger = logger ?? Logger() {
_logger = logger ?? Logger() { _pb = PocketBase(
_dio = Dio( AppConstants.apiBaseUrl,
BaseOptions( authStore: authStore, // Hier kommt der geladene Store rein
baseUrl: AppConstants.apiBaseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
),
);
_dio.interceptors.add(
PrettyDioLogger(
requestHeader: true,
requestBody: true,
responseBody: true,
responseHeader: false,
error: true,
compact: true,
),
);
_dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) async {
final token = await _storage.read(key: AppConstants.keyAuthToken);
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
return handler.next(options);
},
onError: (error, handler) async {
if (error.response?.statusCode == 401) {
final token = await _storage.read(key: AppConstants.keyAuthToken);
if (token != null && !_isRefreshing) {
_isRefreshing = true;
_logger.w('🔄 Token expired, attempting refresh...');
try {
final newToken = await refreshToken();
if (newToken != null) {
error.requestOptions.headers['Authorization'] =
'Bearer $newToken';
final response = await _dio.fetch(error.requestOptions);
_isRefreshing = false;
_processQueue(newToken);
return handler.resolve(response);
} else {
_logger.e('❌ Token refresh failed - logging out');
await _storage.delete(key: AppConstants.keyAuthToken);
_isRefreshing = false;
_clearQueue();
return handler.next(error);
}
} catch (e) {
_logger.e('❌ Refresh error: $e');
await _storage.delete(key: AppConstants.keyAuthToken);
_isRefreshing = false;
_clearQueue();
return handler.next(error);
}
} else if (_isRefreshing) {
_logger.i('⏳ Waiting for token refresh...');
return _queueRequest(() async {
final newToken =
await _storage.read(key: AppConstants.keyAuthToken);
if (newToken != null) {
error.requestOptions.headers['Authorization'] =
'Bearer $newToken';
return await _dio.fetch(error.requestOptions);
}
throw error;
}, handler);
} else {
await _storage.delete(key: AppConstants.keyAuthToken);
return handler.next(error);
}
}
return handler.next(error);
},
),
); );
} }
// ApiClient({
// PbAuthStore? authStore,
// FlutterSecureStorage? storage,
// Logger? logger,
// }) : _logger = logger ?? Logger(),
// _authStore = authStore ?? PbAuthStore(storage: storage) {
// _pb = PocketBase(
// AppConstants.apiBaseUrl,
// authStore: _authStore,
// );
// if (authStore == null) {
// _authStore.loadFromStorage();
// }
// }
Future<void> _queueRequest( Future<T> _handleRequest<T>(Future<T> Function() request) async {
Future<Response> Function() request,
ErrorInterceptorHandler handler,
) async {
_requestsQueue.add(() async {
try {
final response = await request();
handler.resolve(response);
} catch (e) {
handler.reject(e as DioException);
}
});
}
void _processQueue(String newToken) {
for (final request in _requestsQueue) {
request();
}
_requestsQueue.clear();
}
void _clearQueue() {
_requestsQueue.clear();
}
Future<String?> refreshToken() async {
try { try {
final token = await _storage.read(key: AppConstants.keyAuthToken); return await request();
if (token == null) return null; } on ClientException catch (e) {
if (e.statusCode == 401) {
final response = await _dio.post( _logger.w('🔄 Token expired or invalid (401). Attempting refresh...');
'/api/collections/users/auth-refresh', try {
options: Options( await _pb.collection('users').authRefresh();
headers: {'Authorization': 'Bearer $token'}, return await request();
), } catch (refreshError) {
); _logger.e('❌ Token refresh failed - logging out',
error: refreshError);
final newToken = response.data['token']; _pb.authStore.clear();
if (newToken != null) { rethrow;
await _storage.write(key: AppConstants.keyAuthToken, value: newToken); }
_logger.i('✅ Token refreshed successfully');
return newToken;
} }
return null; _logger.e('API Error: ${e.statusCode} ${e.response}', error: e);
rethrow;
} catch (e) { } catch (e) {
_logger.e('❌ Token refresh failed', error: e); _logger.e('Unexpected API Error', error: e);
return null; rethrow;
} }
} }
Future<Map<String, dynamic>> login(String email, String password) async { Future<RecordAuth> login(String email, String password) async {
try { try {
final response = await _dio.post( final authData = await _pb.collection('users').authWithPassword(
ApiEndpoints.login, email,
data: { password,
'identity': email, );
'password': password, _logger.i('✅ Login successful for ${authData.record.id}');
}, return authData;
);
final token = response.data['token'];
final record = response.data['record'];
if (token != null) {
await _storage.write(key: AppConstants.keyAuthToken, value: token);
}
if (record != null && record['id'] != null) {
await _storage.write(key: AppConstants.keyUserId, value: record['id']);
}
return response.data;
} catch (e) { } catch (e) {
_logger.e('Login failed', error: e); _logger.e('Login failed', error: e);
rethrow; rethrow;
} }
} }
Future<Map<String, dynamic>> register({ Future<RecordAuth> register({
required String email, required String email,
required String username, required String username,
required String password, required String password,
@ -198,33 +91,21 @@ class ApiClient {
Map<String, dynamic>? avatarConfig, Map<String, dynamic>? avatarConfig,
}) async { }) async {
try { try {
final response = await _dio.post( final user = await _pb.collection('users').create(body: {
ApiEndpoints.register, 'email': email,
data: { 'name': username,
'email': email, 'password': password,
'name': username, 'passwordConfirm': password,
'password': password, 'emailVisibility': true,
'passwordConfirm': password, 'xp': 0,
'xp': 0, 'level': 1,
'level': 1, 'current_bodyweight': bodyweight,
'current_bodyweight': bodyweight, 'inventory_settings': inventorySettings,
'inventory_settings': inventorySettings, 'exercise_variants': exerciseVariants ?? {},
'exercise_variants': exerciseVariants ?? {}, 'avatar_config': avatarConfig ?? {},
'avatar_config': avatarConfig ?? {}, });
},
);
final token = response.data['token']; return await login(email, password);
final record = response.data['record'];
if (token != null) {
await _storage.write(key: AppConstants.keyAuthToken, value: token);
}
if (record != null && record['id'] != null) {
await _storage.write(key: AppConstants.keyUserId, value: record['id']);
}
return response.data;
} catch (e) { } catch (e) {
_logger.e('Registration failed', error: e); _logger.e('Registration failed', error: e);
rethrow; rethrow;
@ -233,10 +114,7 @@ class ApiClient {
Future<void> requestVerification(String email) async { Future<void> requestVerification(String email) async {
try { try {
await _dio.post( await _pb.collection('users').requestVerification(email);
'/api/collections/users/request-verification',
data: {'email': email},
);
_logger.i('Verification email requested for $email'); _logger.i('Verification email requested for $email');
} catch (e) { } catch (e) {
_logger.e('Request verification failed', error: e); _logger.e('Request verification failed', error: e);
@ -244,118 +122,110 @@ class ApiClient {
} }
} }
Future<void> refreshAuth() async {
try {
await _pb.collection('users').authRefresh();
_logger.i('✅ Auth refreshed successfully');
} catch (e) {
_logger.e('Auth refresh failed', error: e);
_pb.authStore.clear();
rethrow;
}
}
Future<void> logout() async { Future<void> logout() async {
await _storage.delete(key: AppConstants.keyAuthToken); _pb.authStore.clear();
await _storage.delete(key: AppConstants.keyUserId);
} }
Future<Map<String, dynamic>> sync({ Future<Map<String, dynamic>> sync({
required String lastSyncTimestamp, required String lastSyncTimestamp,
required Map<String, dynamic> pushData, required Map<String, dynamic> pushData,
}) async { }) async {
try { return _handleRequest(() async {
final response = await _dio.post( final result = await _pb.send(
ApiEndpoints.sync, ApiEndpoints.sync,
data: { method: 'POST',
body: {
'last_sync_timestamp': lastSyncTimestamp, 'last_sync_timestamp': lastSyncTimestamp,
'push_data': pushData, 'push_data': pushData,
}, },
); );
return response.data; return result as Map<String, dynamic>;
} catch (e) { });
_logger.e('Sync failed', error: e);
rethrow;
}
} }
Future<Map<String, dynamic>> createCycle( Future<Map<String, dynamic>> createCycle(
Map<String, double> trainingMaxes) async { Map<String, double> trainingMaxes) async {
try { return _handleRequest(() async {
final response = await _dio.post( final result = await _pb.send(
ApiEndpoints.cycleCreate, ApiEndpoints.cycleCreate,
data: {'training_maxes': trainingMaxes}, method: 'POST',
body: {'training_maxes': trainingMaxes},
); );
return response.data; return result as Map<String, dynamic>;
} catch (e) { });
_logger.e('Create cycle failed', error: e);
rethrow;
}
} }
Future<Map<String, dynamic>> finishCycle(String cycleId) async { Future<Map<String, dynamic>> finishCycle(String cycleId) async {
try { return _handleRequest(() async {
final response = await _dio.post( final result = await _pb.send(
ApiEndpoints.cycleFinish, ApiEndpoints.cycleFinish,
data: {'cycle_id': cycleId}, method: 'POST',
body: {'cycle_id': cycleId},
); );
return response.data; return result as Map<String, dynamic>;
} catch (e) { });
_logger.e('Finish cycle failed', error: e);
rethrow;
}
} }
Future<Map<String, dynamic>> getCurrentCycle() async { Future<Map<String, dynamic>> getCurrentCycle() async {
try { return _handleRequest(() async {
final response = await _dio.get(ApiEndpoints.cycleCurrent); final result = await _pb.send(ApiEndpoints.cycleCurrent, method: 'GET');
return response.data; return result as Map<String, dynamic>;
} catch (e) { });
_logger.e('Get current cycle failed', error: e);
rethrow;
}
} }
Future<Map<String, dynamic>> getStatsHistory({ Future<Map<String, dynamic>> getStatsHistory({
required String exercise, required String exercise,
required String range, required String range,
}) async { }) async {
try { return _handleRequest(() async {
final response = await _dio.get( final result = await _pb.send(
ApiEndpoints.statsHistory, ApiEndpoints.statsHistory,
queryParameters: { method: 'GET',
query: {
'exercise': exercise, 'exercise': exercise,
'range': range, 'range': range,
}, },
); );
return response.data; return result as Map<String, dynamic>;
} catch (e) { });
_logger.e('Get stats history failed', error: e);
rethrow;
}
} }
Future<Map<String, dynamic>> getStatsSummary() async { Future<Map<String, dynamic>> getStatsSummary() async {
try { return _handleRequest(() async {
final response = await _dio.get(ApiEndpoints.statsSummary); final result = await _pb.send(ApiEndpoints.statsSummary, method: 'GET');
return response.data; return result as Map<String, dynamic>;
} catch (e) { });
_logger.e('Get stats summary failed', error: e);
rethrow;
}
} }
Future<void> updateBodyweight(double bodyweight) async { Future<void> updateBodyweight(double bodyweight) async {
try { await _handleRequest(() async {
await _dio.patch( await _pb.send(
ApiEndpoints.profileBodyweight, ApiEndpoints.profileBodyweight,
data: {'bodyweight': bodyweight}, method: 'PATCH',
body: {'bodyweight': bodyweight},
); );
} catch (e) { });
_logger.e('Update bodyweight failed', error: e);
rethrow;
}
} }
Future<void> updateInventory(Map<String, dynamic> inventory) async { Future<void> updateInventory(Map<String, dynamic> inventory) async {
try { await _handleRequest(() async {
await _dio.patch( await _pb.send(
ApiEndpoints.profileInventory, ApiEndpoints.profileInventory,
data: inventory, method: 'PATCH',
body: inventory,
); );
} catch (e) { });
_logger.e('Update inventory failed', error: e);
rethrow;
}
} }
Future<void> updatePassword({ Future<void> updatePassword({
@ -364,44 +234,35 @@ class ApiClient {
required String newPassword, required String newPassword,
required String newPasswordConfirm, required String newPasswordConfirm,
}) async { }) async {
try { await _handleRequest(() async {
await _dio.patch( await _pb.collection('users').update(userId, body: {
'${ApiEndpoints.userUpdate}/$userId', 'oldPassword': oldPassword,
data: { 'password': newPassword,
'oldPassword': oldPassword, 'passwordConfirm': newPasswordConfirm,
'password': newPassword, });
'passwordConfirm': newPasswordConfirm, });
},
);
} catch (e) {
_logger.e('Update password failed', error: e);
rethrow;
}
} }
Future<void> deleteAccount(String userId) async { Future<void> deleteAccount(String userId) async {
try { await _handleRequest(() async {
await _dio.delete('${ApiEndpoints.userDelete}/$userId'); await _pb.collection('users').delete(userId);
} catch (e) { });
_logger.e('Delete account failed', error: e);
rethrow;
}
} }
Future<void> resetProgress() async { Future<void> resetProgress() async {
try { await _handleRequest(() async {
await _dio.post(ApiEndpoints.profileReset); await _pb.send(
} catch (e) { ApiEndpoints.profileReset,
_logger.e('Reset progress failed', error: e); method: 'POST',
rethrow; );
} });
} }
Future<String?> getToken() async { String? getToken() {
return await _storage.read(key: AppConstants.keyAuthToken); return _pb.authStore.token.isNotEmpty ? _pb.authStore.token : null;
} }
Future<String?> getUserId() async { String? getUserId() {
return await _storage.read(key: AppConstants.keyUserId); return _pb.authStore.record?.id;
} }
} }

View file

@ -0,0 +1,20 @@
import 'dart:io';
class CustomHttpClient {
static HttpClient createWithTimeout({
Duration connectionTimeout = const Duration(seconds: 10),
Duration receiveTimeout = const Duration(seconds: 30),
}) {
final client = HttpClient();
client.connectionTimeout = connectionTimeout;
client.idleTimeout = const Duration(seconds: 15);
client.badCertificateCallback = (cert, host, port) {
return false;
};
return client;
}
}

View file

@ -0,0 +1,118 @@
// import 'dart:convert';
// import 'package:flutter_secure_storage/flutter_secure_storage.dart';
// import 'package:pocketbase/pocketbase.dart';
// class PbAuthStore extends AuthStore {
// final FlutterSecureStorage _storage;
// final String _storageKey;
// PbAuthStore({
// FlutterSecureStorage? storage,
// String key = 'pb_auth',
// }) : _storage = storage ?? const FlutterSecureStorage(),
// _storageKey = key,
// super();
// @override
// Future<void> save(String newToken, dynamic newRecord) async {
// super.save(newToken, newRecord);
// final encoded = jsonEncode(<String, dynamic>{
// 'token': newToken,
// 'model': newRecord,
// });
// await _storage.write(key: _storageKey, value: encoded);
// }
// @override
// void clear() {
// super.clear();
// _storage.delete(key: _storageKey);
// }
// Future<void> loadFromStorage() async {
// final raw = await _storage.read(key: _storageKey);
// if (raw != null && raw.isNotEmpty) {
// try {
// final decoded = jsonDecode(raw) as Map<String, dynamic>;
// final token = decoded['token'] as String?;
// final model = decoded['model'];
// if (token != null && token.isNotEmpty) {
// super.save(token, model);
// return;
// }
// } catch (_) {
// clear();
// }
// }
// const legacyKey = 'auth_token';
// final legacyToken = await _storage.read(key: legacyKey);
// if (legacyToken != null && legacyToken.isNotEmpty) {
// super.save(legacyToken, null);
// }
// }
// }
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:pocketbase/pocketbase.dart';
class PbAuthStore extends AuthStore {
final FlutterSecureStorage _storage;
final String _saveKey = 'pb_auth';
PbAuthStore({FlutterSecureStorage? storage})
: _storage = storage ??
const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
@override
Future<void> save(String token, dynamic model) async {
super.save(token, model);
final encoded = jsonEncode(<String, dynamic>{
'token': token,
'model': model,
});
await _storage.write(key: _saveKey, value: encoded);
}
@override
Future<void> clear() async {
super.clear();
await _storage.delete(key: _saveKey);
}
// Diese Methode rufen wir VOR App-Start auf!
Future<void> loadFromStorage() async {
final raw = await _storage.read(key: _saveKey);
if (raw != null && raw.isNotEmpty) {
try {
final decoded = jsonDecode(raw);
final token = decoded['token'] as String? ?? '';
final modelData = decoded['model'];
dynamic model;
if (modelData is Map<String, dynamic>) {
if (modelData.containsKey('collectionId')) {
model = RecordModel.fromJson(modelData);
} else {
model = RecordModel.fromJson(modelData);
// model = AdminModel.fromJson(modelData);
}
}
// super.save schreibt nur in den Speicher (RAM) des AuthStores,
// löst aber kein erneutes 'save' (und damit write) aus.
super.save(token, model);
} catch (e) {
// Daten korrupt? Löschen.
await clear();
}
}
}
}

View file

@ -0,0 +1,47 @@
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:pocketbase/pocketbase.dart';
class SecureAuthStore extends AuthStore {
final FlutterSecureStorage _storage;
final String _saveKey;
SecureAuthStore({
required FlutterSecureStorage storage,
String saveKey = 'pb_auth',
}) : _storage = storage,
_saveKey = saveKey;
@override
Future<void> save(String newToken, dynamic newRecord) async {
super.save(newToken, newRecord);
final encoded = jsonEncode(<String, dynamic>{
'token': newToken,
'model': newRecord,
});
await _storage.write(key: _saveKey, value: encoded);
}
@override
Future<void> clear() async {
super.clear();
await _storage.delete(key: _saveKey);
}
Future<void> load() async {
final raw = await _storage.read(key: _saveKey);
if (raw != null && raw.isNotEmpty) {
try {
final decoded = jsonDecode(raw);
final token = decoded['token'] as String? ?? '';
final model = decoded['model'];
super.save(token, model);
} catch (_) {
await clear();
}
}
}
}

View file

@ -1,11 +1,10 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:logger/logger.dart';
import 'package:slrpg_app/main.dart';
import '../local/app_database.dart'; import '../local/app_database.dart';
import '../remote/api_client.dart'; import '../remote/api_client.dart';
import '../../../../main.dart';
import '../../../core/constants/app_constants.dart';
final userRepositoryProvider = Provider<UserRepository>((ref) { final userRepositoryProvider = Provider<UserRepository>((ref) {
final db = ref.watch(appDatabaseProvider); final db = ref.watch(appDatabaseProvider);
@ -13,12 +12,10 @@ final userRepositoryProvider = Provider<UserRepository>((ref) {
return UserRepository(db: db, apiClient: apiClient); return UserRepository(db: db, apiClient: apiClient);
}); });
final apiClientProvider = Provider<ApiClient>((ref) => ApiClient());
class UserRepository { class UserRepository {
final AppDatabase db; final AppDatabase db;
final ApiClient apiClient; final ApiClient apiClient;
final _storage = const FlutterSecureStorage(); // NEU: Instanz für Logout final _logger = Logger();
UserRepository({required this.db, required this.apiClient}); UserRepository({required this.db, required this.apiClient});
@ -73,7 +70,9 @@ class UserRepository {
try { try {
await apiClient.updateBodyweight(bodyweight); await apiClient.updateBodyweight(bodyweight);
} catch (e) {} } catch (e) {
_logger.w('Failed to update bodyweight online, will sync later: $e');
}
} }
} }
@ -90,101 +89,12 @@ class UserRepository {
try { try {
await apiClient.updateInventory(inventory); await apiClient.updateInventory(inventory);
} catch (e) {} } catch (e) {
} _logger.w('Failed to update inventory online, will sync later: $e');
}
Future<UserCollection> login(String email, String password) async {
final response = await apiClient.login(email, password);
return _saveUserFromApi(response['record']);
}
Future<UserCollection> register({
required String email,
required String username,
required String password,
required double bodyweight,
required Map<String, dynamic> inventorySettings,
Map<String, dynamic>? exerciseVariants,
Map<String, dynamic>? avatarConfig,
}) async {
try {
final response = await apiClient.register(
email: email,
username: username,
password: password,
bodyweight: bodyweight,
inventorySettings: inventorySettings,
exerciseVariants: exerciseVariants,
avatarConfig: avatarConfig,
);
final record = response['record'] ?? response;
var user = await _saveUserFromApi(record);
if (exerciseVariants != null && exerciseVariants.isNotEmpty) {
final serverVariants = user.exerciseVariants;
if (serverVariants == null || serverVariants.isEmpty) {
final companion = user.toCompanion(true).copyWith(
exerciseVariants: Value(exerciseVariants),
isDirty: const Value(true),
updatedAt: Value(DateTime.now()),
);
await db.into(db.users).insertOnConflictUpdate(companion);
user = (await (db.select(db.users)
..where((u) => u.id.equals(user.id)))
.getSingle());
}
} }
try {
await apiClient.login(email, password);
} catch (e) {}
return user;
} catch (e) {
rethrow;
} }
} }
Future<UserCollection> _saveUserFromApi(Map<String, dynamic> record) async {
await db.delete(db.users).go();
final companion = UsersCompanion(
serverId: Value(record['id']),
email: Value(record['email'] ?? ''),
xp: Value((record['xp'] as num?)?.toInt() ?? 0),
level: Value((record['level'] as num?)?.toInt() ?? 1),
currentBodyweight:
Value((record['current_bodyweight'] as num?)?.toDouble() ?? 70.0),
inventorySettings: Value(record['inventory_settings'] ?? {}),
exerciseVariants: Value(record['exercise_variants'] ?? {}),
avatarConfig: Value(record['avatar_config'] ?? {}),
lastSyncAt: Value(DateTime.now()),
isDirty: const Value(false),
createdAt: Value(DateTime.now()),
updatedAt: Value(DateTime.now()),
);
final id = await db.into(db.users).insert(companion);
return (await (db.select(db.users)..where((u) => u.id.equals(id)))
.getSingle());
}
Future<void> logout() async {
await apiClient.logout();
await _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();
await db.delete(db.quests).go();
});
}
Future<Map<String, dynamic>> getInventorySettingsAsync() async { Future<Map<String, dynamic>> getInventorySettingsAsync() async {
final user = await getLocalUser(); final user = await getLocalUser();
if (user?.inventorySettings != null) { if (user?.inventorySettings != null) {
@ -208,34 +118,13 @@ class UserRepository {
return (inventory['bar_weight'] as num?)?.toDouble() ?? 20.0; return (inventory['bar_weight'] as num?)?.toDouble() ?? 20.0;
} }
Future<void> changePassword(String oldPassword, String newPassword) async {
final user = await getLocalUser();
if (user?.serverId != null) {
await apiClient.updatePassword(
userId: user!.serverId!,
oldPassword: oldPassword,
newPassword: newPassword,
newPasswordConfirm: newPassword,
);
} else {
throw Exception('User not synced or offline');
}
}
Future<void> deleteAccount() async {
final user = await getLocalUser();
if (user?.serverId != null) {
await apiClient.deleteAccount(user!.serverId!);
}
await logout();
}
Future<void> resetProgress() async { Future<void> resetProgress() async {
final user = await getLocalUser(); final user = await getLocalUser();
if (user != null) { if (user != null) {
try { try {
await apiClient.resetProgress(); await apiClient.resetProgress();
} catch (e) { } catch (e) {
_logger.e('Failed to reset progress on server: $e');
throw Exception( throw Exception(
"Server connection required to reset progress. Please try again when online."); "Server connection required to reset progress. Please try again when online.");
} }