feat(i18n): full RTL/CJK support — fonts, Japanese locale, directional fixes

Bundle Noto Sans Arabic + Noto Sans JP (SIL OFL 1.1) as per-glyph fallbacks
for both the UI text theme and every generated PDF, so RTL (Arabic) and CJK
render on all platforms — including desktop, where the system font may lack
them — instead of tofu. A shared pdf_fonts.dart centralizes the PDF theme and
the three label/catalog/recovery services reuse it.

Add Japanese (ja) as the CJK reference locale — fittingly, the app's own name
is Japanese (種, tane, 'seed'). Core strings translated; the rest fall back to
English via slang. Wired into supportedLocales (automatic) and the settings
picker.

Fix three physical Alignment.centerLeft -> AlignmentDirectional.centerStart in
variety_detail_screen, and make month-abbreviation and avatar-initial
truncation grapheme-safe (.characters) so CJK text is never cut mid-character.

Tests: CJK/Arabic PDF glyph render, ja locale resolution + English fallback.
The chat-bubble mirroring flagged by the audit was verified correct (already
respects Directionality) and left unchanged.
This commit is contained in:
vjrj 2026-07-14 11:11:19 +02:00
parent 41ea6735d2
commit baa8867287
31 changed files with 686 additions and 104 deletions

View file

@ -97,6 +97,7 @@
"langAst": "Asturianu",
"langFr": "Français",
"langDe": "Deutsch",
"langJa": "日本語",
"about": "Tocante a",
"aboutText": "Inventariu llocal y cifráu pa simiente tradicional. AGPL-3.0.",
"aboutOpen": "Tocante a Tane"

View file

@ -100,7 +100,8 @@
"aboutText": "Lokale, verschlüsselte Saatgutbank für traditionelle Samen. AGPL-3.0.",
"aboutOpen": "Über Tane",
"langDe": "Deutsch",
"langFr": "Français"
"langFr": "Français",
"langJa": "日本語"
},
"backup": {
"title": "Sicherung und Wiederherstellung",

View file

@ -98,6 +98,7 @@
"langAst": "Asturianu",
"langFr": "Français",
"langDe": "Deutsch",
"langJa": "日本語",
"about": "About",
"aboutText": "Local-first, encrypted inventory for traditional seeds. AGPL-3.0.",
"aboutOpen": "About Tane"

View file

@ -97,6 +97,7 @@
"langPt": "Português",
"langFr": "Français",
"langDe": "Deutsch",
"langJa": "日本語",
"about": "Acerca de",
"aboutText": "Inventario local y cifrado para semillas tradicionales. AGPL-3.0.",
"aboutOpen": "Acerca de Tane"

View file

@ -98,6 +98,7 @@
"langAst": "Asturianu",
"langFr": "Français",
"langDe": "Deutsch",
"langJa": "日本語",
"about": "À propos",
"aboutText": "Inventaire local-first et chiffré pour les semences traditionnelles. AGPL-3.0.",
"aboutOpen": "À propos de Tane"

View file

@ -0,0 +1,46 @@
{
"app": {
"title": "Tane"
},
"common": {
"save": "保存",
"cancel": "キャンセル",
"delete": "削除",
"edit": "編集",
"type": "種類",
"comingSoon": "近日公開",
"offline": "オフラインです — 共有を一時停止しています"
},
"menu": {
"tagline": "あなたの種子バンク",
"inventory": "在庫",
"market": "マーケット",
"profile": "プロフィール",
"chat": "チャット",
"wishlist": "お気に入り",
"following": "フォロー中",
"calendar": "カレンダー",
"settings": "設定"
},
"settings": {
"language": "言語",
"systemLanguage": "システムの言語",
"langEs": "Español",
"langEn": "English",
"langPt": "Português",
"langAst": "Asturianu",
"langFr": "Français",
"langDe": "Deutsch",
"langJa": "日本語",
"about": "このアプリについて",
"aboutText": "在来種のためのローカルファースト・暗号化在庫管理。AGPL-3.0。",
"aboutOpen": "Tane について"
},
"home": {
"tagline": "地域の種を分かち合い、育てよう",
"openMarket": "マーケット",
"openMarketSubtitle": "近くの種を見つけて分かち合う",
"yourInventory": "あなたの在庫",
"yourInventorySubtitle": "種を管理する"
}
}

View file

@ -98,6 +98,7 @@
"langAst": "Asturianu",
"langFr": "Français",
"langDe": "Deutsch",
"langJa": "日本語",
"about": "Acerca de",
"aboutText": "Inventário local e cifrado para sementes tradicionais. AGPL-3.0.",
"aboutOpen": "Acerca do Tane"

View file

@ -3,10 +3,10 @@
/// Source: lib/i18n
/// To regenerate, run: `dart run slang`
///
/// Locales: 6
/// Strings: 3356 (559 per locale)
/// Locales: 7
/// Strings: 3396 (485 per locale)
///
/// Built on 2026-07-13 at 16:11 UTC
/// Built on 2026-07-14 at 09:00 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
@ -22,6 +22,7 @@ import 'strings_ast.g.dart' as l_ast;
import 'strings_de.g.dart' as l_de;
import 'strings_es.g.dart' as l_es;
import 'strings_fr.g.dart' as l_fr;
import 'strings_ja.g.dart' as l_ja;
import 'strings_pt.g.dart' as l_pt;
part 'strings_en.g.dart';
@ -37,6 +38,7 @@ enum AppLocale with BaseAppLocale<AppLocale, Translations> {
de(languageCode: 'de'),
es(languageCode: 'es'),
fr(languageCode: 'fr'),
ja(languageCode: 'ja'),
pt(languageCode: 'pt');
const AppLocale({
@ -99,6 +101,12 @@ enum AppLocale with BaseAppLocale<AppLocale, Translations> {
cardinalResolver: cardinalResolver,
ordinalResolver: ordinalResolver,
);
case AppLocale.ja:
return l_ja.TranslationsJa(
overrides: overrides,
cardinalResolver: cardinalResolver,
ordinalResolver: ordinalResolver,
);
case AppLocale.pt:
return l_pt.TranslationsPt(
overrides: overrides,

View file

@ -265,6 +265,7 @@ class _Translations$settings$ast extends Translations$settings$en {
@override String get langAst => 'Asturianu';
@override String get langFr => 'Français';
@override String get langDe => 'Deutsch';
@override String get langJa => '日本語';
@override String get about => 'Tocante a';
@override String get aboutText => 'Inventariu llocal y cifráu pa simiente tradicional. AGPL-3.0.';
@override String get aboutOpen => 'Tocante a Tane';
@ -1488,6 +1489,7 @@ extension on TranslationsAst {
'settings.langAst' => 'Asturianu',
'settings.langFr' => 'Français',
'settings.langDe' => 'Deutsch',
'settings.langJa' => '日本語',
'settings.about' => 'Tocante a',
'settings.aboutText' => 'Inventariu llocal y cifráu pa simiente tradicional. AGPL-3.0.',
'settings.aboutOpen' => 'Tocante a Tane',
@ -1922,9 +1924,9 @@ extension on TranslationsAst {
'sale.iSold' => 'Vendí',
'sale.iBought' => 'Mercué',
'sale.direction' => '¿Vendes o merques?',
'sale.counterparty' => '¿Con quién?',
_ => null,
} ?? switch (path) {
'sale.counterparty' => '¿Con quién?',
'sale.counterpartyHint' => 'Una persona o un coleutivu (opcional)',
'sale.amount' => 'Importe',
'sale.currency' => 'Moneda',

View file

@ -269,6 +269,7 @@ class _Translations$settings$de extends Translations$settings$en {
@override String get aboutOpen => 'Über Tane';
@override String get langDe => 'Deutsch';
@override String get langFr => 'Français';
@override String get langJa => '日本語';
}
// Path: backup
@ -1488,6 +1489,7 @@ extension on TranslationsDe {
'settings.aboutOpen' => 'Über Tane',
'settings.langDe' => 'Deutsch',
'settings.langFr' => 'Français',
'settings.langJa' => '日本語',
'backup.title' => 'Sicherung und Wiederherstellung',
'backup.autoBackupTitle' => 'Automatische Sicherungen',
'backup.autoBackupLast' => ({required Object date}) => 'Letzte Sicherung ${date}',
@ -1918,9 +1920,9 @@ extension on TranslationsDe {
'sale.counterpartyHint' => 'Eine Person oder ein Kollektiv (optional)',
'sale.amount' => 'Betrag',
'sale.currency' => 'Währung',
'sale.currencyHint' => '€, Ğ1, Stunden… (optional)',
_ => null,
} ?? switch (path) {
'sale.currencyHint' => '€, Ğ1, Stunden… (optional)',
'sale.hours' => 'Stunden',
'sale.note' => 'Notiz (optional)',
'sale.save' => 'Speichern',

View file

@ -424,6 +424,9 @@ class Translations$settings$en {
/// en: 'Deutsch'
String get langDe => 'Deutsch';
/// en: '日本語'
String get langJa => '日本語';
/// en: 'About'
String get about => 'About';
@ -2598,6 +2601,7 @@ extension on Translations {
'settings.langAst' => 'Asturianu',
'settings.langFr' => 'Français',
'settings.langDe' => 'Deutsch',
'settings.langJa' => '日本語',
'settings.about' => 'About',
'settings.aboutText' => 'Local-first, encrypted inventory for traditional seeds. AGPL-3.0.',
'settings.aboutOpen' => 'About Tane',
@ -3031,9 +3035,9 @@ extension on Translations {
'sale.help' => 'Record seed you sold or bought — money, Ğ1, or any currency. A separate model from a gift or a Plantare. No commission is ever taken on seeds.',
'sale.add' => 'Record a sale',
'sale.empty' => 'No sales yet. Note here what you sell or buy.',
'sale.iSold' => 'I sold',
_ => null,
} ?? switch (path) {
'sale.iSold' => 'I sold',
'sale.iBought' => 'I bought',
'sale.direction' => 'Sold or bought?',
'sale.counterparty' => 'With whom?',

View file

@ -265,6 +265,7 @@ class _Translations$settings$es extends Translations$settings$en {
@override String get langPt => 'Português';
@override String get langFr => 'Français';
@override String get langDe => 'Deutsch';
@override String get langJa => '日本語';
@override String get about => 'Acerca de';
@override String get aboutText => 'Inventario local y cifrado para semillas tradicionales. AGPL-3.0.';
@override String get aboutOpen => 'Acerca de Tane';
@ -1490,6 +1491,7 @@ extension on TranslationsEs {
'settings.langPt' => 'Português',
'settings.langFr' => 'Français',
'settings.langDe' => 'Deutsch',
'settings.langJa' => '日本語',
'settings.about' => 'Acerca de',
'settings.aboutText' => 'Inventario local y cifrado para semillas tradicionales. AGPL-3.0.',
'settings.aboutOpen' => 'Acerca de Tane',
@ -1924,9 +1926,9 @@ extension on TranslationsEs {
'sale.add' => 'Registrar venta',
'sale.empty' => 'Aún no hay ventas. Anota aquí lo que vendes o compras.',
'sale.iSold' => 'Vendí',
'sale.iBought' => 'Compré',
_ => null,
} ?? switch (path) {
'sale.iBought' => 'Compré',
'sale.direction' => '¿Vendes o compras?',
'sale.counterparty' => '¿Con quién?',
'sale.counterpartyHint' => 'Una persona o un colectivo (opcional)',

View file

@ -266,6 +266,7 @@ class _Translations$settings$fr extends Translations$settings$en {
@override String get langAst => 'Asturianu';
@override String get langFr => 'Français';
@override String get langDe => 'Deutsch';
@override String get langJa => '日本語';
@override String get about => 'À propos';
@override String get aboutText => 'Inventaire local-first et chiffré pour les semences traditionnelles. AGPL-3.0.';
@override String get aboutOpen => 'À propos de Tane';
@ -1485,6 +1486,7 @@ extension on TranslationsFr {
'settings.langAst' => 'Asturianu',
'settings.langFr' => 'Français',
'settings.langDe' => 'Deutsch',
'settings.langJa' => '日本語',
'settings.about' => 'À propos',
'settings.aboutText' => 'Inventaire local-first et chiffré pour les semences traditionnelles. AGPL-3.0.',
'settings.aboutOpen' => 'À propos de Tane',
@ -1918,9 +1920,9 @@ extension on TranslationsFr {
'sale.counterpartyHint' => 'Une personne ou un collectif (optionnel)',
'sale.amount' => 'Montant',
'sale.currency' => 'Monnaie',
'sale.currencyHint' => '€, Ğ1, heures… (optionnel)',
_ => null,
} ?? switch (path) {
'sale.currencyHint' => '€, Ğ1, heures… (optionnel)',
'sale.hours' => 'heures',
'sale.note' => 'Note (optionnel)',
'sale.save' => 'Enregistrer',

View file

@ -0,0 +1,173 @@
///
/// Generated file. Do not edit.
///
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
// dart format off
import 'package:flutter/widgets.dart';
import 'package:intl/intl.dart';
import 'package:slang/generated.dart';
import 'strings.g.dart';
// Path: <root>
class TranslationsJa extends Translations with BaseTranslations<AppLocale, Translations> {
/// You can call this constructor and build your own translation instance of this locale.
/// Constructing via the enum [AppLocale.build] is preferred.
TranslationsJa({Map<String, Node>? overrides, PluralResolver? cardinalResolver, PluralResolver? ordinalResolver, TranslationMetadata<AppLocale, Translations>? meta})
: assert(overrides == null, 'Set "translation_overrides: true" in order to enable this feature.'),
$meta = meta ?? TranslationMetadata(
locale: AppLocale.ja,
overrides: overrides ?? {},
cardinalResolver: cardinalResolver,
ordinalResolver: ordinalResolver,
),
super(cardinalResolver: cardinalResolver, ordinalResolver: ordinalResolver) {
super.$meta.setFlatMapFunction($meta.getTranslation); // copy base translations to super.$meta
$meta.setFlatMapFunction(_flatMapFunction);
}
/// Metadata for the translations of <ja>.
@override final TranslationMetadata<AppLocale, Translations> $meta;
/// Access flat map
@override dynamic operator[](String key) => $meta.getTranslation(key) ?? super.$meta.getTranslation(key);
late final TranslationsJa _root = this; // ignore: unused_field
@override
TranslationsJa $copyWith({TranslationMetadata<AppLocale, Translations>? meta}) => TranslationsJa(meta: meta ?? this.$meta);
// Translations
@override late final _Translations$app$ja app = _Translations$app$ja._(_root);
@override late final _Translations$common$ja common = _Translations$common$ja._(_root);
@override late final _Translations$menu$ja menu = _Translations$menu$ja._(_root);
@override late final _Translations$settings$ja settings = _Translations$settings$ja._(_root);
@override late final _Translations$home$ja home = _Translations$home$ja._(_root);
}
// Path: app
class _Translations$app$ja extends Translations$app$en {
_Translations$app$ja._(TranslationsJa root) : this._root = root, super.internal(root);
final TranslationsJa _root; // ignore: unused_field
// Translations
@override String get title => 'Tane';
}
// Path: common
class _Translations$common$ja extends Translations$common$en {
_Translations$common$ja._(TranslationsJa root) : this._root = root, super.internal(root);
final TranslationsJa _root; // ignore: unused_field
// Translations
@override String get save => '保存';
@override String get cancel => 'キャンセル';
@override String get delete => '削除';
@override String get edit => '編集';
@override String get type => '種類';
@override String get comingSoon => '近日公開';
@override String get offline => 'オフラインです — 共有を一時停止しています';
}
// Path: menu
class _Translations$menu$ja extends Translations$menu$en {
_Translations$menu$ja._(TranslationsJa root) : this._root = root, super.internal(root);
final TranslationsJa _root; // ignore: unused_field
// Translations
@override String get tagline => 'あなたの種子バンク';
@override String get inventory => '在庫';
@override String get market => 'マーケット';
@override String get profile => 'プロフィール';
@override String get chat => 'チャット';
@override String get wishlist => 'お気に入り';
@override String get following => 'フォロー中';
@override String get calendar => 'カレンダー';
@override String get settings => '設定';
}
// Path: settings
class _Translations$settings$ja extends Translations$settings$en {
_Translations$settings$ja._(TranslationsJa root) : this._root = root, super.internal(root);
final TranslationsJa _root; // ignore: unused_field
// Translations
@override String get language => '言語';
@override String get systemLanguage => 'システムの言語';
@override String get langEs => 'Español';
@override String get langEn => 'English';
@override String get langPt => 'Português';
@override String get langAst => 'Asturianu';
@override String get langFr => 'Français';
@override String get langDe => 'Deutsch';
@override String get langJa => '日本語';
@override String get about => 'このアプリについて';
@override String get aboutText => '在来種のためのローカルファースト・暗号化在庫管理。AGPL-3.0。';
@override String get aboutOpen => 'Tane について';
}
// Path: home
class _Translations$home$ja extends Translations$home$en {
_Translations$home$ja._(TranslationsJa root) : this._root = root, super.internal(root);
final TranslationsJa _root; // ignore: unused_field
// Translations
@override String get tagline => '地域の種を分かち合い、育てよう';
@override String get openMarket => 'マーケット';
@override String get openMarketSubtitle => '近くの種を見つけて分かち合う';
@override String get yourInventory => 'あなたの在庫';
@override String get yourInventorySubtitle => '種を管理する';
}
/// The flat map containing all translations for locale <ja>.
/// Only for edge cases! For simple maps, use the map function of this library.
///
/// The Dart AOT compiler has issues with very large switch statements,
/// so the map is split into smaller functions (512 entries each).
extension on TranslationsJa {
dynamic _flatMapFunction(String path) {
return switch (path) {
'app.title' => 'Tane',
'common.save' => '保存',
'common.cancel' => 'キャンセル',
'common.delete' => '削除',
'common.edit' => '編集',
'common.type' => '種類',
'common.comingSoon' => '近日公開',
'common.offline' => 'オフラインです — 共有を一時停止しています',
'menu.tagline' => 'あなたの種子バンク',
'menu.inventory' => '在庫',
'menu.market' => 'マーケット',
'menu.profile' => 'プロフィール',
'menu.chat' => 'チャット',
'menu.wishlist' => 'お気に入り',
'menu.following' => 'フォロー中',
'menu.calendar' => 'カレンダー',
'menu.settings' => '設定',
'settings.language' => '言語',
'settings.systemLanguage' => 'システムの言語',
'settings.langEs' => 'Español',
'settings.langEn' => 'English',
'settings.langPt' => 'Português',
'settings.langAst' => 'Asturianu',
'settings.langFr' => 'Français',
'settings.langDe' => 'Deutsch',
'settings.langJa' => '日本語',
'settings.about' => 'このアプリについて',
'settings.aboutText' => '在来種のためのローカルファースト・暗号化在庫管理。AGPL-3.0。',
'settings.aboutOpen' => 'Tane について',
'home.tagline' => '地域の種を分かち合い、育てよう',
'home.openMarket' => 'マーケット',
'home.openMarketSubtitle' => '近くの種を見つけて分かち合う',
'home.yourInventory' => 'あなたの在庫',
'home.yourInventorySubtitle' => '種を管理する',
_ => null,
};
}
}

View file

@ -266,6 +266,7 @@ class _Translations$settings$pt extends Translations$settings$en {
@override String get langAst => 'Asturianu';
@override String get langFr => 'Français';
@override String get langDe => 'Deutsch';
@override String get langJa => '日本語';
@override String get about => 'Acerca de';
@override String get aboutText => 'Inventário local e cifrado para sementes tradicionais. AGPL-3.0.';
@override String get aboutOpen => 'Acerca do Tane';
@ -1488,6 +1489,7 @@ extension on TranslationsPt {
'settings.langAst' => 'Asturianu',
'settings.langFr' => 'Français',
'settings.langDe' => 'Deutsch',
'settings.langJa' => '日本語',
'settings.about' => 'Acerca de',
'settings.aboutText' => 'Inventário local e cifrado para sementes tradicionais. AGPL-3.0.',
'settings.aboutOpen' => 'Acerca do Tane',
@ -1921,9 +1923,9 @@ extension on TranslationsPt {
'sale.iBought' => 'Comprei',
'sale.direction' => 'Vendes ou compras?',
'sale.counterparty' => 'Com quem?',
'sale.counterpartyHint' => 'Uma pessoa ou um coletivo (opcional)',
_ => null,
} ?? switch (path) {
'sale.counterpartyHint' => 'Uma pessoa ou um coletivo (opcional)',
'sale.amount' => 'Montante',
'sale.currency' => 'Moeda',
'sale.currencyHint' => '€, Ğ1, horas… (opcional)',

View file

@ -1,11 +1,11 @@
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:flutter/services.dart' show rootBundle;
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'file_service.dart';
import 'pdf_fonts.dart';
/// How the labels are tiled on the page: many small stickers to peel onto
/// packets, or fewer larger cards for jars and boxes.
@ -65,9 +65,10 @@ class _Geometry {
/// platform channels apart from loading the bundled font, so it runs (and is
/// tested) on the host.
///
/// The embedded DejaVu face covers Latin (extended), Cyrillic and Greek. Like
/// the OCR traineddata, the PDF font is a per-locale resource: wider scripts
/// (Arabic, CJK) ship alongside their locales, not hardcoded here.
/// The embedded DejaVu face covers Latin (extended), Cyrillic and Greek, with
/// Noto Arabic + Noto CJK as per-glyph fallbacks (see [buildPdfTheme]) so RTL
/// and CJK text render. Fonts are a per-locale resource, extensible to any
/// script.
class LabelSheetService {
LabelSheetService({required FileService files}) : _files = files;
@ -103,15 +104,8 @@ class LabelSheetService {
bool rtl = false,
}) async {
final textDirection = rtl ? pw.TextDirection.rtl : pw.TextDirection.ltr;
final base = pw.Font.ttf(
await rootBundle.load('assets/fonts/DejaVuSans.ttf'),
);
final bold = pw.Font.ttf(
await rootBundle.load('assets/fonts/DejaVuSans-Bold.ttf'),
);
final doc = pw.Document(
theme: pw.ThemeData.withFont(base: base, bold: bold, italic: base),
);
final fonts = await loadPdfFonts();
final doc = pw.Document(theme: fonts.theme);
final g = _geometryOf(format);
final labelWidth = (PdfPageFormat.a4.width - _margin * 2) / g.columns;
@ -150,7 +144,7 @@ class LabelSheetService {
maxLines: 1,
overflow: pw.TextOverflow.clip,
style: pw.TextStyle(
font: bold,
font: fonts.bold,
fontSize: g.common,
color: PdfColors.grey800,
),
@ -159,7 +153,7 @@ class LabelSheetService {
label.varietyLabel,
maxLines: 2,
overflow: pw.TextOverflow.clip,
style: pw.TextStyle(font: bold, fontSize: g.title),
style: pw.TextStyle(font: fonts.bold, fontSize: g.title),
),
if (label.scientificName != null)
pw.Text(

View file

@ -0,0 +1,38 @@
import 'package:flutter/services.dart' show rootBundle;
import 'package:pdf/widgets.dart' as pw;
/// Fonts and theme shared by every generated PDF (labels, catalog, recovery
/// sheet). [theme] is the document-wide default; [bold] is exposed for the few
/// places that set a heading weight explicitly merging against [theme] keeps
/// its script fallbacks, so bold Arabic/CJK still renders.
typedef PdfFonts = ({pw.ThemeData theme, pw.Font bold});
/// Loads the PDF fonts.
///
/// The base face is DejaVu (Latin extended, Cyrillic, Greek) with Noto Arabic
/// and Noto CJK registered as per-glyph fallbacks, so vernacular species names,
/// user notes, and localized labels in Arabic (RTL) or CJK render instead of
/// showing tofu boxes. The `pdf` package subsets fonts to the glyphs actually
/// used, so bundling the full Noto faces does not bloat the output file.
///
/// Fonts are a per-locale resource, extensible to any script widen the
/// fallback list as more scripts land.
Future<PdfFonts> loadPdfFonts() async {
final base = pw.Font.ttf(
await rootBundle.load('assets/fonts/DejaVuSans.ttf'),
);
final bold = pw.Font.ttf(
await rootBundle.load('assets/fonts/DejaVuSans-Bold.ttf'),
);
final arabic = pw.Font.ttf(
await rootBundle.load('assets/fonts/NotoSansArabic.ttf'),
);
final cjk = pw.Font.ttf(await rootBundle.load('assets/fonts/NotoSansJP.ttf'));
final theme = pw.ThemeData.withFont(
base: base,
bold: bold,
italic: base,
fontFallback: [arabic, cjk],
);
return (theme: theme, bold: bold);
}

View file

@ -1,10 +1,10 @@
import 'dart:typed_data';
import 'package:flutter/services.dart' show rootBundle;
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'file_service.dart';
import 'pdf_fonts.dart';
/// Renders the printable recovery sheet a QR plus the typed code and short
/// instructions ("keep two paper copies, like your best seed"). All strings
@ -19,22 +19,15 @@ class RecoverySheetService {
required String intro,
required String code,
}) async {
final base = pw.Font.ttf(
await rootBundle.load('assets/fonts/DejaVuSans.ttf'),
);
final bold = pw.Font.ttf(
await rootBundle.load('assets/fonts/DejaVuSans-Bold.ttf'),
);
final doc = pw.Document(
theme: pw.ThemeData.withFont(base: base, bold: bold),
);
final fonts = await loadPdfFonts();
final doc = pw.Document(theme: fonts.theme);
doc.addPage(
pw.Page(
pageFormat: PdfPageFormat.a4,
build: (context) => pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(title, style: pw.TextStyle(font: bold, fontSize: 20)),
pw.Text(title, style: pw.TextStyle(font: fonts.bold, fontSize: 20)),
pw.SizedBox(height: 12),
pw.Text(intro, style: const pw.TextStyle(fontSize: 11)),
pw.SizedBox(height: 24),
@ -54,7 +47,7 @@ class RecoverySheetService {
child: pw.Text(
code,
textAlign: pw.TextAlign.center,
style: pw.TextStyle(font: bold, fontSize: 12),
style: pw.TextStyle(font: fonts.bold, fontSize: 12),
),
),
],

View file

@ -1,10 +1,10 @@
import 'dart:typed_data';
import 'package:flutter/services.dart' show rootBundle;
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'file_service.dart';
import 'pdf_fonts.dart';
/// One printable line of the "what I share" catalog. All strings arrive
/// already localized this service knows nothing about i18n or the domain.
@ -47,20 +47,13 @@ class ShareCatalogService {
required String date,
required List<ShareCatalogRow> rows,
}) async {
final base = pw.Font.ttf(
await rootBundle.load('assets/fonts/DejaVuSans.ttf'),
);
final bold = pw.Font.ttf(
await rootBundle.load('assets/fonts/DejaVuSans-Bold.ttf'),
);
final doc = pw.Document(
theme: pw.ThemeData.withFont(base: base, bold: bold, italic: base),
);
final fonts = await loadPdfFonts();
final doc = pw.Document(theme: fonts.theme);
doc.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
build: (context) => [
pw.Text(title, style: pw.TextStyle(font: bold, fontSize: 20)),
pw.Text(title, style: pw.TextStyle(font: fonts.bold, fontSize: 20)),
pw.SizedBox(height: 2),
pw.Text(
date,
@ -84,7 +77,7 @@ class ShareCatalogService {
children: [
pw.Text(
row.name,
style: pw.TextStyle(font: bold, fontSize: 12),
style: pw.TextStyle(font: fonts.bold, fontSize: 12),
),
if (row.scientificName != null)
pw.Text(

View file

@ -63,7 +63,10 @@ class InventoryListScreen extends StatelessWidget {
key: const Key('inventory.search'),
decoration: InputDecoration(
hintText: t.inventory.searchHint,
prefixIcon: const Icon(Icons.search, color: seedMuted),
prefixIcon: const Icon(
Icons.search,
color: seedMuted,
),
hintStyle: const TextStyle(color: seedMuted),
filled: true,
fillColor: Colors.white,
@ -404,15 +407,21 @@ class _FilterBar extends StatelessWidget {
// Lay out the groups in order, dropping a hairline divider between any two
// that both have chips.
final groups = [attrChips, formChips, categoryChips].where((g) => g.isNotEmpty);
final groups = [
attrChips,
formChips,
categoryChips,
].where((g) => g.isNotEmpty);
final row = <Widget>[];
for (final group in groups) {
if (row.isNotEmpty) row.add(const _FilterGroupDivider());
for (final chip in group) {
row.add(Padding(
padding: const EdgeInsetsDirectional.only(end: 8),
child: chip,
));
row.add(
Padding(
padding: const EdgeInsetsDirectional.only(end: 8),
child: chip,
),
);
}
}
final scroller = EdgeFade(
@ -451,11 +460,11 @@ class _FilterGroupDivider extends StatelessWidget {
@override
Widget build(BuildContext context) => Container(
width: 1,
height: 22,
margin: const EdgeInsetsDirectional.only(end: 8),
color: seedOutline,
);
width: 1,
height: 22,
margin: const EdgeInsetsDirectional.only(end: 8),
color: seedOutline,
);
}
/// A [FilterChip] tinted with a [Swatch]: a soft fill when idle, a stronger
@ -587,7 +596,10 @@ class _CategoryHeader extends StatelessWidget {
width: 10,
height: 10,
margin: const EdgeInsetsDirectional.only(end: 8),
decoration: BoxDecoration(color: swatch.ink, shape: BoxShape.circle),
decoration: BoxDecoration(
color: swatch.ink,
shape: BoxShape.circle,
),
),
Flexible(
// Base on the text theme so it honours the system font-scale factor.
@ -740,7 +752,7 @@ class _Avatar extends StatelessWidget {
final trimmed = item.label.trim();
final initial = trimmed.isEmpty
? '?'
: trimmed.substring(0, 1).toUpperCase();
: trimmed.characters.first.toUpperCase();
return ExcludeSemantics(
child: CircleAvatar(
backgroundColor: seedAvatar,

View file

@ -59,6 +59,7 @@ List<(AppLocale, String)> _languages(Translations t) => [
(AppLocale.pt, t.settings.langPt),
(AppLocale.fr, t.settings.langFr),
(AppLocale.de, t.settings.langDe),
(AppLocale.ja, t.settings.langJa),
];
/// A single tile showing the active language; tapping opens a picker with all

View file

@ -52,6 +52,11 @@ ThemeData buildTaneTheme() {
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
// Per-glyph script fallbacks so Arabic (RTL) and CJK text render even where
// the platform's default font lacks them (notably desktop). The primary
// family stays the system default for Latin; these only fill the gaps. The
// ranges are disjoint, so order is immaterial. Fonts bundled in pubspec.
fontFamilyFallback: const ['Noto Sans JP', 'Noto Sans Arabic'],
scaffoldBackgroundColor: seedCanvas,
// Green app bars aligned with the v2 mockups and the real app.
appBarTheme: const AppBarTheme(

View file

@ -147,7 +147,8 @@ class _DetailView extends StatelessWidget {
if (SeedSavingCatalog.guideFor(
scientificName: detail.scientificName,
family: detail.family ?? detail.category,
) case final guide? when guide.hasAny) ...[
)
case final guide? when guide.hasAny) ...[
const SizedBox(height: 8),
// Reference data (facts about the species, not the grower's own
// records) collapsed by default so it's opt-in depth, not noise.
@ -301,7 +302,8 @@ class _PlantareLine extends StatelessWidget {
? t.plantare.statusReturned
: t.plantare.statusForgiven,
];
final overdue = !done &&
final overdue =
!done &&
p.dueBy != null &&
p.dueBy! < DateTime.now().millisecondsSinceEpoch;
final returnBy = p.dueBy == null
@ -997,7 +999,9 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
child: Text(
t.cropCalendar.editorHint,
style: const TextStyle(
color: seedMuted, fontSize: 12),
color: seedMuted,
fontSize: 12,
),
),
),
),
@ -1099,9 +1103,13 @@ class _MonthMultiSelect extends StatelessWidget {
}
}
/// A short (3-char) label for a month name, for the compact calendar chips.
String _monthAbbrev(String name) =>
name.length <= 3 ? name : name.substring(0, 3);
/// A short (3-grapheme) label for a month name, for the compact calendar chips.
/// Counts by grapheme (not UTF-16 units) so CJK/complex scripts don't truncate
/// mid-character.
String _monthAbbrev(String name) {
final chars = name.characters;
return chars.length <= 3 ? name : chars.take(3).toString();
}
/// Read-only crop calendar: one line per recorded phase ("Sow · Mar, Apr, Sep").
/// Renders only the phases that have months set.
@ -1201,14 +1209,24 @@ class _SeedSavingView extends StatelessWidget {
}
if ((guide.recommendedPlants ?? guide.minPlants) case final n?) {
rows.add(_line(
Icons.groups_outlined, t.seedSaving.plants, t.seedSaving.plantsValue(n: n)));
rows.add(
_line(
Icons.groups_outlined,
t.seedSaving.plants,
t.seedSaving.plantsValue(n: n),
),
);
}
if (guide.processing case final proc?) {
final wet = proc == SeedProcessing.wet;
rows.add(_line(wet ? Icons.water_drop_outlined : Icons.grass,
t.seedSaving.processing, wet ? t.seedSaving.procWet : t.seedSaving.procDry));
rows.add(
_line(
wet ? Icons.water_drop_outlined : Icons.grass,
t.seedSaving.processing,
wet ? t.seedSaving.procWet : t.seedSaving.procDry,
),
);
}
final note = guide.noteFor(LocaleSettings.currentLocale.languageCode);
@ -1228,10 +1246,16 @@ class _SeedSavingView extends StatelessWidget {
// Advisory + a light source credit the figures are general, not local
// gospel, and drawn from the seed-saving literature.
const SizedBox(height: 10),
Text(t.seedSaving.advisory,
style: const TextStyle(
color: seedMuted, fontSize: 12, fontStyle: FontStyle.italic)),
if (SeedSavingCatalog.sources case final sources when sources.isNotEmpty)
Text(
t.seedSaving.advisory,
style: const TextStyle(
color: seedMuted,
fontSize: 12,
fontStyle: FontStyle.italic,
),
),
if (SeedSavingCatalog.sources case final sources
when sources.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
@ -1245,23 +1269,28 @@ class _SeedSavingView extends StatelessWidget {
}
Widget _line(IconData icon, String label, String value) => Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 18, color: seedGreen),
const SizedBox(width: 10),
Expanded(
child: Text.rich(TextSpan(children: [
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 18, color: seedGreen),
const SizedBox(width: 10),
Expanded(
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: '$label · ',
style: const TextStyle(fontWeight: FontWeight.w600)),
text: '$label · ',
style: const TextStyle(fontWeight: FontWeight.w600),
),
TextSpan(text: value),
])),
],
),
],
),
),
);
],
),
);
}
class _DifficultyChip extends StatelessWidget {
@ -1274,24 +1303,30 @@ class _DifficultyChip extends StatelessWidget {
final t = context.t;
final (label, color) = switch (difficulty) {
SavingDifficulty.easy => (t.seedSaving.diffEasy, const Color(0xFF3B6D11)),
SavingDifficulty.medium =>
(t.seedSaving.diffMedium, const Color(0xFF8A6D1E)),
SavingDifficulty.medium => (
t.seedSaving.diffMedium,
const Color(0xFF8A6D1E),
),
SavingDifficulty.hard => (t.seedSaving.diffHard, const Color(0xFFA14234)),
};
return Row(
children: [
Icon(Icons.insights, size: 18, color: seedGreen),
const SizedBox(width: 10),
Text('${t.seedSaving.difficulty} · ',
style: const TextStyle(fontWeight: FontWeight.w600)),
Text(
'${t.seedSaving.difficulty} · ',
style: const TextStyle(fontWeight: FontWeight.w600),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(10),
),
child: Text(label,
style: TextStyle(color: color, fontWeight: FontWeight.w600)),
child: Text(
label,
style: TextStyle(color: color, fontWeight: FontWeight.w600),
),
),
],
);
@ -1324,7 +1359,6 @@ String desiccantLabel(Translations t, DesiccantState d) => switch (d) {
DesiccantState.fresh => t.desiccant.fresh,
};
/// Single-select chips for the share terms. Gift and swap come first and sale
/// last, never preselected the gift is first-class, the sale a choice
/// (sharing-model §4.1). There is always a selection ("just for me" default).
@ -1401,7 +1435,7 @@ class _ConditionChecksBlock extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Align(
alignment: Alignment.centerLeft,
alignment: AlignmentDirectional.centerStart,
child: Text(
t.conditionCheck.title,
style: Theme.of(context).textTheme.labelLarge,
@ -1428,7 +1462,7 @@ class _ConditionChecksBlock extends StatelessWidget {
),
),
Align(
alignment: Alignment.centerLeft,
alignment: AlignmentDirectional.centerStart,
child: TextButton.icon(
key: const Key('conditionCheck.open'),
icon: const Icon(Icons.add),
@ -1778,7 +1812,7 @@ Future<void> _showLotSheet(
children: [
const SizedBox(height: 4),
Align(
alignment: Alignment.centerLeft,
alignment: AlignmentDirectional.centerStart,
child: Text(
t.preservation.title,
style: Theme.of(