clud features

master
gitea 2 weeks ago
parent b67e562423
commit fb96745975

@ -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;
}

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

@ -1,5 +1,5 @@
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_overview_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_result_screen.dart';
import 'screens/flagged_questions_screen.dart';
import 'screens/login_screen.dart';
import 'screens/community_decks_screen.dart';
class Routes {
static const String deckList = '/';
@ -19,10 +21,14 @@ class Routes {
static const String attempt = '/attempt';
static const String attemptResult = '/attempt-result';
static const String flaggedQuestions = '/flagged-questions';
static const String login = '/login';
static const String community = '/community';
static Map<String, WidgetBuilder> get routes {
return {
deckList: (context) => const DeckListScreen(),
deckList: (context) => const HomeScreen(),
login: (context) => const LoginScreen(),
community: (context) => const CommunityDecksScreen(),
deckImport: (context) => const DeckImportScreen(),
deckOverview: (context) => const DeckOverviewScreen(),
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,12 +203,13 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
),
],
),
body: SingleChildScrollView(
body: CustomScrollView(
controller: _scrollController,
slivers: [
SliverPadding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
sliver: SliverList(
delegate: SliverChildListDelegate([
// Deck Title
TextField(
controller: _titleController,
@ -218,7 +219,6 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
),
),
const SizedBox(height: 16),
// Deck Description
TextField(
controller: _descriptionController,
@ -229,29 +229,19 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
maxLines: 3,
),
const SizedBox(height: 24),
// Questions Section
Text(
'Questions',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
// Questions List
...List.generate(_questionEditors.length, (index) {
return QuestionEditorCard(
key: ValueKey('question_$index'),
editor: _questionEditors[index],
questionNumber: index + 1,
onDelete: () => _removeQuestion(index),
onUnflag: null,
onChanged: () => setState(() {}),
requestFocusOnPrompt: _focusNewQuestionIndex == index,
);
}),
]),
),
),
if (_questionEditors.isEmpty)
Card(
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverToBoxAdapter(
child: Card(
child: Padding(
padding: const EdgeInsets.all(32),
child: Center(
@ -279,9 +269,29 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
),
),
),
],
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverList.builder(
itemCount: _questionEditors.length,
itemBuilder: (context, index) {
return RepaintBoundary(
child: QuestionEditorCard(
key: ValueKey('question_$index'),
editor: _questionEditors[index],
questionNumber: index + 1,
onDelete: () => _removeQuestion(index),
onUnflag: null,
onChanged: () => setState(() {}),
requestFocusOnPrompt: _focusNewQuestionIndex == index,
),
);
},
),
),
],
),
);
}

@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:practice_engine/practice_engine.dart';
import '../routes.dart';
import '../services/api_auth_service.dart';
import '../services/deck_storage.dart';
import '../services/remote_deck_service.dart';
import '../data/default_deck.dart';
import '../utils/top_snackbar.dart';
@ -9,10 +11,10 @@ class DeckListScreen extends StatefulWidget {
const DeckListScreen({super.key});
@override
State<DeckListScreen> createState() => _DeckListScreenState();
State<DeckListScreen> createState() => DeckListScreenState();
}
class _DeckListScreenState extends State<DeckListScreen> {
class DeckListScreenState extends State<DeckListScreen> {
final DeckStorage _deckStorage = DeckStorage();
List<Deck> _decks = [];
@ -20,21 +22,75 @@ class _DeckListScreenState extends State<DeckListScreen> {
void initState() {
super.initState();
_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() {
// Use sync version for immediate UI update, will work once storage is initialized
setState(() {
_decks = _deckStorage.getAllDecksSync();
});
// Also trigger async load to ensure we have latest data
_deckStorage.getAllDecks().then((decks) {
if (mounted) {
setState(() {
_decks = decks;
});
_loadDecksAsync();
}
/// Server deck ids we already have locally (by deck.id or sync.server_deck_id).
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) {
@ -70,7 +126,8 @@ class _DeckListScreenState extends State<DeckListScreen> {
),
);
if (confirmed == true) {
if (confirmed != true || !mounted) return;
_deckStorage.deleteDeckSync(deck.id);
_loadDecks();
if (mounted) {
@ -81,6 +138,21 @@ class _DeckListScreenState extends State<DeckListScreen> {
);
}
}
static String _userInitials(ApiUser user) {
final name = user.displayName?.trim();
if (name != null && name.isNotEmpty) {
final parts = name.split(RegExp(r'\s+'));
if (parts.length >= 2) {
return '${parts[0][0]}${parts[1][0]}'.toUpperCase();
}
return name.substring(0, name.length.clamp(0, 2)).toUpperCase();
}
final email = user.email?.trim();
if (email != null && email.isNotEmpty) {
return email[0].toUpperCase();
}
return '?';
}
void _navigateToImport() {
@ -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(
context: context,
builder: (context) => SafeArea(
@ -368,12 +441,86 @@ class _DeckListScreenState extends State<DeckListScreen> {
Widget build(BuildContext context) {
return Scaffold(
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: [
ValueListenableBuilder<ApiUser?>(
valueListenable: ApiAuthService.instance.currentUser,
builder: (context, user, _) {
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.add),
tooltip: 'Add Deck',
onPressed: _showAddDeckOptions,
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
Map<String, dynamic> _deckToJson(Deck deck) {
return {
/// Sync metadata key in stored JSON (not part of Deck model).
static const String _syncKey = '_sync';
/// Convert Deck to JSON Map. Optionally include [syncMetadata] for server-synced decks.
Map<String, dynamic> _deckToJson(Deck deck, {Map<String, dynamic>? syncMetadata}) {
final map = <String, dynamic>{
'id': deck.id,
'title': deck.title,
'description': deck.description,
@ -96,10 +99,15 @@ class DeckStorage {
'remainingSeconds': deck.incompleteAttempt!.remainingSeconds,
} : null,
};
if (syncMetadata != null && syncMetadata.isNotEmpty) {
map[_syncKey] = syncMetadata;
}
return map;
}
/// Convert JSON Map to Deck
/// Convert JSON Map to Deck. Strips [_syncKey] from json (use getDeckSyncMetadata to read it).
Deck _jsonToDeck(Map<String, dynamic> json) {
json = Map<String, dynamic>.from(json)..remove(_syncKey);
// Parse config
final configJson = json['config'] as Map<String, dynamic>? ?? {};
final config = DeckConfig(
@ -284,10 +292,45 @@ class DeckStorage {
}
}
/// Add or update a deck
Future<void> saveDeck(Deck deck) async {
/// Sync metadata for a deck (server_deck_id, owner_id, copied_from_deck_id, etc.). Null if not set.
Future<Map<String, dynamic>?> getDeckSyncMetadata(String deckId) async {
await _ensureInitialized();
final deckJson = _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();
final deckJson = jsonEncode(_deckToJson(deck));
Map<String, dynamic>? sync = syncMetadata;
if (sync == null) {
final existing = await getDeckSyncMetadata(deck.id);
if (existing != null) sync = existing;
}
final deckJson = jsonEncode(_deckToJson(deck, syncMetadata: sync));
await _prefs!.setString('$_decksKey:${deck.id}', deckJson);
// Update deck IDs list
@ -298,15 +341,14 @@ class DeckStorage {
}
}
/// Synchronous version for backward compatibility
void saveDeckSync(Deck deck) {
/// Synchronous version for backward compatibility. Pass [syncMetadata] to set or preserve sync info.
void saveDeckSync(Deck deck, {Map<String, dynamic>? syncMetadata}) {
if (!_initialized || _prefs == null) {
// Queue for async save
_ensureInitialized().then((_) => saveDeck(deck));
_ensureInitialized().then((_) => saveDeck(deck, syncMetadata: syncMetadata));
return;
}
final deckJson = jsonEncode(_deckToJson(deck));
Map<String, dynamic>? sync = syncMetadata ?? getDeckSyncMetadataSync(deck.id);
final deckJson = jsonEncode(_deckToJson(deck, syncMetadata: sync));
_prefs!.setString('$_decksKey:${deck.id}', deckJson);
// Update deck IDs list

@ -0,0 +1,338 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:practice_engine/practice_engine.dart';
import '../env.dart';
import 'api_auth_service.dart';
/// Sync metadata for a deck that came from the API (server id, copy source, etc.).
class DeckSyncMetadata {
final String serverDeckId;
final String? ownerId;
final String? copiedFromDeckId;
final int? copiedFromVersion;
final bool published;
final bool needsUpdate;
const DeckSyncMetadata({
required this.serverDeckId,
this.ownerId,
this.copiedFromDeckId,
this.copiedFromVersion,
this.published = false,
this.needsUpdate = false,
});
}
/// Remote deck list item (from GET /api/decks/mine or /api/decks/published).
class RemoteDeckListItem {
final String id;
final String title;
final String description;
final int questionCount;
final bool userHasThis;
final bool needsUpdate;
final String? copiedFromDeckId;
final String? ownerDisplayName;
final double? averageRating;
final int? ratingCount;
const RemoteDeckListItem({
required this.id,
required this.title,
required this.description,
required this.questionCount,
this.userHasThis = false,
this.needsUpdate = false,
this.copiedFromDeckId,
this.ownerDisplayName,
this.averageRating,
this.ratingCount,
});
}
/// Update preview for a community copy (from GET /api/decks/:id/update-preview).
class UpdatePreview {
final List<String> changes;
const UpdatePreview({required this.changes});
}
/// HTTP client for the deck REST API.
class RemoteDeckService {
RemoteDeckService._();
static final RemoteDeckService _instance = RemoteDeckService._();
static RemoteDeckService get instance => _instance;
String? get _token => ApiAuthService.instance.token;
Future<Map<String, String>> get _headers async {
final t = _token;
if (t == null) return {'Content-Type': 'application/json'};
return {
'Content-Type': 'application/json',
'Authorization': 'Bearer $t',
};
}
void _checkResponse(http.Response res) {
if (res.statusCode >= 400) {
throw RemoteDeckException(
statusCode: res.statusCode,
body: res.body,
);
}
}
static String? _stringOrNull(dynamic v) {
if (v == null) return null;
final s = v.toString().trim();
return s.isEmpty ? null : s;
}
List<RemoteDeckListItem> _parseDeckList(dynamic body) {
if (body is List) {
return body.map((e) => _parseDeckListItem(e as Map<String, dynamic>)).toList();
}
if (body is Map<String, dynamic>) {
final list = body['decks'] as List<dynamic>? ?? [];
return list.map((e) => _parseDeckListItem(e as Map<String, dynamic>)).toList();
}
return [];
}
/// GET /api/decks/mine
Future<List<RemoteDeckListItem>> getMyDecks() async {
final uri = Uri.parse('$apiBaseUrl/api/decks/mine');
final res = await http.get(uri, headers: await _headers);
_checkResponse(res);
final body = jsonDecode(res.body);
return _parseDeckList(body);
}
/// GET /api/decks/published
Future<List<RemoteDeckListItem>> getPublishedDecks() async {
final uri = Uri.parse('$apiBaseUrl/api/decks/published');
final res = await http.get(uri, headers: await _headers);
_checkResponse(res);
final body = jsonDecode(res.body);
return _parseDeckList(body);
}
RemoteDeckListItem _parseDeckListItem(Map<String, dynamic> json) {
return RemoteDeckListItem(
id: (json['id']?.toString() ?? '').trim(),
title: json['title'] as String? ?? '',
description: json['description'] as String? ?? '',
questionCount: json['question_count'] as int? ?? 0,
userHasThis: json['user_has_this'] as bool? ?? false,
needsUpdate: json['needs_update'] as bool? ?? false,
copiedFromDeckId: _stringOrNull(json['copied_from_deck_id']),
ownerDisplayName: json['owner_display_name'] as String?,
averageRating: (json['average_rating'] as num?)?.toDouble(),
ratingCount: json['rating_count'] as int?,
);
}
/// GET /api/decks/:id full deck with questions.
Future<Deck> getDeck(String deckId) async {
final uri = Uri.parse('$apiBaseUrl/api/decks/$deckId');
final res = await http.get(uri, headers: await _headers);
_checkResponse(res);
final json = jsonDecode(res.body) as Map<String, dynamic>;
return _jsonToDeck(json);
}
/// POST /api/decks create deck; returns new deck id.
Future<String> createDeck({
required String title,
required String description,
Map<String, dynamic>? config,
List<Map<String, dynamic>>? questions,
String? copiedFromDeckId,
int? copiedFromVersion,
}) async {
final uri = Uri.parse('$apiBaseUrl/api/decks');
final body = <String, dynamic>{
'title': title,
'description': description,
if (config != null) 'config': config,
if (questions != null) 'questions': questions,
if (copiedFromDeckId != null) 'copiedFromDeckId': copiedFromDeckId,
if (copiedFromVersion != null) 'copiedFromVersion': copiedFromVersion,
};
final res = await http.post(
uri,
headers: await _headers,
body: jsonEncode(body),
);
_checkResponse(res);
final data = jsonDecode(res.body) as Map<String, dynamic>;
return data['id'] as String? ?? '';
}
/// PATCH /api/decks/:id
Future<void> updateDeck(
String deckId, {
String? title,
String? description,
Map<String, dynamic>? config,
List<Map<String, dynamic>>? questions,
}) async {
final uri = Uri.parse('$apiBaseUrl/api/decks/$deckId');
final body = <String, dynamic>{};
if (title != null) body['title'] = title;
if (description != null) body['description'] = description;
if (config != null) body['config'] = config;
if (questions != null) body['questions'] = questions;
final res = await http.patch(
uri,
headers: await _headers,
body: jsonEncode(body),
);
_checkResponse(res);
}
/// POST /api/decks/:id/publish
Future<void> setPublished(String deckId, bool published) async {
final uri = Uri.parse('$apiBaseUrl/api/decks/$deckId/publish');
final res = await http.post(
uri,
headers: await _headers,
body: jsonEncode({'published': published}),
);
_checkResponse(res);
}
/// POST /api/decks/:id/copy copy published deck to current user; returns new deck id.
Future<String> copyDeck(String deckId) async {
final uri = Uri.parse('$apiBaseUrl/api/decks/$deckId/copy');
final res = await http.post(uri, headers: await _headers);
_checkResponse(res);
final data = jsonDecode(res.body) as Map<String, dynamic>;
return data['id'] as String? ?? '';
}
/// GET /api/decks/:id/update-preview
Future<UpdatePreview> getUpdatePreview(String copyDeckId) async {
final uri = Uri.parse('$apiBaseUrl/api/decks/$copyDeckId/update-preview');
final res = await http.get(uri, headers: await _headers);
_checkResponse(res);
final data = jsonDecode(res.body) as Map<String, dynamic>;
final changes = (data['changes'] as List<dynamic>?)?.cast<String>() ?? [];
return UpdatePreview(changes: changes);
}
/// POST /api/decks/:id/apply-update
Future<void> applyUpdate(String copyDeckId) async {
final uri = Uri.parse('$apiBaseUrl/api/decks/$copyDeckId/apply-update');
final res = await http.post(uri, headers: await _headers);
_checkResponse(res);
}
Future<void> deleteDeck(String deckId) async {
final uri = Uri.parse('$apiBaseUrl/api/decks/$deckId');
final res = await http.delete(uri, headers: await _headers);
_checkResponse(res);
}
Deck _jsonToDeck(Map<String, dynamic> json) {
final configJson = json['config'] as Map<String, dynamic>? ?? {};
final config = DeckConfig(
requiredConsecutiveCorrect: configJson['requiredConsecutiveCorrect'] as int? ?? 3,
defaultAttemptSize: configJson['defaultAttemptSize'] as int? ?? 10,
priorityIncreaseOnIncorrect: configJson['priorityIncreaseOnIncorrect'] as int? ?? 5,
priorityDecreaseOnCorrect: configJson['priorityDecreaseOnCorrect'] as int? ?? 2,
immediateFeedbackEnabled: configJson['immediateFeedbackEnabled'] as bool? ?? true,
includeKnownInAttempts: configJson['includeKnownInAttempts'] as bool? ?? false,
shuffleAnswerOrder: configJson['shuffleAnswerOrder'] as bool? ?? true,
excludeFlaggedQuestions: configJson['excludeFlaggedQuestions'] as bool? ?? false,
timeLimitSeconds: configJson['timeLimitSeconds'] as int?,
);
final questionsJson = json['questions'] as List<dynamic>? ?? [];
final questions = questionsJson.asMap().entries.map((entry) {
final i = entry.key;
final qJson = entry.value as Map<String, dynamic>;
return _jsonToQuestion(qJson, defaultId: 'q$i');
}).toList();
return Deck(
id: json['id'] as String? ?? '',
title: json['title'] as String? ?? '',
description: json['description'] as String? ?? '',
questions: questions,
config: config,
currentAttemptIndex: 0,
attemptHistory: const [],
incompleteAttempt: null,
);
}
Question _jsonToQuestion(Map<String, dynamic> q, {String? defaultId}) {
List<int>? correctIndices;
if (q['correct_answer_indices'] != null) {
final arr = q['correct_answer_indices'] as List<dynamic>?;
correctIndices = arr?.map((e) => e as int).toList();
}
if (correctIndices == null && q['correctAnswerIndices'] != null) {
final arr = q['correctAnswerIndices'] as List<dynamic>?;
correctIndices = arr?.map((e) => e as int).toList();
}
return Question(
id: q['id'] as String? ?? defaultId ?? '',
prompt: q['prompt'] as String? ?? '',
explanation: q['explanation'] as String?,
answers: (q['answers'] as List<dynamic>?)
?.map((e) => e.toString())
.toList() ??
[],
correctAnswerIndices: correctIndices ?? [0],
consecutiveCorrect: q['consecutiveCorrect'] as int? ?? 0,
isKnown: q['isKnown'] as bool? ?? false,
isFlagged: q['isFlagged'] as bool? ?? false,
priorityPoints: q['priorityPoints'] as int? ?? 0,
lastAttemptIndex: q['lastAttemptIndex'] as int? ?? -1,
totalCorrectAttempts: q['totalCorrectAttempts'] as int? ?? 0,
totalAttempts: q['totalAttempts'] as int? ?? 0,
);
}
static List<Map<String, dynamic>> deckQuestionsToApi(List<Question> questions) {
return questions.asMap().entries.map((entry) {
final q = entry.value;
return {
'prompt': q.prompt,
'explanation': q.explanation,
'answers': q.answers,
'correct_answer_indices': q.correctAnswerIndices,
};
}).toList();
}
static Map<String, dynamic> deckConfigToApi(DeckConfig config) {
return {
'requiredConsecutiveCorrect': config.requiredConsecutiveCorrect,
'defaultAttemptSize': config.defaultAttemptSize,
'priorityIncreaseOnIncorrect': config.priorityIncreaseOnIncorrect,
'priorityDecreaseOnCorrect': config.priorityDecreaseOnCorrect,
'immediateFeedbackEnabled': config.immediateFeedbackEnabled,
'includeKnownInAttempts': config.includeKnownInAttempts,
'shuffleAnswerOrder': config.shuffleAnswerOrder,
'excludeFlaggedQuestions': config.excludeFlaggedQuestions,
'timeLimitSeconds': config.timeLimitSeconds,
};
}
}
class RemoteDeckException implements Exception {
final int statusCode;
final String body;
const RemoteDeckException({required this.statusCode, required this.body});
@override
String toString() => 'RemoteDeckException($statusCode): $body';
}

@ -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
source: sdk
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:
dependency: "direct dev"
description:
@ -120,6 +128,22 @@ packages:
description: flutter
source: sdk
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:
dependency: transitive
description:
@ -340,6 +364,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:

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

Loading…
Cancel
Save

Powered by TurnKey Linux.