import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:practice_engine/practice_engine.dart'; import '../env.dart'; import 'api_auth_service.dart'; /// Sync metadata for a deck that came from the API (server id, copy source, etc.). class DeckSyncMetadata { final String serverDeckId; final String? ownerId; final String? copiedFromDeckId; final int? copiedFromVersion; final bool published; final bool needsUpdate; const DeckSyncMetadata({ required this.serverDeckId, this.ownerId, this.copiedFromDeckId, this.copiedFromVersion, this.published = false, this.needsUpdate = false, }); } /// Remote deck list item (from GET /api/decks/mine or /api/decks/published). class RemoteDeckListItem { final String id; final String title; final String description; final int questionCount; final bool userHasThis; final bool needsUpdate; final String? copiedFromDeckId; final String? ownerDisplayName; final double? averageRating; final int? ratingCount; const RemoteDeckListItem({ required this.id, required this.title, required this.description, required this.questionCount, this.userHasThis = false, this.needsUpdate = false, this.copiedFromDeckId, this.ownerDisplayName, this.averageRating, this.ratingCount, }); } /// Update preview for a community copy (from GET /api/decks/:id/update-preview). class UpdatePreview { final List changes; const UpdatePreview({required this.changes}); } /// HTTP client for the deck REST API. class RemoteDeckService { RemoteDeckService._(); static final RemoteDeckService _instance = RemoteDeckService._(); static RemoteDeckService get instance => _instance; String? get _token => ApiAuthService.instance.token; Future> get _headers async { final t = _token; if (t == null) return {'Content-Type': 'application/json'}; return { 'Content-Type': 'application/json', 'Authorization': 'Bearer $t', }; } void _checkResponse(http.Response res) { if (res.statusCode >= 400) { throw RemoteDeckException( statusCode: res.statusCode, body: res.body, ); } } static String? _stringOrNull(dynamic v) { if (v == null) return null; final s = v.toString().trim(); return s.isEmpty ? null : s; } List _parseDeckList(dynamic body) { if (body is List) { return body.map((e) => _parseDeckListItem(e as Map)).toList(); } if (body is Map) { final list = body['decks'] as List? ?? []; return list.map((e) => _parseDeckListItem(e as Map)).toList(); } return []; } /// GET /api/decks/mine Future> getMyDecks() async { final uri = Uri.parse('$apiBaseUrl/api/decks/mine'); final res = await http.get(uri, headers: await _headers); _checkResponse(res); final body = jsonDecode(res.body); return _parseDeckList(body); } /// GET /api/decks/published Future> getPublishedDecks() async { final uri = Uri.parse('$apiBaseUrl/api/decks/published'); final res = await http.get(uri, headers: await _headers); _checkResponse(res); final body = jsonDecode(res.body); return _parseDeckList(body); } RemoteDeckListItem _parseDeckListItem(Map json) { return RemoteDeckListItem( id: (json['id']?.toString() ?? '').trim(), title: json['title'] as String? ?? '', description: json['description'] as String? ?? '', questionCount: json['question_count'] as int? ?? 0, userHasThis: json['user_has_this'] as bool? ?? false, needsUpdate: json['needs_update'] as bool? ?? false, copiedFromDeckId: _stringOrNull(json['copied_from_deck_id']), ownerDisplayName: json['owner_display_name'] as String?, averageRating: (json['average_rating'] as num?)?.toDouble(), ratingCount: json['rating_count'] as int?, ); } /// GET /api/decks/:id — full deck with questions. Future getDeck(String deckId) async { final uri = Uri.parse('$apiBaseUrl/api/decks/$deckId'); final res = await http.get(uri, headers: await _headers); _checkResponse(res); final json = jsonDecode(res.body) as Map; return _jsonToDeck(json); } /// POST /api/decks — create deck; returns new deck id. Future createDeck({ required String title, required String description, Map? config, List>? questions, String? copiedFromDeckId, int? copiedFromVersion, }) async { final uri = Uri.parse('$apiBaseUrl/api/decks'); final body = { 'title': title, 'description': description, if (config != null) 'config': config, if (questions != null) 'questions': questions, if (copiedFromDeckId != null) 'copiedFromDeckId': copiedFromDeckId, if (copiedFromVersion != null) 'copiedFromVersion': copiedFromVersion, }; final res = await http.post( uri, headers: await _headers, body: jsonEncode(body), ); _checkResponse(res); final data = jsonDecode(res.body) as Map; return data['id'] as String? ?? ''; } /// PATCH /api/decks/:id Future updateDeck( String deckId, { String? title, String? description, Map? config, List>? questions, }) async { final uri = Uri.parse('$apiBaseUrl/api/decks/$deckId'); final body = {}; if (title != null) body['title'] = title; if (description != null) body['description'] = description; if (config != null) body['config'] = config; if (questions != null) body['questions'] = questions; final res = await http.patch( uri, headers: await _headers, body: jsonEncode(body), ); _checkResponse(res); } /// POST /api/decks/:id/publish Future setPublished(String deckId, bool published) async { final uri = Uri.parse('$apiBaseUrl/api/decks/$deckId/publish'); final res = await http.post( uri, headers: await _headers, body: jsonEncode({'published': published}), ); _checkResponse(res); } /// POST /api/decks/:id/copy — copy published deck to current user; returns new deck id. Future copyDeck(String deckId) async { final uri = Uri.parse('$apiBaseUrl/api/decks/$deckId/copy'); final res = await http.post(uri, headers: await _headers); _checkResponse(res); final data = jsonDecode(res.body) as Map; return data['id'] as String? ?? ''; } /// GET /api/decks/:id/update-preview Future getUpdatePreview(String copyDeckId) async { final uri = Uri.parse('$apiBaseUrl/api/decks/$copyDeckId/update-preview'); final res = await http.get(uri, headers: await _headers); _checkResponse(res); final data = jsonDecode(res.body) as Map; final changes = (data['changes'] as List?)?.cast() ?? []; return UpdatePreview(changes: changes); } /// POST /api/decks/:id/apply-update Future applyUpdate(String copyDeckId) async { final uri = Uri.parse('$apiBaseUrl/api/decks/$copyDeckId/apply-update'); final res = await http.post(uri, headers: await _headers); _checkResponse(res); } Future deleteDeck(String deckId) async { final uri = Uri.parse('$apiBaseUrl/api/decks/$deckId'); final res = await http.delete(uri, headers: await _headers); _checkResponse(res); } Deck _jsonToDeck(Map json) { final configJson = json['config'] as Map? ?? {}; final config = DeckConfig( requiredConsecutiveCorrect: configJson['requiredConsecutiveCorrect'] as int? ?? 3, defaultAttemptSize: configJson['defaultAttemptSize'] as int? ?? 10, priorityIncreaseOnIncorrect: configJson['priorityIncreaseOnIncorrect'] as int? ?? 5, priorityDecreaseOnCorrect: configJson['priorityDecreaseOnCorrect'] as int? ?? 2, immediateFeedbackEnabled: configJson['immediateFeedbackEnabled'] as bool? ?? true, includeKnownInAttempts: configJson['includeKnownInAttempts'] as bool? ?? false, shuffleAnswerOrder: configJson['shuffleAnswerOrder'] as bool? ?? true, excludeFlaggedQuestions: configJson['excludeFlaggedQuestions'] as bool? ?? false, timeLimitSeconds: configJson['timeLimitSeconds'] as int?, ); final questionsJson = json['questions'] as List? ?? []; final questions = questionsJson.asMap().entries.map((entry) { final i = entry.key; final qJson = entry.value as Map; return _jsonToQuestion(qJson, defaultId: 'q$i'); }).toList(); return Deck( id: json['id'] as String? ?? '', title: json['title'] as String? ?? '', description: json['description'] as String? ?? '', questions: questions, config: config, currentAttemptIndex: 0, attemptHistory: const [], incompleteAttempt: null, ); } Question _jsonToQuestion(Map q, {String? defaultId}) { List? correctIndices; if (q['correct_answer_indices'] != null) { final arr = q['correct_answer_indices'] as List?; correctIndices = arr?.map((e) => e as int).toList(); } if (correctIndices == null && q['correctAnswerIndices'] != null) { final arr = q['correctAnswerIndices'] as List?; correctIndices = arr?.map((e) => e as int).toList(); } return Question( id: q['id'] as String? ?? defaultId ?? '', prompt: q['prompt'] as String? ?? '', explanation: q['explanation'] as String?, answers: (q['answers'] as List?) ?.map((e) => e.toString()) .toList() ?? [], correctAnswerIndices: correctIndices ?? [0], consecutiveCorrect: q['consecutiveCorrect'] as int? ?? 0, isKnown: q['isKnown'] as bool? ?? false, isFlagged: q['isFlagged'] as bool? ?? false, priorityPoints: q['priorityPoints'] as int? ?? 0, lastAttemptIndex: q['lastAttemptIndex'] as int? ?? -1, totalCorrectAttempts: q['totalCorrectAttempts'] as int? ?? 0, totalAttempts: q['totalAttempts'] as int? ?? 0, ); } static List> deckQuestionsToApi(List questions) { return questions.asMap().entries.map((entry) { final q = entry.value; return { 'prompt': q.prompt, 'explanation': q.explanation, 'answers': q.answers, 'correct_answer_indices': q.correctAnswerIndices, }; }).toList(); } static Map deckConfigToApi(DeckConfig config) { return { 'requiredConsecutiveCorrect': config.requiredConsecutiveCorrect, 'defaultAttemptSize': config.defaultAttemptSize, 'priorityIncreaseOnIncorrect': config.priorityIncreaseOnIncorrect, 'priorityDecreaseOnCorrect': config.priorityDecreaseOnCorrect, 'immediateFeedbackEnabled': config.immediateFeedbackEnabled, 'includeKnownInAttempts': config.includeKnownInAttempts, 'shuffleAnswerOrder': config.shuffleAnswerOrder, 'excludeFlaggedQuestions': config.excludeFlaggedQuestions, 'timeLimitSeconds': config.timeLimitSeconds, }; } } class RemoteDeckException implements Exception { final int statusCode; final String body; const RemoteDeckException({required this.statusCode, required this.body}); @override String toString() => 'RemoteDeckException($statusCode): $body'; }