feat(app): the library's studyset card

A studyset in the library: a bar in its colour, its title with the secondary
title as a kicker, what it holds, and beside the title the action it
invites inside a ring its mature cards fill, with a badge counting the due
cards (at most "99+", growing inwards so it never passes the card's edge).
The action is a review when reviews are due, studying when only new cards
are, and adding content otherwise, which opens the studyset: the card only
invites a session that has something to study. A long press stars it, as
does the star, which a hovering pointer reveals and which stays while
starred; the options edit, archive or unarchive, and delete it. An archived
studyset rests: dimmed, neutral, paused. Until its progress is known, a
muted bar stands in for "No content yet", which it might not be.

A starred star is amber, dark enough in the light theme to stand out from
the cards as a graphic must (3:1), a colour the theme extras now hold. It is
Material's filled star: Lucide has no filled one, and the fill is what says
the star is on.

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 00:40:52 +02:00
commit 6dc1e863ea
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
7 changed files with 802 additions and 2 deletions

View file

@ -15,6 +15,21 @@
"libraryTitle": "Library", "libraryTitle": "Library",
"studysetCountFlashcards": "{count, plural, =1{1 flashcard} other{{count} flashcards}}",
"@studysetCountFlashcards": {"placeholders": {"count": {"type": "int"}}},
"studysetNoContent": "No content yet",
"studysetActionReview": "Review",
"studysetActionStudy": "Study",
"studysetActionAdd": "Add content",
"studysetArchived": "Archived",
"studysetStar": "Star",
"studysetUnstar": "Unstar",
"studysetOptions": "Options",
"studysetMenuEdit": "Edit",
"studysetMenuArchive": "Archive",
"studysetMenuUnarchive": "Unarchive",
"studysetMenuDelete": "Delete",
"exploreTitle": "Explore", "exploreTitle": "Explore",
"exploreEmpty": "Studysets that others share will appear here.", "exploreEmpty": "Studysets that others share will appear here.",

View file

@ -148,6 +148,84 @@ abstract class AppLocalizations {
/// **'Library'** /// **'Library'**
String get libraryTitle; String get libraryTitle;
/// No description provided for @studysetCountFlashcards.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 flashcard} other{{count} flashcards}}'**
String studysetCountFlashcards(int count);
/// No description provided for @studysetNoContent.
///
/// In en, this message translates to:
/// **'No content yet'**
String get studysetNoContent;
/// No description provided for @studysetActionReview.
///
/// In en, this message translates to:
/// **'Review'**
String get studysetActionReview;
/// No description provided for @studysetActionStudy.
///
/// In en, this message translates to:
/// **'Study'**
String get studysetActionStudy;
/// No description provided for @studysetActionAdd.
///
/// In en, this message translates to:
/// **'Add content'**
String get studysetActionAdd;
/// No description provided for @studysetArchived.
///
/// In en, this message translates to:
/// **'Archived'**
String get studysetArchived;
/// No description provided for @studysetStar.
///
/// In en, this message translates to:
/// **'Star'**
String get studysetStar;
/// No description provided for @studysetUnstar.
///
/// In en, this message translates to:
/// **'Unstar'**
String get studysetUnstar;
/// No description provided for @studysetOptions.
///
/// In en, this message translates to:
/// **'Options'**
String get studysetOptions;
/// No description provided for @studysetMenuEdit.
///
/// In en, this message translates to:
/// **'Edit'**
String get studysetMenuEdit;
/// No description provided for @studysetMenuArchive.
///
/// In en, this message translates to:
/// **'Archive'**
String get studysetMenuArchive;
/// No description provided for @studysetMenuUnarchive.
///
/// In en, this message translates to:
/// **'Unarchive'**
String get studysetMenuUnarchive;
/// No description provided for @studysetMenuDelete.
///
/// In en, this message translates to:
/// **'Delete'**
String get studysetMenuDelete;
/// No description provided for @exploreTitle. /// No description provided for @exploreTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -37,6 +37,53 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get libraryTitle => 'Library'; String get libraryTitle => 'Library';
@override
String studysetCountFlashcards(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count flashcards',
one: '1 flashcard',
);
return '$_temp0';
}
@override
String get studysetNoContent => 'No content yet';
@override
String get studysetActionReview => 'Review';
@override
String get studysetActionStudy => 'Study';
@override
String get studysetActionAdd => 'Add content';
@override
String get studysetArchived => 'Archived';
@override
String get studysetStar => 'Star';
@override
String get studysetUnstar => 'Unstar';
@override
String get studysetOptions => 'Options';
@override
String get studysetMenuEdit => 'Edit';
@override
String get studysetMenuArchive => 'Archive';
@override
String get studysetMenuUnarchive => 'Unarchive';
@override
String get studysetMenuDelete => 'Delete';
@override @override
String get exploreTitle => 'Explore'; String get exploreTitle => 'Explore';

View file

@ -1,8 +1,9 @@
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
/// The colours Material's colour scheme has no role for: status colours for /// The colours Material's colour scheme has no role for: status colours for
/// feedback (success, warning, info; error is in the scheme), and the four /// feedback (success, warning, info; error is in the scheme), the four FSRS
/// FSRS ratings, which colour the study screen's rating buttons. /// ratings, which colour the study screen's rating buttons, and a starred
/// star.
final class const ThemeExtras({ final class const ThemeExtras({
required final Color success, required final Color success,
required final Color onSuccess, required final Color onSuccess,
@ -22,6 +23,9 @@ final class const ThemeExtras({
/// "Easy". /// "Easy".
required final Color fsrsEasy, required final Color fsrsEasy,
/// A starred studyset's star.
required final Color starred,
}) extends ThemeExtension<ThemeExtras> { }) extends ThemeExtension<ThemeExtras> {
/// On light surfaces. /// On light surfaces.
static const light = ThemeExtras( static const light = ThemeExtras(
@ -35,6 +39,9 @@ final class const ThemeExtras({
fsrsHard: Color(0xFFF78400), fsrsHard: Color(0xFFF78400),
fsrsGood: Color(0xFF3CBB3A), fsrsGood: Color(0xFF3CBB3A),
fsrsEasy: Color(0xFF179FD9), fsrsEasy: Color(0xFF179FD9),
// Amber dark enough to stand out from light surfaces as a graphic must
// (WCAG: 3 to 1).
starred: Color(0xFFC77700),
); );
/// On dark surfaces: the status colours a little brighter, for contrast. /// On dark surfaces: the status colours a little brighter, for contrast.
@ -51,6 +58,7 @@ final class const ThemeExtras({
fsrsHard: Color(0xFFF78400), fsrsHard: Color(0xFFF78400),
fsrsGood: Color(0xFF3CBB3A), fsrsGood: Color(0xFF3CBB3A),
fsrsEasy: Color(0xFF179FD9), fsrsEasy: Color(0xFF179FD9),
starred: Color(0xFFFFC107),
); );
@override @override
@ -65,6 +73,7 @@ final class const ThemeExtras({
Color? fsrsHard, Color? fsrsHard,
Color? fsrsGood, Color? fsrsGood,
Color? fsrsEasy, Color? fsrsEasy,
Color? starred,
}) => ThemeExtras( }) => ThemeExtras(
success: success ?? this.success, success: success ?? this.success,
onSuccess: onSuccess ?? this.onSuccess, onSuccess: onSuccess ?? this.onSuccess,
@ -76,6 +85,7 @@ final class const ThemeExtras({
fsrsHard: fsrsHard ?? this.fsrsHard, fsrsHard: fsrsHard ?? this.fsrsHard,
fsrsGood: fsrsGood ?? this.fsrsGood, fsrsGood: fsrsGood ?? this.fsrsGood,
fsrsEasy: fsrsEasy ?? this.fsrsEasy, fsrsEasy: fsrsEasy ?? this.fsrsEasy,
starred: starred ?? this.starred,
); );
@override @override
@ -95,6 +105,7 @@ final class const ThemeExtras({
fsrsHard: mix(fsrsHard, other.fsrsHard), fsrsHard: mix(fsrsHard, other.fsrsHard),
fsrsGood: mix(fsrsGood, other.fsrsGood), fsrsGood: mix(fsrsGood, other.fsrsGood),
fsrsEasy: mix(fsrsEasy, other.fsrsEasy), fsrsEasy: mix(fsrsEasy, other.fsrsEasy),
starred: mix(starred, other.starred),
); );
} }
} }

View file

@ -0,0 +1,370 @@
import 'package:flutter/services.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/theme/palette_color.dart';
import 'package:halkyon_learn_flutter/src/core/theme/theme_extras.dart';
import 'package:halkyon_learn_flutter/src/core/widget/content_counts_line.dart';
import 'package:halkyon_learn_flutter/src/core/widget/title_with_kicker.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// A studyset in the library. On top a bar in its colour; below, its title
/// with the secondary title as a kicker, and what it holds; beside the title
/// the action it invites, inside a ring its mature cards fill, with how many
/// cards are due; at the bottom its star and its options.
///
/// Tapping the card opens the studyset. The action is a review when reviews
/// are due, studying when only new cards are, and otherwise adding content,
/// which opens the studyset too: the card only invites a session that has
/// something to study. A long press stars or unstars it, as does the star,
/// which a hovering pointer reveals and which stays in view while starred.
/// An archived studyset rests: dimmed, in a neutral colour, paused instead
/// of inviting anything.
class const StudysetCard({
/// The studyset.
required final Studyset studyset,
/// Its cards' progress; null while it is being worked out.
required final StudyProgress? progress,
/// Opens the studyset.
required final VoidCallback onOpen,
/// Starts a study session on it.
required final VoidCallback onStudy,
/// Stars it, or takes its star away.
required final VoidCallback onToggleStar,
/// Edits its title, colour and the like.
required final VoidCallback onEdit,
/// Archives it.
required final VoidCallback onArchive,
/// Takes it out of the archive.
required final VoidCallback onUnarchive,
/// Deletes it.
required final VoidCallback onDelete,
super.key,
}) extends StatefulWidget {
@override
State<StudysetCard> createState() => _StudysetCardState();
}
class _StudysetCardState() extends State<StudysetCard> {
var _hovering = false;
void _toggleStarByLongPress() {
HapticFeedback.selectionClick();
widget.onToggleStar();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final studyset = widget.studyset;
final archived = studyset.isArchived;
final color = archived
? theme.colorScheme.outlineVariant
: PaletteColor.byName(studyset.content.colorName)?.background ??
theme.colorScheme.outline;
return MouseRegion(
onEnter: (_) => setState(() => _hovering = true),
onExit: (_) => setState(() => _hovering = false),
child: Card(
clipBehavior: Clip.antiAlias,
margin: EdgeInsets.zero,
child: InkWell(
onTap: widget.onOpen,
onLongPress: _toggleStarByLongPress,
// Dimming doesn't stop taps: the options still take it out of the
// archive.
child: Opacity(
opacity: archived ? 0.6 : 1,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(height: 4, color: color),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TitleWithKicker(
title: studyset.content.title,
kicker: studyset.content.secondaryTitle,
titleStyle: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
// Cards with and without a kicker sit side by side
// in the grid; this keeps their titles and actions
// level.
reserveKickerSpace: true,
trailing: archived
? const _Paused()
: _Action(
progress: widget.progress,
color: color,
onStudy: widget.onStudy,
onAdd: widget.onOpen,
),
),
const SizedBox(height: 8),
_Holds(progress: widget.progress),
],
),
),
),
const Divider(height: 1, thickness: 1),
_BottomBar(
starred: studyset.isStarred,
starVisible: studyset.isStarred || _hovering,
archived: archived,
onToggleStar: widget.onToggleStar,
onEdit: widget.onEdit,
onArchive: widget.onArchive,
onUnarchive: widget.onUnarchive,
onDelete: widget.onDelete,
),
],
),
),
),
),
);
}
}
/// What the studyset holds; a muted bar of the same height while that is
/// still being worked out, which "No content yet" would misstate.
class const _Holds({required final StudyProgress? progress})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context);
final progress = this.progress;
if (progress == null) {
final fontSize = theme.textTheme.bodySmall?.fontSize ?? 12;
return SizedBox(
height: fontSize * 1.4,
child: Align(
alignment: Alignment.centerLeft,
child: Container(
width: 132,
height: fontSize,
decoration: BoxDecoration(
color: theme.colorScheme.onSurfaceVariant.withValues(
alpha: 0.18,
),
borderRadius: BorderRadius.circular(4),
),
),
),
);
}
return ContentCountsLine(
counts: [(count: progress.cards, label: l10n.studysetCountFlashcards)],
placeholder: l10n.studysetNoContent,
);
}
}
/// The action a studyset invites, inside the ring of its mastery, with a
/// badge counting the due cards when there is something to study.
class const _Action({
required final StudyProgress? progress,
required final Color color,
required final VoidCallback onStudy,
required final VoidCallback onAdd,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context);
final progress = this.progress;
final due = progress?.due ?? 0;
final (icon, tooltip, onPressed) = switch (progress) {
StudyProgress(reviewsDue: > 0) => (
LucideIcons.glasses,
l10n.studysetActionReview,
onStudy,
),
StudyProgress(due: > 0) => (
LucideIcons.play,
l10n.studysetActionStudy,
onStudy,
),
_ => (LucideIcons.plus, l10n.studysetActionAdd, onAdd),
};
final ring = SizedBox.square(
dimension: 48,
child: Stack(
alignment: Alignment.center,
children: [
SizedBox.square(
dimension: 44,
// Sweeps up from empty when the card appears, and on to the new
// share when it changes.
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: progress?.mastery ?? 0),
duration: const Duration(milliseconds: 700),
curve: Curves.easeOutCubic,
builder: (context, value, _) => CircularProgressIndicator(
value: value,
valueColor: AlwaysStoppedAnimation(color),
backgroundColor: color.withValues(alpha: 0.2),
strokeWidth: 3,
),
),
),
IconButton(icon: Icon(icon), tooltip: tooltip, onPressed: onPressed),
],
),
);
if (due == 0) {
return ring;
}
return Stack(
clipBehavior: Clip.none,
children: [
ring,
// Pinned at its right edge, so a longer count grows over the ring
// rather than past the card's edge; and at most "99+".
Positioned(
top: -4,
right: -4,
child: Container(
height: 16,
constraints: const BoxConstraints(minWidth: 16),
padding: const EdgeInsets.symmetric(horizontal: 4),
alignment: Alignment.center,
decoration: ShapeDecoration(
color: theme.colorScheme.error,
shape: const StadiumBorder(),
),
child: Text(
due > 99 ? '99+' : '$due',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onError,
height: 1,
),
),
),
),
],
);
}
}
/// In place of the action while the studyset rests in the archive.
class const _Paused() extends StatelessWidget {
@override
Widget build(BuildContext context) => SizedBox.square(
dimension: 48,
child: Tooltip(
message: AppLocalizations.of(context).studysetArchived,
child: Icon(
LucideIcons.circlePause,
size: 28,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
}
enum _Option() {
edit,
archive,
unarchive,
delete,
}
/// The star and the options.
class const _BottomBar({
required final bool starred,
required final bool starVisible,
required final bool archived,
required final VoidCallback onToggleStar,
required final VoidCallback onEdit,
required final VoidCallback onArchive,
required final VoidCallback onUnarchive,
required final VoidCallback onDelete,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
children: [
const Spacer(),
// Its place is kept while hidden, so revealing it doesn't move the
// options.
IgnorePointer(
ignoring: !starVisible,
child: AnimatedOpacity(
opacity: starVisible ? 1 : 0,
duration: const Duration(milliseconds: 120),
child: IconButton(
// Lucide has no filled star, and a star's fill is what says
// it is on.
icon: Icon(
starred ? Icons.star_rounded : Icons.star_border_rounded,
),
color: starred ? Theme.of(context).extras.starred : null,
tooltip: starred ? l10n.studysetUnstar : l10n.studysetStar,
onPressed: onToggleStar,
),
),
),
PopupMenuButton<_Option>(
icon: const Icon(LucideIcons.ellipsis),
tooltip: l10n.studysetOptions,
onSelected: (option) => switch (option) {
_Option.edit => onEdit(),
_Option.archive => onArchive(),
_Option.unarchive => onUnarchive(),
_Option.delete => onDelete(),
},
itemBuilder: (context) => [
_item(_Option.edit, LucideIcons.pencil, l10n.studysetMenuEdit),
if (archived)
_item(
_Option.unarchive,
LucideIcons.archiveRestore,
l10n.studysetMenuUnarchive,
)
else
_item(
_Option.archive,
LucideIcons.archive,
l10n.studysetMenuArchive,
),
_item(
_Option.delete,
LucideIcons.trash2,
l10n.studysetMenuDelete,
),
],
),
],
),
);
}
PopupMenuItem<_Option> _item(_Option option, IconData icon, String label) =>
PopupMenuItem(
value: option,
child: ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Icon(icon),
title: Text(label),
),
);
}

