Compare commits
No commits in common. 'master' and '1.0' have entirely different histories.
@ -1,6 +0,0 @@
|
||||
# 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
|
||||
@ -1,10 +0,0 @@
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
|
||||
/// API base URL (no trailing slash). Loaded from .env as API_BASE_URL.
|
||||
String get apiBaseUrl {
|
||||
final url = dotenv.env['API_BASE_URL']?.trim();
|
||||
if (url == null || url.isEmpty) {
|
||||
return 'http://localhost:3001';
|
||||
}
|
||||
return url.endsWith('/') ? url.substring(0, url.length - 1) : url;
|
||||
}
|
||||
@ -1,312 +0,0 @@
|
||||
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,195 +0,0 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,166 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../services/api_auth_service.dart';
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
bool _loading = false;
|
||||
String? _errorMessage;
|
||||
bool _isRegister = false;
|
||||
final _displayNameController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
_displayNameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
setState(() {
|
||||
_errorMessage = null;
|
||||
_loading = true;
|
||||
});
|
||||
|
||||
String? err;
|
||||
if (_isRegister) {
|
||||
err = await ApiAuthService.instance.register(
|
||||
_emailController.text,
|
||||
_passwordController.text,
|
||||
_displayNameController.text,
|
||||
);
|
||||
} else {
|
||||
err = await ApiAuthService.instance.login(
|
||||
_emailController.text,
|
||||
_passwordController.text,
|
||||
);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_errorMessage = err;
|
||||
});
|
||||
|
||||
if (err == null) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_isRegister ? 'Create account' : 'Log in'),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 24),
|
||||
if (_isRegister) ...[
|
||||
TextFormField(
|
||||
controller: _displayNameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Display name (optional)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
decoration: InputDecoration(
|
||||
labelText: _isRegister ? 'Email' : 'Email or username',
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: _isRegister ? TextInputType.emailAddress : TextInputType.text,
|
||||
textInputAction: TextInputAction.next,
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().isEmpty) {
|
||||
return _isRegister ? 'Enter your email.' : 'Enter your email or username.';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Password',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
obscureText: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) => _submit(),
|
||||
validator: (v) {
|
||||
if (v == null || v.isEmpty) return 'Enter your password.';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _loading
|
||||
? null
|
||||
: () {
|
||||
if (_formKey.currentState?.validate() ?? false) {
|
||||
_submit();
|
||||
}
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
child: _loading
|
||||
? const SizedBox(
|
||||
height: 24,
|
||||
width: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(_isRegister ? 'Create account' : 'Log in'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: _loading
|
||||
? null
|
||||
: () {
|
||||
setState(() {
|
||||
_isRegister = !_isRegister;
|
||||
_errorMessage = null;
|
||||
});
|
||||
},
|
||||
child: Text(
|
||||
_isRegister
|
||||
? 'Already have an account? Log in'
|
||||
: 'No account? Create one',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,257 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -1,338 +0,0 @@
|
||||
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';
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
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.';
|
||||
}
|
||||
Loading…
Reference in new issue