clud features
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
# API base URL (no trailing slash). Backend must be running (e.g. node server in omotomo_site).
|
||||||
|
# - Android emulator: use http://10.0.2.2:3001 (emulator's alias for host)
|
||||||
|
# - iOS simulator: use http://localhost:3001
|
||||||
|
# - Physical device: use your computer's IP, e.g. http://192.168.1.5:3001 (find IP in system settings)
|
||||||
|
#API_BASE_URL=http://localhost:3001
|
||||||
|
API_BASE_URL=http://10.0.2.2:3001
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
|
|
||||||
|
/// API base URL (no trailing slash). Loaded from .env as API_BASE_URL.
|
||||||
|
String get apiBaseUrl {
|
||||||
|
final url = dotenv.env['API_BASE_URL']?.trim();
|
||||||
|
if (url == null || url.isEmpty) {
|
||||||
|
return 'http://localhost:3001';
|
||||||
|
}
|
||||||
|
return url.endsWith('/') ? url.substring(0, url.length - 1) : url;
|
||||||
|
}
|
||||||
+8
-3
@@ -1,14 +1,19 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
import 'routes.dart';
|
import 'routes.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
|
import 'services/api_auth_service.dart';
|
||||||
import 'services/deck_storage.dart';
|
import 'services/deck_storage.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
// Ensure Flutter bindings are initialized before using any plugins
|
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
// Initialize storage early
|
await dotenv.load(fileName: '.env').catchError(
|
||||||
DeckStorage().initialize();
|
(_) => dotenv.load(fileName: '.env.example'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await DeckStorage().initialize();
|
||||||
|
await ApiAuthService.instance.init();
|
||||||
runApp(const DeckyApp());
|
runApp(const DeckyApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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 {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
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});
|
||||||
|
|
||||||
|
@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 {
|
||||||
|
final user = ApiAuthService.instance.currentUser.value;
|
||||||
|
if (user == null) {
|
||||||
|
setState(() {
|
||||||
|
_loading = false;
|
||||||
|
_decks = [];
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (user == null) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Community decks')),
|
||||||
|
body: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Log in to browse and add community decks.',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pushNamed(context, Routes.login)
|
||||||
|
.then((_) => _load()),
|
||||||
|
child: const Text('Log in'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Community decks')),
|
||||||
|
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: 4),
|
||||||
|
Text(
|
||||||
|
'${d.questionCount} questions'
|
||||||
|
'${d.ownerDisplayName != null ? ' · ${d.ownerDisplayName}' : ''}',
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: FilledButton.tonal(
|
||||||
|
onPressed: () => _addToMyDecks(d),
|
||||||
|
child: const Text('Add'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -203,85 +203,95 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: SingleChildScrollView(
|
body: CustomScrollView(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
padding: const EdgeInsets.all(16),
|
slivers: [
|
||||||
child: Column(
|
SliverPadding(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
sliver: SliverList(
|
||||||
// Deck Title
|
delegate: SliverChildListDelegate([
|
||||||
TextField(
|
// Deck Title
|
||||||
controller: _titleController,
|
TextField(
|
||||||
decoration: const InputDecoration(
|
controller: _titleController,
|
||||||
labelText: 'Deck Title',
|
decoration: const InputDecoration(
|
||||||
border: OutlineInputBorder(),
|
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(() {}),
|
],
|
||||||
requestFocusOnPrompt: _focusNewQuestionIndex == index,
|
),
|
||||||
);
|
|
||||||
}),
|
|
||||||
|
|
||||||
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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
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';
|
||||||
|
|
||||||
@@ -9,10 +11,10 @@ class DeckListScreen extends StatefulWidget {
|
|||||||
const DeckListScreen({super.key});
|
const DeckListScreen({super.key});
|
||||||
|
|
||||||
@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 +22,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 +126,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
|
||||||
@@ -195,7 +267,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(
|
||||||
@@ -368,12 +441,86 @@ class _DeckListScreenState extends State<DeckListScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('omotomo'),
|
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: [
|
actions: [
|
||||||
IconButton(
|
ValueListenableBuilder<ApiUser?>(
|
||||||
icon: const Icon(Icons.add),
|
valueListenable: ApiAuthService.instance.currentUser,
|
||||||
tooltip: 'Add Deck',
|
builder: (context, user, _) {
|
||||||
onPressed: _showAddDeckOptions,
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (user != null) ...[
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 4),
|
||||||
|
child: PopupMenuButton<void>(
|
||||||
|
offset: const Offset(0, 40),
|
||||||
|
tooltip: 'Account',
|
||||||
|
child: CircleAvatar(
|
||||||
|
radius: 18,
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||||
|
child: Text(
|
||||||
|
_userInitials(user),
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
const PopupMenuItem<void>(
|
||||||
|
value: null,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.logout),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
Text('Log out'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onSelected: (_) async {
|
||||||
|
await ApiAuthService.instance.logout();
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.of(context).pushNamedAndRemoveUntil(
|
||||||
|
Routes.login,
|
||||||
|
(route) => false,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.sync),
|
||||||
|
tooltip: 'Sync from server',
|
||||||
|
onPressed: () => _loadDecks(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'deck_list_screen.dart';
|
||||||
|
import 'community_decks_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(
|
||||||
|
body: IndexedStack(
|
||||||
|
index: _currentIndex == 2 ? 1 : 0,
|
||||||
|
children: [
|
||||||
|
DeckListScreen(key: _deckListKey),
|
||||||
|
CommunityDecksScreen(key: _communityKey),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: () => _deckListKey.currentState?.showAddDeckOptions(),
|
||||||
|
tooltip: 'Add Deck',
|
||||||
|
child: const Icon(Icons.add),
|
||||||
|
),
|
||||||
|
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
|
||||||
|
bottomNavigationBar: BottomAppBar(
|
||||||
|
notchMargin: 8,
|
||||||
|
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: 16, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 22, color: selected ? Theme.of(context).colorScheme.primary : null),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: selected ? Theme.of(context).colorScheme.primary : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../routes.dart';
|
||||||
|
import '../services/api_auth_service.dart';
|
||||||
|
|
||||||
|
class LoginScreen extends StatefulWidget {
|
||||||
|
const LoginScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LoginScreen> createState() => _LoginScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoginScreenState extends State<LoginScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _emailController = TextEditingController();
|
||||||
|
final _passwordController = TextEditingController();
|
||||||
|
bool _loading = false;
|
||||||
|
String? _errorMessage;
|
||||||
|
bool _isRegister = false;
|
||||||
|
final _displayNameController = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_emailController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
_displayNameController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = null;
|
||||||
|
_loading = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
String? err;
|
||||||
|
if (_isRegister) {
|
||||||
|
err = await ApiAuthService.instance.register(
|
||||||
|
_emailController.text,
|
||||||
|
_passwordController.text,
|
||||||
|
_displayNameController.text,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
err = await ApiAuthService.instance.login(
|
||||||
|
_emailController.text,
|
||||||
|
_passwordController.text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_loading = false;
|
||||||
|
_errorMessage = err;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (err == null) {
|
||||||
|
Navigator.pushReplacementNamed(context, Routes.deckList);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(_isRegister ? 'Create account' : 'Log in'),
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
if (_isRegister) ...[
|
||||||
|
TextFormField(
|
||||||
|
controller: _displayNameController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Display name (optional)',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
TextFormField(
|
||||||
|
controller: _emailController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: _isRegister ? 'Email' : 'Email or username',
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
keyboardType: _isRegister ? TextInputType.emailAddress : TextInputType.text,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
validator: (v) {
|
||||||
|
if (v == null || v.trim().isEmpty) {
|
||||||
|
return _isRegister ? 'Enter your email.' : 'Enter your email or username.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _passwordController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Password',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
obscureText: true,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onFieldSubmitted: (_) => _submit(),
|
||||||
|
validator: (v) {
|
||||||
|
if (v == null || v.isEmpty) return 'Enter your password.';
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (_errorMessage != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
_errorMessage!,
|
||||||
|
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _loading
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
if (_formKey.currentState?.validate() ?? false) {
|
||||||
|
_submit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
),
|
||||||
|
child: _loading
|
||||||
|
? const SizedBox(
|
||||||
|
height: 24,
|
||||||
|
width: 24,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: Text(_isRegister ? 'Create account' : 'Log in'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextButton(
|
||||||
|
onPressed: _loading
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
setState(() {
|
||||||
|
_isRegister = !_isRegister;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
_isRegister
|
||||||
|
? 'Already have an account? Log in'
|
||||||
|
: 'No account? Create one',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../env.dart';
|
||||||
|
import '../utils/connection_error.dart';
|
||||||
|
|
||||||
|
/// Logged-in user from the API.
|
||||||
|
class ApiUser {
|
||||||
|
final String id;
|
||||||
|
final String? email;
|
||||||
|
final String? displayName;
|
||||||
|
|
||||||
|
const ApiUser({required this.id, this.email, this.displayName});
|
||||||
|
|
||||||
|
factory ApiUser.fromJson(Map<String, dynamic> json) {
|
||||||
|
final metadata = json['user_metadata'] as Map<String, dynamic>?;
|
||||||
|
return ApiUser(
|
||||||
|
id: json['id'] as String? ?? '',
|
||||||
|
email: json['email'] as String?,
|
||||||
|
displayName: json['display_name'] as String? ??
|
||||||
|
metadata?['display_name'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auth service that uses the backend REST API (login, token storage).
|
||||||
|
class ApiAuthService {
|
||||||
|
ApiAuthService._();
|
||||||
|
static final ApiAuthService _instance = ApiAuthService._();
|
||||||
|
static ApiAuthService get instance => _instance;
|
||||||
|
|
||||||
|
static const String _tokenKey = 'api_auth_token';
|
||||||
|
static const String _userKey = 'api_auth_user';
|
||||||
|
|
||||||
|
final ValueNotifier<ApiUser?> currentUser = ValueNotifier<ApiUser?>(null);
|
||||||
|
String? _token;
|
||||||
|
|
||||||
|
Future<SharedPreferences> get _prefs async =>
|
||||||
|
await SharedPreferences.getInstance();
|
||||||
|
|
||||||
|
/// Call after app start to restore session from stored token.
|
||||||
|
Future<void> init() async {
|
||||||
|
final prefs = await _prefs;
|
||||||
|
_token = prefs.getString(_tokenKey);
|
||||||
|
final userJson = prefs.getString(_userKey);
|
||||||
|
if (_token != null && userJson != null) {
|
||||||
|
try {
|
||||||
|
final map = jsonDecode(userJson) as Map<String, dynamic>;
|
||||||
|
currentUser.value = ApiUser.fromJson(map);
|
||||||
|
} catch (_) {
|
||||||
|
await logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (_token != null && currentUser.value == null) {
|
||||||
|
final ok = await _fetchSession();
|
||||||
|
if (!ok) await logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _fetchSession() async {
|
||||||
|
if (_token == null) return false;
|
||||||
|
try {
|
||||||
|
final uri = Uri.parse('$apiBaseUrl/api/auth/session');
|
||||||
|
final res = await http.get(
|
||||||
|
uri,
|
||||||
|
headers: {'Authorization': 'Bearer $_token'},
|
||||||
|
);
|
||||||
|
if (res.statusCode != 200) return false;
|
||||||
|
final data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||||
|
final userJson = data['user'] as Map<String, dynamic>?;
|
||||||
|
if (userJson == null) return false;
|
||||||
|
currentUser.value = ApiUser.fromJson(userJson);
|
||||||
|
final prefs = await _prefs;
|
||||||
|
await prefs.setString(_userKey, jsonEncode(userJson));
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current Bearer token for API requests, or null if not logged in.
|
||||||
|
String? get token => _token;
|
||||||
|
|
||||||
|
/// Login with email or username and password.
|
||||||
|
/// Returns null on success, or an error message string.
|
||||||
|
Future<String?> login(String emailOrUsername, String password) async {
|
||||||
|
final trimmed = emailOrUsername.trim();
|
||||||
|
if (trimmed.isEmpty) return 'Enter your email or username.';
|
||||||
|
if (password.isEmpty) return 'Enter your password.';
|
||||||
|
|
||||||
|
try {
|
||||||
|
final uri = Uri.parse('$apiBaseUrl/api/auth/login');
|
||||||
|
final res = await http.post(
|
||||||
|
uri,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({
|
||||||
|
'email_or_username': trimmed,
|
||||||
|
'password': password,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.statusCode == 401) {
|
||||||
|
final data = jsonDecode(res.body) as Map<String, dynamic>?;
|
||||||
|
return data?['error'] as String? ??
|
||||||
|
data?['message'] as String? ??
|
||||||
|
'Invalid email or password.';
|
||||||
|
}
|
||||||
|
if (res.statusCode != 200) {
|
||||||
|
final data = jsonDecode(res.body) as Map<String, dynamic>?;
|
||||||
|
return data?['error'] as String? ??
|
||||||
|
data?['message'] as String? ??
|
||||||
|
'Login failed. Please try again.';
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||||
|
_token = data['access_token'] as String?;
|
||||||
|
final userJson = data['user'] as Map<String, dynamic>?;
|
||||||
|
if (_token == null || userJson == null) {
|
||||||
|
return 'Invalid response from server.';
|
||||||
|
}
|
||||||
|
|
||||||
|
currentUser.value = ApiUser.fromJson(userJson);
|
||||||
|
final prefs = await _prefs;
|
||||||
|
await prefs.setString(_tokenKey, _token!);
|
||||||
|
await prefs.setString(_userKey, jsonEncode(userJson));
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
return connectionErrorMessage(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register with email, password, and optional display name.
|
||||||
|
/// Returns null on success, or an error message string.
|
||||||
|
Future<String?> register(String email, String password, String displayName) async {
|
||||||
|
final trimmedEmail = email.trim();
|
||||||
|
if (trimmedEmail.isEmpty) return 'Enter your email.';
|
||||||
|
if (password.isEmpty) return 'Enter your password.';
|
||||||
|
|
||||||
|
try {
|
||||||
|
final uri = Uri.parse('$apiBaseUrl/api/auth/register');
|
||||||
|
final res = await http.post(
|
||||||
|
uri,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({
|
||||||
|
'email': trimmedEmail,
|
||||||
|
'password': password,
|
||||||
|
'displayName': displayName.trim().isEmpty ? null : displayName.trim(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.statusCode == 400 || res.statusCode == 422) {
|
||||||
|
final data = jsonDecode(res.body) as Map<String, dynamic>?;
|
||||||
|
return data?['message'] as String? ?? 'Registration failed.';
|
||||||
|
}
|
||||||
|
if (res.statusCode != 200) {
|
||||||
|
return 'Registration failed. Please try again.';
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||||
|
_token = data['access_token'] as String?;
|
||||||
|
final userJson = data['user'] as Map<String, dynamic>?;
|
||||||
|
if (_token == null || userJson == null) {
|
||||||
|
return 'Invalid response from server.';
|
||||||
|
}
|
||||||
|
|
||||||
|
currentUser.value = ApiUser.fromJson(userJson);
|
||||||
|
final prefs = await _prefs;
|
||||||
|
await prefs.setString(_tokenKey, _token!);
|
||||||
|
await prefs.setString(_userKey, jsonEncode(userJson));
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
return connectionErrorMessage(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> logout() async {
|
||||||
|
_token = null;
|
||||||
|
currentUser.value = null;
|
||||||
|
final prefs = await _prefs;
|
||||||
|
await prefs.remove(_tokenKey);
|
||||||
|
await prefs.remove(_userKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,9 +45,12 @@ class DeckStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert Deck to JSON Map
|
/// 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,
|
||||||
@@ -96,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(
|
||||||
@@ -284,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
|
||||||
@@ -298,15 +341,14 @@ class DeckStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Synchronous version for backward compatibility
|
/// Synchronous version for backward compatibility. Pass [syncMetadata] to set or preserve sync info.
|
||||||
void saveDeckSync(Deck deck) {
|
void saveDeckSync(Deck deck, {Map<String, dynamic>? syncMetadata}) {
|
||||||
if (!_initialized || _prefs == null) {
|
if (!_initialized || _prefs == null) {
|
||||||
// Queue for async save
|
_ensureInitialized().then((_) => saveDeck(deck, syncMetadata: syncMetadata));
|
||||||
_ensureInitialized().then((_) => saveDeck(deck));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
Map<String, dynamic>? sync = syncMetadata ?? getDeckSyncMetadataSync(deck.id);
|
||||||
final deckJson = jsonEncode(_deckToJson(deck));
|
final deckJson = jsonEncode(_deckToJson(deck, syncMetadata: sync));
|
||||||
_prefs!.setString('$_decksKey:${deck.id}', deckJson);
|
_prefs!.setString('$_decksKey:${deck.id}', deckJson);
|
||||||
|
|
||||||
// Update deck IDs list
|
// Update deck IDs list
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:practice_engine/practice_engine.dart';
|
||||||
|
|
||||||
|
import '../env.dart';
|
||||||
|
import 'api_auth_service.dart';
|
||||||
|
|
||||||
|
/// Sync metadata for a deck that came from the API (server id, copy source, etc.).
|
||||||
|
class DeckSyncMetadata {
|
||||||
|
final String serverDeckId;
|
||||||
|
final String? ownerId;
|
||||||
|
final String? copiedFromDeckId;
|
||||||
|
final int? copiedFromVersion;
|
||||||
|
final bool published;
|
||||||
|
final bool needsUpdate;
|
||||||
|
|
||||||
|
const DeckSyncMetadata({
|
||||||
|
required this.serverDeckId,
|
||||||
|
this.ownerId,
|
||||||
|
this.copiedFromDeckId,
|
||||||
|
this.copiedFromVersion,
|
||||||
|
this.published = false,
|
||||||
|
this.needsUpdate = false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remote deck list item (from GET /api/decks/mine or /api/decks/published).
|
||||||
|
class RemoteDeckListItem {
|
||||||
|
final String id;
|
||||||
|
final String title;
|
||||||
|
final String description;
|
||||||
|
final int questionCount;
|
||||||
|
final bool userHasThis;
|
||||||
|
final bool needsUpdate;
|
||||||
|
final String? copiedFromDeckId;
|
||||||
|
final String? ownerDisplayName;
|
||||||
|
final double? averageRating;
|
||||||
|
final int? ratingCount;
|
||||||
|
|
||||||
|
const RemoteDeckListItem({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
required this.description,
|
||||||
|
required this.questionCount,
|
||||||
|
this.userHasThis = false,
|
||||||
|
this.needsUpdate = false,
|
||||||
|
this.copiedFromDeckId,
|
||||||
|
this.ownerDisplayName,
|
||||||
|
this.averageRating,
|
||||||
|
this.ratingCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update preview for a community copy (from GET /api/decks/:id/update-preview).
|
||||||
|
class UpdatePreview {
|
||||||
|
final List<String> changes;
|
||||||
|
|
||||||
|
const UpdatePreview({required this.changes});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTTP client for the deck REST API.
|
||||||
|
class RemoteDeckService {
|
||||||
|
RemoteDeckService._();
|
||||||
|
static final RemoteDeckService _instance = RemoteDeckService._();
|
||||||
|
static RemoteDeckService get instance => _instance;
|
||||||
|
|
||||||
|
String? get _token => ApiAuthService.instance.token;
|
||||||
|
|
||||||
|
Future<Map<String, String>> get _headers async {
|
||||||
|
final t = _token;
|
||||||
|
if (t == null) return {'Content-Type': 'application/json'};
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer $t',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void _checkResponse(http.Response res) {
|
||||||
|
if (res.statusCode >= 400) {
|
||||||
|
throw RemoteDeckException(
|
||||||
|
statusCode: res.statusCode,
|
||||||
|
body: res.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _stringOrNull(dynamic v) {
|
||||||
|
if (v == null) return null;
|
||||||
|
final s = v.toString().trim();
|
||||||
|
return s.isEmpty ? null : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<RemoteDeckListItem> _parseDeckList(dynamic body) {
|
||||||
|
if (body is List) {
|
||||||
|
return body.map((e) => _parseDeckListItem(e as Map<String, dynamic>)).toList();
|
||||||
|
}
|
||||||
|
if (body is Map<String, dynamic>) {
|
||||||
|
final list = body['decks'] as List<dynamic>? ?? [];
|
||||||
|
return list.map((e) => _parseDeckListItem(e as Map<String, dynamic>)).toList();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/decks/mine
|
||||||
|
Future<List<RemoteDeckListItem>> getMyDecks() async {
|
||||||
|
final uri = Uri.parse('$apiBaseUrl/api/decks/mine');
|
||||||
|
final res = await http.get(uri, headers: await _headers);
|
||||||
|
_checkResponse(res);
|
||||||
|
final body = jsonDecode(res.body);
|
||||||
|
return _parseDeckList(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/decks/published
|
||||||
|
Future<List<RemoteDeckListItem>> getPublishedDecks() async {
|
||||||
|
final uri = Uri.parse('$apiBaseUrl/api/decks/published');
|
||||||
|
final res = await http.get(uri, headers: await _headers);
|
||||||
|
_checkResponse(res);
|
||||||
|
final body = jsonDecode(res.body);
|
||||||
|
return _parseDeckList(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
RemoteDeckListItem _parseDeckListItem(Map<String, dynamic> json) {
|
||||||
|
return RemoteDeckListItem(
|
||||||
|
id: (json['id']?.toString() ?? '').trim(),
|
||||||
|
title: json['title'] as String? ?? '',
|
||||||
|
description: json['description'] as String? ?? '',
|
||||||
|
questionCount: json['question_count'] as int? ?? 0,
|
||||||
|
userHasThis: json['user_has_this'] as bool? ?? false,
|
||||||
|
needsUpdate: json['needs_update'] as bool? ?? false,
|
||||||
|
copiedFromDeckId: _stringOrNull(json['copied_from_deck_id']),
|
||||||
|
ownerDisplayName: json['owner_display_name'] as String?,
|
||||||
|
averageRating: (json['average_rating'] as num?)?.toDouble(),
|
||||||
|
ratingCount: json['rating_count'] as int?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/decks/:id — full deck with questions.
|
||||||
|
Future<Deck> getDeck(String deckId) async {
|
||||||
|
final uri = Uri.parse('$apiBaseUrl/api/decks/$deckId');
|
||||||
|
final res = await http.get(uri, headers: await _headers);
|
||||||
|
_checkResponse(res);
|
||||||
|
final json = jsonDecode(res.body) as Map<String, dynamic>;
|
||||||
|
return _jsonToDeck(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/decks — create deck; returns new deck id.
|
||||||
|
Future<String> createDeck({
|
||||||
|
required String title,
|
||||||
|
required String description,
|
||||||
|
Map<String, dynamic>? config,
|
||||||
|
List<Map<String, dynamic>>? questions,
|
||||||
|
String? copiedFromDeckId,
|
||||||
|
int? copiedFromVersion,
|
||||||
|
}) async {
|
||||||
|
final uri = Uri.parse('$apiBaseUrl/api/decks');
|
||||||
|
final body = <String, dynamic>{
|
||||||
|
'title': title,
|
||||||
|
'description': description,
|
||||||
|
if (config != null) 'config': config,
|
||||||
|
if (questions != null) 'questions': questions,
|
||||||
|
if (copiedFromDeckId != null) 'copiedFromDeckId': copiedFromDeckId,
|
||||||
|
if (copiedFromVersion != null) 'copiedFromVersion': copiedFromVersion,
|
||||||
|
};
|
||||||
|
final res = await http.post(
|
||||||
|
uri,
|
||||||
|
headers: await _headers,
|
||||||
|
body: jsonEncode(body),
|
||||||
|
);
|
||||||
|
_checkResponse(res);
|
||||||
|
final data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||||
|
return data['id'] as String? ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PATCH /api/decks/:id
|
||||||
|
Future<void> updateDeck(
|
||||||
|
String deckId, {
|
||||||
|
String? title,
|
||||||
|
String? description,
|
||||||
|
Map<String, dynamic>? config,
|
||||||
|
List<Map<String, dynamic>>? questions,
|
||||||
|
}) async {
|
||||||
|
final uri = Uri.parse('$apiBaseUrl/api/decks/$deckId');
|
||||||
|
final body = <String, dynamic>{};
|
||||||
|
if (title != null) body['title'] = title;
|
||||||
|
if (description != null) body['description'] = description;
|
||||||
|
if (config != null) body['config'] = config;
|
||||||
|
if (questions != null) body['questions'] = questions;
|
||||||
|
final res = await http.patch(
|
||||||
|
uri,
|
||||||
|
headers: await _headers,
|
||||||
|
body: jsonEncode(body),
|
||||||
|
);
|
||||||
|
_checkResponse(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/decks/:id/publish
|
||||||
|
Future<void> setPublished(String deckId, bool published) async {
|
||||||
|
final uri = Uri.parse('$apiBaseUrl/api/decks/$deckId/publish');
|
||||||
|
final res = await http.post(
|
||||||
|
uri,
|
||||||
|
headers: await _headers,
|
||||||
|
body: jsonEncode({'published': published}),
|
||||||
|
);
|
||||||
|
_checkResponse(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/decks/:id/copy — copy published deck to current user; returns new deck id.
|
||||||
|
Future<String> copyDeck(String deckId) async {
|
||||||
|
final uri = Uri.parse('$apiBaseUrl/api/decks/$deckId/copy');
|
||||||
|
final res = await http.post(uri, headers: await _headers);
|
||||||
|
_checkResponse(res);
|
||||||
|
final data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||||
|
return data['id'] as String? ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/decks/:id/update-preview
|
||||||
|
Future<UpdatePreview> getUpdatePreview(String copyDeckId) async {
|
||||||
|
final uri = Uri.parse('$apiBaseUrl/api/decks/$copyDeckId/update-preview');
|
||||||
|
final res = await http.get(uri, headers: await _headers);
|
||||||
|
_checkResponse(res);
|
||||||
|
final data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||||
|
final changes = (data['changes'] as List<dynamic>?)?.cast<String>() ?? [];
|
||||||
|
return UpdatePreview(changes: changes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/decks/:id/apply-update
|
||||||
|
Future<void> applyUpdate(String copyDeckId) async {
|
||||||
|
final uri = Uri.parse('$apiBaseUrl/api/decks/$copyDeckId/apply-update');
|
||||||
|
final res = await http.post(uri, headers: await _headers);
|
||||||
|
_checkResponse(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteDeck(String deckId) async {
|
||||||
|
final uri = Uri.parse('$apiBaseUrl/api/decks/$deckId');
|
||||||
|
final res = await http.delete(uri, headers: await _headers);
|
||||||
|
_checkResponse(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
Deck _jsonToDeck(Map<String, dynamic> json) {
|
||||||
|
final configJson = json['config'] as Map<String, dynamic>? ?? {};
|
||||||
|
final config = DeckConfig(
|
||||||
|
requiredConsecutiveCorrect: configJson['requiredConsecutiveCorrect'] as int? ?? 3,
|
||||||
|
defaultAttemptSize: configJson['defaultAttemptSize'] as int? ?? 10,
|
||||||
|
priorityIncreaseOnIncorrect: configJson['priorityIncreaseOnIncorrect'] as int? ?? 5,
|
||||||
|
priorityDecreaseOnCorrect: configJson['priorityDecreaseOnCorrect'] as int? ?? 2,
|
||||||
|
immediateFeedbackEnabled: configJson['immediateFeedbackEnabled'] as bool? ?? true,
|
||||||
|
includeKnownInAttempts: configJson['includeKnownInAttempts'] as bool? ?? false,
|
||||||
|
shuffleAnswerOrder: configJson['shuffleAnswerOrder'] as bool? ?? true,
|
||||||
|
excludeFlaggedQuestions: configJson['excludeFlaggedQuestions'] as bool? ?? false,
|
||||||
|
timeLimitSeconds: configJson['timeLimitSeconds'] as int?,
|
||||||
|
);
|
||||||
|
|
||||||
|
final questionsJson = json['questions'] as List<dynamic>? ?? [];
|
||||||
|
final questions = questionsJson.asMap().entries.map((entry) {
|
||||||
|
final i = entry.key;
|
||||||
|
final qJson = entry.value as Map<String, dynamic>;
|
||||||
|
return _jsonToQuestion(qJson, defaultId: 'q$i');
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return Deck(
|
||||||
|
id: json['id'] as String? ?? '',
|
||||||
|
title: json['title'] as String? ?? '',
|
||||||
|
description: json['description'] as String? ?? '',
|
||||||
|
questions: questions,
|
||||||
|
config: config,
|
||||||
|
currentAttemptIndex: 0,
|
||||||
|
attemptHistory: const [],
|
||||||
|
incompleteAttempt: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Question _jsonToQuestion(Map<String, dynamic> q, {String? defaultId}) {
|
||||||
|
List<int>? correctIndices;
|
||||||
|
if (q['correct_answer_indices'] != null) {
|
||||||
|
final arr = q['correct_answer_indices'] as List<dynamic>?;
|
||||||
|
correctIndices = arr?.map((e) => e as int).toList();
|
||||||
|
}
|
||||||
|
if (correctIndices == null && q['correctAnswerIndices'] != null) {
|
||||||
|
final arr = q['correctAnswerIndices'] as List<dynamic>?;
|
||||||
|
correctIndices = arr?.map((e) => e as int).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Question(
|
||||||
|
id: q['id'] as String? ?? defaultId ?? '',
|
||||||
|
prompt: q['prompt'] as String? ?? '',
|
||||||
|
explanation: q['explanation'] as String?,
|
||||||
|
answers: (q['answers'] as List<dynamic>?)
|
||||||
|
?.map((e) => e.toString())
|
||||||
|
.toList() ??
|
||||||
|
[],
|
||||||
|
correctAnswerIndices: correctIndices ?? [0],
|
||||||
|
consecutiveCorrect: q['consecutiveCorrect'] as int? ?? 0,
|
||||||
|
isKnown: q['isKnown'] as bool? ?? false,
|
||||||
|
isFlagged: q['isFlagged'] as bool? ?? false,
|
||||||
|
priorityPoints: q['priorityPoints'] as int? ?? 0,
|
||||||
|
lastAttemptIndex: q['lastAttemptIndex'] as int? ?? -1,
|
||||||
|
totalCorrectAttempts: q['totalCorrectAttempts'] as int? ?? 0,
|
||||||
|
totalAttempts: q['totalAttempts'] as int? ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<Map<String, dynamic>> deckQuestionsToApi(List<Question> questions) {
|
||||||
|
return questions.asMap().entries.map((entry) {
|
||||||
|
final q = entry.value;
|
||||||
|
return {
|
||||||
|
'prompt': q.prompt,
|
||||||
|
'explanation': q.explanation,
|
||||||
|
'answers': q.answers,
|
||||||
|
'correct_answer_indices': q.correctAnswerIndices,
|
||||||
|
};
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, dynamic> deckConfigToApi(DeckConfig config) {
|
||||||
|
return {
|
||||||
|
'requiredConsecutiveCorrect': config.requiredConsecutiveCorrect,
|
||||||
|
'defaultAttemptSize': config.defaultAttemptSize,
|
||||||
|
'priorityIncreaseOnIncorrect': config.priorityIncreaseOnIncorrect,
|
||||||
|
'priorityDecreaseOnCorrect': config.priorityDecreaseOnCorrect,
|
||||||
|
'immediateFeedbackEnabled': config.immediateFeedbackEnabled,
|
||||||
|
'includeKnownInAttempts': config.includeKnownInAttempts,
|
||||||
|
'shuffleAnswerOrder': config.shuffleAnswerOrder,
|
||||||
|
'excludeFlaggedQuestions': config.excludeFlaggedQuestions,
|
||||||
|
'timeLimitSeconds': config.timeLimitSeconds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RemoteDeckException implements Exception {
|
||||||
|
final int statusCode;
|
||||||
|
final String body;
|
||||||
|
|
||||||
|
const RemoteDeckException({required this.statusCode, required this.body});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'RemoteDeckException($statusCode): $body';
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
/// User-facing message when an API/network request fails.
|
||||||
|
String connectionErrorMessage(Object error) {
|
||||||
|
if (error is SocketException ||
|
||||||
|
error is TimeoutException ||
|
||||||
|
error is http.ClientException ||
|
||||||
|
error is HandshakeException ||
|
||||||
|
error is TlsException) {
|
||||||
|
return 'Connection to server has broken. Check API URL and network.';
|
||||||
|
}
|
||||||
|
return 'Network error. Please check the API URL and try again.';
|
||||||
|
}
|
||||||
@@ -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