Compare commits

..

12 Commits
1.0 ... master

@ -0,0 +1,6 @@
# API base URL (no trailing slash). Backend must be running (e.g. node server in omotomo_site).
# - Android emulator: use http://10.0.2.2:3001 (emulator's alias for host)
# - iOS simulator: use http://localhost:3001
# - Physical device: use your computer's IP, e.g. http://192.168.1.5:3001 (find IP in system settings)
#API_BASE_URL=http://localhost:3001
API_BASE_URL=http://10.0.2.2:3001

@ -17,18 +17,21 @@ class DefaultDeck {
prompt: 'What is the capital city of Australia?', prompt: 'What is the capital city of Australia?',
answers: ['Sydney', 'Melbourne', 'Canberra', 'Perth'], answers: ['Sydney', 'Melbourne', 'Canberra', 'Perth'],
correctAnswerIndices: [2], correctAnswerIndices: [2],
explanation: 'Canberra was chosen as the capital in 1908 as a compromise between Sydney and Melbourne.',
), ),
Question( Question(
id: 'gk_2', id: 'gk_2',
prompt: 'Which planet is known as the Red Planet?', prompt: 'Which planet is known as the Red Planet?',
answers: ['Venus', 'Mars', 'Jupiter', 'Saturn'], answers: ['Venus', 'Mars', 'Jupiter', 'Saturn'],
correctAnswerIndices: [1], correctAnswerIndices: [1],
explanation: 'Mars appears red due to iron oxide (rust) on its surface.',
), ),
Question( Question(
id: 'gk_3', id: 'gk_3',
prompt: 'What is the largest ocean on Earth?', prompt: 'What is the largest ocean on Earth?',
answers: ['Atlantic Ocean', 'Indian Ocean', 'Arctic Ocean', 'Pacific Ocean'], answers: ['Atlantic Ocean', 'Indian Ocean', 'Arctic Ocean', 'Pacific Ocean'],
correctAnswerIndices: [3], correctAnswerIndices: [3],
explanation: 'The Pacific Ocean covers about 63 million square miles and is larger than all of Earth\'s land area combined.',
), ),
Question( Question(
id: 'gk_4', id: 'gk_4',
@ -203,6 +206,7 @@ class DefaultDeck {
prompt: 'What is the capital city of Australia?', prompt: 'What is the capital city of Australia?',
answers: ['Sydney', 'Melbourne', 'Canberra', 'Perth'], answers: ['Sydney', 'Melbourne', 'Canberra', 'Perth'],
correctAnswerIndices: [2], correctAnswerIndices: [2],
explanation: 'Canberra was chosen as the capital in 1908 as a compromise between Sydney and Melbourne.',
consecutiveCorrect: 3, consecutiveCorrect: 3,
isKnown: true, isKnown: true,
priorityPoints: 0, priorityPoints: 0,
@ -216,6 +220,7 @@ class DefaultDeck {
prompt: 'Which planet is known as the Red Planet?', prompt: 'Which planet is known as the Red Planet?',
answers: ['Venus', 'Mars', 'Jupiter', 'Saturn'], answers: ['Venus', 'Mars', 'Jupiter', 'Saturn'],
correctAnswerIndices: [1], correctAnswerIndices: [1],
explanation: 'Mars appears red due to iron oxide (rust) on its surface.',
consecutiveCorrect: 3, consecutiveCorrect: 3,
isKnown: true, isKnown: true,
priorityPoints: 0, priorityPoints: 0,
@ -227,6 +232,7 @@ class DefaultDeck {
Question( Question(
id: 'gk_3', id: 'gk_3',
prompt: 'What is the largest ocean on Earth?', prompt: 'What is the largest ocean on Earth?',
explanation: 'The Pacific Ocean covers about 63 million square miles and is larger than all of Earth\'s land area combined.',
answers: ['Atlantic Ocean', 'Indian Ocean', 'Arctic Ocean', 'Pacific Ocean'], answers: ['Atlantic Ocean', 'Indian Ocean', 'Arctic Ocean', 'Pacific Ocean'],
correctAnswerIndices: [3], correctAnswerIndices: [3],
consecutiveCorrect: 3, consecutiveCorrect: 3,

@ -0,0 +1,10 @@
import 'package:flutter_dotenv/flutter_dotenv.dart';
/// API base URL (no trailing slash). Loaded from .env as API_BASE_URL.
String get apiBaseUrl {
final url = dotenv.env['API_BASE_URL']?.trim();
if (url == null || url.isEmpty) {
return 'http://localhost:3001';
}
return url.endsWith('/') ? url.substring(0, url.length - 1) : url;
}

@ -1,14 +1,19 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'routes.dart'; import 'routes.dart';
import 'theme.dart'; import 'theme.dart';
import 'services/api_auth_service.dart';
import 'services/deck_storage.dart'; import 'services/deck_storage.dart';
void main() async { void main() async {
// Ensure Flutter bindings are initialized before using any plugins
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// Initialize storage early await dotenv.load(fileName: '.env').catchError(
DeckStorage().initialize(); (_) => dotenv.load(fileName: '.env.example'),
);
await DeckStorage().initialize();
await ApiAuthService.instance.init();
runApp(const DeckyApp()); runApp(const DeckyApp());
} }

@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'screens/deck_list_screen.dart'; import 'screens/home_screen.dart';
import 'screens/deck_import_screen.dart'; import 'screens/deck_import_screen.dart';
import 'screens/deck_overview_screen.dart'; import 'screens/deck_overview_screen.dart';
import 'screens/deck_config_screen.dart'; import 'screens/deck_config_screen.dart';
@ -8,6 +8,8 @@ import 'screens/deck_create_screen.dart';
import 'screens/attempt_screen.dart'; import 'screens/attempt_screen.dart';
import 'screens/attempt_result_screen.dart'; import 'screens/attempt_result_screen.dart';
import 'screens/flagged_questions_screen.dart'; import 'screens/flagged_questions_screen.dart';
import 'screens/login_screen.dart';
import 'screens/community_decks_screen.dart';
class Routes { class Routes {
static const String deckList = '/'; static const String deckList = '/';
@ -19,10 +21,14 @@ class Routes {
static const String attempt = '/attempt'; static const String attempt = '/attempt';
static const String attemptResult = '/attempt-result'; static const String attemptResult = '/attempt-result';
static const String flaggedQuestions = '/flagged-questions'; static const String flaggedQuestions = '/flagged-questions';
static const String login = '/login';
static const String community = '/community';
static Map<String, WidgetBuilder> get routes { static Map<String, WidgetBuilder> get routes {
return { return {
deckList: (context) => const DeckListScreen(), deckList: (context) => const HomeScreen(),
login: (context) => const LoginScreen(),
community: (context) => const CommunityDecksScreen(),
deckImport: (context) => const DeckImportScreen(), deckImport: (context) => const DeckImportScreen(),
deckOverview: (context) => const DeckOverviewScreen(), deckOverview: (context) => const DeckOverviewScreen(),
deckConfig: (context) => const DeckConfigScreen(), deckConfig: (context) => const DeckConfigScreen(),
@ -34,4 +40,3 @@ class Routes {
}; };
} }
} }

@ -3,6 +3,7 @@ import 'package:practice_engine/practice_engine.dart';
import '../routes.dart'; import '../routes.dart';
import '../widgets/status_chip.dart'; import '../widgets/status_chip.dart';
import '../services/deck_storage.dart'; import '../services/deck_storage.dart';
import '../utils/top_snackbar.dart';
class AttemptResultScreen extends StatefulWidget { class AttemptResultScreen extends StatefulWidget {
const AttemptResultScreen({super.key}); const AttemptResultScreen({super.key});
@ -14,6 +15,7 @@ class AttemptResultScreen extends StatefulWidget {
class _AttemptResultScreenState extends State<AttemptResultScreen> { class _AttemptResultScreenState extends State<AttemptResultScreen> {
Deck? _deck; Deck? _deck;
AttemptResult? _result; AttemptResult? _result;
Attempt? _completedAttempt;
final DeckStorage _deckStorage = DeckStorage(); final DeckStorage _deckStorage = DeckStorage();
@override @override
@ -29,6 +31,7 @@ class _AttemptResultScreenState extends State<AttemptResultScreen> {
final args = ModalRoute.of(context)?.settings.arguments as Map<String, dynamic>?; final args = ModalRoute.of(context)?.settings.arguments as Map<String, dynamic>?;
_deck = args?['deck'] as Deck? ?? _createSampleDeck(); _deck = args?['deck'] as Deck? ?? _createSampleDeck();
_result = args?['result'] as AttemptResult? ?? _createSampleResult(); _result = args?['result'] as AttemptResult? ?? _createSampleResult();
_completedAttempt = args?['attempt'] as Attempt?;
} }
} }
@ -62,8 +65,17 @@ class _AttemptResultScreenState extends State<AttemptResultScreen> {
void _repeatSameAttempt() { void _repeatSameAttempt() {
if (_deck == null) return; if (_deck == null) return;
// Pass the completed attempt so the attempt screen uses the same questions in the same order
if (_completedAttempt != null && _completedAttempt!.questions.isNotEmpty) {
Navigator.pushReplacementNamed(
context,
Routes.attempt,
arguments: {'deck': _deck, 'repeatAttempt': _completedAttempt},
);
} else {
Navigator.pushReplacementNamed(context, Routes.attempt, arguments: _deck); Navigator.pushReplacementNamed(context, Routes.attempt, arguments: _deck);
} }
}
void _newAttempt() { void _newAttempt() {
if (_deck == null) return; if (_deck == null) return;
@ -74,11 +86,155 @@ class _AttemptResultScreenState extends State<AttemptResultScreen> {
if (_deck == null) return; if (_deck == null) return;
// Save the updated deck to storage // Save the updated deck to storage
_deckStorage.saveDeckSync(_deck!); _deckStorage.saveDeckSync(_deck!);
// Navigate back to deck list // Navigate back to this deck's overview (decks screen), not the home deck list
Navigator.pushNamedAndRemoveUntil( Navigator.popUntil(context, (route) => route.settings.name == Routes.deckOverview);
Navigator.pushReplacementNamed(
context,
Routes.deckOverview,
arguments: _deck,
);
}
Question? _currentQuestionInDeck(String questionId) {
if (_deck == null) return null;
try {
return _deck!.questions.firstWhere((q) => q.id == questionId);
} catch (_) {
return null;
}
}
void _toggleFlag(String questionId) {
if (_deck == null) return;
setState(() {
_deck = DeckService.toggleQuestionFlag(deck: _deck!, questionId: questionId);
});
_deckStorage.saveDeckSync(_deck!);
final q = _currentQuestionInDeck(questionId);
showTopSnackBar(
context,
message: q?.isFlagged == true ? 'Question flagged for review' : 'Question unflagged',
backgroundColor: q?.isFlagged == true ? Colors.orange : null,
);
}
void _markNeedsPractice(String questionId) {
if (_deck == null) return;
setState(() {
_deck = DeckService.markQuestionAsNeedsPractice(deck: _deck!, questionId: questionId);
});
_deckStorage.saveDeckSync(_deck!);
showTopSnackBar(
context, context,
Routes.deckList, message: 'Marked as needs practice',
(route) => false, backgroundColor: Colors.blue,
);
}
void _showQuestionDetail(AnswerResult answerResult) {
final q = answerResult.question;
final correctSet = q.correctIndices.toSet();
final userSet = answerResult.userAnswerIndices.toSet();
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Question'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
q.prompt,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
...q.answers.asMap().entries.map((e) {
final i = e.key;
final answer = e.value;
final isCorrect = correctSet.contains(i);
final isUser = userSet.contains(i);
String? label;
Color? color;
if (isCorrect && isUser) {
label = 'Correct (your answer)';
color = Colors.green;
} else if (isCorrect) {
label = 'Correct';
color = Colors.green;
} else if (isUser) {
label = 'Your answer';
color = Colors.red;
}
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 24,
child: isCorrect
? Icon(Icons.check_circle, color: Colors.green, size: 20)
: isUser
? Icon(Icons.cancel, color: Colors.red, size: 20)
: const Icon(Icons.radio_button_unchecked, size: 20, color: Colors.grey),
),
const SizedBox(width: 8),
Expanded(
child: Text.rich(
TextSpan(
children: [
if (label != null)
TextSpan(
text: '$label: ',
style: TextStyle(
color: color,
fontWeight: FontWeight.bold,
fontSize: Theme.of(context).textTheme.bodyLarge?.fontSize,
),
),
TextSpan(
text: answer,
style: TextStyle(
color: color,
fontWeight: label != null ? FontWeight.bold : null,
fontSize: Theme.of(context).textTheme.bodyLarge?.fontSize,
),
),
],
),
),
),
],
),
);
}),
if (q.explanation != null && q.explanation!.trim().isNotEmpty) ...[
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
Text(
'Explanation',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
q.explanation!,
style: Theme.of(context).textTheme.bodyMedium,
),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
); );
} }
@ -217,6 +373,8 @@ class _AttemptResultScreenState extends State<AttemptResultScreen> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
..._result!.incorrectQuestions.map((answerResult) { ..._result!.incorrectQuestions.map((answerResult) {
final currentQ = _currentQuestionInDeck(answerResult.question.id);
final isFlagged = currentQ?.isFlagged ?? false;
return Card( return Card(
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
child: Padding( child: Padding(
@ -227,28 +385,63 @@ class _AttemptResultScreenState extends State<AttemptResultScreen> {
Text( Text(
answerResult.question.prompt, answerResult.question.prompt,
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
overflow: TextOverflow.ellipsis,
maxLines: 3,
), ),
const SizedBox(height: 12), const SizedBox(height: 8),
Row(
children: [
StatusChip(statusChange: answerResult.statusChange),
const Spacer(),
Text( Text(
'Your answer${answerResult.userAnswerIndices.length > 1 ? 's' : ''}: ${answerResult.userAnswerIndices.map((idx) => answerResult.question.answers[idx]).join(', ')}', answerResult.userAnswerIndices.isEmpty
? 'No answer'
: 'Your answer${answerResult.userAnswerIndices.length > 1 ? 's' : ''}: ${answerResult.userAnswerIndices.map((idx) => answerResult.question.answers[idx]).join(', ')}',
style: TextStyle( style: TextStyle(
color: Colors.red, color: Colors.red,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: Theme.of(context).textTheme.bodyMedium?.fontSize,
), ),
overflow: TextOverflow.ellipsis,
maxLines: 2,
), ),
], const SizedBox(height: 4),
),
const SizedBox(height: 8),
Text( Text(
'Correct answer${answerResult.question.correctIndices.length > 1 ? 's' : ''}: ${answerResult.question.correctIndices.map((idx) => answerResult.question.answers[idx]).join(', ')}', 'Correct answer${answerResult.question.correctIndices.length > 1 ? 's' : ''}: ${answerResult.question.correctIndices.map((idx) => answerResult.question.answers[idx]).join(', ')}',
style: TextStyle( style: TextStyle(
color: Colors.green, color: Colors.green,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: Theme.of(context).textTheme.bodyMedium?.fontSize,
),
overflow: TextOverflow.ellipsis,
maxLines: 2,
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
StatusChip(statusChange: answerResult.statusChange),
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: () => _showQuestionDetail(answerResult),
icon: const Icon(Icons.visibility, size: 22),
tooltip: 'View question details',
),
IconButton(
onPressed: () => _markNeedsPractice(answerResult.question.id),
icon: const Icon(Icons.replay, size: 22),
tooltip: 'Mark as needs practice',
),
IconButton(
onPressed: () => _toggleFlag(answerResult.question.id),
icon: Icon(
isFlagged ? Icons.flag : Icons.outlined_flag,
color: isFlagged ? Colors.red : null,
size: 22,
),
tooltip: isFlagged ? 'Unflag' : 'Flag for review',
),
],
), ),
],
), ),
], ],
), ),
@ -265,16 +458,65 @@ class _AttemptResultScreenState extends State<AttemptResultScreen> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
..._result!.allResults.map((answerResult) { ..._result!.allResults.map((answerResult) {
final currentQ = _currentQuestionInDeck(answerResult.question.id);
final isFlagged = currentQ?.isFlagged ?? false;
return Card( return Card(
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
child: ListTile( child: Padding(
title: Text(answerResult.question.prompt), padding: const EdgeInsets.all(16),
subtitle: Text( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
answerResult.question.prompt,
style: Theme.of(context).textTheme.titleMedium,
overflow: TextOverflow.ellipsis,
maxLines: 3,
),
const SizedBox(height: 4),
Text(
answerResult.isCorrect answerResult.isCorrect
? 'Correct' ? 'Correct'
: answerResult.userAnswerIndices.isEmpty
? 'Incorrect - No answer'
: 'Incorrect - Selected: ${answerResult.userAnswerIndices.map((idx) => answerResult.question.answers[idx]).join(', ')}', : 'Incorrect - Selected: ${answerResult.userAnswerIndices.map((idx) => answerResult.question.answers[idx]).join(', ')}',
style: Theme.of(context).textTheme.bodyMedium,
overflow: TextOverflow.ellipsis,
maxLines: 2,
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
StatusChip(statusChange: answerResult.statusChange),
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: () => _showQuestionDetail(answerResult),
icon: const Icon(Icons.visibility, size: 22),
tooltip: 'View question details',
),
IconButton(
onPressed: () => _markNeedsPractice(answerResult.question.id),
icon: const Icon(Icons.replay, size: 22),
tooltip: 'Mark as needs practice',
),
IconButton(
onPressed: () => _toggleFlag(answerResult.question.id),
icon: Icon(
isFlagged ? Icons.flag : Icons.outlined_flag,
color: isFlagged ? Colors.red : null,
size: 22,
),
tooltip: isFlagged ? 'Unflag' : 'Flag for review',
),
],
),
],
),
],
), ),
trailing: StatusChip(statusChange: answerResult.statusChange),
), ),
); );
}), }),

@ -101,6 +101,8 @@ class _AttemptScreenState extends State<AttemptScreen> {
bool? includeKnown; bool? includeKnown;
bool? resumeAttempt; bool? resumeAttempt;
final repeatAttempt = args is Map<String, dynamic> ? args['repeatAttempt'] as Attempt? : null;
if (args is Map<String, dynamic>) { if (args is Map<String, dynamic>) {
_deck = args['deck'] as Deck? ?? _createSampleDeck(); _deck = args['deck'] as Deck? ?? _createSampleDeck();
includeKnown = args['includeKnown'] as bool?; includeKnown = args['includeKnown'] as bool?;
@ -113,8 +115,35 @@ class _AttemptScreenState extends State<AttemptScreen> {
resumeAttempt = false; resumeAttempt = false;
} }
// Check if we should resume an incomplete attempt // Check if we should repeat the exact same attempt (same questions, same order)
if (resumeAttempt == true && _deck!.incompleteAttempt != null) { if (repeatAttempt != null && repeatAttempt.questions.isNotEmpty) {
final orderedIds = repeatAttempt.questions.map((q) => q.id).toList();
final questions = <Question>[];
for (final id in orderedIds) {
final q = _deck!.questions.where((q) => q.id == id).firstOrNull;
if (q != null) questions.add(q);
}
if (questions.isNotEmpty) {
_attempt = Attempt(
id: 'repeat-${DateTime.now().millisecondsSinceEpoch}',
questions: questions,
startTime: DateTime.now().millisecondsSinceEpoch,
);
// Same as new attempt: randomize answer order per question when config enables it
_answerOrderPerQuestion = {};
final shuffleAnswers = _deck!.config.shuffleAnswerOrder;
for (final q in _attempt!.questions) {
final order = List.generate(q.answers.length, (i) => i);
if (shuffleAnswers) {
order.shuffle(math.Random());
}
_answerOrderPerQuestion[q.id] = order;
}
}
}
// If not repeating, check if we should resume an incomplete attempt
if (_attempt == null && resumeAttempt == true && _deck!.incompleteAttempt != null) {
final incomplete = _deck!.incompleteAttempt!; final incomplete = _deck!.incompleteAttempt!;
_attempt = incomplete.toAttempt(_deck!.questions); _attempt = incomplete.toAttempt(_deck!.questions);
_currentQuestionIndex = incomplete.currentQuestionIndex; _currentQuestionIndex = incomplete.currentQuestionIndex;
@ -133,7 +162,10 @@ class _AttemptScreenState extends State<AttemptScreen> {
_pageController.jumpToPage(_currentQuestionIndex); _pageController.jumpToPage(_currentQuestionIndex);
} }
}); });
} else { }
// Otherwise create a new attempt
if (_attempt == null) {
_attempt = _attemptService!.createAttempt( _attempt = _attemptService!.createAttempt(
deck: _deck!, deck: _deck!,
includeKnown: includeKnown, includeKnown: includeKnown,

@ -0,0 +1,312 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import '../routes.dart';
import '../services/api_auth_service.dart';
import '../services/deck_storage.dart';
import '../services/remote_deck_service.dart';
import '../utils/connection_error.dart';
import '../utils/top_snackbar.dart';
String _apiErrorMessage(RemoteDeckException e) {
if (e.statusCode == 401 || e.statusCode == 403) {
return 'Session expired. Please log in again.';
}
if (e.statusCode >= 500) {
return 'Connection to server has broken. Check API URL and network.';
}
try {
final map = jsonDecode(e.body) as Map<String, dynamic>?;
final msg = map?['error'] as String? ?? map?['message'] as String?;
if (msg != null && msg.isNotEmpty) return msg;
} catch (_) {}
return 'Failed to load community decks.';
}
class CommunityDecksScreen extends StatefulWidget {
const CommunityDecksScreen({super.key, this.showAppBar = true});
final bool showAppBar;
@override
State<CommunityDecksScreen> createState() => CommunityDecksScreenState();
}
class CommunityDecksScreenState extends State<CommunityDecksScreen> {
List<RemoteDeckListItem> _decks = [];
bool _loading = true;
String? _error;
final DeckStorage _storage = DeckStorage();
final RemoteDeckService _remote = RemoteDeckService.instance;
@override
void initState() {
super.initState();
_load();
}
void refresh() {
_load();
}
/// Published deck ids (community list id) that we already have in My decks:
/// - copied_from_deck_id when we added from Community, or
/// - server_deck_id when we own/synced that deck (e.g. our own published deck).
Future<Set<String>> _localPublishedDeckIdsWeHave() async {
await _storage.initialize();
final allDecks = await _storage.getAllDecks();
final ids = <String>{};
for (final deck in allDecks) {
final sync = _storage.getDeckSyncMetadataSync(deck.id);
if (sync == null) continue;
final serverId = sync['server_deck_id']?.toString().trim();
if (serverId != null && serverId.isNotEmpty) ids.add(serverId);
final fromId = sync['copied_from_deck_id']?.toString().trim();
if (fromId != null && fromId.isNotEmpty) ids.add(fromId);
}
return ids;
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
try {
await _storage.initialize();
if (!mounted) return;
final list = await _remote.getPublishedDecks();
if (!mounted) return;
final localCopyIds = await _localPublishedDeckIdsWeHave();
if (!mounted) return;
final merged = list.map((d) {
final publishedId = d.id.toString().trim();
final inMyDecks = publishedId.isNotEmpty && localCopyIds.contains(publishedId);
return RemoteDeckListItem(
id: d.id,
title: d.title,
description: d.description,
questionCount: d.questionCount,
userHasThis: inMyDecks,
needsUpdate: d.needsUpdate,
copiedFromDeckId: d.copiedFromDeckId,
ownerDisplayName: d.ownerDisplayName,
averageRating: d.averageRating,
ratingCount: d.ratingCount,
);
}).toList();
if (mounted) {
setState(() {
_decks = merged;
_loading = false;
_error = null;
});
}
} on RemoteDeckException catch (e) {
if (mounted) {
setState(() {
_loading = false;
_error = _apiErrorMessage(e);
});
}
} catch (e) {
if (mounted) {
setState(() {
_loading = false;
_error = connectionErrorMessage(e);
});
}
}
}
Future<void> _addToMyDecks(RemoteDeckListItem item) async {
if (ApiAuthService.instance.currentUser.value == null) {
Navigator.pushNamed(context, Routes.login).then((_) => _load());
return;
}
try {
final newId = await _remote.copyDeck(item.id);
final deck = await _remote.getDeck(newId);
final syncMetadata = {
'server_deck_id': newId,
'owner_id': ApiAuthService.instance.currentUser.value!.id,
'copied_from_deck_id': item.id,
'copied_from_version': null,
'published': false,
'needs_update': false,
};
_storage.saveDeckSync(deck, syncMetadata: syncMetadata);
if (mounted) {
showTopSnackBar(
context,
message: 'Added "${deck.title}" to your decks',
backgroundColor: Colors.green,
);
_load();
}
} on RemoteDeckException catch (e) {
if (mounted) {
showTopSnackBar(
context,
message: e.statusCode == 401
? 'Session expired. Please log in again.'
: 'Could not add deck.',
backgroundColor: Colors.red,
);
}
} catch (e) {
if (mounted) {
showTopSnackBar(
context,
message: connectionErrorMessage(e),
backgroundColor: Colors.red,
);
}
}
}
@override
Widget build(BuildContext context) {
final user = ApiAuthService.instance.currentUser.value;
final body = _loading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(_error!, textAlign: TextAlign.center),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FilledButton(
onPressed: _load,
child: const Text('Retry'),
),
if (_error!.contains('Session expired')) ...[
const SizedBox(width: 12),
FilledButton.tonal(
onPressed: () async {
await ApiAuthService.instance.logout();
if (!mounted) return;
Navigator.pushNamed(context, Routes.login)
.then((_) => _load());
},
child: const Text('Log in again'),
),
],
],
),
],
),
),
)
: _decks.isEmpty
? Center(
child: Text(
'No published decks yet.',
style: Theme.of(context).textTheme.bodyLarge,
),
)
: RefreshIndicator(
onRefresh: _load,
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _decks.length,
itemBuilder: (context, index) {
final d = _decks[index];
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
title: Text(d.title),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (d.description.isNotEmpty)
Text(
d.description,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 6),
Row(
children: [
Icon(
Icons.person_outline,
size: 14,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
d.ownerDisplayName != null && d.ownerDisplayName!.isNotEmpty
? d.ownerDisplayName!
: 'Unknown',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 2),
Row(
children: [
Text(
'${d.questionCount} questions',
style: Theme.of(context).textTheme.bodySmall,
),
if (d.averageRating != null) ...[
const SizedBox(width: 12),
Icon(
Icons.star,
size: 14,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 2),
Text(
d.ratingCount != null && d.ratingCount! > 0
? '${d.averageRating!.toStringAsFixed(1)} (${d.ratingCount})'
: d.averageRating!.toStringAsFixed(1),
style: Theme.of(context).textTheme.bodySmall,
),
],
],
),
],
),
isThreeLine: true,
trailing: d.userHasThis
? const Chip(
label: Text('In my decks'),
padding: EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
)
: user == null
? FilledButton.tonal(
onPressed: () => Navigator.pushNamed(
context,
Routes.login,
).then((_) => _load()),
child: const Text('Log in to add'),
)
: FilledButton.tonal(
onPressed: () => _addToMyDecks(d),
child: const Text('Add'),
),
),
);
},
),
);
if (widget.showAppBar) {
return Scaffold(appBar: AppBar(title: const Text('Community decks')), body: body);
}
return body;
}
}

@ -1,4 +1,7 @@
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../utils/top_snackbar.dart'; import '../utils/top_snackbar.dart';
import 'package:practice_engine/practice_engine.dart'; import 'package:practice_engine/practice_engine.dart';
import '../services/deck_storage.dart'; import '../services/deck_storage.dart';
@ -135,8 +138,10 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
super.dispose(); super.dispose();
} }
void _save() { /// Builds config from current form state, validates, persists if valid.
if (_deck == null || _config == null) return; /// Returns true if saved, false if validation failed.
bool _applyAndPersist() {
if (_deck == null || _config == null) return false;
final consecutive = int.tryParse(_consecutiveController.text); final consecutive = int.tryParse(_consecutiveController.text);
final attemptSize = int.tryParse(_attemptSizeController.text); final attemptSize = int.tryParse(_attemptSizeController.text);
@ -153,7 +158,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
backgroundColor: Colors.red, backgroundColor: Colors.red,
), ),
); );
return; return false;
} }
if (consecutive < 1 || if (consecutive < 1 ||
@ -166,7 +171,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
backgroundColor: Colors.red, backgroundColor: Colors.red,
), ),
); );
return; return false;
} }
// Calculate time limit in seconds // Calculate time limit in seconds
@ -186,7 +191,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
backgroundColor: Colors.red, backgroundColor: Colors.red,
), ),
); );
return; return false;
} }
} }
@ -203,21 +208,78 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
); );
final updatedDeck = _deck!.copyWith(config: updatedConfig); final updatedDeck = _deck!.copyWith(config: updatedConfig);
final deckStorage = DeckStorage();
deckStorage.saveDeckSync(updatedDeck);
// Show success message setState(() {
_deck = updatedDeck;
_config = updatedConfig;
_configHashCode = updatedConfig.hashCode;
});
return true;
}
Map<String, dynamic> _configToJsonMap() {
int? timeLimitSeconds;
if (_timeLimitEnabled) {
final hours = int.tryParse(_timeLimitHoursController.text) ?? 0;
final minutes = int.tryParse(_timeLimitMinutesController.text) ?? 0;
final seconds = int.tryParse(_timeLimitSecondsController.text) ?? 0;
timeLimitSeconds = hours * 3600 + minutes * 60 + seconds;
if (timeLimitSeconds == 0) timeLimitSeconds = null;
}
return {
'requiredConsecutiveCorrect': int.tryParse(_consecutiveController.text) ?? _config!.requiredConsecutiveCorrect,
'defaultAttemptSize': int.tryParse(_attemptSizeController.text) ?? _config!.defaultAttemptSize,
'priorityIncreaseOnIncorrect': int.tryParse(_priorityIncreaseController.text) ?? _config!.priorityIncreaseOnIncorrect,
'priorityDecreaseOnCorrect': int.tryParse(_priorityDecreaseController.text) ?? _config!.priorityDecreaseOnCorrect,
'immediateFeedbackEnabled': _immediateFeedback,
'includeKnownInAttempts': _includeKnownInAttempts,
'shuffleAnswerOrder': _shuffleAnswerOrder,
'excludeFlaggedQuestions': _excludeFlaggedQuestions,
if (timeLimitSeconds != null) 'timeLimitSeconds': timeLimitSeconds,
};
}
void _showAsJson() {
if (_deck == null || _config == null) return;
final map = _configToJsonMap();
final jsonString = const JsonEncoder.withIndent(' ').convert(map);
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Attempt Settings as JSON'),
content: SingleChildScrollView(
child: SelectableText(
jsonString,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontFamily: 'monospace',
fontSize: 12,
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
FilledButton.icon(
onPressed: () {
Clipboard.setData(ClipboardData(text: jsonString));
Navigator.pop(context);
showTopSnackBar( showTopSnackBar(
context, context,
message: 'Deck configuration saved successfully', message: 'JSON copied to clipboard',
backgroundColor: Colors.green, backgroundColor: Colors.green,
duration: const Duration(seconds: 1), duration: const Duration(seconds: 1),
); );
},
// Pop with the updated deck icon: const Icon(Icons.copy, size: 20),
Navigator.pop(context, updatedDeck); label: const Text('Copy'),
} ),
],
void _cancel() { ),
Navigator.pop(context); );
} }
void _editDeck() { void _editDeck() {
@ -305,9 +367,30 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
); );
} }
return Scaffold( return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) {
if (didPop) return;
_applyAndPersist();
if (mounted) Navigator.pop(context, _deck);
},
child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Deck Configuration'), title: const Text('Attempt Settings'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
_applyAndPersist();
if (mounted) Navigator.pop(context, _deck);
},
),
actions: [
IconButton(
icon: const Icon(Icons.code),
onPressed: _showAsJson,
tooltip: 'Show as JSON',
),
],
), ),
body: SingleChildScrollView( body: SingleChildScrollView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@ -335,6 +418,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
onEditingComplete: _applyAndPersist,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@ -347,6 +431,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
onEditingComplete: _applyAndPersist,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@ -359,6 +444,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
onEditingComplete: _applyAndPersist,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@ -371,6 +457,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
onEditingComplete: _applyAndPersist,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@ -385,6 +472,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
setState(() { setState(() {
_immediateFeedback = value; _immediateFeedback = value;
}); });
_applyAndPersist();
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@ -400,6 +488,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
setState(() { setState(() {
_includeKnownInAttempts = value; _includeKnownInAttempts = value;
}); });
_applyAndPersist();
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@ -415,6 +504,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
setState(() { setState(() {
_shuffleAnswerOrder = value; _shuffleAnswerOrder = value;
}); });
_applyAndPersist();
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@ -430,6 +520,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
setState(() { setState(() {
_excludeFlaggedQuestions = value; _excludeFlaggedQuestions = value;
}); });
_applyAndPersist();
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@ -450,6 +541,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
_timeLimitSecondsController.clear(); _timeLimitSecondsController.clear();
} }
}); });
_applyAndPersist();
}, },
), ),
if (_timeLimitEnabled) ...[ if (_timeLimitEnabled) ...[
@ -464,6 +556,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
onEditingComplete: _applyAndPersist,
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@ -475,6 +568,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
onEditingComplete: _applyAndPersist,
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@ -486,6 +580,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
onEditingComplete: _applyAndPersist,
), ),
), ),
], ],
@ -579,27 +674,8 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
), ),
), ),
), ),
const SizedBox(height: 24),
// Action Buttons
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _cancel,
child: const Text('Cancel'),
),
),
const SizedBox(width: 16),
Expanded(
child: FilledButton(
onPressed: _save,
child: const Text('Save'),
),
),
], ],
), ),
],
), ),
), ),
); );

