diff options
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/main.dart | 30 | ||||
| -rw-r--r-- | lib/screens/entry_browser_screen.dart | 170 | ||||
| -rw-r--r-- | lib/screens/home_screen.dart | 230 | ||||
| -rw-r--r-- | lib/screens/preferences_screen.dart | 96 | ||||
| -rw-r--r-- | lib/services/log_service.dart | 57 | ||||
| -rw-r--r-- | lib/services/preferences.dart | 30 | ||||
| -rw-r--r-- | lib/services/share_service.dart | 25 | ||||
| -rw-r--r-- | lib/services/shared_text_handler.dart | 57 | ||||
| -rw-r--r-- | lib/services/storage.dart | 14 |
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; +} |
