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:
Tizian 「ティツィアーン」 2026-10-01 18:58:54 +02:00
commit 8cf7749ed6
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
7 changed files with 252 additions and 48 deletions

View file

@ -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",

View file

@ -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:

View file

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

View file

@ -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.

View file

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

View 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),
),
],
);
}
}

View file

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