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

@ -0,0 +1,93 @@
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/arabic)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

View file

@ -0,0 +1,93 @@
Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

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(

View file

@ -146,13 +146,26 @@ flutter:
# Remove before shipping if you don't want them in the bundle.
- assets/ocr_fixtures/
# Embedded in generated PDFs (printable catalog). DejaVu covers Latin
# (extended), Cyrillic and Greek; like the OCR packs, PDF fonts are a
# per-locale resource — swap in wider coverage (Arabic, CJK) as locales
# land. Free license (Bitstream Vera / public domain additions).
# (extended), Cyrillic and Greek; Noto Arabic + Noto CJK ride alongside as
# per-glyph fallbacks so RTL and CJK text render instead of tofu. Fonts are
# a per-locale resource, extensible to any script. Licenses: DejaVu
# (Bitstream Vera / public domain), Noto (SIL OFL 1.1). See assets/fonts/.
- assets/fonts/DejaVuSans.ttf
- assets/fonts/DejaVuSans-Bold.ttf
- assets/fonts/NotoSansArabic.ttf
- assets/fonts/NotoSansJP.ttf
fonts:
# Custom seed-themed icon glyphs (chars ak). See docs/mockups/seedks-glyphs.png.
- family: seedks
fonts:
- asset: fonts/seedks.ttf
# Script coverage fallbacks for the UI text theme (see ui/theme.dart). Noto
# (SIL OFL 1.1) covers Arabic (RTL) and CJK (Japanese kanji/kana + shared
# Han) so those scripts render on every platform, including desktop where
# the system font may lack them. Variable fonts; Flutter picks the weight.
- family: Noto Sans Arabic
fonts:
- asset: assets/fonts/NotoSansArabic.ttf
- family: Noto Sans JP
fonts:
- asset: assets/fonts/NotoSansJP.ttf

View file

@ -0,0 +1,31 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/i18n/strings.g.dart';
/// Japanese (`ja`) is the CJK reference locale fitting, since the app's own
/// name is Japanese (, *tane*, "seed"). It proves the CJK path end to end:
/// the locale is wired, core strings resolve in Japanese, and untranslated
/// keys fall back to the English base (slang `fallback_strategy: base_locale`).
void main() {
test('ja is a supported locale', () {
expect(
AppLocaleUtils.supportedLocales.map((l) => l.languageCode),
contains('ja'),
);
});
test('core strings resolve in Japanese', () {
final ja = AppLocale.ja.buildSync();
expect(ja.menu.inventory, '在庫');
expect(ja.common.save, '保存');
expect(ja.settings.langJa, '日本語');
});
test('untranslated keys fall back to the English base', () {
final ja = AppLocale.ja.buildSync();
final en = AppLocale.en.buildSync();
// `menu.plantares` is left untranslated in ja (a coined feature name), so
// it must surface the English text rather than a blank or a key.
expect(ja.menu.plantares, en.menu.plantares);
expect(ja.menu.plantares, isNotEmpty);
});
}

View file

@ -76,6 +76,27 @@ void main() {
expect(String.fromCharCodes(bytes.take(5)), '%PDF-');
});
test('buildPdf embeds CJK and Arabic glyphs (no tofu)', () async {
// Japanese (CJK) and Arabic (RTL) fall outside DejaVu's coverage; the Noto
// fallbacks in the PDF theme must render them without throwing. Guards
// against a regression that drops the fallback fonts.
final service = LabelSheetService(files: _RecordingFileService());
final bytes = await service.buildPdf(
labels: const [
LabelSheetLabel(
varietyLabel: '日本の在来種のトマト',
commonName: 'トマト',
scientificName: 'Solanum lycopersicum',
qrData: 'tane://seed?v=tomato',
),
LabelSheetLabel(varietyLabel: 'طماطم بلدية', qrData: 'tane://seed?v=x'),
],
format: LabelSheetFormat.cards,
);
expect(String.fromCharCodes(bytes.take(5)), '%PDF-');
expect(bytes.length, greaterThan(1000));
});
test('saveLabels hands the PDF to the save dialog', () async {
final files = _RecordingFileService();
final service = LabelSheetService(files: files);

View file

@ -10,11 +10,15 @@ speaks nested JSON) handles natively.
```
apps/app_seeds/lib/i18n/
en.i18n.json ← base locale (source of truth for keys)
es.i18n.json
pt.i18n.json
es.i18n.json ast, pt, fr, de, ja …
strings*.g.dart ← generated, do not edit
```
Live locales: `es`, `ast`, `en`, `pt`, `fr`, `de`, `ja`. Japanese (`ja`) is the
CJK reference — fittingly, the app's own name is Japanese (種, *tane*, "seed").
It ships only a core set of strings translated; the rest fall back to English,
which is exactly how a partial locale is meant to grow.
- Placeholders use braces: `"{count} entries added"`. Keep them verbatim.
- Keys are code and never change per locale; only values are translated.
- Missing keys fall back to English at runtime (`fallback_strategy:
@ -41,6 +45,16 @@ apps/app_seeds/lib/i18n/
Right-to-left locales (Arabic, Hebrew, Persian) are first-class: layouts use
directional insets and are exercised by
[`rtl_smoke_test.dart`](../apps/app_seeds/test/ui/rtl_smoke_test.dart).
**Fonts cover the scripts we claim.** The UI text theme carries Noto Sans
Arabic + Noto Sans JP as per-glyph fallbacks (see
[`theme.dart`](../apps/app_seeds/lib/ui/theme.dart)), so RTL and CJK render on
every platform — including desktop, where the system font may lack them. Every
generated PDF shares the same fallbacks via
[`pdf_fonts.dart`](../apps/app_seeds/lib/services/pdf_fonts.dart), so labels and
catalogs print Arabic/CJK instead of tofu. Fonts are a per-locale resource,
extensible to any script; widen the fallback list as more scripts land.
Locale-specific resources ship with their locale, never hardcoded: OCR
language packs (`assets/tessdata/`), PDF fonts (`assets/fonts/`), and species
common names (`assets/catalog/species.json`, a locale-keyed map).