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

@ -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;