Ocideck/lib/state/tabs_provider.dart

319 lines
11 KiB
Dart
Raw Normal View History

import 'dart:async';
import 'dart:io';
import 'package:flutter_riverpod/legacy.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:uuid/uuid.dart';
import '../services/file_service.dart';
import '../services/markdown_service.dart';
import '../services/recovery_service.dart';
import 'deck_provider.dart';
import 'editor_provider.dart';
import 'settings_provider.dart';
const _uuid = Uuid();
// ── Per-tab data ──────────────────────────────────────────────────────────────
class TabInfo {
final int id;
/// Stabiele sleutel voor het autosave-herstelbestand van dit tabblad.
final String recoveryId;
final DeckNotifier deckNotifier;
final EditorNotifier editorNotifier;
const TabInfo({
required this.id,
required this.recoveryId,
required this.deckNotifier,
required this.editorNotifier,
});
String get label {
final st = deckNotifier.currentState;
// A saved deck is identified by its file name — that is what the user
// recognises, not the parsed first-slide title (which falls back to the
// generic 'Presentatie').
final path = st.filePath;
if (path != null && path.isNotEmpty) {
final name = p.basenameWithoutExtension(path);
if (name.isNotEmpty) return name;
}
final deck = st.deck;
return deck?.title.isNotEmpty == true ? deck!.title : 'Nieuw';
}
bool get isDirty => deckNotifier.currentState.isDirty;
bool get isOpen => deckNotifier.currentState.isOpen;
}
// ── Tabs state ────────────────────────────────────────────────────────────────
class TabsState {
final List<TabInfo> tabs;
final int selectedIndex;
const TabsState({required this.tabs, this.selectedIndex = 0});
int get clampedIndex => selectedIndex.clamp(
0,
(tabs.length - 1).clamp(0, double.maxFinite.toInt()),
);
TabInfo? get current => tabs.isEmpty ? null : tabs[clampedIndex];
bool get anyDirty => tabs.any((t) => t.isDirty);
TabsState copyWith({List<TabInfo>? tabs, int? selectedIndex}) {
return TabsState(
tabs: tabs ?? this.tabs,
selectedIndex: selectedIndex ?? this.selectedIndex,
);
}
}
// ── Tabs notifier ─────────────────────────────────────────────────────────────
class TabsNotifier extends StateNotifier<TabsState> {
final MarkdownService _md;
final FileService _file;
final SettingsNotifier _settings;
final RecoveryService _recovery;
final Map<int, StreamSubscription<DeckState>> _subs = {};
Timer? _autosaveTimer;
int _nextId = 0;
/// Hoe vaak niet-opgeslagen tabbladen naar een herstelbestand worden bewaard.
static const _autosaveInterval = Duration(seconds: 25);
TabsNotifier(this._md, this._file, this._settings, this._recovery)
: super(const TabsState(tabs: [])) {
// Start with one empty tab
final tab = _createTab();
state = TabsState(tabs: [tab], selectedIndex: 0);
_autosaveTimer = Timer.periodic(_autosaveInterval, (_) => _autosaveTick());
}
@override
void dispose() {
_autosaveTimer?.cancel();
for (final sub in _subs.values) {
sub.cancel();
}
// The tabs' notifiers are not disposed here: at teardown the widget tree is
// still unmounting and may read a tab one last time. The process is ending
// anyway. The per-close path (_disposeTab) is what prevents the real leak.
super.dispose();
}
/// Tear down a tab that is being removed: stop listening to it and dispose
/// its notifiers so their listeners and undo/redo history are released. The
/// dispose is deferred to a microtask so any widget still referencing this
/// tab while it unmounts has finished before the notifiers go away.
void _disposeTab(TabInfo tab) {
_subs.remove(tab.id)?.cancel();
final deckNotifier = tab.deckNotifier;
final editorNotifier = tab.editorNotifier;
Future.microtask(() {
deckNotifier.dispose();
editorNotifier.dispose();
});
}
TabInfo _createTab() {
final id = _nextId++;
final recoveryId = _uuid.v4();
final deckNotifier = DeckNotifier(_md, _file);
final tab = TabInfo(
id: id,
recoveryId: recoveryId,
deckNotifier: deckNotifier,
editorNotifier: EditorNotifier(),
);
_subs[id] = deckNotifier.stream.listen((st) {
if (!mounted) return;
// Zodra een tabblad is opgeslagen (schoon), het herstelbestand wissen.
// Schrijven gebeurt gebufferd door de periodieke autosave-tick.
if (!(st.isOpen && st.isDirty)) {
_recovery.discard(recoveryId);
}
state = state.copyWith(tabs: List.from(state.tabs));
});
return tab;
}
/// Bewaar elk niet-opgeslagen tabblad naar zijn herstelbestand.
void _autosaveTick() {
if (!mounted) return;
for (final tab in state.tabs) {
final st = tab.deckNotifier.currentState;
if (st.isOpen && st.isDirty) {
_recovery.save(
RecoverySnapshot(
id: tab.recoveryId,
savedAt: DateTime.now(),
filePath: st.filePath,
label: tab.label,
markdown: tab.deckNotifier.generateMarkdown(),
),
);
}
}
}
/// Open elke teruggehaalde snapshot als (gewijzigd) tabblad en ruim de oude
/// herstelbestanden op. Aangeroepen vanuit het herstel-dialoog bij opstart.
void restoreRecovered(List<RecoverySnapshot> snapshots) {
final restored = <TabInfo>[];
for (final snap in snapshots) {
final deck = _md.parseDeck(snap.markdown, filePath: snap.filePath);
_recovery.discard(snap.id); // oude sleutel; tab krijgt een nieuwe
if (deck == null) continue;
final tab = _createTab();
tab.deckNotifier.loadDeck(deck, filePath: snap.filePath);
tab.deckNotifier.markDirty(); // herstelde inhoud is nog niet opgeslagen
restored.add(tab);
}
if (restored.isEmpty) return;
// Een ongebruikt leeg begin-tabblad vervangen, anders toevoegen.
final replaceEmpty = state.tabs.length == 1 && !state.tabs.first.isOpen;
if (replaceEmpty) {
_disposeTab(state.tabs.first);
state = state.copyWith(tabs: restored, selectedIndex: 0);
} else {
final tabs = [...state.tabs, ...restored];
state = state.copyWith(tabs: tabs, selectedIndex: state.tabs.length);
}
}
void newEmptyTab() {
final tab = _createTab();
final newTabs = [...state.tabs, tab];
state = state.copyWith(tabs: newTabs, selectedIndex: newTabs.length - 1);
}
void newDeckInCurrentTab(String title) {
final tab = state.current;
if (tab == null) return;
tab.deckNotifier.newDeck(title);
tab.editorNotifier.select(0);
// Force rebuild by copying state (label may have changed)
state = state.copyWith(tabs: List.from(state.tabs));
}
void newDeckInNewTab(String title) {
final tab = _createTab();
tab.deckNotifier.newDeck(title);
tab.editorNotifier.select(0);
final newTabs = [...state.tabs, tab];
state = state.copyWith(tabs: newTabs, selectedIndex: newTabs.length - 1);
}
/// Open a file picker and load the chosen deck.
/// If the current tab is empty, replaces it; otherwise opens a new tab.
Future<void> openFile({String? initialDirectory}) async {
final path = await _file.pickMarkdownFile(
initialDirectory: initialDirectory,
);
if (path == null) return;
final deck = await _file.openDeck(path);
if (deck == null) return;
final current = state.current;
if (current != null && !current.isOpen) {
// Replace the empty current tab
current.deckNotifier.loadDeck(deck, filePath: path);
current.editorNotifier.select(0);
state = state.copyWith(tabs: List.from(state.tabs));
} else {
// Open in a new tab
final tab = _createTab();
tab.deckNotifier.loadDeck(deck, filePath: path);
final newTabs = [...state.tabs, tab];
state = state.copyWith(tabs: newTabs, selectedIndex: newTabs.length - 1);
}
await _settings.addRecentFile(path);
}
Future<void> openFileByPath(String path, {int? selectIndex}) async {
final deck = await _file.openDeck(path);
if (deck == null) return;
final index = (selectIndex ?? 0).clamp(0, deck.slides.length - 1);
final current = state.current;
if (current != null && !current.isOpen) {
current.deckNotifier.loadDeck(deck, filePath: path);
current.editorNotifier.select(index);
state = state.copyWith(tabs: List.from(state.tabs));
} else {
final tab = _createTab();
tab.deckNotifier.loadDeck(deck, filePath: path);
tab.editorNotifier.select(index);
final newTabs = [...state.tabs, tab];
state = state.copyWith(tabs: newTabs, selectedIndex: newTabs.length - 1);
}
await _settings.addRecentFile(path);
}
/// Map waarin geïmporteerde pakketten worden uitgepakt.
Future<String> _importDestDir(String? homeDir) async {
if (homeDir != null && homeDir.trim().isNotEmpty) return homeDir;
return (await getApplicationDocumentsDirectory()).path;
}
/// Importeer een `.ocideck`-pakket (zip) en open het in een tab.
Future<bool> importPackageFile(String zipPath, {String? homeDir}) async {
final dest = await _importDestDir(homeDir);
final bytes = await File(zipPath).readAsBytes();
final mdPath = await _file.importPackageBytes(bytes, dest);
if (mdPath == null) return false;
await openFileByPath(mdPath);
return true;
}
/// Haal een presentatie op via een URL (pakket of platte markdown) en open
/// het in een tab.
Future<bool> importFromUrl(String url, {String? homeDir}) async {
final dest = await _importDestDir(homeDir);
final mdPath = await _file.importFromUrl(url, dest);
if (mdPath == null) return false;
await openFileByPath(mdPath);
return true;
}
void selectTab(int index) {
if (index >= 0 && index < state.tabs.length) {
state = state.copyWith(selectedIndex: index);
}
}
/// Close the tab at [index].
/// If it is the only tab, just clears the deck (welcome screen remains).
void closeTab(int index) {
if (state.tabs.length == 1) {
_recovery.discard(state.tabs.first.recoveryId);
state.tabs.first.deckNotifier.closeDeck();
state = state.copyWith(tabs: List.from(state.tabs));
return;
}
final tab = state.tabs[index];
_recovery.discard(tab.recoveryId);
_disposeTab(tab);
final newTabs = List<TabInfo>.from(state.tabs)..removeAt(index);
final newSelected = index >= newTabs.length ? newTabs.length - 1 : index;
state = state.copyWith(tabs: newTabs, selectedIndex: newSelected);
}
}
// ── Provider ──────────────────────────────────────────────────────────────────
final tabsProvider = StateNotifierProvider<TabsNotifier, TabsState>((ref) {
return TabsNotifier(
ref.read(markdownServiceProvider),
ref.read(fileServiceProvider),
ref.read(settingsProvider.notifier),
ref.read(recoveryServiceProvider),
);
});