Compare commits
12
Commits
187d74db8d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c20065e03 | ||
|
|
15867bb177 | ||
|
|
c24076a61c | ||
|
|
fb96745975 | ||
|
|
b67e562423 | ||
|
|
82be8dbdf3 | ||
|
|
cd3b75995d | ||
|
|
f83dfd5717 | ||
|
|
afe86721ed | ||
|
|
25b005db3a | ||
|
|
1ae9413c71 | ||
|
|
f3f44c8c69 |
@@ -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://10.0.2.2:3001
|
||||||
|
API_BASE_URL=https://omotomo.satoshinakamoto.win
|
||||||
@@ -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,48 @@
|
|||||||
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
const String _overrideKey = 'api_base_url_override';
|
||||||
|
|
||||||
|
String? _override;
|
||||||
|
|
||||||
|
String _fromEnv() {
|
||||||
|
final raw = dotenv.env['API_BASE_URL']?.trim() ?? '';
|
||||||
|
final url = raw.replaceAll(' ', '');
|
||||||
|
if (url.isEmpty) return 'http://localhost:3001';
|
||||||
|
return url.endsWith('/') ? url.substring(0, url.length - 1) : url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// API base URL (no trailing slash). Uses in-app override if set, else .env.
|
||||||
|
/// Strips spaces so accidental spaces don't break DNS.
|
||||||
|
String get apiBaseUrl {
|
||||||
|
if (_override != null && _override!.trim().isNotEmpty) {
|
||||||
|
final u = _override!.trim().replaceAll(' ', '');
|
||||||
|
if (u.isNotEmpty) {
|
||||||
|
return u.endsWith('/') ? u.substring(0, u.length - 1) : u;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _fromEnv();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load saved override from disk. Call once at startup.
|
||||||
|
Future<void> loadApiBaseUrlOverride() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_override = prefs.getString(_overrideKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save or clear the in-app base URL override. Pass null or empty to use .env again.
|
||||||
|
Future<void> setApiBaseUrlOverride(String? value) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
await prefs.remove(_overrideKey);
|
||||||
|
_override = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final u = value.trim().replaceAll(' ', '');
|
||||||
|
final withoutSlash = u.endsWith('/') ? u.substring(0, u.length - 1) : u;
|
||||||
|
await prefs.setString(_overrideKey, withoutSlash);
|
||||||
|
_override = withoutSlash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current default from .env (ignoring override). For display only.
|
||||||
|
String get apiBaseUrlDefault => _fromEnv();
|
||||||
+11
-4
@@ -1,14 +1,21 @@
|
|||||||
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 'env.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 loadApiBaseUrlOverride();
|
||||||
|
|
||||||
|
await DeckStorage().initialize();
|
||||||
|
await ApiAuthService.instance.init();
|
||||||
runApp(const DeckyApp());
|
runApp(const DeckyApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
-3
@@ -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 {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,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
|
||||||
@@ -30,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?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +65,16 @@ class _AttemptResultScreenState extends State<AttemptResultScreen> {
|
|||||||
|
|
||||||
void _repeatSameAttempt() {
|
void _repeatSameAttempt() {
|
||||||
if (_deck == null) return;
|
if (_deck == null) return;
|
||||||
Navigator.pushReplacementNamed(context, Routes.attempt, arguments: _deck);
|
// 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _newAttempt() {
|
void _newAttempt() {
|
||||||
@@ -75,11 +86,12 @@ 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,
|
context,
|
||||||
Routes.deckList,
|
Routes.deckOverview,
|
||||||
(route) => false,
|
arguments: _deck,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +113,7 @@ class _AttemptResultScreenState extends State<AttemptResultScreen> {
|
|||||||
final q = _currentQuestionInDeck(questionId);
|
final q = _currentQuestionInDeck(questionId);
|
||||||
showTopSnackBar(
|
showTopSnackBar(
|
||||||
context,
|
context,
|
||||||
q?.isFlagged == true ? 'Question flagged for review' : 'Question unflagged',
|
message: q?.isFlagged == true ? 'Question flagged for review' : 'Question unflagged',
|
||||||
backgroundColor: q?.isFlagged == true ? Colors.orange : null,
|
backgroundColor: q?.isFlagged == true ? Colors.orange : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -114,11 +126,118 @@ class _AttemptResultScreenState extends State<AttemptResultScreen> {
|
|||||||
_deckStorage.saveDeckSync(_deck!);
|
_deckStorage.saveDeckSync(_deck!);
|
||||||
showTopSnackBar(
|
showTopSnackBar(
|
||||||
context,
|
context,
|
||||||
'Marked as needs practice',
|
message: 'Marked as needs practice',
|
||||||
backgroundColor: Colors.blue,
|
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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (_deck == null || _result == null) {
|
if (_deck == null || _result == null) {
|
||||||
@@ -266,45 +385,61 @@ 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,
|
||||||
const SizedBox(height: 12),
|
maxLines: 3,
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
StatusChip(statusChange: answerResult.statusChange),
|
|
||||||
const Spacer(),
|
|
||||||
Text(
|
|
||||||
'Your answer${answerResult.userAnswerIndices.length > 1 ? 's' : ''}: ${answerResult.userAnswerIndices.map((idx) => answerResult.question.answers[idx]).join(', ')}',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.red,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
answerResult.userAnswerIndices.isEmpty
|
||||||
|
? 'No answer'
|
||||||
|
: 'Your answer${answerResult.userAnswerIndices.length > 1 ? 's' : ''}: ${answerResult.userAnswerIndices.map((idx) => answerResult.question.answers[idx]).join(', ')}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.red,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: Theme.of(context).textTheme.bodyMedium?.fontSize,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 2,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
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),
|
const SizedBox(height: 12),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
TextButton.icon(
|
StatusChip(statusChange: answerResult.statusChange),
|
||||||
onPressed: () => _markNeedsPractice(answerResult.question.id),
|
Row(
|
||||||
icon: const Icon(Icons.replay, size: 20),
|
mainAxisSize: MainAxisSize.min,
|
||||||
label: const Text('Needs practice'),
|
children: [
|
||||||
),
|
IconButton(
|
||||||
IconButton(
|
onPressed: () => _showQuestionDetail(answerResult),
|
||||||
onPressed: () => _toggleFlag(answerResult.question.id),
|
icon: const Icon(Icons.visibility, size: 22),
|
||||||
icon: Icon(
|
tooltip: 'View question details',
|
||||||
isFlagged ? Icons.flag : Icons.outlined_flag,
|
),
|
||||||
color: isFlagged ? Colors.red : null,
|
IconButton(
|
||||||
),
|
onPressed: () => _markNeedsPractice(answerResult.question.id),
|
||||||
tooltip: isFlagged ? 'Unflag' : 'Flag for review',
|
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',
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -327,30 +462,58 @@ class _AttemptResultScreenState extends State<AttemptResultScreen> {
|
|||||||
final isFlagged = currentQ?.isFlagged ?? false;
|
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(
|
||||||
answerResult.isCorrect
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
? 'Correct'
|
|
||||||
: 'Incorrect - Selected: ${answerResult.userAnswerIndices.map((idx) => answerResult.question.answers[idx]).join(', ')}',
|
|
||||||
),
|
|
||||||
trailing: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
children: [
|
||||||
StatusChip(statusChange: answerResult.statusChange),
|
Text(
|
||||||
IconButton(
|
answerResult.question.prompt,
|
||||||
onPressed: () => _markNeedsPractice(answerResult.question.id),
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
icon: const Icon(Icons.replay, size: 22),
|
overflow: TextOverflow.ellipsis,
|
||||||
tooltip: 'Mark as needs practice',
|
maxLines: 3,
|
||||||
),
|
),
|
||||||
IconButton(
|
const SizedBox(height: 4),
|
||||||
onPressed: () => _toggleFlag(answerResult.question.id),
|
Text(
|
||||||
icon: Icon(
|
answerResult.isCorrect
|
||||||
isFlagged ? Icons.flag : Icons.outlined_flag,
|
? 'Correct'
|
||||||
color: isFlagged ? Colors.red : null,
|
: answerResult.userAnswerIndices.isEmpty
|
||||||
size: 22,
|
? 'Incorrect - No answer'
|
||||||
),
|
: 'Incorrect - Selected: ${answerResult.userAnswerIndices.map((idx) => answerResult.question.answers[idx]).join(', ')}',
|
||||||
tooltip: isFlagged ? 'Unflag' : 'Flag for review',
|
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',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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?;
|
||||||
@@ -112,9 +114,36 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
|||||||
_deck = _createSampleDeck();
|
_deck = _createSampleDeck();
|
||||||
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
|
||||||
@@ -176,7 +181,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
|||||||
final minutes = int.tryParse(_timeLimitMinutesController.text) ?? 0;
|
final minutes = int.tryParse(_timeLimitMinutesController.text) ?? 0;
|
||||||
final seconds = int.tryParse(_timeLimitSecondsController.text) ?? 0;
|
final seconds = int.tryParse(_timeLimitSecondsController.text) ?? 0;
|
||||||
final totalSeconds = hours * 3600 + minutes * 60 + seconds;
|
final totalSeconds = hours * 3600 + minutes * 60 + seconds;
|
||||||
|
|
||||||
if (totalSeconds > 0) {
|
if (totalSeconds > 0) {
|
||||||
timeLimitSeconds = totalSeconds;
|
timeLimitSeconds = totalSeconds;
|
||||||
} else {
|
} else {
|
||||||
@@ -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(() {
|
||||||
showTopSnackBar(
|
_deck = updatedDeck;
|
||||||
context,
|
_config = updatedConfig;
|
||||||
message: 'Deck configuration saved successfully',
|
_configHashCode = updatedConfig.hashCode;
|
||||||
backgroundColor: Colors.green,
|
});
|
||||||
duration: const Duration(seconds: 1),
|
return true;
|
||||||
);
|
|
||||||
|
|
||||||
// Pop with the updated deck
|
|
||||||
Navigator.pop(context, updatedDeck);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _cancel() {
|
Map<String, dynamic> _configToJsonMap() {
|
||||||
Navigator.pop(context);
|
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(
|
||||||
|
context,
|
||||||
|
message: 'JSON copied to clipboard',
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.copy, size: 20),
|
||||||
|
label: const Text('Copy'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _editDeck() {
|
void _editDeck() {
|
||||||
@@ -305,9 +367,30 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return PopScope(
|
||||||
appBar: AppBar(
|
canPop: false,
|
||||||
title: const Text('Deck Configuration'),
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
|
if (didPop) return;
|
||||||
|
_applyAndPersist();
|
||||||
|
if (mounted) Navigator.pop(context, _deck);
|
||||||
|
},
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
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,29 +674,10 @@ 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,83 +203,95 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: SingleChildScrollView(
|
body: CustomScrollView(
|
||||||
padding: const EdgeInsets.all(16),
|
controller: _scrollController,
|
||||||
child: Column(
|
slivers: [
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
SliverPadding(
|
||||||
children: [
|
padding: const EdgeInsets.all(16),
|
||||||
// Deck Title
|
sliver: SliverList(
|
||||||
TextField(
|
delegate: SliverChildListDelegate([
|
||||||
controller: _titleController,
|
// Deck Title
|
||||||
decoration: const InputDecoration(
|
TextField(
|
||||||
labelText: 'Deck Title',
|
controller: _titleController,
|
||||||
border: OutlineInputBorder(),
|
decoration: const InputDecoration(
|
||||||
),
|
labelText: 'Deck Title',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// Deck Description
|
||||||
|
TextField(
|
||||||
|
controller: _descriptionController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Description (optional)',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
maxLines: 3,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
'Questions',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
]),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
),
|
||||||
|
if (_questionEditors.isEmpty)
|
||||||
// Deck Description
|
SliverPadding(
|
||||||
TextField(
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
controller: _descriptionController,
|
sliver: SliverToBoxAdapter(
|
||||||
decoration: const InputDecoration(
|
child: Card(
|
||||||
labelText: 'Description (optional)',
|
child: Padding(
|
||||||
border: OutlineInputBorder(),
|
padding: const EdgeInsets.all(32),
|
||||||
),
|
child: Center(
|
||||||
maxLines: 3,
|
child: Column(
|
||||||
),
|
children: [
|
||||||
const SizedBox(height: 24),
|
Icon(
|
||||||
|
Icons.quiz_outlined,
|
||||||
// Questions Section
|
size: 48,
|
||||||
Text(
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3),
|
||||||
'Questions',
|
),
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
const SizedBox(height: 16),
|
||||||
),
|
Text(
|
||||||
const SizedBox(height: 16),
|
'No questions yet',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
// Questions List
|
),
|
||||||
...List.generate(_questionEditors.length, (index) {
|
const SizedBox(height: 8),
|
||||||
return QuestionEditorCard(
|
Text(
|
||||||
key: ValueKey('question_$index'),
|
'Tap + in the app bar to add a question',
|
||||||
editor: _questionEditors[index],
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
questionNumber: index + 1,
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
onDelete: () => _removeQuestion(index),
|
),
|
||||||
onUnflag: null,
|
),
|
||||||
onChanged: () => setState(() {}),
|
],
|
||||||
);
|
),
|
||||||
}),
|
|
||||||
|
|
||||||
if (_questionEditors.isEmpty)
|
|
||||||
Card(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(32),
|
|
||||||
child: Center(
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.quiz_outlined,
|
|
||||||
size: 48,
|
|
||||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'No questions yet',
|
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Tap + in the app bar to add a question',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
)
|
||||||
],
|
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,32 +503,24 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Wrap(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
spacing: 8,
|
||||||
|
runSpacing: 4,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
TextButton.icon(
|
||||||
'Deck JSON',
|
onPressed: _pickJsonFile,
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
icon: const Icon(Icons.folder_open, size: 20),
|
||||||
|
label: const Text('Select file'),
|
||||||
),
|
),
|
||||||
Row(
|
TextButton.icon(
|
||||||
mainAxisSize: MainAxisSize.min,
|
onPressed: _loadSampleDeck,
|
||||||
children: [
|
icon: const Icon(Icons.description, size: 20),
|
||||||
TextButton.icon(
|
label: const Text('Load Sample'),
|
||||||
onPressed: _pickJsonFile,
|
),
|
||||||
icon: const Icon(Icons.folder_open),
|
IconButton(
|
||||||
label: const Text('Select file'),
|
onPressed: _copyFieldToClipboard,
|
||||||
),
|
icon: const Icon(Icons.copy),
|
||||||
TextButton.icon(
|
tooltip: 'Copy field contents',
|
||||||
onPressed: _loadSampleDeck,
|
|
||||||
icon: const Icon(Icons.description),
|
|
||||||
label: const Text('Load Sample'),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
onPressed: _copyFieldToClipboard,
|
|
||||||
icon: const Icon(Icons.copy),
|
|
||||||
tooltip: 'Copy field contents',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -484,20 +567,43 @@ 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(
|
||||||
onPressed: _isLoading ? null : _importDeck,
|
children: [
|
||||||
icon: _isLoading
|
Expanded(
|
||||||
? const SizedBox(
|
child: OutlinedButton.icon(
|
||||||
width: 20,
|
onPressed: _isLoading ? null : _mergeWithExistingDeck,
|
||||||
height: 20,
|
icon: _isLoading
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
? const SizedBox(
|
||||||
)
|
width: 20,
|
||||||
: const Icon(Icons.upload),
|
height: 20,
|
||||||
label: Text(_isLoading ? 'Importing...' : 'Import Deck'),
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
style: FilledButton.styleFrom(
|
)
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
: 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,
|
||||||
|
icon: _isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.add_circle_outline, size: 20),
|
||||||
|
label: Text(_isLoading ? 'Importing...' : 'Import and create deck'),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
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,19 +128,35 @@ class _DeckListScreenState extends State<DeckListScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed == true) {
|
if (confirmed != true || !mounted) return;
|
||||||
_deckStorage.deleteDeckSync(deck.id);
|
|
||||||
_loadDecks();
|
_deckStorage.deleteDeckSync(deck.id);
|
||||||
if (mounted) {
|
_loadDecks();
|
||||||
showTopSnackBar(
|
if (mounted) {
|
||||||
context,
|
showTopSnackBar(
|
||||||
message: '${deck.title} deleted',
|
context,
|
||||||
backgroundColor: Colors.green,
|
message: '${deck.title} deleted',
|
||||||
);
|
backgroundColor: Colors.green,
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
Navigator.pushNamed(context, Routes.deckImport).then((_) {
|
Navigator.pushNamed(context, Routes.deckImport).then((_) {
|
||||||
// Reload decks when returning from import
|
// Reload decks when returning from import
|
||||||
@@ -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,7 +643,101 @@ 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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,253 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../env.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();
|
||||||
|
final _serverUrlController = TextEditingController();
|
||||||
|
bool _loading = false;
|
||||||
|
String? _errorMessage;
|
||||||
|
bool _isRegister = false;
|
||||||
|
final _displayNameController = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_serverUrlController.text = apiBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_emailController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
_serverUrlController.dispose();
|
||||||
|
_displayNameController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveServerUrl() async {
|
||||||
|
final raw = _serverUrlController.text.trim().replaceAll(' ', '');
|
||||||
|
if (raw.isEmpty) {
|
||||||
|
await setApiBaseUrlOverride(null);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_serverUrlController.text = apiBaseUrlDefault;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Using default server URL')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final withoutSlash = raw.endsWith('/') ? raw.substring(0, raw.length - 1) : raw;
|
||||||
|
final uri = Uri.tryParse(withoutSlash);
|
||||||
|
if (uri == null ||
|
||||||
|
(!uri.scheme.startsWith('http') || uri.host.isEmpty)) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = 'Enter a valid URL (e.g. https://example.com)';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await setApiBaseUrlOverride(withoutSlash);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _errorMessage = null);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Server URL saved')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _useDefaultServerUrl() async {
|
||||||
|
await setApiBaseUrlOverride(null);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_serverUrlController.text = apiBaseUrlDefault;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Using default server URL')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text(
|
||||||
|
'Server URL',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextFormField(
|
||||||
|
controller: _serverUrlController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Leave empty for app default',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.url,
|
||||||
|
autocorrect: false,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
FilledButton.tonal(
|
||||||
|
onPressed: _loading ? null : _saveServerUrl,
|
||||||
|
child: const Text('Save server URL'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
TextButton(
|
||||||
|
onPressed: _loading ? null : _useDefaultServerUrl,
|
||||||
|
child: const Text('Use default'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
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();
|
await _ensureInitialized();
|
||||||
final deckJson = jsonEncode(_deckToJson(deck));
|
final deckJson = _prefs!.getString('$_decksKey:$deckId');
|
||||||
|
if (deckJson == null) return null;
|
||||||
|
try {
|
||||||
|
final json = jsonDecode(deckJson) as Map<String, dynamic>;
|
||||||
|
final sync = json[_syncKey];
|
||||||
|
if (sync == null) return null;
|
||||||
|
return Map<String, dynamic>.from(sync as Map);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sync metadata (sync version). Null if not set.
|
||||||
|
Map<String, dynamic>? getDeckSyncMetadataSync(String deckId) {
|
||||||
|
if (!_initialized || _prefs == null) return null;
|
||||||
|
final deckJson = _prefs!.getString('$_decksKey:$deckId');
|
||||||
|
if (deckJson == null) return null;
|
||||||
|
try {
|
||||||
|
final json = jsonDecode(deckJson) as Map<String, dynamic>;
|
||||||
|
final sync = json[_syncKey];
|
||||||
|
if (sync == null) return null;
|
||||||
|
return Map<String, dynamic>.from(sync as Map);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add or update a deck. Pass [syncMetadata] to set or update sync info (server_deck_id, etc.).
|
||||||
|
Future<void> saveDeck(Deck deck, {Map<String, dynamic>? syncMetadata}) async {
|
||||||
|
await _ensureInitialized();
|
||||||
|
Map<String, dynamic>? sync = syncMetadata;
|
||||||
|
if (sync == null) {
|
||||||
|
final existing = await getDeckSyncMetadata(deck.id);
|
||||||
|
if (existing != null) sync = existing;
|
||||||
|
}
|
||||||
|
final deckJson = jsonEncode(_deckToJson(deck, syncMetadata: sync));
|
||||||
await _prefs!.setString('$_decksKey:${deck.id}', deckJson);
|
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,21 @@
|
|||||||
|
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) {
|
||||||
|
final msg = error.toString().toLowerCase();
|
||||||
|
if (msg.contains('host lookup') ||
|
||||||
|
msg.contains('no address associated with hostname')) {
|
||||||
|
return "This device can't resolve the server hostname. On an emulator, try setting DNS to 8.8.8.8 or use a real device.";
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
final correctIndices = currentQuestion.correctIndices;
|
||||||
|
final userSet = userAnswerIndices.toSet();
|
||||||
|
final correctSet = correctIndices.toSet();
|
||||||
|
isCorrect = userSet.length == correctSet.length &&
|
||||||
|
userSet.every((idx) => correctSet.contains(idx));
|
||||||
} else {
|
} else {
|
||||||
// Invalid format, treat as incorrect
|
userAnswerIndices = [];
|
||||||
continue;
|
isCorrect = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if answer is correct
|
|
||||||
// For multiple correct answers: user must select all correct answers and no incorrect ones
|
|
||||||
final correctIndices = currentQuestion.correctIndices;
|
|
||||||
final userSet = userAnswerIndices.toSet();
|
|
||||||
final correctSet = correctIndices.toSet();
|
|
||||||
final isCorrect = userSet.length == correctSet.length &&
|
|
||||||
userSet.every((idx) => correctSet.contains(idx));
|
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user