Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions packages/playground/lib/app/providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import 'package:hero_ui/hero_ui.dart';
import 'package:provider/provider.dart';
import 'package:superdeck/superdeck.dart';

import '../core/data/data_sources/app_settings_store.dart';
import '../core/data/data_sources/deck_file_store.dart';
import '../core/data/data_sources/memory_asset_cache_store.dart';
import '../core/data/data_sources/memory_deck_loader.dart';
import '../core/domain/stores/deck_customization_store.dart';
Expand All @@ -17,10 +19,21 @@ import '../core/domain/stores/deck_customization_store.dart';
///
/// The editor's `EditorStore` is provided at its route instead. Slides are read
/// straight off `DeckController.slides` (a signal) in the UI — no bridge store.
///
/// [deckFileStore]/[appSettingsStore] default to the native (filesystem)
/// implementations; tests inject in-memory fakes so the editor's file-backed
/// bootstrap runs without touching disk.
class AppProviders extends StatelessWidget {
const AppProviders({required this.child, super.key});
const AppProviders({
required this.child,
this.deckFileStore,
this.appSettingsStore,
super.key,
});

final Widget child;
final DeckFileStore? deckFileStore;
final AppSettingsStore? appSettingsStore;

@override
Widget build(BuildContext context) {
Expand All @@ -35,8 +48,12 @@ class AppProviders extends StatelessWidget {
create: (_) => MemoryDeckLoader(),
dispose: (_, loader) => loader.dispose(),
),
Provider<MemoryAssetCacheStore>(
create: (_) => MemoryAssetCacheStore(),
Provider<MemoryAssetCacheStore>(create: (_) => MemoryAssetCacheStore()),
Provider<DeckFileStore>(
create: (_) => deckFileStore ?? NativeDeckFileStore(),
),
Provider<AppSettingsStore>(
create: (_) => appSettingsStore ?? NativeAppSettingsStore(),
),
Provider<DeckController>(
lazy: false,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/// Entry point for the app settings store: re-exports the interface plus the
/// platform-selected [NativeAppSettingsStore] via conditional import.
library;

export 'app_settings_store_base.dart';
export 'app_settings_store_stub.dart'
if (dart.library.io) 'app_settings_store_io.dart';
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/// Persists the playground's single app-managed setting: the last-opened deck
/// path, so the editor can reopen it on the next launch.
///
/// Kept behind an interface (native impl + web stub) to match the storage
/// pattern used elsewhere in the app.
abstract class AppSettingsStore {
const AppSettingsStore();

/// The absolute path of the last-opened deck, or `null` if none is
/// remembered (first run) or it could not be read.
Future<String?> lastOpenedDeckPath();

/// Remembers [path] as the last-opened deck.
Future<void> setLastOpenedDeckPath(String path);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import 'dart:convert';
import 'dart:io';

import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';

import 'app_settings_store_base.dart';

/// Native [AppSettingsStore] backed by a small JSON file in the app-support
/// directory (`.../superdeck_playground/settings.json`).
///
/// Deliberately dependency-free (no `shared_preferences`) — one file, one key.
class NativeAppSettingsStore extends AppSettingsStore {
NativeAppSettingsStore();

static const _settingsFolder = 'superdeck_playground';
static const _settingsFileName = 'settings.json';
static const _lastOpenedKey = 'lastOpenedDeckPath';

Future<File> _settingsFile() async {
final support = await getApplicationSupportDirectory();
final dir = Directory(p.join(support.path, _settingsFolder));
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return File(p.join(dir.path, _settingsFileName));
}

Future<Map<String, dynamic>> _read() async {
try {
final file = await _settingsFile();
if (!await file.exists()) return {};
final decoded = jsonDecode(await file.readAsString());
return decoded is Map ? Map<String, dynamic>.from(decoded) : {};
} catch (_) {
// Corrupt/unreadable settings are non-fatal: treat as empty.
return {};
}
}

@override
Future<String?> lastOpenedDeckPath() async {
final value = (await _read())[_lastOpenedKey];
return value is String && value.isNotEmpty ? value : null;
}

@override
Future<void> setLastOpenedDeckPath(String path) async {
final settings = await _read();
settings[_lastOpenedKey] = path;
final file = await _settingsFile();
await file.writeAsString(jsonEncode(settings));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import 'app_settings_store_base.dart';

/// Web/unsupported stub for [AppSettingsStore]. Desktop-only today; nothing is
/// persisted.
class NativeAppSettingsStore extends AppSettingsStore {
NativeAppSettingsStore();

@override
Future<String?> lastOpenedDeckPath() async => null;

@override
Future<void> setLastOpenedDeckPath(String path) async {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/// Entry point for the deck file store: re-exports the interface plus the
/// platform-selected [NativeDeckFileStore] via conditional import (native
/// `dart:io` impl, web stub), mirroring `AssetCacheStore`'s pattern.
library;

export 'deck_file_store_base.dart';
export 'deck_file_store_stub.dart'
if (dart.library.io) 'deck_file_store_io.dart';
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/// Storage contract for deck `.md` files owned by the playground editor.
///
/// Mirrors the `DeckLoader` / `AssetCacheStore` interface + native-impl pattern:
/// the editor talks only to this abstraction, and the concrete implementation is
/// selected per platform through a conditional export (`deck_file_store.dart`).
/// Only the native (`dart:io` + `path_provider` + `file_picker`) implementation
/// is built today; the web stub throws.
abstract class DeckFileStore {
const DeckFileStore();

/// Absolute path of the fixed decks folder (`~/Documents/SuperDeck/`),
/// creating it if it does not yet exist.
Future<String> decksDirectoryPath();

/// Whether a file exists at [path].
Future<bool> exists(String path);

/// Reads the file at [path] as UTF-8 text.
///
/// Throws [DeckFileReadException] if the file cannot be read.
Future<String> read(String path);

/// Writes [content] to the file at [path] as UTF-8 text.
Future<void> write(String path, String content);

/// Creates `<name>.md` in the decks folder seeded with [content] and returns
/// its absolute path.
///
/// Throws [DeckNameCollisionException] if a file with that name already
/// exists — callers re-prompt rather than overwrite.
Future<String> createDeck(String name, {required String content});

/// Opens a native file picker filtered to `.md` and returns the chosen
/// absolute path, or `null` if the user cancelled.
Future<String?> pickDeckFile();

/// Emits an event whenever the file at [path] changes on disk (external edit,
/// deletion, or move). Backed by the shared `FileWatcher` (FS events +
/// polling fallback).
Stream<void> watch(String path);
}

/// Thrown by [DeckFileStore.createDeck] when the target name already exists.
class DeckNameCollisionException implements Exception {
const DeckNameCollisionException(this.fileName);

/// The `<name>.md` filename that collided.
final String fileName;

@override
String toString() => 'A deck named "$fileName" already exists.';
}

/// Thrown by [DeckFileStore.read] when a file cannot be read (missing,
/// permissions, invalid encoding).
class DeckFileReadException implements Exception {
const DeckFileReadException(this.path, this.cause);

/// The path that failed to read.
final String path;

/// The underlying error.
final Object cause;

@override
String toString() => 'Could not read deck file "$path": $cause';
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import 'dart:io';

import 'package:file_picker/file_picker.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:superdeck_core/superdeck_core.dart';

import 'deck_file_store_base.dart';

/// Native ([dart:io]) implementation of [DeckFileStore].
///
/// Decks live in `~/Documents/SuperDeck/` (via `path_provider`); `pickDeckFile`
/// can still reach any path on disk.
class NativeDeckFileStore extends DeckFileStore {
NativeDeckFileStore();

/// Fixed decks-folder name under the documents directory.
static const _decksFolderName = 'SuperDeck';

@override
Future<String> decksDirectoryPath() async {
final documents = await getApplicationDocumentsDirectory();
final dir = Directory(p.join(documents.path, _decksFolderName));
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir.path;
}

@override
Future<bool> exists(String path) => File(path).exists();

@override
Future<String> read(String path) async {
try {
return await File(path).readAsString();
} catch (error) {
throw DeckFileReadException(path, error);
}
}

@override
Future<void> write(String path, String content) async {
final file = File(path);
await file.parent.create(recursive: true);
await file.writeAsString(content);
}

@override
Future<String> createDeck(String name, {required String content}) async {
final dir = await decksDirectoryPath();
final fileName = _toMarkdownFileName(name);
final path = p.join(dir, fileName);
final file = File(path);
if (await file.exists()) {
throw DeckNameCollisionException(fileName);
}
await file.writeAsString(content);
return path;
}

@override
Future<String?> pickDeckFile() async {
final result = await FilePicker.platform.pickFiles(
dialogTitle: 'Open deck',
type: FileType.custom,
allowedExtensions: const ['md'],
);
// Treat an empty/oddly-shaped result or a path-less entry as a cancel
// rather than letting `.single` throw a StateError (which openDeck's
// narrow catch would not handle) escape the Open action.
final files = result?.files;
if (files == null || files.isEmpty) return null;
return files.first.path;
}

@override
Stream<void> watch(String path) => FileWatcher(
File(path),
// Watch for edits, deletion, and moves so the controller can react to
// external changes and to the file disappearing.
events:
FileSystemEvent.modify | FileSystemEvent.delete | FileSystemEvent.move,
).watch();

/// Normalises a user-typed deck name into a safe bare `<name>.md` filename:
/// strips any directory components and ensures the `.md` extension.
String _toMarkdownFileName(String name) {
final bare = p.basename(name.trim());
if (bare.isEmpty) {
throw ArgumentError.value(name, 'name', 'Deck name must not be empty');
}
return p.extension(bare).toLowerCase() == '.md' ? bare : '$bare.md';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import 'deck_file_store_base.dart';

/// Web/unsupported stub for [DeckFileStore].
///
/// The playground builds desktop-only today; filesystem persistence is native.
/// This stub keeps the conditional export well-typed on platforms without
/// `dart:io`. An IndexedDB implementation could replace it later (see the
/// `AssetCacheStore` web impl for the pattern).
class NativeDeckFileStore extends DeckFileStore {
NativeDeckFileStore();

Never _unsupported() => throw UnsupportedError(
'Deck filesystem persistence is only available on desktop platforms.',
);

@override
Future<String> decksDirectoryPath() => _unsupported();

@override
Future<bool> exists(String path) => _unsupported();

@override
Future<String> read(String path) => _unsupported();

@override
Future<void> write(String path, String content) => _unsupported();

@override
Future<String> createDeck(String name, {required String content}) =>
_unsupported();

@override
Future<String?> pickDeckFile() => _unsupported();

@override
Stream<void> watch(String path) => _unsupported();
}
Loading
Loading