467 lines
13 KiB
Dart
467 lines
13 KiB
Dart
|
|
import 'package:file_picker/file_picker.dart';
|
||
|
|
import 'package:flutter/material.dart';
|
||
|
|
import 'package:path/path.dart' as p;
|
||
|
|
import '../../models/settings.dart';
|
||
|
|
import '../../models/slide.dart';
|
||
|
|
import '../../services/file_service.dart';
|
||
|
|
import '../../theme/app_theme.dart';
|
||
|
|
import '../slides/slide_preview.dart';
|
||
|
|
|
||
|
|
/// Dialog that scans a directory for other Marp presentations, lets the user
|
||
|
|
/// search across them and pick individual slides to import. Returns the
|
||
|
|
/// selected slides (with image paths resolved to absolute paths) or null when
|
||
|
|
/// the dialog was cancelled.
|
||
|
|
class ImportSlidesDialog extends StatefulWidget {
|
||
|
|
final FileService fileService;
|
||
|
|
final String? initialDirectory;
|
||
|
|
final String? excludePath;
|
||
|
|
|
||
|
|
const ImportSlidesDialog({
|
||
|
|
super.key,
|
||
|
|
required this.fileService,
|
||
|
|
required this.initialDirectory,
|
||
|
|
this.excludePath,
|
||
|
|
});
|
||
|
|
|
||
|
|
static Future<List<Slide>?> show(
|
||
|
|
BuildContext context, {
|
||
|
|
required FileService fileService,
|
||
|
|
required String? initialDirectory,
|
||
|
|
String? excludePath,
|
||
|
|
}) {
|
||
|
|
return showDialog<List<Slide>>(
|
||
|
|
context: context,
|
||
|
|
builder: (_) => ImportSlidesDialog(
|
||
|
|
fileService: fileService,
|
||
|
|
initialDirectory: initialDirectory,
|
||
|
|
excludePath: excludePath,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
State<ImportSlidesDialog> createState() => _ImportSlidesDialogState();
|
||
|
|
}
|
||
|
|
|
||
|
|
class _ImportSlidesDialogState extends State<ImportSlidesDialog> {
|
||
|
|
String? _directory;
|
||
|
|
bool _loading = false;
|
||
|
|
List<ScannedPresentation> _presentations = const [];
|
||
|
|
final Set<String> _selectedIds = {};
|
||
|
|
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,
|
||
|
|
excludePath: widget.excludePath,
|
||
|
|
);
|
||
|
|
if (!mounted) return;
|
||
|
|
setState(() {
|
||
|
|
_presentations = results;
|
||
|
|
_loading = false;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> _pickDirectory() async {
|
||
|
|
final result = await FilePicker.getDirectoryPath(
|
||
|
|
dialogTitle: 'Map met presentaties kiezen',
|
||
|
|
initialDirectory: _directory,
|
||
|
|
);
|
||
|
|
if (result != null) {
|
||
|
|
setState(() {
|
||
|
|
_directory = result;
|
||
|
|
_selectedIds.clear();
|
||
|
|
});
|
||
|
|
await _scan();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
String _searchText(Slide slide) {
|
||
|
|
return [
|
||
|
|
slide.title,
|
||
|
|
slide.subtitle,
|
||
|
|
...slide.bullets,
|
||
|
|
slide.quote,
|
||
|
|
slide.quoteAuthor,
|
||
|
|
slide.customMarkdown,
|
||
|
|
slide.type.label,
|
||
|
|
].join(' ').toLowerCase();
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Returns, per presentation, the slides that should be shown for the
|
||
|
|
/// current query (preserving document order).
|
||
|
|
List<(ScannedPresentation, List<Slide>)> _visible() {
|
||
|
|
final q = _query.trim().toLowerCase();
|
||
|
|
final out = <(ScannedPresentation, List<Slide>)>[];
|
||
|
|
for (final pres in _presentations) {
|
||
|
|
if (q.isEmpty) {
|
||
|
|
out.add((pres, pres.deck.slides));
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
final nameMatch =
|
||
|
|
pres.deck.title.toLowerCase().contains(q) ||
|
||
|
|
pres.fileName.toLowerCase().contains(q);
|
||
|
|
if (nameMatch) {
|
||
|
|
out.add((pres, pres.deck.slides));
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
final matching = pres.deck.slides
|
||
|
|
.where((s) => _searchText(s).contains(q))
|
||
|
|
.toList();
|
||
|
|
if (matching.isNotEmpty) out.add((pres, matching));
|
||
|
|
}
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
|
||
|
|
List<Slide> _collectSelected() {
|
||
|
|
final result = <Slide>[];
|
||
|
|
for (final pres in _presentations) {
|
||
|
|
for (final slide in pres.deck.slides) {
|
||
|
|
if (!_selectedIds.contains(slide.id)) continue;
|
||
|
|
final resolved = _resolveImage(slide.imagePath, pres.deck.projectPath);
|
||
|
|
result.add(
|
||
|
|
resolved == slide.imagePath
|
||
|
|
? slide
|
||
|
|
: slide.copyWith(imagePath: resolved),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
String _resolveImage(String imagePath, String? projectPath) {
|
||
|
|
if (imagePath.isEmpty) return imagePath;
|
||
|
|
if (p.isAbsolute(imagePath)) return imagePath;
|
||
|
|
if (projectPath != null) return p.join(projectPath, imagePath);
|
||
|
|
return imagePath;
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
final visible = _visible();
|
||
|
|
final selectedCount = _selectedIds.length;
|
||
|
|
|
||
|
|
return AlertDialog(
|
||
|
|
title: Row(
|
||
|
|
children: [
|
||
|
|
const Icon(Icons.library_add_outlined, size: 20),
|
||
|
|
const SizedBox(width: 8),
|
||
|
|
const Text('Slides importeren'),
|
||
|
|
const Spacer(),
|
||
|
|
if (selectedCount > 0)
|
||
|
|
Text(
|
||
|
|
'$selectedCount geselecteerd',
|
||
|
|
style: const TextStyle(
|
||
|
|
fontSize: 12,
|
||
|
|
color: AppTheme.accent,
|
||
|
|
fontWeight: FontWeight.w600,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
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: [
|
||
|
|
TextButton(
|
||
|
|
onPressed: () => Navigator.pop(context),
|
||
|
|
child: const Text('Annuleren'),
|
||
|
|
),
|
||
|
|
ElevatedButton.icon(
|
||
|
|
onPressed: selectedCount == 0
|
||
|
|
? null
|
||
|
|
: () => Navigator.pop(context, _collectSelected()),
|
||
|
|
icon: const Icon(Icons.download_done, size: 16),
|
||
|
|
label: Text(
|
||
|
|
selectedCount == 0 ? 'Importeren' : 'Importeren ($selectedCount)',
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _toolbar() {
|
||
|
|
return Row(
|
||
|
|
children: [
|
||
|
|
Expanded(
|
||
|
|
child: TextField(
|
||
|
|
autofocus: true,
|
||
|
|
decoration: const InputDecoration(
|
||
|
|
isDense: true,
|
||
|
|
prefixIcon: Icon(Icons.search, size: 18),
|
||
|
|
hintText: 'Zoek op presentatie, titel of tekst…',
|
||
|
|
),
|
||
|
|
onChanged: (v) => setState(() => _query = v),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(width: 8),
|
||
|
|
Tooltip(
|
||
|
|
message: _directory ?? 'Geen map gekozen',
|
||
|
|
child: OutlinedButton.icon(
|
||
|
|
onPressed: _pickDirectory,
|
||
|
|
icon: const Icon(Icons.folder_open_outlined, size: 16),
|
||
|
|
label: Text(
|
||
|
|
_directory == null ? 'Map kiezen' : p.basename(_directory!),
|
||
|
|
overflow: TextOverflow.ellipsis,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _body(List<(ScannedPresentation, List<Slide>)> visible) {
|
||
|
|
if (_loading) {
|
||
|
|
return const Center(child: CircularProgressIndicator());
|
||
|
|
}
|
||
|
|
if (_directory == null) {
|
||
|
|
return _empty(
|
||
|
|
Icons.folder_off_outlined,
|
||
|
|
'Kies een map met presentaties om te beginnen.',
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if (_presentations.isEmpty) {
|
||
|
|
return _empty(
|
||
|
|
Icons.search_off_outlined,
|
||
|
|
'Geen andere presentaties (.md) in deze map gevonden.',
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if (visible.isEmpty) {
|
||
|
|
return _empty(
|
||
|
|
Icons.search_off_outlined,
|
||
|
|
'Geen slides gevonden voor "$_query".',
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return ListView.builder(
|
||
|
|
itemCount: visible.length,
|
||
|
|
itemBuilder: (_, i) {
|
||
|
|
final (pres, slides) = visible[i];
|
||
|
|
return _PresentationSection(
|
||
|
|
presentation: pres,
|
||
|
|
slides: slides,
|
||
|
|
selectedIds: _selectedIds,
|
||
|
|
onToggle: (slide) => setState(() {
|
||
|
|
if (!_selectedIds.remove(slide.id)) _selectedIds.add(slide.id);
|
||
|
|
}),
|
||
|
|
onToggleAll: (sel) => setState(() {
|
||
|
|
for (final s in slides) {
|
||
|
|
if (sel) {
|
||
|
|
_selectedIds.add(s.id);
|
||
|
|
} else {
|
||
|
|
_selectedIds.remove(s.id);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
},
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
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),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── One presentation with its (filtered) slides ──────────────────────────────
|
||
|
|
|
||
|
|
class _PresentationSection extends StatelessWidget {
|
||
|
|
final ScannedPresentation presentation;
|
||
|
|
final List<Slide> slides;
|
||
|
|
final Set<String> selectedIds;
|
||
|
|
final ValueChanged<Slide> onToggle;
|
||
|
|
final ValueChanged<bool> onToggleAll;
|
||
|
|
|
||
|
|
const _PresentationSection({
|
||
|
|
required this.presentation,
|
||
|
|
required this.slides,
|
||
|
|
required this.selectedIds,
|
||
|
|
required this.onToggle,
|
||
|
|
required this.onToggleAll,
|
||
|
|
});
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
final allSelected =
|
||
|
|
slides.isNotEmpty && slides.every((s) => selectedIds.contains(s.id));
|
||
|
|
final deck = presentation.deck;
|
||
|
|
|
||
|
|
return Padding(
|
||
|
|
padding: const EdgeInsets.only(bottom: 18),
|
||
|
|
child: Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
Row(
|
||
|
|
children: [
|
||
|
|
const Icon(
|
||
|
|
Icons.slideshow_outlined,
|
||
|
|
size: 16,
|
||
|
|
color: AppTheme.navy,
|
||
|
|
),
|
||
|
|
const SizedBox(width: 6),
|
||
|
|
Expanded(
|
||
|
|
child: Text.rich(
|
||
|
|
TextSpan(
|
||
|
|
children: [
|
||
|
|
TextSpan(
|
||
|
|
text: deck.title.isEmpty
|
||
|
|
? presentation.fileName
|
||
|
|
: deck.title,
|
||
|
|
style: const TextStyle(
|
||
|
|
fontSize: 13,
|
||
|
|
fontWeight: FontWeight.w700,
|
||
|
|
color: Color(0xFF1E293B),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
TextSpan(
|
||
|
|
text: ' ${presentation.fileName}',
|
||
|
|
style: const TextStyle(
|
||
|
|
fontSize: 11,
|
||
|
|
color: Color(0xFF94A3B8),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
overflow: TextOverflow.ellipsis,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
TextButton(
|
||
|
|
onPressed: () => onToggleAll(!allSelected),
|
||
|
|
style: TextButton.styleFrom(
|
||
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||
|
|
textStyle: const TextStyle(fontSize: 11),
|
||
|
|
),
|
||
|
|
child: Text(
|
||
|
|
allSelected ? 'Deselecteer alles' : 'Selecteer alles',
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
const SizedBox(height: 8),
|
||
|
|
Wrap(
|
||
|
|
spacing: 10,
|
||
|
|
runSpacing: 10,
|
||
|
|
children: [
|
||
|
|
for (final slide in slides)
|
||
|
|
_SlideCard(
|
||
|
|
slide: slide,
|
||
|
|
projectPath: deck.projectPath,
|
||
|
|
themeProfile: deck.themeProfile,
|
||
|
|
selected: selectedIds.contains(slide.id),
|
||
|
|
onTap: () => onToggle(slide),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Selectable slide thumbnail ───────────────────────────────────────────────
|
||
|
|
|
||
|
|
class _SlideCard extends StatelessWidget {
|
||
|
|
final Slide slide;
|
||
|
|
final String? projectPath;
|
||
|
|
final ThemeProfile themeProfile;
|
||
|
|
final bool selected;
|
||
|
|
final VoidCallback onTap;
|
||
|
|
|
||
|
|
const _SlideCard({
|
||
|
|
required this.slide,
|
||
|
|
required this.projectPath,
|
||
|
|
required this.themeProfile,
|
||
|
|
required this.selected,
|
||
|
|
required this.onTap,
|
||
|
|
});
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
return GestureDetector(
|
||
|
|
onTap: onTap,
|
||
|
|
child: SizedBox(
|
||
|
|
width: 168,
|
||
|
|
child: Stack(
|
||
|
|
children: [
|
||
|
|
Container(
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
borderRadius: BorderRadius.circular(6),
|
||
|
|
border: Border.all(
|
||
|
|
color: selected ? AppTheme.accent : const Color(0xFFCBD5E1),
|
||
|
|
width: selected ? 2.5 : 1,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
child: ClipRRect(
|
||
|
|
borderRadius: BorderRadius.circular(5),
|
||
|
|
child: AspectRatio(
|
||
|
|
aspectRatio: 16 / 9,
|
||
|
|
child: SlidePreviewWidget(
|
||
|
|
slide: slide,
|
||
|
|
projectPath: projectPath,
|
||
|
|
themeProfile: themeProfile,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
Positioned(
|
||
|
|
top: 4,
|
||
|
|
right: 4,
|
||
|
|
child: AnimatedOpacity(
|
||
|
|
opacity: selected ? 1 : 0.55,
|
||
|
|
duration: const Duration(milliseconds: 120),
|
||
|
|
child: Container(
|
||
|
|
width: 22,
|
||
|
|
height: 22,
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: selected ? AppTheme.accent : Colors.black38,
|
||
|
|
shape: BoxShape.circle,
|
||
|
|
border: Border.all(color: Colors.white, width: 1.5),
|
||
|
|
),
|
||
|
|
child: Icon(
|
||
|
|
selected ? Icons.check : Icons.add,
|
||
|
|
size: 14,
|
||
|
|
color: Colors.white,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|