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 {