diff --git a/apps/app_seeds/lib/data/variety_repository.dart b/apps/app_seeds/lib/data/variety_repository.dart index 6e50303..f1d993b 100644 --- a/apps/app_seeds/lib/data/variety_repository.dart +++ b/apps/app_seeds/lib/data/variety_repository.dart @@ -126,6 +126,22 @@ class VarietyPhoto extends Equatable { List 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 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 lots; final List vernacularNames; final List photos; + final List links; @override List 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 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 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 softDeleteVariety(String id) async { diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index 2e0c0fa..1e586b1 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -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", diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index c5e93e4..d11401c 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -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", diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index 296d59d..31be84b 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -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 diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index 439bc69..5376688 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -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', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index d778202..a224e8b 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -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', diff --git a/apps/app_seeds/lib/state/variety_detail_cubit.dart b/apps/app_seeds/lib/state/variety_detail_cubit.dart index 078118c..aeb2189 100644 --- a/apps/app_seeds/lib/state/variety_detail_cubit.dart +++ b/apps/app_seeds/lib/state/variety_detail_cubit.dart @@ -92,6 +92,12 @@ class VarietyDetailCubit extends Cubit { Future removePhoto(String attachmentId) => _repo.removePhoto(attachmentId); + Future addExternalLink(String url, {String? title}) => + _repo.addExternalLink(varietyId, url, title: title); + + Future removeExternalLink(String linkId) => + _repo.removeExternalLink(linkId); + Future linkSpecies(String speciesId) => _repo.linkSpecies(varietyId, speciesId); diff --git a/apps/app_seeds/lib/ui/variety_detail_screen.dart b/apps/app_seeds/lib/ui/variety_detail_screen.dart index 533534a..d43d1cb 100644 --- a/apps/app_seeds/lib/ui/variety_detail_screen.dart +++ b/apps/app_seeds/lib/ui/variety_detail_screen.dart @@ -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 _showAddNameDialog( ); } +Future _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 _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( + 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; diff --git a/apps/app_seeds/linux/flutter/generated_plugin_registrant.cc b/apps/app_seeds/linux/flutter/generated_plugin_registrant.cc index 73a1106..ce507da 100644 --- a/apps/app_seeds/linux/flutter/generated_plugin_registrant.cc +++ b/apps/app_seeds/linux/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include 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); } diff --git a/apps/app_seeds/linux/flutter/generated_plugins.cmake b/apps/app_seeds/linux/flutter/generated_plugins.cmake index 3b59c3a..c2ca208 100644 --- a/apps/app_seeds/linux/flutter/generated_plugins.cmake +++ b/apps/app_seeds/linux/flutter/generated_plugins.cmake @@ -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 diff --git a/apps/app_seeds/pubspec.yaml b/apps/app_seeds/pubspec.yaml index 9c6f9b8..17faf7c 100644 --- a/apps/app_seeds/pubspec.yaml +++ b/apps/app_seeds/pubspec.yaml @@ -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: diff --git a/apps/app_seeds/test/data/variety_detail_test.dart b/apps/app_seeds/test/data/variety_detail_test.dart index 43389ac..f54a8a6 100644 --- a/apps/app_seeds/test/data/variety_detail_test.dart +++ b/apps/app_seeds/test/data/variety_detail_test.dart @@ -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); + }); } diff --git a/apps/app_seeds/test/ui/variety_detail_screen_test.dart b/apps/app_seeds/test/ui/variety_detail_screen_test.dart index 20ecaec..d83d6de 100644 --- a/apps/app_seeds/test/ui/variety_detail_screen_test.dart +++ b/apps/app_seeds/test/ui/variety_detail_screen_test.dart @@ -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); + }); } diff --git a/pubspec.lock b/pubspec.lock index e1f463d..105714b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: