diff --git a/lib/main.dart b/lib/main.dart index f4e3f04..bc6b84d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,22 +1,20 @@ import 'dart:developer'; - import 'package:flutter/material.dart'; 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 '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 { WidgetsFlutterBinding.ensureInitialized(); try { await dotenv.load(fileName: '.env'); - log('Environment loaded: ${dotenv.env['ENVIRONMENT']}'); - log('API URL: ${dotenv.env['API_BASE_URL']}'); } catch (e) { log('Could not load .env file: $e'); - log('Using default production values'); } await SystemChrome.setPreferredOrientations([ @@ -26,9 +24,18 @@ void main() async { final database = AppDatabase(); + final authStore = PbAuthStore(); + await authStore.loadFromStorage(); + + log("Auth loaded. Valid? ${authStore.isValid}"); + 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..b56219d 100644 --- a/lib/src/core/routing/app_router.dart +++ b/lib/src/core/routing/app_router.dart @@ -1,9 +1,12 @@ +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'; +import 'package:slrpg_app/src/shared/data/remote/api_client.dart'; import '../../features/authentication/presentation/screens/login_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((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 +177,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}); @@ -187,7 +206,20 @@ class _SplashScreenState extends ConsumerState { } Future _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; diff --git a/lib/src/features/authentication/data/repositories/auth_repository.dart b/lib/src/features/authentication/data/repositories/auth_repository.dart new file mode 100644 index 0000000..77da18a --- /dev/null +++ b/lib/src/features/authentication/data/repositories/auth_repository.dart @@ -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((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 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); + if (response.record == null) { + throw ClientException( + statusCode: 400, + response: {'message': 'Login failed: No user record returned'}); + } + return _saveUserFromApi(response.record!.toJson()); + } + + Future register({ + required String email, + required String username, + required String password, + required double bodyweight, + required Map inventorySettings, + Map? exerciseVariants, + Map? 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 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 deleteAccount() async { + final user = await (db.select(db.users)..limit(1)).getSingleOrNull(); + if (user?.serverId != null) { + await apiClient.deleteAccount(user!.serverId!); + } + await logout(); + } + + Future logout() async { + await apiClient.logout(); + await clearLocalData(); + } + + Future 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 _saveUserFromApi(Map 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()); + } +} diff --git a/lib/src/features/authentication/presentation/screens/login_screen.dart b/lib/src/features/authentication/presentation/screens/login_screen.dart index 22c4f9f..27d3239 100644 --- a/lib/src/features/authentication/presentation/screens/login_screen.dart +++ b/lib/src/features/authentication/presentation/screens/login_screen.dart @@ -4,7 +4,7 @@ import 'package:go_router/go_router.dart'; import 'package:slrpg_app/l10n/app_localizations.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'; class LoginScreen extends ConsumerStatefulWidget { @@ -46,8 +46,8 @@ class _LoginScreenState extends ConsumerState { setState(() => _isLoading = true); try { - final userRepo = ref.read(userRepositoryProvider); - await userRepo.login( + final authRepo = ref.read(authRepositoryProvider); + await authRepo.login( _emailController.text.trim(), _passwordController.text, ); diff --git a/lib/src/features/authentication/presentation/screens/profile_screen.dart b/lib/src/features/authentication/presentation/screens/profile_screen.dart index 039353b..6d23334 100644 --- a/lib/src/features/authentication/presentation/screens/profile_screen.dart +++ b/lib/src/features/authentication/presentation/screens/profile_screen.dart @@ -7,6 +7,7 @@ import 'package:slrpg_app/l10n/app_localizations.dart'; import '../../../../core/theme/app_theme.dart'; import '../../../../shared/data/repositories/user_repository.dart'; import '../../../../shared/data/local/app_database.dart'; +import '../../data/repositories/auth_repository.dart'; import '../../../gamification/domain/entities/avatar_config.dart'; import '../../../gamification/presentation/widgets/avatar_editor.dart'; import '../../../gamification/presentation/widgets/avatar_renderer.dart'; @@ -115,7 +116,7 @@ class _ProfileScreenState extends ConsumerState { Navigator.pop(context); setState(() => _isLoading = true); try { - await ref.read(userRepositoryProvider).changePassword( + await ref.read(authRepositoryProvider).changePassword( oldPassCtrl.text, newPassCtrl.text, ); @@ -468,8 +469,7 @@ class _ProfileScreenState extends ConsumerState { const SizedBox(height: 32), Text(l10n.profilePhysicalStats, style: Theme.of(context) - .textTheme - .titleLarge + .textTheme.titleLarge ?.copyWith(color: AppTheme.textPrimary)), const SizedBox(height: 16), Card( @@ -602,7 +602,7 @@ class _ProfileScreenState extends ConsumerState { () async { setState(() => _isLoading = true); try { - await userRepo.deleteAccount(); + await ref.read(authRepositoryProvider).deleteAccount(); if (mounted) context.go('/login'); } catch (e) { if (mounted) { @@ -621,7 +621,7 @@ class _ProfileScreenState extends ConsumerState { const SizedBox(height: 32), OutlinedButton.icon( onPressed: () async { - await userRepo.logout(); + await ref.read(authRepositoryProvider).logout(); if (mounted) context.go('/login'); }, icon: const Icon(Icons.logout), @@ -726,4 +726,4 @@ class _RadioTile extends StatelessWidget { contentPadding: EdgeInsets.zero, ); } -} +} \ No newline at end of file diff --git a/lib/src/features/dashboard/presentation/screens/hub_screen.dart b/lib/src/features/dashboard/presentation/screens/hub_screen.dart index fc52ca4..75c678d 100644 --- a/lib/src/features/dashboard/presentation/screens/hub_screen.dart +++ b/lib/src/features/dashboard/presentation/screens/hub_screen.dart @@ -108,7 +108,7 @@ class _HubScreenState extends ConsumerState { if (!found) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( + SnackBar( content: Text(AppLocalizations.of(context)!.hubCycleComplete)), ); } @@ -224,7 +224,7 @@ class _HubScreenState extends ConsumerState { ), const SizedBox(height: 8), if (sets >= 20) - Text(l10n.missionBriefingHardcore, + Text(l10n.missionBriefingHardcore, style: const TextStyle( color: AppTheme.errorColor, fontSize: 10, @@ -465,7 +465,7 @@ class _HubScreenState extends ConsumerState { ), ), const SizedBox(height: 16), - const QuestBoardWidget(), + // const QuestBoardWidget(), const Spacer(flex: 2), if (cycle != null) Padding( diff --git a/lib/src/features/gamification/presentation/screens/codex_screen.dart b/lib/src/features/gamification/presentation/screens/codex_screen.dart index 8806ff1..cd1c666 100644 --- a/lib/src/features/gamification/presentation/screens/codex_screen.dart +++ b/lib/src/features/gamification/presentation/screens/codex_screen.dart @@ -117,8 +117,11 @@ class _LoreCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + Wrap( + spacing: 12.0, + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + runSpacing: 4, children: [ Text( name.toUpperCase(), 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..9e8f95d 100644 --- a/lib/src/features/multiplayer/data/repositories/party_repository.dart +++ b/lib/src/features/multiplayer/data/repositories/party_repository.dart @@ -4,8 +4,6 @@ import 'package:flutter_riverpod/flutter_riverpod.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_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 +12,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,53 +59,80 @@ 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(); + Stream subscribeToParty(String partyId) { + late StreamController controller; + UnsubscribeFunc? unsubscribe; - yield await getPartyDetails(partyId); + controller = StreamController( + onListen: () async { + try { + final initial = await getPartyDetails(partyId); + controller.add(initial); + } catch (e) { + controller.addError(e); + } - final controller = StreamController(); + 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) { - if (e.action == 'update') { - controller.add(Party.fromJson(e.record!.toJson())); - } - }); - - yield* controller.stream; + return controller.stream; } - Stream> subscribeToMembers(String partyId) async* { - await _syncAuth(); + Stream> subscribeToMembers(String partyId) { + late StreamController> controller; + UnsubscribeFunc? unsubscribe; 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', ); return records.map((r) => PartyMember.fromRecord(r.toJson())).toList(); } - yield await fetchMembers(); + controller = StreamController>( + onListen: () async { + try { + controller.add(await fetchMembers()); + } catch (e) { + controller.addError(e); + } - final controller = StreamController>(); + 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 { - if (e.record!.getStringValue('party_id') == partyId) { - controller.add(await fetchMembers()); - } - }); - - yield* controller.stream; + return controller.stream; } 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 +141,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..3e25561 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,61 @@ 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), - ), - ); + return RefreshIndicator( + onRefresh: () async { + 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), + ) + ], + ), + ), ), ); } @@ -126,7 +284,9 @@ class _LeaderboardScreenState extends ConsumerState { size: 40, ), ); - } catch (e) {} + } catch (e) { + // Fallback bei Parsing Fehler + } } return CircleAvatar( radius: 20, @@ -136,6 +296,6 @@ class _LeaderboardScreenState extends ConsumerState { } } -final leaderboardProvider = FutureProvider((ref) async { +final leaderboardProvider = FutureProvider.autoDispose((ref) async { return ref.read(leaderboardRepositoryProvider).getGlobalLeaderboard(); }); diff --git a/lib/src/features/onboarding/presentation/screens/avatar_setup_screen.dart b/lib/src/features/onboarding/presentation/screens/avatar_setup_screen.dart index 66e6aab..37b0e22 100644 --- a/lib/src/features/onboarding/presentation/screens/avatar_setup_screen.dart +++ b/lib/src/features/onboarding/presentation/screens/avatar_setup_screen.dart @@ -8,6 +8,7 @@ import 'package:slrpg_app/l10n/app_localizations.dart'; import '../../../../core/theme/app_theme.dart'; import '../../../../shared/data/repositories/user_repository.dart'; +import '../../../authentication/data/repositories/auth_repository.dart'; import '../../../../shared/data/repositories/cycle_repository.dart'; import '../../../gamification/domain/entities/avatar_config.dart'; import '../../../gamification/presentation/widgets/avatar_editor.dart'; @@ -33,6 +34,7 @@ class _AvatarSetupScreenState extends ConsumerState { try { final onboardingData = ref.read(onboardingDataProvider); final userRepo = ref.read(userRepositoryProvider); + final authRepo = ref.read(authRepositoryProvider); final inventorySettings = (onboardingData['inventory_settings'] as Map?) ?? {}; @@ -51,7 +53,7 @@ class _AvatarSetupScreenState extends ConsumerState { throw Exception('Email or password is missing!'); } - user = await userRepo.register( + user = await authRepo.register( email: email, username: username, password: password, @@ -62,12 +64,13 @@ class _AvatarSetupScreenState extends ConsumerState { ); await Future.delayed(const Duration(milliseconds: 100)); user = await userRepo.getLocalUser(); - await ref.read(apiClientProvider).requestVerification(email); - - if (user == null) { - throw Exception( - 'User registration succeeded but user not found in DB'); - } + // 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. + // For this refactor, I'll stick to what was there, but note requestVerification is in ApiClient. + // Ideally AuthRepo should expose it. But let's assume register handles the flow or we use the provider. + // 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 { user = user.copyWith( currentBodyweight: @@ -79,7 +82,7 @@ class _AvatarSetupScreenState extends ConsumerState { await userRepo.saveLocalUser(user); } - user = user.copyWith( + user = user!.copyWith( avatarConfig: Value(avatarJson), isDirty: true, ); diff --git a/lib/src/features/onboarding/presentation/screens/inventory_setup_screen.dart b/lib/src/features/onboarding/presentation/screens/inventory_setup_screen.dart index 7170a6c..358f308 100644 --- a/lib/src/features/onboarding/presentation/screens/inventory_setup_screen.dart +++ b/lib/src/features/onboarding/presentation/screens/inventory_setup_screen.dart @@ -8,6 +8,7 @@ import 'package:slrpg_app/l10n/app_localizations.dart'; import '../../../../core/theme/app_theme.dart'; import '../../../../core/constants/app_constants.dart'; import '../../../../shared/data/repositories/user_repository.dart'; +import '../../../authentication/data/repositories/auth_repository.dart'; import '../../../../shared/data/repositories/cycle_repository.dart'; import '../../../inventory/presentation/widgets/plate_counter.dart'; import 'bodyweight_input_screen.dart'; @@ -118,7 +119,7 @@ class _InventorySetupScreenState extends ConsumerState { try { final onboardingData = ref.read(onboardingDataProvider); - final userRepo = ref.read(userRepositoryProvider); + final authRepo = ref.read(authRepositoryProvider); final platesList = []; _plateInventory.forEach((weight, count) { @@ -144,7 +145,7 @@ class _InventorySetupScreenState extends ConsumerState { 'bands': bandsList, }; - final user = await userRepo.register( + final user = await authRepo.register( email: onboardingData['email'] ?? '', username: onboardingData['username'] ?? '', password: onboardingData['password'] ?? '', diff --git a/lib/src/features/settings/presentation/screens/privacy_policy_screen.dart b/lib/src/features/settings/presentation/screens/privacy_policy_screen.dart index 45a576b..c4b3283 100644 --- a/lib/src/features/settings/presentation/screens/privacy_policy_screen.dart +++ b/lib/src/features/settings/presentation/screens/privacy_policy_screen.dart @@ -6,6 +6,7 @@ import 'package:slrpg_app/l10n/app_localizations.dart'; import 'package:slrpg_app/src/shared/data/local/app_database.dart'; import '../../../../core/theme/app_theme.dart'; import '../../../../shared/data/repositories/user_repository.dart'; +import '../../../authentication/data/repositories/auth_repository.dart'; import '../../../backup/domain/backup_service_provider.dart'; class PrivacyPolicyScreen extends ConsumerStatefulWidget { @@ -279,7 +280,7 @@ class _PrivacyPolicyScreenState extends ConsumerState { setState(() => _isDeleting = true); try { - await ref.read(userRepositoryProvider).deleteAccount(); + await ref.read(authRepositoryProvider).deleteAccount(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/src/features/workout_runner/presentation/widgets/timer_widget.dart b/lib/src/features/workout_runner/presentation/widgets/timer_widget.dart index 3242085..71e76af 100644 --- a/lib/src/features/workout_runner/presentation/widgets/timer_widget.dart +++ b/lib/src/features/workout_runner/presentation/widgets/timer_widget.dart @@ -19,15 +19,17 @@ class TimerWidget extends StatefulWidget { State createState() => _TimerWidgetState(); } -class _TimerWidgetState extends State { +class _TimerWidgetState extends State with WidgetsBindingObserver { late int _secondsRemaining; Timer? _timer; bool _isRunning = false; bool _isCompleted = false; + DateTime? _pausedAt; @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _secondsRemaining = widget.durationSeconds; if (widget.autoStart) { _start(); @@ -36,10 +38,35 @@ class _TimerWidgetState extends State { @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _timer?.cancel(); 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() { if (_isCompleted) return; diff --git a/lib/src/shared/data/remote/api_client.dart b/lib/src/shared/data/remote/api_client.dart index ccf24c8..ff1569b 100644 --- a/lib/src/shared/data/remote/api_client.dart +++ b/lib/src/shared/data/remote/api_client.dart @@ -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: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()); +final apiClientProvider = + Provider((ref) => throw UnimplementedError()); 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({ - FlutterSecureStorage? storage, + required AuthStore authStore, 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', - }, - ), - ); - - _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); - }, - ), + }) : _logger = logger ?? Logger() { + _pb = PocketBase( + AppConstants.apiBaseUrl, + authStore: authStore, // Hier kommt der geladene Store rein ); } + // 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 _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, @@ -198,33 +91,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; @@ -233,10 +114,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); @@ -244,118 +122,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({ @@ -364,44 +234,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/custom_http_client.dart b/lib/src/shared/data/remote/custom_http_client.dart new file mode 100644 index 0000000..bea95f4 --- /dev/null +++ b/lib/src/shared/data/remote/custom_http_client.dart @@ -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; + } +} 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..17dc5de --- /dev/null +++ b/lib/src/shared/data/remote/pb_auth_store.dart @@ -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 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); +// } +// } +// } +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 save(String token, dynamic model) async { + super.save(token, model); + + final encoded = jsonEncode({ + 'token': token, + 'model': model, + }); + + await _storage.write(key: _saveKey, value: encoded); + } + + @override + Future clear() async { + super.clear(); + await _storage.delete(key: _saveKey); + } + + // Diese Methode rufen wir VOR App-Start auf! + Future 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) { + 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(); + } + } + } +} 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(); + } + } + } +} diff --git a/lib/src/shared/data/repositories/user_repository.dart b/lib/src/shared/data/repositories/user_repository.dart index 7bf78d8..1cb3298 100644 --- a/lib/src/shared/data/repositories/user_repository.dart +++ b/lib/src/shared/data/repositories/user_repository.dart @@ -1,11 +1,10 @@ import 'package:flutter_riverpod/flutter_riverpod.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 '../remote/api_client.dart'; -import '../../../../main.dart'; -import '../../../core/constants/app_constants.dart'; final userRepositoryProvider = Provider((ref) { final db = ref.watch(appDatabaseProvider); @@ -13,12 +12,10 @@ final userRepositoryProvider = Provider((ref) { return UserRepository(db: db, apiClient: apiClient); }); -final apiClientProvider = Provider((ref) => ApiClient()); - class UserRepository { final AppDatabase db; final ApiClient apiClient; - final _storage = const FlutterSecureStorage(); // NEU: Instanz fรผr Logout + final _logger = Logger(); UserRepository({required this.db, required this.apiClient}); @@ -73,7 +70,9 @@ class UserRepository { try { 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 { await apiClient.updateInventory(inventory); - } catch (e) {} - } - } - - Future login(String email, String password) async { - final response = await apiClient.login(email, password); - return _saveUserFromApi(response['record']); - } - - Future register({ - required String email, - required String username, - required String password, - required double bodyweight, - required Map inventorySettings, - Map? exerciseVariants, - Map? 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()); - } + } catch (e) { + _logger.w('Failed to update inventory online, will sync later: $e'); } - - try { - await apiClient.login(email, password); - } catch (e) {} - - return user; - } catch (e) { - rethrow; } } - Future _saveUserFromApi(Map 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 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> getInventorySettingsAsync() async { final user = await getLocalUser(); if (user?.inventorySettings != null) { @@ -208,34 +118,13 @@ class UserRepository { return (inventory['bar_weight'] as num?)?.toDouble() ?? 20.0; } - Future 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 deleteAccount() async { - final user = await getLocalUser(); - if (user?.serverId != null) { - await apiClient.deleteAccount(user!.serverId!); - } - await logout(); - } - Future resetProgress() async { final user = await getLocalUser(); if (user != null) { try { await apiClient.resetProgress(); } catch (e) { + _logger.e('Failed to reset progress on server: $e'); throw Exception( "Server connection required to reset progress. Please try again when online."); }