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:
parent
49e10895f4
commit
4aed71c8db
15 changed files with 321 additions and 74 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
);
|
||||
}
|
||||
|
|
@ -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 '
|
||||
|
|
|
|||
Loading…
Reference in a new issue