@ -1,4 +1,7 @@
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../utils/top_snackbar.dart'; import '../utils/top_snackbar.dart';
import 'package:practice_engine/practice_engine.dart'; import 'package:practice_engine/practice_engine.dart';
import '../services/deck_storage.dart'; import '../services/deck_storage.dart';
@ -16,6 +19,8 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
late TextEditingController _descriptionController; late TextEditingController _descriptionController;
final List<QuestionEditor> _questionEditors = []; final List<QuestionEditor> _questionEditors = [];
final DeckStorage _deckStorage = DeckStorage(); final DeckStorage _deckStorage = DeckStorage();
final ScrollController _scrollController = ScrollController();
int? _focusNewQuestionIndex;
@override @override
void initState() { void initState() {
@ -48,6 +53,7 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
@override @override
void dispose() { void dispose() {
_scrollController.dispose();
_titleController.dispose(); _titleController.dispose();
_descriptionController.dispose(); _descriptionController.dispose();
for (final editor in _questionEditors) { for (final editor in _questionEditors) {
@ -59,6 +65,21 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
void _addQuestion() { void _addQuestion() {
setState(() { setState(() {
_questionEditors.add(QuestionEditor.empty()); _questionEditors.add(QuestionEditor.empty());
_focusNewQuestionIndex = _questionEditors.length - 1;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final pos = _scrollController.position;
if (pos.maxScrollExtent > pos.pixels) {
_scrollController.animateTo(
pos.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
Future.microtask(() {
if (mounted) setState(() => _focusNewQuestionIndex = null);
});
}); });
} }
@ -131,6 +152,28 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
Navigator.pop(context, updatedDeck); Navigator.pop(context, updatedDeck);
} }
void _copyQuestionsJson() {
if (_questionEditors.isEmpty) {
showTopSnackBar(context, message: 'No questions to copy');
return;
}
final questions = _questionEditors.map((e) => e.toQuestion()).toList();
final list = questions.map((q) => {
'id': q.id,
'prompt': q.prompt,
if (q.explanation != null && q.explanation!.isNotEmpty) 'explanation': q.explanation,
'answers': q.answers,
'correctAnswerIndices': q.correctAnswerIndices,
}).toList();
final jsonString = const JsonEncoder.withIndent(' ').convert(list);
Clipboard.setData(ClipboardData(text: jsonString));
showTopSnackBar(
context,
message: '${questions.length} question(s) copied as JSON',
backgroundColor: Colors.green,
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_deck == null) { if (_deck == null) {
@ -143,6 +186,11 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
appBar: AppBar( appBar: AppBar(
title: const Text('Edit Deck'), title: const Text('Edit Deck'),
actions: [ actions: [
IconButton(
icon: const Icon(Icons.copy),
onPressed: _copyQuestionsJson,
tooltip: 'Copy questions as JSON',
),
IconButton( IconButton(
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
onPressed: _addQuestion, onPressed: _addQuestion,
@ -155,11 +203,13 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
), ),
], ],
), ),
body: SingleChildScrollView( body: CustomScrollView(
controller: _scrollController,
slivers: [
SliverPadding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( sliver: SliverList(
crossAxisAlignment: CrossAxisAlignment.stretch, delegate: SliverChildListDelegate([
children: [
// Deck Title // Deck Title
TextField( TextField(
controller: _titleController, controller: _titleController,
@ -169,7 +219,6 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Deck Description // Deck Description
TextField( TextField(
controller: _descriptionController, controller: _descriptionController,
@ -180,28 +229,19 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
maxLines: 3, maxLines: 3,
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// Questions Section
Text( Text(
'Questions', 'Questions',
style: Theme.of(context).textTheme.titleLarge, style: Theme.of(context).textTheme.titleLarge,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
]),
// Questions List ),
...List.generate(_questionEditors.length, (index) { ),
return QuestionEditorCard(
key: ValueKey('question_$index'),
editor: _questionEditors[index],
questionNumber: index + 1,
onDelete: () => _removeQuestion(index),
onUnflag: null,
onChanged: () => setState(() {}),
);
}),
if (_questionEditors.isEmpty) if (_questionEditors.isEmpty)
Card( SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverToBoxAdapter(
child: Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(32), padding: const EdgeInsets.all(32),
child: Center( child: Center(
@ -229,9 +269,29 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
), ),
), ),
), ),
],
), ),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverList.builder(
itemCount: _questionEditors.length,
itemBuilder: (context, index) {
return RepaintBoundary(
child: QuestionEditorCard(
key: ValueKey('question_$index'),
editor: _questionEditors[index],
questionNumber: index + 1,
onDelete: () => _removeQuestion(index),
onUnflag: null,
onChanged: () => setState(() {}),
requestFocusOnPrompt: _focusNewQuestionIndex == index,
),
);
},
),
),
],
), ),
); );
} }
@ -243,6 +303,7 @@ class QuestionEditorCard extends StatefulWidget {
final VoidCallback? onDelete; final VoidCallback? onDelete;
final VoidCallback? onUnflag; final VoidCallback? onUnflag;
final VoidCallback onChanged; final VoidCallback onChanged;
final bool requestFocusOnPrompt;
const QuestionEditorCard({ const QuestionEditorCard({
super.key, super.key,
@ -251,6 +312,7 @@ class QuestionEditorCard extends StatefulWidget {
this.onDelete, this.onDelete,
this.onUnflag, this.onUnflag,
required this.onChanged, required this.onChanged,
this.requestFocusOnPrompt = false,
}); });
@override @override
@ -258,8 +320,31 @@ class QuestionEditorCard extends StatefulWidget {
} }
class _QuestionEditorCardState extends State<QuestionEditorCard> { class _QuestionEditorCardState extends State<QuestionEditorCard> {
final FocusNode _promptFocusNode = FocusNode();
@override
void dispose() {
_promptFocusNode.dispose();
super.dispose();
}
@override
void didUpdateWidget(QuestionEditorCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.requestFocusOnPrompt && !oldWidget.requestFocusOnPrompt) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _promptFocusNode.requestFocus();
});
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (widget.requestFocusOnPrompt) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _promptFocusNode.requestFocus();
});
}
return Card( return Card(
margin: const EdgeInsets.only(bottom: 16), margin: const EdgeInsets.only(bottom: 16),
child: Padding( child: Padding(
@ -297,12 +382,23 @@ class _QuestionEditorCardState extends State<QuestionEditorCard> {
// Question Prompt // Question Prompt
TextField( TextField(
controller: widget.editor.promptController, controller: widget.editor.promptController,
focusNode: _promptFocusNode,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Question', labelText: 'Question',
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
maxLines: 2, maxLines: 2,
), ),
const SizedBox(height: 12),
TextField(
controller: widget.editor.explanationController,
decoration: const InputDecoration(
labelText: 'Explanation (optional)',
helperText: 'Shown when viewing question details after an attempt',
border: OutlineInputBorder(),
),
maxLines: 3,
),
const SizedBox(height: 16), const SizedBox(height: 16),
// Answers // Answers
@ -399,12 +495,14 @@ class _QuestionEditorCardState extends State<QuestionEditorCard> {
class QuestionEditor { class QuestionEditor {
final TextEditingController promptController; final TextEditingController promptController;
final TextEditingController explanationController;
final List<TextEditingController> answerControllers; final List<TextEditingController> answerControllers;
Set<int> correctAnswerIndices; Set<int> correctAnswerIndices;
final String? originalId; final String? originalId;
QuestionEditor({ QuestionEditor({
required this.promptController, required this.promptController,
required this.explanationController,
required this.answerControllers, required this.answerControllers,
Set<int>? correctAnswerIndices, Set<int>? correctAnswerIndices,
this.originalId, this.originalId,
@ -413,6 +511,7 @@ class QuestionEditor {
factory QuestionEditor.fromQuestion(Question question) { factory QuestionEditor.fromQuestion(Question question) {
return QuestionEditor( return QuestionEditor(
promptController: TextEditingController(text: question.prompt), promptController: TextEditingController(text: question.prompt),
explanationController: TextEditingController(text: question.explanation ?? ''),
answerControllers: question.answers answerControllers: question.answers
.map((answer) => TextEditingController(text: answer)) .map((answer) => TextEditingController(text: answer))
.toList(), .toList(),
@ -424,6 +523,7 @@ class QuestionEditor {
factory QuestionEditor.empty() { factory QuestionEditor.empty() {
return QuestionEditor( return QuestionEditor(
promptController: TextEditingController(), promptController: TextEditingController(),
explanationController: TextEditingController(),
answerControllers: [ answerControllers: [
TextEditingController(), TextEditingController(),
TextEditingController(), TextEditingController(),
@ -446,9 +546,11 @@ class QuestionEditor {
} }
Question toQuestion() { Question toQuestion() {
final explanationText = explanationController.text.trim();
return Question( return Question(
id: originalId ?? DateTime.now().millisecondsSinceEpoch.toString(), id: originalId ?? DateTime.now().millisecondsSinceEpoch.toString(),
prompt: promptController.text.trim(), prompt: promptController.text.trim(),
explanation: explanationText.isEmpty ? null : explanationText,
answers: answerControllers.map((c) => c.text.trim()).toList(), answers: answerControllers.map((c) => c.text.trim()).toList(),
correctAnswerIndices: correctAnswerIndices.toList()..sort(), correctAnswerIndices: correctAnswerIndices.toList()..sort(),
); );
@ -456,6 +558,7 @@ class QuestionEditor {
void dispose() { void dispose() {
promptController.dispose(); promptController.dispose();
explanationController.dispose();
for (final controller in answerControllers) { for (final controller in answerControllers) {
controller.dispose(); controller.dispose();
} }

@ -64,6 +64,7 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
return Question( return Question(
id: questionMap['id'] as String? ?? '', id: questionMap['id'] as String? ?? '',
prompt: questionMap['prompt'] as String? ?? '', prompt: questionMap['prompt'] as String? ?? '',
explanation: questionMap['explanation'] as String?,
answers: (questionMap['answers'] as List<dynamic>?) answers: (questionMap['answers'] as List<dynamic>?)
?.map((e) => e.toString()) ?.map((e) => e.toString())
.toList() ?? .toList() ??
@ -118,7 +119,96 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
final deckStorage = DeckStorage(); final deckStorage = DeckStorage();
deckStorage.saveDeckSync(deck); deckStorage.saveDeckSync(deck);
// Navigate back to deck list showTopSnackBar(
context,
message: 'Deck created successfully',
backgroundColor: Colors.green,
);
Navigator.pop(context);
} catch (e) {
setState(() {
_errorMessage = e.toString();
_isLoading = false;
});
}
}
void _mergeWithExistingDeck() async {
setState(() {
_errorMessage = null;
_isLoading = true;
});
try {
if (_jsonController.text.trim().isEmpty) {
throw FormatException('Please enter JSON data');
}
final parsedDeck = _parseDeckFromJson(_jsonController.text.trim());
if (parsedDeck == null || parsedDeck.questions.isEmpty) {
throw FormatException(
parsedDeck == null
? 'Failed to parse deck'
: 'JSON deck must contain at least one question to merge',
);
}
final deckToMerge = parsedDeck;
final deckStorage = DeckStorage();
final existingDecks = deckStorage.getAllDecksSync();
if (existingDecks.isEmpty) {
throw FormatException('No existing decks to merge into. Create or import a deck first.');
}
setState(() => _isLoading = false);
final selectedDeck = await showDialog<Deck>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Merge with existing deck'),
content: SizedBox(
width: double.maxFinite,
child: ListView.builder(
shrinkWrap: true,
itemCount: existingDecks.length,
itemBuilder: (context, index) {
final deck = existingDecks[index];
return ListTile(
title: Text(deck.title),
subtitle: Text('${deck.questions.length} questions'),
onTap: () => Navigator.pop(context, deck),
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
],
),
);
if (selectedDeck == null || !mounted) return;
final timestamp = DateTime.now().millisecondsSinceEpoch;
final mergedQuestions = [
...selectedDeck.questions,
...deckToMerge.questions.asMap().entries.map((e) {
final q = e.value;
final i = e.key;
return q.copyWith(id: '${q.id}_merged_${timestamp}_$i');
}),
];
final mergedDeck = selectedDeck.copyWith(questions: mergedQuestions);
deckStorage.saveDeckSync(mergedDeck);
showTopSnackBar(
context,
message: 'Added ${deckToMerge.questions.length} question(s) to "${selectedDeck.title}"',
backgroundColor: Colors.green,
);
Navigator.pop(context); Navigator.pop(context);
} catch (e) { } catch (e) {
setState(() { setState(() {
@ -258,6 +348,7 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
'prompt': 'Sample Question $i?', 'prompt': 'Sample Question $i?',
'answers': ['Answer A', 'Answer B', 'Answer C', 'Answer D'], 'answers': ['Answer A', 'Answer B', 'Answer C', 'Answer D'],
'correctAnswerIndices': [i % 4], 'correctAnswerIndices': [i % 4],
if (i == 0) 'explanation': 'Optional explanation shown when viewing question details after an attempt.',
'isKnown': i < 5, 'isKnown': i < 5,
}; };
}), }),
@ -391,12 +482,12 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'Import Deck from JSON', 'Import from JSON',
style: Theme.of(context).textTheme.titleLarge, style: Theme.of(context).textTheme.titleLarge,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Paste your deck JSON below. The format should include id, title, description, config, and questions.', 'Paste your deck JSON below. Create a new deck or merge questions into an existing one.',
style: Theme.of(context).textTheme.bodyMedium, style: Theme.of(context).textTheme.bodyMedium,
), ),
], ],
@ -412,24 +503,18 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Row( Wrap(
mainAxisAlignment: MainAxisAlignment.spaceBetween, spacing: 8,
children: [ runSpacing: 4,
Text(
'Deck JSON',
style: Theme.of(context).textTheme.titleMedium,
),
Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
TextButton.icon( TextButton.icon(
onPressed: _pickJsonFile, onPressed: _pickJsonFile,
icon: const Icon(Icons.folder_open), icon: const Icon(Icons.folder_open, size: 20),
label: const Text('Select file'), label: const Text('Select file'),
), ),
TextButton.icon( TextButton.icon(
onPressed: _loadSampleDeck, onPressed: _loadSampleDeck,
icon: const Icon(Icons.description), icon: const Icon(Icons.description, size: 20),
label: const Text('Load Sample'), label: const Text('Load Sample'),
), ),
IconButton( IconButton(
@ -439,8 +524,6 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
), ),
], ],
), ),
],
),
const SizedBox(height: 8), const SizedBox(height: 8),
TextField( TextField(
controller: _jsonController, controller: _jsonController,
@ -484,8 +567,28 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
if (_errorMessage != null) const SizedBox(height: 16), if (_errorMessage != null) const SizedBox(height: 16),
// Import Button // Import and Merge Buttons
FilledButton.icon( Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _isLoading ? null : _mergeWithExistingDeck,
icon: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.merge_type, size: 20),
label: Text(_isLoading ? '...' : 'Merge with deck'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.icon(
onPressed: _isLoading ? null : _importDeck, onPressed: _isLoading ? null : _importDeck,
icon: _isLoading icon: _isLoading
? const SizedBox( ? const SizedBox(
@ -493,12 +596,15 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
height: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
) )
: const Icon(Icons.upload), : const Icon(Icons.add_circle_outline, size: 20),
label: Text(_isLoading ? 'Importing...' : 'Import Deck'), label: Text(_isLoading ? 'Importing...' : 'Import and create deck'),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 16),
), ),
), ),
),
],
),
const SizedBox(height: 16), const SizedBox(height: 16),
@ -531,6 +637,7 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
const Text('• answers: array of strings (required)'), const Text('• answers: array of strings (required)'),
const Text('• correctAnswerIndex: number (deprecated, use correctAnswerIndices)'), const Text('• correctAnswerIndex: number (deprecated, use correctAnswerIndices)'),
const Text('• correctAnswerIndices: array of numbers (for multiple correct answers)'), const Text('• correctAnswerIndices: array of numbers (for multiple correct answers)'),
const Text('• explanation: string (optional, shown when viewing question details)'),
const Text('• isKnown: boolean (optional)'), const Text('• isKnown: boolean (optional)'),
const Text('• consecutiveCorrect: number (optional)'), const Text('• consecutiveCorrect: number (optional)'),
const Text('• priorityPoints: number (optional)'), const Text('• priorityPoints: number (optional)'),

@ -1,18 +1,22 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:practice_engine/practice_engine.dart'; import 'package:practice_engine/practice_engine.dart';
import '../routes.dart'; import '../routes.dart';
import '../services/api_auth_service.dart';
import '../services/deck_storage.dart'; import '../services/deck_storage.dart';
import '../services/remote_deck_service.dart';
import '../data/default_deck.dart'; import '../data/default_deck.dart';
import '../utils/top_snackbar.dart'; import '../utils/top_snackbar.dart';
class DeckListScreen extends StatefulWidget { class DeckListScreen extends StatefulWidget {
const DeckListScreen({super.key}); const DeckListScreen({super.key, this.showAppBar = true});
final bool showAppBar;
@override @override
State<DeckListScreen> createState() => _DeckListScreenState(); State<DeckListScreen> createState() => DeckListScreenState();
} }
class _DeckListScreenState extends State<DeckListScreen> { class DeckListScreenState extends State<DeckListScreen> {
final DeckStorage _deckStorage = DeckStorage(); final DeckStorage _deckStorage = DeckStorage();
List<Deck> _decks = []; List<Deck> _decks = [];
@ -20,21 +24,75 @@ class _DeckListScreenState extends State<DeckListScreen> {
void initState() { void initState() {
super.initState(); super.initState();
_loadDecks(); _loadDecks();
ApiAuthService.instance.currentUser.addListener(_onAuthChanged);
}
void _onAuthChanged() {
if (mounted) setState(() {});
}
@override
void dispose() {
ApiAuthService.instance.currentUser.removeListener(_onAuthChanged);
super.dispose();
}
/// Call from parent (e.g. when switching to My Decks tab) to refresh the list.
void refresh() {
_loadDecks();
} }
void _loadDecks() { void _loadDecks() {
// Use sync version for immediate UI update, will work once storage is initialized
setState(() { setState(() {
_decks = _deckStorage.getAllDecksSync(); _decks = _deckStorage.getAllDecksSync();
}); });
// Also trigger async load to ensure we have latest data _loadDecksAsync();
_deckStorage.getAllDecks().then((decks) { }
if (mounted) {
setState(() { /// Server deck ids we already have locally (by deck.id or sync.server_deck_id).
_decks = decks; Set<String> _localServerDeckIds(List<Deck> fromStorage) {
}); final ids = <String>{};
for (final deck in fromStorage) {
ids.add(deck.id);
final sync = _deckStorage.getDeckSyncMetadataSync(deck.id);
final serverId = sync?['server_deck_id']?.toString().trim();
if (serverId != null && serverId.isNotEmpty) ids.add(serverId);
}
return ids;
}
/// Load from local storage, then if logged in fetch "my decks" from server and add any missing ones locally.
Future<void> _loadDecksAsync() async {
await _deckStorage.initialize();
if (!mounted) return;
var fromStorage = await _deckStorage.getAllDecks();
if (!mounted) return;
setState(() => _decks = fromStorage);
final user = ApiAuthService.instance.currentUser.value;
if (user == null) return;
try {
final myDecks = await RemoteDeckService.instance.getMyDecks();
final haveIds = _localServerDeckIds(fromStorage);
for (final item in myDecks) {
final serverId = item.id.toString().trim();
if (serverId.isEmpty || haveIds.contains(serverId)) continue;
final deck = await RemoteDeckService.instance.getDeck(serverId);
final syncMetadata = {
'server_deck_id': serverId,
'owner_id': user.id,
'copied_from_deck_id': item.copiedFromDeckId,
'published': false,
'needs_update': item.needsUpdate,
};
_deckStorage.saveDeckSync(deck, syncMetadata: syncMetadata);
}
final updated = await _deckStorage.getAllDecks();
if (mounted) setState(() => _decks = updated);
} catch (_) {
// Keep showing local decks if server unreachable
} }
});
} }
void _openDeck(Deck deck) { void _openDeck(Deck deck) {
@ -70,7 +128,8 @@ class _DeckListScreenState extends State<DeckListScreen> {
), ),
); );
if (confirmed == true) { if (confirmed != true || !mounted) return;
_deckStorage.deleteDeckSync(deck.id); _deckStorage.deleteDeckSync(deck.id);
_loadDecks(); _loadDecks();
if (mounted) { if (mounted) {
@ -81,6 +140,21 @@ class _DeckListScreenState extends State<DeckListScreen> {
); );
} }
} }
static String _userInitials(ApiUser user) {
final name = user.displayName?.trim();
if (name != null && name.isNotEmpty) {
final parts = name.split(RegExp(r'\s+'));
if (parts.length >= 2) {
return '${parts[0][0]}${parts[1][0]}'.toUpperCase();
}
return name.substring(0, name.length.clamp(0, 2)).toUpperCase();
}
final email = user.email?.trim();
if (email != null && email.isNotEmpty) {
return email[0].toUpperCase();
}
return '?';
} }
void _navigateToImport() { void _navigateToImport() {
@ -102,6 +176,68 @@ class _DeckListScreenState extends State<DeckListScreen> {
}); });
} }
void _mergeDeck(Deck sourceDeck) async {
final otherDecks = _decks.where((d) => d.id != sourceDeck.id).toList();
if (otherDecks.isEmpty) {
showTopSnackBar(
context,
message: 'No other deck to merge with',
backgroundColor: Colors.orange,
);
return;
}
final targetDeck = await showDialog<Deck>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Merge with deck'),
content: SizedBox(
width: double.maxFinite,
child: ListView.builder(
shrinkWrap: true,
itemCount: otherDecks.length,
itemBuilder: (context, index) {
final deck = otherDecks[index];
return ListTile(
title: Text(deck.title),
subtitle: Text('${deck.questions.length} questions'),
onTap: () => Navigator.pop(context, deck),
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
],
),
);
if (targetDeck == null || !mounted) return;
final timestamp = DateTime.now().millisecondsSinceEpoch;
final mergedQuestions = [
...targetDeck.questions,
...sourceDeck.questions.asMap().entries.map((e) {
final q = e.value;
final i = e.key;
return q.copyWith(id: '${q.id}_merged_${timestamp}_$i');
}),
];
final mergedDeck = targetDeck.copyWith(questions: mergedQuestions);
_deckStorage.saveDeckSync(mergedDeck);
_loadDecks();
if (mounted) {
showTopSnackBar(
context,
message: 'Merged ${sourceDeck.questions.length} question(s) into "${targetDeck.title}"',
backgroundColor: Colors.green,
);
}
}
void _cloneDeck(Deck deck) { void _cloneDeck(Deck deck) {
// Create a copy of the deck with a new ID and reset progress // Create a copy of the deck with a new ID and reset progress
final clonedDeck = deck.copyWith( final clonedDeck = deck.copyWith(
@ -133,7 +269,8 @@ class _DeckListScreenState extends State<DeckListScreen> {
} }
} }
void _showAddDeckOptions() { /// Called from app bar or bottom nav (HomeScreen). Public so HomeScreen can trigger add.
void showAddDeckOptions() {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
builder: (context) => SafeArea( builder: (context) => SafeArea(
@ -302,20 +439,8 @@ class _DeckListScreenState extends State<DeckListScreen> {
_loadDecks(); _loadDecks();
} }
@override Widget _buildBody(BuildContext context) {
Widget build(BuildContext context) { return _decks.isEmpty
return Scaffold(
appBar: AppBar(
title: const Text('omotomo'),
actions: [
IconButton(
icon: const Icon(Icons.add),
tooltip: 'Add Deck',
onPressed: _showAddDeckOptions,
),
],
),
body: _decks.isEmpty
? Center( ? Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@ -452,6 +577,21 @@ class _DeckListScreenState extends State<DeckListScreen> {
); );
}, },
), ),
PopupMenuItem(
child: const Row(
children: [
Icon(Icons.merge_type, color: Colors.teal),
SizedBox(width: 8),
Text('Merge'),
],
),
onTap: () {
Future.delayed(
const Duration(milliseconds: 100),
() => _mergeDeck(deck),
);
},
),
PopupMenuItem( PopupMenuItem(
child: const Row( child: const Row(
children: [ children: [
@ -503,9 +643,103 @@ class _DeckListScreenState extends State<DeckListScreen> {
); );
}, },
), ),
);
}
@override
Widget build(BuildContext context) {
final body = _buildBody(context);
if (!widget.showAppBar) {
return body;
}
return Scaffold(
appBar: AppBar(
title: Row(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
'android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png',
height: 32,
width: 32,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => Icon(
Icons.auto_stories,
size: 28,
color: Theme.of(context).colorScheme.primaryContainer,
),
),
const SizedBox(width: 10),
Text(
'omotomo',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
),
),
],
),
actions: [
ValueListenableBuilder<ApiUser?>(
valueListenable: ApiAuthService.instance.currentUser,
builder: (context, user, _) {
if (user == null) {
return Padding(
padding: const EdgeInsets.only(right: 12),
child: FilledButton.tonal(
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
),
onPressed: () => Navigator.of(context).pushNamed(Routes.login),
child: const Text('Log in'),
), ),
); );
} }
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.sync),
tooltip: 'Sync from server',
onPressed: () => _loadDecks(),
),
Padding(
padding: const EdgeInsets.only(right: 4),
child: PopupMenuButton<String>(
offset: const Offset(0, 40),
tooltip: 'Account',
child: UserAvatar(user: user),
itemBuilder: (context) => [
const PopupMenuItem<String>(
value: 'logout',
child: Row(
children: [
Icon(Icons.logout),
SizedBox(width: 12),
Text('Log out'),
],
),
),
],
onSelected: (value) async {
if (value != 'logout') return;
await ApiAuthService.instance.logout();
if (!mounted) return;
Navigator.of(context).pushNamedAndRemoveUntil(
Routes.deckList,
(route) => false,
);
},
),
),
],
);
},
),
],
),
body: body,
);
}
} }
class _StatChip extends StatelessWidget { class _StatChip extends StatelessWidget {
@ -543,6 +777,49 @@ class _StatChip extends StatelessWidget {
} }
} }
/// Shows profile image from [ApiUser.avatarUrl] when available, otherwise initials.
/// Public so HomeScreen can use the same avatar in the shared app bar.
class UserAvatar extends StatelessWidget {
const UserAvatar({super.key, required this.user});
final ApiUser user;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final initials = DeckListScreenState._userInitials(user);
final initialsWidget = Text(
initials,
style: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600,
fontSize: 16,
),
);
final url = user.avatarUrl?.trim();
if (url == null || url.isEmpty) {
return CircleAvatar(
radius: 18,
backgroundColor: theme.colorScheme.primaryContainer,
child: initialsWidget,
);
}
return CircleAvatar(
radius: 18,
backgroundColor: theme.colorScheme.primaryContainer,
child: ClipOval(
child: Image.network(
url,
width: 36,
height: 36,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => initialsWidget,
),
),
);
}
}
class _MockDataDialog extends StatefulWidget { class _MockDataDialog extends StatefulWidget {
@override @override
State<_MockDataDialog> createState() => _MockDataDialogState(); State<_MockDataDialog> createState() => _MockDataDialogState();

@ -73,6 +73,68 @@ class _FlaggedQuestionsScreenState extends State<FlaggedQuestionsScreen> {
showTopSnackBar(context, message: 'Question unflagged'); showTopSnackBar(context, message: 'Question unflagged');
} }
void _removeQuestion(int index) {
if (_deck == null || index < 0 || index >= _editors.length) return;
final questionId = _editors[index].originalId;
if (questionId == null) return;
final updatedQuestions =
_deck!.questions.where((q) => q.id != questionId).toList();
final updatedDeck = _deck!.copyWith(questions: updatedQuestions);
_deckStorage.saveDeckSync(updatedDeck);
_editors[index].dispose();
setState(() {
_deck = updatedDeck;
_editors.removeAt(index);
});
showTopSnackBar(context, message: 'Question removed from deck');
}
Future<void> _removeAllQuestions() async {
if (_deck == null || _editors.isEmpty) return;
final count = _editors.length;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Remove all flagged questions?'),
content: Text(
'Remove all $count flagged question${count == 1 ? '' : 's'} from the deck? This cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: const Text('Remove all'),
),
],
),
);
if (confirmed != true || !mounted) return;
final idsToRemove =
_editors.map((e) => e.originalId).whereType<String>().toSet();
final updatedQuestions =
_deck!.questions.where((q) => !idsToRemove.contains(q.id)).toList();
final updatedDeck = _deck!.copyWith(questions: updatedQuestions);
_deckStorage.saveDeckSync(updatedDeck);
for (final e in _editors) {
e.dispose();
}
setState(() {
_deck = updatedDeck;
_editors.clear();
});
showTopSnackBar(
context,
message: '$count question${count == 1 ? '' : 's'} removed from deck',
backgroundColor: Colors.green,
);
}
void _save() { void _save() {
if (_deck == null) return; if (_deck == null) return;
for (int i = 0; i < _editors.length; i++) { for (int i = 0; i < _editors.length; i++) {
@ -94,8 +156,10 @@ class _FlaggedQuestionsScreenState extends State<FlaggedQuestionsScreen> {
if (id == null) continue; if (id == null) continue;
final existing = questionMap[id]; final existing = questionMap[id];
if (existing == null) continue; if (existing == null) continue;
final explanationText = editor.explanationController.text.trim();
final updated = existing.copyWith( final updated = existing.copyWith(
prompt: editor.promptController.text.trim(), prompt: editor.promptController.text.trim(),
explanation: explanationText.isEmpty ? null : explanationText,
answers: editor.answerControllers.map((c) => c.text.trim()).toList(), answers: editor.answerControllers.map((c) => c.text.trim()).toList(),
correctAnswerIndices: editor.correctAnswerIndices.toList()..sort(), correctAnswerIndices: editor.correctAnswerIndices.toList()..sort(),
); );
@ -141,6 +205,7 @@ class _FlaggedQuestionsScreenState extends State<FlaggedQuestionsScreen> {
final list = questions.map((q) => { final list = questions.map((q) => {
'id': q.id, 'id': q.id,
'prompt': q.prompt, 'prompt': q.prompt,
if (q.explanation != null && q.explanation!.isNotEmpty) 'explanation': q.explanation,
'answers': q.answers, 'answers': q.answers,
'correctAnswerIndices': q.correctAnswerIndices, 'correctAnswerIndices': q.correctAnswerIndices,
}).toList(); }).toList();
@ -162,6 +227,11 @@ class _FlaggedQuestionsScreenState extends State<FlaggedQuestionsScreen> {
title: Text('Flagged questions (${_editors.length})'), title: Text('Flagged questions (${_editors.length})'),
actions: [ actions: [
if (hasEditors) ...[ if (hasEditors) ...[
IconButton(
onPressed: _removeAllQuestions,
icon: const Icon(Icons.delete_forever),
tooltip: 'Remove all from deck',
),
IconButton( IconButton(
onPressed: _save, onPressed: _save,
icon: const Icon(Icons.save), icon: const Icon(Icons.save),
@ -216,7 +286,7 @@ class _FlaggedQuestionsScreenState extends State<FlaggedQuestionsScreen> {
key: ValueKey('flagged_${_editors[index].originalId}_$index'), key: ValueKey('flagged_${_editors[index].originalId}_$index'),
editor: _editors[index], editor: _editors[index],
questionNumber: index + 1, questionNumber: index + 1,
onDelete: null, onDelete: () => _removeQuestion(index),
onUnflag: () => _unflag(index), onUnflag: () => _unflag(index),
onChanged: () => setState(() {}), onChanged: () => setState(() {}),
); );

@ -0,0 +1,195 @@
import 'package:flutter/material.dart';
import '../routes.dart';
import '../services/api_auth_service.dart';
import 'community_decks_screen.dart';
import 'deck_list_screen.dart';
/// Shell with tab navigation: My Decks and Community.
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
int _currentIndex = 0;
final GlobalKey<DeckListScreenState> _deckListKey = GlobalKey<DeckListScreenState>();
final GlobalKey<CommunityDecksScreenState> _communityKey = GlobalKey<CommunityDecksScreenState>();
void _onDestinationSelected(int index) {
if (index == _currentIndex) return;
setState(() => _currentIndex = index);
if (index == 0) {
_deckListKey.currentState?.refresh();
} else if (index == 2) {
_communityKey.currentState?.refresh();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Row(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
'android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png',
height: 32,
width: 32,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => Icon(
Icons.auto_stories,
size: 28,
color: Theme.of(context).colorScheme.primaryContainer,
),
),
const SizedBox(width: 10),
Text(
'omotomo',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
),
),
],
),
actions: [
ValueListenableBuilder<ApiUser?>(
valueListenable: ApiAuthService.instance.currentUser,
builder: (context, user, _) {
if (user == null) {
return Padding(
padding: const EdgeInsets.only(right: 12),
child: FilledButton.tonal(
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
),
onPressed: () => Navigator.of(context).pushNamed(Routes.login),
child: const Text('Log in'),
),
);
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.sync),
tooltip: 'Sync from server',
onPressed: () => _deckListKey.currentState?.refresh(),
),
Padding(
padding: const EdgeInsets.only(right: 4),
child: PopupMenuButton<String>(
offset: const Offset(0, 40),
tooltip: 'Account',
child: UserAvatar(user: user),
itemBuilder: (context) => [
const PopupMenuItem<String>(
value: 'logout',
child: Row(
children: [
Icon(Icons.logout),
SizedBox(width: 12),
Text('Log out'),
],
),
),
],
onSelected: (value) async {
if (value != 'logout') return;
await ApiAuthService.instance.logout();
if (!mounted) return;
Navigator.of(context).pushNamedAndRemoveUntil(
Routes.deckList,
(route) => false,
);
},
),
),
],
);
},
),
],
),
body: IndexedStack(
index: _currentIndex == 2 ? 1 : 0,
children: [
DeckListScreen(key: _deckListKey, showAppBar: false),
CommunityDecksScreen(key: _communityKey, showAppBar: false),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => _deckListKey.currentState?.showAddDeckOptions(),
tooltip: 'Add Deck',
child: const Icon(Icons.add),
),
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
bottomNavigationBar: BottomAppBar(
notchMargin: 2,
padding: EdgeInsets.zero,
height: 40,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_NavItem(
icon: Icons.folder,
label: 'My Decks',
selected: _currentIndex == 0,
onTap: () => _onDestinationSelected(0),
),
const SizedBox(width: 56),
_NavItem(
icon: Icons.people,
label: 'Community',
selected: _currentIndex == 2,
onTap: () => _onDestinationSelected(2),
),
],
),
),
);
}
}
class _NavItem extends StatelessWidget {
final IconData icon;
final String label;
final bool selected;
final VoidCallback onTap;
const _NavItem({
required this.icon,
required this.label,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 18, color: selected ? Theme.of(context).colorScheme.primary : null),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
fontSize: 10,
color: selected ? Theme.of(context).colorScheme.primary : null,
),
),
],
),
),
);
}
}

