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:
parent
fe4591d747
commit
7ae6becd8f
31 changed files with 686 additions and 104 deletions
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue