diff --git a/lib/main.dart b/lib/main.dart index f4e3f04..1c58ead 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,6 +5,8 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'src/app.dart'; import 'src/shared/data/local/app_database.dart'; +import 'src/shared/data/remote/api_client.dart'; +import 'src/shared/data/remote/pb_auth_store.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; void main() async { @@ -26,9 +28,25 @@ void main() async { final database = AppDatabase(); + final authStore = PbAuthStore(); + await authStore.loadFromStorage(); + + if (authStore.isValid && authStore.record == null) { + final tempClient = ApiClient(authStore: authStore); + try { + await tempClient.refreshAuth(); + } catch (e) { + log('Initial auth refresh failed: $e'); + } + } + runApp( ProviderScope( - overrides: [appDatabaseProvider.overrideWithValue(database)], + overrides: [ + appDatabaseProvider.overrideWithValue(database), + apiClientProvider + .overrideWith((ref) => ApiClient(authStore: authStore)), + ], child: const SLRPGApp(), ), ); diff --git a/lib/src/core/routing/app_router.dart b/lib/src/core/routing/app_router.dart index b415ddf..c2ec64b 100644 --- a/lib/src/core/routing/app_router.dart +++ b/lib/src/core/routing/app_router.dart @@ -1,6 +1,8 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.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/lobby_screen.dart'; import 'package:slrpg_app/src/features/settings/presentation/screens/privacy_policy_screen.dart'; @@ -25,9 +27,11 @@ import '../../features/gamification/presentation/screens/codex_screen.dart'; final routerProvider = Provider((ref) { final userRepo = ref.watch(userRepositoryProvider); + final authRepo = ref.watch(authRepositoryProvider); return GoRouter( initialLocation: '/splash', + refreshListenable: _StreamToLegacyListenable(authRepo.authStateChanges), redirect: (context, state) async { final user = await userRepo.getLocalUser(); final isAuthenticated = user != null; @@ -172,6 +176,20 @@ final routerProvider = Provider((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 { const SplashScreen({super.key}); diff --git a/lib/src/features/authentication/data/repositories/auth_repository.dart b/lib/src/features/authentication/data/repositories/auth_repository.dart index 84d84fa..0840ddc 100644 --- a/lib/src/features/authentication/data/repositories/auth_repository.dart +++ b/lib/src/features/authentication/data/repositories/auth_repository.dart @@ -1,17 +1,25 @@ 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 '../../../../shared/data/repositories/user_repository.dart'; import '../../../../core/constants/app_constants.dart'; final authRepositoryProvider = Provider((ref) { final db = ref.watch(appDatabaseProvider); final apiClient = ref.watch(apiClientProvider); - return AuthRepository(db: db, apiClient: apiClient); + final repo = AuthRepository(db: db, apiClient: apiClient); + + apiClient.authStateChanges.listen((event) { + if (event.token.isEmpty) { + repo.logout(); + } + }); + + return repo; }); class AuthRepository { @@ -19,11 +27,18 @@ class AuthRepository { final ApiClient apiClient; final _storage = const FlutterSecureStorage(); + Stream get authStateChanges => apiClient.authStateChanges; + AuthRepository({required this.db, required this.apiClient}); Future login(String email, String password) async { final response = await apiClient.login(email, password); - return _saveUserFromApi(response['record']); + if (response.record == null) { + throw ClientException( + statusCode: 400, + response: {'message': 'Login failed: No user record returned'}); + } + return _saveUserFromApi(response.record!.toJson()); } Future register({ @@ -46,8 +61,14 @@ class AuthRepository { avatarConfig: avatarConfig, ); - final record = response['record'] ?? response; - var user = await _saveUserFromApi(record); + 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; @@ -65,13 +86,6 @@ class AuthRepository { } } - // Auto-Login after register usually handled by token return, but checking consistency - try { - await apiClient.login(email, password); - } catch (e) { - // Token might already be set by register - } - return user; } catch (e) { rethrow; @@ -101,7 +115,10 @@ class AuthRepository { } Future logout() async { - await apiClient.logout(); + if (apiClient.getToken() != null) { + await apiClient.logout(); + } + await _storage.delete(key: AppConstants.keyLastSync); await db.transaction(() async { diff --git a/lib/src/features/multiplayer/data/repositories/leaderboard_repository.dart b/lib/src/features/multiplayer/data/repositories/leaderboard_repository.dart index f77a954..71ca542 100644 --- a/lib/src/features/multiplayer/data/repositories/leaderboard_repository.dart +++ b/lib/src/features/multiplayer/data/repositories/leaderboard_repository.dart @@ -1,6 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.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'; final leaderboardRepositoryProvider = Provider((ref) { @@ -14,19 +13,15 @@ class LeaderboardRepository { Future> getGlobalLeaderboard() async { try { - final response = await _api.dio.get( - '/api/collections/leaderboard/records', - queryParameters: { - 'sort': '-level,-xp', - 'perPage': 50, - }, + final records = await _api.pb.collection('leaderboard').getList( + sort: '-level,-xp', + perPage: 50, ); - final items = (response.data['items'] as List); - - return items.asMap().entries.map((entry) { + return records.items.asMap().entries.map((entry) { final index = entry.key; - final data = entry.value as Map; + final record = entry.value; + final data = record.toJson(); if (data['name'] == null || data['name'].toString().isEmpty) { data['name'] = 'Unknown Hero'; @@ -38,4 +33,4 @@ class LeaderboardRepository { throw Exception('Failed to load leaderboard: $e'); } } -} +} \ No newline at end of file diff --git a/lib/src/features/multiplayer/data/repositories/party_repository.dart b/lib/src/features/multiplayer/data/repositories/party_repository.dart index fb99bf1..830af6c 100644 --- a/lib/src/features/multiplayer/data/repositories/party_repository.dart +++ b/lib/src/features/multiplayer/data/repositories/party_repository.dart @@ -5,7 +5,6 @@ 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_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'; final partyRepositoryProvider = Provider((ref) { @@ -14,47 +13,39 @@ final partyRepositoryProvider = Provider((ref) { class PartyRepository { final ApiClient _api; - final PocketBase _pb; - PartyRepository(this._api) : _pb = PocketBase(AppConstants.apiBaseUrl); - - Future _syncAuth() async { - final token = await _api.getToken(); - if (token != null) { - _pb.authStore.save(token, null); - } - } + PartyRepository(this._api); Future createParty() async { - final response = await _api.dio.post('/api/v1/party/create'); - final partyId = response.data['party_id']; + final response = await _api.pb.send('/api/v1/party/create', method: 'POST'); + final partyId = response['party_id']; return getPartyDetails(partyId); } Future joinParty(String code) async { - final response = await _api.dio.post( + final response = await _api.pb.send( '/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); } Future getPartyDetails(String partyId) async { - final response = - await _api.dio.get('/api/collections/parties/records/$partyId'); - return Party.fromJson(response.data); + final record = await _api.pb.collection('parties').getOne(partyId); + return Party.fromJson(record.toJson()); } Future 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"', ); if (members.items.isNotEmpty) { - await _pb.collection('party_members').update( + await _api.pb.collection('party_members').update( members.items.first.id, body: {'is_ready': isReady}, ); @@ -69,18 +60,16 @@ class PartyRepository { body['max_hp'] = customHp; } - await _pb.collection('parties').update(partyId, body: body); + await _api.pb.collection('parties').update(partyId, body: body); } Stream subscribeToParty(String partyId) async* { - await _syncAuth(); - yield await getPartyDetails(partyId); final controller = StreamController(); - _pb.collection('parties').subscribe(partyId, (e) { - if (e.action == 'update') { + _api.pb.collection('parties').subscribe(partyId, (e) { + if (e.action == 'update' && e.record != null) { controller.add(Party.fromJson(e.record!.toJson())); } }); @@ -89,10 +78,8 @@ class PartyRepository { } Stream> subscribeToMembers(String partyId) async* { - await _syncAuth(); - Future> fetchMembers() async { - final records = await _pb.collection('party_members').getFullList( + final records = await _api.pb.collection('party_members').getFullList( filter: 'party_id="$partyId"', expand: 'user_id', ); @@ -103,8 +90,8 @@ class PartyRepository { final controller = StreamController>(); - _pb.collection('party_members').subscribe('*', (e) async { - if (e.record!.getStringValue('party_id') == partyId) { + _api.pb.collection('party_members').subscribe('*', (e) async { + if (e.record != null && e.record!.getStringValue('party_id') == partyId) { controller.add(await fetchMembers()); } }); @@ -113,9 +100,10 @@ class PartyRepository { } Future dealDamage(String partyId, int damage) async { - await _api.dio.post( + await _api.pb.send( '/api/v1/party/damage', - data: { + method: 'POST', + body: { 'party_id': partyId, 'damage': damage, }, @@ -124,13 +112,13 @@ class PartyRepository { Future leaveParty(String partyId, String userId) async { 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"', ); if (result.items.isNotEmpty) { final memberId = result.items.first.id; - await _pb.collection('party_members').delete(memberId); + await _api.pb.collection('party_members').delete(memberId); } } catch (e) { log('Error leaving party: $e'); diff --git a/lib/src/features/multiplayer/presentation/screens/leaderboard_screen.dart b/lib/src/features/multiplayer/presentation/screens/leaderboard_screen.dart index 0e0b5e9..5dfda17 100644 --- a/lib/src/features/multiplayer/presentation/screens/leaderboard_screen.dart +++ b/lib/src/features/multiplayer/presentation/screens/leaderboard_screen.dart @@ -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 createState() => _LeaderboardScreenState(); +// } + +// class _LeaderboardScreenState extends ConsumerState { +// @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_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -38,44 +179,63 @@ class _LeaderboardScreenState extends ConsumerState { 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), - ), - ); + // NEU: RefreshIndicator für Pull-to-Refresh + return RefreshIndicator( + onRefresh: () async { + // Erzwingt ein Neuladen der Daten + return ref.refresh(leaderboardProvider.future); }, + 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()), - error: (err, stack) => - Center(child: Text(l10n.setupFailed(err.toString()))), + error: (err, stack) => Center( + 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), + ) + ], + ), + ), ), ); } @@ -116,6 +276,8 @@ class _LeaderboardScreenState extends ConsumerState { Widget _buildAvatarPreview(LeaderboardEntry entry) { if (entry.avatar != null && entry.avatar!.isNotEmpty) { try { + // Hier prüfen, ob es ein JSON String oder eine Map ist, falls nötig. + // Da wir im Repository .toJson() aufrufen, ist es hier sicher eine Map. final config = AvatarConfig.fromJson(entry.avatar!); return SizedBox( @@ -126,7 +288,9 @@ class _LeaderboardScreenState extends ConsumerState { size: 40, ), ); - } catch (e) {} + } catch (e) { + // Fallback bei Parsing Fehler + } } return CircleAvatar( radius: 20, @@ -136,6 +300,8 @@ class _LeaderboardScreenState extends ConsumerState { } } -final leaderboardProvider = FutureProvider((ref) async { +// WICHTIG: .autoDispose sorgt dafür, dass die Daten neu geladen werden, +// wenn der Screen verlassen und wieder betreten wird. +final leaderboardProvider = FutureProvider.autoDispose((ref) async { return ref.read(leaderboardRepositoryProvider).getGlobalLeaderboard(); }); diff --git a/lib/src/shared/data/remote/api_client.dart b/lib/src/shared/data/remote/api_client.dart index a501755..1d94790 100644 --- a/lib/src/shared/data/remote/api_client.dart +++ b/lib/src/shared/data/remote/api_client.dart @@ -1,200 +1,76 @@ -import 'package:dio/dio.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.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 'pb_auth_store.dart'; final apiClientProvider = Provider((ref) => ApiClient()); class ApiClient { - late final Dio _dio; - final FlutterSecureStorage _storage; + late final PocketBase _pb; + final PbAuthStore _authStore; final Logger _logger; - bool _isRefreshing = false; - final List _requestsQueue = []; + PocketBase get pb => _pb; - Dio get dio => _dio; + Stream get authStateChanges => _pb.authStore.onChange; ApiClient({ + PbAuthStore? authStore, FlutterSecureStorage? storage, Logger? logger, - }) : _storage = storage ?? const FlutterSecureStorage(), - _logger = logger ?? Logger() { - _dio = Dio( - BaseOptions( - baseUrl: AppConstants.apiBaseUrl, - connectTimeout: const Duration(seconds: 10), - receiveTimeout: const Duration(seconds: 10), - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - ), + }) : _logger = logger ?? Logger(), + _authStore = authStore ?? PbAuthStore(storage: storage) { + _pb = PocketBase( + AppConstants.apiBaseUrl, + authStore: _authStore, ); - - if (kDebugMode) { - _dio.interceptors.add( - PrettyDioLogger( - requestHeader: true, - requestBody: true, - responseBody: true, - responseHeader: false, - error: true, - compact: true, - ), - ); + if (authStore == null) { + _authStore.loadFromStorage(); } - - _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); - }, - ), - ); } - Future _queueRequest( - Future 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 refreshToken() async { + Future _handleRequest(Future Function() request) async { try { - final token = await _storage.read(key: AppConstants.keyAuthToken); - if (token == null) return null; - - final response = await _dio.post( - '/api/collections/users/auth-refresh', - options: Options( - headers: {'Authorization': 'Bearer $token'}, - ), - ); - - final newToken = response.data['token']; - if (newToken != null) { - await _storage.write(key: AppConstants.keyAuthToken, value: newToken); - _logger.i('✅ Token refreshed successfully'); - return newToken; + return await request(); + } on ClientException catch (e) { + if (e.statusCode == 401) { + _logger.w('🔄 Token expired or invalid (401). Attempting refresh...'); + try { + await _pb.collection('users').authRefresh(); + return await request(); + } catch (refreshError) { + _logger.e('❌ Token refresh failed - logging out', + error: refreshError); + _pb.authStore.clear(); + rethrow; + } } - return null; + _logger.e('API Error: ${e.statusCode} ${e.response}', error: e); + rethrow; } catch (e) { - _logger.e('❌ Token refresh failed', error: e); - return null; + _logger.e('Unexpected API Error', error: e); + rethrow; } } - Future> login(String email, String password) async { + Future login(String email, String password) async { try { - final response = await _dio.post( - ApiEndpoints.login, - data: { - 'identity': email, - 'password': password, - }, - ); - - 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; + final authData = await _pb.collection('users').authWithPassword( + email, + password, + ); + _logger.i('✅ Login successful for ${authData.record.id}'); + return authData; } catch (e) { _logger.e('Login failed', error: e); rethrow; } } - Future> register({ + Future register({ required String email, required String username, required String password, @@ -204,33 +80,21 @@ class ApiClient { Map? avatarConfig, }) async { try { - final response = await _dio.post( - ApiEndpoints.register, - data: { - 'email': email, - 'name': username, - 'password': password, - 'passwordConfirm': password, - 'xp': 0, - 'level': 1, - 'current_bodyweight': bodyweight, - 'inventory_settings': inventorySettings, - 'exercise_variants': exerciseVariants ?? {}, - 'avatar_config': avatarConfig ?? {}, - }, - ); + final user = await _pb.collection('users').create(body: { + 'email': email, + 'name': username, + 'password': password, + 'passwordConfirm': password, + 'emailVisibility': true, + 'xp': 0, + 'level': 1, + 'current_bodyweight': bodyweight, + 'inventory_settings': inventorySettings, + 'exercise_variants': exerciseVariants ?? {}, + 'avatar_config': avatarConfig ?? {}, + }); - 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; + return await login(email, password); } catch (e) { _logger.e('Registration failed', error: e); rethrow; @@ -239,10 +103,7 @@ class ApiClient { Future requestVerification(String email) async { try { - await _dio.post( - '/api/collections/users/request-verification', - data: {'email': email}, - ); + await _pb.collection('users').requestVerification(email); _logger.i('Verification email requested for $email'); } catch (e) { _logger.e('Request verification failed', error: e); @@ -250,118 +111,110 @@ class ApiClient { } } + Future 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 logout() async { - await _storage.delete(key: AppConstants.keyAuthToken); - await _storage.delete(key: AppConstants.keyUserId); + _pb.authStore.clear(); } Future> sync({ required String lastSyncTimestamp, required Map pushData, }) async { - try { - final response = await _dio.post( + return _handleRequest(() async { + final result = await _pb.send( ApiEndpoints.sync, - data: { + method: 'POST', + body: { 'last_sync_timestamp': lastSyncTimestamp, 'push_data': pushData, }, ); - return response.data; - } catch (e) { - _logger.e('Sync failed', error: e); - rethrow; - } + return result as Map; + }); } Future> createCycle( Map trainingMaxes) async { - try { - final response = await _dio.post( + return _handleRequest(() async { + final result = await _pb.send( ApiEndpoints.cycleCreate, - data: {'training_maxes': trainingMaxes}, + method: 'POST', + body: {'training_maxes': trainingMaxes}, ); - return response.data; - } catch (e) { - _logger.e('Create cycle failed', error: e); - rethrow; - } + return result as Map; + }); } Future> finishCycle(String cycleId) async { - try { - final response = await _dio.post( + return _handleRequest(() async { + final result = await _pb.send( ApiEndpoints.cycleFinish, - data: {'cycle_id': cycleId}, + method: 'POST', + body: {'cycle_id': cycleId}, ); - return response.data; - } catch (e) { - _logger.e('Finish cycle failed', error: e); - rethrow; - } + return result as Map; + }); } Future> getCurrentCycle() async { - try { - final response = await _dio.get(ApiEndpoints.cycleCurrent); - return response.data; - } catch (e) { - _logger.e('Get current cycle failed', error: e); - rethrow; - } + return _handleRequest(() async { + final result = await _pb.send(ApiEndpoints.cycleCurrent, method: 'GET'); + return result as Map; + }); } Future> getStatsHistory({ required String exercise, required String range, }) async { - try { - final response = await _dio.get( + return _handleRequest(() async { + final result = await _pb.send( ApiEndpoints.statsHistory, - queryParameters: { + method: 'GET', + query: { 'exercise': exercise, 'range': range, }, ); - return response.data; - } catch (e) { - _logger.e('Get stats history failed', error: e); - rethrow; - } + return result as Map; + }); } Future> getStatsSummary() async { - try { - final response = await _dio.get(ApiEndpoints.statsSummary); - return response.data; - } catch (e) { - _logger.e('Get stats summary failed', error: e); - rethrow; - } + return _handleRequest(() async { + final result = await _pb.send(ApiEndpoints.statsSummary, method: 'GET'); + return result as Map; + }); } Future updateBodyweight(double bodyweight) async { - try { - await _dio.patch( + await _handleRequest(() async { + await _pb.send( ApiEndpoints.profileBodyweight, - data: {'bodyweight': bodyweight}, + method: 'PATCH', + body: {'bodyweight': bodyweight}, ); - } catch (e) { - _logger.e('Update bodyweight failed', error: e); - rethrow; - } + }); } Future updateInventory(Map inventory) async { - try { - await _dio.patch( + await _handleRequest(() async { + await _pb.send( ApiEndpoints.profileInventory, - data: inventory, + method: 'PATCH', + body: inventory, ); - } catch (e) { - _logger.e('Update inventory failed', error: e); - rethrow; - } + }); } Future updatePassword({ @@ -370,44 +223,35 @@ class ApiClient { required String newPassword, required String newPasswordConfirm, }) async { - try { - await _dio.patch( - '${ApiEndpoints.userUpdate}/$userId', - data: { - 'oldPassword': oldPassword, - 'password': newPassword, - 'passwordConfirm': newPasswordConfirm, - }, - ); - } catch (e) { - _logger.e('Update password failed', error: e); - rethrow; - } + await _handleRequest(() async { + await _pb.collection('users').update(userId, body: { + 'oldPassword': oldPassword, + 'password': newPassword, + 'passwordConfirm': newPasswordConfirm, + }); + }); } Future deleteAccount(String userId) async { - try { - await _dio.delete('${ApiEndpoints.userDelete}/$userId'); - } catch (e) { - _logger.e('Delete account failed', error: e); - rethrow; - } + await _handleRequest(() async { + await _pb.collection('users').delete(userId); + }); } Future resetProgress() async { - try { - await _dio.post(ApiEndpoints.profileReset); - } catch (e) { - _logger.e('Reset progress failed', error: e); - rethrow; - } + await _handleRequest(() async { + await _pb.send( + ApiEndpoints.profileReset, + method: 'POST', + ); + }); } - Future getToken() async { - return await _storage.read(key: AppConstants.keyAuthToken); + String? getToken() { + return _pb.authStore.token.isNotEmpty ? _pb.authStore.token : null; } - Future getUserId() async { - return await _storage.read(key: AppConstants.keyUserId); + String? getUserId() { + return _pb.authStore.record?.id; } } diff --git a/lib/src/shared/data/remote/pb_auth_store.dart b/lib/src/shared/data/remote/pb_auth_store.dart new file mode 100644 index 0000000..ef43b5c --- /dev/null +++ b/lib/src/shared/data/remote/pb_auth_store.dart @@ -0,0 +1,57 @@ +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 save(String newToken, dynamic newRecord) async { + super.save(newToken, newRecord); + + final encoded = jsonEncode({ + 'token': newToken, + 'model': newRecord, + }); + + await _storage.write(key: _storageKey, value: encoded); + } + + @override + void clear() { + super.clear(); + _storage.delete(key: _storageKey); + } + + Future loadFromStorage() async { + final raw = await _storage.read(key: _storageKey); + if (raw != null && raw.isNotEmpty) { + try { + final decoded = jsonDecode(raw) as Map; + 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); + } + } +} diff --git a/lib/src/shared/data/remote/secure_auth_store.dart b/lib/src/shared/data/remote/secure_auth_store.dart new file mode 100644 index 0000000..31e0a35 --- /dev/null +++ b/lib/src/shared/data/remote/secure_auth_store.dart @@ -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 save(String newToken, dynamic newRecord) async { + super.save(newToken, newRecord); + + final encoded = jsonEncode({ + 'token': newToken, + 'model': newRecord, + }); + + await _storage.write(key: _saveKey, value: encoded); + } + + @override + Future clear() async { + super.clear(); + await _storage.delete(key: _saveKey); + } + + Future 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(); + } + } + } +}