feat: cover seedlings (plantón) in copy + legal, backup cadence, server picker

Beta-tester feedback triage. The data model already had LotType.seedling as
first class; the gaps were copy and UX.

- Seedlings/plantones: broaden landing hero (EN/ES), in-app legal notice, and
  all legal texts (site + docs masters) from seeds-only to "seeds and
  seedlings", with a live-plant phytosanitary/transport caveat. Seeds stay the
  hero — targeted, not a blanket rename.
- Backups: settings line now states the 7-day cadence explicitly; cadence lives
  in AutoBackupService.backupInterval as the single source for text and schedule.
- Community servers: replace the manual wss:// text box with a checklist of the
  known servers (defaults visible/toggleable) plus an "add server" affordance
  with basic validation. No jargon in the UI.

i18n across en/es/pt/fr/de/ast (ja falls back). Tests updated + a new
server-picker widget test.
This commit is contained in:
vjrj 2026-07-15 15:57:23 +02:00
parent 7891a973f4
commit 1ce32c2b5c
36 changed files with 410 additions and 179 deletions

View file

@ -357,8 +357,9 @@ class _AutoBackupTileState extends State<_AutoBackupTile> {
future: _lastBackup,
builder: (context, snapshot) {
final last = snapshot.data;
final days = AutoBackupService.backupInterval.inDays;
final subtitle = last == null
? t.backup.autoBackupNone
? t.backup.autoBackupNone(days: days)
: t.backup.autoBackupLast(
// Format via the Localizations locale, not the raw app locale:
// Asturian (`ast`) has no `intl` date symbols and would throw,
@ -366,6 +367,7 @@ class _AutoBackupTileState extends State<_AutoBackupTile> {
date: DateFormat.yMMMd(
Localizations.localeOf(context).languageCode,
).format(last),
days: days,
);
return ListTile(
leading: const Icon(Icons.shield_outlined),

View file

@ -656,7 +656,11 @@ class _ConfigSheet extends StatefulWidget {
class _ConfigSheetState extends State<_ConfigSheet> {
final _area = TextEditingController();
final _servers = TextEditingController();
/// Servers offered as toggles: the built-in defaults plus any the user has
/// saved or added. [_selectedRelays] is the subset currently switched on.
List<String> _knownRelays = const [];
Set<String> _selectedRelays = <String>{};
bool _loading = true;
bool _locationBusy = false;
String? _locationError;
@ -670,18 +674,93 @@ class _ConfigSheetState extends State<_ConfigSheet> {
Future<void> _load() async {
_area.text = await widget.settings.areaGeohash();
_servers.text = (await widget.settings.relayUrls()).join('\n');
final saved = await widget.settings.relayUrls();
_selectedRelays = saved.toSet();
// Show the defaults plus anything the user saved, defaults first.
_knownRelays = <String>{...SocialSettings.defaultRelays, ...saved}.toList();
_precision = await widget.settings.searchPrecision();
if (mounted) setState(() => _loading = false);
}
Future<void> _save() async {
await widget.settings.setAreaGeohash(_area.text);
await widget.settings.setRelayUrls(_servers.text.split('\n'));
await widget.settings.setRelayUrls(_selectedRelays.toList());
await widget.settings.setSearchPrecision(_precision);
if (mounted) Navigator.of(context).pop(true);
}
/// The human-friendly server name just the host, no `wss://` scheme or
/// trailing slash (jargon stays out of the UI).
String _relayLabel(String url) => url
.trim()
.replaceFirst(RegExp(r'^wss?://'), '')
.replaceFirst(RegExp(r'/+$'), '');
/// Coerces user input into a `wss://` address, or null if it can't be one.
/// Bare hosts get a `wss://` prefix so people needn't type the scheme.
String? _normalizeRelay(String raw) {
var s = raw.trim();
if (s.isEmpty) return null;
if (!s.contains('://')) s = 'wss://$s';
final uri = Uri.tryParse(s);
if (uri == null ||
(uri.scheme != 'wss' && uri.scheme != 'ws') ||
uri.host.isEmpty) {
return null;
}
return s.replaceFirst(RegExp(r'/+$'), '');
}
Future<void> _addServer() async {
final t = context.t;
final controller = TextEditingController();
final url = await showDialog<String>(
context: context,
builder: (dialogContext) {
String? error;
return StatefulBuilder(
builder: (dialogContext, setLocal) => AlertDialog(
title: Text(t.market.serversAdvanced),
content: TextField(
key: const Key('market.serverField'),
controller: controller,
autofocus: true,
keyboardType: TextInputType.url,
decoration: InputDecoration(
labelText: t.market.serverAddress,
hintText: 'wss://…',
errorText: error,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text(t.common.cancel),
),
FilledButton(
key: const Key('market.serverConfirm'),
onPressed: () {
final value = _normalizeRelay(controller.text);
if (value == null) {
setLocal(() => error = t.market.serverInvalid);
} else {
Navigator.pop(dialogContext, value);
}
},
child: Text(t.market.save),
),
],
),
);
},
);
if (url == null || !mounted) return;
setState(() {
if (!_knownRelays.contains(url)) _knownRelays = [..._knownRelays, url];
_selectedRelays.add(url);
});
}
/// Fills the area from the device's approximate location, reduced to a coarse
/// geohash so no precise fix is stored.
Future<void> _useLocation() async {
@ -706,7 +785,6 @@ class _ConfigSheetState extends State<_ConfigSheet> {
@override
void dispose() {
_area.dispose();
_servers.dispose();
super.dispose();
}
@ -835,15 +913,46 @@ class _ConfigSheetState extends State<_ConfigSheet> {
),
),
const SizedBox(height: 14),
TextField(
key: const Key('market.servers'),
controller: _servers,
minLines: 1,
maxLines: 3,
decoration: InputDecoration(
labelText: t.market.serversLabel,
helperText: t.market.serversHelp,
helperMaxLines: 2,
Align(
alignment: AlignmentDirectional.centerStart,
child: Text(
t.market.serversLabel,
style: const TextStyle(
fontSize: 13, color: seedMuted),
),
),
Padding(
padding: const EdgeInsetsDirectional.only(
top: 2, bottom: 4),
child: Text(
t.market.serversHelp,
style: const TextStyle(
fontSize: 12, color: seedMuted, height: 1.3),
),
),
for (final url in _knownRelays)
CheckboxListTile(
key: Key('market.server.$url'),
contentPadding: EdgeInsets.zero,
dense: true,
controlAffinity: ListTileControlAffinity.leading,
value: _selectedRelays.contains(url),
title: Text(_relayLabel(url)),
onChanged: (on) => setState(() {
if (on ?? false) {
_selectedRelays.add(url);
} else {
_selectedRelays.remove(url);
}
}),
),
Align(
alignment: AlignmentDirectional.centerStart,
child: TextButton.icon(
key: const Key('market.addServer'),
onPressed: _addServer,
icon: const Icon(Icons.add, size: 18),
label: Text(t.market.serversAdvanced),
),
),
ListTile(