fix: every text field has a limit, and long names no longer overflow

A Pomodoro profile's name could be as long as one liked, and pushed the
Home card's header beyond the window. Of 56 fields, only release notes and
a licence's year had a limit.

Limits by what a field holds (core/widget/text_limits.dart, decided
2026-10-11): a name shown in a menu or on a chip 40 (Pomodoro profiles,
tags, a study button's label); a secondary title or a reading above
characters 60; a title 120 (studysets, notes, paths, steps, PDFs,
callouts, a video's caption, a licence's holder); an answer or item 200
(meanings, readings, glosses, options, categories, gaps, typed answers);
a search 200; a part of a citation 500; a message 2000 (reports, replies,
questions to Claude); a web address 2000; a passage with gaps or a
formula 5000. The public profile and a published studyset's name take the
instance's own limits (display name 30, handle 30, bio 500, links 2000,
name 50), which the app didn't say before. Pasted flashcards to import and
numbers have none. Fields count only from 80 % of their limit.

Text can still be longer where it came from elsewhere (an older app, an
import, a release), so the places showing it cut it short: the Pomodoro
card's header and its profiles. A test shows names beyond every limit on
Home, the library, a studyset's screen with its tabs and topics, the card
browser with its tags, and a session, on a phone and a desktop: none
overflows (it fails on the Pomodoro card as it was).

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:06:18 +02:00
commit e32a3129f6
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
38 changed files with 500 additions and 42 deletions

View file

@ -2,6 +2,7 @@ import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/citation/model/source.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/core/widget/text_limits.dart';
import 'package:material_ui/material_ui.dart';
/// Asks for a source's parts: a new one, or [source] changed. Completes with
@ -100,6 +101,8 @@ class _SourceSheetState() extends State<_SourceSheet> {
}) => Padding(
padding: const EdgeInsets.only(top: 16),
child: TextField(
maxLength: TextLimits.reference,
buildCounter: nearLimitCounter,
controller: controller,
keyboardType: multiline ? TextInputType.multiline : keyboardType,
minLines: 1,

View file

@ -6,6 +6,7 @@ import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/editor/editor_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/theme/theme_extras.dart';
import 'package:halkyon_learn_flutter/src/core/widget/text_limits.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -411,6 +412,8 @@ class _TitleDialogState() extends State<_TitleDialog> {
return AlertDialog(
title: Text(l10n.calloutTitleDialog),
content: TextField(
maxLength: TextLimits.title,
buildCounter: nearLimitCounter,
controller: _field,
autofocus: true,
decoration: InputDecoration(

View file

@ -3,6 +3,7 @@ import 'package:flutter_math_fork/flutter_math.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/editor/math_view.dart';
import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart';
import 'package:halkyon_learn_flutter/src/core/widget/text_limits.dart';
import 'package:material_ui/material_ui.dart';
/// Edits a formula in LaTeX in the whole window, previewed as it is typed,
@ -165,6 +166,8 @@ class _FormulaScreenState() extends State<_FormulaScreen> {
),
const SizedBox(height: 16),
TextField(
maxLength: TextLimits.passage,
buildCounter: nearLimitCounter,
controller: _field,
focusNode: _focus,
autofocus: true,

View file

@ -2,6 +2,7 @@ import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/gestures.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/widget/text_limits.dart';
import 'package:material_ui/material_ui.dart';
// Furigana (ruby): the reading of text within a line, kept on the text's
@ -223,6 +224,8 @@ class _ReadingDialogState() extends State<_ReadingDialog> {
return AlertDialog(
title: Text(l10n.rubyTitle),
content: TextField(
maxLength: TextLimits.secondary,
buildCounter: nearLimitCounter,
controller: _reading,
autofocus: true,
decoration: InputDecoration(

View file

@ -7,6 +7,7 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.d
import 'package:halkyon_learn_flutter/src/core/editor/atomic_block.dart';
import 'package:halkyon_learn_flutter/src/core/editor/editor_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/video/youtube_videos.dart';
import 'package:halkyon_learn_flutter/src/core/widget/text_limits.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -279,6 +280,8 @@ class _VideoDialogState() extends State<_VideoDialog> {
mainAxisSize: MainAxisSize.min,
children: [
TextField(
maxLength: TextLimits.address,
buildCounter: nearLimitCounter,
controller: _link,
autofocus: true,
keyboardType: TextInputType.url,
@ -293,6 +296,8 @@ class _VideoDialogState() extends State<_VideoDialog> {
),
const SizedBox(height: 16),
TextField(
maxLength: TextLimits.title,
buildCounter: nearLimitCounter,
controller: _caption,
decoration: InputDecoration(
labelText: l10n.youtubeCaption,

View file

@ -0,0 +1,68 @@
import 'package:material_ui/material_ui.dart';
/// How long what people type into the app's fields may be, by what it is
/// (decided 2026-10-11): enough for any real one, short enough to show
/// without breaking a row. A field stops there ([TextField.maxLength]) and
/// counts only near the end ([nearLimitCounter]). Text can still be longer
/// where it came from elsewhere (an older app, an import, another person's
/// release), so every place that shows it cuts it short as well. The server
/// keeps its own limits where it checks what is sent (a public profile, a
/// release); the app's fields for those take the server's.
abstract final class TextLimits() {
/// A name shown in a menu, on a chip or a button: a Pomodoro profile, a
/// tag, a study button's label.
static const name = 40;
/// A short line beside a title or above a word: a studyset's or a path's
/// secondary title, a reading set above characters.
static const secondary = 60;
/// The title of something holding content: a studyset, a note, a path, a
/// step, a PDF, a callout, a video's caption; who holds a licence.
static const title = 120;
/// One answer or item: a meaning, a reading, a gloss, an option, a
/// category, a gap, an answer typed while studying.
static const phrase = 200;
/// What is searched for.
static const search = 200;
/// A part of a citation: its authors, title, publisher, and so on.
static const reference = 500;
/// A message to someone: a report's note, a reply, a question asked.
static const message = 2000;
/// A web address: a link, a search engine's, an instance's.
static const address = 2000;
/// A longer text typed as one field: a passage with gaps, a formula.
static const passage = 5000;
/// The share of a limit from which a field counts its characters.
static const countFrom = 0.8;
}
/// A text field's counter ([TextField.buildCounter]) that shows only once
/// the text is near its limit, "38 / 40", in the error colour at the limit:
/// a counter under every title would only be noise.
Widget? nearLimitCounter(
BuildContext context, {
required int currentLength,
required int? maxLength,
required bool isFocused,
}) {
if (maxLength == null || currentLength < maxLength * TextLimits.countFrom) {
return null;
}
final theme = Theme.of(context);
return Text(
'$currentLength / $maxLength',
style: theme.textTheme.bodySmall?.copyWith(
color: currentLength >= maxLength
? theme.colorScheme.error
: theme.colorScheme.onSurfaceVariant,
),
);
}

View file

@ -5,6 +5,7 @@ import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_settings_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
import 'package:halkyon_learn_flutter/src/core/widget/text_limits.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_topic.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/search_engine.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@ -167,6 +168,8 @@ class _AssistantFieldState() extends State<AssistantField> {
_TargetSwitch(target: target, onSwitch: () => _switch(target)),
Expanded(
child: TextField(
maxLength: TextLimits.message,
buildCounter: nearLimitCounter,
controller: _question,
autofocus: widget.autofocus,
minLines: 1,

View file

@ -8,6 +8,7 @@ import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_sett
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/section_card.dart';
import 'package:halkyon_learn_flutter/src/core/widget/text_limits.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/search_engine.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -160,6 +161,8 @@ class _EngineSheetState() extends State<_EngineSheet> {
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: TextField(
maxLength: TextLimits.address,
buildCounter: nearLimitCounter,
controller: _url,
autofocus: true,
keyboardType: TextInputType.url,

View file

@ -16,6 +16,7 @@ import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/theme/theme_extras.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
import 'package:halkyon_learn_flutter/src/core/widget/text_limits.dart';
import 'package:halkyon_learn_flutter/src/core/widget/top_gradient.dart';
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_browser_bloc.dart';
@ -617,6 +618,8 @@ class const _SearchRow({
child: ListenableBuilder(
listenable: controller,
builder: (context, _) => TextField(
maxLength: TextLimits.search,
buildCounter: nearLimitCounter,
controller: controller,
focusNode: focusNode,
textInputAction: TextInputAction.search,

View file

@ -1,4 +1,5 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/widget/text_limits.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -71,6 +72,8 @@ class _CharacterFieldsState() extends State<CharacterFields> {
Text(label, style: labelStyle),
const SizedBox(height: 8),
TextField(
maxLength: TextLimits.phrase,
buildCounter: nearLimitCounter,
controller: controller,
decoration: InputDecoration(
hintText: hint,
@ -93,6 +96,8 @@ class _CharacterFieldsState() extends State<CharacterFields> {
children: [
Expanded(
child: TextField(
maxLength: TextLimits.phrase,
buildCounter: nearLimitCounter,
controller: meaning,
decoration: InputDecoration(
hintText: i == 0

View file

@ -1,4 +1,5 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/widget/text_limits.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -98,6 +99,8 @@ class _MatchingFieldsState() extends State<MatchingFields> {
children: [
Expanded(
child: TextField(
maxLength: TextLimits.phrase,
buildCounter: nearLimitCounter,
controller: target,
decoration: InputDecoration(
hintText: l10n.matchingCategory(t + 1),
@ -151,6 +154,8 @@ class _MatchingFieldsState() extends State<MatchingFields> {
Expanded(
flex: 2,
child: TextField(
maxLength: TextLimits.phrase,
buildCounter: nearLimitCounter,
controller: item,
decoration: InputDecoration(
hintText: l10n.matchingItem(i + 1),

View file

@ -1,4 +1,5 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/widget/text_limits.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/jmdict_tags.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@ -66,6 +67,8 @@ class _VocabularyFieldsState() extends State<VocabularyFields> {
Text(l10n.vocabularyReading, style: labelStyle),
const SizedBox(height: 8),
TextField(
maxLength: TextLimits.phrase,
buildCounter: nearLimitCounter,
controller: _reading,
decoration: InputDecoration(
hintText: l10n.vocabularyReadingHint,
@ -90,6 +93,8 @@ class _VocabularyFieldsState() extends State<VocabularyFields> {
children: [
Expanded(
child: TextField(
maxLength: TextLimits.phrase,
buildCounter: nearLimitCounter,
controller: meaning,
decoration: InputDecoration(
hintText: i == 0
@ -222,6 +227,8 @@ class _SenseFieldsState() extends State<_SenseFields> {
children: [
Expanded(
child: TextField(
maxLength: TextLimits.phrase,
buildCounter: nearLimitCounter,
controller: gloss,
decoration: InputDecoration(
hintText: l10n.vocabularyGlossHint,

View file

@ -1,6 +1,7 @@
import 'package:clock/clock.dart';
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/text_limits.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -100,6 +101,8 @@ class _ReportSheetState() extends State<_ReportSheet> {
),
const SizedBox(height: 16),
TextField(
maxLength: TextLimits.message,
buildCounter: nearLimitCounter,
controller: _note,
minLines: 2,
maxLines: 4,

View file

@ -5,6 +5,7 @@ import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/core/widget/text_limits.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/dictionary_word_tile.dart';
@ -94,6 +95,8 @@ class _LookUpState() extends State<_LookUp> {
children: [
const SizedBox(height: 8),
TextField(
maxLength: TextLimits.search,
buildCounter: nearLimitCounter,
controller: _query,
autofocus: widget.query.isEmpty,
textInputAction: TextInputAction.search,

View file

@ -6,6 +6,7 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.d
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/core/widget/text_limits.dart';
import 'package:halkyon_learn_flutter/src/core/widget/top_gradient.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/bloc/dictionary_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_adder.dart';
@ -222,6 +223,8 @@ class _DictionaryViewState()
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: TextField(
maxLength: TextLimits.search,
buildCounter: nearLimitCounter,
controller: _query,
autofocus: true,
textInputAction: TextInputAction.search,

View file

@ -3,6 +3,7 @@ import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
import 'package:halkyon_learn_flutter/src/core/widget/text_limits.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/bloc/inbox_count_bloc.dart';
@ -150,6 +151,8 @@ class _MessageSheetState() extends State<_MessageSheet> {
const SizedBox(height: 16),
],
TextField(
maxLength: TextLimits.message,
buildCounter: nearLimitCounter,
controller: _text,
minLines: 3,
maxLines: 8,

View file

@ -5,6 +5,7 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
import 'package:halkyon_learn_flutter/src/core/widget/text_limits.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/report_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/bloc/feedback_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/model/feedback.dart';
@ -232,6 +233,8 @@ class _ThreadSheetState() extends State<_ThreadSheet> {
children: [
Expanded(
child: TextField(
maxLength: TextLimits.message,
buildCounter: nearLimitCounter,
controller: _reply,
minLines: 1,
maxLines: 4,

View file

@ -1,6 +1,7 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/instance/instance.dart';
import 'package:halkyon_learn_flutter/src/core/instance/instance_checker.dart';
import 'package:halkyon_learn_flutter/src/core/widget/text_limits.dart';
import 'package:material_ui/material_ui.dart';
/// Choosing an instance, as Mastodon's apps do: its address typed, checked
@ -92,6 +93,8 @@ class _InstanceStepState() extends State<InstanceStep> {
const SizedBox(height: 24),
],
TextField(
maxLength: TextLimits.address,
buildCounter: nearLimitCounter,
controller: _address,
enabled: !_checking,
autocorrect: false,

View file

@ -8,6 +8,7 @@ import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
import 'package:halkyon_learn_flutter/src/core/widget/text_limits.dart';
import 'package:halkyon_learn_flutter/src/core/widget/top_gradient.dart';
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_editor_bloc.dart';
@ -119,6 +120,8 @@ class _PathEditorScreenState() extends State<PathEditorScreen> {
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: TextField(
maxLength: TextLimits.title,
buildCounter: nearLimitCounter,
controller: _title,
style: theme.textTheme.headlineSmall,
decoration: InputDecoration(
@ -134,6 +137,8 @@ class _PathEditorScreenState() extends State<PathEditorScreen> {
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: TextField(
maxLength: TextLimits.secondary,
buildCounter: nearLimitCounter,
controller: _secondary,
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,

View file

@ -12,6 +12,7 @@ import 'package:halkyon_learn_flutter/src/core/widget/empty_list_message.dart';
import 'package:halkyon_learn_flutter/src/core/widget/lift_while_dragged.dart';
import 'package:halkyon_learn_flutter/src/core/widget/reorder_grip.dart';
import 'package:halkyon_learn_flutter/src/core/widget/sliver_centered.dart';
import 'package:halkyon_learn_flutter/src/core/widget/text_limits.dart';
import 'package:halkyon_learn_flutter/src/core/widget/title_with_kicker.dart';
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_list_bloc.dart';
@ -267,6 +268,8 @@ class _NewPathSheetState() extends State<_NewPathSheet> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
maxLength: TextLimits.title,
buildCounter: nearLimitCounter,
controller: _title,
autofocus: true,
decoration: InputDecoration(labelText: l10n.pathTitleLabel),
@ -275,6 +278,8 @@ class _NewPathSheetState() extends State<_NewPathSheet> {
),
const SizedBox(height: 12),
TextField(
maxLength: TextLimits.secondary,
buildCounter: nearLimitCounter,
controller: _secondary,
decoration: InputDecoration(
labelText: l10n.pathSecondaryTitleLabel,

Some files were not shown because too many files have changed in this diff Show more