@ -0,0 +1,166 @@
import 'package:flutter/material.dart';
import '../services/api_auth_service.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _loading = false;
String? _errorMessage;
bool _isRegister = false;
final _displayNameController = TextEditingController();
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
_displayNameController.dispose();
super.dispose();
}
Future<void> _submit() async {
setState(() {
_errorMessage = null;
_loading = true;
});
String? err;
if (_isRegister) {
err = await ApiAuthService.instance.register(
_emailController.text,
_passwordController.text,
_displayNameController.text,
);
} else {
err = await ApiAuthService.instance.login(
_emailController.text,
_passwordController.text,
);
}
if (!mounted) return;
setState(() {
_loading = false;
_errorMessage = err;
});
if (err == null) {
Navigator.pop(context);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_isRegister ? 'Create account' : 'Log in'),
),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 24),
if (_isRegister) ...[
TextFormField(
controller: _displayNameController,
decoration: const InputDecoration(
labelText: 'Display name (optional)',
border: OutlineInputBorder(),
),
textInputAction: TextInputAction.next,
),
const SizedBox(height: 16),
],
TextFormField(
controller: _emailController,
decoration: InputDecoration(
labelText: _isRegister ? 'Email' : 'Email or username',
border: const OutlineInputBorder(),
),
keyboardType: _isRegister ? TextInputType.emailAddress : TextInputType.text,
textInputAction: TextInputAction.next,
validator: (v) {
if (v == null || v.trim().isEmpty) {
return _isRegister ? 'Enter your email.' : 'Enter your email or username.';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
decoration: const InputDecoration(
labelText: 'Password',
border: OutlineInputBorder(),
),
obscureText: true,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _submit(),
validator: (v) {
if (v == null || v.isEmpty) return 'Enter your password.';
return null;
},
),
if (_errorMessage != null) ...[
const SizedBox(height: 16),
Text(
_errorMessage!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
const SizedBox(height: 24),
FilledButton(
onPressed: _loading
? null
: () {
if (_formKey.currentState?.validate() ?? false) {
_submit();
}
},
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: _loading
? const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(_isRegister ? 'Create account' : 'Log in'),
),
const SizedBox(height: 16),
TextButton(
onPressed: _loading
? null
: () {
setState(() {
_isRegister = !_isRegister;
_errorMessage = null;
});
},
child: Text(
_isRegister
? 'Already have an account? Log in'
: 'No account? Create one',
),
),
],
),
),
),
),
);
}
}

