diff --git a/.env b/.env new file mode 100644 index 0000000..0887302 --- /dev/null +++ b/.env @@ -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 diff --git a/lib/env.dart b/lib/env.dart new file mode 100644 index 0000000..4d3bbce --- /dev/null +++ b/lib/env.dart @@ -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; +} diff --git a/lib/main.dart b/lib/main.dart index 1f604b5..a89f0eb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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()); } diff --git a/lib/routes.dart b/lib/routes.dart index 2b03529..3e323e1 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -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 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 { }; } } - diff --git a/lib/screens/community_decks_screen.dart b/lib/screens/community_decks_screen.dart new file mode 100644 index 0000000..0e7dccf --- /dev/null +++ b/lib/screens/community_decks_screen.dart @@ -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?; + 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 createState() => CommunityDecksScreenState(); +} + +class CommunityDecksScreenState extends State { + List _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> _localPublishedDeckIdsWeHave() async { + await _storage.initialize(); + final allDecks = await _storage.getAllDecks(); + final ids = {}; + 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 _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 _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'), + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/lib/screens/deck_edit_screen.dart b/lib/screens/deck_edit_screen.dart index 751a81e..ba80434 100644 --- a/lib/screens/deck_edit_screen.dart +++ b/lib/screens/deck_edit_screen.dart @@ -203,85 +203,95 @@ class _DeckEditScreenState extends State { ), ], ), - body: SingleChildScrollView( + body: CustomScrollView( controller: _scrollController, - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Deck Title - TextField( - controller: _titleController, - decoration: const InputDecoration( - labelText: 'Deck Title', - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 16), - - // Deck Description - TextField( - controller: _descriptionController, - decoration: const InputDecoration( - labelText: 'Description (optional)', - border: OutlineInputBorder(), - ), - maxLines: 3, - ), - const SizedBox(height: 24), - - // Questions Section - Text( - 'Questions', - style: Theme.of(context).textTheme.titleLarge, + slivers: [ + SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverList( + delegate: SliverChildListDelegate([ + // Deck Title + TextField( + controller: _titleController, + decoration: const InputDecoration( + labelText: 'Deck Title', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + // Deck Description + TextField( + controller: _descriptionController, + decoration: const InputDecoration( + labelText: 'Description (optional)', + border: OutlineInputBorder(), + ), + maxLines: 3, + ), + const SizedBox(height: 24), + Text( + 'Questions', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + ]), ), - const SizedBox(height: 16), - - // 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( - 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), - ), - ), - ], + ), + if (_questionEditors.isEmpty) + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverToBoxAdapter( + child: 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, + ), + ); + }, + ), + ), + ], ), ); } diff --git a/lib/screens/deck_list_screen.dart b/lib/screens/deck_list_screen.dart index 9002a84..d11bcdb 100644 --- a/lib/screens/deck_list_screen.dart +++ b/lib/screens/deck_list_screen.dart @@ -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 createState() => _DeckListScreenState(); + State createState() => DeckListScreenState(); } -class _DeckListScreenState extends State { +class DeckListScreenState extends State { final DeckStorage _deckStorage = DeckStorage(); List _decks = []; @@ -20,21 +22,75 @@ class _DeckListScreenState extends State { 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 _localServerDeckIds(List fromStorage) { + final ids = {}; + 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 _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,17 +126,33 @@ class _DeckListScreenState extends State { ), ); - if (confirmed == true) { - _deckStorage.deleteDeckSync(deck.id); - _loadDecks(); - if (mounted) { - showTopSnackBar( - context, - message: '${deck.title} deleted', - backgroundColor: Colors.green, - ); + if (confirmed != true || !mounted) return; + + _deckStorage.deleteDeckSync(deck.id); + _loadDecks(); + if (mounted) { + showTopSnackBar( + context, + 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() { @@ -195,7 +267,8 @@ class _DeckListScreenState extends State { } } - 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 { 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: [ - IconButton( - icon: const Icon(Icons.add), - tooltip: 'Add Deck', - onPressed: _showAddDeckOptions, + ValueListenableBuilder( + 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( + 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( + 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(), + ), + ], + ], + ); + }, ), ], ), diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart new file mode 100644 index 0000000..c8b6178 --- /dev/null +++ b/lib/screens/home_screen.dart @@ -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 createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + int _currentIndex = 0; + final GlobalKey _deckListKey = GlobalKey(); + final GlobalKey _communityKey = GlobalKey(); + + 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, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart new file mode 100644 index 0000000..0be0db7 --- /dev/null +++ b/lib/screens/login_screen.dart @@ -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 createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final _formKey = GlobalKey(); + 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 _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', + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/services/api_auth_service.dart b/lib/services/api_auth_service.dart new file mode 100644 index 0000000..7222477 --- /dev/null +++ b/lib/services/api_auth_service.dart @@ -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 json) { + final metadata = json['user_metadata'] as Map?; + 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 currentUser = ValueNotifier(null); + String? _token; + + Future get _prefs async => + await SharedPreferences.getInstance(); + + /// Call after app start to restore session from stored token. + Future 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; + currentUser.value = ApiUser.fromJson(map); + } catch (_) { + await logout(); + } + } + if (_token != null && currentUser.value == null) { + final ok = await _fetchSession(); + if (!ok) await logout(); + } + } + + Future _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; + final userJson = data['user'] as Map?; + 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 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?; + return data?['error'] as String? ?? + data?['message'] as String? ?? + 'Invalid email or password.'; + } + if (res.statusCode != 200) { + final data = jsonDecode(res.body) as Map?; + return data?['error'] as String? ?? + data?['message'] as String? ?? + 'Login failed. Please try again.'; + } + + final data = jsonDecode(res.body) as Map; + _token = data['access_token'] as String?; + final userJson = data['user'] as Map?; + 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 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?; + return data?['message'] as String? ?? 'Registration failed.'; + } + if (res.statusCode != 200) { + return 'Registration failed. Please try again.'; + } + + final data = jsonDecode(res.body) as Map; + _token = data['access_token'] as String?; + final userJson = data['user'] as Map?; + 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 logout() async { + _token = null; + currentUser.value = null; + final prefs = await _prefs; + await prefs.remove(_tokenKey); + await prefs.remove(_userKey); + } +} diff --git a/lib/services/deck_storage.dart b/lib/services/deck_storage.dart index f3b9563..16dabf0 100644 --- a/lib/services/deck_storage.dart +++ b/lib/services/deck_storage.dart @@ -45,9 +45,12 @@ class DeckStorage { } } - /// Convert Deck to JSON Map - Map _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 _deckToJson(Deck deck, {Map? syncMetadata}) { + final map = { '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 json) { + json = Map.from(json)..remove(_syncKey); // Parse config final configJson = json['config'] as Map? ?? {}; final config = DeckConfig( @@ -284,10 +292,45 @@ class DeckStorage { } } - /// Add or update a deck - Future saveDeck(Deck deck) async { + /// Sync metadata for a deck (server_deck_id, owner_id, copied_from_deck_id, etc.). Null if not set. + Future?> getDeckSyncMetadata(String deckId) async { + await _ensureInitialized(); + final deckJson = _prefs!.getString('$_decksKey:$deckId'); + if (deckJson == null) return null; + try { + final json = jsonDecode(deckJson) as Map; + final sync = json[_syncKey]; + if (sync == null) return null; + return Map.from(sync as Map); + } catch (_) { + return null; + } + } + + /// Sync metadata (sync version). Null if not set. + Map? 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; + final sync = json[_syncKey]; + if (sync == null) return null; + return Map.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 saveDeck(Deck deck, {Map? syncMetadata}) async { await _ensureInitialized(); - final deckJson = jsonEncode(_deckToJson(deck)); + Map? 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? 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? sync = syncMetadata ?? getDeckSyncMetadataSync(deck.id); + final deckJson = jsonEncode(_deckToJson(deck, syncMetadata: sync)); _prefs!.setString('$_decksKey:${deck.id}', deckJson); // Update deck IDs list diff --git a/lib/services/remote_deck_service.dart b/lib/services/remote_deck_service.dart new file mode 100644 index 0000000..39301d3 --- /dev/null +++ b/lib/services/remote_deck_service.dart @@ -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 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> 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 _parseDeckList(dynamic body) { + if (body is List) { + return body.map((e) => _parseDeckListItem(e as Map)).toList(); + } + if (body is Map) { + final list = body['decks'] as List? ?? []; + return list.map((e) => _parseDeckListItem(e as Map)).toList(); + } + return []; + } + + /// GET /api/decks/mine + Future> 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> 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 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 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; + return _jsonToDeck(json); + } + + /// POST /api/decks โ€” create deck; returns new deck id. + Future createDeck({ + required String title, + required String description, + Map? config, + List>? questions, + String? copiedFromDeckId, + int? copiedFromVersion, + }) async { + final uri = Uri.parse('$apiBaseUrl/api/decks'); + final body = { + '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; + return data['id'] as String? ?? ''; + } + + /// PATCH /api/decks/:id + Future updateDeck( + String deckId, { + String? title, + String? description, + Map? config, + List>? questions, + }) async { + final uri = Uri.parse('$apiBaseUrl/api/decks/$deckId'); + final body = {}; + 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 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 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; + return data['id'] as String? ?? ''; + } + + /// GET /api/decks/:id/update-preview + Future 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; + final changes = (data['changes'] as List?)?.cast() ?? []; + return UpdatePreview(changes: changes); + } + + /// POST /api/decks/:id/apply-update + Future 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 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 json) { + final configJson = json['config'] as Map? ?? {}; + 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? ?? []; + final questions = questionsJson.asMap().entries.map((entry) { + final i = entry.key; + final qJson = entry.value as Map; + 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 q, {String? defaultId}) { + List? correctIndices; + if (q['correct_answer_indices'] != null) { + final arr = q['correct_answer_indices'] as List?; + correctIndices = arr?.map((e) => e as int).toList(); + } + if (correctIndices == null && q['correctAnswerIndices'] != null) { + final arr = q['correctAnswerIndices'] as List?; + 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?) + ?.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> deckQuestionsToApi(List 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 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'; +} diff --git a/lib/utils/connection_error.dart b/lib/utils/connection_error.dart new file mode 100644 index 0000000..cd381ed --- /dev/null +++ b/lib/utils/connection_error.dart @@ -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.'; +} diff --git a/pubspec.lock b/pubspec.lock index 01a36c2..c340a08 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index c9ca489..94ca6cb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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