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:
parent
02a9d5bf39
commit
b23f4f1c77
14 changed files with 315 additions and 2 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue