clud features
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../env.dart';
|
||||
import '../utils/connection_error.dart';
|
||||
|
||||
/// Logged-in user from the API.
|
||||
class ApiUser {
|
||||
final String id;
|
||||
final String? email;
|
||||
final String? displayName;
|
||||
|
||||
const ApiUser({required this.id, this.email, this.displayName});
|
||||
|
||||
factory ApiUser.fromJson(Map<String, dynamic> json) {
|
||||
final metadata = json['user_metadata'] as Map<String, dynamic>?;
|
||||
return ApiUser(
|
||||
id: json['id'] as String? ?? '',
|
||||
email: json['email'] as String?,
|
||||
displayName: json['display_name'] as String? ??
|
||||
metadata?['display_name'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Auth service that uses the backend REST API (login, token storage).
|
||||
class ApiAuthService {
|
||||
ApiAuthService._();
|
||||
static final ApiAuthService _instance = ApiAuthService._();
|
||||
static ApiAuthService get instance => _instance;
|
||||
|
||||
static const String _tokenKey = 'api_auth_token';
|
||||
static const String _userKey = 'api_auth_user';
|
||||
|
||||
final ValueNotifier<ApiUser?> currentUser = ValueNotifier<ApiUser?>(null);
|
||||
String? _token;
|
||||
|
||||
Future<SharedPreferences> get _prefs async =>
|
||||
await SharedPreferences.getInstance();
|
||||
|
||||
/// Call after app start to restore session from stored token.
|
||||
Future<void> init() async {
|
||||
final prefs = await _prefs;
|
||||
_token = prefs.getString(_tokenKey);
|
||||
final userJson = prefs.getString(_userKey);
|
||||
if (_token != null && userJson != null) {
|
||||
try {
|
||||
final map = jsonDecode(userJson) as Map<String, dynamic>;
|
||||
currentUser.value = ApiUser.fromJson(map);
|
||||
} catch (_) {
|
||||
await logout();
|
||||
}
|
||||
}
|
||||
if (_token != null && currentUser.value == null) {
|
||||
final ok = await _fetchSession();
|
||||
if (!ok) await logout();
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _fetchSession() async {
|
||||
if (_token == null) return false;
|
||||
try {
|
||||
final uri = Uri.parse('$apiBaseUrl/api/auth/session');
|
||||
final res = await http.get(
|
||||
uri,
|
||||
headers: {'Authorization': 'Bearer $_token'},
|
||||
);
|
||||
if (res.statusCode != 200) return false;
|
||||
final data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
final userJson = data['user'] as Map<String, dynamic>?;
|
||||
if (userJson == null) return false;
|
||||
currentUser.value = ApiUser.fromJson(userJson);
|
||||
final prefs = await _prefs;
|
||||
await prefs.setString(_userKey, jsonEncode(userJson));
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the current Bearer token for API requests, or null if not logged in.
|
||||
String? get token => _token;
|
||||
|
||||
/// Login with email or username and password.
|
||||
/// Returns null on success, or an error message string.
|
||||
Future<String?> login(String emailOrUsername, String password) async {
|
||||
final trimmed = emailOrUsername.trim();
|
||||
if (trimmed.isEmpty) return 'Enter your email or username.';
|
||||
if (password.isEmpty) return 'Enter your password.';
|
||||
|
||||
try {
|
||||
final uri = Uri.parse('$apiBaseUrl/api/auth/login');
|
||||
final res = await http.post(
|
||||
uri,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'email_or_username': trimmed,
|
||||
'password': password,
|
||||
}),
|
||||
);
|
||||
|
||||
if (res.statusCode == 401) {
|
||||
final data = jsonDecode(res.body) as Map<String, dynamic>?;
|
||||
return data?['error'] as String? ??
|
||||
data?['message'] as String? ??
|
||||
'Invalid email or password.';
|
||||
}
|
||||
if (res.statusCode != 200) {
|
||||
final data = jsonDecode(res.body) as Map<String, dynamic>?;
|
||||
return data?['error'] as String? ??
|
||||
data?['message'] as String? ??
|
||||
'Login failed. Please try again.';
|
||||
}
|
||||
|
||||
final data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
_token = data['access_token'] as String?;
|
||||
final userJson = data['user'] as Map<String, dynamic>?;
|
||||
if (_token == null || userJson == null) {
|
||||
return 'Invalid response from server.';
|
||||
}
|
||||
|
||||
currentUser.value = ApiUser.fromJson(userJson);
|
||||
final prefs = await _prefs;
|
||||
await prefs.setString(_tokenKey, _token!);
|
||||
await prefs.setString(_userKey, jsonEncode(userJson));
|
||||
return null;
|
||||
} catch (e) {
|
||||
return connectionErrorMessage(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Register with email, password, and optional display name.
|
||||
/// Returns null on success, or an error message string.
|
||||
Future<String?> register(String email, String password, String displayName) async {
|
||||
final trimmedEmail = email.trim();
|
||||
if (trimmedEmail.isEmpty) return 'Enter your email.';
|
||||
if (password.isEmpty) return 'Enter your password.';
|
||||
|
||||
try {
|
||||
final uri = Uri.parse('$apiBaseUrl/api/auth/register');
|
||||
final res = await http.post(
|
||||
uri,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'email': trimmedEmail,
|
||||
'password': password,
|
||||
'displayName': displayName.trim().isEmpty ? null : displayName.trim(),
|
||||
}),
|
||||
);
|
||||
|
||||
if (res.statusCode == 400 || res.statusCode == 422) {
|
||||
final data = jsonDecode(res.body) as Map<String, dynamic>?;
|
||||
return data?['message'] as String? ?? 'Registration failed.';
|
||||
}
|
||||
if (res.statusCode != 200) {
|
||||
return 'Registration failed. Please try again.';
|
||||
}
|
||||
|
||||
final data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
_token = data['access_token'] as String?;
|
||||
final userJson = data['user'] as Map<String, dynamic>?;
|
||||
if (_token == null || userJson == null) {
|
||||
return 'Invalid response from server.';
|
||||
}
|
||||
|
||||
currentUser.value = ApiUser.fromJson(userJson);
|
||||
final prefs = await _prefs;
|
||||
await prefs.setString(_tokenKey, _token!);
|
||||
await prefs.setString(_userKey, jsonEncode(userJson));
|
||||
return null;
|
||||
} catch (e) {
|
||||
return connectionErrorMessage(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
_token = null;
|
||||
currentUser.value = null;
|
||||
final prefs = await _prefs;
|
||||
await prefs.remove(_tokenKey);
|
||||
await prefs.remove(_userKey);
|
||||
}
|
||||
}
|
||||
@@ -45,9 +45,12 @@ class DeckStorage {
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert Deck to JSON Map
|
||||
Map<String, dynamic> _deckToJson(Deck deck) {
|
||||
return {
|
||||
/// Sync metadata key in stored JSON (not part of Deck model).
|
||||
static const String _syncKey = '_sync';
|
||||
|
||||
/// Convert Deck to JSON Map. Optionally include [syncMetadata] for server-synced decks.
|
||||
Map<String, dynamic> _deckToJson(Deck deck, {Map<String, dynamic>? syncMetadata}) {
|
||||
final map = <String, dynamic>{
|
||||
'id': deck.id,
|
||||
'title': deck.title,
|
||||
'description': deck.description,
|
||||
@@ -96,10 +99,15 @@ class DeckStorage {
|
||||
'remainingSeconds': deck.incompleteAttempt!.remainingSeconds,
|
||||
} : null,
|
||||
};
|
||||
if (syncMetadata != null && syncMetadata.isNotEmpty) {
|
||||
map[_syncKey] = syncMetadata;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// Convert JSON Map to Deck
|
||||
/// Convert JSON Map to Deck. Strips [_syncKey] from json (use getDeckSyncMetadata to read it).
|
||||
Deck _jsonToDeck(Map<String, dynamic> json) {
|
||||
json = Map<String, dynamic>.from(json)..remove(_syncKey);
|
||||
// Parse config
|
||||
final configJson = json['config'] as Map<String, dynamic>? ?? {};
|
||||
final config = DeckConfig(
|
||||
@@ -284,10 +292,45 @@ class DeckStorage {
|
||||
}
|
||||
}
|
||||
|
||||
/// Add or update a deck
|
||||
Future<void> saveDeck(Deck deck) async {
|
||||
/// Sync metadata for a deck (server_deck_id, owner_id, copied_from_deck_id, etc.). Null if not set.
|
||||
Future<Map<String, dynamic>?> getDeckSyncMetadata(String deckId) async {
|
||||
await _ensureInitialized();
|
||||
final deckJson = jsonEncode(_deckToJson(deck));
|
||||
final deckJson = _prefs!.getString('$_decksKey:$deckId');
|
||||
if (deckJson == null) return null;
|
||||
try {
|
||||
final json = jsonDecode(deckJson) as Map<String, dynamic>;
|
||||
final sync = json[_syncKey];
|
||||
if (sync == null) return null;
|
||||
return Map<String, dynamic>.from(sync as Map);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync metadata (sync version). Null if not set.
|
||||
Map<String, dynamic>? getDeckSyncMetadataSync(String deckId) {
|
||||
if (!_initialized || _prefs == null) return null;
|
||||
final deckJson = _prefs!.getString('$_decksKey:$deckId');
|
||||
if (deckJson == null) return null;
|
||||
try {
|
||||
final json = jsonDecode(deckJson) as Map<String, dynamic>;
|
||||
final sync = json[_syncKey];
|
||||
if (sync == null) return null;
|
||||
return Map<String, dynamic>.from(sync as Map);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Add or update a deck. Pass [syncMetadata] to set or update sync info (server_deck_id, etc.).
|
||||
Future<void> saveDeck(Deck deck, {Map<String, dynamic>? syncMetadata}) async {
|
||||
await _ensureInitialized();
|
||||
Map<String, dynamic>? sync = syncMetadata;
|
||||
if (sync == null) {
|
||||
final existing = await getDeckSyncMetadata(deck.id);
|
||||
if (existing != null) sync = existing;
|
||||
}
|
||||
final deckJson = jsonEncode(_deckToJson(deck, syncMetadata: sync));
|
||||
await _prefs!.setString('$_decksKey:${deck.id}', deckJson);
|
||||
|
||||
// Update deck IDs list
|
||||
@@ -298,15 +341,14 @@ class DeckStorage {
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronous version for backward compatibility
|
||||
void saveDeckSync(Deck deck) {
|
||||
/// Synchronous version for backward compatibility. Pass [syncMetadata] to set or preserve sync info.
|
||||
void saveDeckSync(Deck deck, {Map<String, dynamic>? syncMetadata}) {
|
||||
if (!_initialized || _prefs == null) {
|
||||
// Queue for async save
|
||||
_ensureInitialized().then((_) => saveDeck(deck));
|
||||
_ensureInitialized().then((_) => saveDeck(deck, syncMetadata: syncMetadata));
|
||||
return;
|
||||
}
|
||||
|
||||
final deckJson = jsonEncode(_deckToJson(deck));
|
||||
Map<String, dynamic>? sync = syncMetadata ?? getDeckSyncMetadataSync(deck.id);
|
||||
final deckJson = jsonEncode(_deckToJson(deck, syncMetadata: sync));
|
||||
_prefs!.setString('$_decksKey:${deck.id}', deckJson);
|
||||
|
||||
// Update deck IDs list
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
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';
|
||||
}
|
||||
Reference in New Issue
Block a user