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.
decky/lib/services/remote_deck_service.dart

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';
}

Powered by TurnKey Linux.