tane/apps/app_seeds/lib/ui/calendar_screen.dart
vjrj 071be44851
Some checks are pending
ci / analyze (push) Waiting to run
ci / test-commons-core (push) Waiting to run
ci / test-app-seeds (push) Waiting to run
site / deploy (push) Successful in 1m8s
perf(android): R8, bitmap downscaling, edge-to-edge; recover device compat; publish to production
Device compatibility (regression fix):
- The CAMERA permission (from zxing_barcode_scanner / image_picker) implicitly
  required android.hardware.camera, excluding camera-less devices — Play showed
  Automotive -96%, Chromebook -86%, TV -25%. Declare camera & location
  uses-feature required="false" to keep those devices supported.
- Detect a real camera at runtime (PackageManager.FEATURE_CAMERA_ANY via the
  existing MethodChannel), cache it at bootstrap, and hide the QR scan button and
  the camera photo-source option when absent, so no broken actions are offered.

Play "app optimization" recommendations:
- Enable R8 (isMinifyEnabled + isShrinkResources) with keep rules for the
  OCR (tesseract4android), SQLCipher and notifications JNI/reflection code.
- Bitmap downscaling: cacheWidth/cacheHeight (and ResizeImage for avatars) on
  list thumbnails and avatars so photos decode to on-screen size, not full res.
- Edge-to-edge: opt in with transparent system bars in main().

Release flow:
- Tagged builds now publish to the production track at 100% (was internal);
  add a manual deploy_internal lane as a QA safety net.
- Document country/region availability as a Play Console setting (not in-repo).
2026-07-20 22:53:48 +02:00

298 lines
9.8 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../app.dart' show materialLocaleFor;
import '../data/variety_repository.dart';
import '../domain/crop_calendar.dart';
import '../i18n/strings.g.dart';
import 'category_palette.dart';
import 'edge_fade.dart';
import 'theme.dart';
/// One crop-calendar phase as shown on the "this month" screen: its label,
/// which month mask on a [CalendarEntry] it reads, an icon and a tint.
class _Phase {
const _Phase(this.label, this.maskOf, this.icon, this.color);
final String Function(Translations t) label;
final int? Function(CalendarEntry e) maskOf;
final IconData icon;
final Color color;
}
// Actions first (sow / transplant / harvest seed), then the informational
// phases (flowering / fruiting) — "qué toca hacer" above "qué está pasando".
const _phases = <_Phase>[
_Phase(_sowLabel, _sowMask, Icons.grass, Color(0xFF2F7D34)),
_Phase(_transplantLabel, _transplantMask, Icons.spa, Color(0xFF3E7168)),
_Phase(_harvestLabel, _harvestMask, Icons.grain, Color(0xFF8A6D1E)),
_Phase(_floweringLabel, _floweringMask, Icons.local_florist, Color(0xFF9B5566)),
_Phase(_fruitingLabel, _fruitingMask, Icons.eco, Color(0xFF9C5B3B)),
];
String _sowLabel(Translations t) => t.cropCalendar.sow;
String _transplantLabel(Translations t) => t.cropCalendar.transplant;
String _harvestLabel(Translations t) => t.cropCalendar.seedHarvest;
String _floweringLabel(Translations t) => t.cropCalendar.flowering;
String _fruitingLabel(Translations t) => t.cropCalendar.fruiting;
int? _sowMask(CalendarEntry e) => e.sowMonths;
int? _transplantMask(CalendarEntry e) => e.transplantMonths;
int? _harvestMask(CalendarEntry e) => e.seedHarvestMonths;
int? _floweringMask(CalendarEntry e) => e.floweringMonths;
int? _fruitingMask(CalendarEntry e) => e.fruitingMonths;
/// "What's due this month" across the whole inventory: a month strip plus the
/// varieties whose recorded calendar has an action in the picked month, grouped
/// by phase. Reuses the per-variety calendar data — no schema of its own.
class CalendarScreen extends StatefulWidget {
const CalendarScreen({this.initialMonth, super.key});
/// The month (1..12) to open on; defaults to the current month. Injected in
/// tests so they don't depend on the wall clock.
final int? initialMonth;
@override
State<CalendarScreen> createState() => _CalendarScreenState();
}
class _CalendarScreenState extends State<CalendarScreen> {
late int _month = widget.initialMonth ?? DateTime.now().month;
@override
Widget build(BuildContext context) {
final t = context.t;
final repo = context.read<VarietyRepository>();
return Scaffold(
appBar: AppBar(title: Text(t.calendar.title)),
body: Column(
children: [
_MonthStrip(
month: _month,
onSelect: (m) => setState(() => _month = m),
),
Padding(
padding: const EdgeInsetsDirectional.fromSTEB(16, 0, 16, 8),
child: Text(
t.calendar.selfNote,
style: const TextStyle(color: seedMuted, fontSize: 12),
),
),
const Divider(height: 1),
Expanded(
child: StreamBuilder<List<CalendarEntry>>(
stream: repo.watchCalendar(),
builder: (context, snapshot) {
final entries = snapshot.data ?? const <CalendarEntry>[];
final sections = <Widget>[];
for (final phase in _phases) {
final matches = [
for (final e in entries)
if (maskHasMonth(phase.maskOf(e), _month)) e,
];
if (matches.isEmpty) continue;
sections.add(_PhaseGroup(phase: phase, entries: matches));
}
if (sections.isEmpty) {
return _EmptyMonth(monthName: _monthName(context, _month));
}
return ListView(
padding: const EdgeInsets.only(bottom: 24),
children: sections,
);
},
),
),
],
),
);
}
}
/// A horizontally scrolling strip of the twelve months; the picked one is
/// filled. Scrolls the picked month into view (so late-year months aren't left
/// off-screen when the screen opens in, say, November).
class _MonthStrip extends StatefulWidget {
const _MonthStrip({required this.month, required this.onSelect});
final int month;
final ValueChanged<int> onSelect;
@override
State<_MonthStrip> createState() => _MonthStripState();
}
class _MonthStripState extends State<_MonthStrip> {
final _selectedKey = GlobalKey();
@override
void initState() {
super.initState();
_revealSelected();
}
@override
void didUpdateWidget(_MonthStrip old) {
super.didUpdateWidget(old);
if (old.month != widget.month) _revealSelected();
}
void _revealSelected() {
WidgetsBinding.instance.addPostFrameCallback((_) {
final ctx = _selectedKey.currentContext;
if (ctx != null) {
Scrollable.ensureVisible(ctx,
alignment: 0.5, duration: const Duration(milliseconds: 250));
}
});
}
@override
Widget build(BuildContext context) {
final locale = materialLocaleFor(Localizations.localeOf(context));
final fmt = DateFormat.MMM(locale.toLanguageTag());
return SizedBox(
height: 56,
child: EdgeFade(
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
itemCount: 12,
separatorBuilder: (_, _) => const SizedBox(width: 8),
itemBuilder: (context, i) {
final m = i + 1;
final selected = m == widget.month;
return ChoiceChip(
key: Key('calendar.month.$m'),
showCheckmark: false,
labelPadding: const EdgeInsets.symmetric(horizontal: 6),
label: Text(
_capitalise(fmt.format(DateTime(2000, m))),
key: selected ? _selectedKey : null,
),
selected: selected,
onSelected: (_) => widget.onSelect(m),
selectedColor: seedGreen,
labelStyle: TextStyle(
color: selected ? Colors.white : seedMuted,
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
),
);
},
),
),
);
}
}
class _PhaseGroup extends StatelessWidget {
const _PhaseGroup({required this.phase, required this.entries});
final _Phase phase;
final List<CalendarEntry> entries;
@override
Widget build(BuildContext context) {
final t = context.t;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 18, 16, 6),
child: Row(
children: [
Icon(phase.icon, size: 20, color: phase.color),
const SizedBox(width: 8),
Text(
phase.label(t),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: phase.color,
),
),
const SizedBox(width: 6),
Text('· ${entries.length}',
style: const TextStyle(color: seedMuted)),
],
),
),
for (final e in entries) _CalendarRow(entry: e),
],
);
}
}
class _CalendarRow extends StatelessWidget {
const _CalendarRow({required this.entry});
final CalendarEntry entry;
@override
Widget build(BuildContext context) {
final swatch = categorySwatch(entry.category ?? '');
return ListTile(
key: Key('calendar.entry.${entry.id}'),
leading: CircleAvatar(
radius: 20,
backgroundColor: swatch.fill,
// Decode to the 40px avatar size (× dpr), not the full photo.
foregroundImage: entry.photo == null
? null
: ResizeImage(
MemoryImage(entry.photo!),
width: (40 * MediaQuery.devicePixelRatioOf(context)).round(),
height: (40 * MediaQuery.devicePixelRatioOf(context)).round(),
),
child: entry.photo == null
? Text(
entry.label.isEmpty
? '?'
: entry.label.substring(0, 1).toUpperCase(),
style: TextStyle(color: swatch.ink, fontWeight: FontWeight.w600),
)
: null,
),
title: Text(entry.label),
subtitle: entry.category == null ? null : Text(entry.category!),
onTap: () => context.push('/variety/${entry.id}'),
);
}
}
class _EmptyMonth extends StatelessWidget {
const _EmptyMonth({required this.monthName});
final String monthName;
@override
Widget build(BuildContext context) {
final t = context.t;
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.event_available,
size: 64, color: seedGreen.withValues(alpha: 0.5)),
const SizedBox(height: 16),
Text(
t.calendar.nothing(month: monthName),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
),
);
}
}
String _monthName(BuildContext context, int month) {
final locale = materialLocaleFor(Localizations.localeOf(context));
return _capitalise(
DateFormat.MMMM(locale.toLanguageTag()).format(DateTime(2000, month)));
}
String _capitalise(String s) =>
s.isEmpty ? s : s[0].toUpperCase() + s.substring(1);