View file

@ -74,5 +74,22 @@ void main() {
greaterThanOrEqualTo(4.5), greaterThanOrEqualTo(4.5),
); );
}); });
for (final brightness in Brightness.values) {
test('$brightness: a starred star stands out from the cards, as a '
'graphic must (3 to 1)', () {
final theme = appTheme(brightness);
for (final surface in [
theme.colorScheme.surface,
theme.colorScheme.surfaceContainerLow,
]) {
expect(
_contrast(theme.extras.starred, surface),
greaterThanOrEqualTo(3),
);
}
});
}
}); });
} }

View file

@ -0,0 +1,262 @@
import 'package:flutter/gestures.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/theme/app_theme.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_card.dart';
import 'package:material_ui/material_ui.dart';
final vocabulary = Studyset(
id: 'vocabulary',
content: StudysetContent(
title: 'Vocabulary',
description: RichContent.empty(),
secondaryTitle: 'Japanese',
colorName: 'cobalt',
),
);
/// 52 cards, a quarter of them mature, none due.
const nothingDue = StudyProgress(cards: 52, due: 0, reviewsDue: 0, mature: 13);
void main() {
late List<String> calls;
setUp(() => calls = []);
Future<void> show(
WidgetTester tester, {
Studyset? studyset,
StudyProgress? progress = nothingDue,
}) async {
await tester.pumpWidget(
MaterialApp(
theme: appTheme(Brightness.light),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: Center(
child: SizedBox(
width: 360,
height: 188,
child: StudysetCard(
studyset: studyset ?? vocabulary,
progress: progress,
onOpen: () => calls.add('open'),
onStudy: () => calls.add('study'),
onToggleStar: () => calls.add('star'),
onEdit: () => calls.add('edit'),
onArchive: () => calls.add('archive'),
onUnarchive: () => calls.add('unarchive'),
onDelete: () => calls.add('delete'),
),
),
),
),
),
);
await tester.pumpAndSettle();
}
Studyset changed({DateTime? archivedAt, DateTime? starredAt}) => Studyset(
id: vocabulary.id,
content: vocabulary.content,
archivedAt: archivedAt,
starredAt: starredAt,
);
testWidgets('shows the title with its kicker and what the studyset '
'holds', (tester) async {
await show(tester);
expect(find.text('JAPANESE'), findsOneWidget);
expect(find.text('Vocabulary'), findsOneWidget);
expect(find.text('52 flashcards'), findsOneWidget);
});
testWidgets('says when a studyset holds nothing yet', (tester) async {
await show(
tester,
progress: const StudyProgress(cards: 0, due: 0, reviewsDue: 0, mature: 0),
);
expect(find.text('No content yet'), findsOneWidget);
});
testWidgets('claims nothing while its progress is being worked out', (
tester,
) async {
await show(tester, progress: null);
expect(find.textContaining('flashcard'), findsNothing);
expect(find.text('No content yet'), findsNothing);
});
testWidgets('invites a review when reviews are due, with how many cards '
'are due', (tester) async {
await show(
tester,
progress: const StudyProgress(
cards: 52,
due: 12,
reviewsDue: 3,
mature: 13,
),
);
expect(find.text('12'), findsOneWidget);
await tester.tap(find.byTooltip('Review'));
expect(calls, ['study']);
});
testWidgets('invites studying when only new cards are due', (tester) async {
await show(
tester,
progress: const StudyProgress(
cards: 52,
due: 20,
reviewsDue: 0,
mature: 0,
),
);
await tester.tap(find.byTooltip('Study'));
expect(calls, ['study']);
});
testWidgets('invites adding content when nothing is due, which opens '
'it', (tester) async {
await show(tester);
expect(find.text('0'), findsNothing);
await tester.tap(find.byTooltip('Add content'));
expect(calls, ['open']);
});
testWidgets('counts at most 99+ due cards', (tester) async {
await show(
tester,
progress: const StudyProgress(
cards: 900,
due: 875,
reviewsDue: 875,
mature: 0,
),
);
expect(find.text('99+'), findsOneWidget);
});
testWidgets('fills its ring with the share of mature cards', (tester) async {
await show(tester);
final ring = tester.widget<CircularProgressIndicator>(
find.byType(CircularProgressIndicator),
);
expect(ring.value, 0.25);
});
testWidgets('an archived studyset rests: dimmed and paused, inviting '
'nothing', (tester) async {
await show(
tester,
studyset: changed(archivedAt: DateTime.utc(2026, 10)),
progress: const StudyProgress(
cards: 52,
due: 12,
reviewsDue: 3,
mature: 13,
),
);
expect(find.byTooltip('Archived'), findsOneWidget);
expect(find.byTooltip('Review'), findsNothing);
expect(find.text('12'), findsNothing);
expect(
tester
.widget<Opacity>(
find.ancestor(
of: find.text('Vocabulary'),
matching: find.byType(Opacity),
),
)
.opacity,
0.6,
);
});
testWidgets('opens when tapped', (tester) async {
await show(tester);
await tester.tap(find.text('Vocabulary'));
expect(calls, ['open']);
});
testWidgets('a starred studyset shows its star, which takes it away', (
tester,
) async {
await show(tester, studyset: changed(starredAt: DateTime.utc(2026, 10)));
await tester.tap(find.byTooltip('Unstar'));
expect(calls, ['star']);
});
testWidgets('shows the star of an unstarred studyset only to a pointer '
'hovering over it', (tester) async {
await show(tester);
expect(find.byTooltip('Star').hitTestable(), findsNothing);
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(mouse.removePointer);
await mouse.addPointer(location: tester.getCenter(find.text('Vocabulary')));
await tester.pumpAndSettle();
expect(find.byTooltip('Star').hitTestable(), findsOneWidget);
});
testWidgets('a long press stars it', (tester) async {
await show(tester);
await tester.longPress(find.text('Vocabulary'));
expect(calls, ['star']);
});
for (final (option, call) in [
('Edit', 'edit'),
('Archive', 'archive'),
('Delete', 'delete'),
]) {
testWidgets('its options include "$option"', (tester) async {
await show(tester);
await tester.tap(find.byTooltip('Options'));
await tester.pumpAndSettle();
expect(find.text('Unarchive'), findsNothing);
await tester.tap(find.text(option));
await tester.pumpAndSettle();
expect(calls, [call]);
});
}
testWidgets('an archived studyset offers to unarchive it', (tester) async {
await show(tester, studyset: changed(archivedAt: DateTime.utc(2026, 10)));
await tester.tap(find.byTooltip('Options'));
await tester.pumpAndSettle();
expect(find.text('Archive'), findsNothing);
await tester.tap(find.text('Unarchive'));
await tester.pumpAndSettle();
expect(calls, ['unarchive']);
});
}