summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/main.dart30
-rw-r--r--lib/screens/entry_browser_screen.dart170
-rw-r--r--lib/screens/home_screen.dart230
-rw-r--r--lib/screens/preferences_screen.dart96
-rw-r--r--lib/services/log_service.dart57
-rw-r--r--lib/services/preferences.dart30
-rw-r--r--lib/services/share_service.dart25
-rw-r--r--lib/services/shared_text_handler.dart57
-rw-r--r--lib/services/storage.dart14
9 files changed, 709 insertions, 0 deletions
diff --git a/lib/main.dart b/lib/main.dart
new file mode 100644
index 0000000..440f995
--- /dev/null
+++ b/lib/main.dart
@@ -0,0 +1,30 @@
+import 'package:flutter/material.dart';
+
+import 'screens/home_screen.dart';
+
+void main() {
+ runApp(const QuickLoggerApp());
+}
+
+class QuickLoggerApp extends StatelessWidget {
+ const QuickLoggerApp({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return MaterialApp(
+ title: 'Quicklog',
+ theme: ThemeData(
+ colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
+ useMaterial3: true,
+ ),
+ darkTheme: ThemeData(
+ colorScheme: ColorScheme.fromSeed(
+ seedColor: Colors.indigo,
+ brightness: Brightness.dark,
+ ),
+ useMaterial3: true,
+ ),
+ home: const HomeScreen(),
+ );
+ }
+}
diff --git a/lib/screens/entry_browser_screen.dart b/lib/screens/entry_browser_screen.dart
new file mode 100644
index 0000000..5ffa707
--- /dev/null
+++ b/lib/screens/entry_browser_screen.dart
@@ -0,0 +1,170 @@
+import 'dart:io';
+
+import 'package:flutter/material.dart';
+import 'package:intl/intl.dart';
+import 'package:path/path.dart' as p;
+
+import '../services/log_service.dart';
+import '../services/preferences.dart';
+
+final _displayFormat = DateFormat('yyyy-MM-dd HH:mm:ss');
+
+class EntryBrowserScreen extends StatefulWidget {
+ const EntryBrowserScreen({super.key});
+
+ @override
+ State<EntryBrowserScreen> createState() => _EntryBrowserScreenState();
+}
+
+class _EntryBrowserScreenState extends State<EntryBrowserScreen> {
+ final PreferencesService _prefs = PreferencesService();
+ Future<List<LogEntry>>? _future;
+ String _dir = '';
+
+ @override
+ void initState() {
+ super.initState();
+ _refresh();
+ }
+
+ void _refresh() {
+ setState(() {
+ _future = _load();
+ });
+ }
+
+ Future<List<LogEntry>> _load() async {
+ _dir = await _prefs.directory();
+ return listEntries(_dir);
+ }
+
+ Future<void> _open(LogEntry entry) async {
+ final content = await entry.file.readAsString();
+ if (!mounted) return;
+ await Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => _EntryDetailScreen(entry: entry, content: content),
+ ),
+ );
+ }
+
+ Future<void> _confirmDelete(LogEntry entry) async {
+ final ok = await showDialog<bool>(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ title: const Text('Delete entry?'),
+ content: Text(p.basename(entry.file.path)),
+ actions: [
+ TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')),
+ FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('Delete')),
+ ],
+ ),
+ );
+ if (ok == true) {
+ await deleteEntry(entry.file);
+ _refresh();
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('Entries'),
+ actions: [
+ IconButton(
+ tooltip: 'Refresh',
+ icon: const Icon(Icons.refresh),
+ onPressed: _refresh,
+ ),
+ ],
+ ),
+ body: FutureBuilder<List<LogEntry>>(
+ future: _future,
+ builder: (ctx, snap) {
+ if (snap.connectionState != ConnectionState.done) {
+ return const Center(child: CircularProgressIndicator());
+ }
+ if (snap.hasError) {
+ return Center(child: Text('Error: ${snap.error}'));
+ }
+ final entries = snap.data ?? const [];
+ if (entries.isEmpty) {
+ return Center(
+ child: Padding(
+ padding: const EdgeInsets.all(24),
+ child: Text(
+ 'No entries in $_dir',
+ textAlign: TextAlign.center,
+ ),
+ ),
+ );
+ }
+ return RefreshIndicator(
+ onRefresh: () async => _refresh(),
+ child: ListView.separated(
+ itemCount: entries.length,
+ separatorBuilder: (_, _) => const Divider(height: 1),
+ itemBuilder: (_, i) => _EntryTile(
+ entry: entries[i],
+ onTap: () => _open(entries[i]),
+ onLongPress: () => _confirmDelete(entries[i]),
+ ),
+ ),
+ );
+ },
+ ),
+ );
+ }
+}
+
+class _EntryTile extends StatelessWidget {
+ const _EntryTile({required this.entry, required this.onTap, required this.onLongPress});
+ final LogEntry entry;
+ final VoidCallback onTap;
+ final VoidCallback onLongPress;
+
+ @override
+ Widget build(BuildContext context) {
+ return ListTile(
+ title: Text(_displayFormat.format(entry.timestamp)),
+ subtitle: FutureBuilder<String>(
+ future: _firstLine(entry.file),
+ builder: (_, snap) => Text(
+ snap.data ?? '',
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ onTap: onTap,
+ onLongPress: onLongPress,
+ );
+ }
+
+ Future<String> _firstLine(File f) async {
+ try {
+ final content = await f.readAsString();
+ final i = content.indexOf('\n');
+ return i < 0 ? content : content.substring(0, i);
+ } catch (_) {
+ return '';
+ }
+ }
+}
+
+class _EntryDetailScreen extends StatelessWidget {
+ const _EntryDetailScreen({required this.entry, required this.content});
+ final LogEntry entry;
+ final String content;
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(title: Text(p.basename(entry.file.path))),
+ body: SingleChildScrollView(
+ padding: const EdgeInsets.all(12),
+ child: SelectableText(content),
+ ),
+ );
+ }
+}
diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart
new file mode 100644
index 0000000..86dd4d0
--- /dev/null
+++ b/lib/screens/home_screen.dart
@@ -0,0 +1,230 @@
+import 'dart:io';
+
+import 'package:flutter/material.dart';
+
+import '../services/log_service.dart';
+import '../services/preferences.dart';
+import '../services/share_service.dart';
+import '../services/shared_text_handler.dart';
+import 'entry_browser_screen.dart';
+import 'preferences_screen.dart';
+
+const int kMaxTextLength = 5000;
+
+class HomeScreen extends StatefulWidget {
+ const HomeScreen({super.key});
+
+ @override
+ State<HomeScreen> createState() => _HomeScreenState();
+}
+
+class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
+ final TextEditingController _controller = TextEditingController();
+ final FocusNode _focusNode = FocusNode();
+ final PreferencesService _prefs = PreferencesService();
+ bool _warnShown = false;
+ bool _loadingShared = false;
+
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addObserver(this);
+ _controller.addListener(_onTextChanged);
+ if (Platform.isAndroid) {
+ WidgetsBinding.instance.addPostFrameCallback((_) => _loadSharedText());
+ }
+ }
+
+ @override
+ void dispose() {
+ WidgetsBinding.instance.removeObserver(this);
+ _controller.removeListener(_onTextChanged);
+ _controller.dispose();
+ _focusNode.dispose();
+ super.dispose();
+ }
+
+ @override
+ void didChangeAppLifecycleState(AppLifecycleState state) {
+ if (state == AppLifecycleState.resumed && Platform.isAndroid) {
+ _loadSharedText();
+ }
+ }
+
+ void _onTextChanged() {
+ final length = _controller.text.length;
+ if (_loadingShared) {
+ _warnShown = false;
+ setState(() {});
+ return;
+ }
+ if (length > kMaxTextLength && !_warnShown) {
+ _warnShown = true;
+ _showLengthWarning(length);
+ } else if (length <= kMaxTextLength) {
+ _warnShown = false;
+ }
+ setState(() {});
+ }
+
+ void _showLengthWarning(int length) {
+ showDialog<void>(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ title: const Text('Text Limit'),
+ content: Text(
+ 'Text is getting long ($length chars). Consider logging to avoid '
+ 'performance issues.',
+ ),
+ actions: [
+ TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('OK')),
+ ],
+ ),
+ );
+ }
+
+ void _resetInput() {
+ _controller.clear();
+ _warnShown = false;
+ setState(() {});
+ }
+
+ Future<void> _logText() async {
+ final dir = await _prefs.directory();
+ try {
+ await logEntry(dir, _controller.text);
+ _resetInput();
+ } catch (e) {
+ _showError(e);
+ }
+ }
+
+ void _showError(Object error) {
+ if (!mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('Error: $error'), backgroundColor: Colors.red),
+ );
+ }
+
+ void _showInfo(String title, String message) {
+ if (!mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
+ }
+
+ Future<void> _loadSharedText() async {
+ final txt = await ShareService.readSharedTextFromCache();
+ if (txt == null || txt.isEmpty) return;
+ _loadingShared = true;
+ final dir = await _prefs.directory();
+ final autoLog = await _prefs.autoLogSharedText();
+ await handleSharedTextLoad(
+ text: txt,
+ autoLog: autoLog,
+ dir: dir,
+ prefill: (s) {
+ _controller.text = s;
+ _controller.selection = TextSelection.collapsed(offset: s.length);
+ },
+ focus: () => _focusNode.requestFocus(),
+ resetInput: _resetInput,
+ clearCache: ShareService.clearSharedTextCache,
+ logFn: (d, t) async {
+ await logEntry(d, t);
+ },
+ showInfo: _showInfo,
+ showError: _showError,
+ );
+ _loadingShared = false;
+ if (mounted) setState(() {});
+ }
+
+ Future<void> _openPreferences() async {
+ await Navigator.of(context).push(
+ MaterialPageRoute(builder: (_) => const PreferencesScreen()),
+ );
+ }
+
+ Future<void> _openEntryBrowser() async {
+ await Navigator.of(context).push(
+ MaterialPageRoute(builder: (_) => const EntryBrowserScreen()),
+ );
+ }
+
+ void _showAbout() {
+ showAboutDialog(
+ context: context,
+ applicationName: 'Quicklog',
+ applicationVersion: '0.1.2',
+ applicationIcon: Image.asset('logo-small.png', width: 48, height: 48),
+ applicationLegalese: 'Jot timestamped markdown notes.',
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final length = _controller.text.length;
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('Quicklog'),
+ actions: [
+ IconButton(
+ tooltip: 'Browse entries',
+ icon: const Icon(Icons.list),
+ onPressed: _openEntryBrowser,
+ ),
+ IconButton(
+ tooltip: 'Preferences',
+ icon: const Icon(Icons.settings),
+ onPressed: _openPreferences,
+ ),
+ IconButton(
+ tooltip: 'About',
+ icon: const Icon(Icons.info_outline),
+ onPressed: _showAbout,
+ ),
+ ],
+ ),
+ body: Padding(
+ padding: const EdgeInsets.all(12),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Expanded(
+ child: TextField(
+ controller: _controller,
+ focusNode: _focusNode,
+ maxLines: null,
+ expands: true,
+ textAlignVertical: TextAlignVertical.top,
+ decoration: const InputDecoration(
+ hintText: 'Enter text here...',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ ),
+ const SizedBox(height: 8),
+ Row(
+ children: [
+ FilledButton.icon(
+ onPressed: _logText,
+ icon: const Icon(Icons.save),
+ label: const Text('Log text'),
+ ),
+ const SizedBox(width: 8),
+ OutlinedButton(
+ onPressed: () {
+ _resetInput();
+ _focusNode.requestFocus();
+ },
+ child: const Text('Clear'),
+ ),
+ const Spacer(),
+ Text('$length chars'),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/screens/preferences_screen.dart b/lib/screens/preferences_screen.dart
new file mode 100644
index 0000000..842b6cc
--- /dev/null
+++ b/lib/screens/preferences_screen.dart
@@ -0,0 +1,96 @@
+import 'package:flutter/material.dart';
+
+import '../services/preferences.dart';
+import '../services/storage.dart';
+
+class PreferencesScreen extends StatefulWidget {
+ const PreferencesScreen({super.key});
+
+ @override
+ State<PreferencesScreen> createState() => _PreferencesScreenState();
+}
+
+class _PreferencesScreenState extends State<PreferencesScreen> {
+ final PreferencesService _prefs = PreferencesService();
+ final TextEditingController _dirController = TextEditingController();
+ bool _autoLog = false;
+ bool _loaded = false;
+
+ @override
+ void initState() {
+ super.initState();
+ _load();
+ }
+
+ Future<void> _load() async {
+ _dirController.text = await _prefs.directory();
+ _autoLog = await _prefs.autoLogSharedText();
+ if (!mounted) return;
+ setState(() => _loaded = true);
+ }
+
+ Future<void> _resetToDefault() async {
+ _dirController.text = await defaultLogDirectory();
+ setState(() {});
+ }
+
+ Future<void> _save() async {
+ await _prefs.setDirectory(_dirController.text);
+ await _prefs.setAutoLogSharedText(_autoLog);
+ if (!mounted) return;
+ Navigator.of(context).pop();
+ }
+
+ @override
+ void dispose() {
+ _dirController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ if (!_loaded) {
+ return const Scaffold(body: Center(child: CircularProgressIndicator()));
+ }
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('Preferences'),
+ actions: [
+ IconButton(
+ tooltip: 'Save',
+ icon: const Icon(Icons.check),
+ onPressed: _save,
+ ),
+ ],
+ ),
+ body: ListView(
+ padding: const EdgeInsets.all(12),
+ children: [
+ const Text('Directory:', style: TextStyle(fontWeight: FontWeight.bold)),
+ const SizedBox(height: 4),
+ TextField(
+ controller: _dirController,
+ decoration: InputDecoration(
+ border: const OutlineInputBorder(),
+ suffixIcon: IconButton(
+ tooltip: 'Reset to default',
+ icon: const Icon(Icons.restore),
+ onPressed: _resetToDefault,
+ ),
+ ),
+ ),
+ const SizedBox(height: 16),
+ SwitchListTile(
+ title: const Text('Auto-log shared text'),
+ subtitle: const Text(
+ 'When enabled, text shared from other apps is logged immediately '
+ 'instead of prefilled into the editor.',
+ ),
+ value: _autoLog,
+ onChanged: (v) => setState(() => _autoLog = v),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/services/log_service.dart b/lib/services/log_service.dart
new file mode 100644
index 0000000..f2f662c
--- /dev/null
+++ b/lib/services/log_service.dart
@@ -0,0 +1,57 @@
+import 'dart:io';
+
+import 'package:intl/intl.dart';
+import 'package:path/path.dart' as p;
+
+class LogEntry {
+ LogEntry({required this.file, required this.timestamp});
+ final File file;
+ final DateTime timestamp;
+}
+
+final _filenameRegex = RegExp(r'^ql-(\d{6})-(\d{6})\.md$');
+final _timestampFormat = DateFormat('yyMMdd-HHmmss');
+
+Future<File> logEntry(String dir, String text, {DateTime? now}) async {
+ final stamp = _timestampFormat.format(now ?? DateTime.now());
+ final file = File(p.join(dir, 'ql-$stamp.md'));
+ await file.writeAsString(text);
+ return file;
+}
+
+Future<List<LogEntry>> listEntries(String dir) async {
+ final directory = Directory(dir);
+ if (!await directory.exists()) return const [];
+ final entries = <LogEntry>[];
+ await for (final entity in directory.list(followLinks: false)) {
+ if (entity is! File) continue;
+ final ts = _parseFilename(p.basename(entity.path));
+ if (ts == null) continue;
+ entries.add(LogEntry(file: entity, timestamp: ts));
+ }
+ entries.sort((a, b) => b.timestamp.compareTo(a.timestamp));
+ return entries;
+}
+
+Future<void> deleteEntry(File f) async {
+ if (await f.exists()) await f.delete();
+}
+
+DateTime? _parseFilename(String name) {
+ final m = _filenameRegex.firstMatch(name);
+ if (m == null) return null;
+ final d = m.group(1)!; // YYMMDD
+ final t = m.group(2)!; // HHMMSS
+ try {
+ return DateTime(
+ 2000 + int.parse(d.substring(0, 2)),
+ int.parse(d.substring(2, 4)),
+ int.parse(d.substring(4, 6)),
+ int.parse(t.substring(0, 2)),
+ int.parse(t.substring(2, 4)),
+ int.parse(t.substring(4, 6)),
+ );
+ } catch (_) {
+ return null;
+ }
+}
diff --git a/lib/services/preferences.dart b/lib/services/preferences.dart
new file mode 100644
index 0000000..dbe261a
--- /dev/null
+++ b/lib/services/preferences.dart
@@ -0,0 +1,30 @@
+import 'package:shared_preferences/shared_preferences.dart';
+
+import 'storage.dart';
+
+const _kDirectory = 'Directory';
+const _kAutoLogSharedText = 'AutoLogSharedText';
+
+class PreferencesService {
+ Future<String> directory() async {
+ final prefs = await SharedPreferences.getInstance();
+ final stored = prefs.getString(_kDirectory);
+ if (stored != null && stored.isNotEmpty) return stored;
+ return defaultLogDirectory();
+ }
+
+ Future<bool> autoLogSharedText() async {
+ final prefs = await SharedPreferences.getInstance();
+ return prefs.getBool(_kAutoLogSharedText) ?? false;
+ }
+
+ Future<void> setDirectory(String value) async {
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setString(_kDirectory, value);
+ }
+
+ Future<void> setAutoLogSharedText(bool value) async {
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setBool(_kAutoLogSharedText, value);
+ }
+}
diff --git a/lib/services/share_service.dart b/lib/services/share_service.dart
new file mode 100644
index 0000000..36eb7af
--- /dev/null
+++ b/lib/services/share_service.dart
@@ -0,0 +1,25 @@
+import 'dart:io' show Platform;
+
+import 'package:flutter/services.dart';
+
+class ShareService {
+ static const _channel = MethodChannel('org.buetow.quicklog/share');
+
+ static Future<String?> readSharedTextFromCache() async {
+ if (!Platform.isAndroid) return null;
+ try {
+ return await _channel.invokeMethod<String>('readSharedTextFromCache');
+ } on MissingPluginException {
+ return null;
+ }
+ }
+
+ static Future<void> clearSharedTextCache() async {
+ if (!Platform.isAndroid) return;
+ try {
+ await _channel.invokeMethod<void>('clearSharedTextCache');
+ } on MissingPluginException {
+ // No-op: channel not registered (e.g. running on a non-Android target).
+ }
+ }
+}
diff --git a/lib/services/shared_text_handler.dart b/lib/services/shared_text_handler.dart
new file mode 100644
index 0000000..c8e99a4
--- /dev/null
+++ b/lib/services/shared_text_handler.dart
@@ -0,0 +1,57 @@
+enum SharedTextLoadMode { prefill, autoLog }
+
+class SharedTextDecision {
+ const SharedTextDecision({required this.mode, required this.text, required this.proceed});
+ final SharedTextLoadMode mode;
+ final String text;
+ final bool proceed;
+}
+
+SharedTextDecision prepareSharedTextLoad(String text, bool autoLog) {
+ if (text.trim().isEmpty) {
+ return const SharedTextDecision(mode: SharedTextLoadMode.prefill, text: '', proceed: false);
+ }
+ return SharedTextDecision(
+ mode: autoLog ? SharedTextLoadMode.autoLog : SharedTextLoadMode.prefill,
+ text: text,
+ proceed: true,
+ );
+}
+
+typedef LogFn = Future<void> Function(String dir, String text);
+typedef ShowInfo = void Function(String title, String message);
+typedef ShowError = void Function(Object error);
+
+Future<void> handleSharedTextLoad({
+ required String text,
+ required bool autoLog,
+ required String dir,
+ required void Function(String) prefill,
+ required void Function() focus,
+ required void Function() resetInput,
+ required Future<void> Function() clearCache,
+ required LogFn logFn,
+ required ShowInfo showInfo,
+ required ShowError showError,
+}) async {
+ final decision = prepareSharedTextLoad(text, autoLog);
+ if (!decision.proceed) {
+ await clearCache();
+ return;
+ }
+ if (decision.mode == SharedTextLoadMode.autoLog) {
+ try {
+ await logFn(dir, decision.text);
+ } catch (e) {
+ showError(e);
+ return;
+ }
+ showInfo('Logged', 'Shared text has been logged.');
+ resetInput();
+ await clearCache();
+ return;
+ }
+ prefill(decision.text);
+ focus();
+ await clearCache();
+}
diff --git a/lib/services/storage.dart b/lib/services/storage.dart
new file mode 100644
index 0000000..e5a0f50
--- /dev/null
+++ b/lib/services/storage.dart
@@ -0,0 +1,14 @@
+import 'dart:io' show Directory, Platform;
+
+import 'package:path_provider/path_provider.dart';
+
+const String defaultLinuxDirectory = '.';
+
+Future<String> defaultLogDirectory() async {
+ if (Platform.isAndroid) {
+ final dir = await getExternalStorageDirectory();
+ if (dir != null) return dir.path;
+ return (await getApplicationDocumentsDirectory()).path;
+ }
+ return Directory.current.path;
+}