feat(study): limits switch on and off, step by 5, and take any number typed

Your calls (2026-10-11): a switch per limit, and steps of 5 with any
number typed.

- A daily limit (new cards, reviews; in a studyset's study options and the
  study settings) is a switch, "Limit new cards per day", and its number
  while on. Counting down to 0 for no limit had to be guessed, and a goal
  has nothing like it. Switched off and on again, the limit comes back as
  it was; the first time, at the defaults' 20 new cards, or 200 reviews as
  Anki starts them. Stored as before: 0 still stands for none.
- The study goal counts in steps of 5 as the limits do (reviews: 25).
  Plus and minus go to the next multiple, so 12 steps to 15 or 10, never
  below the first step.
- Tapping the number lets any other be typed, from 1 (CountStepper.onTyped).

Pomodoro minutes and cards per round keep their own steps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-11 14:14:49 +02:00
commit abddd8b9b0
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
13 changed files with 370 additions and 93 deletions

View file

@ -883,6 +883,9 @@
"studySortNewestFirst": "Neueste zuerst",
"studyFewer": "Verringern",
"studyMore": "Erhöhen",
"countStepperType": "Zahl eingeben",
"studyLimitNewPerDay": "Neue Karten pro Tag begrenzen",
"studyLimitReviewsPerDay": "Wiederholungen pro Tag begrenzen",
"studysetMenuReset": "Fortschritt zurücksetzen",
"studysetResetTitle": "Fortschritt zurücksetzen?",

View file

@ -979,6 +979,9 @@
"studySortNewestFirst": "Newest first",
"studyFewer": "Decrease",
"studyMore": "Increase",
"countStepperType": "Type a number",
"studyLimitNewPerDay": "Limit new cards per day",
"studyLimitReviewsPerDay": "Limit reviews per day",
"studysetMenuReset": "Reset progress",
"studysetResetTitle": "Reset progress?",

View file

