diff --git a/lib/core/widgets/context_menu_region.dart b/lib/core/widgets/context_menu_region.dart index 1943338..0bb2c7c 100644 --- a/lib/core/widgets/context_menu_region.dart +++ b/lib/core/widgets/context_menu_region.dart @@ -47,12 +47,14 @@ class ContextMenuRegion extends StatelessWidget { final Widget child; final ValueChanged onAction; final List>? menuItems; + final bool enableLongPress; const ContextMenuRegion({ super.key, required this.onAction, required this.child, this.menuItems, + this.enableLongPress = true, }); Future _show(BuildContext context, {Offset? globalPosition}) async { @@ -81,10 +83,12 @@ class ContextMenuRegion extends StatelessWidget { return GestureDetector( onSecondaryTapDown: (details) => _show(context, globalPosition: details.globalPosition), - onLongPressStart: (details) { - HapticFeedback.heavyImpact(); - _show(context, globalPosition: details.globalPosition); - }, + onLongPressStart: enableLongPress + ? (details) { + HapticFeedback.heavyImpact(); + _show(context, globalPosition: details.globalPosition); + } + : null, child: child, ); } diff --git a/lib/features/cards/data/card_repository.dart b/lib/features/cards/data/card_repository.dart index c736d83..af5c5c4 100644 --- a/lib/features/cards/data/card_repository.dart +++ b/lib/features/cards/data/card_repository.dart @@ -222,6 +222,54 @@ class CardRepository { ); } + /// Bulk-moves non-deleted cards to [newDeckId] in a single transaction. + Future moveCards(List cardIds, String newDeckId) async { + if (cardIds.isEmpty) return; + + final db = await _dbHelper.database; + final now = DateTime.now().toUtc().toIso8601String(); + final placeholders = List.filled(cardIds.length, '?').join(', '); + + await db.transaction((txn) async { + await txn.update( + DatabaseConstants.tableCards, + { + DatabaseConstants.colDeckId: newDeckId, + DatabaseConstants.colUpdatedAt: now, + DatabaseConstants.colSyncStatus: SyncStatus.pending.name, + }, + where: + '${DatabaseConstants.colCardId} IN ($placeholders) ' + 'AND ${DatabaseConstants.colIsDeleted} = 0', + whereArgs: cardIds, + ); + }); + } + + /// Bulk soft-delete for cards. + Future bulkDelete(List cardIds) async { + if (cardIds.isEmpty) return; + + final db = await _dbHelper.database; + final now = DateTime.now().toUtc().toIso8601String(); + final placeholders = List.filled(cardIds.length, '?').join(', '); + + await db.transaction((txn) async { + await txn.update( + DatabaseConstants.tableCards, + { + DatabaseConstants.colIsDeleted: 1, + DatabaseConstants.colUpdatedAt: now, + DatabaseConstants.colSyncStatus: SyncStatus.pending.name, + }, + where: + '${DatabaseConstants.colCardId} IN ($placeholders) ' + 'AND ${DatabaseConstants.colIsDeleted} = 0', + whereArgs: cardIds, + ); + }); + } + /// Returns all cards with pending sync status. Future> getUnsynced() async { final db = await _dbHelper.database; diff --git a/lib/features/decks/data/deck_repository.dart b/lib/features/decks/data/deck_repository.dart index 8e19894..ea9da8f 100644 --- a/lib/features/decks/data/deck_repository.dart +++ b/lib/features/decks/data/deck_repository.dart @@ -292,8 +292,38 @@ class DeckRepository { /// Soft-deletes [deckId], all descendant decks, and all their cards /// in a single transaction. Future delete(String deckId) async { - final allIds = await getDescendantIds(deckId); + await bulkDelete([deckId]); + } + + /// Bulk soft-delete for decks and each selected deck's descendants. + Future bulkDelete(List deckIds) async { + if (deckIds.isEmpty) return; + final db = await _dbHelper.database; + final seedPlaceholders = List.filled(deckIds.length, '?').join(', '); + final descendantRows = await db.rawQuery( + ''' + WITH RECURSIVE descendants(${DatabaseConstants.colDeckId}) AS ( + SELECT ${DatabaseConstants.colDeckId} + FROM ${DatabaseConstants.tableDecks} + WHERE ${DatabaseConstants.colDeckId} IN ($seedPlaceholders) + AND ${DatabaseConstants.colIsDeleted} = 0 + UNION ALL + SELECT d.${DatabaseConstants.colDeckId} + FROM ${DatabaseConstants.tableDecks} d + INNER JOIN descendants dt + ON d.${DatabaseConstants.colParentId} = dt.${DatabaseConstants.colDeckId} + WHERE d.${DatabaseConstants.colIsDeleted} = 0 + ) + SELECT DISTINCT ${DatabaseConstants.colDeckId} FROM descendants + ''', + deckIds, + ); + final allIds = descendantRows + .map((row) => row[DatabaseConstants.colDeckId] as String) + .toList(growable: false); + if (allIds.isEmpty) return; + final now = DateTime.now().toUtc().toIso8601String(); final deletedFields = { DatabaseConstants.colIsDeleted: 1, @@ -318,6 +348,30 @@ class DeckRepository { }); } + /// Bulk-moves non-deleted decks to [newParentId] in a single transaction. + Future moveDecks(List deckIds, String? newParentId) async { + if (deckIds.isEmpty) return; + + final db = await _dbHelper.database; + final now = DateTime.now().toUtc().toIso8601String(); + final placeholders = List.filled(deckIds.length, '?').join(', '); + + await db.transaction((txn) async { + await txn.update( + DatabaseConstants.tableDecks, + { + DatabaseConstants.colParentId: newParentId, + DatabaseConstants.colUpdatedAt: now, + DatabaseConstants.colSyncStatus: SyncStatus.pending.name, + }, + where: + '${DatabaseConstants.colDeckId} IN ($placeholders) ' + 'AND ${DatabaseConstants.colIsDeleted} = 0', + whereArgs: deckIds, + ); + }); + } + /// Finds a deck by its full path (e.g., "Parent::Child::Grandchild"). /// Creates the deck hierarchy if it doesn't exist. /// diff --git a/lib/features/decks/presentation/providers/deck_detail_provider.dart b/lib/features/decks/presentation/providers/deck_detail_provider.dart index 6999e33..e945b65 100644 --- a/lib/features/decks/presentation/providers/deck_detail_provider.dart +++ b/lib/features/decks/presentation/providers/deck_detail_provider.dart @@ -2,7 +2,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lapse/core/sync/sync_service.dart'; import 'package:lapse/features/cards/data/card_repository_provider.dart'; import 'package:lapse/features/decks/data/deck_repository_provider.dart'; -import 'package:lapse/features/decks/domain/deck.dart'; import 'package:lapse/features/decks/presentation/providers/deck_detail_state.dart'; import 'package:lapse/features/decks/presentation/providers/deck_list_provider.dart'; @@ -97,14 +96,26 @@ class DeckDetailNotifier extends AsyncNotifier { ref.invalidate(deckListProvider); } - Future moveChildDeck(String childDeckId, String? newParentId) async { + Future deleteChildDecks(List childDeckIds) async { + if (childDeckIds.isEmpty) return; final deckRepo = ref.read(deckRepositoryProvider); - final deck = await deckRepo.getById(childDeckId); - if (deck == null) return; + await deckRepo.bulkDelete(childDeckIds); + ref.invalidateSelf(); + ref.invalidate(deckListProvider); + ref.read(syncServiceProvider.notifier).schedulePush(); + } - await deckRepo.update( - deck.copyWith(parentId: Optional.value(newParentId)), - ); + Future moveChildDeck(String childDeckId, String? newParentId) async { + await moveChildDecks([childDeckId], newParentId); + } + + Future moveChildDecks( + List childDeckIds, + String? newParentId, + ) async { + if (childDeckIds.isEmpty) return; + final deckRepo = ref.read(deckRepositoryProvider); + await deckRepo.moveDecks(childDeckIds, newParentId); ref.invalidateSelf(); if (newParentId != null) { ref.invalidate(deckDetailProvider(newParentId)); @@ -114,11 +125,21 @@ class DeckDetailNotifier extends AsyncNotifier { } Future moveCard(String cardId, String newDeckId) async { + await moveCards([cardId], newDeckId); + } + + Future moveCards(List cardIds, String newDeckId) async { + if (cardIds.isEmpty) return; final cardRepo = ref.read(cardRepositoryProvider); - final card = await cardRepo.getById(cardId); - if (card == null) return; + await cardRepo.moveCards(cardIds, newDeckId); + ref.invalidateSelf(); + ref.invalidate(deckListProvider); + ref.read(syncServiceProvider.notifier).schedulePush(); + } - await cardRepo.update(card.copyWith(deckId: newDeckId)); + Future deleteCards(List cardIds) async { + if (cardIds.isEmpty) return; + await ref.read(cardRepositoryProvider).bulkDelete(cardIds); ref.invalidateSelf(); ref.invalidate(deckListProvider); ref.read(syncServiceProvider.notifier).schedulePush(); diff --git a/lib/features/decks/presentation/screens/deck_detail_screen.dart b/lib/features/decks/presentation/screens/deck_detail_screen.dart index 2b416f6..385cab1 100644 --- a/lib/features/decks/presentation/screens/deck_detail_screen.dart +++ b/lib/features/decks/presentation/screens/deck_detail_screen.dart @@ -40,10 +40,16 @@ class DeckDetailScreen extends ConsumerStatefulWidget { ConsumerState createState() => _DeckDetailScreenState(); } +enum _SelectionKind { deck, card } + class _DeckDetailScreenState extends ConsumerState with RouteAware { final ScrollController _breadcrumbScrollController = ScrollController(); final ScrollController _cardsScrollController = ScrollController(); + bool _selectionMode = false; + _SelectionKind? _selectionKind; + final Set _selectedDeckIds = {}; + final Set _selectedCardIds = {}; @override void initState() { @@ -92,6 +98,235 @@ class _DeckDetailScreenState extends ConsumerState ref.read(deckDetailProvider(widget.deckId).notifier).loadMoreCards(); } + bool get _isDeckSelection => + _selectionMode && _selectionKind == _SelectionKind.deck; + bool get _isCardSelection => + _selectionMode && _selectionKind == _SelectionKind.card; + + int get _selectedCount => _isDeckSelection + ? _selectedDeckIds.length + : _isCardSelection + ? _selectedCardIds.length + : 0; + + void _exitSelectionMode() { + setState(() { + _selectionMode = false; + _selectionKind = null; + _selectedDeckIds.clear(); + _selectedCardIds.clear(); + }); + } + + void _enterDeckSelection(String deckId) { + setState(() { + _selectionMode = true; + _selectionKind = _SelectionKind.deck; + _selectedCardIds.clear(); + _selectedDeckIds + ..clear() + ..add(deckId); + }); + } + + void _enterCardSelection(String cardId) { + setState(() { + _selectionMode = true; + _selectionKind = _SelectionKind.card; + _selectedDeckIds.clear(); + _selectedCardIds + ..clear() + ..add(cardId); + }); + } + + void _toggleDeckSelection(String deckId) { + if (!_isDeckSelection) return; + setState(() { + if (_selectedDeckIds.contains(deckId)) { + _selectedDeckIds.remove(deckId); + } else { + _selectedDeckIds.add(deckId); + } + if (_selectedDeckIds.isEmpty) { + _selectionMode = false; + _selectionKind = null; + } + }); + } + + void _toggleCardSelection(String cardId) { + if (!_isCardSelection) return; + setState(() { + if (_selectedCardIds.contains(cardId)) { + _selectedCardIds.remove(cardId); + } else { + _selectedCardIds.add(cardId); + } + if (_selectedCardIds.isEmpty) { + _selectionMode = false; + _selectionKind = null; + } + }); + } + + void _selectAll(DeckDetailState detail) { + if (_isDeckSelection) { + setState(() { + _selectedDeckIds + ..clear() + ..addAll(detail.children.map((c) => c.deck.deckId)); + }); + return; + } + if (_isCardSelection) { + setState(() { + _selectedCardIds + ..clear() + ..addAll(detail.cards.map((c) => c.cardId)); + }); + } + } + + Future _bulkDelete() async { + if (_selectedCount == 0) return; + final isDeck = _isDeckSelection; + final selectedDeckIds = _selectedDeckIds.toList(growable: false); + final selectedCardIds = _selectedCardIds.toList(growable: false); + final noun = isDeck ? 'deck' : 'card'; + final confirmed = await ConfirmDialog.show( + context: context, + title: 'Delete selected $noun${_selectedCount == 1 ? '' : 's'}?', + message: 'This action cannot be undone.', + confirmLabel: 'Delete', + isDestructive: true, + ); + if (!confirmed || !mounted) return; + + try { + final notifier = ref.read(deckDetailProvider(widget.deckId).notifier); + if (isDeck) { + await notifier.deleteChildDecks(selectedDeckIds); + } else { + await notifier.deleteCards(selectedCardIds); + } + if (!mounted) return; + AppSnackBar.show( + context, + 'Deleted $_selectedCount $noun${_selectedCount == 1 ? '' : 's'}', + ); + _exitSelectionMode(); + } catch (e) { + if (!mounted) return; + AppSnackBar.show(context, 'Delete failed: $e'); + } + } + + Future _bulkMove(DeckDetailState detail) async { + if (_selectedCount == 0) return; + + final deckRepo = ref.read(deckRepositoryProvider); + final allDecks = await deckRepo.getAll(); + if (!mounted) return; + + try { + if (_isDeckSelection) { + final selectedDeckIds = _selectedDeckIds.toList(growable: false); + final selectedDecks = detail.children + .where((c) => _selectedDeckIds.contains(c.deck.deckId)) + .map((c) => c.deck) + .toList(growable: false); + if (selectedDeckIds.isEmpty || selectedDecks.isEmpty) return; + + final excludeIds = {...selectedDeckIds}; + for (final id in selectedDeckIds) { + final descendants = await deckRepo.getDescendantIds(id); + excludeIds.addAll(descendants); + } + + if (!mounted) return; + final targetId = await DeckPickerDialog.show( + context: context, + decks: allDecks, + excludeIds: excludeIds, + currentParentId: null, + title: 'Move to', + confirmLabel: 'Move', + allowReselect: true, + ); + if (targetId == null || !mounted) return; + final newParentId = targetId.isEmpty ? null : targetId; + + final selectedNames = selectedDecks + .map((d) => d.deckName.trim().toLowerCase()) + .toList(); + if (selectedNames.toSet().length != selectedNames.length) { + AppSnackBar.show( + context, + 'Cannot move decks with duplicate names to the same location', + ); + return; + } + + for (final deck in selectedDecks) { + final conflict = await deckRepo.nameExistsAtLevel( + name: deck.deckName, + parentId: newParentId, + excludeDeckId: deck.deckId, + ); + if (conflict) { + if (!mounted) return; + AppSnackBar.show( + context, + 'A deck named "${deck.deckName}" already exists there', + ); + return; + } + } + + await ref + .read(deckDetailProvider(widget.deckId).notifier) + .moveChildDecks(selectedDeckIds, newParentId); + if (!mounted) return; + AppSnackBar.show( + context, + 'Moved ${selectedDeckIds.length} deck${selectedDeckIds.length == 1 ? '' : 's'}', + ); + _exitSelectionMode(); + return; + } + + if (_isCardSelection) { + final selectedCardIds = _selectedCardIds.toList(growable: false); + if (selectedCardIds.isEmpty) return; + final targetId = await DeckPickerDialog.show( + context: context, + decks: allDecks, + excludeIds: const {}, + currentParentId: widget.deckId, + showRoot: false, + title: 'Move to', + confirmLabel: 'Move', + ); + if (targetId == null || !mounted) return; + if (targetId == widget.deckId) return; + + await ref + .read(deckDetailProvider(widget.deckId).notifier) + .moveCards(selectedCardIds, targetId); + if (!mounted) return; + AppSnackBar.show( + context, + 'Moved ${selectedCardIds.length} card${selectedCardIds.length == 1 ? '' : 's'}', + ); + _exitSelectionMode(); + } + } catch (e) { + if (!mounted) return; + AppSnackBar.show(context, 'Move failed: $e'); + } + } + // ── Navigation helpers ────────────────────────────────────────── /// Rebuild the navigation stack to match the deck hierarchy. @@ -347,38 +582,56 @@ class _DeckDetailScreenState extends ConsumerState }) { return Scaffold( appBar: AppBar( - title: Text(deckName), - actions: [ - IconButton( - icon: const Icon(Icons.edit_outlined), - onPressed: deck == null - ? null - : () => context.push( - Routes.deckEditPath(widget.deckId), - extra: deck, - ), - ), - IconButton( - icon: const Icon(Icons.delete_outline), - onPressed: detail == null ? null : () => _deleteDeck(detail), - ), - ], + leading: _selectionMode + ? IconButton( + icon: const Icon(Icons.close), + onPressed: _exitSelectionMode, + ) + : null, + title: Text( + _selectionMode ? '$_selectedCount selected' : deckName, + ), + actions: _selectionMode + ? null + : [ + IconButton( + icon: const Icon(Icons.edit_outlined), + onPressed: deck == null + ? null + : () => context.push( + Routes.deckEditPath(widget.deckId), + extra: deck, + ), + ), + IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: detail == null ? null : () => _deleteDeck(detail), + ), + ], ), body: body, - floatingActionButton: SpeedDialFab( - actions: [ - SpeedDialAction( - icon: Icons.style_outlined, - label: 'New Card', - onPressed: () => context.push(Routes.cardNewPath(widget.deckId)), - ), - SpeedDialAction( - icon: Icons.folder_outlined, - label: 'New Deck', - onPressed: () => context.push(Routes.deckNew, extra: widget.deckId), - ), - ], - ), + floatingActionButton: _selectionMode + ? null + : SpeedDialFab( + actions: [ + SpeedDialAction( + icon: Icons.style_outlined, + label: 'New Card', + onPressed: () => context.push(Routes.cardNewPath(widget.deckId)), + ), + SpeedDialAction( + icon: Icons.folder_outlined, + label: 'New Deck', + onPressed: () => context.push( + Routes.deckNew, + extra: widget.deckId, + ), + ), + ], + ), + bottomNavigationBar: _selectionMode && detail != null + ? _buildSelectionBar(detail) + : null, ); } @@ -443,25 +696,48 @@ class _DeckDetailScreenState extends ConsumerState sliver: SliverList( delegate: SliverChildBuilderDelegate((context, index) { final child = detail.children[index]; + final isSelected = _selectedDeckIds.contains(child.deck.deckId); + Widget deckTile = DeckCard( + deck: child.deck, + cardCount: child.cardCount, + dueCount: child.dueCount, + showSelectionCheckbox: _isDeckSelection, + selected: isSelected, + showTrailingChevron: !_isDeckSelection, + onTap: () { + if (_isDeckSelection) { + _toggleDeckSelection(child.deck.deckId); + return; + } + if (_selectionMode) return; + context.push( + Routes.deckPath(child.deck.deckId), + extra: { + 'deck': child.deck, + 'ancestors': [...detail.ancestors, detail.deck], + 'cardCount': child.cardCount, + 'dueCount': child.dueCount, + }, + ); + }, + onLongPress: (!_selectionMode || _isDeckSelection) + ? () { + if (_isDeckSelection) { + _toggleDeckSelection(child.deck.deckId); + } else { + _enterDeckSelection(child.deck.deckId); + } + } + : null, + ); + + if (_selectionMode) return deckTile; + return ContextMenuRegion( + enableLongPress: false, onAction: (action) => _handleDeckContextAction(child.deck, action), - child: DeckCard( - deck: child.deck, - cardCount: child.cardCount, - dueCount: child.dueCount, - onTap: () { - context.push( - Routes.deckPath(child.deck.deckId), - extra: { - 'deck': child.deck, - 'ancestors': [...detail.ancestors, detail.deck], - 'cardCount': child.cardCount, - 'dueCount': child.dueCount, - }, - ); - }, - ), + child: deckTile, ); }, childCount: detail.children.length), ), @@ -629,58 +905,126 @@ class _DeckDetailScreenState extends ConsumerState ); } + Widget _buildSelectionBar(DeckDetailState detail) { + return SafeArea( + top: false, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.lg, + vertical: Spacing.sm, + ), + decoration: const BoxDecoration( + color: AppColors.surfaceElevated, + border: Border(top: BorderSide(color: AppColors.outline)), + ), + child: Row( + children: [ + TextButton.icon( + onPressed: _selectedCount == 0 ? null : () => _bulkMove(detail), + icon: const Icon(Icons.folder_open_outlined), + label: const Text('Move'), + ), + const SizedBox(width: Spacing.sm), + TextButton.icon( + onPressed: _selectedCount == 0 ? null : _bulkDelete, + icon: const Icon(Icons.delete_outline), + label: const Text('Delete'), + ), + const Spacer(), + TextButton( + onPressed: () => _selectAll(detail), + child: const Text('Select All'), + ), + ], + ), + ), + ); + } + Widget _buildCardItem(Flashcard card) { - return ContextMenuRegion( - onAction: (action) => _handleCardContextAction(card, action), - child: InkWell( - onTap: () => context.push( + final isSelected = _selectedCardIds.contains(card.cardId); + final canToggleCardSelection = !_selectionMode || _isCardSelection; + + final row = InkWell( + onTap: () { + if (_isCardSelection) { + _toggleCardSelection(card.cardId); + return; + } + if (_selectionMode) return; + context.push( Routes.cardPath(widget.deckId, card.cardId), extra: card, + ); + }, + onLongPress: canToggleCardSelection + ? () { + if (_isCardSelection) { + _toggleCardSelection(card.cardId); + } else { + _enterCardSelection(card.cardId); + } + } + : null, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.lg, + vertical: Spacing.md, ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.lg, - vertical: Spacing.md, - ), - child: Row( - children: [ - Expanded( - child: Text.rich( - TextSpan( - children: [ - TextSpan( - text: card.front, - style: Theme.of(context).textTheme.bodyMedium, - ), - TextSpan( - text: ' → ', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: AppColors.textTertiary, - ), + child: Row( + children: [ + if (_isCardSelection) + IgnorePointer( + child: Checkbox( + value: isSelected, + onChanged: (_) {}, + ), + ), + if (_isCardSelection) const SizedBox(width: Spacing.xs), + Expanded( + child: Text.rich( + TextSpan( + children: [ + TextSpan( + text: card.front, + style: Theme.of(context).textTheme.bodyMedium, + ), + TextSpan( + text: ' → ', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppColors.textTertiary, ), - TextSpan( - text: card.back, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: AppColors.textSecondary, - ), + ), + TextSpan( + text: card.back, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppColors.textSecondary, ), - ], - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + ), + ], ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - const SizedBox(width: Spacing.sm), + ), + if (!_isCardSelection) const SizedBox(width: Spacing.sm), + if (!_isCardSelection) const Icon( Icons.chevron_right, size: 20, color: AppColors.textTertiary, ), - ], - ), + ], ), ), ); + + if (_selectionMode) return row; + return ContextMenuRegion( + enableLongPress: false, + onAction: (action) => _handleCardContextAction(card, action), + child: row, + ); } } diff --git a/lib/features/decks/presentation/widgets/deck_card.dart b/lib/features/decks/presentation/widgets/deck_card.dart index d0f5d5b..5f7fc7b 100644 --- a/lib/features/decks/presentation/widgets/deck_card.dart +++ b/lib/features/decks/presentation/widgets/deck_card.dart @@ -10,6 +10,9 @@ class DeckCard extends StatelessWidget { final VoidCallback? onLongPress; final int cardCount; final int dueCount; + final bool showSelectionCheckbox; + final bool selected; + final bool showTrailingChevron; const DeckCard({ super.key, @@ -19,6 +22,9 @@ class DeckCard extends StatelessWidget { this.onLongPress, this.cardCount = 0, this.dueCount = 0, + this.showSelectionCheckbox = false, + this.selected = false, + this.showTrailingChevron = true, }); int get _cardCount => cardCount; @@ -39,12 +45,21 @@ class DeckCard extends StatelessWidget { padding: const EdgeInsets.all(Spacing.cardPadding), child: Row( children: [ + if (showSelectionCheckbox) + IgnorePointer( + child: Checkbox( + value: selected, + onChanged: (_) {}, + ), + ), + if (showSelectionCheckbox) const SizedBox(width: Spacing.xs), _buildIcon(), const SizedBox(width: Spacing.lg), Expanded(child: _buildContent(context)), if (_dueCount > 0) _buildDueBadge(context), - const SizedBox(width: Spacing.sm), - const Icon(Icons.chevron_right, color: AppColors.textTertiary), + if (showTrailingChevron) const SizedBox(width: Spacing.sm), + if (showTrailingChevron) + const Icon(Icons.chevron_right, color: AppColors.textTertiary), ], ), ), diff --git a/test/features/cards/data/card_repository_test.dart b/test/features/cards/data/card_repository_test.dart index 21028e3..45d197f 100644 --- a/test/features/cards/data/card_repository_test.dart +++ b/test/features/cards/data/card_repository_test.dart @@ -139,6 +139,44 @@ void main() { expect(fetched, isNull); }); + test('moveCards moves multiple cards to a new deck', () async { + await insertParentDeck(id: 'deck-a'); + await insertParentDeck(id: 'deck-b'); + await cardRepo.create(makeCard(id: 'c1', deckId: 'deck-a')); + await cardRepo.create(makeCard(id: 'c2', deckId: 'deck-a')); + + await cardRepo.moveCards(['c1', 'c2'], 'deck-b'); + + final sourceCards = await cardRepo.getByDeckId('deck-a'); + final targetCards = await cardRepo.getByDeckId('deck-b'); + expect(sourceCards, isEmpty); + expect(targetCards.map((c) => c.cardId).toSet(), {'c1', 'c2'}); + expect(targetCards.every((c) => c.syncStatus == SyncStatus.pending), isTrue); + }); + + test('bulkDelete soft-deletes multiple cards', () async { + await insertParentDeck(); + await cardRepo.create(makeCard(id: 'c1')); + await cardRepo.create(makeCard(id: 'c2')); + + await cardRepo.bulkDelete(['c1', 'c2']); + + expect(await cardRepo.getById('c1'), isNull); + expect(await cardRepo.getById('c2'), isNull); + + final db = await helper.database; + final rows = await db.query( + 'cards', + where: 'card_id IN (?, ?)', + whereArgs: ['c1', 'c2'], + ); + expect(rows, hasLength(2)); + for (final row in rows) { + expect(row['is_deleted'], 1); + expect(row['sync_status'], 'pending'); + } + }); + group('sync status', () { test('create sets syncStatus to pending', () async { await insertParentDeck(); diff --git a/test/features/decks/data/deck_repository_test.dart b/test/features/decks/data/deck_repository_test.dart index 30838a6..36c00ae 100644 --- a/test/features/decks/data/deck_repository_test.dart +++ b/test/features/decks/data/deck_repository_test.dart @@ -107,6 +107,36 @@ void main() { expect(fetched, isNull); }); + test('moveDecks moves multiple decks to new parent', () async { + await repo.create(makeDeck(id: 'root-a', name: 'Root A')); + await repo.create(makeDeck(id: 'root-b', name: 'Root B')); + await repo.create(makeDeck(id: 'd1', parentId: 'root-a', name: 'Deck 1')); + await repo.create(makeDeck(id: 'd2', parentId: 'root-a', name: 'Deck 2')); + + await repo.moveDecks(['d1', 'd2'], 'root-b'); + + final d1 = await repo.getById('d1'); + final d2 = await repo.getById('d2'); + expect(d1, isNotNull); + expect(d2, isNotNull); + expect(d1!.parentId, 'root-b'); + expect(d2!.parentId, 'root-b'); + expect(d1.syncStatus, SyncStatus.pending); + expect(d2.syncStatus, SyncStatus.pending); + }); + + test('moveDecks supports moving decks to root level', () async { + await repo.create(makeDeck(id: 'parent', name: 'Parent')); + await repo.create(makeDeck(id: 'child', parentId: 'parent', name: 'Child')); + + await repo.moveDecks(['child'], null); + + final moved = await repo.getById('child'); + expect(moved, isNotNull); + expect(moved!.parentId, isNull); + expect(moved.syncStatus, SyncStatus.pending); + }); + group('sync status', () { test('create sets syncStatus to pending', () async { final deck = makeDeck(); @@ -340,6 +370,28 @@ void main() { expect(remaining, hasLength(1)); expect(remaining.first.deckId, 'child-b'); }); + + test('bulkDelete removes multiple deck subtrees in one call', () async { + await repo.create(makeDeck(id: 'root-a')); + await repo.create(makeDeck(id: 'a-child', parentId: 'root-a')); + await repo.create(makeDeck(id: 'root-b')); + await repo.create(makeDeck(id: 'b-child', parentId: 'root-b')); + await repo.create(makeDeck(id: 'keep')); + + await repo.bulkDelete(['root-a', 'root-b']); + + expect(await repo.getById('root-a'), isNull); + expect(await repo.getById('a-child'), isNull); + expect(await repo.getById('root-b'), isNull); + expect(await repo.getById('b-child'), isNull); + expect(await repo.getById('keep'), isNotNull); + }); + + test('bulkDelete with empty list is a no-op', () async { + await repo.create(makeDeck(id: 'keep')); + await repo.bulkDelete(const []); + expect(await repo.getById('keep'), isNotNull); + }); }); group('getChildren edge cases', () {