The propose sheet used a free-text seed name, unlinked from the inventory. Now the seed is picked from your catalogued varieties so the Plantaré is always tied to a real Variety (tap-through to its detail, and it shows in that seed's commitments): - Seed field is an Autocomplete over the inventory; on send the typed name resolves to the matching Variety, or creates a quick one if it's new — so varietyId is never null. Sheet takes the VarietyRepository; chat passes it. - New repo one-shot `varietyLabels()` (id + label, no joins) feeds the picker. Deliberately a Future, not `watchInventory()` — a transient sheet must not hold a live Drift subscription (that hung the widget test ~36 min). Tests use it too and now run in ~2s with bounded pumps + a tall surface, no pumpAndSettle. Tests: a new name creates & links a Variety; a matching name links the existing one without duplicating. ui/services/data plantare suites green.
358 lines
13 KiB
Dart
358 lines
13 KiB
Dart
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<bool?> showProposePlantareSheet(
|
|
BuildContext context, {
|
|
required PlantareService service,
|
|
required VarietyRepository repository,
|
|
required String peerPubkey,
|
|
String? peerName,
|
|
String? varietyId,
|
|
String? seedLabel,
|
|
}) {
|
|
return showModalBottomSheet<bool>(
|
|
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<void> _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<String> _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<void> _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<void> _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<PlantareReturnKind> 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),
|
|
);
|
|
}
|
|
}
|