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'; class DeckListScreen extends StatefulWidget { const DeckListScreen({super.key, this.showAppBar = true}); final bool showAppBar; @override State createState() => DeckListScreenState(); } class DeckListScreenState extends State { final DeckStorage _deckStorage = DeckStorage(); List _decks = []; @override 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() { setState(() { _decks = _deckStorage.getAllDecksSync(); }); _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) { Navigator.pushNamed( context, Routes.deckOverview, arguments: deck, ).then((_) { // Reload decks when returning from overview (in case deck was updated) _loadDecks(); }); } void _deleteDeck(Deck deck) async { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Delete Deck'), content: Text('Are you sure you want to delete "${deck.title}"? This action cannot be undone.'), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('Cancel'), ), FilledButton( onPressed: () => Navigator.pop(context, true), style: FilledButton.styleFrom( backgroundColor: Colors.red, ), child: const Text('Delete'), ), ], ), ); 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() { Navigator.pushNamed(context, Routes.deckImport).then((_) { // Reload decks when returning from import _loadDecks(); }); } void _editDeck(Deck deck) { Navigator.pushNamed( context, Routes.deckEdit, arguments: deck, ).then((updatedDeck) { if (updatedDeck != null) { _loadDecks(); } }); } void _mergeDeck(Deck sourceDeck) async { final otherDecks = _decks.where((d) => d.id != sourceDeck.id).toList(); if (otherDecks.isEmpty) { showTopSnackBar( context, message: 'No other deck to merge with', backgroundColor: Colors.orange, ); return; } final targetDeck = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Merge with deck'), content: SizedBox( width: double.maxFinite, child: ListView.builder( shrinkWrap: true, itemCount: otherDecks.length, itemBuilder: (context, index) { final deck = otherDecks[index]; return ListTile( title: Text(deck.title), subtitle: Text('${deck.questions.length} questions'), onTap: () => Navigator.pop(context, deck), ); }, ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Cancel'), ), ], ), ); if (targetDeck == null || !mounted) return; final timestamp = DateTime.now().millisecondsSinceEpoch; final mergedQuestions = [ ...targetDeck.questions, ...sourceDeck.questions.asMap().entries.map((e) { final q = e.value; final i = e.key; return q.copyWith(id: '${q.id}_merged_${timestamp}_$i'); }), ]; final mergedDeck = targetDeck.copyWith(questions: mergedQuestions); _deckStorage.saveDeckSync(mergedDeck); _loadDecks(); if (mounted) { showTopSnackBar( context, message: 'Merged ${sourceDeck.questions.length} question(s) into "${targetDeck.title}"', backgroundColor: Colors.green, ); } } void _cloneDeck(Deck deck) { // Create a copy of the deck with a new ID and reset progress final clonedDeck = deck.copyWith( id: '${deck.id}_clone_${DateTime.now().millisecondsSinceEpoch}', title: '${deck.title} (Copy)', currentAttemptIndex: 0, attemptHistory: [], questions: deck.questions.map((q) { return q.copyWith( consecutiveCorrect: 0, isKnown: false, priorityPoints: 0, lastAttemptIndex: -1, totalCorrectAttempts: 0, totalAttempts: 0, ); }).toList(), ); _deckStorage.saveDeckSync(clonedDeck); _loadDecks(); if (mounted) { showTopSnackBar( context, message: '${deck.title} cloned successfully', backgroundColor: Colors.green, ); } } /// Called from app bar or bottom nav (HomeScreen). Public so HomeScreen can trigger add. void showAddDeckOptions() { showModalBottomSheet( context: context, builder: (context) => SafeArea( child: Padding( padding: const EdgeInsets.all(16), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( 'Add New Deck', style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 16), // Import JSON ListTile( leading: const Icon(Icons.upload_file), title: const Text('Import JSON'), subtitle: const Text('Import a deck from JSON format'), onTap: () { Navigator.pop(context); _navigateToImport(); }, ), const Divider(), // Create Manually ListTile( leading: const Icon(Icons.edit), title: const Text('Create Manually'), subtitle: const Text('Create a new deck from scratch'), onTap: () { Navigator.pop(context); Navigator.pushNamed(context, Routes.deckCreate).then((_) { _loadDecks(); }); }, ), const Divider(), // Use Default Quiz ListTile( leading: const Icon(Icons.quiz), title: const Text('Use Default Quiz'), subtitle: const Text('Add the default general knowledge quiz'), onTap: () { Navigator.pop(context); _useDefaultDeck(); }, ), const SizedBox(height: 8), ], ), ), ), ); } void _useDefaultDeck() async { // Always show dialog to ask if user wants mock data final includeMockData = await showDialog( context: context, builder: (context) => _MockDataDialog(), ); if (includeMockData == null) { // User cancelled return; } // Get the appropriate deck version final defaultDeck = includeMockData ? DefaultDeck.deckWithMockData : DefaultDeck.deck; final baseDeck = DefaultDeck.deck; final deckExists = _deckStorage.hasDeckSync(baseDeck.id); if (deckExists) { // Show dialog with options: Replace, Add Copy, or Cancel final action = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Deck Already Exists'), content: const Text( 'The General Knowledge Quiz already exists. What would you like to do?', ), actions: [ TextButton( onPressed: () => Navigator.pop(context, 'cancel'), child: const Text('Cancel'), ), TextButton( onPressed: () => Navigator.pop(context, 'copy'), child: const Text('Add Copy'), ), FilledButton( onPressed: () => Navigator.pop(context, 'replace'), child: const Text('Replace'), ), ], ), ); if (action == null || action == 'cancel') { return; } else if (action == 'replace') { _deckStorage.saveDeckSync(defaultDeck); if (mounted) { showTopSnackBar( context, message: 'Default deck replaced successfully', backgroundColor: Colors.green, ); } } else if (action == 'copy') { // Find the next copy number final allDecks = _deckStorage.getAllDecksSync(); final baseTitle = defaultDeck.title; final copyPattern = RegExp(r'^(.+?)\s*\(Copy\s+(\d+)\)$'); int maxCopyNumber = 0; for (final deck in allDecks) { if (deck.title == baseTitle) { // Original deck exists continue; } final match = copyPattern.firstMatch(deck.title); if (match != null) { final titlePart = match.group(1)?.trim(); if (titlePart == baseTitle) { final copyNum = int.tryParse(match.group(2) ?? '0') ?? 0; if (copyNum > maxCopyNumber) { maxCopyNumber = copyNum; } } } } final nextCopyNumber = maxCopyNumber + 1; final copiedDeck = defaultDeck.copyWith( id: '${defaultDeck.id}_${DateTime.now().millisecondsSinceEpoch}', title: '$baseTitle (Copy $nextCopyNumber)', ); _deckStorage.saveDeckSync(copiedDeck); if (mounted) { showTopSnackBar( context, message: 'Default deck copied successfully', backgroundColor: Colors.green, ); } } } else { // Save default deck to storage _deckStorage.saveDeckSync(defaultDeck); if (mounted) { showTopSnackBar( context, message: 'Default deck added successfully', backgroundColor: Colors.green, ); } } _loadDecks(); } Widget _buildBody(BuildContext context) { return _decks.isEmpty ? Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.inbox, size: 64, color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3), ), const SizedBox(height: 16), Text( 'No decks yet', style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 8), Text( 'Tap the + button to add a deck', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), ), ), ], ), ) : RefreshIndicator( onRefresh: () async { _loadDecks(); }, child: ListView.builder( padding: const EdgeInsets.all(12), itemCount: _decks.length, itemBuilder: (context, index) { final deck = _decks[index]; final isCompleted = deck.knownCount == deck.numberOfQuestions && deck.numberOfQuestions > 0; return Card( margin: const EdgeInsets.only(bottom: 12), child: InkWell( onTap: () => _openDeck(deck), borderRadius: BorderRadius.circular(12), child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( deck.title, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), ), ), if (isCompleted) ...[ const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: Colors.green.shade700, borderRadius: BorderRadius.circular(12), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.check_circle, size: 16, color: Colors.white, ), const SizedBox(width: 4), Text( 'Completed', style: Theme.of(context).textTheme.labelSmall?.copyWith( color: Colors.white, fontWeight: FontWeight.bold, ), ), ], ), ), ], ], ), if (deck.description.isNotEmpty) ...[ const SizedBox(height: 4), Text( deck.description, style: Theme.of(context).textTheme.bodySmall, maxLines: 2, overflow: TextOverflow.ellipsis, ), ], ], ), ), PopupMenuButton( itemBuilder: (context) => [ PopupMenuItem( child: const Row( children: [ Icon(Icons.edit, color: Colors.blue), SizedBox(width: 8), Text('Edit'), ], ), onTap: () { Future.delayed( const Duration(milliseconds: 100), () => _editDeck(deck), ); }, ), PopupMenuItem( child: const Row( children: [ Icon(Icons.copy, color: Colors.orange), SizedBox(width: 8), Text('Clone'), ], ), onTap: () { Future.delayed( const Duration(milliseconds: 100), () => _cloneDeck(deck), ); }, ), PopupMenuItem( child: const Row( children: [ Icon(Icons.merge_type, color: Colors.teal), SizedBox(width: 8), Text('Merge'), ], ), onTap: () { Future.delayed( const Duration(milliseconds: 100), () => _mergeDeck(deck), ); }, ), PopupMenuItem( child: const Row( children: [ Icon(Icons.delete, color: Colors.red), SizedBox(width: 8), Text('Delete'), ], ), onTap: () { Future.delayed( const Duration(milliseconds: 100), () => _deleteDeck(deck), ); }, ), ], ), ], ), const SizedBox(height: 12), Row( children: [ Expanded( child: _StatChip( icon: Icons.quiz, label: '${deck.numberOfQuestions} questions', ), ), const SizedBox(width: 8), Expanded( child: _StatChip( icon: Icons.check_circle, label: '${deck.knownCount} known', ), ), const SizedBox(width: 8), Expanded( child: _StatChip( icon: Icons.trending_up, label: '${deck.progressPercentage.toStringAsFixed(0)}%', ), ), ], ), ], ), ), ), ); }, ), ); } @override Widget build(BuildContext context) { final body = _buildBody(context); if (!widget.showAppBar) { return body; } return Scaffold( appBar: AppBar( title: Row( mainAxisSize: MainAxisSize.min, children: [ Image.asset( 'android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png', height: 32, width: 32, fit: BoxFit.contain, errorBuilder: (_, __, ___) => Icon( Icons.auto_stories, size: 28, color: Theme.of(context).colorScheme.primaryContainer, ), ), const SizedBox(width: 10), Text( 'omotomo', style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w700, letterSpacing: -0.5, ), ), ], ), actions: [ ValueListenableBuilder( valueListenable: ApiAuthService.instance.currentUser, builder: (context, user, _) { if (user == null) { return FilledButton.tonal( onPressed: () => Navigator.of(context).pushNamed(Routes.login), child: const Text('Log in'), ); } return Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( icon: const Icon(Icons.sync), tooltip: 'Sync from server', onPressed: () => _loadDecks(), ), Padding( padding: const EdgeInsets.only(right: 4), child: PopupMenuButton( offset: const Offset(0, 40), tooltip: 'Account', child: UserAvatar(user: user), itemBuilder: (context) => [ const PopupMenuItem( value: 'logout', child: Row( children: [ Icon(Icons.logout), SizedBox(width: 12), Text('Log out'), ], ), ), ], onSelected: (value) async { if (value != 'logout') return; final navigator = Navigator.of(context); await ApiAuthService.instance.logout(); navigator.pushNamedAndRemoveUntil( Routes.login, (route) => false, ); }, ), ), ], ); }, ), ], ), body: body, ); } } class _StatChip extends StatelessWidget { final IconData icon; final String label; const _StatChip({ required this.icon, required this.label, }); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 16), const SizedBox(width: 4), Flexible( child: Text( label, style: Theme.of(context).textTheme.bodySmall, overflow: TextOverflow.ellipsis, ), ), ], ), ); } } /// Shows profile image from [ApiUser.avatarUrl] when available, otherwise initials. /// Public so HomeScreen can use the same avatar in the shared app bar. class UserAvatar extends StatelessWidget { const UserAvatar({super.key, required this.user}); final ApiUser user; @override Widget build(BuildContext context) { final theme = Theme.of(context); final initials = DeckListScreenState._userInitials(user); final initialsWidget = Text( initials, style: TextStyle( color: theme.colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 16, ), ); final url = user.avatarUrl?.trim(); if (url == null || url.isEmpty) { return CircleAvatar( radius: 18, backgroundColor: theme.colorScheme.primaryContainer, child: initialsWidget, ); } return CircleAvatar( radius: 18, backgroundColor: theme.colorScheme.primaryContainer, child: ClipOval( child: Image.network( url, width: 36, height: 36, fit: BoxFit.cover, errorBuilder: (_, __, ___) => initialsWidget, ), ), ); } } class _MockDataDialog extends StatefulWidget { @override State<_MockDataDialog> createState() => _MockDataDialogState(); } class _MockDataDialogState extends State<_MockDataDialog> { bool _includeMockData = false; @override Widget build(BuildContext context) { return AlertDialog( title: const Text('Add Default Deck'), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'Would you like to include mock attempt history and progress data?', style: TextStyle(fontSize: 14), ), const SizedBox(height: 16), CheckboxListTile( value: _includeMockData, onChanged: (value) { setState(() { _includeMockData = value ?? false; }); }, title: const Text('Include mock data'), subtitle: const Text( 'Adds 4 sample attempts showing progress over time', style: TextStyle(fontSize: 12), ), contentPadding: EdgeInsets.zero, controlAffinity: ListTileControlAffinity.leading, ), ], ), actions: [ TextButton( onPressed: () => Navigator.pop(context, null), child: const Text('Cancel'), ), FilledButton( onPressed: () => Navigator.pop(context, _includeMockData), child: const Text('Add Deck'), ), ], ); } }