@ -0,0 +1,257 @@
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;
final String? avatarUrl;
const ApiUser({
required this.id,
this.email,
this.displayName,
this.avatarUrl,
});
ApiUser copyWith({
String? id,
String? email,
String? displayName,
String? avatarUrl,
}) {
return ApiUser(
id: id ?? this.id,
email: email ?? this.email,
displayName: displayName ?? this.displayName,
avatarUrl: avatarUrl ?? this.avatarUrl,
);
}
factory ApiUser.fromJson(Map<String, dynamic> json) {
final metadata = json['user_metadata'] as Map<String, dynamic>?;
final avatar = json['avatar_url'] as String?;
final avatarTrimmed = avatar?.trim();
return ApiUser(
id: json['id'] as String? ?? '',
email: json['email'] as String?,
displayName: json['display_name'] as String? ??
metadata?['display_name'] as String?,
avatarUrl: (avatarTrimmed != null && avatarTrimmed.isNotEmpty)
? avatarTrimmed
: null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
if (email != null) 'email': email,
if (displayName != null) 'display_name': displayName,
if (avatarUrl != null) 'avatar_url': avatarUrl,
};
}
}
/// 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();
}
if (_token != null && currentUser.value != null) {
await _refreshProfile();
}
}
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;
}
}
/// GET /api/auth/profile fetches profile (display_name, email, avatar_url) and updates currentUser.
Future<void> _refreshProfile() async {
if (_token == null) return;
try {
final uri = Uri.parse('$apiBaseUrl/api/auth/profile');
final res = await http.get(
uri,
headers: {'Authorization': 'Bearer $_token'},
);
if (res.statusCode != 200) return;
final data = jsonDecode(res.body) as Map<String, dynamic>;
final id = data['id'] as String? ?? currentUser.value?.id ?? '';
final displayName = data['display_name'] as String?;
final email = data['email'] as String?;
final avatarUrl = data['avatar_url'] as String?;
final trimmedAvatar = avatarUrl?.trim();
final newUser = (currentUser.value ?? ApiUser(id: id)).copyWith(
displayName: displayName ?? currentUser.value?.displayName,
email: email ?? currentUser.value?.email,
avatarUrl: (trimmedAvatar != null && trimmedAvatar.isNotEmpty)
? trimmedAvatar
: currentUser.value?.avatarUrl,
);
currentUser.value = newUser;
final prefs = await _prefs;
await prefs.setString(_userKey, jsonEncode(newUser.toJson()));
} catch (_) {
// Keep existing user if profile fetch fails
}
}
/// 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));
await _refreshProfile();
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));
await _refreshProfile();
return null;
} catch (e) {
return connectionErrorMessage(e);
}
}
/// Clears session (token + user) and persists. Call this to log out.
Future<void> logout() async {
final prefs = await _prefs;
await prefs.remove(_tokenKey);
await prefs.remove(_userKey);
_token = null;
currentUser.value = null;
}
}