@ -5218,6 +5218,24 @@ abstract class AppLocalizations {
/// **'Increase'**
String get studyMore;
/// No description provided for @countStepperType.
///
/// In en, this message translates to:
/// **'Type a number'**
String get countStepperType;
/// No description provided for @studyLimitNewPerDay.
///
/// In en, this message translates to:
/// **'Limit new cards per day'**
String get studyLimitNewPerDay;
/// No description provided for @studyLimitReviewsPerDay.
///
/// In en, this message translates to:
/// **'Limit reviews per day'**
String get studyLimitReviewsPerDay;
/// No description provided for @studysetMenuReset.
///
/// In en, this message translates to:

View file

@ -3231,6 +3231,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get studyMore => 'Erhöhen';
@override
String get countStepperType => 'Zahl eingeben';
@override
String get studyLimitNewPerDay => 'Neue Karten pro Tag begrenzen';
@override
String get studyLimitReviewsPerDay => 'Wiederholungen pro Tag begrenzen';
@override
String get studysetMenuReset => 'Fortschritt zurücksetzen';

View file

@ -3212,6 +3212,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get studyMore => 'Increase';
@override
String get countStepperType => 'Type a number';
@override
String get studyLimitNewPerDay => 'Limit new cards per day';
@override
String get studyLimitReviewsPerDay => 'Limit reviews per day';
@override
String get studysetMenuReset => 'Reset progress';

View file

@ -1,3 +1,6 @@
import 'dart:math' as math;
import 'package:flutter/services.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -5,6 +8,10 @@ import 'package:material_ui/material_ui.dart';
/// A number between a minus and a plus, the width of what holds it, in an
/// outlined box: the number bold ([style], body text by default), [suffix]
/// (" / day") lighter after it. A null action can't go further.
///
/// With [onTyped], tapping the number lets one type another in its place,
/// such as 12 between steps of 5 ([stepUp], [stepDown]); it is handed over
/// once confirmed or left, if it is a number from 1.
class const CountStepper({
required final String value,
required final VoidCallback? onFewer,
@ -15,13 +22,88 @@ class const CountStepper({
/// The number's style, before it is made bold.
final TextStyle? style,
/// The number typed, from 1; none to type without.
final ValueChanged<int>? onTyped,
/// The number to start typing over: [value] as a number.
final int? number,
super.key,
}) extends StatelessWidget {
}) extends StatefulWidget {
/// The most digits a number typed has.
static const maxDigits = 4;
@override
State<CountStepper> createState() => _CountStepperState();
}
class _CountStepperState() extends State<CountStepper> {
var _typing = false;
final _field = TextEditingController();
final _focus = FocusNode();
@override
void initState() {
super.initState();
_focus.addListener(() {
if (!_focus.hasFocus && _typing) {
_confirm();
}
});
}
@override
void dispose() {
_field.dispose();
_focus.dispose();
super.dispose();
}
void _type() {
_field.text = '${widget.number ?? ''}';
_field.selection = TextSelection(
baseOffset: 0,
extentOffset: _field.text.length,
);
setState(() => _typing = true);
_focus.requestFocus();
}
void _confirm() {
final typed = int.tryParse(_field.text);
setState(() => _typing = false);
if (typed != null && typed >= 1) {
widget.onTyped?.call(typed);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final colors = theme.colorScheme;
final numberStyle = (widget.style ?? theme.textTheme.bodyLarge)?.copyWith(
color: colors.onSurface,
fontWeight: FontWeight.w600,
);
final shown = Text.rich(
TextSpan(
text: widget.value,
children: [
if (widget.suffix.isNotEmpty)
TextSpan(
text: widget.suffix,
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
fontWeight: FontWeight.w400,
),
),
],
),
textAlign: TextAlign.center,
style: numberStyle,
);
final typable = widget.onTyped != null;
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
@ -33,34 +115,48 @@ class const CountStepper({
children: [
IconButton(
tooltip: l10n.studyFewer,
onPressed: onFewer,
onPressed: _typing ? null : widget.onFewer,
icon: const Icon(LucideIcons.minus),
),
Expanded(
child: Text.rich(
TextSpan(
text: value,
children: [
if (suffix.isNotEmpty)
TextSpan(
text: suffix,
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
fontWeight: FontWeight.w400,
),
child: _typing
? TextField(
controller: _field,
focusNode: _focus,
textAlign: TextAlign.center,
style: numberStyle,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.done,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(
CountStepper.maxDigits,
),
],
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
hintText: l10n.countStepperType,
),
textAlign: TextAlign.center,
style: (style ?? theme.textTheme.bodyLarge)?.copyWith(
color: colors.onSurface,
fontWeight: FontWeight.w600,
onSubmitted: (_) => _focus.unfocus(),
)
: typable
? Tooltip(
message: l10n.countStepperType,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: _type,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: shown,
),
),
)
: shown,
),
IconButton(
tooltip: l10n.studyMore,
onPressed: onMore,
onPressed: _typing ? null : widget.onMore,
icon: const Icon(LucideIcons.plus),
),
],
@ -69,3 +165,16 @@ class const CountStepper({
);
}
}
/// [value] up to the next multiple of [step], at most [max]: 12 to 15 in
/// steps of 5.
int stepUp(int value, int step, {required int max}) =>
math.min(max, (value ~/ step + 1) * step);
/// [value] down to the multiple of [step] below it, never below the first
/// step: 12 to 10, 15 to 10 in steps of 5. Smaller numbers are typed.
int stepDown(int value, int step) =>
math.max(step, ((value - 1) ~/ step) * step);
/// Whether [value] can step down: it is above the first step.
bool canStepDown(int value, int step) => value > step;

View file

@ -242,18 +242,29 @@ class _GoalSheetState() extends State<_GoalSheet> {
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 8),
// In steps of 5, as the study options count (decided 2026-10-11);
// any number in between is typed.
CountStepper(
value: '$_target',
number: _target,
style: theme.textTheme.titleMedium,
onFewer: _target > StudyGoal.minTarget
? () => _setTarget(_target - 1)
onFewer: canStepDown(_target, _step)
? () => _setTarget(stepDown(_target, _step))
: null,
onMore: _target < StudyGoal.maxTarget
? () => _setTarget(_target + 1)
? () => _setTarget(
stepUp(_target, _step, max: StudyGoal.maxTarget),
)
: null,
onTyped: (typed) => _setTarget(
typed.clamp(StudyGoal.minTarget, StudyGoal.maxTarget),
),
),
],
),
);
}
}
/// How far the goal moves at a step.
const _step = 5;

View file

@ -0,0 +1,95 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/widget/count_stepper.dart';
import 'package:material_ui/material_ui.dart';
/// A daily limit, such as of new cards: a switch saying whether there is one
/// ([title]), and while it is on, its number in steps of [step] up to [max],
/// which can also be typed (decided 2026-10-11: that 0 meant no limit had to
/// be guessed, and a goal has no such thing). Switched off and on again, it
/// comes back as it was; the first time, at [initial].
class const DailyLimit({
/// What switches the limit on: "Limit new cards per day".
required final String title,
/// The limit; [unlimited] for none.
required final int value,
/// The value standing for no limit.
required final int unlimited,
/// How far a step moves it.
required final int step,
/// The highest it goes.
required final int max,
/// What it starts at, switched on for the first time.
required final int initial,
/// The limit changed.
required final ValueChanged<int> onChanged,
/// What the limit does, under the switch.
final String? hint,
super.key,
}) extends StatefulWidget {
@override
State<DailyLimit> createState() => _DailyLimitState();
}
class _DailyLimitState() extends State<DailyLimit> {
/// The limit before it was switched off.
late var _last = widget.value == widget.unlimited
? widget.initial
: widget.value;
@override
void didUpdateWidget(DailyLimit oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.value != widget.unlimited) {
_last = widget.value;
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final DailyLimit(:value, :step, :max, :onChanged) = widget;
final limited = value != widget.unlimited;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(widget.title, style: theme.textTheme.titleSmall),
subtitle: limited ? null : Text(l10n.studySettingsUnlimited),
value: limited,
onChanged: (on) => onChanged(on ? _last : widget.unlimited),
),
if (widget.hint case final hint?)
Text(
hint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
if (limited) ...[
const SizedBox(height: 4),
CountStepper(
value: '$value',
number: value,
suffix: l10n.studySettingsPerDay,
onFewer: canStepDown(value, step)
? () => onChanged(stepDown(value, step))
: null,
onMore: value < max
? () => onChanged(stepUp(value, step, max: max))
: null,
onTyped: (typed) => onChanged(typed.clamp(1, max)),
),
],
],
);
}
}

View file

@ -6,6 +6,7 @@ import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart
import 'package:halkyon_learn_flutter/src/core/widget/count_stepper.dart';
import 'package:halkyon_learn_flutter/src/feature/study/bloc/study_settings_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/daily_limit.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';
@ -184,32 +185,15 @@ class const _Options({required final StudyOptions options})
),
if (options.mode == LearnMode.spaced) ...[
const SizedBox(height: 20),
Text(l10n.studySettingsNewPerDay, style: heading),
const SizedBox(height: 4),
Text(
l10n.studySettingsNewPerDayHint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
CountStepper(
value: options.newPerDay == StudyOptions.unlimitedNewPerDay
? l10n.studySettingsUnlimited
: '${options.newPerDay}',
suffix: options.newPerDay == StudyOptions.unlimitedNewPerDay
? ''
: l10n.studySettingsPerDay,
onFewer: options.newPerDay > StudyOptions.unlimitedNewPerDay
? () => change(
options.copyWith(newPerDay: options.newPerDay - _newStep),
)
: null,
onMore: options.newPerDay < StudyOptions.maxNewPerDay
? () => change(
options.copyWith(newPerDay: options.newPerDay + _newStep),
)
: null,
DailyLimit(
title: l10n.studyLimitNewPerDay,
hint: l10n.studySettingsNewPerDayHint,
value: options.newPerDay,
unlimited: StudyOptions.unlimitedNewPerDay,
step: _newStep,
max: StudyOptions.maxNewPerDay,
initial: const StudyOptions().newPerDay,
onChanged: (value) => change(options.copyWith(newPerDay: value)),
),
],
const SizedBox(height: 20),

View file

@ -1,7 +1,7 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
import 'package:halkyon_learn_flutter/src/core/widget/count_stepper.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/daily_limit.dart';
import 'package:material_ui/material_ui.dart';
/// What the study options sheet ended with: the options to keep, or null to
@ -30,6 +30,10 @@ const _newStep = 5;
/// How far the daily limit of reviews moves at a step.
const _reviewsStep = 25;
/// The daily limit of reviews the first time it is switched on, as Anki
/// starts it.
const _initialReviews = 200;
class const _StudyOptionsSheet({
required final StudysetStudyOptions? current,
required final StudysetStudyOptions defaults,
@ -49,11 +53,6 @@ class _StudyOptionsSheetState() extends State<_StudyOptionsSheet> {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final heading = theme.textTheme.titleSmall;
String limit(int value) => value == StudysetStudyOptions.unlimited
? l10n.studySettingsUnlimited
: '$value';
String perDay(int value) =>
value == StudysetStudyOptions.unlimited ? '' : l10n.studySettingsPerDay;
final StudysetStudyOptions(:newPerDay, :reviewsPerDay, :order) = _options;
return SheetScaffold(
title: l10n.studyOptionsTitle,
@ -94,42 +93,25 @@ class _StudyOptionsSheetState() extends State<_StudyOptionsSheet> {
),
),
const SizedBox(height: 16),
Text(l10n.studySettingsNewPerDay, style: heading),
const SizedBox(height: 8),
CountStepper(
value: limit(newPerDay),
suffix: perDay(newPerDay),
onFewer: newPerDay > StudysetStudyOptions.unlimited
? () => _change(
_options.copyWith(newPerDay: newPerDay - _newStep),
)
: null,
onMore: newPerDay < StudysetStudyOptions.maxNewPerDay
? () => _change(
_options.copyWith(newPerDay: newPerDay + _newStep),
)
: null,
DailyLimit(
title: l10n.studyLimitNewPerDay,
value: newPerDay,
unlimited: StudysetStudyOptions.unlimited,
step: _newStep,
max: StudysetStudyOptions.maxNewPerDay,
initial: widget.defaults.newPerDay,
onChanged: (value) => _change(_options.copyWith(newPerDay: value)),
),
const SizedBox(height: 20),
Text(l10n.studyOptionsReviewsPerDay, style: heading),
const SizedBox(height: 8),
CountStepper(
value: limit(reviewsPerDay),
suffix: perDay(reviewsPerDay),
onFewer: reviewsPerDay > StudysetStudyOptions.unlimited
? () => _change(
_options.copyWith(
reviewsPerDay: reviewsPerDay - _reviewsStep,
),
)
: null,
onMore: reviewsPerDay < StudysetStudyOptions.maxReviewsPerDay
? () => _change(
_options.copyWith(
reviewsPerDay: reviewsPerDay + _reviewsStep,
),
)
: null,
const SizedBox(height: 12),
DailyLimit(
title: l10n.studyLimitReviewsPerDay,
value: reviewsPerDay,
unlimited: StudysetStudyOptions.unlimited,
step: _reviewsStep,
max: StudysetStudyOptions.maxReviewsPerDay,
initial: _initialReviews,
onChanged: (value) =>
_change(_options.copyWith(reviewsPerDay: value)),
),
const SizedBox(height: 20),
Text(l10n.studyOptionsOrder, style: heading),

View file

@ -0,0 +1,17 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/widget/count_stepper.dart';
void main() {
test('steps go to the next multiple of the step, never below the first', () {
expect(stepUp(12, 5, max: 100), 15);
expect(stepUp(15, 5, max: 100), 20);
expect(stepUp(98, 5, max: 100), 100);
expect(stepDown(12, 5), 10);
expect(stepDown(15, 5), 10);
expect(stepDown(7, 5), 5);
expect(canStepDown(5, 5), isFalse);
expect(canStepDown(6, 5), isTrue);
expect(stepUp(1, 25, max: 1000), 25);
expect(stepDown(230, 25), 225);
});
}

View file

@ -175,7 +175,8 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('Weekly goal'), findsOneWidget);
expect(find.text('0 of 21 cards this week'), findsOneWidget);
// In steps of 5, as the study options.
expect(find.text('0 of 25 cards this week'), findsOneWidget);
expect(find.byIcon(LucideIcons.pencil), findsOneWidget);
await tester.tap(find.text('Weekly goal'));

View file

@ -469,6 +469,8 @@ void main() {
await tester.tap(find.byTooltip('Decrease').first);
await tester.pump();
await tester.tap(find.text('Limit reviews per day'));
await tester.pump();
await tester.tap(find.byTooltip('Increase').last);
await tester.pump();
await tester.tap(find.text('Mixed'));
@ -480,12 +482,46 @@ void main() {
written('japanese'),
const StudysetStudyOptions(
newPerDay: 15,
reviewsPerDay: 25,
reviewsPerDay: 225,
order: NewCardOrder.mixed,
),
);
});
testWidgets('a limit steps to the next multiple of its step, takes any '
'number typed, and is switched off for none, coming back as it '
'was', (tester) async {
stored('japanese', 'Japanese');
await showLibrary(tester);
await choose(tester, 'Japanese', 'Study options');
Finder newCards() => find.text('20 / day', findRichText: true);
expect(newCards(), findsOneWidget);
await tester.tap(find.byTooltip('Type a number').first);
await tester.pump();
await tester.enterText(find.byType(TextField), '12');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
expect(find.text('12 / day', findRichText: true), findsOneWidget);
await tester.tap(find.byTooltip('Increase').first);
await tester.pump();
expect(find.text('15 / day', findRichText: true), findsOneWidget);
await tester.tap(find.text('Limit new cards per day'));
await tester.pump();
expect(find.text('15 / day', findRichText: true), findsNothing);
expect(find.text('Unlimited'), findsNWidgets(2));
await tester.tap(find.text('Limit new cards per day'));
await tester.pump();
expect(find.text('15 / day', findRichText: true), findsOneWidget);
await tester.tap(find.text('Limit new cards per day'));
await tester.pump();
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(written('japanese')?.newPerDay, StudysetStudyOptions.unlimited);
});
testWidgets('saved as they came, the defaults stay the defaults', (
tester,
) async {