431 lines
14 KiB
Dart
431 lines
14 KiB
Dart
import 'package:file_picker/file_picker.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:path/path.dart' as p;
|
|
import '../../models/slide.dart';
|
|
import '../../services/file_service.dart';
|
|
import '../../theme/app_theme.dart';
|
|
import '../../l10n/app_localizations.dart';
|
|
|
|
/// What the open dialog returns: a presentation path and, optionally, the
|
|
/// index of a slide to jump to (when the user picked a search hit).
|
|
class OpenSearchResult {
|
|
final String path;
|
|
final int? slideIndex;
|
|
const OpenSearchResult(this.path, {this.slideIndex});
|
|
}
|
|
|
|
/// Dialog that scans a directory for Marp presentations and lets the user
|
|
/// full-text search across the `.md` files (file name, title and slide text)
|
|
/// before opening one. "Bladeren…" falls back to the native file picker.
|
|
class OpenPresentationDialog extends StatefulWidget {
|
|
final FileService fileService;
|
|
final String? initialDirectory;
|
|
|
|
const OpenPresentationDialog({
|
|
super.key,
|
|
required this.fileService,
|
|
required this.initialDirectory,
|
|
});
|
|
|
|
static Future<OpenSearchResult?> show(
|
|
BuildContext context, {
|
|
required FileService fileService,
|
|
required String? initialDirectory,
|
|
}) {
|
|
return showDialog<OpenSearchResult>(
|
|
context: context,
|
|
builder: (_) => OpenPresentationDialog(
|
|
fileService: fileService,
|
|
initialDirectory: initialDirectory,
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
State<OpenPresentationDialog> createState() => _OpenPresentationDialogState();
|
|
}
|
|
|
|
class _OpenPresentationDialogState extends State<OpenPresentationDialog> {
|
|
String? _directory;
|
|
bool _loading = false;
|
|
List<ScannedPresentation> _presentations = const [];
|
|
String _query = '';
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_directory = widget.initialDirectory;
|
|
if (_directory != null) _scan();
|
|
}
|
|
|
|
Future<void> _scan() async {
|
|
final dir = _directory;
|
|
if (dir == null) return;
|
|
setState(() => _loading = true);
|
|
final results = await widget.fileService.scanPresentations(dir);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_presentations = results;
|
|
_loading = false;
|
|
});
|
|
}
|
|
|
|
Future<void> _pickDirectory() async {
|
|
final result = await FilePicker.getDirectoryPath(
|
|
dialogTitle: context.l10n.d('Map met presentaties kiezen'),
|
|
initialDirectory: _directory,
|
|
);
|
|
if (result != null) {
|
|
setState(() => _directory = result);
|
|
await _scan();
|
|
}
|
|
}
|
|
|
|
Future<void> _browse() async {
|
|
final path = await widget.fileService.pickMarkdownFile(
|
|
initialDirectory: _directory,
|
|
);
|
|
if (path != null && mounted) {
|
|
Navigator.pop(context, OpenSearchResult(path));
|
|
}
|
|
}
|
|
|
|
String _slideText(Slide slide) {
|
|
return [
|
|
slide.title,
|
|
slide.subtitle,
|
|
...slide.bullets,
|
|
slide.quote,
|
|
slide.quoteAuthor,
|
|
slide.customMarkdown,
|
|
slide.imageCaption,
|
|
slide.imageCaption2,
|
|
slide.imagePath,
|
|
slide.imagePath2,
|
|
slide.videoPath,
|
|
slide.audioPath,
|
|
slide.notes,
|
|
].where((s) => s.isNotEmpty).join(' · ');
|
|
}
|
|
|
|
/// A short excerpt of [text] centred on the first occurrence of [query].
|
|
String _snippet(String text, String query) {
|
|
final lower = text.toLowerCase();
|
|
final idx = lower.indexOf(query);
|
|
if (idx < 0) return text.length <= 80 ? text : '${text.substring(0, 80)}…';
|
|
final start = (idx - 24).clamp(0, text.length);
|
|
final end = (idx + query.length + 48).clamp(0, text.length);
|
|
final prefix = start > 0 ? '…' : '';
|
|
final suffix = end < text.length ? '…' : '';
|
|
return '$prefix${text.substring(start, end).trim()}$suffix';
|
|
}
|
|
|
|
/// Per visible presentation: the matching slide hits for the current query
|
|
/// (empty when the match was on the file name / title, or no query).
|
|
List<(ScannedPresentation, List<_SlideHit>)> _visible() {
|
|
final q = _query.trim().toLowerCase();
|
|
final out = <(ScannedPresentation, List<_SlideHit>)>[];
|
|
if (q.isEmpty) {
|
|
for (final pres in _presentations) {
|
|
out.add((pres, const []));
|
|
}
|
|
return out;
|
|
}
|
|
// Multi-word AND: every term must appear somewhere, not necessarily
|
|
// adjacent — maximises what you can find.
|
|
final terms = q.split(RegExp(r'\s+')).where((t) => t.isNotEmpty).toList();
|
|
final first = terms.first;
|
|
bool matchesAll(String hay) => terms.every(hay.contains);
|
|
|
|
for (final pres in _presentations) {
|
|
// A file qualifies on its name/title or anywhere in the raw markdown
|
|
// (front matter, comments, image paths, …) — maximal reach.
|
|
final fileHay =
|
|
'${pres.fileName.toLowerCase()} '
|
|
'${pres.deck.title.toLowerCase()} '
|
|
'${pres.content.toLowerCase()}';
|
|
final fileMatch = matchesAll(fileHay);
|
|
final hits = <_SlideHit>[];
|
|
for (var i = 0; i < pres.deck.slides.length; i++) {
|
|
final text = _slideText(pres.deck.slides[i]);
|
|
if (matchesAll(text.toLowerCase())) {
|
|
hits.add(_SlideHit(i, _snippet(text, first)));
|
|
}
|
|
}
|
|
if (fileMatch || hits.isNotEmpty) out.add((pres, hits));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = context.l10n;
|
|
final visible = _visible();
|
|
|
|
return AlertDialog(
|
|
title: Row(
|
|
children: [
|
|
const Icon(Icons.folder_open_outlined, size: 20),
|
|
const SizedBox(width: 8),
|
|
Text(l10n.d('Presentatie openen')),
|
|
],
|
|
),
|
|
contentPadding: const EdgeInsets.fromLTRB(24, 12, 24, 0),
|
|
content: SizedBox(
|
|
width: 760,
|
|
height: 560,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_toolbar(),
|
|
const SizedBox(height: 12),
|
|
Expanded(child: _body(visible)),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
OutlinedButton.icon(
|
|
onPressed: _browse,
|
|
icon: const Icon(Icons.insert_drive_file_outlined, size: 16),
|
|
label: Text(l10n.d('Bladeren…')),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: Text(l10n.t('cancel')),
|
|
),
|
|
],
|
|
// Knoppen uit elkaar: Bladeren links, Annuleren rechts. (Geen Spacer in
|
|
// de actions — die gaan in een OverflowBar en accepteren geen Expanded.)
|
|
actionsAlignment: MainAxisAlignment.spaceBetween,
|
|
);
|
|
}
|
|
|
|
Widget _toolbar() {
|
|
final l10n = context.l10n;
|
|
return Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
autofocus: true,
|
|
decoration: InputDecoration(
|
|
isDense: true,
|
|
prefixIcon: const Icon(Icons.search, size: 18),
|
|
hintText: l10n.d(
|
|
'Zoek op bestandsnaam, titel of tekst in de slides…',
|
|
),
|
|
),
|
|
onChanged: (v) => setState(() => _query = v),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Tooltip(
|
|
message: _directory ?? l10n.d('Geen map gekozen'),
|
|
child: OutlinedButton.icon(
|
|
onPressed: _pickDirectory,
|
|
icon: const Icon(Icons.folder_outlined, size: 16),
|
|
label: Text(
|
|
_directory == null
|
|
? l10n.d('Map kiezen')
|
|
: p.basename(_directory!),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _body(List<(ScannedPresentation, List<_SlideHit>)> visible) {
|
|
final l10n = context.l10n;
|
|
if (_loading) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
if (_directory == null) {
|
|
return _empty(
|
|
Icons.folder_off_outlined,
|
|
l10n.d('Kies een map met presentaties om te beginnen.'),
|
|
);
|
|
}
|
|
if (_presentations.isEmpty) {
|
|
return _empty(
|
|
Icons.search_off_outlined,
|
|
l10n.d('Geen presentaties (.md) in deze map gevonden.'),
|
|
);
|
|
}
|
|
if (visible.isEmpty) {
|
|
return _empty(
|
|
Icons.search_off_outlined,
|
|
'${l10n.d('Geen presentaties gevonden voor')} "$_query".',
|
|
);
|
|
}
|
|
|
|
return ListView.separated(
|
|
itemCount: visible.length,
|
|
separatorBuilder: (_, _) => const Divider(height: 1),
|
|
itemBuilder: (_, i) {
|
|
final (pres, hits) = visible[i];
|
|
return _PresentationRow(
|
|
presentation: pres,
|
|
hits: hits,
|
|
onOpen: () => Navigator.pop(context, OpenSearchResult(pres.path)),
|
|
onOpenAt: (index) => Navigator.pop(
|
|
context,
|
|
OpenSearchResult(pres.path, slideIndex: index),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _empty(IconData icon, String message) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, size: 40, color: const Color(0xFF94A3B8)),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
message,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(color: Color(0xFF64748B), fontSize: 13),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SlideHit {
|
|
final int index;
|
|
final String snippet;
|
|
const _SlideHit(this.index, this.snippet);
|
|
}
|
|
|
|
class _PresentationRow extends StatelessWidget {
|
|
final ScannedPresentation presentation;
|
|
final List<_SlideHit> hits;
|
|
final VoidCallback onOpen;
|
|
final ValueChanged<int> onOpenAt;
|
|
|
|
const _PresentationRow({
|
|
required this.presentation,
|
|
required this.hits,
|
|
required this.onOpen,
|
|
required this.onOpenAt,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = context.l10n;
|
|
final deck = presentation.deck;
|
|
final title = deck.title.isEmpty ? presentation.fileName : deck.title;
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
InkWell(
|
|
onTap: onOpen,
|
|
borderRadius: BorderRadius.circular(6),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6),
|
|
child: Row(
|
|
children: [
|
|
const Icon(
|
|
Icons.slideshow_outlined,
|
|
size: 18,
|
|
color: AppTheme.navy,
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
title,
|
|
style: const TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w700,
|
|
color: Color(0xFF1E293B),
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
Text(
|
|
'${presentation.fileName} · ${deck.slides.length} ${l10n.t('slides')}',
|
|
style: const TextStyle(
|
|
fontSize: 11,
|
|
color: Color(0xFF94A3B8),
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Icon(Icons.north_east, size: 16, color: Colors.grey.shade500),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
if (hits.isNotEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 34, top: 2, bottom: 2),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
for (final hit in hits.take(4))
|
|
InkWell(
|
|
onTap: () => onOpenAt(hit.index),
|
|
borderRadius: BorderRadius.circular(4),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 4,
|
|
vertical: 3,
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'${l10n.d('Slide')} ${hit.index + 1}',
|
|
style: const TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppTheme.accent,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
hit.snippet,
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
color: Color(0xFF475569),
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
if (hits.length > 4)
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 4, top: 2),
|
|
child: Text(
|
|
'+ ${hits.length - 4} ${l10n.d('meer treffer(s)')}',
|
|
style: const TextStyle(
|
|
fontSize: 11,
|
|
color: Color(0xFF94A3B8),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|