feat: rebuild app with pocketbase sdk instead of dio
This commit is contained in:
parent
09ee89d928
commit
b58b7ca57a
9 changed files with 531 additions and 381 deletions
|
|
@ -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(),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<GoRouter>((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<GoRouter>((ref) {
|
|||
);
|
||||
});
|
||||
|
||||
class _StreamToLegacyListenable extends ChangeNotifier {
|
||||
late final StreamSubscription _subscription;
|
||||
|
||||
_StreamToLegacyListenable(Stream stream) {
|
||||
_subscription = stream.listen((_) => notifyListeners());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subscription.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class SplashScreen extends ConsumerStatefulWidget {
|
||||
const SplashScreen({super.key});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<AuthRepository>((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<AuthStoreEvent> get authStateChanges => apiClient.authStateChanges;
|
||||
|
||||
AuthRepository({required this.db, required this.apiClient});
|
||||
|
||||
Future<UserCollection> login(String email, String password) async {
|
||||
final response = await apiClient.login(email, password);
|
||||
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<UserCollection> 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<void> logout() async {
|
||||
if (apiClient.getToken() != null) {
|
||||
await apiClient.logout();
|
||||
}
|
||||
|
||||
await _storage.delete(key: AppConstants.keyLastSync);
|
||||
|
||||
await db.transaction(() async {
|
||||
|
|
|
|||
|
|
@ -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<List<LeaderboardEntry>> 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<String, dynamic>;
|
||||
final record = entry.value;
|
||||
final data = record.toJson();
|
||||
|
||||
if (data['name'] == null || data['name'].toString().isEmpty) {
|
||||
data['name'] = 'Unknown Hero';
|
||||
|
|
|
|||
|
|
@ -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<void> _syncAuth() async {
|
||||
final token = await _api.getToken();
|
||||
if (token != null) {
|
||||
_pb.authStore.save(token, null);
|
||||
}
|
||||
}
|
||||
PartyRepository(this._api);
|
||||
|
||||
Future<Party> 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<Party> 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<Party> 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<void> setReady(String partyId, bool isReady) async {
|
||||
final userId = (await _api.getUserId())!;
|
||||
final userId = _api.getUserId()!;
|
||||
|
||||
final members = await _pb.collection('party_members').getList(
|
||||
final members = await _api.pb.collection('party_members').getList(
|
||||
filter: 'party_id="$partyId" && user_id="$userId"',
|
||||
);
|
||||
|
||||
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<Party> subscribeToParty(String partyId) async* {
|
||||
await _syncAuth();
|
||||
|
||||
yield await getPartyDetails(partyId);
|
||||
|
||||
final controller = StreamController<Party>();
|
||||
|
||||
_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<List<PartyMember>> subscribeToMembers(String partyId) async* {
|
||||
await _syncAuth();
|
||||
|
||||
Future<List<PartyMember>> fetchMembers() async {
|
||||
final records = await _pb.collection('party_members').getFullList(
|
||||
final records = await _api.pb.collection('party_members').getFullList(
|
||||
filter: 'party_id="$partyId"',
|
||||
expand: 'user_id',
|
||||
);
|
||||
|
|
@ -103,8 +90,8 @@ class PartyRepository {
|
|||
|
||||
final controller = StreamController<List<PartyMember>>();
|
||||
|
||||
_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<void> 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<void> 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');
|
||||
|
|
|
|||
|
|
@ -1,3 +1,144 @@
|
|||
// import 'package:flutter/material.dart';
|
||||
// import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
// import 'package:go_router/go_router.dart';
|
||||
// import 'package:slrpg_app/l10n/app_localizations.dart';
|
||||
// import 'package:slrpg_app/src/features/multiplayer/domain/entities/leaderboard_entry.dart';
|
||||
// import '../../../../core/theme/app_theme.dart';
|
||||
// import '../../../../shared/data/repositories/user_repository.dart';
|
||||
// import '../../data/repositories/leaderboard_repository.dart';
|
||||
// import '../../../gamification/domain/entities/avatar_config.dart';
|
||||
// import '../../../gamification/presentation/widgets/avatar_renderer.dart';
|
||||
|
||||
// class LeaderboardScreen extends ConsumerStatefulWidget {
|
||||
// const LeaderboardScreen({super.key});
|
||||
|
||||
// @override
|
||||
// ConsumerState<LeaderboardScreen> createState() => _LeaderboardScreenState();
|
||||
// }
|
||||
|
||||
// class _LeaderboardScreenState extends ConsumerState<LeaderboardScreen> {
|
||||
// @override
|
||||
// Widget build(BuildContext context) {
|
||||
// final leaderboardAsync = ref.watch(leaderboardProvider);
|
||||
// final currentUserAsync = ref.watch(userRepositoryProvider).getLocalUser();
|
||||
// final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
// return Scaffold(
|
||||
// appBar: AppBar(
|
||||
// title: Text(l10n.leaderboardTitle),
|
||||
// leading: IconButton(
|
||||
// icon: const Icon(Icons.arrow_back),
|
||||
// onPressed: () => context.go('/hub'),
|
||||
// ),
|
||||
// ),
|
||||
// body: leaderboardAsync.when(
|
||||
// data: (entries) {
|
||||
// return FutureBuilder(
|
||||
// future: currentUserAsync,
|
||||
// builder: (context, snapshot) {
|
||||
// final currentUserId = snapshot.data?.serverId ?? '';
|
||||
|
||||
// return ListView.builder(
|
||||
// padding: const EdgeInsets.all(16),
|
||||
// itemCount: entries.length,
|
||||
// itemBuilder: (context, index) {
|
||||
// final entry = entries[index];
|
||||
// final isMe = entry.id == currentUserId;
|
||||
|
||||
// return Card(
|
||||
// color: isMe
|
||||
// ? AppTheme.primaryColor.withValues(alpha: 0.1)
|
||||
// : null,
|
||||
// margin: const EdgeInsets.only(bottom: 8),
|
||||
// child: ListTile(
|
||||
// contentPadding: const EdgeInsets.symmetric(
|
||||
// horizontal: 16, vertical: 8),
|
||||
// leading: _buildRankBadge(entry.rank),
|
||||
// title: Text(
|
||||
// entry.name.isEmpty
|
||||
// ? l10n.leaderboardHero(entry.id.substring(0, 5))
|
||||
// : entry.name,
|
||||
// style: TextStyle(
|
||||
// fontWeight:
|
||||
// isMe ? FontWeight.bold : FontWeight.normal,
|
||||
// color: isMe ? AppTheme.primaryColor : Colors.white,
|
||||
// ),
|
||||
// ),
|
||||
// subtitle: Text(
|
||||
// l10n.leaderboardSubtitle(entry.level, entry.xp)),
|
||||
// trailing: _buildAvatarPreview(entry),
|
||||
// ),
|
||||
// );
|
||||
// },
|
||||
// );
|
||||
// });
|
||||
// },
|
||||
// loading: () => const Center(child: CircularProgressIndicator()),
|
||||
// error: (err, stack) =>
|
||||
// Center(child: Text(l10n.setupFailed(err.toString()))),
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
|
||||
// Widget _buildRankBadge(int rank) {
|
||||
// Color color;
|
||||
// double size = 24;
|
||||
|
||||
// switch (rank) {
|
||||
// case 1:
|
||||
// color = Colors.amber;
|
||||
// size = 32;
|
||||
// break;
|
||||
// case 2:
|
||||
// color = Colors.grey.shade400;
|
||||
// size = 28;
|
||||
// break;
|
||||
// case 3:
|
||||
// color = Colors.brown.shade400;
|
||||
// size = 26;
|
||||
// break;
|
||||
// default:
|
||||
// color = Colors.grey.shade700;
|
||||
// }
|
||||
|
||||
// return SizedBox(
|
||||
// width: 40,
|
||||
// child: Center(
|
||||
// child: rank <= 3
|
||||
// ? Icon(Icons.emoji_events, color: color, size: size)
|
||||
// : Text('#$rank',
|
||||
// style: const TextStyle(
|
||||
// fontWeight: FontWeight.bold, color: Colors.grey)),
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
|
||||
// Widget _buildAvatarPreview(LeaderboardEntry entry) {
|
||||
// if (entry.avatar != null && entry.avatar!.isNotEmpty) {
|
||||
// try {
|
||||
// final config = AvatarConfig.fromJson(entry.avatar!);
|
||||
|
||||
// return SizedBox(
|
||||
// width: 40,
|
||||
// height: 40,
|
||||
// child: AvatarRenderer(
|
||||
// config: config,
|
||||
// size: 40,
|
||||
// ),
|
||||
// );
|
||||
// } catch (e) {}
|
||||
// }
|
||||
// return CircleAvatar(
|
||||
// radius: 20,
|
||||
// backgroundColor: Colors.grey.shade800,
|
||||
// child: const Icon(Icons.person, color: Colors.white70),
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
||||
// final leaderboardProvider = FutureProvider((ref) async {
|
||||
// return ref.read(leaderboardRepositoryProvider).getGlobalLeaderboard();
|
||||
// });
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
|
@ -38,7 +179,13 @@ class _LeaderboardScreenState extends ConsumerState<LeaderboardScreen> {
|
|||
builder: (context, snapshot) {
|
||||
final currentUserId = snapshot.data?.serverId ?? '';
|
||||
|
||||
return ListView.builder(
|
||||
// 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) {
|
||||
|
|
@ -61,7 +208,8 @@ class _LeaderboardScreenState extends ConsumerState<LeaderboardScreen> {
|
|||
style: TextStyle(
|
||||
fontWeight:
|
||||
isMe ? FontWeight.bold : FontWeight.normal,
|
||||
color: isMe ? AppTheme.primaryColor : Colors.white,
|
||||
color:
|
||||
isMe ? AppTheme.primaryColor : Colors.white,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
|
|
@ -70,12 +218,24 @@ class _LeaderboardScreenState extends ConsumerState<LeaderboardScreen> {
|
|||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
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<LeaderboardScreen> {
|
|||
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<LeaderboardScreen> {
|
|||
size: 40,
|
||||
),
|
||||
);
|
||||
} catch (e) {}
|
||||
} catch (e) {
|
||||
// Fallback bei Parsing Fehler
|
||||
}
|
||||
}
|
||||
return CircleAvatar(
|
||||
radius: 20,
|
||||
|
|
@ -136,6 +300,8 @@ class _LeaderboardScreenState extends ConsumerState<LeaderboardScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<ApiClient>((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<Function> _requestsQueue = [];
|
||||
PocketBase get pb => _pb;
|
||||
|
||||
Dio get dio => _dio;
|
||||
Stream<AuthStoreEvent> 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',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (kDebugMode) {
|
||||
_dio.interceptors.add(
|
||||
PrettyDioLogger(
|
||||
requestHeader: true,
|
||||
requestBody: true,
|
||||
responseBody: true,
|
||||
responseHeader: false,
|
||||
error: true,
|
||||
compact: true,
|
||||
),
|
||||
}) : _logger = logger ?? Logger(),
|
||||
_authStore = authStore ?? PbAuthStore(storage: storage) {
|
||||
_pb = PocketBase(
|
||||
AppConstants.apiBaseUrl,
|
||||
authStore: _authStore,
|
||||
);
|
||||
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...');
|
||||
|
||||
Future<T> _handleRequest<T>(Future<T> Function() request) async {
|
||||
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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
_logger.e('API Error: ${e.statusCode} ${e.response}', error: e);
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
_logger.e('❌ Refresh error: $e');
|
||||
await _storage.delete(key: AppConstants.keyAuthToken);
|
||||
_isRefreshing = false;
|
||||
_clearQueue();
|
||||
return handler.next(error);
|
||||
_logger.e('Unexpected API Error', error: e);
|
||||
rethrow;
|
||||
}
|
||||
} 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<void> _queueRequest(
|
||||
Future<Response> Function() request,
|
||||
ErrorInterceptorHandler handler,
|
||||
) async {
|
||||
_requestsQueue.add(() async {
|
||||
Future<RecordAuth> login(String email, String password) async {
|
||||
try {
|
||||
final response = await request();
|
||||
handler.resolve(response);
|
||||
} catch (e) {
|
||||
handler.reject(e as DioException);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _processQueue(String newToken) {
|
||||
for (final request in _requestsQueue) {
|
||||
request();
|
||||
}
|
||||
_requestsQueue.clear();
|
||||
}
|
||||
|
||||
void _clearQueue() {
|
||||
_requestsQueue.clear();
|
||||
}
|
||||
|
||||
Future<String?> refreshToken() async {
|
||||
try {
|
||||
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 authData = await _pb.collection('users').authWithPassword(
|
||||
email,
|
||||
password,
|
||||
);
|
||||
|
||||
final newToken = response.data['token'];
|
||||
if (newToken != null) {
|
||||
await _storage.write(key: AppConstants.keyAuthToken, value: newToken);
|
||||
_logger.i('✅ Token refreshed successfully');
|
||||
return newToken;
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
_logger.e('❌ Token refresh failed', error: e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> 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;
|
||||
_logger.i('✅ Login successful for ${authData.record.id}');
|
||||
return authData;
|
||||
} catch (e) {
|
||||
_logger.e('Login failed', error: e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> register({
|
||||
Future<RecordAuth> register({
|
||||
required String email,
|
||||
required String username,
|
||||
required String password,
|
||||
|
|
@ -204,33 +80,21 @@ class ApiClient {
|
|||
Map<String, dynamic>? avatarConfig,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
ApiEndpoints.register,
|
||||
data: {
|
||||
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<void> 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<void> refreshAuth() async {
|
||||
try {
|
||||
await _pb.collection('users').authRefresh();
|
||||
_logger.i('✅ Auth refreshed successfully');
|
||||
} catch (e) {
|
||||
_logger.e('Auth refresh failed', error: e);
|
||||
_pb.authStore.clear();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await _storage.delete(key: AppConstants.keyAuthToken);
|
||||
await _storage.delete(key: AppConstants.keyUserId);
|
||||
_pb.authStore.clear();
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> sync({
|
||||
required String lastSyncTimestamp,
|
||||
required Map<String, dynamic> 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<String, dynamic>;
|
||||
});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> createCycle(
|
||||
Map<String, double> 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<String, dynamic>;
|
||||
});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> 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<String, dynamic>;
|
||||
});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> 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<String, dynamic>;
|
||||
});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> 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<String, dynamic>;
|
||||
});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> 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<String, dynamic>;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> 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<void> updateInventory(Map<String, dynamic> 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<void> updatePassword({
|
||||
|
|
@ -370,44 +223,35 @@ class ApiClient {
|
|||
required String newPassword,
|
||||
required String newPasswordConfirm,
|
||||
}) async {
|
||||
try {
|
||||
await _dio.patch(
|
||||
'${ApiEndpoints.userUpdate}/$userId',
|
||||
data: {
|
||||
await _handleRequest(() async {
|
||||
await _pb.collection('users').update(userId, body: {
|
||||
'oldPassword': oldPassword,
|
||||
'password': newPassword,
|
||||
'passwordConfirm': newPasswordConfirm,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.e('Update password failed', error: e);
|
||||
rethrow;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> 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<void> 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<String?> getToken() async {
|
||||
return await _storage.read(key: AppConstants.keyAuthToken);
|
||||
String? getToken() {
|
||||
return _pb.authStore.token.isNotEmpty ? _pb.authStore.token : null;
|
||||
}
|
||||
|
||||
Future<String?> getUserId() async {
|
||||
return await _storage.read(key: AppConstants.keyUserId);
|
||||
String? getUserId() {
|
||||
return _pb.authStore.record?.id;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
57
lib/src/shared/data/remote/pb_auth_store.dart
Normal file
57
lib/src/shared/data/remote/pb_auth_store.dart
Normal file
|
|
@ -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<void> save(String newToken, dynamic newRecord) async {
|
||||
super.save(newToken, newRecord);
|
||||
|
||||
final encoded = jsonEncode(<String, dynamic>{
|
||||
'token': newToken,
|
||||
'model': newRecord,
|
||||
});
|
||||
|
||||
await _storage.write(key: _storageKey, value: encoded);
|
||||
}
|
||||
|
||||
@override
|
||||
void clear() {
|
||||
super.clear();
|
||||
_storage.delete(key: _storageKey);
|
||||
}
|
||||
|
||||
Future<void> loadFromStorage() async {
|
||||
final raw = await _storage.read(key: _storageKey);
|
||||
if (raw != null && raw.isNotEmpty) {
|
||||
try {
|
||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
||||
final token = decoded['token'] as String?;
|
||||
final model = decoded['model'];
|
||||
|
||||
if (token != null && token.isNotEmpty) {
|
||||
super.save(token, model);
|
||||
return;
|
||||
}
|
||||
} catch (_) {
|
||||
clear();
|
||||
}
|
||||
}
|
||||
|
||||
const legacyKey = 'auth_token';
|
||||
final legacyToken = await _storage.read(key: legacyKey);
|
||||
if (legacyToken != null && legacyToken.isNotEmpty) {
|
||||
super.save(legacyToken, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
47
lib/src/shared/data/remote/secure_auth_store.dart
Normal file
47
lib/src/shared/data/remote/secure_auth_store.dart
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import 'dart:convert';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
|
||||
class SecureAuthStore extends AuthStore {
|
||||
final FlutterSecureStorage _storage;
|
||||
final String _saveKey;
|
||||
|
||||
SecureAuthStore({
|
||||
required FlutterSecureStorage storage,
|
||||
String saveKey = 'pb_auth',
|
||||
}) : _storage = storage,
|
||||
_saveKey = saveKey;
|
||||
|
||||
@override
|
||||
Future<void> save(String newToken, dynamic newRecord) async {
|
||||
super.save(newToken, newRecord);
|
||||
|
||||
final encoded = jsonEncode(<String, dynamic>{
|
||||
'token': newToken,
|
||||
'model': newRecord,
|
||||
});
|
||||
|
||||
await _storage.write(key: _saveKey, value: encoded);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
super.clear();
|
||||
await _storage.delete(key: _saveKey);
|
||||
}
|
||||
|
||||
Future<void> load() async {
|
||||
final raw = await _storage.read(key: _saveKey);
|
||||
if (raw != null && raw.isNotEmpty) {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
final token = decoded['token'] as String? ?? '';
|
||||
final model = decoded['model'];
|
||||
|
||||
super.save(token, model);
|
||||
} catch (_) {
|
||||
await clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue