fix(app): glyph shapes, languages, licences and the side bar's foot

- A Japanese front keeps Japanese shapes whatever the back's language
  (Japanese learnt in Chinese or Korean took Chinese or Korean shapes).
- A change of languages no longer rebuilds the screen below: the scope
  keeps one shape, so a study session keeps its Undo.
- The PDF reader, with the cards and notes written beside it, takes its
  studyset's languages too.
- Licences: GPL-3.0 and AGPL-3.0 mean "only", as SPDX defines them (not
  "or later"); GPLv3 and "public domain" stay as typed; words around a
  Creative Commons licence ("International", "Unported", "Deed") and its
  name written out are read; 4.0 takes no jurisdiction.
- A new sub-studyset takes its parent's licence, not its holder or year.
- The year says it is wrong once typed whole or left; full-width digits
  count; the year's range comes from clock.
- New sub-studyset shows whole in the side bar, no longer cut at half.
- The languages switch says that neither side set means inheriting;
  "license" as the British verb.

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-09 12:45:19 +02:00
commit 4aed71c8db
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
15 changed files with 321 additions and 74 deletions

View file

@ -310,7 +310,7 @@
"studysetSheetLanguages": "Sprachen",
"studysetSheetSetLanguages": "Sprachen festlegen",
"studysetSheetLanguagesHelper": "In welcher Sprache die Seiten der Karten sind. Chinesisch, Japanisch und Koreanisch erscheinen dann in ihren eigenen Schriftzeichen.",
"studysetSheetLanguagesInherited": "Ausgeschaltet übernimmt es die Sprachen des Lernsets, in dem es liegt.",
"studysetSheetLanguagesInherited": "Ausgeschaltet oder ohne festgelegte Seite übernimmt es die Sprachen des Lernsets, in dem es liegt.",
"studysetSheetFrontLanguage": "Sprache der Vorderseite",
"studysetSheetBackLanguage": "Sprache der Rückseite",
"studysetSheetLanguageNotSet": "Nicht festgelegt",

View file

@ -342,7 +342,7 @@
"studysetSheetLanguages": "Languages",
"studysetSheetSetLanguages": "Set languages",
"studysetSheetLanguagesHelper": "Which language each side of the cards is in. Chinese, Japanese and Korean then show in their own character shapes.",
"studysetSheetLanguagesInherited": "Off, it takes the languages of the studyset it is in.",
"studysetSheetLanguagesInherited": "Off, or with neither side set, it takes the languages of the studyset it is in.",
"studysetSheetFrontLanguage": "Front language",
"studysetSheetBackLanguage": "Back language",
"studysetSheetLanguageNotSet": "Not set",
@ -380,7 +380,7 @@
"licenseChooserUse": "Use this licence",
"licenseSummaryCc0": "Anyone may use it for anything, without conditions or credit.",
"licenseSummaryBy": "Others may share and adapt it, even commercially, as long as they credit you.",
"licenseSummaryBySa": "Others may share and adapt it, even commercially, if they credit you and licence their adaptations the same way.",
"licenseSummaryBySa": "Others may share and adapt it, even commercially, if they credit you and license their adaptations the same way.",
"licenseSummaryByNc": "Others may share and adapt it for non-commercial purposes, with credit to you.",
"licenseSummaryByNcSa": "Others may share and adapt it non-commercially, with credit and the same licence on adaptations.",
"licenseSummaryByNd": "Others may share it, even commercially, with credit, but may not share adaptations.",

View file

@ -1921,7 +1921,7 @@ abstract class AppLocalizations {
/// No description provided for @studysetSheetLanguagesInherited.
///
/// In en, this message translates to:
/// **'Off, it takes the languages of the studyset it is in.'**
/// **'Off, or with neither side set, it takes the languages of the studyset it is in.'**
String get studysetSheetLanguagesInherited;
/// No description provided for @studysetSheetFrontLanguage.
@ -2137,7 +2137,7 @@ abstract class AppLocalizations {
/// No description provided for @licenseSummaryBySa.
///
/// In en, this message translates to:
/// **'Others may share and adapt it, even commercially, if they credit you and licence their adaptations the same way.'**
/// **'Others may share and adapt it, even commercially, if they credit you and license their adaptations the same way.'**
String get licenseSummaryBySa;
/// No description provided for @licenseSummaryByNc.

View file

@ -1139,7 +1139,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get studysetSheetLanguagesInherited =>
'Ausgeschaltet übernimmt es die Sprachen des Lernsets, in dem es liegt.';
'Ausgeschaltet oder ohne festgelegte Seite übernimmt es die Sprachen des Lernsets, in dem es liegt.';
@override
String get studysetSheetFrontLanguage => 'Sprache der Vorderseite';

View file

@ -1133,7 +1133,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get studysetSheetLanguagesInherited =>
'Off, it takes the languages of the studyset it is in.';
'Off, or with neither side set, it takes the languages of the studyset it is in.';
@override
String get studysetSheetFrontLanguage => 'Front language';
@ -1261,7 +1261,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get licenseSummaryBySa =>
'Others may share and adapt it, even commercially, if they credit you and licence their adaptations the same way.';
'Others may share and adapt it, even commercially, if they credit you and license their adaptations the same way.';
@override
String get licenseSummaryByNc =>

View file

@ -32,8 +32,21 @@ const appFontFallback = [
/// it shares with the others take its shapes; for any other language, or
/// none, [appFontFallback].
List<String> appFontFallbackFor(String? language) {
final first = _ownFont(language);
return switch (first) {
null => appFontFallback,
final first => [first, ...appFontFallback.where((font) => font != first)],
};
}
/// Whether [language] (a BCP 47 tag) has a font of its own among the
/// bundled ones: Chinese, Japanese and Korean, whose Han characters each
/// draw in their own shapes.
bool hasOwnFont(String? language) => _ownFont(language) != null;
String? _ownFont(String? language) {
final tag = language?.toLowerCase() ?? '';
final first = switch (tag.split('-')) {
return switch (tag.split('-')) {
['ja', ...] => _japanese,
['ko', ...] => _korean,
['zh', ...] when ['hant', 'tw', 'hk', 'mo'].any(tag.split('-').contains) =>
@ -41,10 +54,6 @@ List<String> appFontFallbackFor(String? language) {
['zh', ...] => _simplifiedChinese,
_ => null,
};
return switch (first) {
null => appFontFallback,
final first => [first, ...appFontFallback.where((font) => font != first)],
};
}
/// Adds the bundled fonts' licences to the app's licence list, beside the

View file

@ -16,6 +16,9 @@ import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_engine.dar
import 'package:halkyon_learn_flutter/src/feature/pdf_source/widget/pdf_capture.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/widget/pdf_reader_panels.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_focus.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_language_scope.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -49,7 +52,10 @@ class const PdfReaderScreen({
builder: (context, state) {
final l10n = AppLocalizations.of(context);
return switch (state) {
PdfReaderStateReady() => _Reader(state: state),
PdfReaderStateReady(:final source) => _StudysetLanguages(
studysetId: source.studysetId,
child: _Reader(state: state),
),
PdfReaderStateLoading() => const Scaffold(
body: Center(child: AppLoadingIndicator()),
),
@ -67,6 +73,37 @@ class const PdfReaderScreen({
);
}
/// [child], the PDF and the cards and notes written beside it, with text
/// set for the languages of the studyset [studysetId] (Chinese in its own
/// character shapes, say), as every screen of a studyset has it.
class const _StudysetLanguages({
required final String studysetId,
required final Widget child,
}) extends StatefulWidget {
@override
State<_StudysetLanguages> createState() => _StudysetLanguagesState();
}
class _StudysetLanguagesState() extends State<_StudysetLanguages> {
late final Stream<List<Studyset>> _studysets = PersonScope.of(
context,
).studysets.watchAll();
@override
Widget build(BuildContext context) => StreamBuilder<List<Studyset>>(
stream: _studysets,
builder: (context, snapshot) {
final all = snapshot.data ?? const <Studyset>[];
return LanguageScope(
languages: all.any((studyset) => studyset.id == widget.studysetId)
? languagesOf(widget.studysetId, all)
: (front: null, back: null),
child: widget.child,
);
},
);
}
class const _Message({required final String text, final VoidCallback? onRetry})
extends StatelessWidget {
@override

View file

@ -20,10 +20,12 @@ const knownLicenses = [
/// Short names and spellings that don't become a known licence by folding
/// alone ([_fold]): a Creative Commons licence without a version means the
/// current one, a GPL without "or later" the usual "or later".
/// current one. `GPL-3.0` and `AGPL-3.0` are SPDX's older identifiers for
/// "only" that version, never "or later", which would grant more than the
/// holder chose; `GPLv3` doesn't say which, and "public domain" isn't a
/// licence (CC0 is the holder waiving theirs), so both stay as typed.
const _aliases = {
'CC0': 'CC0-1.0',
'PUBLIC-DOMAIN': 'CC0-1.0',
'CC-BY': 'CC-BY-4.0',
'CC-BY-SA': 'CC-BY-SA-4.0',
'CC-BY-NC': 'CC-BY-NC-4.0',
@ -31,10 +33,10 @@ const _aliases = {
'CC-BY-ND': 'CC-BY-ND-4.0',
'CC-BY-NC-ND': 'CC-BY-NC-ND-4.0',
'APACHE-2': 'Apache-2.0',
'GPL-3.0': 'GPL-3.0-or-later',
'GPLV3': 'GPL-3.0-or-later',
'AGPL-3.0': 'AGPL-3.0-or-later',
'AGPLV3': 'AGPL-3.0-or-later',
'GPL-3.0': 'GPL-3.0-only',
'GPL-3.0-ONLY': 'GPL-3.0-only',
'AGPL-3.0': 'AGPL-3.0-only',
'AGPL-3.0-ONLY': 'AGPL-3.0-only',
'ARR': 'All Rights Reserved',
};
@ -51,11 +53,29 @@ String _fold(String raw) => raw
.replaceAll(RegExp(r'[\s_-]+'), '-')
.replaceAll(RegExp(r'^-+|-+$'), '');
/// A Creative Commons licence, folded: its kind, version and jurisdiction.
final _creativeCommons = RegExp(
r'^CC-(BY(?:-NC)?(?:-SA|-ND)?)-(\d\.\d)(?:-([A-Z]{2,}))?$',
/// A Creative Commons licence by its SPDX identifier: its kind, version
/// and the jurisdiction it was ported to (`US`, `IGO`), which licences
/// before 4.0 may have.
final creativeCommonsLicense = RegExp(
r'^CC-(BY(?:-NC)?(?:-SA|-ND)?)-(\d\.\d)(?:-([A-Z]{2,3}))?$',
);
/// [folded] with the words people write around a Creative Commons licence
/// as the identifier spells them: `Creative Commons Attribution-ShareAlike
/// 4.0 International` is `CC-BY-SA-4.0`.
String _ccWords(String folded) => folded
.replaceAll(RegExp('^CREATIVE-COMMONS-'), 'CC-')
.replaceAll('ATTRIBUTION', 'BY')
.replaceAll('NONCOMMERCIAL', 'NC')
.replaceAll('NON-COMMERCIAL', 'NC')
.replaceAll('SHAREALIKE', 'SA')
.replaceAll('SHARE-ALIKE', 'SA')
.replaceAll(RegExp('NODERIVATIVES|NODERIVS|NO-DERIVATIVES'), 'ND')
.replaceAll(
RegExp(r'(-(INTERNATIONAL|UNPORTED|GENERIC|DEED|LICEN[CS]E|PUBLIC))+$'),
'',
);
/// [raw] as a studyset keeps it: a known licence in its own spelling
/// (`Cc By 4.0` is `CC-BY-4.0`), any Creative Commons licence by its SPDX
/// identifier (`cc by-nc-sa 3.0 us` is `CC-BY-NC-SA-3.0-US`), and anything
@ -66,15 +86,25 @@ String? canonicalLicense(String? raw) {
return null;
}
final folded = _fold(tidy);
final cc = _ccWords(folded);
return _byFolded[folded] ??
(_creativeCommons.hasMatch(folded) ? folded : tidy);
_byFolded[cc] ??
(_isCreativeCommons(cc) ? cc : tidy);
}
/// Whether [license] is a Creative Commons licence that exists: 4.0 was
/// never ported to a jurisdiction.
bool _isCreativeCommons(String license) =>
switch (creativeCommonsLicense.firstMatch(license)) {
null => false,
final match => match[2] != '4.0' || match[3] == null,
};
/// The current version of the Creative Commons licence [license] when it
/// is an older one or one ported to a country (`CC-BY-NC-SA-4.0` for
/// `CC-BY-NC-SA-3.0-US`); null when it is current, or not Creative Commons.
String? newerCreativeCommons(String license) {
final match = _creativeCommons.firstMatch(license);
final match = creativeCommonsLicense.firstMatch(license);
if (match == null || (match[2] == '4.0' && match[3] == null)) {
return null;
}

View file

@ -1,3 +1,5 @@
import 'package:halkyon_learn_flutter/src/feature/studyset/model/license.dart';
/// Where a licence's text is, for a studyset's licence line: the deed of a
/// Creative Commons licence (`CC-BY-SA-4.0`, `CC-BY-NC-SA-3.0-US`,
/// `CC0-1.0`), SPDX's page of a few other well-known licences; null for one
@ -6,7 +8,7 @@ Uri? licenseUrl(String license) {
if (license == 'CC0-1.0') {
return Uri.parse('https://creativecommons.org/publicdomain/zero/1.0/');
}
if (_creativeCommons.firstMatch(license) case final match?) {
if (creativeCommonsLicense.firstMatch(license) case final match?) {
final kind = match[1]!.toLowerCase();
final version = match[2]!;
final jurisdiction = switch (match[3]) {
@ -23,17 +25,13 @@ Uri? licenseUrl(String license) {
return null;
}
/// A Creative Commons licence's SPDX identifier: its kind, version and
/// jurisdiction, if any.
final _creativeCommons = RegExp(
r'^CC-(BY(?:-NC)?(?:-SA|-ND)?)-(\d\.\d)(?:-([A-Z]{2,}))?$',
);
/// Other licences content commonly comes under, by SPDX identifier.
const _spdx = {
'MIT',
'Apache-2.0',
'BSD-3-Clause',
'GPL-3.0-only',
'GPL-3.0-or-later',
'AGPL-3.0-only',
'AGPL-3.0-or-later',
};

View file

@ -23,27 +23,41 @@ class const StudysetLanguageScope({
context.dependOnInheritedWidgetOfExactType<_Languages>()?.languages ??
(front: null, back: null);
@override
Widget build(BuildContext context) =>
LanguageScope(languages: languagesOf(studyset.id, all), child: child);
}
/// [child] with text set for [languages], as [StudysetLanguageScope] sets
/// it, for what knows the languages but not the studyset at first (a PDF
/// read while its studyset is still being found: no languages until then).
class const LanguageScope({
required final StudysetLanguages languages,
required final Widget child,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final languages = languagesOf(studyset.id, all);
final (:front, :back) = languages;
final fallback = switch (appFontFallbackFor(front)) {
final forFront when !listEquals(forFront, appFontFallback) => forFront,
_ => appFontFallbackFor(back),
};
final scoped = _Languages(languages: languages, child: child);
if (listEquals(fallback, appFontFallback)) {
return scoped;
}
// The front's font when it has its own (Japanese learnt in Chinese
// stays Japanese), else the back's.
final fallback = appFontFallbackFor(
hasOwnFont(front) ? front : (hasOwnFont(back) ? back : null),
);
final theme = Theme.of(context);
// Always a Theme, the same one when nothing changes: a widget tree that
// changed shape with the languages would rebuild every screen below
// from scratch (a study session would lose its Undo).
return Theme(
data: theme.copyWith(
data: listEquals(fallback, appFontFallback)
? theme
: theme.copyWith(
textTheme: theme.textTheme.apply(fontFamilyFallback: fallback),
primaryTextTheme: theme.primaryTextTheme.apply(
fontFamilyFallback: fallback,
),
),
child: scoped,
child: _Languages(languages: languages, child: child),
);
}
}

View file

@ -62,11 +62,9 @@ class _StudysetRailState() extends State<StudysetRail> {
insideAnother: true,
inheritedColorName: colorNameOf(widget.parent.id, widget.all),
inheritedLanguages: languagesOf(widget.parent.id, widget.all),
inheritedLicense: (
license: parent.license,
holder: parent.licenseHolder,
year: parent.licenseYear,
),
// The licence, not who holds it nor the year: what the person writes
// inside is theirs, from now.
inheritedLicense: (license: parent.license, holder: null, year: null),
);
if (content != null) {
bloc.add(StudysetListEventCreateRequested(content));
@ -266,9 +264,12 @@ class _StudysetRailState() extends State<StudysetRail> {
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Row(
children: [
// The button takes the width the end leaves, not
// half of it, and shortens only in a narrow drawer.
if (newSubStudyset)
// Leaves room for the end in a narrow drawer.
Flexible(
Expanded(
child: Align(
alignment: AlignmentDirectional.centerStart,
child: FilledButton.tonalIcon(
onPressed: _create,
icon: const Icon(LucideIcons.plus),
@ -279,6 +280,8 @@ class _StudysetRailState() extends State<StudysetRail> {
),
),
),
)
else
const Spacer(),
?footEnd,
],

View file

@ -1,3 +1,4 @@
import 'package:clock/clock.dart';
import 'package:flutter/services.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
@ -86,6 +87,7 @@ class _StudysetSheetState() extends State<_StudysetSheet> {
text: _licenseBefore.year?.toString(),
);
final _licenseFocus = FocusNode();
final _yearFocus = FocusNode();
/// The colour's name as the content holds it: none to take the colour of
/// the studyset it is in (or, at the top level, the neutral one of
@ -116,6 +118,8 @@ class _StudysetSheetState() extends State<_StudysetSheet> {
_license.addListener(() => setState(() {}));
_licenseHolder.addListener(() => setState(() {}));
_licenseYear.addListener(() => setState(() {}));
// A year typed only in part says so once the person moves on.
_yearFocus.addListener(() => setState(() {}));
}
@override
@ -126,6 +130,7 @@ class _StudysetSheetState() extends State<_StudysetSheet> {
_licenseHolder.dispose();
_licenseYear.dispose();
_licenseFocus.dispose();
_yearFocus.dispose();
super.dispose();
}
@ -139,15 +144,29 @@ class _StudysetSheetState() extends State<_StudysetSheet> {
);
}
/// What is wrong with the year: outside the years a copyright can have
/// been from, as far as next year; null when it is fine or empty.
/// Whether the year is one a copyright can have been from, as far as
/// next year, or none is given.
bool get _yearValid {
final text = _licenseYear.text.trim();
final year = int.tryParse(text);
return text.isEmpty ||
(year != null &&
year >= earliestLicenseYear &&
year <= clock.now().year + 1);
}
/// What is wrong with the year, once there is something to say: a whole
/// year out of range, or part of one when the person has moved on; null
/// while it is being typed, fine or empty.
String? _yearError(AppLocalizations l10n) {
final year = int.tryParse(_licenseYear.text.trim());
final latest = DateTime.now().year + 1;
if (year == null || (year >= earliestLicenseYear && year <= latest)) {
final whole = _licenseYear.text.trim().length == 4;
if (_yearValid || (!whole && _yearFocus.hasFocus)) {
return null;
}
return l10n.studysetSheetLicenseYearInvalid(earliestLicenseYear, latest);
return l10n.studysetSheetLicenseYearInvalid(
earliestLicenseYear,
clock.now().year + 1,
);
}
Future<void> _chooseLicense() async {
@ -234,7 +253,7 @@ class _StudysetSheetState() extends State<_StudysetSheet> {
// A long form whose parts shouldn't make it jump.
heightFactor: 0.85,
footer: FilledButton(
onPressed: _title.text.trim().isEmpty || _yearError(l10n) != null
onPressed: _title.text.trim().isEmpty || !_yearValid
? null
: () => Navigator.of(context).pop(_content),
child: Text(
@ -351,8 +370,12 @@ class _StudysetSheetState() extends State<_StudysetSheet> {
width: 140,
child: TextField(
controller: _licenseYear,
focusNode: _yearFocus,
keyboardType: TextInputType.number,
inputFormatters: [
// A Japanese or Chinese keyboard's full-width digits
// as the digits they are, not dropped.
const _HalfWidthDigits(),
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(4),
],
@ -610,3 +633,18 @@ class const _UpperCase() extends TextInputFormatter {
TextEditingValue newValue,
) => newValue.copyWith(text: newValue.text.toUpperCase());
}
/// Full-width digits (`2026`) as the ordinary ones.
class const _HalfWidthDigits() extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) => newValue.copyWith(
text: String.fromCharCodes([
for (final code in newValue.text.codeUnits)
// 0 (U+FF10) to 9 (U+FF19) lie 0xFEE0 above 0 to 9.
if (code >= 0xFF10 && code <= 0xFF19) code - 0xFEE0 else code,
]),
);
}

View file

@ -6,7 +6,6 @@ void main() {
'Commons by its identifier, anything else as typed', () {
expect(canonicalLicense('Cc By 4.0'), 'CC-BY-4.0');
expect(canonicalLicense('cc_by_sa'), 'CC-BY-SA-4.0');
expect(canonicalLicense('public domain'), 'CC0-1.0');
expect(canonicalLicense('mit'), 'MIT');
expect(canonicalLicense('arr'), 'All Rights Reserved');
expect(canonicalLicense('cc by-nc-sa 3.0 us'), 'CC-BY-NC-SA-3.0-US');
@ -15,6 +14,31 @@ void main() {
expect(canonicalLicense(null), isNull);
});
test("words around a Creative Commons licence don't change it, and a "
'name written out is read', () {
expect(canonicalLicense('CC BY-SA 4.0 International'), 'CC-BY-SA-4.0');
expect(canonicalLicense('CC BY 3.0 Unported'), 'CC-BY-3.0');
expect(
canonicalLicense(
'Creative Commons Attribution-NonCommercial-ShareAlike 4.0 '
'International Public License',
),
'CC-BY-NC-SA-4.0',
);
expect(canonicalLicense('CC BY 3.0 IGO'), 'CC-BY-3.0-IGO');
// 4.0 was never ported to a country.
expect(canonicalLicense('CC BY 4.0 DE'), 'CC BY 4.0 DE');
});
test("a licence's meaning is never widened: GPL-3.0 is \"only\", and "
'what says neither, or is no licence, stays as typed', () {
expect(canonicalLicense('GPL-3.0'), 'GPL-3.0-only');
expect(canonicalLicense('agpl 3.0'), 'AGPL-3.0-only');
expect(canonicalLicense('GPL-3.0-or-later'), 'GPL-3.0-or-later');
expect(canonicalLicense('GPLv3'), 'GPLv3');
expect(canonicalLicense('public domain'), 'public domain');
});
test('an older or ported Creative Commons licence has a current version; '
'a current one, or another licence, none', () {
expect(newerCreativeCommons('CC-BY-NC-SA-3.0-US'), 'CC-BY-NC-SA-4.0');

View file

@ -0,0 +1,37 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_language_scope.dart';
import 'package:material_ui/material_ui.dart';
void main() {
testWidgets('a change of languages keeps what is shown below: the screen '
'goes on as it was', (tester) async {
Widget scoped(StudysetLanguages languages) => MaterialApp(
home: LanguageScope(languages: languages, child: const _Counter()),
);
await tester.pumpWidget(scoped((front: 'de', back: 'en')));
await tester.tap(find.byType(TextButton));
await tester.pump();
expect(find.text('1'), findsOneWidget);
await tester.pumpWidget(scoped((front: 'zh-Hant', back: 'en')));
expect(find.text('1'), findsOneWidget);
});
}
/// Counts taps: a state that a rebuilt subtree would lose.
class const _Counter() extends StatefulWidget {
@override
State<_Counter> createState() => _CounterState();
}
class _CounterState() extends State<_Counter> {
var _count = 0;
@override
Widget build(BuildContext context) => TextButton(
onPressed: () => setState(() => _count++),
child: Text('$_count'),
);
}

View file

@ -1,3 +1,4 @@
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/citation/model/source.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
@ -451,6 +452,22 @@ void main() {
expect(bibliography.right, moreOrLessEquals(rail.right - 16));
});
testWidgets('in the side bar, New sub-studyset shows whole', (
tester,
) async {
stored('japanese', 'Japanese');
await showApp(tester, location: RoutePaths.studyset('japanese'));
await tester.pumpAndSettle();
final label = tester.renderObject<RenderParagraph>(
find.descendant(
of: find.byType(StudysetRail),
matching: find.text('New sub-studyset'),
),
);
expect(label.didExceedMaxLines, isFalse);
});
testWidgets('without topics, the foot is the same: one New sub-studyset '
'at its start, the bibliography at its end', (tester) async {
stored('japanese', 'Japanese');
@ -590,6 +607,16 @@ void main() {
expect(firstCjkFont(tester), 'Noto Sans TC');
});
testWidgets('Japanese learnt in Chinese or Korean keeps Japanese shapes: '
'the front comes first', (tester) async {
stored('japanese', 'Japanese', frontLanguage: 'ja', backLanguage: 'ko');
await showApp(tester, location: RoutePaths.studyset('japanese'));
await tester.pumpAndSettle();
expect(firstCjkFont(tester), 'Noto Sans JP');
});
testWidgets('a studyset in no CJK language sets Japanese shapes first', (
tester,
) async {
@ -698,6 +725,9 @@ void main() {
);
await tester.enterText(find.widgetWithText(TextField, 'Year'), '999');
await tester.pump();
// Said once the person moves on from the year.
await tester.tap(find.widgetWithText(TextField, 'License'));
await tester.pump();
expect(
find.text('Enter a year between 1000 and ${DateTime.now().year + 1}.'),
findsOneWidget,
@ -731,7 +761,8 @@ void main() {
});
testWidgets('a new sub-studyset starts with the licence of the one it is '
'in', (tester) async {
'in, but not its holder or year: what is written in it is the '
"person's own", (tester) async {
stored(
'japanese',
'Japanese',
@ -752,9 +783,35 @@ void main() {
final content = saved();
expect(
(content.license, content.licenseHolder, content.licenseYear),
('CC-BY-NC-SA-3.0-US', 'Tae Kim', 2006),
('CC-BY-NC-SA-3.0-US', null, null),
);
});
testWidgets('a year typed in part waits until the person moves on to '
'say so; full-width digits are taken as digits', (tester) async {
stored('japanese', 'Japanese');
await showApp(tester, location: RoutePaths.studyset('japanese'));
await tester.pumpAndSettle();
await tester.tap(find.text('New sub-studyset'));
await tester.pumpAndSettle();
await tester.enterText(find.widgetWithText(TextField, 'Title *'), 'Kana');
final year = find.widgetWithText(TextField, 'Year');
await tester.ensureVisible(year);
await tester.tap(year);
await tester.enterText(year, '202');
await tester.pump();
expect(find.textContaining('Enter a year between'), findsNothing);
// The person moves on.
FocusManager.instance.primaryFocus?.unfocus();
await tester.pump();
expect(find.textContaining('Enter a year between'), findsOneWidget);
await tester.enterText(year, '2026');
await tester.pump();
expect(find.widgetWithText(TextField, '2026'), findsOneWidget);
expect(find.textContaining('Enter a year between'), findsNothing);
});
});
testWidgets('with two notes or more, Reorder beside the tabs lets them be '