@ -45,9 +45,12 @@ class DeckStorage {
} }
} }
/// Convert Deck to JSON Map /// Sync metadata key in stored JSON (not part of Deck model).
Map<String, dynamic> _deckToJson(Deck deck) { static const String _syncKey = '_sync';
return {
/// 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, 'id': deck.id,
'title': deck.title, 'title': deck.title,
'description': deck.description, 'description': deck.description,
@ -66,6 +69,7 @@ class DeckStorage {
'questions': deck.questions.map((q) => { 'questions': deck.questions.map((q) => {
'id': q.id, 'id': q.id,
'prompt': q.prompt, 'prompt': q.prompt,
if (q.explanation != null && q.explanation!.isNotEmpty) 'explanation': q.explanation,
'answers': q.answers, 'answers': q.answers,
'correctAnswerIndices': q.correctAnswerIndices, 'correctAnswerIndices': q.correctAnswerIndices,
'consecutiveCorrect': q.consecutiveCorrect, 'consecutiveCorrect': q.consecutiveCorrect,
@ -95,10 +99,15 @@ class DeckStorage {
'remainingSeconds': deck.incompleteAttempt!.remainingSeconds, 'remainingSeconds': deck.incompleteAttempt!.remainingSeconds,
} : null, } : 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) { Deck _jsonToDeck(Map<String, dynamic> json) {
json = Map<String, dynamic>.from(json)..remove(_syncKey);
// Parse config // Parse config
final configJson = json['config'] as Map<String, dynamic>? ?? {}; final configJson = json['config'] as Map<String, dynamic>? ?? {};
final config = DeckConfig( final config = DeckConfig(
@ -133,6 +142,7 @@ class DeckStorage {
return Question( return Question(
id: questionMap['id'] as String? ?? '', id: questionMap['id'] as String? ?? '',
prompt: questionMap['prompt'] as String? ?? '', prompt: questionMap['prompt'] as String? ?? '',
explanation: questionMap['explanation'] as String?,
answers: (questionMap['answers'] as List<dynamic>?) answers: (questionMap['answers'] as List<dynamic>?)
?.map((e) => e.toString()) ?.map((e) => e.toString())
.toList() ?? .toList() ??
@ -282,10 +292,45 @@ class DeckStorage {
} }
} }
/// Add or update a deck /// Sync metadata for a deck (server_deck_id, owner_id, copied_from_deck_id, etc.). Null if not set.
Future<void> saveDeck(Deck deck) async { Future<Map<String, dynamic>?> getDeckSyncMetadata(String deckId) async {
await _ensureInitialized();
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(); await _ensureInitialized();
final deckJson = jsonEncode(_deckToJson(deck)); 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); await _prefs!.setString('$_decksKey:${deck.id}', deckJson);
// Update deck IDs list // Update deck IDs list
@ -296,15 +341,14 @@ class DeckStorage {
} }
} }
/// Synchronous version for backward compatibility /// Synchronous version for backward compatibility. Pass [syncMetadata] to set or preserve sync info.
void saveDeckSync(Deck deck) { void saveDeckSync(Deck deck, {Map<String, dynamic>? syncMetadata}) {
if (!_initialized || _prefs == null) { if (!_initialized || _prefs == null) {
// Queue for async save _ensureInitialized().then((_) => saveDeck(deck, syncMetadata: syncMetadata));
_ensureInitialized().then((_) => saveDeck(deck));
return; return;
} }
Map<String, dynamic>? sync = syncMetadata ?? getDeckSyncMetadataSync(deck.id);
final deckJson = jsonEncode(_deckToJson(deck)); final deckJson = jsonEncode(_deckToJson(deck, syncMetadata: sync));
_prefs!.setString('$_decksKey:${deck.id}', deckJson); _prefs!.setString('$_decksKey:${deck.id}', deckJson);
// Update deck IDs list // 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';
}

@ -0,0 +1,16 @@
import 'dart:async';
import 'dart:io';
import 'package:http/http.dart' as http;
/// User-facing message when an API/network request fails.
String connectionErrorMessage(Object error) {
if (error is SocketException ||
error is TimeoutException ||
error is http.ClientException ||
error is HandshakeException ||
error is TlsException) {
return 'Connection to server has broken. Check API URL and network.';
}
return 'Network error. Please check the API URL and try again.';
}

@ -80,29 +80,32 @@ class AttemptService {
.firstOrNull ?? questionInAttempt; .firstOrNull ?? questionInAttempt;
final userAnswer = answers[currentQuestion.id]; final userAnswer = answers[currentQuestion.id];
if (userAnswer == null) {
// Question not answered, treat as incorrect
continue;
}
// Handle both single answer (int) and multiple answers (List<int>) // Not answering or invalid format: treat same as incorrect
final List<int> userAnswerIndices; final List<int> userAnswerIndices;
if (userAnswer is int) { final bool isCorrect;
if (userAnswer == null) {
userAnswerIndices = [];
isCorrect = false;
} else if (userAnswer is int) {
userAnswerIndices = [userAnswer]; userAnswerIndices = [userAnswer];
final correctIndices = currentQuestion.correctIndices;
final userSet = userAnswerIndices.toSet();
final correctSet = correctIndices.toSet();
isCorrect = userSet.length == correctSet.length &&
userSet.every((idx) => correctSet.contains(idx));
} else if (userAnswer is List<int>) { } else if (userAnswer is List<int>) {
userAnswerIndices = userAnswer; userAnswerIndices = userAnswer;
} else {
// Invalid format, treat as incorrect
continue;
}
// Check if answer is correct
// For multiple correct answers: user must select all correct answers and no incorrect ones
final correctIndices = currentQuestion.correctIndices; final correctIndices = currentQuestion.correctIndices;
final userSet = userAnswerIndices.toSet(); final userSet = userAnswerIndices.toSet();
final correctSet = correctIndices.toSet(); final correctSet = correctIndices.toSet();
final isCorrect = userSet.length == correctSet.length && isCorrect = userSet.length == correctSet.length &&
userSet.every((idx) => correctSet.contains(idx)); userSet.every((idx) => correctSet.contains(idx));
} else {
userAnswerIndices = [];
isCorrect = false;
}
final userMarkedNeedsPractice = overrides[currentQuestion.id] ?? false; final userMarkedNeedsPractice = overrides[currentQuestion.id] ?? false;
// Determine status change (from current deck state) // Determine status change (from current deck state)

@ -6,6 +6,9 @@ class Question {
/// The question prompt. /// The question prompt.
final String prompt; final String prompt;
/// Optional explanation shown when viewing question details (e.g. after an attempt).
final String? explanation;
/// List of possible answers. /// List of possible answers.
final List<String> answers; final List<String> answers;
@ -42,6 +45,7 @@ class Question {
Question({ Question({
required this.id, required this.id,
required this.prompt, required this.prompt,
this.explanation,
required this.answers, required this.answers,
List<int>? correctAnswerIndices, List<int>? correctAnswerIndices,
@Deprecated('Use correctAnswerIndices instead') int? correctAnswerIndex, @Deprecated('Use correctAnswerIndices instead') int? correctAnswerIndex,
@ -60,6 +64,7 @@ class Question {
Question copyWith({ Question copyWith({
String? id, String? id,
String? prompt, String? prompt,
String? explanation,
List<String>? answers, List<String>? answers,
List<int>? correctAnswerIndices, List<int>? correctAnswerIndices,
@Deprecated('Use correctAnswerIndices instead') int? correctAnswerIndex, @Deprecated('Use correctAnswerIndices instead') int? correctAnswerIndex,
@ -74,6 +79,7 @@ class Question {
return Question( return Question(
id: id ?? this.id, id: id ?? this.id,
prompt: prompt ?? this.prompt, prompt: prompt ?? this.prompt,
explanation: explanation ?? this.explanation,
answers: answers ?? this.answers, answers: answers ?? this.answers,
correctAnswerIndices: correctAnswerIndices ?? this.correctAnswerIndices, correctAnswerIndices: correctAnswerIndices ?? this.correctAnswerIndices,
correctAnswerIndex: correctAnswerIndex ?? this.correctAnswerIndex, correctAnswerIndex: correctAnswerIndex ?? this.correctAnswerIndex,
@ -119,6 +125,7 @@ class Question {
runtimeType == other.runtimeType && runtimeType == other.runtimeType &&
id == other.id && id == other.id &&
prompt == other.prompt && prompt == other.prompt &&
explanation == other.explanation &&
answers.toString() == other.answers.toString() && answers.toString() == other.answers.toString() &&
correctAnswerIndices.toString() == other.correctAnswerIndices.toString() && correctAnswerIndices.toString() == other.correctAnswerIndices.toString() &&
consecutiveCorrect == other.consecutiveCorrect && consecutiveCorrect == other.consecutiveCorrect &&
@ -133,6 +140,7 @@ class Question {
int get hashCode => int get hashCode =>
id.hashCode ^ id.hashCode ^
prompt.hashCode ^ prompt.hashCode ^
(explanation?.hashCode ?? 0) ^
answers.hashCode ^ answers.hashCode ^
correctAnswerIndices.hashCode ^ correctAnswerIndices.hashCode ^
consecutiveCorrect.hashCode ^ consecutiveCorrect.hashCode ^

@ -94,6 +94,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_dotenv:
dependency: "direct main"
description:
name: flutter_dotenv
sha256: b7c7be5cd9f6ef7a78429cabd2774d3c4af50e79cb2b7593e3d5d763ef95c61b
url: "https://pub.dev"
source: hosted
version: "5.2.1"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@ -120,6 +128,22 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
http:
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@ -340,6 +364,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.7" version: "0.7.7"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:

@ -14,6 +14,8 @@ dependencies:
cupertino_icons: ^1.0.6 cupertino_icons: ^1.0.6
shared_preferences: ^2.2.2 shared_preferences: ^2.2.2
file_picker: ^8.1.6 file_picker: ^8.1.6
flutter_dotenv: ^5.1.0
http: ^1.2.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@ -22,4 +24,7 @@ dev_dependencies:
flutter: flutter:
uses-material-design: true uses-material-design: true
assets:
- .env
- android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png

Loading…
Cancel
Save

Powered by TurnKey Linux.