feat(app): choose a studyset's licence, its year and holder

The studyset sheet gains Licence, as before: "Help me choose" asks three
questions (keep any rights? commercial use? adaptations, alike?) and
recommends one of the six current Creative Commons licences or CC0, with
what it lets others do and a link to its terms; the licence field
suggests well-known licences and takes any other as typed, kept in one
spelling (`cc by-nc-sa 3.0 us` is `CC-BY-NC-SA-3.0-US`); the year sits at
its end (1000 to next year), the holder below. An older or ported
Creative Commons licence gets a note naming its current version. A new
sub-studyset starts with the licence of the one it is in.

Left out until publishing exists: the warnings about sub-studysets'
licences that a parent's doesn't cover. The licence shows for every
studyset, as there is no visibility yet.

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-08 23:48:43 +02:00
commit bd6016ee1b
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
12 changed files with 1385 additions and 9 deletions

View file

@ -314,6 +314,45 @@
"studysetSheetFrontLanguage": "Sprache der Vorderseite",
"studysetSheetBackLanguage": "Sprache der Rückseite",
"studysetSheetLanguageNotSet": "Nicht festgelegt",
"studysetSheetLicense": "Lizenz",
"studysetSheetLicenseHelp": "Hilf mir bei der Wahl",
"studysetSheetLicenseLabel": "Lizenz",
"studysetSheetLicenseHelper": "Unter welchen Bedingungen andere den Inhalt nutzen dürfen, unter dem Lernset mit einem Link darauf angezeigt. Beispiele: CC-BY-4.0, CC-BY-SA-4.0, MIT, All Rights Reserved.",
"studysetSheetLicenseYear": "Jahr",
"studysetSheetLicenseYearHelper": "Das Jahr des Urheberrechts, angezeigt als „© 2019 Name“. Optional.",
"studysetSheetLicenseYearInvalid": "Gib ein Jahr zwischen {min} und {max} ein.",
"@studysetSheetLicenseYearInvalid": {"placeholders": {"min": {"type": "int"}, "max": {"type": "int"}}},
"studysetSheetLicenseHolder": "Rechteinhaber",
"studysetSheetLicenseHolderHelper": "Wer genannt wird: ein Name oder eine Organisation.",
"studysetSheetLicenseNewer": "{license} ist eine ältere Creative-Commons-Lizenz. Ihre aktuelle Version, {current}, passt vielleicht besser.",
"@studysetSheetLicenseNewer": {"placeholders": {"license": {"type": "String"}, "current": {"type": "String"}}},
"licenseChooserTitle": "Lizenz wählen",
"licenseChooserSubtitle": "Ein paar Fragen, um die Creative-Commons-Lizenz zu finden, die dazu passt, wie du dein Werk teilen möchtest.",
"licenseChooserIntroPrompt": "Möchtest du Rechte an deinem Werk behalten?",
"licenseChooserIntroYes": "Ja, unter einer Lizenz teilen",
"licenseChooserIntroYesDetail": "Andere müssen die Bedingungen einhalten, die du als Nächstes wählst.",
"licenseChooserIntroNo": "Nein, als Gemeingut freigeben",
"licenseChooserIntroNoDetail": "CC0: Jeder darf es für alles nutzen, ohne Bedingungen.",
"licenseChooserCommercialPrompt": "Dürfen andere dein Werk kommerziell nutzen?",
"licenseChooserYes": "Ja",
"licenseChooserCommercialYesDetail": "Auch mit Gewinnabsicht.",
"licenseChooserNo": "Nein",
"licenseChooserCommercialNoDetail": "Nur nicht-kommerzielle Nutzung (NC).",
"licenseChooserAdaptationsPrompt": "Dürfen andere dein Werk bearbeiten oder darauf aufbauen?",
"licenseChooserAdaptationsYesDetail": "Bearbeitungen und Remixe sind erlaubt.",
"licenseChooserAdaptationsShareAlike": "Ja, unter gleichen Bedingungen",
"licenseChooserAdaptationsShareAlikeDetail": "Bearbeitungen stehen unter derselben Lizenz (SA).",
"licenseChooserAdaptationsNoDetail": "Nur Weitergabe, keine Bearbeitungen (ND).",
"licenseChooserBack": "Zurück",
"licenseChooserRecommended": "Empfohlene Lizenz",
"licenseChooserUse": "Diese Lizenz verwenden",
"licenseSummaryCc0": "Jeder darf es für alles nutzen, ohne Bedingungen und ohne Nennung.",
"licenseSummaryBy": "Andere dürfen es teilen und bearbeiten, auch kommerziell, solange sie dich nennen.",
"licenseSummaryBySa": "Andere dürfen es teilen und bearbeiten, auch kommerziell, wenn sie dich nennen und ihre Bearbeitungen genauso lizenzieren.",
"licenseSummaryByNc": "Andere dürfen es für nicht-kommerzielle Zwecke teilen und bearbeiten, mit Nennung.",
"licenseSummaryByNcSa": "Andere dürfen es nicht-kommerziell teilen und bearbeiten, mit Nennung und derselben Lizenz für Bearbeitungen.",
"licenseSummaryByNd": "Andere dürfen es teilen, auch kommerziell, mit Nennung, aber keine Bearbeitungen weitergeben.",
"licenseSummaryByNcNd": "Andere dürfen es nicht-kommerziell teilen, mit Nennung, aber nicht bearbeiten: die strengste Creative-Commons-Lizenz.",
"languageArabic": "Arabisch",
"languageGerman": "Deutsch",
"languageGreek": "Griechisch",

View file

@ -346,6 +346,45 @@
"studysetSheetFrontLanguage": "Front language",
"studysetSheetBackLanguage": "Back language",
"studysetSheetLanguageNotSet": "Not set",
"studysetSheetLicense": "Licence",
"studysetSheetLicenseHelp": "Help me choose",
"studysetSheetLicenseLabel": "Licence",
"studysetSheetLicenseHelper": "The terms others may use the content under, shown below the studyset with a link to them. Examples: CC-BY-4.0, CC-BY-SA-4.0, MIT, All Rights Reserved.",
"studysetSheetLicenseYear": "Year",
"studysetSheetLicenseYearHelper": "The copyright year, shown as “© 2019 Name”. Optional.",
"studysetSheetLicenseYearInvalid": "Enter a year between {min} and {max}.",
"@studysetSheetLicenseYearInvalid": {"placeholders": {"min": {"type": "int"}, "max": {"type": "int"}}},
"studysetSheetLicenseHolder": "Licence holder",
"studysetSheetLicenseHolderHelper": "Who to credit: a name or an organisation.",
"studysetSheetLicenseNewer": "{license} is an older Creative Commons licence. Its current version, {current}, may fit better.",
"@studysetSheetLicenseNewer": {"placeholders": {"license": {"type": "String"}, "current": {"type": "String"}}},
"licenseChooserTitle": "Choose a licence",
"licenseChooserSubtitle": "A few questions to find the Creative Commons licence that fits how you want your work shared.",
"licenseChooserIntroPrompt": "Do you want to keep any rights to your work?",
"licenseChooserIntroYes": "Yes, share it under a licence",
"licenseChooserIntroYesDetail": "Others must follow the conditions you choose next.",
"licenseChooserIntroNo": "No, dedicate it to the public domain",
"licenseChooserIntroNoDetail": "CC0: anyone may use it for anything, without conditions.",
"licenseChooserCommercialPrompt": "Allow others to use your work commercially?",
"licenseChooserYes": "Yes",
"licenseChooserCommercialYesDetail": "Including for profit.",
"licenseChooserNo": "No",
"licenseChooserCommercialNoDetail": "Non-commercial uses only (NC).",
"licenseChooserAdaptationsPrompt": "Allow others to adapt or build upon your work?",
"licenseChooserAdaptationsYesDetail": "Remixes and adaptations are allowed.",
"licenseChooserAdaptationsShareAlike": "Yes, if they share alike",
"licenseChooserAdaptationsShareAlikeDetail": "Adaptations carry this same licence (SA).",
"licenseChooserAdaptationsNoDetail": "Sharing only, no adaptations (ND).",
"licenseChooserBack": "Back",
"licenseChooserRecommended": "Recommended licence",
"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.",
"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.",
"licenseSummaryByNcNd": "Others may share it non-commercially, with credit, but may not adapt it: the strictest Creative Commons licence.",
"languageArabic": "Arabic",
"languageGerman": "German",
"languageGreek": "Greek",

View file

@ -5,5 +5,19 @@
"colorPickerLabel": "Color: {color}",
"tagSheetColor": "Color",
"formatAlignCenter": "Center",
"homeCustomise": "Customize"
"homeCustomise": "Customize",
"studysetSheetLicense": "License",
"studysetSheetLicenseLabel": "License",
"studysetSheetLicenseHolder": "License holder",
"studysetSheetLicenseHolderHelper": "Who to credit: a name or an organization.",
"studysetSheetLicenseNewer": "{license} is an older Creative Commons license. Its current version, {current}, may fit better.",
"licenseChooserTitle": "Choose a license",
"licenseChooserSubtitle": "A few questions to find the Creative Commons license that fits how you want your work shared.",
"licenseChooserIntroYes": "Yes, share it under a license",
"licenseChooserAdaptationsShareAlikeDetail": "Adaptations carry this same license (SA).",
"licenseChooserRecommended": "Recommended license",
"licenseChooserUse": "Use this license",
"licenseSummaryBySa": "Others may share and adapt it, even commercially, if they credit you and license their adaptations the same way.",
"licenseSummaryByNcSa": "Others may share and adapt it non-commercially, with credit and the same license on adaptations.",
"licenseSummaryByNcNd": "Others may share it non-commercially, with credit, but may not adapt it: the strictest Creative Commons license."
}

View file

@ -1942,6 +1942,228 @@ abstract class AppLocalizations {
/// **'Not set'**
String get studysetSheetLanguageNotSet;
/// No description provided for @studysetSheetLicense.
///
/// In en, this message translates to:
/// **'Licence'**
String get studysetSheetLicense;
/// No description provided for @studysetSheetLicenseHelp.
///
/// In en, this message translates to:
/// **'Help me choose'**
String get studysetSheetLicenseHelp;
/// No description provided for @studysetSheetLicenseLabel.
///
/// In en, this message translates to:
/// **'Licence'**
String get studysetSheetLicenseLabel;
/// No description provided for @studysetSheetLicenseHelper.
///
/// In en, this message translates to:
/// **'The terms others may use the content under, shown below the studyset with a link to them. Examples: CC-BY-4.0, CC-BY-SA-4.0, MIT, All Rights Reserved.'**
String get studysetSheetLicenseHelper;
/// No description provided for @studysetSheetLicenseYear.
///
/// In en, this message translates to:
/// **'Year'**
String get studysetSheetLicenseYear;
/// No description provided for @studysetSheetLicenseYearHelper.
///
/// In en, this message translates to:
/// **'The copyright year, shown as “© 2019 Name”. Optional.'**
String get studysetSheetLicenseYearHelper;
/// No description provided for @studysetSheetLicenseYearInvalid.
///
/// In en, this message translates to:
/// **'Enter a year between {min} and {max}.'**
String studysetSheetLicenseYearInvalid(int min, int max);
/// No description provided for @studysetSheetLicenseHolder.
///
/// In en, this message translates to:
/// **'Licence holder'**
String get studysetSheetLicenseHolder;
/// No description provided for @studysetSheetLicenseHolderHelper.
///
/// In en, this message translates to:
/// **'Who to credit: a name or an organisation.'**
String get studysetSheetLicenseHolderHelper;
/// No description provided for @studysetSheetLicenseNewer.
///
/// In en, this message translates to:
/// **'{license} is an older Creative Commons licence. Its current version, {current}, may fit better.'**
String studysetSheetLicenseNewer(String license, String current);
/// No description provided for @licenseChooserTitle.
///
/// In en, this message translates to:
/// **'Choose a licence'**
String get licenseChooserTitle;
/// No description provided for @licenseChooserSubtitle.
///
/// In en, this message translates to:
/// **'A few questions to find the Creative Commons licence that fits how you want your work shared.'**
String get licenseChooserSubtitle;
/// No description provided for @licenseChooserIntroPrompt.
///
/// In en, this message translates to:
/// **'Do you want to keep any rights to your work?'**
String get licenseChooserIntroPrompt;
/// No description provided for @licenseChooserIntroYes.
///
/// In en, this message translates to:
/// **'Yes, share it under a licence'**
String get licenseChooserIntroYes;
/// No description provided for @licenseChooserIntroYesDetail.
///
/// In en, this message translates to:
/// **'Others must follow the conditions you choose next.'**
String get licenseChooserIntroYesDetail;
/// No description provided for @licenseChooserIntroNo.
///
/// In en, this message translates to:
/// **'No, dedicate it to the public domain'**
String get licenseChooserIntroNo;
/// No description provided for @licenseChooserIntroNoDetail.
///
/// In en, this message translates to:
/// **'CC0: anyone may use it for anything, without conditions.'**
String get licenseChooserIntroNoDetail;
/// No description provided for @licenseChooserCommercialPrompt.
///
/// In en, this message translates to:
/// **'Allow others to use your work commercially?'**
String get licenseChooserCommercialPrompt;
/// No description provided for @licenseChooserYes.
///
/// In en, this message translates to:
/// **'Yes'**
String get licenseChooserYes;
/// No description provided for @licenseChooserCommercialYesDetail.
///
/// In en, this message translates to:
/// **'Including for profit.'**
String get licenseChooserCommercialYesDetail;
/// No description provided for @licenseChooserNo.
///
/// In en, this message translates to:
/// **'No'**
String get licenseChooserNo;
/// No description provided for @licenseChooserCommercialNoDetail.
///
/// In en, this message translates to:
/// **'Non-commercial uses only (NC).'**
String get licenseChooserCommercialNoDetail;
/// No description provided for @licenseChooserAdaptationsPrompt.
///
/// In en, this message translates to:
/// **'Allow others to adapt or build upon your work?'**
String get licenseChooserAdaptationsPrompt;
/// No description provided for @licenseChooserAdaptationsYesDetail.
///
/// In en, this message translates to:
/// **'Remixes and adaptations are allowed.'**
String get licenseChooserAdaptationsYesDetail;
/// No description provided for @licenseChooserAdaptationsShareAlike.
///
/// In en, this message translates to:
/// **'Yes, if they share alike'**
String get licenseChooserAdaptationsShareAlike;
/// No description provided for @licenseChooserAdaptationsShareAlikeDetail.
///
/// In en, this message translates to:
/// **'Adaptations carry this same licence (SA).'**
String get licenseChooserAdaptationsShareAlikeDetail;
/// No description provided for @licenseChooserAdaptationsNoDetail.
///
/// In en, this message translates to:
/// **'Sharing only, no adaptations (ND).'**
String get licenseChooserAdaptationsNoDetail;
/// No description provided for @licenseChooserBack.
///
/// In en, this message translates to:
/// **'Back'**
String get licenseChooserBack;
/// No description provided for @licenseChooserRecommended.
///
/// In en, this message translates to:
/// **'Recommended licence'**
String get licenseChooserRecommended;
/// No description provided for @licenseChooserUse.
///
/// In en, this message translates to:
/// **'Use this licence'**
String get licenseChooserUse;
/// No description provided for @licenseSummaryCc0.
///
/// In en, this message translates to:
/// **'Anyone may use it for anything, without conditions or credit.'**
String get licenseSummaryCc0;
/// No description provided for @licenseSummaryBy.
///
/// In en, this message translates to:
/// **'Others may share and adapt it, even commercially, as long as they credit you.'**
String get licenseSummaryBy;
/// 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.'**
String get licenseSummaryBySa;
/// No description provided for @licenseSummaryByNc.
///
/// In en, this message translates to:
/// **'Others may share and adapt it for non-commercial purposes, with credit to you.'**
String get licenseSummaryByNc;
/// No description provided for @licenseSummaryByNcSa.
///
/// In en, this message translates to:
/// **'Others may share and adapt it non-commercially, with credit and the same licence on adaptations.'**
String get licenseSummaryByNcSa;
/// No description provided for @licenseSummaryByNd.
///
/// In en, this message translates to:
/// **'Others may share it, even commercially, with credit, but may not share adaptations.'**
String get licenseSummaryByNd;
/// No description provided for @licenseSummaryByNcNd.
///
/// In en, this message translates to:
/// **'Others may share it non-commercially, with credit, but may not adapt it: the strictest Creative Commons licence.'**
String get licenseSummaryByNcNd;
/// No description provided for @languageArabic.
///
/// In en, this message translates to:

View file

@ -1150,6 +1150,142 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get studysetSheetLanguageNotSet => 'Nicht festgelegt';
@override
String get studysetSheetLicense => 'Lizenz';
@override
String get studysetSheetLicenseHelp => 'Hilf mir bei der Wahl';
@override
String get studysetSheetLicenseLabel => 'Lizenz';
@override
String get studysetSheetLicenseHelper =>
'Unter welchen Bedingungen andere den Inhalt nutzen dürfen, unter dem Lernset mit einem Link darauf angezeigt. Beispiele: CC-BY-4.0, CC-BY-SA-4.0, MIT, All Rights Reserved.';
@override
String get studysetSheetLicenseYear => 'Jahr';
@override
String get studysetSheetLicenseYearHelper =>
'Das Jahr des Urheberrechts, angezeigt als „© 2019 Name“. Optional.';
@override
String studysetSheetLicenseYearInvalid(int min, int max) {
return 'Gib ein Jahr zwischen $min und $max ein.';
}
@override
String get studysetSheetLicenseHolder => 'Rechteinhaber';
@override
String get studysetSheetLicenseHolderHelper =>
'Wer genannt wird: ein Name oder eine Organisation.';
@override
String studysetSheetLicenseNewer(String license, String current) {
return '$license ist eine ältere Creative-Commons-Lizenz. Ihre aktuelle Version, $current, passt vielleicht besser.';
}
@override
String get licenseChooserTitle => 'Lizenz wählen';
@override
String get licenseChooserSubtitle =>
'Ein paar Fragen, um die Creative-Commons-Lizenz zu finden, die dazu passt, wie du dein Werk teilen möchtest.';
@override
String get licenseChooserIntroPrompt =>
'Möchtest du Rechte an deinem Werk behalten?';
@override
String get licenseChooserIntroYes => 'Ja, unter einer Lizenz teilen';
@override
String get licenseChooserIntroYesDetail =>
'Andere müssen die Bedingungen einhalten, die du als Nächstes wählst.';
@override
String get licenseChooserIntroNo => 'Nein, als Gemeingut freigeben';
@override
String get licenseChooserIntroNoDetail =>
'CC0: Jeder darf es für alles nutzen, ohne Bedingungen.';
@override
String get licenseChooserCommercialPrompt =>
'Dürfen andere dein Werk kommerziell nutzen?';
@override
String get licenseChooserYes => 'Ja';
@override
String get licenseChooserCommercialYesDetail => 'Auch mit Gewinnabsicht.';
@override
String get licenseChooserNo => 'Nein';
@override
String get licenseChooserCommercialNoDetail =>
'Nur nicht-kommerzielle Nutzung (NC).';
@override
String get licenseChooserAdaptationsPrompt =>
'Dürfen andere dein Werk bearbeiten oder darauf aufbauen?';
@override
String get licenseChooserAdaptationsYesDetail =>
'Bearbeitungen und Remixe sind erlaubt.';
@override
String get licenseChooserAdaptationsShareAlike =>
'Ja, unter gleichen Bedingungen';
@override
String get licenseChooserAdaptationsShareAlikeDetail =>
'Bearbeitungen stehen unter derselben Lizenz (SA).';
@override
String get licenseChooserAdaptationsNoDetail =>
'Nur Weitergabe, keine Bearbeitungen (ND).';
@override
String get licenseChooserBack => 'Zurück';
@override
String get licenseChooserRecommended => 'Empfohlene Lizenz';
@override
String get licenseChooserUse => 'Diese Lizenz verwenden';
@override
String get licenseSummaryCc0 =>
'Jeder darf es für alles nutzen, ohne Bedingungen und ohne Nennung.';
@override
String get licenseSummaryBy =>
'Andere dürfen es teilen und bearbeiten, auch kommerziell, solange sie dich nennen.';
@override
String get licenseSummaryBySa =>
'Andere dürfen es teilen und bearbeiten, auch kommerziell, wenn sie dich nennen und ihre Bearbeitungen genauso lizenzieren.';
@override
String get licenseSummaryByNc =>
'Andere dürfen es für nicht-kommerzielle Zwecke teilen und bearbeiten, mit Nennung.';
@override
String get licenseSummaryByNcSa =>
'Andere dürfen es nicht-kommerziell teilen und bearbeiten, mit Nennung und derselben Lizenz für Bearbeitungen.';
@override
String get licenseSummaryByNd =>
'Andere dürfen es teilen, auch kommerziell, mit Nennung, aber keine Bearbeitungen weitergeben.';
@override
String get licenseSummaryByNcNd =>
'Andere dürfen es nicht-kommerziell teilen, mit Nennung, aber nicht bearbeiten: die strengste Creative-Commons-Lizenz.';
@override
String get languageArabic => 'Arabisch';

View file

@ -1144,6 +1144,141 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get studysetSheetLanguageNotSet => 'Not set';
@override
String get studysetSheetLicense => 'Licence';
@override
String get studysetSheetLicenseHelp => 'Help me choose';
@override
String get studysetSheetLicenseLabel => 'Licence';
@override
String get studysetSheetLicenseHelper =>
'The terms others may use the content under, shown below the studyset with a link to them. Examples: CC-BY-4.0, CC-BY-SA-4.0, MIT, All Rights Reserved.';
@override
String get studysetSheetLicenseYear => 'Year';
@override
String get studysetSheetLicenseYearHelper =>
'The copyright year, shown as “© 2019 Name”. Optional.';
@override
String studysetSheetLicenseYearInvalid(int min, int max) {
return 'Enter a year between $min and $max.';
}
@override
String get studysetSheetLicenseHolder => 'Licence holder';
@override
String get studysetSheetLicenseHolderHelper =>
'Who to credit: a name or an organisation.';
@override
String studysetSheetLicenseNewer(String license, String current) {
return '$license is an older Creative Commons licence. Its current version, $current, may fit better.';
}
@override
String get licenseChooserTitle => 'Choose a licence';
@override
String get licenseChooserSubtitle =>
'A few questions to find the Creative Commons licence that fits how you want your work shared.';
@override
String get licenseChooserIntroPrompt =>
'Do you want to keep any rights to your work?';
@override
String get licenseChooserIntroYes => 'Yes, share it under a licence';
@override
String get licenseChooserIntroYesDetail =>
'Others must follow the conditions you choose next.';
@override
String get licenseChooserIntroNo => 'No, dedicate it to the public domain';
@override
String get licenseChooserIntroNoDetail =>
'CC0: anyone may use it for anything, without conditions.';
@override
String get licenseChooserCommercialPrompt =>
'Allow others to use your work commercially?';
@override
String get licenseChooserYes => 'Yes';
@override
String get licenseChooserCommercialYesDetail => 'Including for profit.';
@override
String get licenseChooserNo => 'No';
@override
String get licenseChooserCommercialNoDetail =>
'Non-commercial uses only (NC).';
@override
String get licenseChooserAdaptationsPrompt =>
'Allow others to adapt or build upon your work?';
@override
String get licenseChooserAdaptationsYesDetail =>
'Remixes and adaptations are allowed.';
@override
String get licenseChooserAdaptationsShareAlike => 'Yes, if they share alike';
@override
String get licenseChooserAdaptationsShareAlikeDetail =>
'Adaptations carry this same licence (SA).';
@override
String get licenseChooserAdaptationsNoDetail =>
'Sharing only, no adaptations (ND).';
@override
String get licenseChooserBack => 'Back';
@override
String get licenseChooserRecommended => 'Recommended licence';
@override
String get licenseChooserUse => 'Use this licence';
@override
String get licenseSummaryCc0 =>
'Anyone may use it for anything, without conditions or credit.';
@override
String get licenseSummaryBy =>
'Others may share and adapt it, even commercially, as long as they credit you.';
@override
String get licenseSummaryBySa =>
'Others may share and adapt it, even commercially, if they credit you and licence their adaptations the same way.';
@override
String get licenseSummaryByNc =>
'Others may share and adapt it for non-commercial purposes, with credit to you.';
@override
String get licenseSummaryByNcSa =>
'Others may share and adapt it non-commercially, with credit and the same licence on adaptations.';
@override
String get licenseSummaryByNd =>
'Others may share it, even commercially, with credit, but may not share adaptations.';
@override
String get licenseSummaryByNcNd =>
'Others may share it non-commercially, with credit, but may not adapt it: the strictest Creative Commons licence.';
@override
String get languageArabic => 'Arabic';
@ -4019,6 +4154,56 @@ class AppLocalizationsEnUs extends AppLocalizationsEn {
return 'Color: $color';
}
@override
String get studysetSheetLicense => 'License';
@override
String get studysetSheetLicenseLabel => 'License';
@override
String get studysetSheetLicenseHolder => 'License holder';
@override
String get studysetSheetLicenseHolderHelper =>
'Who to credit: a name or an organization.';
@override
String studysetSheetLicenseNewer(String license, String current) {
return '$license is an older Creative Commons license. Its current version, $current, may fit better.';
}
@override
String get licenseChooserTitle => 'Choose a license';
@override
String get licenseChooserSubtitle =>
'A few questions to find the Creative Commons license that fits how you want your work shared.';
@override
String get licenseChooserIntroYes => 'Yes, share it under a license';
@override
String get licenseChooserAdaptationsShareAlikeDetail =>
'Adaptations carry this same license (SA).';
@override
String get licenseChooserRecommended => 'Recommended license';
@override
String get licenseChooserUse => 'Use this license';
@override
String get licenseSummaryBySa =>
'Others may share and adapt it, even commercially, if they credit you and license their adaptations the same way.';
@override
String get licenseSummaryByNcSa =>
'Others may share and adapt it non-commercially, with credit and the same license on adaptations.';
@override
String get licenseSummaryByNcNd =>
'Others may share it non-commercially, with credit, but may not adapt it: the strictest Creative Commons license.';
@override
String get tagSheetColor => 'Color';

View file

@ -0,0 +1,89 @@
/// Licences content commonly comes under, as a studyset keeps them: SPDX
/// identifiers, and "All Rights Reserved", which has none but marks content
/// not licensed to anyone. They are the licence field's suggestions; any
/// other licence is kept as typed.
const knownLicenses = [
'CC0-1.0',
'CC-BY-4.0',
'CC-BY-SA-4.0',
'CC-BY-NC-4.0',
'CC-BY-NC-SA-4.0',
'CC-BY-ND-4.0',
'CC-BY-NC-ND-4.0',
'MIT',
'Apache-2.0',
'BSD-3-Clause',
'GPL-3.0-or-later',
'AGPL-3.0-or-later',
'All Rights Reserved',
];
/// 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".
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',
'CC-BY-NC-SA': 'CC-BY-NC-SA-4.0',
'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',
'ARR': 'All Rights Reserved',
};
final _byFolded = {
for (final license in knownLicenses) _fold(license): license,
..._aliases,
};
/// [raw] in capitals, every run of spaces, underscores and hyphens one
/// hyphen: `cc by_sa 4.0` and `CC-BY-SA-4.0` fold alike.
String _fold(String raw) => raw
.trim()
.toUpperCase()
.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,}))?$',
);
/// [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
/// else as typed, spaces tidied; null for nothing.
String? canonicalLicense(String? raw) {
final tidy = raw?.trim().replaceAll(RegExp(r'\s+'), ' ') ?? '';
if (tidy.isEmpty) {
return null;
}
final folded = _fold(tidy);
return _byFolded[folded] ??
(_creativeCommons.hasMatch(folded) ? folded : tidy);
}
/// 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);
if (match == null || (match[2] == '4.0' && match[3] == null)) {
return null;
}
return 'CC-${match[1]}-4.0';
}
/// The earliest copyright year the licence's year takes.
const earliestLicenseYear = 1000;
/// A studyset's licence as a whole: the licence, who holds the rights, and
/// the copyright year; each null when not given.
typedef StudysetLicense = ({String? license, String? holder, int? year});

View file

@ -0,0 +1,305 @@
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/feature/studyset/model/license_link.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
import 'package:url_launcher/url_launcher.dart';
/// Finds the Creative Commons licence that fits how someone wants their
/// work shared, through a few plain questions, as Creative Commons' own
/// chooser does: whether to keep any rights, whether others may use it
/// commercially, and whether they may adapt it. Completes with the licence
/// recommended, one of the six current CC licences or the public-domain
/// dedication CC0 (`CC-BY-NC-SA-4.0`), once taken, or with null.
/// Licences for code, or of one's own, are typed in the licence field.
Future<String?> showLicenseChooser(BuildContext context) =>
showAdaptiveSheet<String>(
context,
builder: (context) => const _LicenseChooser(),
);
/// The chooser's questions, then what they come to.
enum _Step() {
rights,
commercial,
adaptations,
result,
}
/// Whether others may adapt the work: freely, if they share alike, or not.
enum _Adaptations() {
yes,
shareAlike,
no,
}
class const _LicenseChooser() extends StatefulWidget {
@override
State<_LicenseChooser> createState() => _LicenseChooserState();
}
class _LicenseChooserState() extends State<_LicenseChooser> {
var _step = _Step.rights;
var _publicDomain = false;
var _commercial = true;
var _adaptations = _Adaptations.yes;
/// The licence the answers so far come to.
String get _license => _publicDomain
? 'CC0-1.0'
: [
'CC-BY',
if (!_commercial) 'NC',
if (_adaptations == _Adaptations.shareAlike) 'SA',
if (_adaptations == _Adaptations.no) 'ND',
'4.0',
].join('-');
void _answer(VoidCallback answer, _Step next) => setState(() {
answer();
_step = next;
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return SheetScaffold(
title: l10n.licenseChooserTitle,
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 4),
Text(
l10n.licenseChooserSubtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
// One step swapped for the next.
AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
child: KeyedSubtree(key: ValueKey(_step), child: _stepView(l10n)),
),
const SizedBox(height: 16),
],
),
);
}
Widget _stepView(AppLocalizations l10n) => switch (_step) {
_Step.rights => _Question(
prompt: l10n.licenseChooserIntroPrompt,
options: [
_Option(
label: l10n.licenseChooserIntroYes,
detail: l10n.licenseChooserIntroYesDetail,
onTap: () => _answer(() => _publicDomain = false, _Step.commercial),
),
_Option(
label: l10n.licenseChooserIntroNo,
detail: l10n.licenseChooserIntroNoDetail,
onTap: () => _answer(() => _publicDomain = true, _Step.result),
),
],
),
_Step.commercial => _Question(
prompt: l10n.licenseChooserCommercialPrompt,
onBack: () => setState(() => _step = _Step.rights),
options: [
_Option(
label: l10n.licenseChooserYes,
detail: l10n.licenseChooserCommercialYesDetail,
onTap: () => _answer(() => _commercial = true, _Step.adaptations),
),
_Option(
label: l10n.licenseChooserNo,
detail: l10n.licenseChooserCommercialNoDetail,
onTap: () => _answer(() => _commercial = false, _Step.adaptations),
),
],
),
_Step.adaptations => _Question(
prompt: l10n.licenseChooserAdaptationsPrompt,
onBack: () => setState(() => _step = _Step.commercial),
options: [
for (final (adaptations, label, detail) in [
(
_Adaptations.yes,
l10n.licenseChooserYes,
l10n.licenseChooserAdaptationsYesDetail,
),
(
_Adaptations.shareAlike,
l10n.licenseChooserAdaptationsShareAlike,
l10n.licenseChooserAdaptationsShareAlikeDetail,
),
(
_Adaptations.no,
l10n.licenseChooserNo,
l10n.licenseChooserAdaptationsNoDetail,
),
])
_Option(
label: label,
detail: detail,
onTap: () =>
_answer(() => _adaptations = adaptations, _Step.result),
),
],
),
_Step.result => _Result(
license: _license,
summary: _summaryOf(l10n, _license),
onBack: () => setState(
() => _step = _publicDomain ? _Step.rights : _Step.adaptations,
),
onUse: () => Navigator.of(context).pop(_license),
),
};
}
/// What the licence [license], one the chooser recommends, lets others do.
String _summaryOf(AppLocalizations l10n, String license) => switch (license) {
'CC0-1.0' => l10n.licenseSummaryCc0,
'CC-BY-4.0' => l10n.licenseSummaryBy,
'CC-BY-SA-4.0' => l10n.licenseSummaryBySa,
'CC-BY-NC-4.0' => l10n.licenseSummaryByNc,
'CC-BY-NC-SA-4.0' => l10n.licenseSummaryByNcSa,
'CC-BY-ND-4.0' => l10n.licenseSummaryByNd,
_ => l10n.licenseSummaryByNcNd,
};
/// A question with its answers, each a card to tap, and Back below them
/// from the second question on.
class const _Question({
required final String prompt,
required final List<_Option> options,
final VoidCallback? onBack,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(prompt, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
for (final option in options) ...[option, const SizedBox(height: 8)],
if (onBack != null)
Align(
alignment: AlignmentDirectional.centerStart,
child: _BackButton(onPressed: onBack!),
),
],
);
}
class const _BackButton({required final VoidCallback onPressed})
extends StatelessWidget {
@override
Widget build(BuildContext context) => TextButton.icon(
onPressed: onPressed,
icon: const Icon(LucideIcons.arrowLeft, size: 18),
label: Text(AppLocalizations.of(context).licenseChooserBack),
);
}
/// An answer: what it is, and what it means.
class const _Option({
required final String label,
required final String detail,
required final VoidCallback onTap,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => Card(
margin: EdgeInsets.zero,
elevation: 0,
// A choice within the sheet, not a raised card.
color: Theme.of(context).colorScheme.surfaceContainerHighest,
clipBehavior: Clip.antiAlias,
child: ListTile(
title: Text(label),
subtitle: Text(detail),
trailing: const Icon(LucideIcons.chevronRight),
onTap: onTap,
),
);
}
/// The licence recommended: its identifier, what it lets others do, and a
/// link to its full terms; Back, and taking it.
class const _Result({
required final String license,
required final String summary,
required final VoidCallback onBack,
required final VoidCallback onUse,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final terms = licenseUrl(license);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.licenseChooserRecommended,
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 12),
DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(license, style: theme.textTheme.titleMedium),
const SizedBox(height: 6),
Text(summary, style: theme.textTheme.bodyMedium),
if (terms != null) ...[
const SizedBox(height: 8),
InkWell(
onTap: () => launchUrl(terms),
borderRadius: BorderRadius.circular(4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
'$terms',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary,
decoration: TextDecoration.underline,
),
),
),
const SizedBox(width: 4),
Icon(
LucideIcons.externalLink,
size: 14,
color: theme.colorScheme.primary,
),
],
),
),
],
],
),
),
),
const SizedBox(height: 16),
Row(
children: [
_BackButton(onPressed: onBack),
const Spacer(),
FilledButton(onPressed: onUse, child: Text(l10n.licenseChooserUse)),
],
),
],
);
}
}

View file

@ -56,11 +56,17 @@ class _StudysetRailState() extends State<StudysetRail> {
Future<void> _create() async {
final bloc = context.read<StudysetListBloc>();
final parent = widget.parent.content;
final content = await showStudysetSheet(
context,
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,
),
);
if (content != null) {
bloc.add(StudysetListEventCreateRequested(content));

View file

@ -8,27 +8,32 @@ 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/field_label.dart';
import 'package:halkyon_learn_flutter/src/core/widget/palette_color_picker.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/license.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/license_chooser_sheet.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Creates a studyset's content, or edits [initial]: its title, secondary
/// title, description, colour and languages. Completes with the new content
/// once the person saves, or with null when they close the sheet; closing it
/// with changes asks first.
/// title, description, colour, licence and languages. Completes with the new
/// content once the person saves, or with null when they close the sheet;
/// closing it with changes asks first.
///
/// A studyset [insideAnother] can take the colour of the studyset it is in
/// ([inheritedColorName], shown meanwhile), and a new one does at first; at
/// the top level a new studyset is cobalt. Its languages are off until set,
/// taking those of the studyset it is in ([inheritedLanguages], which they
/// start from once set). What the sheet doesn't show keeps its value.
/// start from once set). A new studyset starts with the licence of the one
/// it is in, [inheritedLicense], its own to change from then on. What the
/// sheet doesn't show keeps its value.
Future<StudysetContent?> showStudysetSheet(
BuildContext context, {
StudysetContent? initial,
bool insideAnother = false,
String? inheritedColorName,
StudysetLanguages inheritedLanguages = (front: null, back: null),
StudysetLicense inheritedLicense = (license: null, holder: null, year: null),
}) => showAdaptiveSheet<StudysetContent>(
context,
// Swiping would discard changes without asking.
@ -38,6 +43,7 @@ Future<StudysetContent?> showStudysetSheet(
insideAnother: insideAnother,
inheritedColorName: inheritedColorName,
inheritedLanguages: inheritedLanguages,
inheritedLicense: inheritedLicense,
),
);
@ -49,6 +55,7 @@ class const _StudysetSheet({
required final bool insideAnother,
required final String? inheritedColorName,
required final StudysetLanguages inheritedLanguages,
required final StudysetLicense inheritedLicense,
}) extends StatefulWidget {
@override
State<_StudysetSheet> createState() => _StudysetSheetState();
@ -61,6 +68,25 @@ class _StudysetSheetState() extends State<_StudysetSheet> {
);
late var _description = widget.initial?.description ?? RichContent.empty();
/// The licence as it was: the studyset's, or for a new one the one it is
/// in, which it starts with.
late final StudysetLicense _licenseBefore = switch (widget.initial) {
final initial? => (
license: initial.license,
holder: initial.licenseHolder,
year: initial.licenseYear,
),
null => widget.inheritedLicense,
};
late final _license = TextEditingController(text: _licenseBefore.license);
late final _licenseHolder = TextEditingController(
text: _licenseBefore.holder,
);
late final _licenseYear = TextEditingController(
text: _licenseBefore.year?.toString(),
);
final _licenseFocus = 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
/// studysets from before colours). An existing studyset keeps its colour,
@ -86,27 +112,63 @@ class _StudysetSheetState() extends State<_StudysetSheet> {
// The Create or Save button follows the title.
_title.addListener(() => setState(() {}));
_secondaryTitle.addListener(() => setState(() {}));
// As are the note on an older licence and the year's error.
_license.addListener(() => setState(() {}));
_licenseHolder.addListener(() => setState(() {}));
_licenseYear.addListener(() => setState(() {}));
}
@override
void dispose() {
_title.dispose();
_secondaryTitle.dispose();
_license.dispose();
_licenseHolder.dispose();
_licenseYear.dispose();
_licenseFocus.dispose();
super.dispose();
}
/// The licence as the sheet holds it.
StudysetLicense get _licenseNow {
final holder = _licenseHolder.text.trim();
return (
license: canonicalLicense(_license.text),
holder: holder.isEmpty ? null : holder,
year: int.tryParse(_licenseYear.text.trim()),
);
}
/// 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.
String? _yearError(AppLocalizations l10n) {
final year = int.tryParse(_licenseYear.text.trim());
final latest = DateTime.now().year + 1;
if (year == null || (year >= earliestLicenseYear && year <= latest)) {
return null;
}
return l10n.studysetSheetLicenseYearInvalid(earliestLicenseYear, latest);
}
Future<void> _chooseLicense() async {
if (await showLicenseChooser(context) case final license?) {
_license.text = license;
}
}
/// The content as the sheet holds it.
StudysetContent get _content {
final initial = widget.initial;
final secondaryTitle = _secondaryTitle.text.trim();
final license = _licenseNow;
return StudysetContent(
title: _title.text.trim(),
description: _description,
secondaryTitle: secondaryTitle.isEmpty ? null : secondaryTitle,
colorName: _colorName,
license: initial?.license,
licenseHolder: initial?.licenseHolder,
licenseYear: initial?.licenseYear,
license: license.license,
licenseHolder: license.holder,
licenseYear: license.year,
frontLanguage: _setLanguages ? _frontLanguage : null,
backLanguage: _setLanguages ? _backLanguage : null,
studyOptions: initial?.studyOptions,
@ -121,6 +183,7 @@ class _StudysetSheetState() extends State<_StudysetSheet> {
_secondaryTitle.text.trim().isNotEmpty ||
_description != RichContent.empty() ||
_colorName != (widget.insideAnother ? null : _defaultColor.name) ||
_licenseNow != _licenseBefore ||
(_setLanguages && (_frontLanguage ?? _backLanguage) != null),
};
@ -171,7 +234,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
onPressed: _title.text.trim().isEmpty || _yearError(l10n) != null
? null
: () => Navigator.of(context).pop(_content),
child: Text(
@ -261,6 +324,89 @@ class _StudysetSheetState() extends State<_StudysetSheet> {
],
),
const SizedBox(height: 24),
Text(l10n.studysetSheetLicense, style: theme.textTheme.labelLarge),
const SizedBox(height: 8),
Align(
alignment: AlignmentDirectional.centerStart,
child: TextButton.icon(
onPressed: _chooseLicense,
icon: const Icon(LucideIcons.sparkles, size: 18),
label: Text(l10n.studysetSheetLicenseHelp),
),
),
const SizedBox(height: 8),
// The licence, and its year at the end of the same line: four
// digits need no more.
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _LicenseField(
controller: _license,
focusNode: _licenseFocus,
),
),
const SizedBox(width: 12),
SizedBox(
width: 140,
child: TextField(
controller: _licenseYear,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(4),
],
decoration: InputDecoration(
labelText: l10n.studysetSheetLicenseYear,
suffixIcon: FieldInfoButton(
l10n.studysetSheetLicenseYearHelper,
),
border: const OutlineInputBorder(),
errorText: _yearError(l10n),
// The error is wider than the field.
errorMaxLines: 4,
),
),
),
],
),
if (_licenseNow.license case final license?)
if (newerCreativeCommons(license) case final current?)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
LucideIcons.info,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Expanded(
child: Text(
l10n.studysetSheetLicenseNewer(license, current),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
),
const SizedBox(height: 12),
TextField(
controller: _licenseHolder,
textCapitalization: TextCapitalization.words,
decoration: InputDecoration(
labelText: l10n.studysetSheetLicenseHolder,
suffixIcon: FieldInfoButton(
l10n.studysetSheetLicenseHolderHelper,
),
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 24),
Text(
l10n.studysetSheetLanguages,
style: theme.textTheme.labelLarge,
@ -309,6 +455,62 @@ class _StudysetSheetState() extends State<_StudysetSheet> {
}
}
/// The licence, typed: any text, suggesting the [knownLicenses] that hold
/// what is typed so far.
class const _LicenseField({
required final TextEditingController controller,
required final FocusNode focusNode,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return RawAutocomplete<String>(
textEditingController: controller,
focusNode: focusNode,
optionsBuilder: (value) {
final typed = value.text.trim().toUpperCase();
return [
for (final license in knownLicenses)
if (license.toUpperCase().contains(typed)) license,
];
},
fieldViewBuilder: (context, controller, focusNode, onSubmitted) =>
TextField(
controller: controller,
focusNode: focusNode,
decoration: InputDecoration(
labelText: l10n.studysetSheetLicenseLabel,
suffixIcon: FieldInfoButton(l10n.studysetSheetLicenseHelper),
border: const OutlineInputBorder(),
),
onSubmitted: (_) => onSubmitted(),
),
optionsViewBuilder: (context, onSelected, options) => Align(
alignment: AlignmentDirectional.topStart,
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(8),
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 240, maxWidth: 360),
child: ListView(
padding: EdgeInsets.zero,
shrinkWrap: true,
children: [
for (final option in options)
ListTile(
dense: true,
title: Text(option),
onTap: () => onSelected(option),
),
],
),
),
),
),
);
}
}
/// Chooses the language of one side of the cards, by its name in the app's
/// language, or none.
class const _LanguageDropdown({

View file

@ -0,0 +1,26 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/license.dart';
void main() {
test('a licence is kept in one spelling: known ones as known, Creative '
'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');
expect(canonicalLicense(' My own terms '), 'My own terms');
expect(canonicalLicense(' '), isNull);
expect(canonicalLicense(null), isNull);
});
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');
expect(newerCreativeCommons('CC-BY-2.0'), 'CC-BY-4.0');
expect(newerCreativeCommons('CC-BY-SA-4.0'), isNull);
expect(newerCreativeCommons('CC0-1.0'), isNull);
expect(newerCreativeCommons('MIT'), isNull);
});
}

View file

@ -644,6 +644,119 @@ void main() {
expect((content.frontLanguage, content.backLanguage), (null, null));
});
group('the licence', () {
/// Opens the studyset `japanese`'s sheet, scrolled to its licence. The
/// tests speak American English, as the device does.
Future<void> editLicense(WidgetTester tester) async {
await choose(tester, 'Edit');
await tester.ensureVisible(find.text('Help me choose'));
await tester.pumpAndSettle();
}
StudysetContent saved() => decodeStudysetPayload(gateway.writes.last.$2);
testWidgets('Help me choose asks three questions and fills in the '
'licence they come to', (tester) async {
stored('japanese', 'Japanese');
await showApp(tester, location: RoutePaths.studyset('japanese'));
await tester.pumpAndSettle();
await editLicense(tester);
await tester.tap(find.text('Help me choose'));
await tester.pumpAndSettle();
await tester.tap(find.text('Yes, share it under a license'));
await tester.pumpAndSettle();
await tester.tap(find.text('No'));
await tester.pumpAndSettle();
await tester.tap(find.text('Yes, if they share alike'));
await tester.pumpAndSettle();
expect(find.text('CC-BY-NC-SA-4.0'), findsOneWidget);
expect(
find.textContaining('the same license on adaptations'),
findsOneWidget,
);
await tester.tap(find.text('Use this license'));
await tester.pumpAndSettle();
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(saved().license, 'CC-BY-NC-SA-4.0');
});
testWidgets('a typed licence is kept in its standard spelling, with its '
'year and holder; an older Creative Commons one gets a note', (
tester,
) async {
stored('japanese', 'Japanese');
await showApp(tester, location: RoutePaths.studyset('japanese'));
await tester.pumpAndSettle();
await editLicense(tester);
await tester.enterText(
find.widgetWithText(TextField, 'License'),
'cc by-nc-sa 3.0 us',
);
await tester.enterText(find.widgetWithText(TextField, 'Year'), '999');
await tester.pump();
expect(
find.text('Enter a year between 1000 and ${DateTime.now().year + 1}.'),
findsOneWidget,
);
expect(
tester
.widget<FilledButton>(find.widgetWithText(FilledButton, 'Save'))
.onPressed,
isNull,
);
await tester.enterText(find.widgetWithText(TextField, 'Year'), '2006');
await tester.enterText(
find.widgetWithText(TextField, 'License holder'),
'Tae Kim',
);
await tester.pump();
expect(
find.textContaining(
'CC-BY-NC-SA-3.0-US is an older Creative Commons license',
),
findsOneWidget,
);
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
final content = saved();
expect(
(content.license, content.licenseHolder, content.licenseYear),
('CC-BY-NC-SA-3.0-US', 'Tae Kim', 2006),
);
});
testWidgets('a new sub-studyset starts with the licence of the one it is '
'in', (tester) async {
stored(
'japanese',
'Japanese',
license: 'CC-BY-NC-SA-3.0-US',
licenseHolder: 'Tae Kim',
licenseYear: 2006,
);
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');
await tester.pump();
await tester.tap(find.text('Create'));
await tester.pumpAndSettle();
final content = saved();
expect(
(content.license, content.licenseHolder, content.licenseYear),
('CC-BY-NC-SA-3.0-US', 'Tae Kim', 2006),
);
});
});
testWidgets('with two notes or more, Reorder beside the tabs lets them be '
'dragged into another order, until Done', (tester) async {
stored('japanese', 'Japanese');