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:
parent
2ee1eb293a
commit
6dc1e863ea
7 changed files with 802 additions and 2 deletions
|
|
@ -15,6 +15,21 @@
|
|||
|
||||
"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",
|
||||
"exploreEmpty": "Studysets that others share will appear here.",
|
||||
|
||||
|
|
|
|||
|
|
@ -148,6 +148,84 @@ abstract class AppLocalizations {
|
|||
/// **'Library'**
|
||||
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.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -37,6 +37,53 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
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
|
||||
String get exploreTitle => 'Explore';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// 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
|
||||
/// FSRS ratings, which colour the study screen's rating buttons.
|
||||
/// feedback (success, warning, info; error is in the scheme), the four FSRS
|
||||
/// ratings, which colour the study screen's rating buttons, and a starred
|
||||
/// star.
|
||||
final class const ThemeExtras({
|
||||
required final Color success,
|
||||
required final Color onSuccess,
|
||||
|
|
@ -22,6 +23,9 @@ final class const ThemeExtras({
|
|||
|
||||
/// "Easy".
|
||||
required final Color fsrsEasy,
|
||||
|
||||
/// A starred studyset's star.
|
||||
required final Color starred,
|
||||
}) extends ThemeExtension<ThemeExtras> {
|
||||
/// On light surfaces.
|
||||
static const light = ThemeExtras(
|
||||
|
|
@ -35,6 +39,9 @@ final class const ThemeExtras({
|
|||
fsrsHard: Color(0xFFF78400),
|
||||
fsrsGood: Color(0xFF3CBB3A),
|
||||
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.
|
||||
|
|
@ -51,6 +58,7 @@ final class const ThemeExtras({
|
|||
fsrsHard: Color(0xFFF78400),
|
||||
fsrsGood: Color(0xFF3CBB3A),
|
||||
fsrsEasy: Color(0xFF179FD9),
|
||||
starred: Color(0xFFFFC107),
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
@ -65,6 +73,7 @@ final class const ThemeExtras({
|
|||
Color? fsrsHard,
|
||||
Color? fsrsGood,
|
||||
Color? fsrsEasy,
|
||||
Color? starred,
|
||||
}) => ThemeExtras(
|
||||
success: success ?? this.success,
|
||||
onSuccess: onSuccess ?? this.onSuccess,
|
||||
|
|
@ -76,6 +85,7 @@ final class const ThemeExtras({
|
|||
fsrsHard: fsrsHard ?? this.fsrsHard,
|
||||
fsrsGood: fsrsGood ?? this.fsrsGood,
|
||||
fsrsEasy: fsrsEasy ?? this.fsrsEasy,
|
||||
starred: starred ?? this.starred,
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
@ -95,6 +105,7 @@ final class const ThemeExtras({
|
|||
fsrsHard: mix(fsrsHard, other.fsrsHard),
|
||||
fsrsGood: mix(fsrsGood, other.fsrsGood),
|
||||
fsrsEasy: mix(fsrsEasy, other.fsrsEasy),
|
||||
starred: mix(starred, other.starred),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -74,5 +74,22 @@ void main() {
|
|||
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),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue