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 fe4591d747
commit 7ae6becd8f
31 changed files with 686 additions and 104 deletions

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(