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];
|
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).
|
/// The full detail of one variety (identity + its lots, names and photos).
|
||||||
class VarietyDetail extends Equatable {
|
class VarietyDetail extends Equatable {
|
||||||
const VarietyDetail({
|
const VarietyDetail({
|
||||||
|
|
@ -138,6 +154,7 @@ class VarietyDetail extends Equatable {
|
||||||
this.lots = const [],
|
this.lots = const [],
|
||||||
this.vernacularNames = const [],
|
this.vernacularNames = const [],
|
||||||
this.photos = const [],
|
this.photos = const [],
|
||||||
|
this.links = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
|
|
@ -149,6 +166,7 @@ class VarietyDetail extends Equatable {
|
||||||
final List<VarietyLot> lots;
|
final List<VarietyLot> lots;
|
||||||
final List<VernacularName> vernacularNames;
|
final List<VernacularName> vernacularNames;
|
||||||
final List<VarietyPhoto> photos;
|
final List<VarietyPhoto> photos;
|
||||||
|
final List<ExternalLinkItem> links;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [
|
List<Object?> get props => [
|
||||||
|
|
@ -161,6 +179,7 @@ class VarietyDetail extends Equatable {
|
||||||
lots,
|
lots,
|
||||||
vernacularNames,
|
vernacularNames,
|
||||||
photos,
|
photos,
|
||||||
|
links,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -347,6 +366,9 @@ class VarietyRepository {
|
||||||
(_db.select(
|
(_db.select(
|
||||||
_db.attachments,
|
_db.attachments,
|
||||||
)..where((a) => a.parentId.equals(id))).watch().map((_) {}),
|
)..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).
|
// Coarse: any germination-test change re-emits (catalog of tests is tiny).
|
||||||
_db.select(_db.germinationTests).watch().map((_) {}),
|
_db.select(_db.germinationTests).watch().map((_) {}),
|
||||||
]);
|
]);
|
||||||
|
|
@ -412,6 +434,17 @@ class VarietyRepository {
|
||||||
..orderBy([(a) => OrderingTerm(expression: a.createdAt)]))
|
..orderBy([(a) => OrderingTerm(expression: a.createdAt)]))
|
||||||
.get();
|
.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(
|
return VarietyDetail(
|
||||||
id: v.id,
|
id: v.id,
|
||||||
label: v.label,
|
label: v.label,
|
||||||
|
|
@ -434,6 +467,9 @@ class VarietyRepository {
|
||||||
for (final p in photos)
|
for (final p in photos)
|
||||||
if (p.bytes != null) VarietyPhoto(id: p.id, bytes: p.bytes!),
|
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
|
/// Soft-deletes a variety (tombstone); it disappears from the inventory but
|
||||||
/// the row survives for correct CRDT merges later.
|
/// the row survives for correct CRDT merges later.
|
||||||
Future<void> softDeleteVariety(String id) async {
|
Future<void> softDeleteVariety(String id) async {
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,10 @@
|
||||||
"noLots": "No lots yet.",
|
"noLots": "No lots yet.",
|
||||||
"names": "Also known as",
|
"names": "Also known as",
|
||||||
"addName": "Add name",
|
"addName": "Add name",
|
||||||
|
"links": "Links",
|
||||||
|
"addLink": "Add link",
|
||||||
|
"linkUrl": "URL",
|
||||||
|
"linkTitle": "Title (optional)",
|
||||||
"notes": "Notes",
|
"notes": "Notes",
|
||||||
"addLot": "Add lot",
|
"addLot": "Add lot",
|
||||||
"editLot": "Edit lot",
|
"editLot": "Edit lot",
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,10 @@
|
||||||
"noLots": "Aún no hay lotes.",
|
"noLots": "Aún no hay lotes.",
|
||||||
"names": "También conocida como",
|
"names": "También conocida como",
|
||||||
"addName": "Añadir nombre",
|
"addName": "Añadir nombre",
|
||||||
|
"links": "Enlaces",
|
||||||
|
"addLink": "Añadir enlace",
|
||||||
|
"linkUrl": "URL",
|
||||||
|
"linkTitle": "Título (opcional)",
|
||||||
"notes": "Notas",
|
"notes": "Notas",
|
||||||
"addLot": "Añadir lote",
|
"addLot": "Añadir lote",
|
||||||
"editLot": "Editar lote",
|
"editLot": "Editar lote",
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
/// To regenerate, run: `dart run slang`
|
/// To regenerate, run: `dart run slang`
|
||||||
///
|
///
|
||||||
/// Locales: 2
|
/// 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
|
// coverage:ignore-file
|
||||||
// ignore_for_file: type=lint, unused_import
|
// ignore_for_file: type=lint, unused_import
|
||||||
|
|
|
||||||
|
|
@ -216,6 +216,18 @@ class Translations$detail$en {
|
||||||
/// en: 'Add name'
|
/// en: 'Add name'
|
||||||
String get addName => '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'
|
/// en: 'Notes'
|
||||||
String get notes => 'Notes';
|
String get notes => 'Notes';
|
||||||
|
|
||||||
|
|
@ -757,6 +769,10 @@ extension on Translations {
|
||||||
'detail.noLots' => 'No lots yet.',
|
'detail.noLots' => 'No lots yet.',
|
||||||
'detail.names' => 'Also known as',
|
'detail.names' => 'Also known as',
|
||||||
'detail.addName' => 'Add name',
|
'detail.addName' => 'Add name',
|
||||||
|
'detail.links' => 'Links',
|
||||||
|
'detail.addLink' => 'Add link',
|
||||||
|
'detail.linkUrl' => 'URL',
|
||||||
|
'detail.linkTitle' => 'Title (optional)',
|
||||||
'detail.notes' => 'Notes',
|
'detail.notes' => 'Notes',
|
||||||
'detail.addLot' => 'Add lot',
|
'detail.addLot' => 'Add lot',
|
||||||
'detail.editLot' => 'Edit 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 noLots => 'Aún no hay lotes.';
|
||||||
@override String get names => 'También conocida como';
|
@override String get names => 'También conocida como';
|
||||||
@override String get addName => 'Añadir nombre';
|
@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 notes => 'Notas';
|
||||||
@override String get addLot => 'Añadir lote';
|
@override String get addLot => 'Añadir lote';
|
||||||
@override String get editLot => 'Editar lote';
|
@override String get editLot => 'Editar lote';
|
||||||
|
|
@ -542,6 +546,10 @@ extension on TranslationsEs {
|
||||||
'detail.noLots' => 'Aún no hay lotes.',
|
'detail.noLots' => 'Aún no hay lotes.',
|
||||||
'detail.names' => 'También conocida como',
|
'detail.names' => 'También conocida como',
|
||||||
'detail.addName' => 'Añadir nombre',
|
'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.notes' => 'Notas',
|
||||||
'detail.addLot' => 'Añadir lote',
|
'detail.addLot' => 'Añadir lote',
|
||||||
'detail.editLot' => 'Editar lote',
|
'detail.editLot' => 'Editar lote',
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,12 @@ class VarietyDetailCubit extends Cubit<VarietyDetailState> {
|
||||||
Future<void> removePhoto(String attachmentId) =>
|
Future<void> removePhoto(String attachmentId) =>
|
||||||
_repo.removePhoto(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) =>
|
Future<void> linkSpecies(String speciesId) =>
|
||||||
_repo.linkSpecies(varietyId, speciesId);
|
_repo.linkSpecies(varietyId, speciesId);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
import '../data/species_repository.dart';
|
import '../data/species_repository.dart';
|
||||||
import '../data/variety_repository.dart';
|
import '../data/variety_repository.dart';
|
||||||
|
|
@ -106,6 +107,30 @@ class _DetailView extends StatelessWidget {
|
||||||
Text(detail.notes!),
|
Text(detail.notes!),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 16),
|
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(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
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) {
|
String? _nullIfBlank(String value) {
|
||||||
final trimmed = value.trim();
|
final trimmed = value.trim();
|
||||||
return trimmed.isEmpty ? null : trimmed;
|
return trimmed.isEmpty ? null : trimmed;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
#include <file_selector_linux/file_selector_plugin.h>
|
#include <file_selector_linux/file_selector_plugin.h>
|
||||||
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||||
#include <sqlcipher_flutter_libs/sqlite3_flutter_libs_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) {
|
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
|
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
|
||||||
|
|
@ -20,4 +21,7 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
g_autoptr(FlPluginRegistrar) sqlcipher_flutter_libs_registrar =
|
g_autoptr(FlPluginRegistrar) sqlcipher_flutter_libs_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin");
|
||||||
sqlite3_flutter_libs_plugin_register_with_registrar(sqlcipher_flutter_libs_registrar);
|
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
|
file_selector_linux
|
||||||
flutter_secure_storage_linux
|
flutter_secure_storage_linux
|
||||||
sqlcipher_flutter_libs
|
sqlcipher_flutter_libs
|
||||||
|
url_launcher_linux
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ dependencies:
|
||||||
|
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
material_symbols_icons: ^4.2951.0
|
material_symbols_icons: ^4.2951.0
|
||||||
|
url_launcher: ^6.3.2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
|
|
@ -212,4 +212,20 @@ void main() {
|
||||||
|
|
||||||
await queue.cancel();
|
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);
|
expect(find.text('Maíz'), findsNothing);
|
||||||
await disposeTree(tester);
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
64
pubspec.lock
64
pubspec.lock
|
|
@ -1041,6 +1041,70 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
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:
|
uuid:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue