feat(inventory): external links on the variety detail

Add Wikipedia/forum-style URLs to a variety:
- Repository: addExternalLink/removeExternalLink (soft delete); watchVariety
  loads links and re-emits on external_links changes; ExternalLinkItem model
  (title preferred over URL for display).
- Cubit: addExternalLink/removeExternalLink.
- Detail: a "Links" section with tappable link chips (open via url_launcher,
  http(s):// prepended if missing), delete per chip, and an "Add link" dialog
  (URL + optional title).

Tests: repo add/remove + widget add/remove. 52 green; Linux runs.
This commit is contained in:
vjrj 2026-07-08 13:38:31 +02:00
parent 02a9d5bf39
commit b23f4f1c77
14 changed files with 315 additions and 2 deletions

View file

@ -126,6 +126,22 @@ class VarietyPhoto extends Equatable {
List<Object?> get props => [id, bytes];
}
/// An external URL attached to a variety (Wikipedia, a forum thread).
class ExternalLinkItem extends Equatable {
const ExternalLinkItem({required this.id, required this.url, this.title});
final String id;
final String url;
final String? title;
/// What to show: the title if present, otherwise the URL.
String get display =>
(title != null && title!.trim().isNotEmpty) ? title! : url;
@override
List<Object?> get props => [id, url, title];
}
/// The full detail of one variety (identity + its lots, names and photos).
class VarietyDetail extends Equatable {
const VarietyDetail({
@ -138,6 +154,7 @@ class VarietyDetail extends Equatable {
this.lots = const [],
this.vernacularNames = const [],
this.photos = const [],
this.links = const [],
});
final String id;
@ -149,6 +166,7 @@ class VarietyDetail extends Equatable {
final List<VarietyLot> lots;
final List<VernacularName> vernacularNames;
final List<VarietyPhoto> photos;
final List<ExternalLinkItem> links;
@override
List<Object?> get props => [
@ -161,6 +179,7 @@ class VarietyDetail extends Equatable {
lots,
vernacularNames,
photos,
links,
];
}
@ -347,6 +366,9 @@ class VarietyRepository {
(_db.select(
_db.attachments,
)..where((a) => a.parentId.equals(id))).watch().map((_) {}),
(_db.select(
_db.externalLinks,
)..where((e) => e.parentId.equals(id))).watch().map((_) {}),
// Coarse: any germination-test change re-emits (catalog of tests is tiny).
_db.select(_db.germinationTests).watch().map((_) {}),
]);
@ -412,6 +434,17 @@ class VarietyRepository {
..orderBy([(a) => OrderingTerm(expression: a.createdAt)]))
.get();
final links =
await (_db.select(_db.externalLinks)
..where(
(e) =>
e.parentId.equals(id) &
e.parentType.equalsValue(ParentType.variety) &
e.isDeleted.equals(false),
)
..orderBy([(e) => OrderingTerm(expression: e.createdAt)]))
.get();
return VarietyDetail(
id: v.id,
label: v.label,
@ -434,6 +467,9 @@ class VarietyRepository {
for (final p in photos)
if (p.bytes != null) VarietyPhoto(id: p.id, bytes: p.bytes!),
],
links: links
.map((e) => ExternalLinkItem(id: e.id, url: e.url, title: e.title))
.toList(),
);
}
@ -630,6 +666,45 @@ class VarietyRepository {
);
}
/// Adds an external link (URL, optional title) to a variety. Returns its id.
Future<String> addExternalLink(
String varietyId,
String url, {
String? title,
}) async {
final (created, updated) = _stamp();
final id = idGen.newId();
await _db
.into(_db.externalLinks)
.insert(
ExternalLinksCompanion.insert(
id: id,
createdAt: created,
updatedAt: updated,
lastAuthor: nodeId,
parentType: ParentType.variety,
parentId: varietyId,
url: url,
title: Value(title),
),
);
return id;
}
/// Soft-deletes an external link (tombstone).
Future<void> removeExternalLink(String linkId) async {
final (_, updated) = _stamp();
await (_db.update(
_db.externalLinks,
)..where((e) => e.id.equals(linkId))).write(
ExternalLinksCompanion(
isDeleted: const Value(true),
updatedAt: Value(updated),
lastAuthor: Value(nodeId),
),
);
}
/// Soft-deletes a variety (tombstone); it disappears from the inventory but
/// the row survives for correct CRDT merges later.
Future<void> softDeleteVariety(String id) async {

View file

@ -45,6 +45,10 @@
"noLots": "No lots yet.",
"names": "Also known as",
"addName": "Add name",
"links": "Links",
"addLink": "Add link",
"linkUrl": "URL",
"linkTitle": "Title (optional)",
"notes": "Notes",
"addLot": "Add lot",
"editLot": "Edit lot",

View file

@ -45,6 +45,10 @@
"noLots": "Aún no hay lotes.",
"names": "También conocida como",
"addName": "Añadir nombre",
"links": "Enlaces",
"addLink": "Añadir enlace",
"linkUrl": "URL",
"linkTitle": "Título (opcional)",
"notes": "Notas",
"addLot": "Añadir lote",
"editLot": "Editar lote",

View file

@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 2
/// Strings: 236 (118 per locale)
/// Strings: 244 (122 per locale)
///
/// Built on 2026-07-08 at 10:18 UTC
/// Built on 2026-07-08 at 10:27 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import

View file

@ -216,6 +216,18 @@ class Translations$detail$en {
/// en: 'Add name'
String get addName => 'Add name';
/// en: 'Links'
String get links => 'Links';
/// en: 'Add link'
String get addLink => 'Add link';
/// en: 'URL'
String get linkUrl => 'URL';
/// en: 'Title (optional)'
String get linkTitle => 'Title (optional)';
/// en: 'Notes'
String get notes => 'Notes';
@ -757,6 +769,10 @@ extension on Translations {
'detail.noLots' => 'No lots yet.',
'detail.names' => 'Also known as',
'detail.addName' => 'Add name',
'detail.links' => 'Links',
'detail.addLink' => 'Add link',
'detail.linkUrl' => 'URL',
'detail.linkTitle' => 'Title (optional)',
'detail.notes' => 'Notes',
'detail.addLot' => 'Add lot',
'detail.editLot' => 'Edit lot',

View file

@ -148,6 +148,10 @@ class _Translations$detail$es extends Translations$detail$en {
@override String get noLots => 'Aún no hay lotes.';
@override String get names => 'También conocida como';
@override String get addName => 'Añadir nombre';
@override String get links => 'Enlaces';
@override String get addLink => 'Añadir enlace';
@override String get linkUrl => 'URL';
@override String get linkTitle => 'Título (opcional)';
@override String get notes => 'Notas';
@override String get addLot => 'Añadir lote';
@override String get editLot => 'Editar lote';
@ -542,6 +546,10 @@ extension on TranslationsEs {
'detail.noLots' => 'Aún no hay lotes.',
'detail.names' => 'También conocida como',
'detail.addName' => 'Añadir nombre',
'detail.links' => 'Enlaces',
'detail.addLink' => 'Añadir enlace',
'detail.linkUrl' => 'URL',
'detail.linkTitle' => 'Título (opcional)',
'detail.notes' => 'Notas',
'detail.addLot' => 'Añadir lote',
'detail.editLot' => 'Editar lote',

View file

@ -92,6 +92,12 @@ class VarietyDetailCubit extends Cubit<VarietyDetailState> {
Future<void> removePhoto(String attachmentId) =>
_repo.removePhoto(attachmentId);
Future<void> addExternalLink(String url, {String? title}) =>
_repo.addExternalLink(varietyId, url, title: title);
Future<void> removeExternalLink(String linkId) =>
_repo.removeExternalLink(linkId);
Future<void> linkSpecies(String speciesId) =>
_repo.linkSpecies(varietyId, speciesId);

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:image_picker/image_picker.dart';
import 'package:url_launcher/url_launcher.dart';
import '../data/species_repository.dart';
import '../data/variety_repository.dart';
@ -106,6 +107,30 @@ class _DetailView extends StatelessWidget {
Text(detail.notes!),
],
const SizedBox(height: 16),
_SectionTitle(t.detail.links),
Wrap(
spacing: 8,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
for (final link in detail.links)
InputChip(
key: Key('link.${link.id}'),
avatar: const Icon(Icons.link, size: 18),
label: Text(link.display),
deleteIcon: const Icon(Icons.close, size: 18),
onDeleted: () => cubit.removeExternalLink(link.id),
onPressed: () => _openUrl(link.url),
),
ActionChip(
key: const Key('link.add'),
avatar: const Icon(Icons.add, size: 18),
label: Text(t.detail.addLink),
onPressed: () => _showAddLinkDialog(context, cubit),
),
],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@ -648,6 +673,66 @@ Future<void> _showAddNameDialog(
);
}
Future<void> _openUrl(String url) async {
final normalized = url.contains('://') ? url : 'https://$url';
final uri = Uri.tryParse(normalized);
if (uri != null) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
Future<void> _showAddLinkDialog(
BuildContext context,
VarietyDetailCubit cubit,
) {
final t = context.t;
final urlController = TextEditingController();
final titleController = TextEditingController();
void submit(BuildContext dialogContext) {
final url = urlController.text.trim();
if (url.isNotEmpty) {
cubit.addExternalLink(url, title: _nullIfBlank(titleController.text));
}
Navigator.of(dialogContext).pop();
}
return showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(t.detail.addLink),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
key: const Key('link.url'),
controller: urlController,
autofocus: true,
keyboardType: TextInputType.url,
decoration: InputDecoration(labelText: t.detail.linkUrl),
),
const SizedBox(height: 8),
TextField(
key: const Key('link.title'),
controller: titleController,
decoration: InputDecoration(labelText: t.detail.linkTitle),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text(t.common.cancel),
),
FilledButton(
key: const Key('link.save'),
onPressed: () => submit(dialogContext),
child: Text(t.common.save),
),
],
),
);
}
String? _nullIfBlank(String value) {
final trimmed = value.trim();
return trimmed.isEmpty ? null : trimmed;

View file

@ -9,6 +9,7 @@
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <sqlcipher_flutter_libs/sqlite3_flutter_libs_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
@ -20,4 +21,7 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) sqlcipher_flutter_libs_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin");
sqlite3_flutter_libs_plugin_register_with_registrar(sqlcipher_flutter_libs_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
}

View file

@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
flutter_secure_storage_linux
sqlcipher_flutter_libs
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View file

@ -49,6 +49,7 @@ dependencies:
cupertino_icons: ^1.0.8
material_symbols_icons: ^4.2951.0
url_launcher: ^6.3.2
dev_dependencies:
flutter_test:

View file

@ -212,4 +212,20 @@ void main() {
await queue.cancel();
});
test('add and remove external links', () async {
final id = await repo.addQuickVariety(label: 'Maize');
final linkId = await repo.addExternalLink(
id,
'https://en.wikipedia.org/wiki/Maize',
title: 'Wikipedia',
);
final detail = await repo.watchVariety(id).first;
expect(detail!.links.single.url, 'https://en.wikipedia.org/wiki/Maize');
expect(detail.links.single.display, 'Wikipedia'); // title preferred
await repo.removeExternalLink(linkId);
expect((await repo.watchVariety(id).first)!.links, isEmpty);
});
}

View file

@ -329,4 +329,33 @@ void main() {
expect(find.text('Maíz'), findsNothing);
await disposeTree(tester);
});
testWidgets('adding and removing an external link', (tester) async {
final repo = newTestRepository(db);
final id = await repo.addQuickVariety(label: 'Maize');
await tester.pumpWidget(
wrapDetail(
repository: repo,
varietyId: id,
species: newTestSpeciesRepository(db),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('link.add')));
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const Key('link.url')), 'wikipedia.org');
await tester.enterText(find.byKey(const Key('link.title')), 'Wiki');
await tester.tap(find.byKey(const Key('link.save')));
await tester.pumpAndSettle();
expect(find.text('Wiki'), findsOneWidget);
await tester.tap(find.byIcon(Icons.close)); // the link chip's delete icon
await tester.pumpAndSettle();
expect(find.text('Wiki'), findsNothing);
await disposeTree(tester);
});
}

View file

@ -1041,6 +1041,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
url_launcher:
dependency: transitive
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
url: "https://pub.dev"
source: hosted
version: "6.3.30"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
url: "https://pub.dev"
source: hosted
version: "2.4.3"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.dev"
source: hosted
version: "3.1.5"
uuid:
dependency: transitive
description: