You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
339 lines
11 KiB
339 lines
11 KiB
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<String> 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<Map<String, String>> 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<RemoteDeckListItem> _parseDeckList(dynamic body) {
|
|
if (body is List) {
|
|
return body.map((e) => _parseDeckListItem(e as Map<String, dynamic>)).toList();
|
|
}
|
|
if (body is Map<String, dynamic>) {
|
|
final list = body['decks'] as List<dynamic>? ?? [];
|
|
return list.map((e) => _parseDeckListItem(e as Map<String, dynamic>)).toList();
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/// GET /api/decks/mine
|
|
Future<List<RemoteDeckListItem>> 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<List<RemoteDeckListItem>> 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<String, dynamic> 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<Deck> 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<String, dynamic>;
|
|
return _jsonToDeck(json);
|
|
}
|
|
|
|
/// POST /api/decks — create deck; returns new deck id.
|
|
Future<String> createDeck({
|
|
required String title,
|
|
required String description,
|
|
Map<String, dynamic>? config,
|
|
List<Map<String, dynamic>>? questions,
|
|
String? copiedFromDeckId,
|
|
int? copiedFromVersion,
|
|
}) async {
|
|
final uri = Uri.parse('$apiBaseUrl/api/decks');
|
|
final body = <String, dynamic>{
|
|
'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<String, dynamic>;
|
|
return data['id'] as String? ?? '';
|
|
}
|
|
|
|
/// PATCH /api/decks/:id
|
|
Future<void> updateDeck(
|
|
String deckId, {
|
|
String? title,
|
|
String? description,
|
|
Map<String, dynamic>? config,
|
|
List<Map<String, dynamic>>? questions,
|
|
}) async {
|
|
final uri = Uri.parse('$apiBaseUrl/api/decks/$deckId');
|
|
final body = <String, dynamic>{};
|
|
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<void> 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<String> 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<String, dynamic>;
|
|
return data['id'] as String? ?? '';
|
|
}
|
|
|
|
/// GET /api/decks/:id/update-preview
|
|
Future<UpdatePreview> 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<String, dynamic>;
|
|
final changes = (data['changes'] as List<dynamic>?)?.cast<String>() ?? [];
|
|
return UpdatePreview(changes: changes);
|
|
}
|
|
|
|
/// POST /api/decks/:id/apply-update
|
|
Future<void> 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<void> 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<String, dynamic> json) {
|
|
final configJson = json['config'] as Map<String, dynamic>? ?? {};
|
|
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<dynamic>? ?? [];
|
|
final questions = questionsJson.asMap().entries.map((entry) {
|
|
final i = entry.key;
|
|
final qJson = entry.value as Map<String, dynamic>;
|
|
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<String, dynamic> q, {String? defaultId}) {
|
|
List<int>? correctIndices;
|
|
if (q['correct_answer_indices'] != null) {
|
|
final arr = q['correct_answer_indices'] as List<dynamic>?;
|
|
correctIndices = arr?.map((e) => e as int).toList();
|
|
}
|
|
if (correctIndices == null && q['correctAnswerIndices'] != null) {
|
|
final arr = q['correctAnswerIndices'] as List<dynamic>?;
|
|
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<dynamic>?)
|
|
?.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<Map<String, dynamic>> deckQuestionsToApi(List<Question> 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<String, dynamic> 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';
|
|
}
|