import 'package:flutter/material.dart'; import '../data/variety_repository.dart'; import '../db/enums.dart'; import '../i18n/strings.g.dart'; import '../services/plantare_service.dart'; import '../services/profile_cache.dart' show shortPubkey; import 'theme.dart'; /// Opens the "propose a signed Plantaré" sheet against a specific peer, whose /// [peerPubkey] is already in hand (the flow starts from a chat or a closed /// offer). The seed is picked from the inventory ([repository]) so the promise /// is tied to a real `Variety`; a new name creates one. Fills the terms, /// self-signs, and sends via [PlantareService]; the peer counter-signs to close /// it. When [varietyId]/[seedLabel] are given (opened from a seed) that seed is /// preselected. Returns true if a proposal was sent. Future showProposePlantareSheet( BuildContext context, { required PlantareService service, required VarietyRepository repository, required String peerPubkey, String? peerName, String? varietyId, String? seedLabel, }) { return showModalBottomSheet( context: context, isScrollControlled: true, builder: (_) => _ProposeSheet( service: service, repository: repository, peerPubkey: peerPubkey, peerName: peerName, varietyId: varietyId, seedLabel: seedLabel, ), ); } class _ProposeSheet extends StatefulWidget { const _ProposeSheet({ required this.service, required this.repository, required this.peerPubkey, this.peerName, this.varietyId, this.seedLabel, }); final PlantareService service; final VarietyRepository repository; final String peerPubkey; final String? peerName; final String? varietyId; final String? seedLabel; @override State<_ProposeSheet> createState() => _ProposeSheetState(); } class _ProposeSheetState extends State<_ProposeSheet> { // Default: I gave the seed and they'll return some (I'm the creditor) — the // common "I'm sharing seed" case. PlantareDirection _direction = PlantareDirection.owedToMe; PlantareReturnKind _returnKind = PlantareReturnKind.similar; final _owed = TextEditingController(); final _hours = TextEditingController(); DateTime? _dueBy; bool _saving = false; /// The user's catalogued seeds (id + label), for the picker (empty until /// loaded). A one-shot load, not a live stream — a transient sheet must not /// hold a Drift subscription. List<({String id, String label})> _varieties = []; /// The picked seed's id — set when the typed name matches (or is chosen from) /// the inventory, cleared when the text is edited to something else. Null → /// the name will create a new Variety on send, so the promise is always tied /// to a real seed. String? _varietyId; /// Autocomplete owns the field's controller; captured here to read on send /// and to seed the initial text. TextEditingController? _seedController; @override void initState() { super.initState(); _varietyId = widget.varietyId; _loadVarieties(); } Future _loadVarieties() async { final items = await widget.repository.varietyLabels(); if (!mounted) return; setState(() => _varieties = items); } @override void dispose() { _owed.dispose(); _hours.dispose(); super.dispose(); } String? _nullIfBlank(String s) => s.trim().isEmpty ? null : s.trim(); /// Resolves the typed seed name to a real Variety id: an existing one when the /// name matches the inventory, otherwise a freshly created quick variety — so /// the Plantaré is always linked to a seed. Future _resolveVarietyId(String label) async { if (_varietyId != null) return _varietyId!; final match = _varieties.where( (v) => v.label.trim().toLowerCase() == label.toLowerCase(), ); if (match.isNotEmpty) return match.first.id; return widget.repository.addQuickVariety(label: label); } Future _pickDueBy() async { final now = DateTime.now(); final picked = await showDatePicker( context: context, initialDate: _dueBy ?? DateTime(now.year + 1, now.month, now.day), firstDate: DateTime(now.year - 1), lastDate: DateTime(now.year + 10), ); if (picked != null) setState(() => _dueBy = picked); } Future _send() async { final label = (_seedController?.text ?? widget.seedLabel ?? '').trim(); if (label.isEmpty) return; // the seed name is the one thing we need setState(() => _saving = true); final varietyId = await _resolveVarietyId(label); await widget.service.propose( direction: _direction, counterpartyKey: widget.peerPubkey, counterpartyName: widget.peerName, varietyId: varietyId, label: label, owedDescription: _nullIfBlank(_owed.text), returnKind: _returnKind, workHours: _returnKind == PlantareReturnKind.workHours ? double.tryParse(_hours.text.trim().replaceAll(',', '.')) : null, dueBy: _dueBy, ); if (mounted) Navigator.of(context).pop(true); } @override Widget build(BuildContext context) { final t = context.t; final peer = widget.peerName ?? shortPubkey(widget.peerPubkey); return Padding( padding: EdgeInsets.only( left: 16, right: 16, top: 16, bottom: MediaQuery.of(context).viewInsets.bottom + 16, ), child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text(t.plantare.propose, style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 4), Text(t.plantare.proposeTo(name: peer), style: const TextStyle(color: seedGreen, fontSize: 13)), const SizedBox(height: 8), Text(t.plantare.proposeHelp, style: const TextStyle(color: seedMuted, fontSize: 13)), const SizedBox(height: 16), Text(t.plantare.direction, style: Theme.of(context).textTheme.labelLarge), const SizedBox(height: 6), for (final d in PlantareDirection.values) ListTile( key: Key('propose.direction.${d.name}'), contentPadding: EdgeInsets.zero, leading: Icon( _direction == d ? Icons.radio_button_checked : Icons.radio_button_unchecked, color: _direction == d ? seedGreen : seedMuted, ), title: Text(d == PlantareDirection.iReturn ? t.plantare.iReturn : t.plantare.owedToMe), onTap: () => setState(() => _direction = d), ), const SizedBox(height: 8), // Pick the seed from the inventory so the promise is tied to a real // Variety; typing a new name creates one on send. Autocomplete<({String id, String label})>( initialValue: TextEditingValue(text: widget.seedLabel ?? ''), displayStringForOption: (v) => v.label, optionsBuilder: (value) { final q = value.text.trim().toLowerCase(); if (q.isEmpty) return _varieties; return _varieties .where((v) => v.label.toLowerCase().contains(q)); }, onSelected: (v) => setState(() => _varietyId = v.id), fieldViewBuilder: (context, controller, focusNode, onFieldSubmitted) { _seedController = controller; return TextField( key: const Key('propose.seed'), controller: controller, focusNode: focusNode, textCapitalization: TextCapitalization.sentences, onChanged: (_) { // Editing away from a picked seed unlinks it (a new name // will create its own Variety). if (_varietyId != null) setState(() => _varietyId = null); }, onSubmitted: (_) => onFieldSubmitted(), decoration: InputDecoration( labelText: t.plantare.seedLabel, helperText: t.plantare.seedHint, border: const OutlineInputBorder(), ), ); }, ), const SizedBox(height: 16), Text(t.plantare.returnKindLabel, style: Theme.of(context).textTheme.labelLarge), const SizedBox(height: 6), _ReturnChoice( value: PlantareReturnKind.similar, group: _returnKind, title: t.plantare.returnSimilar, note: t.plantare.returnSimilarNote, onTap: (v) => setState(() => _returnKind = v), ), _ReturnChoice( value: PlantareReturnKind.workHours, group: _returnKind, title: t.plantare.returnWork, onTap: (v) => setState(() => _returnKind = v), ), _ReturnChoice( value: PlantareReturnKind.other, group: _returnKind, title: t.plantare.returnOther, onTap: (v) => setState(() => _returnKind = v), ), if (_returnKind == PlantareReturnKind.workHours) ...[ const SizedBox(height: 8), TextField( key: const Key('propose.hours'), controller: _hours, keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: InputDecoration( labelText: t.plantare.workHoursLabel, border: const OutlineInputBorder(), ), ), ], const SizedBox(height: 12), TextField( key: const Key('propose.owed'), controller: _owed, decoration: InputDecoration( labelText: t.plantare.owed, helperText: t.plantare.owedHint, helperMaxLines: 2, border: const OutlineInputBorder(), ), ), const SizedBox(height: 12), InputDecorator( decoration: InputDecoration( labelText: t.plantare.dueByLabel, helperText: t.plantare.dueByHint, border: const OutlineInputBorder(), ), child: Row( children: [ Expanded( child: Text( _dueBy == null ? t.plantare.pickDate : MaterialLocalizations.of(context) .formatShortDate(_dueBy!), style: TextStyle( color: _dueBy == null ? seedMuted : seedOnSurface, ), ), ), if (_dueBy != null) IconButton( icon: const Icon(Icons.clear, size: 20), tooltip: t.plantare.clearDate, onPressed: () => setState(() => _dueBy = null), ), IconButton( key: const Key('propose.dueBy.pick'), icon: const Icon(Icons.event_outlined), tooltip: t.plantare.pickDate, onPressed: _pickDueBy, ), ], ), ), const SizedBox(height: 20), FilledButton.icon( key: const Key('propose.send'), onPressed: _saving ? null : _send, icon: const Icon(Icons.draw_outlined), label: Text(t.plantare.propose), ), ], ), ), ); } } class _ReturnChoice extends StatelessWidget { const _ReturnChoice({ required this.value, required this.group, required this.title, required this.onTap, this.note, }); final PlantareReturnKind value; final PlantareReturnKind group; final String title; final String? note; final ValueChanged onTap; @override Widget build(BuildContext context) { final selected = value == group; return ListTile( key: Key('propose.return.${value.name}'), contentPadding: EdgeInsets.zero, leading: Icon( selected ? Icons.radio_button_checked : Icons.radio_button_unchecked, color: selected ? seedGreen : seedMuted, ), title: Text(title), subtitle: note == null ? null : Text(note!, style: const TextStyle(color: seedMuted, fontSize: 12)), onTap: () => onTap(value), ); } }