From 3e4bacb7b939b1de98a2fb07115f638750637357 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Fri, 8 May 2026 00:05:39 +0300 Subject: Rewrite as Flutter app and rename to Quicklog Drop the Go/Fyne implementation and replace it with a native Flutter app targeting Android (primary) and Linux desktop (development). Rename quicklogger -> quicklog throughout (project, applicationId, MethodChannel, cache filename, AppBar title, Linux binary). Storage on Android moves to /Android/data/org.buetow.quicklog/files/ so MANAGE_EXTERNAL_STORAGE is no longer required. Share-intent receive is preserved via a Kotlin ShareActivity that writes shared text to the app cache, which Dart reads through a MethodChannel on app resume; MainActivity also accepts SEND directly so launchers that prefer the main target work. New since the Fyne version: an entry browser screen with read-only viewer and long-press delete. Cross-compiling to Android ARM from amd64 Linux no longer needs Docker / fyne-cross / NDK -- flutter build apk emits ARM binaries directly. Co-Authored-By: Claude Opus 4.7 --- test/home_screen_widget_test.dart | 44 +++++++++++ test/log_service_test.dart | 67 +++++++++++++++++ test/shared_text_handler_test.dart | 147 +++++++++++++++++++++++++++++++++++++ 3 files changed, 258 insertions(+) create mode 100644 test/home_screen_widget_test.dart create mode 100644 test/log_service_test.dart create mode 100644 test/shared_text_handler_test.dart (limited to 'test') diff --git a/test/home_screen_widget_test.dart b/test/home_screen_widget_test.dart new file mode 100644 index 0000000..c05d49b --- /dev/null +++ b/test/home_screen_widget_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:quicklog/screens/home_screen.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('character counter updates as user types', (tester) async { + SharedPreferences.setMockInitialValues({}); + await tester.pumpWidget(const MaterialApp(home: HomeScreen())); + await tester.pumpAndSettle(); + + expect(find.text('0 chars'), findsOneWidget); + + await tester.enterText(find.byType(TextField), 'hello'); + await tester.pump(); + expect(find.text('5 chars'), findsOneWidget); + }); + + testWidgets('Clear button empties the input', (tester) async { + SharedPreferences.setMockInitialValues({}); + await tester.pumpWidget(const MaterialApp(home: HomeScreen())); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'something'); + await tester.pump(); + expect(find.text('9 chars'), findsOneWidget); + + await tester.tap(find.text('Clear')); + await tester.pump(); + expect(find.text('0 chars'), findsOneWidget); + }); + + testWidgets('Log text button is rendered and enabled', (tester) async { + SharedPreferences.setMockInitialValues({}); + await tester.pumpWidget(const MaterialApp(home: HomeScreen())); + await tester.pumpAndSettle(); + + final btn = find.widgetWithText(FilledButton, 'Log text'); + expect(btn, findsOneWidget); + expect(tester.widget(btn).enabled, isTrue); + }); +} diff --git a/test/log_service_test.dart b/test/log_service_test.dart new file mode 100644 index 0000000..969d1f8 --- /dev/null +++ b/test/log_service_test.dart @@ -0,0 +1,67 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:quicklog/services/log_service.dart'; + +void main() { + group('logEntry', () { + late Directory tmp; + + setUp(() async { + tmp = await Directory.systemTemp.createTemp('ql-test-'); + }); + + tearDown(() async { + if (await tmp.exists()) await tmp.delete(recursive: true); + }); + + test('writes a file with ql-YYMMDD-HHMMSS.md filename pattern', () async { + final f = await logEntry(tmp.path, 'hello'); + expect(p.basename(f.path), matches(RegExp(r'^ql-\d{6}-\d{6}\.md$'))); + expect(await f.readAsString(), 'hello'); + }); + + test('preserves arbitrary content including unicode and newlines', () async { + const text = 'line 1\nläine 2\n第三行'; + final f = await logEntry(tmp.path, text); + expect(await f.readAsString(), text); + }); + + test('uses the provided timestamp when given', () async { + final ts = DateTime(2026, 5, 7, 14, 30, 45); + final f = await logEntry(tmp.path, 'x', now: ts); + expect(p.basename(f.path), 'ql-260507-143045.md'); + }); + + test('throws on a non-existent directory', () async { + final bad = p.join(tmp.path, 'does', 'not', 'exist'); + expect(() => logEntry(bad, 'x'), throwsA(isA())); + }); + }); + + group('listEntries', () { + late Directory tmp; + setUp(() async => tmp = await Directory.systemTemp.createTemp('ql-list-')); + tearDown(() async { + if (await tmp.exists()) await tmp.delete(recursive: true); + }); + + test('returns entries sorted newest first and skips malformed names', () async { + await File(p.join(tmp.path, 'ql-260101-000000.md')).writeAsString('a'); + await File(p.join(tmp.path, 'ql-260102-000000.md')).writeAsString('b'); + await File(p.join(tmp.path, 'random.md')).writeAsString('skip'); + await File(p.join(tmp.path, 'ql-bad-format.md')).writeAsString('skip'); + + final entries = await listEntries(tmp.path); + expect(entries.length, 2); + expect(p.basename(entries[0].file.path), 'ql-260102-000000.md'); + expect(p.basename(entries[1].file.path), 'ql-260101-000000.md'); + }); + + test('returns empty list for missing directory', () async { + final entries = await listEntries(p.join(tmp.path, 'nope')); + expect(entries, isEmpty); + }); + }); +} diff --git a/test/shared_text_handler_test.dart b/test/shared_text_handler_test.dart new file mode 100644 index 0000000..e0940e3 --- /dev/null +++ b/test/shared_text_handler_test.dart @@ -0,0 +1,147 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:quicklog/services/shared_text_handler.dart'; + +void main() { + group('prepareSharedTextLoad', () { + test('rejects whitespace-only input', () { + final d = prepareSharedTextLoad(' \n\t', false); + expect(d.proceed, isFalse); + }); + + test('returns prefill mode when autoLog is false', () { + final d = prepareSharedTextLoad('hello', false); + expect(d.proceed, isTrue); + expect(d.mode, SharedTextLoadMode.prefill); + expect(d.text, 'hello'); + }); + + test('returns autoLog mode when autoLog is true', () { + final d = prepareSharedTextLoad('hello', true); + expect(d.proceed, isTrue); + expect(d.mode, SharedTextLoadMode.autoLog); + }); + + test('does not truncate long input', () { + final big = 'x' * 10000; + final d = prepareSharedTextLoad(big, true); + expect(d.text.length, 10000); + }); + }); + + group('handleSharedTextLoad', () { + late _Probe p; + setUp(() => p = _Probe()); + + test('autoLog success: logs, shows info, resets, clears cache', () async { + await handleSharedTextLoad( + text: 'note', + autoLog: true, + dir: '/tmp', + prefill: p.prefill, + focus: p.focus, + resetInput: p.resetInput, + clearCache: p.clearCache, + logFn: (_, _) async => p.logged = true, + showInfo: p.showInfo, + showError: p.showError, + ); + expect(p.logged, true); + expect(p.info, ['Logged']); + expect(p.didReset, true); + expect(p.cleared, true); + expect(p.errors, isEmpty); + }); + + test('autoLog failure: shows error, keeps cache, does not reset', () async { + await handleSharedTextLoad( + text: 'note', + autoLog: true, + dir: '/tmp', + prefill: p.prefill, + focus: p.focus, + resetInput: p.resetInput, + clearCache: p.clearCache, + logFn: (_, _) async => throw Exception('boom'), + showInfo: p.showInfo, + showError: p.showError, + ); + expect(p.errors.length, 1); + expect(p.cleared, false); + expect(p.didReset, false); + }); + + test('empty text: clears cache and skips everything else', () async { + await handleSharedTextLoad( + text: ' ', + autoLog: true, + dir: '/tmp', + prefill: p.prefill, + focus: p.focus, + resetInput: p.resetInput, + clearCache: p.clearCache, + logFn: (_, _) async { + p.logged = true; + }, + showInfo: p.showInfo, + showError: p.showError, + ); + expect(p.logged, false); + expect(p.cleared, true); + expect(p.didReset, false); + expect(p.prefilled, isNull); + }); + + test('prefill mode: prefills + focuses + clears cache, no log', () async { + await handleSharedTextLoad( + text: 'note', + autoLog: false, + dir: '/tmp', + prefill: p.prefill, + focus: p.focus, + resetInput: p.resetInput, + clearCache: p.clearCache, + logFn: (_, _) async => p.logged = true, + showInfo: p.showInfo, + showError: p.showError, + ); + expect(p.prefilled, 'note'); + expect(p.focused, true); + expect(p.cleared, true); + expect(p.logged, false); + }); + }); +} + +class _Probe { + String? prefilled; + bool focused = false; + bool didReset = false; + bool cleared = false; + bool logged = false; + final List info = []; + final List errors = []; + + void prefill(String s) { + prefilled = s; + } + + void focus() { + focused = true; + } + + void resetInput() { + didReset = true; + } + + Future clearCache() async { + cleared = true; + } + + void showInfo(String title, String msg) { + info.add(title); + } + + void showError(Object e) { + errors.add(e); + } +} -- cgit v1.2.3