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:
parent
7891a973f4
commit
1ce32c2b5c
36 changed files with 410 additions and 179 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue