feat(app): card tiles: the front, the tags, the options
A studyset's cards list as bordered rows of one height showing the front, the card's tags above it and a badge when devices changed it differently; tapping opens the composer, "…" offers editing and deleting (after asking). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
2c8d6a425a
commit
8cf7749ed6
7 changed files with 252 additions and 48 deletions
|
|
@ -248,6 +248,12 @@
|
|||
"matchingRemoveItem": "Remove item",
|
||||
"matchingMinItems": "At least two items required",
|
||||
|
||||
"cardTileOptions": "Options",
|
||||
"cardTileEdit": "Edit",
|
||||
"cardTileDelete": "Delete",
|
||||
"cardTileUntitled": "Untitled",
|
||||
"cardTileConflict": "Changed on two devices",
|
||||
|
||||
"commonCancel": "Cancel",
|
||||
"commonClose": "Close",
|
||||
"commonUndo": "Undo",
|
||||
|
|
|
|||
|
|
@ -1384,6 +1384,36 @@ abstract class AppLocalizations {
|
|||
/// **'At least two items required'**
|
||||
String get matchingMinItems;
|
||||
|
||||
/// No description provided for @cardTileOptions.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Options'**
|
||||
String get cardTileOptions;
|
||||
|
||||
/// No description provided for @cardTileEdit.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Edit'**
|
||||
String get cardTileEdit;
|
||||
|
||||
/// No description provided for @cardTileDelete.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete'**
|
||||
String get cardTileDelete;
|
||||
|
||||
/// No description provided for @cardTileUntitled.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Untitled'**
|
||||
String get cardTileUntitled;
|
||||
|
||||
/// No description provided for @cardTileConflict.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Changed on two devices'**
|
||||
String get cardTileConflict;
|
||||
|
||||
/// No description provided for @commonCancel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -761,6 +761,21 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get matchingMinItems => 'At least two items required';
|
||||
|
||||
@override
|
||||
String get cardTileOptions => 'Options';
|
||||
|
||||
@override
|
||||
String get cardTileEdit => 'Edit';
|
||||
|
||||
@override
|
||||
String get cardTileDelete => 'Delete';
|
||||
|
||||
@override
|
||||
String get cardTileUntitled => 'Untitled';
|
||||
|
||||
@override
|
||||
String get cardTileConflict => 'Changed on two devices';
|
||||
|
||||
@override
|
||||
String get commonCancel => 'Cancel';
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ class CardListBloc(
|
|||
this : super(const CardListStateLoading()) {
|
||||
on<CardListEventStarted>(_onStarted);
|
||||
on<CardListEventMoved>(_onMoved);
|
||||
on<CardListEventDeleteRequested>(
|
||||
(event, emit) => _cards.delete(event.card),
|
||||
);
|
||||
on<CardListEventTagFilterChanged>((event, emit) {
|
||||
_tagFilter = event.tagId;
|
||||
if (_loaded() case final loaded?) {
|
||||
|
|
@ -91,6 +94,15 @@ final class const CardListEventMoved(
|
|||
List<Object?> get props => [card, toIndex];
|
||||
}
|
||||
|
||||
/// Delete [card]; the person has confirmed it.
|
||||
final class const CardListEventDeleteRequested(
|
||||
/// The card to delete.
|
||||
final Card card,
|
||||
) extends CardListEvent {
|
||||
@override
|
||||
List<Object?> get props => [card];
|
||||
}
|
||||
|
||||
/// Show only the cards with the tag [tagId], or all when it is null.
|
||||
final class const CardListEventTagFilterChanged(
|
||||
/// The tag's id.
|
||||
|
|
|
|||
|
|
@ -3,14 +3,15 @@ import 'package:go_router/go_router.dart';
|
|||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_list_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_tile.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tagging.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/widget/manage_tags_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/widget/tag_chip.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
// Material's Card widget would clash with the domain's Card.
|
||||
import 'package:material_ui/material_ui.dart' hide Card;
|
||||
|
|
@ -244,52 +245,24 @@ class const _Cards(
|
|||
),
|
||||
children: [
|
||||
for (final card in cards)
|
||||
ListTile(
|
||||
CardTile(
|
||||
key: ValueKey(card.id),
|
||||
title: Text(_preview(card.content.front.plainText)),
|
||||
subtitle: _Subtitle(
|
||||
back: _preview(card.content.back.plainText),
|
||||
tags: tagging.of(card.id),
|
||||
),
|
||||
leading: card.hasConflict
|
||||
? const Tooltip(
|
||||
message: 'Changed on two devices',
|
||||
child: Icon(Icons.call_split),
|
||||
)
|
||||
: null,
|
||||
onTap: () => context.push(RoutePaths.card(studysetId, card.id)),
|
||||
card: card,
|
||||
tags: tagging.of(card.id),
|
||||
onOpen: () => context.push(RoutePaths.card(studysetId, card.id)),
|
||||
onDelete: () async {
|
||||
final bloc = context.read<CardListBloc>();
|
||||
final l10n = AppLocalizations.of(context);
|
||||
if (await confirmDestructiveAction(
|
||||
context,
|
||||
title: l10n.composerDeleteTitle,
|
||||
message: l10n.composerDeleteSavedMessage,
|
||||
confirmLabel: l10n.composerDelete,
|
||||
)) {
|
||||
bloc.add(CardListEventDeleteRequested(card));
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// A card's back, and below it its tags, in one line that scrolls sideways
|
||||
/// rather than growing.
|
||||
class const _Subtitle({
|
||||
required final String back,
|
||||
required final List<Tag> tags,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (tags.isEmpty) {
|
||||
return Text(back);
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(back),
|
||||
const SizedBox(height: 4),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
spacing: 6,
|
||||
children: [for (final tag in tags) TagLabel(tag: tag)],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The first line of [text], or a dash for nothing.
|
||||
String _preview(String text) => text.isEmpty ? '–' : text.split('\n').first;
|
||||
|
|
|
|||
143
halkyon_learn_flutter/lib/src/feature/card/widget/card_tile.dart
Normal file
143
halkyon_learn_flutter/lib/src/feature/card/widget/card_tile.dart
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/widget/tag_chip.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
// Material's Card widget would clash with the domain's Card.
|
||||
import 'package:material_ui/material_ui.dart' hide Card;
|
||||
|
||||
/// A card in a studyset's list: a bordered row of one height showing only
|
||||
/// the front, so a long list stays easy to skim, with the card's tags above
|
||||
/// it and a badge when devices changed it differently. Tapping opens it;
|
||||
/// "…" offers editing and deleting.
|
||||
class const CardTile({
|
||||
/// The card.
|
||||
required final Card card,
|
||||
|
||||
/// Its tags, by name.
|
||||
required final List<Tag> tags,
|
||||
|
||||
/// Opens it in the composer.
|
||||
required final VoidCallback onOpen,
|
||||
|
||||
/// Deletes it; asks first.
|
||||
required final VoidCallback onDelete,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final front = card.content.front.plainText.trim().split('\n').first;
|
||||
final hasMeta = tags.isNotEmpty;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Material(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: onOpen,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Ink(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
// At least this high, so rows are even, yet free to grow with
|
||||
// large text.
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 64),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 8, 4, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (card.hasConflict) ...[
|
||||
const _ConflictBadge(),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
if (hasMeta) ...[
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
for (final tag in tags) TagLabel(tag: tag),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
Text(
|
||||
front.isEmpty ? l10n.cardTileUntitled : front,
|
||||
maxLines: hasMeta ? 1 : 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
MenuAnchor(
|
||||
menuChildren: [
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(LucideIcons.pencil, size: 18),
|
||||
onPressed: onOpen,
|
||||
child: Text(l10n.cardTileEdit),
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: Icon(
|
||||
LucideIcons.trash2,
|
||||
size: 18,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
onPressed: onDelete,
|
||||
child: Text(
|
||||
l10n.cardTileDelete,
|
||||
style: TextStyle(color: theme.colorScheme.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
builder: (context, menu, _) => IconButton(
|
||||
tooltip: l10n.cardTileOptions,
|
||||
icon: const Icon(LucideIcons.ellipsis),
|
||||
onPressed: () =>
|
||||
menu.isOpen ? menu.close() : menu.open(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Says a card was changed differently on two devices, to be settled in the
|
||||
/// composer.
|
||||
class const _ConflictBadge() extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final color = theme.colorScheme.error;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(LucideIcons.gitFork, size: 14, color: color),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
AppLocalizations.of(context).cardTileConflict,
|
||||
style: theme.textTheme.labelSmall?.copyWith(color: color),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_tile.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
import 'package:material_ui/material_ui.dart' hide Card;
|
||||
|
|
@ -87,7 +88,7 @@ void main() {
|
|||
expect(find.text('No flashcards yet'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('lists the cards in order, front and back', (tester) async {
|
||||
testWidgets('lists the cards in order, each by its front', (tester) async {
|
||||
await cards.create('japanese', card('猫', 'cat'));
|
||||
await cards.create('japanese', card('犬', 'dog'));
|
||||
|
||||
|
|
@ -97,7 +98,8 @@ void main() {
|
|||
tester.getTopLeft(find.text('猫')).dy,
|
||||
lessThan(tester.getTopLeft(find.text('犬')).dy),
|
||||
);
|
||||
expect(find.text('dog'), findsOneWidget);
|
||||
// The back stays for the composer and studying.
|
||||
expect(find.text('dog'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('marks a card changed on two devices', (tester) async {
|
||||
|
|
@ -105,6 +107,29 @@ void main() {
|
|||
|
||||
await showList(tester);
|
||||
|
||||
expect(find.byTooltip('Changed on two devices'), findsOneWidget);
|
||||
expect(find.text('Changed on two devices'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("a card's options delete it, after asking", (tester) async {
|
||||
final id = await cards.create('japanese', card('猫', 'cat'));
|
||||
await showList(tester);
|
||||
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(CardTile),
|
||||
matching: find.byTooltip('Options'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Delete'));
|
||||
// A menu item acts once the menu has closed, a frame later.
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(gateway.deletions, ['blank $id', 'delete $id']);
|
||||
expect(find.text('No flashcards yet'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue