mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-13 21:10:59 +01:00
Compare commits
16 Commits
v1.0.70-bu
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 19f930a93a | |||
| f7e24bbb1a | |||
| 4423937f5c | |||
| f4913daaa8 | |||
| c9eb4d6cff | |||
| 1010d96426 | |||
| 319f18d33a | |||
| c3ac6ec05d | |||
| 0e56cddd47 | |||
| 203e61d6ac | |||
| edf6969949 | |||
| 5ea7ba7b73 | |||
| 091e68a2d9 | |||
| a30e7b34a9 | |||
| 17d6d2a533 | |||
| 4e8ccd6bfd |
@ -50,6 +50,7 @@ and sync peer-to-peer through a GitHub repo used as dumb storage.
|
||||
- lint: `flutter analyze`
|
||||
- coverage: `flutter test --coverage --reporter=compact`
|
||||
- coverage-gaps: `coverage-gaps coverage/lcov.info`
|
||||
- deploy: `scripts/deploy.sh` (MCP service, desktop pacman package, phone release; exits non-zero unless every health check passes)
|
||||
|
||||
## Architecture
|
||||
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
org.gradle.jvmargs=-Xmx2G -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=256m -XX:+UseSerialGC -XX:TieredStopAtLevel=1
|
||||
org.gradle.caching=true
|
||||
org.gradle.parallel=true
|
||||
kotlin.compiler.execution.strategy=in-process
|
||||
android.useAndroidX=true
|
||||
# This newDsl flag was added by the Flutter template
|
||||
android.newDsl=false
|
||||
|
||||
@ -63,6 +63,9 @@ class CliArgs {
|
||||
case '--get' || '--delete' || '--close':
|
||||
modes.add(CliMode.values.byName(arg.substring(2)));
|
||||
id = value();
|
||||
case '--set-text':
|
||||
modes.add(CliMode.setText);
|
||||
id = value();
|
||||
case '--set-status':
|
||||
modes.add(CliMode.setStatus);
|
||||
id = value();
|
||||
@ -74,7 +77,8 @@ class CliArgs {
|
||||
if (modes.contains(CliMode.help)) return const CliArgs(mode: CliMode.help);
|
||||
if (modes.length > 1) {
|
||||
throw UsageError(
|
||||
'--list, --search, --get, --delete, --set-status, --close and '
|
||||
'--list, --search, --get, --delete, --set-status, --set-text, '
|
||||
'--close and '
|
||||
'--backfill-created-by are mutually exclusive',
|
||||
);
|
||||
}
|
||||
@ -110,7 +114,8 @@ class CliArgs {
|
||||
/// The selected mode.
|
||||
final CliMode mode;
|
||||
|
||||
/// Note text from `--text`; null means "read stdin" in [CliMode.add].
|
||||
/// Note text from `--text`; null means "read stdin" in [CliMode.add] and
|
||||
/// [CliMode.setText].
|
||||
final String? text;
|
||||
|
||||
/// Full id or 8+ character prefix of the note an id mode acts on.
|
||||
@ -146,7 +151,7 @@ class CliArgs {
|
||||
|
||||
static const _adding = 'when adding a note';
|
||||
static const Map<String, (String, Set<CliMode>)> _scopes = {
|
||||
'--text': (_adding, {CliMode.add}),
|
||||
'--text': ('$_adding or to --set-text', {CliMode.add, CliMode.setText}),
|
||||
'--done-check': (_adding, {CliMode.add}),
|
||||
'--by': (_adding, {CliMode.add}),
|
||||
'--repo': (
|
||||
|
||||
@ -136,6 +136,15 @@ class CliCommands {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Replaces a note's whole text (title is its first line) and pushes it.
|
||||
Future<int> setText(String ref, String text) async {
|
||||
final note = await _pullAndFind(ref);
|
||||
await _save(note.copyWith(text: text));
|
||||
if (!await _push('text change')) return 1;
|
||||
_out.writeln('${note.id}\ttext');
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Marks a note done with [evidence], keeping it, and pushes it.
|
||||
Future<int> close(String ref, String evidence) async {
|
||||
final note = await _pullAndFind(ref);
|
||||
|
||||
@ -18,6 +18,9 @@ enum CliMode {
|
||||
/// Change one note's status and push it.
|
||||
setStatus,
|
||||
|
||||
/// Replace one note's whole text and push it.
|
||||
setText,
|
||||
|
||||
/// Set a task done with evidence and push it.
|
||||
close,
|
||||
|
||||
|
||||
@ -54,7 +54,7 @@ Future<int> runCli(
|
||||
return 0;
|
||||
}
|
||||
String? text;
|
||||
if (args.mode == CliMode.add) {
|
||||
if (args.mode == CliMode.add || args.mode == CliMode.setText) {
|
||||
text = args.text ?? await readStdin();
|
||||
if (text.trim().isEmpty) {
|
||||
err.writeln('error: empty note (pass --text or pipe text on stdin)');
|
||||
@ -79,6 +79,7 @@ Future<int> runCli(
|
||||
CliMode.list || CliMode.search => await cli.list(args),
|
||||
CliMode.get => await cli.get(args.id!),
|
||||
CliMode.setStatus => await cli.setStatus(args.id!, args.status!),
|
||||
CliMode.setText => await cli.setText(args.id!, text!),
|
||||
CliMode.close => await cli.close(args.id!, args.opts.evidence!),
|
||||
CliMode.backfill => await cli.backfillCreatedBy(
|
||||
claudeNodeIds(env),
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
/// records hand-written into RTDB.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
@ -46,7 +47,21 @@ Set<String> claudeNodeIds(Map<String, String> env) {
|
||||
class CliSession {
|
||||
/// Wraps already-open parts; [open] is the normal way in. Public so a test
|
||||
/// can subclass the session and script [sync]'s outcome.
|
||||
CliSession(this.dataDir, this.repository, this._firebase);
|
||||
CliSession(
|
||||
this.dataDir,
|
||||
this.repository,
|
||||
this._firebase, {
|
||||
this.passphraseFile,
|
||||
this.sharedPasswordFile,
|
||||
});
|
||||
|
||||
/// The fleet-wide crdt-sync password the Python daemons read too
|
||||
/// (`~/.config/crdt-sync/password`), found from the seed's config root.
|
||||
final File? sharedPasswordFile;
|
||||
|
||||
/// Where the passphrase is read from besides the environment: next to the
|
||||
/// seeded Firebase session, e.g. `~/.config/todo/e2e_passphrase`.
|
||||
final File? passphraseFile;
|
||||
|
||||
/// File holding this install's persisted CRDT node id.
|
||||
static const nodeIdFileName = 'node_id';
|
||||
@ -105,7 +120,17 @@ class CliSession {
|
||||
auth: auth,
|
||||
httpClient: httpClient,
|
||||
);
|
||||
return CliSession(dataDir, repository, firebase);
|
||||
return CliSession(
|
||||
dataDir,
|
||||
repository,
|
||||
firebase,
|
||||
passphraseFile: File(
|
||||
p.join(p.dirname(seedCredentials), e2ePassphraseFileName),
|
||||
),
|
||||
sharedPasswordFile: File(
|
||||
p.join(p.dirname(p.dirname(seedCredentials)), 'crdt-sync', 'password'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Pull every peer's log and merge it; with [push], also publish this
|
||||
@ -113,15 +138,91 @@ class CliSession {
|
||||
///
|
||||
/// Without [push] nothing is written anywhere remote, and the revision cache
|
||||
/// is bypassed so a read always sees every peer's current log.
|
||||
Future<SyncResult> sync({required bool push}) {
|
||||
Future<SyncResult> sync({required bool push}) async {
|
||||
final plainService = SyncService(
|
||||
stateStore: push
|
||||
? PersistedSyncStateStore(
|
||||
FileLogPersistence(File(p.join(dataDir, syncStateFileName))),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
final e2e = await openEncryptedStore(
|
||||
store: _remote(push: push),
|
||||
oldPrefix: plainService.notesDir,
|
||||
readPassphrase: _readPassphrase,
|
||||
// Argon2id once per install, not on every CLI/MCP call.
|
||||
keyCache: E2eKeyCache(read: _readCachedKey, write: _writeCachedKey),
|
||||
);
|
||||
if (!push) {
|
||||
return const SyncService().sync(repository, PullOnlyStore(_firebase));
|
||||
// Read-only during the migration: old builds still write the plaintext
|
||||
// prefix, so merge it, then the encrypted one when this host has a key.
|
||||
final plain = await const SyncService().sync(
|
||||
repository,
|
||||
_remote(push: false),
|
||||
);
|
||||
if (e2e is! E2eReady) return plain;
|
||||
final layout = E2eLayout.of(plainService.notesDir);
|
||||
return await SyncService(
|
||||
notesDir: layout.prefix,
|
||||
revsPath: layout.revsPath,
|
||||
).sync(repository, e2e.store);
|
||||
}
|
||||
return SyncService(
|
||||
stateStore: PersistedSyncStateStore(
|
||||
FileLogPersistence(File(p.join(dataDir, syncStateFileName))),
|
||||
),
|
||||
).sync(repository, _firebase);
|
||||
if (e2e is! E2eReady) {
|
||||
throw StateError(
|
||||
'sync is locked: put the crdt-sync passphrase in '
|
||||
'$e2ePassphraseEnv or ${passphraseFile?.path ?? e2ePassphraseFileName}',
|
||||
);
|
||||
}
|
||||
final layout = E2eLayout.of(plainService.notesDir);
|
||||
return await SyncService(
|
||||
notesDir: layout.prefix,
|
||||
revsPath: layout.revsPath,
|
||||
stateStore: plainService.stateStore,
|
||||
).sync(repository, e2e.store);
|
||||
}
|
||||
|
||||
RemoteStore _remote({required bool push}) =>
|
||||
push ? _firebase : PullOnlyStore(_firebase);
|
||||
|
||||
/// Owner-only file in the data dir caching the derived E2E key.
|
||||
static const e2eKeyFileName = 'e2e_key';
|
||||
|
||||
File get _keyFile => File(p.join(dataDir, e2eKeyFileName));
|
||||
|
||||
Future<List<int>?> _readCachedKey() async {
|
||||
final file = _keyFile;
|
||||
if (!file.existsSync()) return null;
|
||||
try {
|
||||
return base64.decode((await file.readAsString()).trim());
|
||||
} on FormatException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _writeCachedKey(List<int> bytes) async {
|
||||
final file = _keyFile;
|
||||
// Restrict before the key is written, never after.
|
||||
await file.writeAsString('');
|
||||
await Process.run('chmod', ['600', file.path]);
|
||||
await file.writeAsString(base64.encode(bytes), flush: true);
|
||||
}
|
||||
|
||||
/// Environment variable holding the shared crdt-sync passphrase.
|
||||
static const e2ePassphraseEnv = 'TODO_E2E_PASSPHRASE';
|
||||
|
||||
/// File name of the passphrase, beside the seeded Firebase session.
|
||||
static const e2ePassphraseFileName = 'e2e_passphrase';
|
||||
|
||||
Future<String?> _readPassphrase() async {
|
||||
final fromEnv = Platform.environment[e2ePassphraseEnv];
|
||||
if (fromEnv != null && fromEnv.isNotEmpty) return fromEnv;
|
||||
for (final file in [passphraseFile, sharedPasswordFile]) {
|
||||
if (file != null && file.existsSync()) {
|
||||
final text = (await file.readAsString()).trim();
|
||||
if (text.isNotEmpty) return text;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Releases the log and the HTTP client.
|
||||
|
||||
@ -22,6 +22,8 @@ A note with --repo is a repo task; one without is a plain idea.
|
||||
--get <id> pull, then print one note in full (exit 1 if absent)
|
||||
--delete <id> pull, tombstone the note, push
|
||||
--set-status <id> <s> pull, change the note's status (as --status), push
|
||||
--set-text <id> pull, replace the note's whole text with --text (or
|
||||
stdin; first line = title), keep its other fields, push
|
||||
--close <id> --evidence <s>
|
||||
pull, set status done + evidence (commit sha + done
|
||||
check exit code), push; refused without --evidence
|
||||
|
||||
@ -73,11 +73,7 @@ class LegacySqliteMigration {
|
||||
}
|
||||
// coverage:ignore-end
|
||||
|
||||
static Future<void> _onUpgrade(
|
||||
CrdtTableExecutor db,
|
||||
int from,
|
||||
int to,
|
||||
) async {
|
||||
static Future<void> _onUpgrade(CrdtTableExecutor db, int from, int to) async {
|
||||
if (from < 2) {
|
||||
await db.execute(
|
||||
'ALTER TABLE notes ADD COLUMN status INTEGER NOT NULL DEFAULT 0',
|
||||
|
||||
@ -7,6 +7,11 @@ import 'package:path/path.dart' as p;
|
||||
|
||||
import 'package:todo/desktop/static_content_type.dart';
|
||||
import 'package:todo/desktop/wrapper_image_routes.dart';
|
||||
import 'package:todo/sync/e2e_passphrase_source.dart'
|
||||
show kSyncE2ePassphrasePath;
|
||||
|
||||
export 'package:todo/sync/e2e_passphrase_source.dart'
|
||||
show kSyncE2ePassphrasePath;
|
||||
|
||||
export 'wrapper_paths.dart';
|
||||
|
||||
@ -86,6 +91,9 @@ class WrapperServer {
|
||||
/// wrappers also serve, which stays opt-in.
|
||||
bool _todoCredentialsServed = false;
|
||||
|
||||
/// Whether [kSyncE2ePassphrasePath] has served the passphrase yet.
|
||||
bool _e2ePassphraseServed = false;
|
||||
|
||||
/// `todo`'s own seeded Firebase session, written by
|
||||
/// `crdt-sync/tool/seed_session.py --app todo` in the same
|
||||
/// `{id_token, refresh_token, expires_at}` shape every other app's
|
||||
@ -184,6 +192,9 @@ class WrapperServer {
|
||||
if (path == kSyncCredentialsPath) {
|
||||
return await _syncCredentials(request);
|
||||
}
|
||||
if (path == kSyncE2ePassphrasePath) {
|
||||
return await _syncE2ePassphrase(request);
|
||||
}
|
||||
if (await images.handle(request)) return;
|
||||
return await _static(request, path);
|
||||
}
|
||||
|
||||
@ -121,4 +121,23 @@ extension _SyncRoutes on WrapperServer {
|
||||
// through must remain retryable rather than burning the one serve.
|
||||
_todoCredentialsServed = true;
|
||||
}
|
||||
|
||||
/// Serves the shared crdt-sync password as the E2E passphrase, once per
|
||||
/// wrapper process, like [_syncCredentials]: the web app stores it in its
|
||||
/// keystore on first sync and never asks for it.
|
||||
Future<void> _syncE2ePassphrase(HttpRequest request) async {
|
||||
final passwordFile = File(p.join(syncConfigDir, 'password'));
|
||||
if (_e2ePassphraseServed || !passwordFile.existsSync()) {
|
||||
request.response.statusCode = HttpStatus.notFound;
|
||||
return;
|
||||
}
|
||||
final text = (await passwordFile.readAsString()).trim();
|
||||
if (text.isEmpty) {
|
||||
request.response.statusCode = HttpStatus.notFound;
|
||||
return;
|
||||
}
|
||||
request.response.headers.contentType = ContentType.text;
|
||||
request.response.write(text);
|
||||
_e2ePassphraseServed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ import 'package:todo/data/note_repository.dart';
|
||||
import 'package:todo/data/repository_factory.dart';
|
||||
import 'package:todo/frame_stats.dart';
|
||||
import 'package:todo/ui/capture_screen.dart';
|
||||
import 'package:todo/ui/text_free_localizations.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@ -69,6 +70,7 @@ class TodoApp extends StatelessWidget {
|
||||
store: imageStore,
|
||||
child: MaterialApp(
|
||||
title: 'todo',
|
||||
localizationsDelegates: kTextFreeLocalizations,
|
||||
theme: buildLightTheme(),
|
||||
darkTheme: buildDarkTheme(),
|
||||
home: CaptureScreen(
|
||||
|
||||
38
lib/sync/e2e_passphrase_source.dart
Normal file
38
lib/sync/e2e_passphrase_source.dart
Normal file
@ -0,0 +1,38 @@
|
||||
/// Where todo gets the shared crdt-sync passphrase its E2E key comes from.
|
||||
///
|
||||
/// The keystore first. On the desktop (a web build behind the local
|
||||
/// wrapper) an empty keystore adopts the wrapper's one-shot
|
||||
/// `/sync-e2e-passphrase` route, which serves `~/.config/crdt-sync/password`,
|
||||
/// so the desktop app never asks. Android has no wrapper (`Uri.base` is
|
||||
/// `file:///`) and is unlocked through the settings lock tile instead.
|
||||
library;
|
||||
|
||||
import 'package:crdt_sync_flutter/crdt_sync_flutter.dart'
|
||||
show kE2ePassphraseKey, kSecureStorage, saveE2ePassphrase;
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Path of the wrapper's one-shot passphrase route.
|
||||
const kSyncE2ePassphrasePath = '/sync-e2e-passphrase';
|
||||
|
||||
/// Returns the passphrase, adopting the wrapper's once; null when none.
|
||||
Future<String?> readE2ePassphrase({Uri? base, http.Client? client}) async {
|
||||
final stored = await kSecureStorage.read(key: kE2ePassphraseKey);
|
||||
if (stored != null && stored.isNotEmpty) return stored;
|
||||
final origin = base ?? Uri.base;
|
||||
if (origin.scheme != 'http' && origin.scheme != 'https') return null;
|
||||
final httpClient = client ?? http.Client();
|
||||
try {
|
||||
final response = await httpClient.get(
|
||||
origin.resolve(kSyncE2ePassphrasePath),
|
||||
);
|
||||
final text = response.body.trim();
|
||||
if (response.statusCode != 200 || text.isEmpty) return null;
|
||||
await saveE2ePassphrase(text);
|
||||
return text;
|
||||
} on Object {
|
||||
// No wrapper, or it already served: this device is simply locked.
|
||||
return null;
|
||||
} finally {
|
||||
if (client == null) httpClient.close();
|
||||
}
|
||||
}
|
||||
@ -7,15 +7,26 @@
|
||||
library;
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:crdt_sync_flutter/crdt_sync_flutter.dart' show keystoreKeyCache;
|
||||
import 'package:flutter/foundation.dart' show ValueNotifier, visibleForTesting;
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:todo/analytics/analytics_service.dart';
|
||||
import 'package:todo/data/app_settings.dart';
|
||||
import 'package:todo/data/note_repository.dart';
|
||||
import 'package:todo/sync/e2e_passphrase_source.dart';
|
||||
import 'package:todo/sync/firebase_backend.dart';
|
||||
import 'package:todo/sync/sync_service.dart';
|
||||
import 'package:todo/sync/sync_settings.dart';
|
||||
import 'package:todo/sync/sync_state_factory.dart';
|
||||
|
||||
/// Argon2id cost for new headers; only tests lower it.
|
||||
@visibleForTesting
|
||||
Argon2Params e2eArgon2Params = Argon2Params.recommended;
|
||||
|
||||
/// Whether the last sync found no usable end-to-end key; drives the lock
|
||||
/// tile in settings.
|
||||
final ValueNotifier<bool> e2eLocked = ValueNotifier<bool>(false);
|
||||
|
||||
/// Bundles a [SyncResult] with the [AppSettings] as reconciled against the
|
||||
/// same Firebase session — so a settings change made on another device is
|
||||
/// picked up without a second authenticated round trip.
|
||||
@ -44,8 +55,12 @@ class SyncRunResult {
|
||||
/// rather than each concern (notes, settings, analytics) opening and
|
||||
/// closing its own.
|
||||
///
|
||||
/// [firebaseFactory] and [stateStore] exist for tests, which must not reach
|
||||
/// the OS keystore or an application-support directory.
|
||||
/// [firebaseFactory], [stateStore] and [readPassphrase] exist for tests, which
|
||||
/// must not reach the OS keystore or an application-support directory.
|
||||
///
|
||||
/// Everything pushed is end-to-end encrypted under the shared crdt-sync
|
||||
/// passphrase; without a usable key the run is a no-op flagged
|
||||
/// [SyncResult.locked] rather than a plaintext sync.
|
||||
Future<SyncRunResult> runSync(
|
||||
NoteRepository repository,
|
||||
SyncSettings settings, {
|
||||
@ -54,6 +69,7 @@ Future<SyncRunResult> runSync(
|
||||
http.Client? httpClient,
|
||||
Future<FirebaseRestClient?> Function()? firebaseFactory,
|
||||
SyncStateStore? stateStore,
|
||||
Future<String?> Function()? readPassphrase,
|
||||
}) async {
|
||||
final github = GitHubClient(
|
||||
owner: settings.owner,
|
||||
@ -72,12 +88,29 @@ Future<SyncRunResult> runSync(
|
||||
stateStore: stateStore ?? await openSyncStateStore(),
|
||||
// coverage:ignore-end
|
||||
);
|
||||
final result = await service.sync(
|
||||
repository,
|
||||
firebase == null
|
||||
? github
|
||||
: MirrorStore(primary: firebase, mirror: github),
|
||||
final backend = firebase == null
|
||||
? github
|
||||
: MirrorStore(primary: firebase, mirror: github);
|
||||
final e2e = await openEncryptedStore(
|
||||
store: backend,
|
||||
oldPrefix: service.notesDir,
|
||||
readPassphrase: readPassphrase ?? readE2ePassphrase,
|
||||
params: e2eArgon2Params,
|
||||
// Argon2id once per device, not once per tick.
|
||||
keyCache: keystoreKeyCache(service.notesDir),
|
||||
);
|
||||
e2eLocked.value = e2e is! E2eReady;
|
||||
if (e2e is! E2eReady) {
|
||||
return const SyncRunResult(
|
||||
syncResult: SyncResult(mergedDevices: 0, pushed: false, locked: true),
|
||||
);
|
||||
}
|
||||
final layout = E2eLayout.of(service.notesDir);
|
||||
final result = await SyncService(
|
||||
notesDir: layout.prefix,
|
||||
revsPath: layout.revsPath,
|
||||
stateStore: service.stateStore,
|
||||
).sync(repository, e2e.store);
|
||||
await analytics?.flush(firebase);
|
||||
return SyncRunResult(
|
||||
syncResult: result.withFirebaseConnected(value: firebase != null),
|
||||
|
||||
@ -11,6 +11,7 @@ class SyncResult {
|
||||
this.skippedFiles = const [],
|
||||
this.skippedUnchanged = 0,
|
||||
this.firebaseConnected = false,
|
||||
this.locked = false,
|
||||
});
|
||||
|
||||
/// How many other devices' logs were pulled and merged.
|
||||
@ -41,6 +42,10 @@ class SyncResult {
|
||||
/// device dropping out.
|
||||
final int skippedUnchanged;
|
||||
|
||||
/// True when end-to-end encryption has no usable key on this device, so
|
||||
/// nothing was pulled, merged or pushed.
|
||||
final bool locked;
|
||||
|
||||
/// Returns a copy with [firebaseConnected] set. [SyncService] never sets
|
||||
/// it -- only [runSync], which is the layer that knows which [RemoteStore]
|
||||
/// it built -- so this is the seam that lets it do that without
|
||||
@ -51,6 +56,7 @@ class SyncResult {
|
||||
skippedFiles: skippedFiles,
|
||||
skippedUnchanged: skippedUnchanged,
|
||||
firebaseConnected: value,
|
||||
locked: locked,
|
||||
);
|
||||
|
||||
@override
|
||||
@ -73,7 +79,11 @@ class SyncResult {
|
||||
/// two never share a path and an old client can't misread a new file.
|
||||
class SyncService {
|
||||
/// Creates a [SyncService], optionally overriding [notesDir].
|
||||
const SyncService({this.notesDir = 'todo-sync/notes', this.stateStore});
|
||||
const SyncService({
|
||||
this.notesDir = 'todo-sync/notes',
|
||||
this.stateStore,
|
||||
this.revsPath,
|
||||
});
|
||||
|
||||
/// Directory in the repo under which per-device note-log files live.
|
||||
final String notesDir;
|
||||
@ -87,7 +97,11 @@ class SyncService {
|
||||
final SyncStateStore? stateStore;
|
||||
|
||||
/// Where revisions live, as a sibling of [notesDir].
|
||||
String get revsDir => defaultRevsPath(notesDir);
|
||||
/// Revision cache directory; null derives it from [notesDir].
|
||||
final String? revsPath;
|
||||
|
||||
/// Where peers publish their revisions.
|
||||
String get revsDir => revsPath ?? defaultRevsPath(notesDir);
|
||||
|
||||
/// Runs a full pull-merge-push cycle. Safe to call repeatedly.
|
||||
///
|
||||
@ -123,7 +137,15 @@ class SyncService {
|
||||
skippedUnchanged++;
|
||||
continue;
|
||||
}
|
||||
final text = await remote.getFileText('$notesDir/$name');
|
||||
final String? text;
|
||||
try {
|
||||
text = await remote.getFileText('$notesDir/$name');
|
||||
} on E2eDecryptError {
|
||||
// Wrong key, tampered blob or a newer format: skipped and reported,
|
||||
// never merged and never fatal.
|
||||
skipped.add(name);
|
||||
continue;
|
||||
}
|
||||
final remoteLog = text == null ? null : _decodeLog(text);
|
||||
if (remoteLog == null) {
|
||||
// A vanished or undecodable peer file must not abort the sync, but
|
||||
|
||||
@ -44,18 +44,24 @@ Future<List<XFile>> pickAttachments(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_camera_outlined),
|
||||
title: const Text('Camera'),
|
||||
leading: const Icon(
|
||||
Icons.photo_camera_outlined,
|
||||
semanticLabel: 'Camera',
|
||||
),
|
||||
onTap: () => Navigator.pop(context, AttachmentPickSource.camera),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_library_outlined),
|
||||
title: const Text('Gallery'),
|
||||
leading: const Icon(
|
||||
Icons.photo_library_outlined,
|
||||
semanticLabel: 'Gallery',
|
||||
),
|
||||
onTap: () => Navigator.pop(context, AttachmentPickSource.gallery),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.insert_drive_file_outlined),
|
||||
title: const Text('File'),
|
||||
leading: const Icon(
|
||||
Icons.insert_drive_file_outlined,
|
||||
semanticLabel: 'File',
|
||||
),
|
||||
onTap: () => Navigator.pop(context, AttachmentPickSource.file),
|
||||
),
|
||||
],
|
||||
@ -102,8 +108,7 @@ class AttachButton extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
tooltip: 'Attach',
|
||||
icon: const Icon(Icons.attach_file),
|
||||
icon: const Icon(Icons.attach_file, semanticLabel: 'Attach'),
|
||||
onPressed: () async {
|
||||
final files = await (picking ?? pickAttachments)(context);
|
||||
if (files.isNotEmpty) onFiles(files);
|
||||
|
||||
@ -86,13 +86,11 @@ class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
// Not gated on advanced mode: attaching is basic capture.
|
||||
AttachButton(onFiles: onAttachFiles, picking: attachmentPicking),
|
||||
IconButton(
|
||||
tooltip: 'New note',
|
||||
onPressed: onNewNote,
|
||||
icon: const Icon(Icons.check),
|
||||
icon: const Icon(Icons.check, semanticLabel: 'New note'),
|
||||
),
|
||||
if (advanced)
|
||||
IconButton(
|
||||
tooltip: 'Sync',
|
||||
onPressed: syncing ? null : onSync,
|
||||
icon: syncing
|
||||
? const SizedBox(
|
||||
@ -100,12 +98,11 @@ class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.sync),
|
||||
: const Icon(Icons.sync, semanticLabel: 'Sync'),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Notes',
|
||||
onPressed: onOpenList,
|
||||
icon: const Icon(Icons.list),
|
||||
icon: const Icon(Icons.list, semanticLabel: 'Notes'),
|
||||
),
|
||||
// Live count of stored notes, proving local persistence. Sits directly
|
||||
// right of the Notes button it belongs to, and carries no icon of its
|
||||
@ -122,9 +119,8 @@ class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
),
|
||||
CaptureConfirmMark(captured: captured),
|
||||
IconButton(
|
||||
tooltip: 'Settings',
|
||||
onPressed: onOpenSettings,
|
||||
icon: const Icon(Icons.settings),
|
||||
icon: const Icon(Icons.settings, semanticLabel: 'Settings'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
@ -22,6 +21,7 @@ import 'package:todo/ui/capture_draft.dart';
|
||||
import 'package:todo/ui/capture_status.dart';
|
||||
import 'package:todo/ui/capture_status_footer.dart';
|
||||
import 'package:todo/ui/capture_sync_runner.dart';
|
||||
import 'package:todo/ui/cue_snack.dart';
|
||||
import 'package:todo/ui/image_attach.dart';
|
||||
import 'package:todo/ui/image_input.dart';
|
||||
import 'package:todo/ui/note_form.dart';
|
||||
@ -194,7 +194,14 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
Future<void> _sync() async {
|
||||
final settings = _settings ?? await SyncSettings.load();
|
||||
if (!settings.isConfigured && await openFirebase() == null) {
|
||||
_showSnack('Connect Firebase (or add a GitHub token) in settings');
|
||||
// The cue plus the settings screen opening is the whole message.
|
||||
if (mounted) {
|
||||
showCue(
|
||||
context,
|
||||
Icons.cloud_off,
|
||||
'Connect Firebase (or add a GitHub token) in settings',
|
||||
);
|
||||
}
|
||||
await _openSettings();
|
||||
return;
|
||||
}
|
||||
@ -215,12 +222,7 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
if (outcome.ok) 'detail': outcome.detail,
|
||||
},
|
||||
);
|
||||
_showSnack(
|
||||
outcome.ok
|
||||
? 'Synced: ${outcome.detail}'
|
||||
: 'Sync failed: ${outcome.detail}',
|
||||
isError: !outcome.ok,
|
||||
);
|
||||
_showSyncCue(outcome);
|
||||
} finally {
|
||||
if (mounted) setState(() => _syncing = false);
|
||||
}
|
||||
|
||||
@ -104,13 +104,20 @@ extension _CaptureSyncBehaviour on _CaptureScreenState {
|
||||
/// replace-don't-queue behaviour match every other app; [isError] picks
|
||||
/// the danger hue and the longer duration, because an error the user
|
||||
/// missed is an error they will hit again.
|
||||
void _showSnack(String message, {bool isError = false}) {
|
||||
/// A manual sync's result as a glyph: done, done-but-degraded (Firebase
|
||||
/// off or a peer unreadable, in the danger hue), or failed. The summary
|
||||
/// sentence is spoken only; the status line keeps the failure time.
|
||||
void _showSyncCue(CaptureSyncOutcome outcome) {
|
||||
if (!mounted) return;
|
||||
if (isError) {
|
||||
showError(context, message);
|
||||
} else {
|
||||
showToast(context, message);
|
||||
}
|
||||
final spoken = outcome.ok
|
||||
? 'Synced: ${outcome.detail}'
|
||||
: 'Sync failed: ${outcome.detail}';
|
||||
final (icon, error) = !outcome.ok
|
||||
? (Icons.sync_problem, true)
|
||||
: outcome.degraded
|
||||
? (Icons.warning_amber, true)
|
||||
: (Icons.cloud_done, false);
|
||||
showCue(context, icon, spoken, error: error);
|
||||
}
|
||||
|
||||
/// Reloads the persisted last-sync outcome so the status line survives a
|
||||
|
||||
@ -98,6 +98,7 @@ class SyncStatusStore {
|
||||
/// precisely how a desktop stuck syncing over the GitHub mirror alone
|
||||
/// went unnoticed for days.
|
||||
String describeSyncResult(SyncResult result) {
|
||||
if (result.locked) return '🔒';
|
||||
final skipped = result.skippedFiles.length;
|
||||
final backend = result.firebaseConnected
|
||||
? ''
|
||||
|
||||
@ -44,16 +44,16 @@ class CaptureStatusFooter extends StatelessWidget {
|
||||
if (status == null || status.ok) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
// Glyph + time in the danger hue; the error itself is spoken.
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
child: _cueLine(
|
||||
Icons.sync_problem,
|
||||
formatSyncTime(status.time),
|
||||
'Sync failed at ${formatSyncTime(status.time)} · '
|
||||
'${status.detail}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
theme.textTheme.bodySmall,
|
||||
theme.colorScheme.error,
|
||||
),
|
||||
);
|
||||
},
|
||||
@ -63,15 +63,45 @@ class CaptureStatusFooter extends StatelessWidget {
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: ValueListenableBuilder<DateTime?>(
|
||||
valueListenable: lastSavedAt,
|
||||
builder: (context, savedAt, _) => Text(
|
||||
savedAt == null
|
||||
? 'Autosaves as you type'
|
||||
: 'Saved locally at ${formatSyncTime(savedAt)}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
// Before the first save: the save glyph alone, muted.
|
||||
builder: (context, savedAt, _) => savedAt == null
|
||||
? _cueLine(
|
||||
Icons.save_outlined,
|
||||
'',
|
||||
'Autosaves as you type',
|
||||
theme.textTheme.bodySmall,
|
||||
theme.colorScheme.outline,
|
||||
)
|
||||
: _cueLine(
|
||||
Icons.save,
|
||||
formatSyncTime(savedAt),
|
||||
'Saved locally at ${formatSyncTime(savedAt)}',
|
||||
theme.textTheme.bodySmall,
|
||||
null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// One status line: a small glyph and a time, spoken as [spoken].
|
||||
Widget _cueLine(
|
||||
IconData icon,
|
||||
String time,
|
||||
String spoken,
|
||||
TextStyle? style,
|
||||
Color? color,
|
||||
) => Semantics(
|
||||
label: spoken,
|
||||
excludeSemantics: true,
|
||||
container: true,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 16, color: color),
|
||||
const SizedBox(width: 4),
|
||||
Text(time, style: style?.copyWith(color: color)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -26,11 +26,16 @@ class CaptureSyncOutcome {
|
||||
required this.ok,
|
||||
required this.detail,
|
||||
this.reconciled,
|
||||
this.degraded = false,
|
||||
});
|
||||
|
||||
/// Whether the pass completed without throwing.
|
||||
final bool ok;
|
||||
|
||||
/// A pass that completed but not fully: Firebase was not connected, or a
|
||||
/// peer's file could not be read. Shown as a warning, not a plain success.
|
||||
final bool degraded;
|
||||
|
||||
/// One-line summary: merged/skipped counts on success, the error otherwise.
|
||||
final String detail;
|
||||
|
||||
@ -90,10 +95,15 @@ class CaptureSyncRunner {
|
||||
);
|
||||
final detail = describeSyncResult(result.syncResult);
|
||||
await status.record(ok: true, detail: detail, live: live());
|
||||
final synced = result.syncResult;
|
||||
return CaptureSyncOutcome(
|
||||
ok: true,
|
||||
detail: detail,
|
||||
reconciled: result.appSettings,
|
||||
degraded:
|
||||
synced.locked ||
|
||||
!synced.firebaseConnected ||
|
||||
synced.skippedFiles.isNotEmpty,
|
||||
);
|
||||
} on Exception catch (e) {
|
||||
await status.record(ok: false, detail: '$e', live: live());
|
||||
|
||||
@ -11,6 +11,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'package:todo/attachments/image_password_store.dart';
|
||||
@ -18,6 +19,7 @@ import 'package:todo/attachments/image_store_api.dart';
|
||||
import 'package:todo/attachments/image_store_scope.dart';
|
||||
import 'package:todo/images/dufs_image_client.dart';
|
||||
import 'package:todo/ui/app_route.dart';
|
||||
import 'package:todo/ui/status_cue.dart';
|
||||
|
||||
/// The command that creates the login, shown wherever it is missing.
|
||||
const kAddLoginCommand =
|
||||
@ -34,8 +36,7 @@ class CloudImagesTile extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Cloud images'),
|
||||
subtitle: const Text('Where attached images and files upload (dufs)'),
|
||||
leading: const Icon(Icons.cloud, semanticLabel: 'Cloud images'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => Navigator.of(context).push<void>(
|
||||
appRoute(context, (_) => CloudImagesScreen(httpClient: httpClient)),
|
||||
@ -69,7 +70,7 @@ class CloudImagesScreen extends StatefulWidget {
|
||||
class _CloudImagesScreenState extends State<CloudImagesScreen> {
|
||||
final _password = TextEditingController();
|
||||
ImageStoreStatus? _status;
|
||||
String? _checkResult;
|
||||
StatusCue? _checkResult;
|
||||
bool _checking = false;
|
||||
|
||||
@override
|
||||
@ -98,7 +99,11 @@ class _CloudImagesScreenState extends State<CloudImagesScreen> {
|
||||
final password = _password.text.trim();
|
||||
setState(() => _checking = true);
|
||||
final saved = await widget.passwords.write(password);
|
||||
var result = 'Could not save: the keystore is unavailable';
|
||||
var result = const StatusCue(
|
||||
Icons.no_encryption,
|
||||
'Could not save: the keystore is unavailable',
|
||||
error: true,
|
||||
);
|
||||
if (saved) {
|
||||
final client = DufsImageClient(
|
||||
DufsLogin(user: kDufsImagesUser, password: password),
|
||||
@ -106,9 +111,17 @@ class _CloudImagesScreenState extends State<CloudImagesScreen> {
|
||||
);
|
||||
final check = await client.check();
|
||||
result = switch (check) {
|
||||
DufsCheck.ok => 'Saved — dufs accepted the login',
|
||||
DufsCheck.rejected => 'Saved, but dufs rejected the password',
|
||||
DufsCheck.unreachable => 'Saved — dufs is unreachable right now',
|
||||
DufsCheck.ok => const StatusCue.ok('Saved — dufs accepted the login'),
|
||||
DufsCheck.rejected => const StatusCue(
|
||||
Icons.lock,
|
||||
'Saved, but dufs rejected the password',
|
||||
error: true,
|
||||
),
|
||||
DufsCheck.unreachable => const StatusCue(
|
||||
Icons.cloud_off,
|
||||
'Saved — dufs is unreachable right now',
|
||||
error: true,
|
||||
),
|
||||
};
|
||||
// Upload what queued up while there was no password, now rather than
|
||||
// on the next sync. No note texts: an empty ref set only uploads, it
|
||||
@ -129,7 +142,9 @@ class _CloudImagesScreenState extends State<CloudImagesScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
final status = _status;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Cloud images')),
|
||||
appBar: AppBar(
|
||||
title: const Icon(Icons.cloud_outlined, semanticLabel: 'Cloud images'),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
@ -138,36 +153,78 @@ class _CloudImagesScreenState extends State<CloudImagesScreen> {
|
||||
controller: _password,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Password for the "todo" dufs login',
|
||||
prefixIcon: Icon(
|
||||
Icons.lock_outline,
|
||||
semanticLabel: 'Password for the "todo" dufs login',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
onPressed: _checking ? null : _saveAndTest,
|
||||
child: const Text('Save & test'),
|
||||
child: const Icon(Icons.save, semanticLabel: 'Save & test'),
|
||||
),
|
||||
if (_checkResult case final result?) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(result),
|
||||
StatusCueView(result),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
if (status != null) ...[
|
||||
Text(
|
||||
status.configured
|
||||
? 'Login: configured'
|
||||
: 'Login: missing — create it on the PC with',
|
||||
// Shape + colour: a filled check, or a red error ring followed by
|
||||
// a copy button that puts the fixing command on the clipboard.
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
status.configured ? Icons.check_circle : Icons.error_outline,
|
||||
color: status.configured
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.error,
|
||||
semanticLabel: status.configured
|
||||
? 'Login: configured'
|
||||
: 'Login: missing — create it on the PC with',
|
||||
),
|
||||
if (!status.configured)
|
||||
IconButton(
|
||||
onPressed: () => unawaited(
|
||||
Clipboard.setData(
|
||||
const ClipboardData(text: kAddLoginCommand),
|
||||
),
|
||||
),
|
||||
icon: const Icon(
|
||||
Icons.content_copy,
|
||||
semanticLabel: 'Copy: $kAddLoginCommand',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (!status.configured) const SelectableText(kAddLoginCommand),
|
||||
const SizedBox(height: 8),
|
||||
Text('Waiting to upload: ${status.pending}'),
|
||||
// Icon + the count itself: the number is the information.
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.cloud_upload_outlined),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${status.pending}',
|
||||
semanticsLabel: 'Waiting to upload: ${status.pending}',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Images are shrunk to 2560px JPEGs without location data before '
|
||||
'they leave this device. Other files upload unchanged, at any '
|
||||
'size.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
// Shrunk + no location, as glyphs; the sentence is spoken only.
|
||||
const Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.photo_size_select_large,
|
||||
semanticLabel:
|
||||
'Images are shrunk to 2560px JPEGs without location data '
|
||||
'before they leave this device. Other files upload '
|
||||
'unchanged, at any size.',
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Icon(Icons.location_off),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
58
lib/ui/confirm_delete.dart
Normal file
58
lib/ui/confirm_delete.dart
Normal file
@ -0,0 +1,58 @@
|
||||
/// Icon-only confirmation before deleting a note.
|
||||
///
|
||||
/// Replaces design_system's `confirmDestructive` here because that dialog
|
||||
/// paints its title, message and button labels as text. The safety contract
|
||||
/// is kept: Cancel is autofocused (a reflexive Return keeps the note), Escape
|
||||
/// and the barrier answer "no", and the confirm glyph wears the error colour.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Asks whether to delete a note; true only when the delete glyph is pressed.
|
||||
Future<bool> confirmDeleteNote(BuildContext context) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => const _ConfirmDeleteDialog(),
|
||||
);
|
||||
return confirmed ?? false;
|
||||
}
|
||||
|
||||
class _ConfirmDeleteDialog extends StatelessWidget {
|
||||
const _ConfirmDeleteDialog();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final error = Theme.of(context).colorScheme.error;
|
||||
return CallbackShortcuts(
|
||||
bindings: {
|
||||
const SingleActivator(LogicalKeyboardKey.escape): () =>
|
||||
Navigator.of(context).pop(false),
|
||||
},
|
||||
child: AlertDialog(
|
||||
// Spoken when the dialog opens; the old written body.
|
||||
semanticLabel: 'This cannot be undone.',
|
||||
title: Icon(
|
||||
Icons.delete_forever,
|
||||
size: 40,
|
||||
color: error,
|
||||
semanticLabel: 'Delete note?',
|
||||
),
|
||||
actionsAlignment: MainAxisAlignment.spaceEvenly,
|
||||
actions: [
|
||||
IconButton(
|
||||
key: const ValueKey('confirm-cancel'),
|
||||
autofocus: true,
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
icon: const Icon(Icons.close, semanticLabel: 'Cancel'),
|
||||
),
|
||||
IconButton(
|
||||
key: const ValueKey('confirm-delete'),
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
icon: Icon(Icons.delete, color: error, semanticLabel: 'Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
58
lib/ui/cue_snack.dart
Normal file
58
lib/ui/cue_snack.dart
Normal file
@ -0,0 +1,58 @@
|
||||
/// Transient feedback as a glyph, not a sentence (kuhy's textgate rule).
|
||||
///
|
||||
/// The design system's `showToast`/`showError` paint a message; this paints
|
||||
/// an icon (plus an optional number) and keeps the old sentence as the
|
||||
/// spoken label only. Colour is the second channel: errors use the danger
|
||||
/// hue and stay up longer, as `showError` does.
|
||||
library;
|
||||
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const Duration _toastDuration = Duration(seconds: 3);
|
||||
const Duration _errorDuration = Duration(seconds: 5);
|
||||
|
||||
/// Shows [icon] (and [count], if given) in a snack bar; [spoken] is what a
|
||||
/// screen reader says. Returns quietly when no `ScaffoldMessenger` is above
|
||||
/// [context] (an async callback outliving its screen).
|
||||
void showCue(
|
||||
BuildContext context,
|
||||
IconData icon,
|
||||
String spoken, {
|
||||
bool error = false,
|
||||
int? count,
|
||||
}) {
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
if (messenger == null) return;
|
||||
final colors = context.statusColors;
|
||||
final fg = error ? colors.onStatus : null;
|
||||
// Replace rather than queue: a queued cue describes an action the user
|
||||
// has already moved on from.
|
||||
messenger
|
||||
..clearSnackBars()
|
||||
..showSnackBar(
|
||||
SnackBar(
|
||||
content: Semantics(
|
||||
label: spoken,
|
||||
excludeSemantics: true,
|
||||
// Its own node: otherwise the label merges into the snack bar's.
|
||||
container: true,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: fg),
|
||||
if (count != null) ...[
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Text('$count', style: TextStyle(color: fg)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
backgroundColor: error ? colors.danger : null,
|
||||
duration: error ? _errorDuration : _toastDuration,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
23
lib/ui/empty_notes_cue.dart
Normal file
23
lib/ui/empty_notes_cue.dart
Normal file
@ -0,0 +1,23 @@
|
||||
/// The notes list's empty state as a picture, not a sentence.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A large muted glyph: an empty inbox when there are no notes at all, a
|
||||
/// crossed-out filter when the current filter hides every note (the filter
|
||||
/// button's badge is the second channel saying a filter is on).
|
||||
class EmptyNotesCue extends StatelessWidget {
|
||||
/// Creates the cue; [filtered] picks the filter variant.
|
||||
const EmptyNotesCue({required this.filtered, super.key});
|
||||
|
||||
/// Whether a filter is active (notes may exist but none match).
|
||||
final bool filtered;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Icon(
|
||||
filtered ? Icons.filter_alt_off_outlined : Icons.inbox_outlined,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
semanticLabel: filtered ? 'No notes match these filters' : 'No notes yet',
|
||||
);
|
||||
}
|
||||
105
lib/ui/enum_glyphs.dart
Normal file
105
lib/ui/enum_glyphs.dart
Normal file
@ -0,0 +1,105 @@
|
||||
/// Glyphs for the Priority and Status enums, and the chip row that picks
|
||||
/// them, so every picker shows the same shape + colour for the same value.
|
||||
///
|
||||
/// The enum's `label` survives only as the spoken (semantics) label.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/ui/note_grid_tile.dart';
|
||||
|
||||
/// Shape channel for a priority: up for high, level for medium, down for low.
|
||||
/// Colour ([priorityColor]) is the second channel.
|
||||
IconData priorityIcon(Priority p) => switch (p) {
|
||||
Priority.high => Icons.keyboard_double_arrow_up,
|
||||
Priority.medium => Icons.drag_handle,
|
||||
Priority.low => Icons.keyboard_double_arrow_down,
|
||||
};
|
||||
|
||||
/// Heading glyph standing for "status" (matches the bulk-status button).
|
||||
const IconData kStatusHeadingIcon = Icons.task_alt;
|
||||
|
||||
/// Heading glyph standing for "priority" (matches the bulk-priority button).
|
||||
const IconData kPriorityHeadingIcon = Icons.flag_outlined;
|
||||
|
||||
/// [s] as its glyph, spoken as its label.
|
||||
Widget statusGlyph(Status s, {double? size}) =>
|
||||
Icon(statusIcon(s), size: size, semanticLabel: s.label);
|
||||
|
||||
/// [p] as its tinted glyph, spoken as its label.
|
||||
Widget priorityGlyph(BuildContext context, Priority p, {double? size}) => Icon(
|
||||
priorityIcon(p),
|
||||
size: size,
|
||||
color: priorityColor(p, Theme.of(context).colorScheme),
|
||||
semanticLabel: p.label,
|
||||
);
|
||||
|
||||
/// A heading glyph followed by one chip per value, each chip a glyph.
|
||||
///
|
||||
/// [multi] switches between FilterChips (toggle many) and ChoiceChips (pick
|
||||
/// one); either way the selected chips are filled and checked.
|
||||
class GlyphChips<T> extends StatelessWidget {
|
||||
/// Creates a chip row for [values].
|
||||
const GlyphChips({
|
||||
required this.heading,
|
||||
required this.semanticLabel,
|
||||
required this.values,
|
||||
required this.selected,
|
||||
required this.glyphOf,
|
||||
required this.onTap,
|
||||
this.multi = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Glyph shown in front of the chips in place of a written heading.
|
||||
final IconData heading;
|
||||
|
||||
/// Spoken name of the row (the old written heading).
|
||||
final String semanticLabel;
|
||||
|
||||
/// Every value offered.
|
||||
final List<T> values;
|
||||
|
||||
/// Values currently selected.
|
||||
final Set<T> selected;
|
||||
|
||||
/// Builds the glyph for one value.
|
||||
final Widget Function(T) glyphOf;
|
||||
|
||||
/// Called with the tapped value.
|
||||
final ValueChanged<T> onTap;
|
||||
|
||||
/// Whether this is a multi-select (filter) row.
|
||||
final bool multi;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(heading, semanticLabel: semanticLabel),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
for (final v in values)
|
||||
if (multi)
|
||||
FilterChip(
|
||||
label: glyphOf(v),
|
||||
selected: selected.contains(v),
|
||||
onSelected: (_) => onTap(v),
|
||||
)
|
||||
else
|
||||
ChoiceChip(
|
||||
label: glyphOf(v),
|
||||
selected: selected.contains(v),
|
||||
onSelected: (_) => onTap(v),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -9,6 +9,7 @@ import 'package:todo/data/app_settings.dart';
|
||||
import 'package:todo/data/note_repository.dart';
|
||||
import 'package:todo/sync/run_sync.dart';
|
||||
import 'package:todo/sync/sync_settings.dart';
|
||||
import 'package:todo/ui/status_cue.dart';
|
||||
|
||||
part 'github_mirror_screen_widget.dart';
|
||||
|
||||
@ -27,7 +28,7 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
|
||||
);
|
||||
|
||||
bool _testing = false;
|
||||
String? _status;
|
||||
StatusCue? _status;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@ -49,8 +50,13 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
|
||||
Future<void> _connectGitHub() async {
|
||||
final clientId = _clientId.text.trim();
|
||||
if (clientId.isEmpty) {
|
||||
setState(() => _status = 'Enter the OAuth App client id first.');
|
||||
return;
|
||||
return _show(
|
||||
const StatusCue(
|
||||
Icons.key_off,
|
||||
'Enter the OAuth App client id first.',
|
||||
error: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
final auth = GitHubDeviceAuth(
|
||||
clientId: clientId,
|
||||
@ -67,20 +73,19 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
|
||||
if (token != null && token.isNotEmpty) {
|
||||
setState(() {
|
||||
_token.text = token;
|
||||
_status = 'Connected — syncing…';
|
||||
_status = const StatusCue(Icons.sync, 'Connected — syncing…');
|
||||
});
|
||||
await _current.save();
|
||||
await _syncAfterConnect();
|
||||
}
|
||||
} on Exception catch (e) {
|
||||
if (mounted) setState(() => _status = 'Could not start device flow: $e');
|
||||
_show(StatusCue.failed('Could not start device flow: $e'));
|
||||
} finally {
|
||||
auth.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a sync right after connecting so the user's notes download
|
||||
/// immediately and they get clear confirmation it worked.
|
||||
/// Syncs right after connecting so notes download at once, with a cue.
|
||||
Future<void> _syncAfterConnect() async {
|
||||
final s = _current;
|
||||
try {
|
||||
@ -96,19 +101,29 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
|
||||
widget.appSettings.value = widget.appSettings.value.adopt(
|
||||
run.appSettings,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(
|
||||
() => _status =
|
||||
'Connected and synced (merged '
|
||||
'${run.syncResult.mergedDevices} device(s)). Your notes are '
|
||||
'up to date.',
|
||||
);
|
||||
}
|
||||
_show(
|
||||
StatusCue.ok(
|
||||
'Connected and synced (merged '
|
||||
'${run.syncResult.mergedDevices} device(s)). Your notes are '
|
||||
'up to date.',
|
||||
),
|
||||
);
|
||||
} on Exception catch (e) {
|
||||
if (mounted) setState(() => _status = 'Connected, but sync failed: $e');
|
||||
_show(
|
||||
StatusCue(
|
||||
Icons.sync_problem,
|
||||
'Connected, but sync failed: $e',
|
||||
error: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows [cue] as the screen's latest result, if still mounted.
|
||||
void _show(StatusCue cue) {
|
||||
if (mounted) setState(() => _status = cue);
|
||||
}
|
||||
|
||||
Future<void> _test() async {
|
||||
setState(() {
|
||||
_testing = true;
|
||||
@ -123,13 +138,17 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
|
||||
);
|
||||
try {
|
||||
final ok = await client.canAccessRepo();
|
||||
setState(
|
||||
() => _status = ok
|
||||
? 'Connected — repo is reachable.'
|
||||
: 'Could not access ${s.owner}/${s.repo}. Check token scope.',
|
||||
_show(
|
||||
ok
|
||||
? const StatusCue.ok('Connected — repo is reachable.')
|
||||
: StatusCue(
|
||||
Icons.lock,
|
||||
'Could not access ${s.owner}/${s.repo}. Check token scope.',
|
||||
error: true,
|
||||
),
|
||||
);
|
||||
} on Exception catch (e) {
|
||||
setState(() => _status = 'Error: $e');
|
||||
_show(StatusCue.failed('Error: $e'));
|
||||
} finally {
|
||||
client.close();
|
||||
if (mounted) setState(() => _testing = false);
|
||||
@ -145,29 +164,27 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Advanced sync (GitHub)')),
|
||||
appBar: AppBar(
|
||||
title: const Icon(
|
||||
Icons.cloud_sync,
|
||||
semanticLabel: 'Advanced sync (GitHub)',
|
||||
),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Text(
|
||||
'Authorize in your browser — no token to paste. Syncs to '
|
||||
'kuhyx/syncs by default.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FilledButton.icon(
|
||||
child: IconButton.filled(
|
||||
onPressed: _connectGitHub,
|
||||
icon: const Icon(Icons.login),
|
||||
label: const Text('Connect GitHub'),
|
||||
icon: const Icon(Icons.login, semanticLabel: 'Connect GitHub'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _owner,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'GitHub owner',
|
||||
prefixIcon: Icon(Icons.person, semanticLabel: 'GitHub owner'),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
@ -175,7 +192,7 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
|
||||
TextField(
|
||||
controller: _repo,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Repository name',
|
||||
prefixIcon: Icon(Icons.folder, semanticLabel: 'Repository name'),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
@ -183,8 +200,7 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
|
||||
TextField(
|
||||
controller: _clientId,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'OAuth App client id',
|
||||
helperText: 'Leave as the baked-in default unless self-hosting',
|
||||
prefixIcon: Icon(Icons.key, semanticLabel: 'OAuth App client id'),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
@ -193,15 +209,17 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
|
||||
controller: _token,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Access token (fallback)',
|
||||
helperText: 'Contents: read/write on the sync repo only',
|
||||
prefixIcon: Icon(
|
||||
Icons.vpn_key,
|
||||
semanticLabel: 'Access token (fallback)',
|
||||
),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
IconButton.outlined(
|
||||
onPressed: _testing ? null : _test,
|
||||
icon: _testing
|
||||
? const SizedBox(
|
||||
@ -209,20 +227,21 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.wifi_tethering),
|
||||
label: const Text('Test connection'),
|
||||
: const Icon(
|
||||
Icons.wifi_tethering,
|
||||
semanticLabel: 'Test connection',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
FilledButton.icon(
|
||||
IconButton.filled(
|
||||
onPressed: _save,
|
||||
icon: const Icon(Icons.save),
|
||||
label: const Text('Save'),
|
||||
icon: const Icon(Icons.save, semanticLabel: 'Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_status != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(_status!, style: Theme.of(context).textTheme.bodyMedium),
|
||||
StatusCueView(_status!),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
@ -9,12 +9,12 @@ library;
|
||||
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:todo/attachments/image_store_api.dart';
|
||||
import 'package:todo/attachments/image_store_scope.dart';
|
||||
import 'package:todo/images/attachment_ref.dart';
|
||||
import 'package:todo/ui/cue_snack.dart';
|
||||
|
||||
/// Lets a screen insert lines into whichever editor is currently mounted.
|
||||
class ImageAttachController {
|
||||
@ -73,11 +73,15 @@ Future<void> attachImages(
|
||||
target.insert([for (final ref in refs) ref.imageMarkdown]);
|
||||
if (!context.mounted) return;
|
||||
if (failure != null) {
|
||||
showError(context, failure);
|
||||
showCue(context, Icons.cloud_off, failure, error: true);
|
||||
} else if (notImages > 0) {
|
||||
showError(
|
||||
// A broken-image glyph plus how many were refused.
|
||||
showCue(
|
||||
context,
|
||||
Icons.broken_image_outlined,
|
||||
notImages == 1 ? 'Not an image' : '$notImages files were not images',
|
||||
error: true,
|
||||
count: notImages,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -104,5 +108,7 @@ Future<void> attachFiles(
|
||||
}
|
||||
}
|
||||
target.insert([for (final attachment in attached) attachment.markdown]);
|
||||
if (failure != null && context.mounted) showError(context, failure);
|
||||
if (failure != null && context.mounted) {
|
||||
showCue(context, Icons.cloud_off, failure, error: true);
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:todo/attachments/image_store_api.dart';
|
||||
import 'package:todo/attachments/image_store_scope.dart';
|
||||
import 'package:todo/images/attachment_ref.dart';
|
||||
import 'package:todo/ui/cue_snack.dart';
|
||||
import 'package:todo/ui/images/file_opening.dart';
|
||||
import 'package:todo/ui/images/image_embed.dart';
|
||||
|
||||
@ -51,7 +52,9 @@ class _FileEmbedState extends State<FileEmbed> {
|
||||
|
||||
Future<void> _open(ImageSource source) async {
|
||||
final failure = await widget.opening(source, widget.attachment.name);
|
||||
if (failure != null && mounted) showError(context, failure);
|
||||
if (failure != null && mounted) {
|
||||
showCue(context, Icons.block, failure, error: true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@ -73,9 +76,10 @@ class _FileEmbedState extends State<FileEmbed> {
|
||||
style: widget.compact ? theme.textTheme.bodySmall : null,
|
||||
);
|
||||
final pending = store.isPending(attachment.ref)
|
||||
? const Tooltip(
|
||||
message: kImageWaitingToUpload,
|
||||
child: Icon(Icons.cloud_upload_outlined, size: 18),
|
||||
? const Icon(
|
||||
Icons.cloud_upload_outlined,
|
||||
size: 18,
|
||||
semanticLabel: kImageWaitingToUpload,
|
||||
)
|
||||
: null;
|
||||
final content = widget.compact
|
||||
@ -104,15 +108,21 @@ class _FileEmbedState extends State<FileEmbed> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
name,
|
||||
Text(detail, style: theme.textTheme.bodySmall),
|
||||
// Not downloaded: a download glyph; else the size.
|
||||
if (size == null)
|
||||
const Icon(Icons.cloud_download_outlined, size: 16)
|
||||
else
|
||||
Text(detail, style: theme.textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
?pending,
|
||||
],
|
||||
);
|
||||
// Spoken only: nothing paints this string.
|
||||
final semanticLabel = 'Attached file ${attachment.name}, $detail';
|
||||
return Semantics(
|
||||
label: 'Attached file ${attachment.name}, $detail',
|
||||
label: semanticLabel,
|
||||
button: size != null,
|
||||
child: Material(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
|
||||
@ -33,8 +33,10 @@ class ImageEmbed extends StatelessWidget {
|
||||
final store = ImageStoreScope.of(context);
|
||||
final source = store.sourceFor(ref);
|
||||
final picture = imageFor(source, fit: fit);
|
||||
// Spoken only: nothing paints this string.
|
||||
const semanticLabel = 'Attached image';
|
||||
return Semantics(
|
||||
label: 'Attached image',
|
||||
label: semanticLabel,
|
||||
button: source is! MissingImageSource,
|
||||
child: GestureDetector(
|
||||
onTap: source is MissingImageSource
|
||||
@ -52,9 +54,10 @@ class ImageEmbed extends StatelessWidget {
|
||||
const Positioned(
|
||||
right: 4,
|
||||
top: 4,
|
||||
child: Tooltip(
|
||||
message: kImageWaitingToUpload,
|
||||
child: Icon(Icons.cloud_upload_outlined, size: 18),
|
||||
child: Icon(
|
||||
Icons.cloud_upload_outlined,
|
||||
size: 18,
|
||||
semanticLabel: kImageWaitingToUpload,
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -85,7 +88,7 @@ Widget imageFor(ImageSource source, {required BoxFit fit}) {
|
||||
};
|
||||
}
|
||||
|
||||
/// Grey box with [kImageNotDownloaded].
|
||||
/// Grey box with a download glyph, spoken as [kImageNotDownloaded].
|
||||
class ImagePlaceholder extends StatelessWidget {
|
||||
/// Creates the placeholder.
|
||||
const ImagePlaceholder({super.key});
|
||||
@ -98,10 +101,10 @@ class ImagePlaceholder extends StatelessWidget {
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Text(
|
||||
kImageNotDownloaded,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall,
|
||||
child: Icon(
|
||||
Icons.cloud_download_outlined,
|
||||
color: theme.colorScheme.outline,
|
||||
semanticLabel: kImageNotDownloaded,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -6,10 +6,11 @@ library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_template.dart';
|
||||
import 'package:todo/ui/confirm_delete.dart';
|
||||
import 'package:todo/ui/enum_glyphs.dart';
|
||||
import 'package:todo/ui/note_sheet_details.dart';
|
||||
import 'package:todo/ui/note_task_details.dart';
|
||||
|
||||
@ -61,53 +62,54 @@ class _NoteActionsSheetState extends State<NoteActionsSheet> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
firstLine.isEmpty ? '(empty)' : firstLine,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
// User content; untitled shows nothing, not a placeholder.
|
||||
if (firstLine.isNotEmpty)
|
||||
Text(
|
||||
firstLine,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
NoteSheetDetails(note: widget.note),
|
||||
const SizedBox(height: 16),
|
||||
_EnumChips<Status>(
|
||||
label: 'Status',
|
||||
GlyphChips<Status>(
|
||||
heading: kStatusHeadingIcon,
|
||||
semanticLabel: 'Status',
|
||||
values: Status.values,
|
||||
selected: {_status},
|
||||
labelOf: (s) => s.label,
|
||||
onSelected: (s) {
|
||||
glyphOf: statusGlyph,
|
||||
onTap: (s) {
|
||||
setState(() => _status = s);
|
||||
unawaited(_persist());
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_EnumChips<Priority>(
|
||||
label: 'Priority',
|
||||
GlyphChips<Priority>(
|
||||
heading: kPriorityHeadingIcon,
|
||||
semanticLabel: 'Priority',
|
||||
values: Priority.values,
|
||||
selected: {_priority},
|
||||
labelOf: (p) => p.label,
|
||||
onSelected: (p) {
|
||||
glyphOf: (p) => priorityGlyph(context, p),
|
||||
onTap: (p) {
|
||||
setState(() => _priority = p);
|
||||
unawaited(_persist());
|
||||
},
|
||||
),
|
||||
NoteTaskDetails(note: widget.note),
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
label: const Text('Delete note'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.error,
|
||||
Align(
|
||||
child: IconButton.outlined(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
icon: const Icon(
|
||||
Icons.delete_outline,
|
||||
semanticLabel: 'Delete note',
|
||||
),
|
||||
onPressed: () async {
|
||||
if (!await confirmDeleteNote(context)) return;
|
||||
await widget.onDelete();
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
onPressed: () async {
|
||||
final confirmed = await confirmDestructive(
|
||||
context,
|
||||
title: 'Delete note?',
|
||||
message: 'This cannot be undone.',
|
||||
);
|
||||
if (!confirmed) return;
|
||||
await widget.onDelete();
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -116,42 +118,3 @@ class _NoteActionsSheetState extends State<NoteActionsSheet> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Single-select chip row for an enum (used for per-note priority/status).
|
||||
class _EnumChips<T> extends StatelessWidget {
|
||||
const _EnumChips({
|
||||
required this.label,
|
||||
required this.values,
|
||||
required this.selected,
|
||||
required this.labelOf,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final List<T> values;
|
||||
final Set<T> selected;
|
||||
final String Function(T) labelOf;
|
||||
final ValueChanged<T> onSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: Theme.of(context).textTheme.labelLarge),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
for (final v in values)
|
||||
ChoiceChip(
|
||||
label: Text(labelOf(v)),
|
||||
selected: selected.contains(v),
|
||||
onSelected: (_) => onSelected(v),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,19 +9,29 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Glyph for "created" (a note added), shared by the filter and sort menus.
|
||||
const IconData kCreatedIcon = Icons.post_add;
|
||||
|
||||
/// Glyph for "last updated", shared by the filter and sort menus.
|
||||
const IconData kUpdatedIcon = Icons.update;
|
||||
|
||||
/// A labelled date range shown as one preset menu plus a custom picker.
|
||||
class DateRangeField extends StatelessWidget {
|
||||
/// Creates a labelled date-range control.
|
||||
const DateRangeField({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.semanticLabel,
|
||||
required this.from,
|
||||
required this.to,
|
||||
required this.onChanged,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Heading shown above the control (e.g. 'Created').
|
||||
final String label;
|
||||
/// Glyph shown in the control's border in place of a written heading.
|
||||
final IconData icon;
|
||||
|
||||
/// Spoken name of the control (e.g. 'Created'); never painted.
|
||||
final String semanticLabel;
|
||||
|
||||
/// Start of the selected range, or null when unbounded.
|
||||
final DateTime? from;
|
||||
@ -86,15 +96,16 @@ class DateRangeField extends StatelessWidget {
|
||||
// A popup menu rather than DropdownButton: re-picking "Custom…" while a
|
||||
// custom range is shown must still reopen the picker.
|
||||
return PopupMenuButton<_Preset>(
|
||||
tooltip: label,
|
||||
// Empty, not the label: a tooltip is painted on long-press.
|
||||
tooltip: '',
|
||||
onSelected: (p) => _select(context, p),
|
||||
itemBuilder: (_) => [
|
||||
for (final p in _Preset.values)
|
||||
PopupMenuItem(value: p, child: Text(p.label)),
|
||||
PopupMenuItem(value: p, child: p.glyph()),
|
||||
],
|
||||
child: InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
prefixIcon: Icon(icon, semanticLabel: semanticLabel),
|
||||
isDense: true,
|
||||
border: const OutlineInputBorder(),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
@ -103,10 +114,9 @@ class DateRangeField extends StatelessWidget {
|
||||
),
|
||||
suffixIcon: const Icon(Icons.arrow_drop_down),
|
||||
),
|
||||
child: Text(
|
||||
current == _Preset.custom ? _rangeLabel() : current.label,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
child: current == _Preset.custom
|
||||
? Text(_rangeLabel(), style: theme.textTheme.bodyMedium)
|
||||
: current.glyph(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -131,8 +141,26 @@ enum _Preset {
|
||||
|
||||
const _Preset(this.label, this.days);
|
||||
|
||||
/// Spoken name only; what is painted is [glyph].
|
||||
final String label;
|
||||
final int? days;
|
||||
|
||||
/// "Any" is an infinity sign, "today" the today-calendar, a span its day
|
||||
/// count beside a range-calendar, "custom" the edit-calendar.
|
||||
Widget glyph() => Semantics(
|
||||
label: label,
|
||||
excludeSemantics: true,
|
||||
container: true,
|
||||
child: switch (this) {
|
||||
any => const Icon(Icons.all_inclusive),
|
||||
today => const Icon(Icons.today),
|
||||
custom => const Icon(Icons.edit_calendar),
|
||||
week || month => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [const Icon(Icons.date_range), Text(' $days')],
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
static const List<_Preset> dayPresets = [today, week, month];
|
||||
}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:todo/data/app_settings.dart';
|
||||
@ -8,6 +7,7 @@ import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_repository.dart';
|
||||
import 'package:todo/data/note_template.dart';
|
||||
import 'package:todo/ui/attachment_picking.dart';
|
||||
import 'package:todo/ui/confirm_delete.dart';
|
||||
import 'package:todo/ui/image_attach.dart';
|
||||
import 'package:todo/ui/image_input.dart';
|
||||
import 'package:todo/ui/note_form.dart';
|
||||
@ -85,12 +85,7 @@ class _NoteDetailScreenState extends State<NoteDetailScreen> {
|
||||
_persist(_note.copyWith(text: text, updatedAt: DateTime.now()));
|
||||
|
||||
Future<void> _delete() async {
|
||||
final confirmed = await confirmDestructive(
|
||||
context,
|
||||
title: 'Delete note?',
|
||||
message: 'This cannot be undone.',
|
||||
);
|
||||
if (!confirmed) return;
|
||||
if (!await confirmDeleteNote(context)) return;
|
||||
await widget.repository.delete(_note.id);
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
}
|
||||
@ -100,7 +95,8 @@ class _NoteDetailScreenState extends State<NoteDetailScreen> {
|
||||
final title = noteTitle(_note.text);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(title.isEmpty ? '(empty)' : title),
|
||||
// An untitled note shows no title rather than a written placeholder.
|
||||
title: title.isEmpty ? null : Text(title),
|
||||
actions: [
|
||||
AttachButton(
|
||||
onFiles: _attachFiles,
|
||||
@ -114,8 +110,10 @@ class _NoteDetailScreenState extends State<NoteDetailScreen> {
|
||||
onPressed: _copy,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Delete note',
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
icon: const Icon(
|
||||
Icons.delete_outline,
|
||||
semanticLabel: 'Delete note',
|
||||
),
|
||||
onPressed: _delete,
|
||||
),
|
||||
],
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_template.dart';
|
||||
import 'package:todo/ui/cue_snack.dart';
|
||||
import 'package:todo/ui/image_attach.dart';
|
||||
import 'package:todo/ui/markdown_view.dart';
|
||||
import 'package:todo/ui/note_editor_chrome.dart';
|
||||
@ -127,9 +127,12 @@ class _NoteEditorState extends State<NoteEditor> {
|
||||
case NoteEditorMode.guided:
|
||||
// raw -> guided: only if the edited text still fits the template.
|
||||
if (!_doc.tryAdoptGuided()) {
|
||||
showError(
|
||||
// The mode toggle staying on raw is the second channel.
|
||||
showCue(
|
||||
context,
|
||||
Icons.rule,
|
||||
"Text doesn't match the template — staying in raw",
|
||||
error: true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:todo/data/note_template.dart';
|
||||
import 'package:todo/ui/note_editor_mode.dart';
|
||||
import 'package:todo/ui/template_glyphs.dart';
|
||||
|
||||
/// Template dropdown above a segmented View / Guided / Raw selector.
|
||||
class NoteEditorChrome extends StatelessWidget {
|
||||
@ -43,20 +44,20 @@ class NoteEditorChrome extends StatelessWidget {
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: template.id,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Template',
|
||||
// A prefix, not a floating label: a floated icon sat on the
|
||||
// border on top of the selected template's glyph.
|
||||
prefixIcon: Icon(Icons.description, semanticLabel: 'Template'),
|
||||
isDense: true,
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
),
|
||||
items: [
|
||||
for (final t in NoteTemplate.all)
|
||||
DropdownMenuItem(value: t.id, child: Text(t.label)),
|
||||
DropdownMenuItem(value: t.id, child: templateGlyph(t)),
|
||||
],
|
||||
onChanged: (id) {
|
||||
if (id == null) return;
|
||||
onTemplateSelected(
|
||||
NoteTemplate.all.firstWhere((t) => t.id == id),
|
||||
);
|
||||
onTemplateSelected(NoteTemplate.all.firstWhere((t) => t.id == id));
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@ -67,8 +68,7 @@ class NoteEditorChrome extends StatelessWidget {
|
||||
segments: [
|
||||
const ButtonSegment(
|
||||
value: NoteEditorMode.preview,
|
||||
icon: Icon(Icons.visibility_outlined),
|
||||
label: Text('View'),
|
||||
icon: Icon(Icons.visibility_outlined, semanticLabel: 'View'),
|
||||
),
|
||||
// Guided is offered for any structured template; tapping it
|
||||
// on an empty draft opens the wizard, and is blocked at
|
||||
@ -76,13 +76,11 @@ class NoteEditorChrome extends StatelessWidget {
|
||||
if (!template.isFreeform)
|
||||
const ButtonSegment(
|
||||
value: NoteEditorMode.guided,
|
||||
icon: Icon(Icons.checklist),
|
||||
label: Text('Guided'),
|
||||
icon: Icon(Icons.checklist, semanticLabel: 'Guided'),
|
||||
),
|
||||
const ButtonSegment(
|
||||
value: NoteEditorMode.raw,
|
||||
icon: Icon(Icons.notes),
|
||||
label: Text('Raw'),
|
||||
icon: Icon(Icons.notes, semanticLabel: 'Raw'),
|
||||
),
|
||||
],
|
||||
selected: {mode},
|
||||
|
||||
@ -44,10 +44,8 @@ class NoteEditorRawField extends StatelessWidget {
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
keyboardType: TextInputType.multiline,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
decoration: const InputDecoration(
|
||||
border: InputBorder.none,
|
||||
hintText: 'Write your idea…',
|
||||
),
|
||||
// No hint: the cursor in an empty full-height box says "type".
|
||||
decoration: const InputDecoration(border: InputBorder.none),
|
||||
onChanged: (_) => onChanged(),
|
||||
),
|
||||
),
|
||||
|
||||
@ -16,6 +16,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:todo/data/note_template.dart';
|
||||
import 'package:todo/ui/motion.dart';
|
||||
import 'package:todo/ui/template_glyphs.dart';
|
||||
|
||||
/// One step of the guided editor: label, guidance, input, and navigation.
|
||||
class NoteEditorStepPage extends StatelessWidget {
|
||||
@ -66,8 +67,7 @@ class NoteEditorStepPage extends StatelessWidget {
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
tooltip: 'Exit guided',
|
||||
icon: const Icon(Icons.arrow_back, semanticLabel: 'Exit guided'),
|
||||
onPressed: onExit,
|
||||
),
|
||||
),
|
||||
@ -96,16 +96,14 @@ class NoteEditorStepPage extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
section.isTitle ? 'title' : section.label,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
section.helper,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
// A glyph per section; its label (the note's `## heading`) and
|
||||
// the helper sentence are spoken only.
|
||||
Icon(
|
||||
sectionIcon(section),
|
||||
color: theme.colorScheme.primary,
|
||||
semanticLabel: section.isTitle
|
||||
? section.helper
|
||||
: '${section.label}: ${section.helper}',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
@ -123,9 +121,10 @@ class NoteEditorStepPage extends StatelessWidget {
|
||||
maxLines: null,
|
||||
keyboardType: TextInputType.multiline,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
decoration: InputDecoration(
|
||||
hintText: section.hint,
|
||||
border: const OutlineInputBorder(),
|
||||
// No hintText: a hint is painted text. The section glyph
|
||||
// above already names the field.
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: (_) => onChanged(),
|
||||
@ -140,16 +139,22 @@ class NoteEditorStepPage extends StatelessWidget {
|
||||
if (index > 0)
|
||||
TextButton(
|
||||
onPressed: () => onGoToStep(index - 1),
|
||||
child: const Text('Back'),
|
||||
child: const Icon(
|
||||
Icons.navigate_before,
|
||||
semanticLabel: 'Back',
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (index < total - 1)
|
||||
FilledButton(
|
||||
onPressed: () => onGoToStep(index + 1),
|
||||
child: const Text('Next'),
|
||||
child: const Icon(Icons.navigate_next, semanticLabel: 'Next'),
|
||||
)
|
||||
else
|
||||
FilledButton(onPressed: onExit, child: const Text('Done')),
|
||||
FilledButton(
|
||||
onPressed: onExit,
|
||||
child: const Icon(Icons.check, semanticLabel: 'Done'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@ -13,6 +13,8 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_template.dart';
|
||||
import 'package:todo/ui/enum_glyphs.dart';
|
||||
import 'package:todo/ui/template_glyphs.dart';
|
||||
|
||||
/// Asks for a priority, then a template, then hands both back via [onStart].
|
||||
class NoteEditorWizard extends StatefulWidget {
|
||||
@ -48,7 +50,7 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final semanticLabel = 'Step ${_step + 1} of 2';
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@ -56,11 +58,24 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: 'Cancel',
|
||||
icon: const Icon(Icons.close, semanticLabel: 'Cancel'),
|
||||
onPressed: widget.onCancel,
|
||||
),
|
||||
Text('Step ${_step + 1} of 2', style: theme.textTheme.labelLarge),
|
||||
// Two step dots, the current one filled: position by shape.
|
||||
Semantics(
|
||||
label: semanticLabel,
|
||||
child: ExcludeSemantics(
|
||||
child: Row(
|
||||
children: [
|
||||
for (var i = 0; i < 2; i++)
|
||||
Icon(
|
||||
i == _step ? Icons.circle : Icons.circle_outlined,
|
||||
size: 12,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@ -74,14 +89,14 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
|
||||
DropdownButtonFormField<Priority>(
|
||||
initialValue: _priority,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Priority',
|
||||
prefixIcon: Icon(kPriorityHeadingIcon, semanticLabel: 'Priority'),
|
||||
isDense: true,
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
),
|
||||
items: [
|
||||
for (final p in Priority.values)
|
||||
DropdownMenuItem(value: p, child: Text(p.label)),
|
||||
DropdownMenuItem(value: p, child: priorityGlyph(context, p)),
|
||||
],
|
||||
onChanged: (p) {
|
||||
if (p != null) setState(() => _priority = p);
|
||||
@ -92,7 +107,7 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
|
||||
alignment: Alignment.centerRight,
|
||||
child: FilledButton(
|
||||
onPressed: () => setState(() => _step = 1),
|
||||
child: const Text('Next'),
|
||||
child: const Icon(Icons.navigate_next, semanticLabel: 'Next'),
|
||||
),
|
||||
),
|
||||
];
|
||||
@ -104,14 +119,14 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _template.id,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Template',
|
||||
prefixIcon: Icon(Icons.description, semanticLabel: 'Template'),
|
||||
isDense: true,
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
),
|
||||
items: [
|
||||
for (final t in templates)
|
||||
DropdownMenuItem(value: t.id, child: Text(t.label)),
|
||||
DropdownMenuItem(value: t.id, child: templateGlyph(t)),
|
||||
],
|
||||
onChanged: (id) {
|
||||
if (id == null) return;
|
||||
@ -124,12 +139,12 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => setState(() => _step = 0),
|
||||
child: const Text('Back'),
|
||||
child: const Icon(Icons.arrow_back, semanticLabel: 'Back'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
onPressed: () => widget.onStart(_priority, _template),
|
||||
child: const Text('Start'),
|
||||
child: const Icon(Icons.play_arrow, semanticLabel: 'Start'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_template.dart';
|
||||
import 'package:todo/ui/enum_glyphs.dart';
|
||||
|
||||
/// Danger for high, the accent for medium, muted for low.
|
||||
Color priorityColor(Priority p, ColorScheme scheme) => switch (p) {
|
||||
@ -14,6 +15,9 @@ Color priorityColor(Priority p, ColorScheme scheme) => switch (p) {
|
||||
Priority.low => scheme.outline,
|
||||
};
|
||||
|
||||
/// Painted in place of an empty note's title: a mark, not a word.
|
||||
const String kEmptyTitle = '…';
|
||||
|
||||
/// The glyph standing for [s] wherever a status is shown without words.
|
||||
IconData statusIcon(Status s) => switch (s) {
|
||||
Status.todo => Icons.radio_button_unchecked,
|
||||
@ -85,7 +89,7 @@ class NoteGridTile extends StatelessWidget {
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title.isEmpty ? '(empty)' : title,
|
||||
title.isEmpty ? kEmptyTitle : title,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
@ -115,25 +119,20 @@ class NoteGridTile extends StatelessWidget {
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
// Shape + the tile's accent border carry status/priority;
|
||||
// the enum words survive only as spoken labels.
|
||||
Icon(
|
||||
statusIcon(note.status),
|
||||
size: 14,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
Flexible(
|
||||
child: Text(
|
||||
note.status.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: cue,
|
||||
),
|
||||
semanticLabel: note.status.label,
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
note.priority.label,
|
||||
maxLines: 1,
|
||||
style: cue?.copyWith(color: accentFor(scheme)),
|
||||
Icon(
|
||||
priorityIcon(note.priority),
|
||||
size: 14,
|
||||
color: accentFor(scheme),
|
||||
semanticLabel: note.priority.label,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@ -8,24 +8,30 @@ library;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/ui/enum_glyphs.dart';
|
||||
|
||||
/// A compact labelled dropdown for picking an enum value (priority/status).
|
||||
/// A compact dropdown for picking an enum value (priority/status), headed by
|
||||
/// a glyph and showing each value as its glyph.
|
||||
///
|
||||
/// Generic over the enum type [T] so the same control drives both pickers
|
||||
/// without duplication; [labelOf] maps a value to its display string.
|
||||
/// without duplication; [glyphOf] maps a value to its glyph.
|
||||
class MetaDropdown<T> extends StatelessWidget {
|
||||
/// Creates a dropdown showing [value] out of [values].
|
||||
const MetaDropdown({
|
||||
required this.label,
|
||||
required this.heading,
|
||||
required this.semanticLabel,
|
||||
required this.value,
|
||||
required this.values,
|
||||
required this.labelOf,
|
||||
required this.glyphOf,
|
||||
required this.onChanged,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Label shown above the control.
|
||||
final String label;
|
||||
/// Glyph in the control's border in place of a written heading.
|
||||
final IconData heading;
|
||||
|
||||
/// Spoken name of the control; never painted.
|
||||
final String semanticLabel;
|
||||
|
||||
/// Currently selected value.
|
||||
final T value;
|
||||
@ -33,8 +39,8 @@ class MetaDropdown<T> extends StatelessWidget {
|
||||
/// Every selectable value.
|
||||
final List<T> values;
|
||||
|
||||
/// Maps a value to its display string.
|
||||
final String Function(T) labelOf;
|
||||
/// Maps a value to its glyph.
|
||||
final Widget Function(T) glyphOf;
|
||||
|
||||
/// Called with the newly picked value.
|
||||
final ValueChanged<T> onChanged;
|
||||
@ -43,7 +49,7 @@ class MetaDropdown<T> extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
prefixIcon: Icon(heading, semanticLabel: semanticLabel),
|
||||
isDense: true,
|
||||
border: const OutlineInputBorder(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
@ -55,7 +61,7 @@ class MetaDropdown<T> extends StatelessWidget {
|
||||
isExpanded: true,
|
||||
items: [
|
||||
for (final v in values)
|
||||
DropdownMenuItem<T>(value: v, child: Text(labelOf(v))),
|
||||
DropdownMenuItem<T>(value: v, child: glyphOf(v)),
|
||||
],
|
||||
onChanged: (v) {
|
||||
if (v != null) onChanged(v);
|
||||
@ -98,20 +104,22 @@ class NoteMetaRow extends StatelessWidget {
|
||||
children: [
|
||||
Expanded(
|
||||
child: MetaDropdown<Priority>(
|
||||
label: 'Priority',
|
||||
heading: kPriorityHeadingIcon,
|
||||
semanticLabel: 'Priority',
|
||||
value: priority,
|
||||
values: Priority.values,
|
||||
labelOf: (p) => p.label,
|
||||
glyphOf: (p) => priorityGlyph(context, p),
|
||||
onChanged: onPriorityChanged,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: MetaDropdown<Status>(
|
||||
label: 'Status',
|
||||
heading: kStatusHeadingIcon,
|
||||
semanticLabel: 'Status',
|
||||
value: status,
|
||||
values: Status.values,
|
||||
labelOf: (s) => s.label,
|
||||
glyphOf: statusGlyph,
|
||||
onChanged: onStatusChanged,
|
||||
),
|
||||
),
|
||||
|
||||
@ -39,12 +39,25 @@ class NoteSheetDetails extends StatelessWidget {
|
||||
Text(body, style: theme.bodyMedium),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Edited ${_stamp(note.updatedAt)}'
|
||||
' · Created ${_stamp(note.createdAt)}',
|
||||
style: theme.bodySmall,
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
children: [
|
||||
_dated(Icons.history, 'Edited', note.updatedAt, theme.bodySmall),
|
||||
_dated(Icons.add, 'Created', note.createdAt, theme.bodySmall),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// A glyph (edited = history, created = plus) before its timestamp.
|
||||
Widget _dated(IconData icon, String spoken, DateTime t, TextStyle? style) =>
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 16, semanticLabel: spoken),
|
||||
const SizedBox(width: 4),
|
||||
Text(_stamp(t), style: style),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -12,20 +12,27 @@ class NoteTaskDetails extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context).textTheme;
|
||||
// A glyph per field in place of its written name (kept spoken only);
|
||||
// the values are content and stay as typed.
|
||||
final rows = [
|
||||
('Repo', note.repo),
|
||||
('Done check', note.doneCheck),
|
||||
('By', note.by),
|
||||
('Evidence', note.evidence),
|
||||
].where((row) => row.$2 != null);
|
||||
(Icons.folder_outlined, 'Repo', note.repo),
|
||||
(Icons.fact_check_outlined, 'Done check', note.doneCheck),
|
||||
(Icons.person_outline, 'By', note.by),
|
||||
(Icons.verified_outlined, 'Evidence', note.evidence),
|
||||
].where((row) => row.$3 != null);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final (label, value) in rows) ...[
|
||||
for (final (icon, label, value) in rows) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(label, style: theme.labelLarge),
|
||||
const SizedBox(height: 4),
|
||||
Text(value!, style: theme.bodyMedium),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, size: 20, semanticLabel: label),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(value!, style: theme.bodyMedium)),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
@ -8,6 +8,8 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_template.dart';
|
||||
import 'package:todo/ui/enum_glyphs.dart';
|
||||
import 'package:todo/ui/note_grid_tile.dart';
|
||||
|
||||
/// One row in the notes list: title, metadata line, and an actions button.
|
||||
class NoteTile extends StatelessWidget {
|
||||
@ -37,27 +39,19 @@ class NoteTile extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final firstLine = noteTitle(note.text);
|
||||
// Every note has a status and a priority now, so both are always shown.
|
||||
final meta = <String>[
|
||||
note.status.label,
|
||||
note.priority.label,
|
||||
'edited ${_relative(note.updatedAt)}',
|
||||
?note.repoName,
|
||||
].join(' · ');
|
||||
return ListTile(
|
||||
selected: selected,
|
||||
leading: selected
|
||||
? const Icon(Icons.check_circle, semanticLabel: 'Selected')
|
||||
: null,
|
||||
title: Text(
|
||||
firstLine.isEmpty ? '(empty)' : firstLine,
|
||||
firstLine.isEmpty ? kEmptyTitle : firstLine,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(meta),
|
||||
subtitle: _meta(context),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
tooltip: 'Quick actions',
|
||||
icon: const Icon(Icons.more_vert, semanticLabel: 'Quick actions'),
|
||||
onPressed: onActions,
|
||||
),
|
||||
onTap: onTap,
|
||||
@ -65,14 +59,28 @@ class NoteTile extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Compact relative time like "2m ago" for the list subtitle.
|
||||
String _relative(DateTime t) {
|
||||
final d = DateTime.now().difference(t);
|
||||
if (d.inMinutes < 1) return 'just now';
|
||||
if (d.inHours < 1) return '${d.inMinutes}m ago';
|
||||
if (d.inDays < 1) return '${d.inHours}h ago';
|
||||
return '${d.inDays}d ago';
|
||||
}
|
||||
/// Status and priority glyphs, the edit age, and the repo (content).
|
||||
/// Every note has a status and a priority, so both are always shown.
|
||||
Widget _meta(BuildContext context) => Row(
|
||||
children: [
|
||||
statusGlyph(note.status, size: 16),
|
||||
const SizedBox(width: 6),
|
||||
priorityGlyph(context, note.priority, size: 16),
|
||||
const SizedBox(width: 6),
|
||||
const Icon(Icons.history, size: 16, semanticLabel: 'Edited'),
|
||||
Text(relativeAge(note.updatedAt)),
|
||||
if (note.repoName case final repo?) ...[
|
||||
const SizedBox(width: 6),
|
||||
Flexible(child: Text(repo, overflow: TextOverflow.ellipsis)),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Bottom sheet for editing one note's priority/status or deleting it.
|
||||
/// Compact age like "2m" / "3h" / "4d": a number plus a unit symbol.
|
||||
String relativeAge(DateTime t) {
|
||||
final d = DateTime.now().difference(t);
|
||||
if (d.inHours < 1) return '${d.inMinutes}m';
|
||||
if (d.inDays < 1) return '${d.inHours}h';
|
||||
return '${d.inDays}d';
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_repository.dart';
|
||||
import 'package:todo/ui/enum_glyphs.dart';
|
||||
import 'package:todo/ui/note_date_range_field.dart';
|
||||
import 'package:todo/ui/notes_list_defaults.dart';
|
||||
|
||||
@ -66,7 +67,6 @@ class _FilterSheetState extends State<FilterSheet> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
@ -82,33 +82,38 @@ class _FilterSheetState extends State<FilterSheet> {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text('Filters', style: theme.textTheme.titleLarge),
|
||||
const Icon(Icons.filter_list, semanticLabel: 'Filters'),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: _clearAll,
|
||||
child: const Text('Clear all'),
|
||||
child: const Icon(Icons.clear, semanticLabel: 'Clear all'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_MultiChips<Status>(
|
||||
label: 'Status',
|
||||
GlyphChips<Status>(
|
||||
multi: true,
|
||||
heading: kStatusHeadingIcon,
|
||||
semanticLabel: 'Status',
|
||||
values: Status.values,
|
||||
selected: _statuses,
|
||||
labelOf: (s) => s.label,
|
||||
onToggle: (s) => _toggle(_statuses, s),
|
||||
glyphOf: statusGlyph,
|
||||
onTap: (s) => _toggle(_statuses, s),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_MultiChips<Priority>(
|
||||
label: 'Priority',
|
||||
GlyphChips<Priority>(
|
||||
multi: true,
|
||||
heading: kPriorityHeadingIcon,
|
||||
semanticLabel: 'Priority',
|
||||
values: Priority.values,
|
||||
selected: _priorities,
|
||||
labelOf: (p) => p.label,
|
||||
onToggle: (p) => _toggle(_priorities, p),
|
||||
glyphOf: (p) => priorityGlyph(context, p),
|
||||
onTap: (p) => _toggle(_priorities, p),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DateRangeField(
|
||||
label: 'Created',
|
||||
icon: kCreatedIcon,
|
||||
semanticLabel: 'Created',
|
||||
from: _createdFrom,
|
||||
to: _createdTo,
|
||||
onChanged: (from, to) => setState(() {
|
||||
@ -118,7 +123,8 @@ class _FilterSheetState extends State<FilterSheet> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DateRangeField(
|
||||
label: 'Last updated',
|
||||
icon: kUpdatedIcon,
|
||||
semanticLabel: 'Last updated',
|
||||
from: _updatedFrom,
|
||||
to: _updatedTo,
|
||||
onChanged: (from, to) => setState(() {
|
||||
@ -129,7 +135,7 @@ class _FilterSheetState extends State<FilterSheet> {
|
||||
const SizedBox(height: 20),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(_build()),
|
||||
child: const Text('Apply'),
|
||||
child: const Icon(Icons.check, semanticLabel: 'Apply'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -139,45 +145,6 @@ class _FilterSheetState extends State<FilterSheet> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Multi-select chip group for an enum (used by the filter sheet).
|
||||
class _MultiChips<T> extends StatelessWidget {
|
||||
const _MultiChips({
|
||||
required this.label,
|
||||
required this.values,
|
||||
required this.selected,
|
||||
required this.labelOf,
|
||||
required this.onToggle,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final List<T> values;
|
||||
final Set<T> selected;
|
||||
final String Function(T) labelOf;
|
||||
final ValueChanged<T> onToggle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: Theme.of(context).textTheme.labelLarge),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
for (final v in values)
|
||||
FilterChip(
|
||||
label: Text(labelOf(v)),
|
||||
selected: selected.contains(v),
|
||||
onSelected: (_) => onToggle(v),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A date-range control offering quick presets plus a custom range picker.
|
||||
///
|
||||
/// Reports the chosen [from]/[to] (day granularity, both inclusive) back to
|
||||
|
||||
@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:todo/data/app_settings.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_repository.dart';
|
||||
import 'package:todo/ui/empty_notes_cue.dart';
|
||||
import 'package:todo/ui/note_selection.dart';
|
||||
import 'package:todo/ui/notes_assign_flow.dart';
|
||||
import 'package:todo/ui/notes_filter_sheet.dart';
|
||||
@ -134,7 +135,7 @@ class _NotesListScreenState extends State<NotesListScreen> {
|
||||
final badgeCount = userFacetCount(_filter);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Notes'),
|
||||
title: const Icon(Icons.note, semanticLabel: 'Notes'),
|
||||
actions: [
|
||||
_selectButton(),
|
||||
// Eye, no label: open = Claude-made notes shown, slashed = hidden.
|
||||
@ -148,26 +149,27 @@ class _NotesListScreenState extends State<NotesListScreen> {
|
||||
onPressed: _toggleClaudeNotes,
|
||||
),
|
||||
PopupMenuButton<NoteSort>(
|
||||
tooltip: 'Sort',
|
||||
icon: const Icon(Icons.sort),
|
||||
// Empty, not omitted: null falls back to the painted "Show menu".
|
||||
tooltip: '',
|
||||
icon: const Icon(Icons.sort, semanticLabel: 'Sort'),
|
||||
initialValue: _sort,
|
||||
onSelected: _setSort,
|
||||
itemBuilder: (_) => const [
|
||||
PopupMenuItem(
|
||||
value: NoteSort.modifiedDesc,
|
||||
child: Text('Last updated'),
|
||||
child: Icon(Icons.update, semanticLabel: 'Last updated'),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: NoteSort.createdDesc,
|
||||
child: Text('Newest created'),
|
||||
child: Icon(Icons.post_add, semanticLabel: 'Newest created'),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: NoteSort.alphabetical,
|
||||
child: Text('Alphabetical'),
|
||||
child: Icon(Icons.sort_by_alpha, semanticLabel: 'Alphabetical'),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: NoteSort.priorityDesc,
|
||||
child: Text('Priority'),
|
||||
child: Icon(Icons.flag_outlined, semanticLabel: 'Priority'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -181,8 +183,7 @@ class _NotesListScreenState extends State<NotesListScreen> {
|
||||
label: Text('$badgeCount'),
|
||||
offset: const Offset(-8, 4),
|
||||
child: IconButton(
|
||||
tooltip: 'Filter',
|
||||
icon: const Icon(Icons.filter_list),
|
||||
icon: const Icon(Icons.filter_list, semanticLabel: 'Filter'),
|
||||
onPressed: _openFilters,
|
||||
),
|
||||
),
|
||||
@ -198,15 +199,16 @@ class _NotesListScreenState extends State<NotesListScreen> {
|
||||
onChanged: _onSearchChanged,
|
||||
textInputAction: TextInputAction.search,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search notes…',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
isDense: true,
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: _searchController.text.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
tooltip: 'Clear search',
|
||||
icon: const Icon(Icons.clear),
|
||||
icon: const Icon(
|
||||
Icons.clear,
|
||||
semanticLabel: 'Clear search',
|
||||
),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
_onSearchChanged('');
|
||||
@ -223,11 +225,7 @@ class _NotesListScreenState extends State<NotesListScreen> {
|
||||
final notes = snapshot.data ?? const <Note>[];
|
||||
if (notes.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
_filter.isEmpty
|
||||
? 'No notes yet'
|
||||
: 'No notes match these filters',
|
||||
),
|
||||
child: EmptyNotesCue(filtered: !_filter.isEmpty),
|
||||
);
|
||||
}
|
||||
return NotesListBody(
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:crdt_sync_flutter/crdt_sync_flutter.dart'
|
||||
show E2ePassphraseButton;
|
||||
import 'package:file_selector/file_selector.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
@ -13,6 +15,7 @@ import 'package:todo/sync/backlog_export.dart';
|
||||
import 'package:todo/sync/firebase_backend.dart';
|
||||
import 'package:todo/sync/google_sign_in_backend.dart';
|
||||
import 'package:todo/sync/notes_markdown.dart';
|
||||
import 'package:todo/sync/run_sync.dart';
|
||||
import 'package:todo/sync/sync_settings.dart';
|
||||
import 'package:todo/ui/app_route.dart';
|
||||
import 'package:todo/ui/cloud_images_settings.dart';
|
||||
@ -105,11 +108,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
/// session is available, best-effort mirrors it so the choice follows the
|
||||
/// user to their other devices.
|
||||
///
|
||||
/// The local write always lands — a signed-in-as-wrong-account device
|
||||
/// throwing [FirebaseAuthError] out of `openFirebase()` must not take the
|
||||
/// toggle down with it, so opening the client has its own try/catch,
|
||||
/// separate from (and preceding) the try that guards the local write and
|
||||
/// mirror push.
|
||||
/// The local write always lands: a [FirebaseAuthError] from opening the
|
||||
/// client (wrong account) is caught on its own, before the write.
|
||||
Future<void> _setAdvancedMode(bool value) async {
|
||||
final analytics = widget.analytics;
|
||||
if (analytics != null) {
|
||||
@ -167,6 +167,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
await widget.repository.importNotes(notes);
|
||||
}
|
||||
|
||||
/// Retries the sync after a passphrase is entered; the lock tile follows
|
||||
/// [e2eLocked], which the run updates.
|
||||
Future<void> _syncNow() async {
|
||||
try {
|
||||
await runSync(
|
||||
widget.repository,
|
||||
widget.initial,
|
||||
httpClient: widget.httpClient,
|
||||
firebaseFactory: widget.firebaseFactory,
|
||||
stateStore: widget.stateStore,
|
||||
);
|
||||
} on Exception {
|
||||
// The capture screen's status line reports sync failures.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openSyncSettings() async {
|
||||
await Navigator.of(context).push<void>(
|
||||
appRoute(
|
||||
@ -206,7 +222,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Settings')),
|
||||
appBar: AppBar(
|
||||
title: const Icon(Icons.settings, semanticLabel: 'Settings'),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
@ -214,33 +232,49 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
valueListenable: widget.appSettings,
|
||||
builder: (context, settings, _) => SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Enable advanced'),
|
||||
subtitle: const Text(
|
||||
'Priority/status, templates, view modes, and sync details '
|
||||
'in the capture screen',
|
||||
),
|
||||
secondary: const Icon(Icons.tune, semanticLabel: _advanced),
|
||||
value: settings.advancedMode,
|
||||
onChanged: (value) => unawaited(_setAdvancedMode(value)),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Sync settings'),
|
||||
subtitle: const Text('Firebase sync and notes backup'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => unawaited(_openSyncSettings()),
|
||||
_link(
|
||||
const Icon(Icons.sync, semanticLabel: 'Sync settings: Firebase'),
|
||||
_openSyncSettings,
|
||||
),
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: e2eLocked,
|
||||
builder: (context, locked, _) => ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: E2ePassphraseButton(
|
||||
locked: locked,
|
||||
// A new passphrase is only proven by a sync that unlocks.
|
||||
onSaved: () => unawaited(_syncNow()),
|
||||
),
|
||||
),
|
||||
),
|
||||
CloudImagesTile(httpClient: widget.httpClient),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Advanced sync (GitHub)'),
|
||||
subtitle: const Text('Cutover mirror — not recommended'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => unawaited(_openGitHubMirror()),
|
||||
_link(
|
||||
Icon(
|
||||
Icons.cloud_sync,
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
semanticLabel: 'Advanced sync (GitHub): not recommended',
|
||||
),
|
||||
_openGitHubMirror,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Spoken only: what the advanced switch turns on.
|
||||
static const _advanced = 'Enable advanced: priority, status, view modes';
|
||||
|
||||
/// A row that opens a sub-screen: its glyph (muted = not recommended).
|
||||
Widget _link(Icon icon, Future<void> Function() open) => ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: icon,
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => unawaited(open()),
|
||||
);
|
||||
}
|
||||
|
||||
49
lib/ui/status_cue.dart
Normal file
49
lib/ui/status_cue.dart
Normal file
@ -0,0 +1,49 @@
|
||||
/// A screen's latest result as a glyph in place of a status sentence.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// One result: what to paint, whether it is a failure, and the old
|
||||
/// sentence, kept as the spoken label only.
|
||||
class StatusCue {
|
||||
/// Creates a cue.
|
||||
const StatusCue(this.icon, this.spoken, {this.error = false});
|
||||
|
||||
/// A success: a check glyph in the accent colour.
|
||||
const StatusCue.ok(this.spoken) : icon = Icons.check_circle, error = false;
|
||||
|
||||
/// A failure: an error glyph in the danger hue.
|
||||
const StatusCue.failed(this.spoken) : icon = Icons.error, error = true;
|
||||
|
||||
/// The glyph painted.
|
||||
final IconData icon;
|
||||
|
||||
/// What a screen reader says.
|
||||
final String spoken;
|
||||
|
||||
/// Whether this is a failure (danger hue) rather than a success.
|
||||
final bool error;
|
||||
}
|
||||
|
||||
/// Paints [cue] as a large glyph; colour is the second channel.
|
||||
class StatusCueView extends StatelessWidget {
|
||||
/// Creates the view.
|
||||
const StatusCueView(this.cue, {super.key});
|
||||
|
||||
/// The result shown.
|
||||
final StatusCue cue;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Icon(
|
||||
cue.icon,
|
||||
size: 32,
|
||||
color: cue.error ? scheme.error : scheme.primary,
|
||||
semanticLabel: cue.spoken,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
30
lib/ui/template_glyphs.dart
Normal file
30
lib/ui/template_glyphs.dart
Normal file
@ -0,0 +1,30 @@
|
||||
/// Glyphs standing for note templates and their sections in the editor.
|
||||
///
|
||||
/// The section labels stay in `note_template.dart`: they are the note's own
|
||||
/// `## heading` format, read by `parse()`, BACKLOG.md and the CLI. Only what
|
||||
/// the editor paints around them is a glyph; the label and helper are spoken.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:todo/data/note_template.dart';
|
||||
|
||||
/// The glyph for template [t]: a structured form, or a blank page.
|
||||
IconData templateIcon(NoteTemplate t) =>
|
||||
t.isFreeform ? Icons.note_outlined : Icons.assignment_outlined;
|
||||
|
||||
/// [t] as its glyph, spoken as its label.
|
||||
Widget templateGlyph(NoteTemplate t) =>
|
||||
Icon(templateIcon(t), semanticLabel: t.label);
|
||||
|
||||
/// The glyph heading a guided step, keyed by the section's stable `key`.
|
||||
IconData sectionIcon(TemplateSection s) => switch (s.key) {
|
||||
'title' => Icons.title,
|
||||
'what' => Icons.lightbulb_outline,
|
||||
'where' => Icons.place_outlined,
|
||||
'must' => Icons.rule,
|
||||
'done' => Icons.emoji_events_outlined,
|
||||
'verify' => Icons.devices,
|
||||
'refs' => Icons.menu_book_outlined,
|
||||
_ => Icons.notes,
|
||||
};
|
||||
55
lib/ui/text_free_localizations.dart
Normal file
55
lib/ui/text_free_localizations.dart
Normal file
@ -0,0 +1,55 @@
|
||||
/// Framework strings the app paints without asking for them.
|
||||
///
|
||||
/// Material's own buttons (the app bar back arrow, a dialog's close, the
|
||||
/// drawer, the overflow menu) carry tooltips from [MaterialLocalizations],
|
||||
/// and a tooltip is painted on long-press and hover. kuhy's textgate rule
|
||||
/// says nothing he sees has words he did not ask for, so these tooltips are
|
||||
/// blanked: an empty tooltip is not built at all. Screen readers still get
|
||||
/// the button's semantics from the icon itself.
|
||||
library;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// English Material strings with every button tooltip blanked.
|
||||
class TextFreeMaterialLocalizations extends DefaultMaterialLocalizations {
|
||||
/// Creates the localizations.
|
||||
const TextFreeMaterialLocalizations();
|
||||
|
||||
@override
|
||||
String get backButtonTooltip => '';
|
||||
|
||||
@override
|
||||
String get closeButtonTooltip => '';
|
||||
|
||||
@override
|
||||
String get openAppDrawerTooltip => '';
|
||||
|
||||
@override
|
||||
String get showMenuTooltip => '';
|
||||
|
||||
@override
|
||||
String get moreButtonTooltip => '';
|
||||
|
||||
@override
|
||||
String get deleteButtonTooltip => '';
|
||||
}
|
||||
|
||||
class _Delegate extends LocalizationsDelegate<MaterialLocalizations> {
|
||||
const _Delegate();
|
||||
|
||||
@override
|
||||
bool isSupported(Locale locale) => locale.languageCode == 'en';
|
||||
|
||||
@override
|
||||
Future<MaterialLocalizations> load(Locale locale) =>
|
||||
SynchronousFuture(const TextFreeMaterialLocalizations());
|
||||
|
||||
@override
|
||||
bool shouldReload(_Delegate old) => false;
|
||||
}
|
||||
|
||||
/// Pass as `MaterialApp.localizationsDelegates`.
|
||||
const List<LocalizationsDelegate<dynamic>> kTextFreeLocalizations = [
|
||||
_Delegate(),
|
||||
];
|
||||
73
pubspec.lock
73
pubspec.lock
@ -5,10 +5,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: android_file_picker
|
||||
sha256: "868183c959e754e915954d8074c20cfa4cfdb4684b35ecc08378437be1a542d8"
|
||||
sha256: "04c1306eda42c23ecbf449c909f4fa05c5e66fc48cf5f994d8d08b9d45e5396b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
version: "2.1.5"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -109,11 +109,20 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: crdt_sync_dart
|
||||
ref: "crdt_sync_dart-v0.12.1"
|
||||
resolved-ref: "6641d85f0550ca60433fc8e0d13a3dafdbb664d6"
|
||||
ref: "crdt_sync_dart-v0.14.0"
|
||||
resolved-ref: "84343aec2dd07c38cba40690c3b5f2c84ae9cdf6"
|
||||
url: "https://github.com/kuhyx/utils"
|
||||
source: git
|
||||
version: "0.12.1"
|
||||
version: "0.14.0"
|
||||
crdt_sync_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: crdt_sync_flutter
|
||||
ref: "crdt_sync_flutter-v0.8.0"
|
||||
resolved-ref: e5990c9c15761934110f955529bc1d68479b06ff
|
||||
url: "https://github.com/kuhyx/utils"
|
||||
source: git
|
||||
version: "0.8.0"
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -130,6 +139,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.8"
|
||||
cryptography:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cryptography
|
||||
sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.9.0"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -142,11 +159,11 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
path: data_transfer_flutter
|
||||
ref: "data_transfer_flutter-v0.2.1"
|
||||
resolved-ref: d4fca83c9f2ed33b3315cd838b9ca532ae8e559c
|
||||
ref: "data_transfer_flutter-v0.2.2"
|
||||
resolved-ref: eef7af4c86837ee82891493587ff3456297e5dea
|
||||
url: "https://github.com/kuhyx/utils"
|
||||
source: git
|
||||
version: "0.2.1"
|
||||
version: "0.2.2"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -208,10 +225,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: file_picker
|
||||
sha256: c64036857780d7125f011d543f89eedcb5df61da473668bace4eee9ab875284d
|
||||
sha256: "079dc46b753abbddb7f77cd87e6ed9ecc62902580707514568321bdf8e050a86"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "13.3.2"
|
||||
version: "13.3.3"
|
||||
file_picker_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -224,10 +241,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_picker_linux
|
||||
sha256: c6562fc7d426db2fb85ea66fe2627953d2709932df5584ceaacf0aa5e39cf4da
|
||||
sha256: "9215ee8c672508cdd9a325202669f849e1baac883658626b3abed5d7777e6a4b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
version: "2.0.4"
|
||||
file_picker_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -240,10 +257,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_picker_web
|
||||
sha256: a2288008280aabe371aff029466b3405e60838e0a1d714767488c59b926c8b8c
|
||||
sha256: "50749a6da2984fc29609e102ad4d80fd1d07e6895150f8e4922086167d9d45d8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.0"
|
||||
version: "4.2.1"
|
||||
file_selector:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -399,11 +416,11 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: github_device_auth
|
||||
ref: "github_device_auth-v0.3.2"
|
||||
resolved-ref: "4ccaba1bb3e495ef72d70c6a7e7d7ec98d755792"
|
||||
ref: "github_device_auth-v0.4.0"
|
||||
resolved-ref: "3084080d744c348a217727e26a6be838e7d5a4ff"
|
||||
url: "https://github.com/kuhyx/utils"
|
||||
source: git
|
||||
version: "0.3.2"
|
||||
version: "0.4.0"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -449,10 +466,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: idb_shim
|
||||
sha256: "60965b556624f7018ec26205283e32670bc78c6fe1388d4b90bdfe6b2311b87b"
|
||||
sha256: "507d8f3154fa4e41165941c5aa8509c6bf155bb30f76ba25a00d1e7ba1dd64c9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.11.2"
|
||||
version: "2.11.3"
|
||||
image:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -833,10 +850,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sembast
|
||||
sha256: "35775a26edbdf0d891bab304929a712e103c9d9a6e2fb5ebbd8437bb3139615a"
|
||||
sha256: "4f71f8c95105918fa207065429c913f38d0fb05fec9f3a745bbf4c113f5d482b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.10.0"
|
||||
version: "3.10.1"
|
||||
share_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -934,10 +951,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqflite_common_ffi
|
||||
sha256: "119d730460264a1f11f414d9eaa43fb686789357e81ce35f9e8ccb66ddd040e4"
|
||||
sha256: "9aad7a63bb8de6fe4c1e5367e8dcd8dfc89d491f44a526edf8e58a5a1fb1feed"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.5"
|
||||
version: "2.4.7"
|
||||
sqflite_common_ffi_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1006,11 +1023,11 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: sync_settings_ui
|
||||
ref: "sync_settings_ui-v0.6.2"
|
||||
resolved-ref: "95cd3731ede686114353d32ae6c286ad1694b488"
|
||||
ref: "sync_settings_ui-v0.7.2"
|
||||
resolved-ref: "4567e7ca3042e683befdf6697ace1c8be9c829fc"
|
||||
url: "https://github.com/kuhyx/utils"
|
||||
source: git
|
||||
version: "0.6.2"
|
||||
version: "0.7.2"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1159,10 +1176,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: windows_file_picker
|
||||
sha256: "18452aa293de3b6961c6d05b0e71b4be7bb90c67635abdd3aa5c4e75dd875072"
|
||||
sha256: cd1be37ada0abcfaa55bddb7410e8515844e35cd77669c35d9a28a15722299d8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.1"
|
||||
version: "2.0.2"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
19
pubspec.yaml
19
pubspec.yaml
@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.0.70+1
|
||||
version: 1.0.87+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
@ -33,8 +33,13 @@ dependencies:
|
||||
crdt_sync:
|
||||
git:
|
||||
url: https://github.com/kuhyx/utils
|
||||
ref: crdt_sync_dart-v0.12.1
|
||||
ref: crdt_sync_dart-v0.14.0
|
||||
path: crdt_sync_dart
|
||||
crdt_sync_flutter:
|
||||
git:
|
||||
url: https://github.com/kuhyx/utils
|
||||
ref: crdt_sync_flutter-v0.8.0
|
||||
path: crdt_sync_flutter
|
||||
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
@ -47,7 +52,7 @@ dependencies:
|
||||
url: https://github.com/kuhyx/utils
|
||||
ref: design_system-v0.3.1
|
||||
path: design_system
|
||||
file_picker: 13.3.2
|
||||
file_picker: 13.3.3
|
||||
file_selector: 1.1.0
|
||||
flutter:
|
||||
sdk: flutter
|
||||
@ -59,7 +64,7 @@ dependencies:
|
||||
github_device_auth:
|
||||
git:
|
||||
url: https://github.com/kuhyx/utils
|
||||
ref: github_device_auth-v0.3.2
|
||||
ref: github_device_auth-v0.4.0
|
||||
path: github_device_auth
|
||||
# One-tap Google ID token via the framework AccountManager chooser (FOSS,
|
||||
# no Play Services sign-in SDK). Used by the sync backend in lib/sync/.
|
||||
@ -69,7 +74,7 @@ dependencies:
|
||||
ref: google_account_token-v0.1.0
|
||||
path: google_account_token
|
||||
http: 1.6.0
|
||||
idb_shim: 2.11.2
|
||||
idb_shim: 2.11.3
|
||||
# Re-encodes attached images (resize, EXIF strip) in pure Dart, so the
|
||||
# phone and the desktop wrapper share one code path.
|
||||
image: 4.12.0
|
||||
@ -85,7 +90,7 @@ dependencies:
|
||||
path_provider: 2.1.6
|
||||
share_plus: 13.3.1
|
||||
shared_preferences: 2.5.6
|
||||
sqflite_common_ffi: 2.4.5
|
||||
sqflite_common_ffi: 2.4.7
|
||||
# Retained only to read the legacy on-device DB during the one-time
|
||||
# migration in NoteRepository.open; remove once all devices are migrated.
|
||||
sqlite_crdt: 3.0.4
|
||||
@ -97,7 +102,7 @@ dependencies:
|
||||
sync_settings_ui:
|
||||
git:
|
||||
url: https://github.com/kuhyx/utils
|
||||
ref: sync_settings_ui-v0.6.2
|
||||
ref: sync_settings_ui-v0.7.2
|
||||
path: sync_settings_ui
|
||||
url_launcher: 6.3.3
|
||||
uuid: 4.6.0
|
||||
|
||||
183
scripts/deploy.sh
Executable file
183
scripts/deploy.sh
Executable file
@ -0,0 +1,183 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================================================
|
||||
# Deploy todo everywhere it runs, then prove each one is live.
|
||||
#
|
||||
# scripts/deploy.sh # all targets: mcp desktop phone
|
||||
# scripts/deploy.sh mcp desktop # only the named targets
|
||||
#
|
||||
# Targets (each has its own health check; any failure makes the exit non-zero,
|
||||
# but the remaining targets still deploy so one unplugged phone does not leave
|
||||
# the desktop on old code):
|
||||
# mcp frozen binary + todo-mcp.service (mcp/install.sh), restart, then a
|
||||
# real initialize + list_tasks against :8735 (mcp/verify_mcp.py).
|
||||
# desktop web build + pacman package todo-flutter (install_arch.sh); health
|
||||
# = installed package and bundled web version.json match pubspec.
|
||||
# phone release APK via phone_deploy.sh (adb install -r only; it has no
|
||||
# uninstall / pm clear path); health = versionName on the Pixel
|
||||
# matches pubspec and the app process is running.
|
||||
#
|
||||
# Idempotent: desktop and phone skip the build when the installed version
|
||||
# already equals pubspec's (every commit bumps the patch version, so an equal
|
||||
# version means equal code); the MCP binary is cheap and always rebuilt.
|
||||
#
|
||||
# Nothing live runs from ~/src/todo (the MCP is a frozen binary, the desktop
|
||||
# app lives in /opt/todo), so this deploys the checkout it sits in and never
|
||||
# pulls kuhy's working tree.
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
readonly ROOT
|
||||
readonly SERIAL="${ADB_SERIAL:-23181JEGR08034}"
|
||||
readonly APP_ID="dev.kuhy.todo"
|
||||
readonly MCP_UNIT="todo-mcp.service"
|
||||
readonly MCP_URL="http://127.0.0.1:8735/mcp"
|
||||
readonly MCP_TOKEN="${HOME}/.config/todo-mcp-client/token"
|
||||
readonly PHONE_DEPLOY="${HOME}/.claude/scripts/phone_deploy.sh"
|
||||
readonly CAPPED="${HOME}/.claude/scripts/capped.sh"
|
||||
readonly WEB_VERSION_JSON="/opt/todo/web/version.json"
|
||||
readonly KEY_PROPS="$ROOT/android/key.properties"
|
||||
readonly SHARED_KEY_PROPS="${HOME}/.android/release/key.properties"
|
||||
|
||||
# systemd units and Ship do not source ~/.zshenv; phone_deploy needs the SDK
|
||||
# for its apksigner check.
|
||||
export ANDROID_HOME="${ANDROID_HOME:-${HOME}/sdk/foss-android-sdk}"
|
||||
export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}"
|
||||
|
||||
FAILED=()
|
||||
|
||||
say() { echo "[deploy:$1] $2"; }
|
||||
fail() {
|
||||
echo "[deploy:$1] FAIL: $2" >&2
|
||||
FAILED+=("$1")
|
||||
}
|
||||
|
||||
# pubspec `version: 1.0.77+1` -> `1.0.77` (the +build part is CI's).
|
||||
pubspec_version() {
|
||||
sed -n 's/^version:[[:space:]]*\([^+[:space:]]*\).*/\1/p' "$ROOT/pubspec.yaml"
|
||||
}
|
||||
|
||||
# Builds run under the shared resource cap when it exists on this machine.
|
||||
heavy() {
|
||||
if [[ -x "$CAPPED" ]]; then
|
||||
CAP_MEM=4G CAP_CPU_PCT=20 "$CAPPED" "$@"
|
||||
else
|
||||
"$@"
|
||||
fi
|
||||
}
|
||||
|
||||
mcp_pid() { systemctl --user show -p MainPID --value "$MCP_UNIT" 2>/dev/null || echo 0; }
|
||||
|
||||
deploy_mcp() {
|
||||
local old_pid new_pid out
|
||||
old_pid="$(mcp_pid)"
|
||||
heavy "$ROOT/mcp/install.sh" || { fail mcp "mcp/install.sh failed"; return; }
|
||||
# install.sh only restarts a running unit; make sure it is enabled + up.
|
||||
systemctl --user enable --now "$MCP_UNIT" >/dev/null 2>&1 ||
|
||||
{ fail mcp "cannot start $MCP_UNIT"; return; }
|
||||
systemctl --user is-active --quiet "$MCP_UNIT" ||
|
||||
{ fail mcp "$MCP_UNIT not active"; return; }
|
||||
new_pid="$(mcp_pid)"
|
||||
if [[ "$new_pid" == 0 || "$new_pid" == "$old_pid" ]]; then
|
||||
fail mcp "$MCP_UNIT was not restarted (pid $old_pid -> $new_pid)"
|
||||
return
|
||||
fi
|
||||
[[ -s "$MCP_TOKEN" ]] || { fail mcp "no client token at $MCP_TOKEN"; return; }
|
||||
out="$(python3 "$ROOT/mcp/verify_mcp.py" "$MCP_URL" \
|
||||
--token-file "$MCP_TOKEN" --wait 20 2>&1)" ||
|
||||
{ fail mcp "live check: $out"; return; }
|
||||
say mcp "ok: pid $new_pid, ${out//$'\n'/, }"
|
||||
}
|
||||
|
||||
desktop_versions_match() {
|
||||
local want="$1" pkg web
|
||||
pkg="$(pacman -Q todo-flutter 2>/dev/null | awk '{print $2}')"
|
||||
web="$(sed -n 's/.*"version":"\([^"]*\)".*/\1/p' "$WEB_VERSION_JSON" 2>/dev/null)"
|
||||
[[ "${pkg%-*}" == "$want" && "$web" == "$want" ]]
|
||||
}
|
||||
|
||||
deploy_desktop() {
|
||||
local want
|
||||
want="$(pubspec_version)"
|
||||
if desktop_versions_match "$want"; then
|
||||
say desktop "ok: todo-flutter $want already installed"
|
||||
return
|
||||
fi
|
||||
sudo -n true 2>/dev/null ||
|
||||
{ fail desktop "sudo needs a password; run: ! $ROOT/install_arch.sh"; return; }
|
||||
heavy "$ROOT/install_arch.sh" || { fail desktop "install_arch.sh failed"; return; }
|
||||
if desktop_versions_match "$want"; then
|
||||
say desktop "ok: todo-flutter $want installed"
|
||||
else
|
||||
fail desktop "installed version is not $want ($(pacman -Q todo-flutter 2>&1))"
|
||||
fi
|
||||
}
|
||||
|
||||
phone_version() {
|
||||
adb -s "$SERIAL" shell dumpsys package "$APP_ID" 2>/dev/null |
|
||||
sed -n 's/^[[:space:]]*versionName=\([^[:space:]]*\).*/\1/p' | head -n 1
|
||||
}
|
||||
|
||||
phone_health() {
|
||||
local want="$1" got
|
||||
got="$(phone_version)"
|
||||
[[ "$got" == "$want" ]] || { fail phone "versionName on phone is '$got', want $want"; return; }
|
||||
adb -s "$SERIAL" shell pidof "$APP_ID" >/dev/null 2>&1 ||
|
||||
adb -s "$SERIAL" shell monkey -p "$APP_ID" -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1 || true
|
||||
sleep 2
|
||||
adb -s "$SERIAL" shell pidof "$APP_ID" >/dev/null 2>&1 ||
|
||||
{ fail phone "$APP_ID is installed but not running"; return; }
|
||||
say phone "ok: $APP_ID $want running on $SERIAL"
|
||||
}
|
||||
|
||||
deploy_phone() {
|
||||
local want rc=0
|
||||
want="$(pubspec_version)"
|
||||
if [[ "$(adb -s "$SERIAL" get-state 2>/dev/null)" != device ]]; then
|
||||
fail phone "phone unreachable: $SERIAL not attached (plug it in, enable USB debugging)"
|
||||
return
|
||||
fi
|
||||
if [[ "$(phone_version)" == "$want" ]]; then
|
||||
say phone "already at $want, skipping build"
|
||||
else
|
||||
# A fresh checkout has no (gitignored) key.properties; without it the
|
||||
# build signs with the debug key and cannot install over a CI build.
|
||||
[[ -f "$KEY_PROPS" || ! -f "$SHARED_KEY_PROPS" ]] ||
|
||||
install -m 600 "$SHARED_KEY_PROPS" "$KEY_PROPS"
|
||||
# Outside a Claude session each phone_lease call would name its own
|
||||
# pid as owner, so phone_deploy's launch step blocked on the lease its
|
||||
# own install step took (exit 35). One owner for the whole run.
|
||||
# Exit 48 = load too high right now; wait it out (up to 15 min).
|
||||
local tries=0
|
||||
while :; do
|
||||
rc=0
|
||||
PHONE_LEASE_OWNER="${PHONE_LEASE_OWNER:-deploy:$$}" ADB_SERIAL="$SERIAL" \
|
||||
heavy "$PHONE_DEPLOY" "$ROOT" --release || rc=$?
|
||||
((rc == 48 && ++tries <= 30)) || break
|
||||
say phone "machine busy, retrying in 30 s ($tries/30)"
|
||||
sleep 30
|
||||
done
|
||||
((rc == 0)) || { fail phone "phone_deploy.sh exited $rc (see its exit-code table)"; return; }
|
||||
fi
|
||||
phone_health "$want"
|
||||
}
|
||||
|
||||
main() {
|
||||
local targets=("$@") t
|
||||
((${#targets[@]})) || targets=(mcp desktop phone)
|
||||
for t in "${targets[@]}"; do
|
||||
case "$t" in
|
||||
mcp | desktop | phone) "deploy_$t" ;;
|
||||
*) echo "usage: $0 [mcp] [desktop] [phone]" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
if ((${#FAILED[@]})); then
|
||||
echo "[deploy] FAILED: ${FAILED[*]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[deploy] all healthy: ${targets[*]} at $(pubspec_version)"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@ -47,8 +47,9 @@ void main() {
|
||||
expect(pubspec, contains('google_account_token-v0.1.0'));
|
||||
expect(pubspec, isNot(contains('google_sign_in')));
|
||||
expect(
|
||||
File('android/app/src/main/kotlin/dev/kuhy/todo/AccountTokenChannel.kt')
|
||||
.existsSync(),
|
||||
File(
|
||||
'android/app/src/main/kotlin/dev/kuhy/todo/AccountTokenChannel.kt',
|
||||
).existsSync(),
|
||||
isFalse,
|
||||
);
|
||||
expect(File('lib/sync/account_token.dart').existsSync(), isFalse);
|
||||
|
||||
@ -95,10 +95,7 @@ void main() {
|
||||
const service = AnalyticsService(nodeId: 'device-a');
|
||||
for (var i = 0; i < AnalyticsService.maxBufferedEvents + 5; i++) {
|
||||
await service.logEvent(
|
||||
AnalyticsEvent(
|
||||
name: 'event_$i',
|
||||
timestamp: DateTime.now(),
|
||||
),
|
||||
AnalyticsEvent(name: 'event_$i', timestamp: DateTime.now()),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -135,42 +135,39 @@ void main() {
|
||||
expect((remaining.single as Map)['name'], 'mid_flush_event');
|
||||
});
|
||||
|
||||
test(
|
||||
'restores the claimed batch ahead of events logged mid-flush on '
|
||||
'failure',
|
||||
() async {
|
||||
// The HTTP handler runs mid-`putFileText`, strictly after flush()
|
||||
// has already claimed (cleared) the buffer, so writing here is a
|
||||
// genuine race rather than something that could instead land before
|
||||
// or after `flush()` depending on scheduling.
|
||||
final client = _fakeClient((_) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(
|
||||
'analytics.buffer',
|
||||
jsonEncode([
|
||||
AnalyticsEvent(
|
||||
name: 'mid_flush_event',
|
||||
timestamp: DateTime(2026),
|
||||
).toJson(),
|
||||
]),
|
||||
);
|
||||
return http.Response('error', 500);
|
||||
});
|
||||
const service = AnalyticsService(nodeId: 'device-a');
|
||||
await service.logEvent(
|
||||
AnalyticsEvent(name: 'app_open', timestamp: DateTime(2026)),
|
||||
);
|
||||
|
||||
await service.flush(client);
|
||||
|
||||
test('restores the claimed batch ahead of events logged mid-flush on '
|
||||
'failure', () async {
|
||||
// The HTTP handler runs mid-`putFileText`, strictly after flush()
|
||||
// has already claimed (cleared) the buffer, so writing here is a
|
||||
// genuine race rather than something that could instead land before
|
||||
// or after `flush()` depending on scheduling.
|
||||
final client = _fakeClient((_) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final remaining =
|
||||
jsonDecode(prefs.getString('analytics.buffer')!) as List;
|
||||
expect(remaining, hasLength(2));
|
||||
expect((remaining[0] as Map)['name'], 'app_open');
|
||||
expect((remaining[1] as Map)['name'], 'mid_flush_event');
|
||||
},
|
||||
);
|
||||
await prefs.setString(
|
||||
'analytics.buffer',
|
||||
jsonEncode([
|
||||
AnalyticsEvent(
|
||||
name: 'mid_flush_event',
|
||||
timestamp: DateTime(2026),
|
||||
).toJson(),
|
||||
]),
|
||||
);
|
||||
return http.Response('error', 500);
|
||||
});
|
||||
const service = AnalyticsService(nodeId: 'device-a');
|
||||
await service.logEvent(
|
||||
AnalyticsEvent(name: 'app_open', timestamp: DateTime(2026)),
|
||||
);
|
||||
|
||||
await service.flush(client);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final remaining =
|
||||
jsonDecode(prefs.getString('analytics.buffer')!) as List;
|
||||
expect(remaining, hasLength(2));
|
||||
expect((remaining[0] as Map)['name'], 'app_open');
|
||||
expect((remaining[1] as Map)['name'], 'mid_flush_event');
|
||||
});
|
||||
|
||||
test(
|
||||
'a RemoteSyncError from an expired session restores the buffer',
|
||||
|
||||
@ -91,9 +91,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('swallows a push failure and keeps the local write', () async {
|
||||
final client = _fakeClient(
|
||||
(_) async => http.Response('error', 500),
|
||||
);
|
||||
final client = _fakeClient((_) async => http.Response('error', 500));
|
||||
const initial = AppSettings(advancedMode: false);
|
||||
|
||||
final updated = await initial.withAdvancedMode(
|
||||
@ -107,22 +105,19 @@ void main() {
|
||||
expect(reloaded.advancedMode, isTrue);
|
||||
});
|
||||
|
||||
test(
|
||||
'swallows a FirebaseAuthError (expired session) and keeps the local '
|
||||
'write',
|
||||
() async {
|
||||
const initial = AppSettings(advancedMode: false);
|
||||
test('swallows a FirebaseAuthError (expired session) and keeps the local '
|
||||
'write', () async {
|
||||
const initial = AppSettings(advancedMode: false);
|
||||
|
||||
final updated = await initial.withAdvancedMode(
|
||||
value: true,
|
||||
client: _authFailingClient(),
|
||||
);
|
||||
final updated = await initial.withAdvancedMode(
|
||||
value: true,
|
||||
client: _authFailingClient(),
|
||||
);
|
||||
|
||||
expect(updated.advancedMode, isTrue);
|
||||
final reloaded = await AppSettings.load();
|
||||
expect(reloaded.advancedMode, isTrue);
|
||||
},
|
||||
);
|
||||
expect(updated.advancedMode, isTrue);
|
||||
final reloaded = await AppSettings.load();
|
||||
expect(reloaded.advancedMode, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('reconcileWithRemote', () {
|
||||
@ -220,9 +215,7 @@ void main() {
|
||||
() async {
|
||||
const initial = AppSettings(advancedMode: false);
|
||||
|
||||
final result = await initial.reconcileWithRemote(
|
||||
_authFailingClient(),
|
||||
);
|
||||
final result = await initial.reconcileWithRemote(_authFailingClient());
|
||||
|
||||
expect(identical(result, initial), isTrue);
|
||||
},
|
||||
|
||||
@ -20,60 +20,59 @@ import 'fake_note_repository.dart';
|
||||
import 'fake_secure_storage.dart';
|
||||
|
||||
import 'capture_screen_harness.dart';
|
||||
import 'e2e_github_header.dart';
|
||||
import 'text_free_guard.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'auto-sync adopts a newer advancedMode value from Firebase',
|
||||
(tester) async {
|
||||
final remoteTime = DateTime(2026, 6);
|
||||
final firebase = FirebaseRestClient(
|
||||
databaseUrl: 'https://x-rtdb.europe-west1.firebasedatabase.app',
|
||||
auth: FirebaseTokenProvider(
|
||||
apiKey: 'AIzaKey',
|
||||
store: InMemoryCredentialStore(
|
||||
FirebaseCredentials(
|
||||
idToken: 'id',
|
||||
refreshToken: 'refresh',
|
||||
expiresAt: DateTime.now().add(const Duration(hours: 1)),
|
||||
),
|
||||
testWidgets('auto-sync adopts a newer advancedMode value from Firebase', (
|
||||
tester,
|
||||
) async {
|
||||
final remoteTime = DateTime(2026, 6);
|
||||
final firebase = FirebaseRestClient(
|
||||
databaseUrl: 'https://x-rtdb.europe-west1.firebasedatabase.app',
|
||||
auth: FirebaseTokenProvider(
|
||||
apiKey: 'AIzaKey',
|
||||
store: InMemoryCredentialStore(
|
||||
FirebaseCredentials(
|
||||
idToken: 'id',
|
||||
refreshToken: 'refresh',
|
||||
expiresAt: DateTime.now().add(const Duration(hours: 1)),
|
||||
),
|
||||
),
|
||||
httpClient: MockClient((request) async {
|
||||
if (request.url.path == '/settings/advancedMode.json') {
|
||||
return http.Response(
|
||||
'{"value": "true", '
|
||||
'"updatedAtMillis": "${remoteTime.millisecondsSinceEpoch}"}',
|
||||
200,
|
||||
);
|
||||
}
|
||||
if (request.method == 'PUT') return http.Response(request.body, 200);
|
||||
return http.Response('null', 200);
|
||||
}),
|
||||
);
|
||||
final appSettings = ValueNotifier(
|
||||
AppSettings(
|
||||
advancedMode: false,
|
||||
// Older than remoteTime, and non-null, so the reconciliation
|
||||
// guard's `currentAt != null && !reconciledAt.isAfter(currentAt)`
|
||||
// check actually evaluates both operands.
|
||||
advancedModeUpdatedAt: remoteTime.subtract(
|
||||
const Duration(days: 1),
|
||||
),
|
||||
),
|
||||
);
|
||||
),
|
||||
httpClient: e2eMock((request) async {
|
||||
if (request.url.path == '/settings/advancedMode.json') {
|
||||
return http.Response(
|
||||
'{"value": "true", '
|
||||
'"updatedAtMillis": "${remoteTime.millisecondsSinceEpoch}"}',
|
||||
200,
|
||||
);
|
||||
}
|
||||
if (request.method == 'PUT') return http.Response(request.body, 200);
|
||||
return http.Response('null', 200);
|
||||
}),
|
||||
);
|
||||
final appSettings = ValueNotifier(
|
||||
AppSettings(
|
||||
advancedMode: false,
|
||||
// Older than remoteTime, and non-null, so the reconciliation
|
||||
// guard's `currentAt != null && !reconciledAt.isAfter(currentAt)`
|
||||
// check actually evaluates both operands.
|
||||
advancedModeUpdatedAt: remoteTime.subtract(const Duration(days: 1)),
|
||||
),
|
||||
);
|
||||
|
||||
await pumpCapture(
|
||||
tester,
|
||||
prefs: configuredPrefs,
|
||||
firebaseFactory: () async => firebase,
|
||||
appSettings: appSettings,
|
||||
);
|
||||
await tester.pump(); // settings load → auto-sync (pull) …
|
||||
await tester.pump(); // … then push, then reconcile
|
||||
await pumpCapture(
|
||||
tester,
|
||||
prefs: configuredPrefs,
|
||||
firebaseFactory: () async => firebase,
|
||||
appSettings: appSettings,
|
||||
);
|
||||
await tester.pump(); // settings load → auto-sync (pull) …
|
||||
await tester.pump(); // … then push, then reconcile
|
||||
|
||||
expect(appSettings.value.advancedMode, isTrue);
|
||||
},
|
||||
);
|
||||
expect(appSettings.value.advancedMode, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('logs app_open when analytics is injected', (tester) async {
|
||||
const analytics = AnalyticsService(nodeId: 'device-a');
|
||||
@ -140,7 +139,7 @@ void main() {
|
||||
// Capture must never be interrupted by a snackbar…
|
||||
expect(find.byType(SnackBar), findsNothing);
|
||||
// …but the failure is no longer swallowed: the status line shows it.
|
||||
expect(find.textContaining('Sync failed at'), findsOneWidget);
|
||||
expect(find.textContaining('offline'), findsOneWidget);
|
||||
expect(spoken('Sync failed at'), findsOneWidget);
|
||||
expect(spoken('offline'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
@ -20,6 +20,7 @@ import 'fake_note_repository.dart';
|
||||
import 'fake_secure_storage.dart';
|
||||
|
||||
import 'capture_screen_harness.dart';
|
||||
import 'text_free_guard.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('defaults to Raw, with Guided available via the entry wizard', (
|
||||
@ -29,18 +30,18 @@ void main() {
|
||||
|
||||
// Defaults to Raw: a single text field, no note persisted yet.
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
expect(find.text('Guided'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('Guided'), findsOneWidget);
|
||||
expect(find.text('0'), findsOneWidget);
|
||||
|
||||
// Tapping Guided on the empty draft opens the priority+template wizard
|
||||
// rather than jumping straight into the stepper.
|
||||
await tester.tap(find.text('Guided'));
|
||||
await tester.tap(find.bySemanticsLabel('Guided'));
|
||||
await tester.pump();
|
||||
expect(find.text('Step 1 of 2'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('Step 1 of 2'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Next'));
|
||||
await tester.tap(find.bySemanticsLabel('Next'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Start'));
|
||||
await tester.tap(find.bySemanticsLabel('Start'));
|
||||
await tester.pump();
|
||||
|
||||
// Now on step 1 of the full-screen step page.
|
||||
@ -49,13 +50,13 @@ void main() {
|
||||
find.text('1 / ${NoteTemplate.llmDesignSpec.sections.length}'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.textContaining('imperative'), findsOneWidget); // title helper
|
||||
expect(spoken('imperative'), findsOneWidget); // title helper
|
||||
});
|
||||
|
||||
testWidgets('saving the untouched template creates no note', (tester) async {
|
||||
final repo = await pumpCapture(tester);
|
||||
|
||||
await tester.tap(find.byTooltip('New note'));
|
||||
await tester.tap(find.bySemanticsLabel('New note'));
|
||||
await tester.pump();
|
||||
|
||||
expect(await repo.listNotes(), isEmpty);
|
||||
@ -83,7 +84,7 @@ void main() {
|
||||
|
||||
await tester.enterText(find.byType(TextField).first, 'A real idea');
|
||||
await tester.pump();
|
||||
await tester.tap(find.byTooltip('New note'));
|
||||
await tester.tap(find.bySemanticsLabel('New note'));
|
||||
await tester.pump();
|
||||
|
||||
expect(await repo.listNotes(), hasLength(1));
|
||||
@ -98,15 +99,16 @@ void main() {
|
||||
) async {
|
||||
await pumpCapture(tester); // empty prefs → no token → not configured
|
||||
|
||||
await tester.tap(find.byTooltip('Sync'));
|
||||
await tester.tap(find.bySemanticsLabel('Sync'));
|
||||
await tester.pump(); // settings load + snackbar
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500)); // snack fades in
|
||||
|
||||
// The behaviour that matters is the routing, not the wording: an
|
||||
// unconfigured device is sent to settings to connect a backend. Asserting
|
||||
// on the snackbar text tied this to one phrasing, and the snackbar is
|
||||
// gone by the time settings has pushed anyway.
|
||||
expect(find.textContaining('Connect Firebase'), findsWidgets);
|
||||
expect(spoken('Connect Firebase'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('the notes-list button navigates to the list screen', (
|
||||
@ -114,11 +116,11 @@ void main() {
|
||||
) async {
|
||||
await pumpCapture(tester);
|
||||
|
||||
await tester.tap(find.byTooltip('Notes'));
|
||||
await tester.tap(find.bySemanticsLabel('Notes'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300)); // route transition
|
||||
|
||||
expect(find.text('Notes'), findsOneWidget); // list screen app bar title
|
||||
expect(find.byIcon(Icons.note), findsOneWidget); // list screen title
|
||||
});
|
||||
|
||||
testWidgets('changing the priority dropdown updates the saved note', (
|
||||
@ -134,7 +136,7 @@ void main() {
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400)); // menu open
|
||||
await tester.tap(find.text('High').last);
|
||||
await tester.tap(find.bySemanticsLabel('High').last);
|
||||
await tester.pump();
|
||||
|
||||
expect((await repo.listNotes()).single.priority, Priority.high);
|
||||
@ -153,7 +155,7 @@ void main() {
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400)); // menu open
|
||||
await tester.tap(find.text('In progress').last);
|
||||
await tester.tap(find.bySemanticsLabel('In progress').last);
|
||||
await tester.pump();
|
||||
|
||||
expect((await repo.listNotes()).single.status, Status.inProgress);
|
||||
|
||||
@ -28,8 +28,10 @@ import 'package:todo/ui/attachment_picking.dart';
|
||||
import 'package:todo/ui/capture_screen.dart';
|
||||
import 'package:todo/ui/github_mirror_screen.dart';
|
||||
import 'package:todo/ui/settings_screen.dart';
|
||||
import 'package:todo/ui/text_free_localizations.dart';
|
||||
|
||||
import 'fake_note_repository.dart';
|
||||
import 'e2e_github_header.dart';
|
||||
import 'fake_secure_storage.dart';
|
||||
|
||||
// A real CRDT DB schedules sqflite timers that never drain under the
|
||||
@ -73,6 +75,8 @@ Future<FakeNoteRepository> pumpCapture(
|
||||
debounce: Duration.zero,
|
||||
);
|
||||
final app = MaterialApp(
|
||||
// As in main.dart: Material's own button tooltips are blanked.
|
||||
localizationsDelegates: kTextFreeLocalizations,
|
||||
home: CaptureScreen(
|
||||
repository: repo,
|
||||
appSettings:
|
||||
@ -105,7 +109,7 @@ const configuredPrefs = {
|
||||
// A MockClient that records request methods and answers the sync flow:
|
||||
// repo-exists GET (not /contents/) → 200, the (empty) log listing → 404,
|
||||
// and the device's own PUT → 200.
|
||||
MockClient recordingMock(List<String> methods) => MockClient((req) async {
|
||||
MockClient recordingMock(List<String> methods) => e2eMock((req) async {
|
||||
methods.add(req.method);
|
||||
if (req.method == 'PUT') return http.Response('{}', 200);
|
||||
if (!req.url.path.contains('/contents/')) return http.Response('{}', 200);
|
||||
|
||||
@ -20,6 +20,7 @@ import 'fake_note_repository.dart';
|
||||
import 'fake_secure_storage.dart';
|
||||
|
||||
import 'capture_screen_harness.dart';
|
||||
import 'text_free_guard.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
@ -35,8 +36,8 @@ void main() {
|
||||
await tester.pump();
|
||||
|
||||
// Only a failure is worth surfacing; a routine success is silent.
|
||||
expect(find.textContaining('Synced at'), findsNothing);
|
||||
expect(find.textContaining('merged 0 device(s)'), findsNothing);
|
||||
expect(spoken('Synced at'), findsNothing);
|
||||
expect(spoken('merged 0 device(s)'), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
@ -53,8 +54,8 @@ void main() {
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.textContaining('Sync failed at 12:00:00'), findsOneWidget);
|
||||
expect(find.textContaining('rate limited'), findsOneWidget);
|
||||
expect(spoken('Sync failed at 12:00:00'), findsOneWidget);
|
||||
expect(spoken('rate limited'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('losing focus triggers a debounced auto-sync', (tester) async {
|
||||
|
||||
@ -17,9 +17,11 @@ import 'package:todo/ui/github_mirror_screen.dart';
|
||||
import 'package:todo/ui/settings_screen.dart';
|
||||
|
||||
import 'fake_note_repository.dart';
|
||||
import 'e2e_github_header.dart';
|
||||
import 'fake_secure_storage.dart';
|
||||
|
||||
import 'capture_screen_harness.dart';
|
||||
import 'text_free_guard.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Sync with a configured token runs the sync service', (
|
||||
@ -28,25 +30,24 @@ void main() {
|
||||
// Empty remote directory → the service has nothing to merge and pushes
|
||||
// this device's own log (PUT). The bare repo GET (not /contents/) returns
|
||||
// 200 so crdt_sync's client treats the 404 dir as "empty", not missing.
|
||||
final mock = MockClient((req) async {
|
||||
final mock = e2eMock((req) async {
|
||||
if (req.method == 'PUT') return http.Response('{}', 200);
|
||||
if (!req.url.path.contains('/contents/')) return http.Response('{}', 200);
|
||||
return http.Response('', 404);
|
||||
});
|
||||
await pumpCapture(tester, prefs: configuredPrefs, httpClient: mock);
|
||||
|
||||
await tester.tap(find.byTooltip('Sync'));
|
||||
await tester.tap(find.bySemanticsLabel('Sync'));
|
||||
await tester.pump(); // setState(_syncing = true)
|
||||
await tester.pump(); // service runs, snackbar scheduled
|
||||
await tester.pump(); // snackbar builds
|
||||
await tester.pump(const Duration(milliseconds: 500)); // snack fades in
|
||||
|
||||
// No firebaseFactory was injected, so this runs over GitHub alone --
|
||||
// the status line must say so rather than reading identically to a
|
||||
// Firebase-connected sync.
|
||||
expect(
|
||||
find.textContaining(
|
||||
'Synced: GitHub-only (Firebase not connected) — merged 0 device',
|
||||
),
|
||||
spoken('Synced: GitHub-only (Firebase not connected) — merged 0 device'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
@ -55,24 +56,25 @@ void main() {
|
||||
final mock = MockClient((_) async => throw Exception('offline'));
|
||||
await pumpCapture(tester, prefs: configuredPrefs, httpClient: mock);
|
||||
|
||||
await tester.tap(find.byTooltip('Sync'));
|
||||
await tester.tap(find.bySemanticsLabel('Sync'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500)); // snack fades in
|
||||
|
||||
// 'Sync failed:' is the snackbar; the status line says 'Sync failed at'.
|
||||
expect(find.textContaining('Sync failed:'), findsOneWidget);
|
||||
expect(find.textContaining('Sync failed at'), findsOneWidget);
|
||||
expect(spoken('Sync failed:'), findsOneWidget);
|
||||
expect(spoken('Sync failed at'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('manual sync reports peer files it could not decode', (
|
||||
tester,
|
||||
) async {
|
||||
// One peer file exists but holds garbage → merged 0, skipped 1.
|
||||
final mock = MockClient((req) async {
|
||||
final mock = e2eMock((req) async {
|
||||
if (req.method == 'PUT') return http.Response('{}', 200);
|
||||
if (!req.url.path.contains('/contents/')) return http.Response('{}', 200);
|
||||
if (req.url.path.endsWith('/todo-sync/notes')) {
|
||||
if (req.url.path.endsWith('/todo-sync/notes-e2e')) {
|
||||
return http.Response('[{"name": "bad.json"}]', 200);
|
||||
}
|
||||
if (req.url.path.endsWith('/bad.json')) {
|
||||
@ -85,13 +87,14 @@ void main() {
|
||||
});
|
||||
await pumpCapture(tester, prefs: configuredPrefs, httpClient: mock);
|
||||
|
||||
await tester.tap(find.byTooltip('Sync'));
|
||||
await tester.tap(find.bySemanticsLabel('Sync'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500)); // snack fades in
|
||||
|
||||
expect(
|
||||
find.textContaining('skipped 1 unreadable file(s)'),
|
||||
spoken('skipped 1 unreadable file(s)'),
|
||||
findsWidgets, // snackbar and status line both call it out
|
||||
);
|
||||
});
|
||||
@ -101,7 +104,7 @@ void main() {
|
||||
) async {
|
||||
await pumpCapture(tester);
|
||||
|
||||
await tester.tap(find.byTooltip('Settings'));
|
||||
await tester.tap(find.bySemanticsLabel('Settings'));
|
||||
await tester.pumpAndSettle(); // route transition
|
||||
expect(find.byType(SettingsScreen), findsOneWidget); // settings is up
|
||||
|
||||
@ -112,25 +115,25 @@ void main() {
|
||||
// without popping a result at all, so a stale in-memory value would be
|
||||
// wrong there too). This exercises that reload-after-return path, then
|
||||
// Back returns to capture.
|
||||
await tester.tap(find.text('Advanced sync (GitHub)'));
|
||||
await tester.tap(find.byIcon(Icons.cloud_sync));
|
||||
await tester.pumpAndSettle(); // route transition
|
||||
expect(find.byType(GitHubMirrorScreen), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.tap(find.bySemanticsLabel('Save'));
|
||||
await tester.pumpAndSettle(); // save + pop transition back to Settings
|
||||
expect(find.byType(SettingsScreen), findsOneWidget);
|
||||
|
||||
await tester.pageBack();
|
||||
await tester.tap(find.byType(BackButton)); // no 'Back' tooltip to find
|
||||
await tester.pumpAndSettle(); // pop transition back to capture
|
||||
|
||||
expect(find.byType(SettingsScreen), findsNothing); // back on capture
|
||||
expect(find.byTooltip('Settings'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('Settings'), findsOneWidget);
|
||||
});
|
||||
|
||||
// A MockClient that records request methods and answers the sync flow:
|
||||
// repo-exists GET (not /contents/) → 200, the (empty) log listing → 404,
|
||||
// and the device's own PUT → 200.
|
||||
MockClient recordingMock(List<String> methods) => MockClient((req) async {
|
||||
MockClient recordingMock(List<String> methods) => e2eMock((req) async {
|
||||
methods.add(req.method);
|
||||
if (req.method == 'PUT') return http.Response('{}', 200);
|
||||
if (!req.url.path.contains('/contents/')) return http.Response('{}', 200);
|
||||
@ -148,42 +151,36 @@ void main() {
|
||||
await tester.pump();
|
||||
|
||||
// The editor's own template/mode chrome is gone.
|
||||
expect(find.text('Guided'), findsNothing);
|
||||
expect(find.text('Template'), findsNothing);
|
||||
expect(find.bySemanticsLabel('Guided'), findsNothing);
|
||||
expect(find.bySemanticsLabel('Template'), findsNothing);
|
||||
// The capture screen's priority/status row is gone.
|
||||
expect(find.text('Priority'), findsNothing);
|
||||
expect(find.text('Status'), findsNothing);
|
||||
expect(find.bySemanticsLabel('Priority'), findsNothing);
|
||||
expect(find.bySemanticsLabel('Status'), findsNothing);
|
||||
// Routine status chatter is gone; only a badge count remains.
|
||||
expect(find.textContaining('Autosaves as you type'), findsNothing);
|
||||
expect(spoken('Autosaves as you type'), findsNothing);
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
// The note still persisted normally underneath the simplified UI.
|
||||
expect(find.text('A plain idea'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'draft text survives an advancedMode toggle mid-typing',
|
||||
(tester) async {
|
||||
final appSettings = ValueNotifier(
|
||||
const AppSettings(advancedMode: true),
|
||||
);
|
||||
await pumpCapture(tester, appSettings: appSettings);
|
||||
testWidgets('draft text survives an advancedMode toggle mid-typing', (
|
||||
tester,
|
||||
) async {
|
||||
final appSettings = ValueNotifier(const AppSettings(advancedMode: true));
|
||||
await pumpCapture(tester, appSettings: appSettings);
|
||||
|
||||
await tester.enterText(
|
||||
find.byType(TextField).first,
|
||||
'my important idea',
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.enterText(find.byType(TextField).first, 'my important idea');
|
||||
await tester.pump();
|
||||
|
||||
// NoteForm's metadata row is conditionally present, so toggling
|
||||
// advancedMode changes the editor's position beneath it — a key one
|
||||
// level too shallow doesn't survive that reposition and Flutter
|
||||
// remounts the editor with an empty draft, losing the typing.
|
||||
appSettings.value = const AppSettings(advancedMode: false);
|
||||
await tester.pump();
|
||||
// NoteForm's metadata row is conditionally present, so toggling
|
||||
// advancedMode changes the editor's position beneath it — a key one
|
||||
// level too shallow doesn't survive that reposition and Flutter
|
||||
// remounts the editor with an empty draft, losing the typing.
|
||||
appSettings.value = const AppSettings(advancedMode: false);
|
||||
await tester.pump();
|
||||
|
||||
final field = tester.widget<TextField>(find.byType(TextField).first);
|
||||
expect(field.controller?.text, contains('my important idea'));
|
||||
},
|
||||
);
|
||||
final field = tester.widget<TextField>(find.byType(TextField).first);
|
||||
expect(field.controller?.text, contains('my important idea'));
|
||||
});
|
||||
}
|
||||
|
||||
@ -19,6 +19,9 @@ class CliHarness {
|
||||
writeSeedCredentials(
|
||||
tmp,
|
||||
).copySync('${home.path}/.config/todo/firebase_auth.json');
|
||||
File(
|
||||
'${tmp.path}/e2e_passphrase',
|
||||
).copySync('${home.path}/.config/todo/e2e_passphrase');
|
||||
env = {'HOME': home.path, 'XDG_DATA_HOME': '${tmp.path}/xdg'};
|
||||
}
|
||||
|
||||
@ -32,7 +35,11 @@ class CliHarness {
|
||||
|
||||
String dir(String name) => '${tmp.path}/$name';
|
||||
|
||||
Future<int> run(List<String> argv, {SessionOpener? opener}) async {
|
||||
Future<int> run(
|
||||
List<String> argv, {
|
||||
SessionOpener? opener,
|
||||
String stdin = '',
|
||||
}) async {
|
||||
out.clear();
|
||||
err.clear();
|
||||
return runCli(
|
||||
@ -40,7 +47,7 @@ class CliHarness {
|
||||
env: env,
|
||||
out: out,
|
||||
err: err,
|
||||
readStdin: () async => '',
|
||||
readStdin: () async => stdin,
|
||||
open:
|
||||
opener ?? (d, {required seedCredentials}) => open(d, seedCredentials),
|
||||
);
|
||||
@ -62,6 +69,7 @@ class CliHarness {
|
||||
|
||||
/// Publishes [notes] as device [node]'s log.
|
||||
void seedPeer(List<Note> notes, {String node = 'peer'}) {
|
||||
resetE2eCopyMemo();
|
||||
db.files['todo-sync/notes/$node~2Ejson'] = logToJson({
|
||||
for (final n in notes) n.id: NoteRepository.recordAtNoteTime(n, node),
|
||||
});
|
||||
|
||||
@ -22,6 +22,9 @@ void main() {
|
||||
writeSeedCredentials(
|
||||
tmp,
|
||||
).copySync('${home.path}/.config/todo/firebase_auth.json');
|
||||
File(
|
||||
'${tmp.path}/e2e_passphrase',
|
||||
).copySync('${home.path}/.config/todo/e2e_passphrase');
|
||||
env = {'HOME': home.path, 'XDG_DATA_HOME': '${tmp.path}/xdg'};
|
||||
out = StringBuffer();
|
||||
err = StringBuffer();
|
||||
@ -86,7 +89,10 @@ void main() {
|
||||
test('pushes an ordinary CRDT log and prints the new id', () async {
|
||||
final id = await add('a', 'Title\n\nbody', ['--priority', 'high']);
|
||||
expect(id, matches(RegExp(r'^[0-9a-f-]{36}$')));
|
||||
expect(db.files.keys.where((k) => k.contains('notes/')), hasLength(1));
|
||||
expect(
|
||||
db.files.keys.where((k) => k.contains('notes-e2e/')),
|
||||
hasLength(1),
|
||||
);
|
||||
expect(err.toString(), isEmpty);
|
||||
});
|
||||
|
||||
|
||||
@ -117,8 +117,8 @@ void main() {
|
||||
final first = await session.sync(push: true);
|
||||
expect(first.pushed, isTrue);
|
||||
final node = session.repository.nodeId;
|
||||
expect(db.files.keys, contains('todo-sync/notes/$node~2Ejson'));
|
||||
expect(db.files.keys, contains('todo-sync/revs/$node'));
|
||||
expect(db.files.keys, contains('todo-sync/notes-e2e/$node~2Ejson'));
|
||||
expect(db.files.keys, contains('todo-sync/revs-e2e/$node'));
|
||||
expect(
|
||||
File('${tmp.path}/push/${CliSession.syncStateFileName}').existsSync(),
|
||||
isTrue,
|
||||
@ -129,6 +129,21 @@ void main() {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test('the derived key is cached owner-only for the next run', () async {
|
||||
final first = await open('cache');
|
||||
await first.repository.upsert(note('n1'));
|
||||
await first.sync(push: true);
|
||||
await first.close();
|
||||
final cached = File('${tmp.path}/cache/${CliSession.e2eKeyFileName}');
|
||||
expect(cached.existsSync(), isTrue);
|
||||
expect(cached.statSync().mode & 0x1ff, 384); // 0600
|
||||
// Without any passphrase the next run still unlocks from the cache.
|
||||
File('${tmp.path}/e2e_passphrase').deleteSync();
|
||||
final second = await open('cache');
|
||||
expect((await second.sync(push: true)).pushed, isFalse);
|
||||
await second.close();
|
||||
});
|
||||
|
||||
test('a second node sees the first node note', () async {
|
||||
final a = await open('a');
|
||||
await a.repository.upsert(note('shared'));
|
||||
|
||||
83
test/cli/cli_set_text_test.dart
Normal file
83
test/cli/cli_set_text_test.dart
Normal file
@ -0,0 +1,83 @@
|
||||
// --set-text: replace a note's whole text, keeping every other field.
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:todo/cli/cli_args.dart';
|
||||
import 'package:todo/cli/cli_mode.dart';
|
||||
import 'package:todo/cli/cli_session.dart';
|
||||
|
||||
import 'cli_harness.dart';
|
||||
import 'scripted_session.dart';
|
||||
|
||||
void main() {
|
||||
late CliHarness h;
|
||||
setUp(() => h = CliHarness());
|
||||
tearDown(() => h.dispose());
|
||||
|
||||
test('--set-text replaces the text and keeps repo and status', () async {
|
||||
final id = await h.add('Old title\nold body', ['--repo', 'kuhyx/todo']);
|
||||
expect(await h.run(['--set-status', id, 'in_progress']), 0);
|
||||
expect(
|
||||
await h.run([
|
||||
'--set-text',
|
||||
id.substring(0, 8),
|
||||
'--text',
|
||||
'New title\nnew body',
|
||||
'--data-dir',
|
||||
h.dir('a'),
|
||||
]),
|
||||
0,
|
||||
);
|
||||
expect(h.out.toString().trim(), '$id\ttext');
|
||||
expect(await h.run(['--get', id, '--data-dir', h.dir('b')]), 0);
|
||||
expect(
|
||||
h.out.toString(),
|
||||
allOf(
|
||||
contains('New title\nnew body'),
|
||||
isNot(contains('old body')),
|
||||
contains('repo: kuhyx/todo'),
|
||||
contains('in_progress'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('--set-text reads stdin when --text is absent', () async {
|
||||
final id = await h.add('Old');
|
||||
expect(await h.run(['--set-text', id], stdin: 'From stdin'), 0);
|
||||
expect(await h.run(['--get', id, '--data-dir', h.dir('b')]), 0);
|
||||
expect(h.out.toString(), contains('From stdin'));
|
||||
});
|
||||
|
||||
test('--set-text refuses empty text and changes nothing', () async {
|
||||
final id = await h.add('Keep me');
|
||||
expect(await h.run(['--set-text', id, '--text', ' ']), 2);
|
||||
expect(h.err.toString(), contains('empty note'));
|
||||
expect(await h.run(['--get', id]), 0);
|
||||
expect(h.out.toString(), contains('Keep me'));
|
||||
});
|
||||
|
||||
test('--set-text on a missing id exits 1', () async {
|
||||
expect(await h.run(['--set-text', 'ffffffff', '--text', 'x']), 1);
|
||||
expect(h.err.toString(), contains('no live note with id ffffffff'));
|
||||
});
|
||||
|
||||
test('--set-text fails when the push reports nothing published', () async {
|
||||
Future<CliSession> opener(String d, {required String seedCredentials}) =>
|
||||
ScriptedSession.create(h.db.client, [true, false], noteId: 'stuck');
|
||||
expect(
|
||||
await h.run(['--set-text', 'stuck', '--text', 'x'], opener: opener),
|
||||
1,
|
||||
);
|
||||
expect(h.err.toString(), contains('text change saved locally'));
|
||||
});
|
||||
|
||||
test('parse: --set-text takes an id and allows --text', () {
|
||||
final args = CliArgs.parse(['--set-text', 'abc', '--text', 'T']);
|
||||
expect(args.mode, CliMode.setText);
|
||||
expect(args.id, 'abc');
|
||||
expect(args.text, 'T');
|
||||
expect(() => CliArgs.parse(['--set-text']), throwsA(isA<UsageError>()));
|
||||
expect(
|
||||
() => CliArgs.parse(['--set-text', 'a', '--repo', 'kuhyx/todo']),
|
||||
throwsA(isA<UsageError>()),
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -8,6 +8,7 @@ library;
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart' show resetE2eCopyMemo;
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart' as http_testing;
|
||||
|
||||
@ -64,6 +65,10 @@ class FakeRtdb {
|
||||
|
||||
/// Writes a Firebase session that does not expire during a test run.
|
||||
File writeSeedCredentials(Directory dir, {String name = 'seed.json'}) {
|
||||
// A fresh fake database: the one-time copy must run again for it.
|
||||
resetE2eCopyMemo();
|
||||
// The CLI reads its crdt-sync passphrase from beside the seed file.
|
||||
File('${dir.path}/e2e_passphrase').writeAsStringSync('test-passphrase');
|
||||
return File('${dir.path}/$name')..writeAsStringSync(
|
||||
jsonEncode({
|
||||
'id_token': 'seed-id-token',
|
||||
|
||||
57
test/confirm_delete_test.dart
Normal file
57
test/confirm_delete_test.dart
Normal file
@ -0,0 +1,57 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:todo/ui/confirm_delete.dart';
|
||||
|
||||
/// Opens the dialog and returns a getter for its eventual answer.
|
||||
Future<bool? Function()> _open(WidgetTester tester) async {
|
||||
bool? answer;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: TextButton(
|
||||
onPressed: () async => answer = await confirmDeleteNote(context),
|
||||
child: const SizedBox.square(dimension: 40),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.byType(TextButton));
|
||||
await tester.pumpAndSettle();
|
||||
return () => answer;
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('the delete glyph answers yes', (tester) async {
|
||||
final answer = await _open(tester);
|
||||
expect(find.bySemanticsLabel('Delete note?'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('confirm-delete')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(answer(), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('cancel is autofocused, so Return keeps the note', (
|
||||
tester,
|
||||
) async {
|
||||
final answer = await _open(tester);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pumpAndSettle();
|
||||
expect(answer(), isFalse);
|
||||
});
|
||||
|
||||
testWidgets('Escape answers no', (tester) async {
|
||||
final answer = await _open(tester);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
await tester.pumpAndSettle();
|
||||
expect(answer(), isFalse);
|
||||
});
|
||||
|
||||
testWidgets('tapping the barrier answers no', (tester) async {
|
||||
final answer = await _open(tester);
|
||||
await tester.tapAt(const Offset(5, 5));
|
||||
await tester.pumpAndSettle();
|
||||
expect(answer(), isFalse);
|
||||
});
|
||||
}
|
||||
35
test/e2e_github_header.dart
Normal file
35
test/e2e_github_header.dart
Normal file
@ -0,0 +1,35 @@
|
||||
/// A GitHub stand-in that persists the end-to-end encryption header, which
|
||||
/// the sync service writes and re-reads; every other request goes to [inner].
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
/// Wraps [inner] so `…/e2e` PUTs are stored and served back on GET.
|
||||
MockClient e2eMock(Future<http.Response> Function(http.Request) inner) {
|
||||
String? header;
|
||||
return MockClient((request) async {
|
||||
if (request.url.path.endsWith('/e2e.json')) {
|
||||
// Firebase REST: the value is a JSON-encoded string.
|
||||
if (request.method == 'PUT') {
|
||||
header = jsonDecode(request.body) as String;
|
||||
return http.Response(request.body, 200);
|
||||
}
|
||||
return http.Response(jsonEncode(header), 200);
|
||||
}
|
||||
if (!request.url.path.endsWith('/e2e')) return inner(request);
|
||||
if (request.method == 'PUT') {
|
||||
final body = jsonDecode(request.body) as Map<String, dynamic>;
|
||||
header = utf8.decode(base64.decode(body['content'] as String));
|
||||
return http.Response(jsonEncode({'content': {}, 'sha': 'h'}), 200);
|
||||
}
|
||||
final text = header;
|
||||
if (text == null) return http.Response('', 404);
|
||||
return http.Response(
|
||||
jsonEncode({'content': base64.encode(utf8.encode(text)), 'sha': 'h'}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -1,4 +1,6 @@
|
||||
import 'package:crdt_sync/crdt_sync.dart' show Argon2Params, resetE2eCopyMemo;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:todo/sync/run_sync.dart' show e2eArgon2Params;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// In-memory fake for the `flutter_secure_storage` platform channel so tests
|
||||
@ -13,7 +15,18 @@ void installFakeSecureStorage({
|
||||
bool throwing = false,
|
||||
}) {
|
||||
const channel = MethodChannel('plugins.it_nomads.com/flutter_secure_storage');
|
||||
final store = <String, String>{...?initial};
|
||||
resetE2eCopyMemo();
|
||||
e2eArgon2Params = const Argon2Params(
|
||||
memoryKiB: 8,
|
||||
iterations: 1,
|
||||
parallelism: 1,
|
||||
);
|
||||
// Sync is end-to-end encrypted, so a device with no passphrase is locked;
|
||||
// tests that sync start with one unless they pass their own `initial`.
|
||||
final store = <String, String>{
|
||||
'crdt_sync_e2e_passphrase': 'test-passphrase',
|
||||
...?initial,
|
||||
};
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@ import 'fake_note_repository.dart';
|
||||
import 'fake_secure_storage.dart';
|
||||
|
||||
import 'github_mirror_harness.dart';
|
||||
import 'text_free_guard.dart';
|
||||
|
||||
/// Stub launcher that records the URL instead of opening it, so `_openPage`
|
||||
/// can be exercised without a real platform channel.
|
||||
@ -72,7 +73,7 @@ void main() {
|
||||
httpClient: mock,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Connect GitHub'));
|
||||
await tester.tap(find.bySemanticsLabel('Connect GitHub'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
@ -80,7 +81,7 @@ void main() {
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
expect(find.textContaining('sync failed'), findsOneWidget);
|
||||
expect(spoken('sync failed'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Save persists the settings and closes the screen', (
|
||||
@ -135,7 +136,7 @@ void main() {
|
||||
await tester.pump(const Duration(milliseconds: 400)); // route transition
|
||||
expect(find.byType(GitHubMirrorScreen), findsOneWidget); // screen is up
|
||||
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.tap(find.bySemanticsLabel('Save'));
|
||||
await tester.pump(); // run _save (persist + pop)
|
||||
await tester.pump(const Duration(milliseconds: 400)); // pop transition
|
||||
|
||||
@ -195,14 +196,15 @@ void main() {
|
||||
httpClient: mock,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Connect GitHub'));
|
||||
await tester.tap(find.bySemanticsLabel('Connect GitHub'));
|
||||
await tester.pump(); // requestDeviceCode
|
||||
await tester.pump(); // dialog builds, poll starts (interval 0)
|
||||
expect(find.text('WXYZ-1234'), findsOneWidget);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 1)); // _delay(0) fires
|
||||
await tester.pump(); // token error throws → _error set
|
||||
expect(find.textContaining('nope'), findsOneWidget); // error rendered
|
||||
await tester.pump(const Duration(milliseconds: 300)); // dialog faded in
|
||||
expect(spoken('nope'), findsOneWidget); // error rendered (spoken glyph)
|
||||
|
||||
// Tap the "open on GitHub" action: copies the code and launches the URL.
|
||||
// _openPage awaits Clipboard.setData then the launcher's supportsMode +
|
||||
@ -212,7 +214,7 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
expect(launcher.launched, 'https://github.com/login/device');
|
||||
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.tap(find.bySemanticsLabel('Cancel'));
|
||||
await tester.pumpAndSettle(); // finish the dialog pop animation
|
||||
expect(find.text('WXYZ-1234'), findsNothing); // dialog dismissed
|
||||
});
|
||||
|
||||
@ -18,6 +18,7 @@ import 'fake_note_repository.dart';
|
||||
import 'fake_secure_storage.dart';
|
||||
|
||||
import 'github_mirror_harness.dart';
|
||||
import 'text_free_guard.dart';
|
||||
|
||||
/// Stub launcher that records the URL instead of opening it, so `_openPage`
|
||||
/// can be exercised without a real platform channel.
|
||||
@ -43,45 +44,42 @@ void main() {
|
||||
tester,
|
||||
) async {
|
||||
await pumpMirror(tester);
|
||||
await tester.tap(find.text('Connect GitHub'));
|
||||
await tester.tap(find.bySemanticsLabel('Connect GitHub'));
|
||||
await tester.pump();
|
||||
expect(
|
||||
find.textContaining('Enter the OAuth App client id'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(spoken('Enter the OAuth App client id'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Test connection reports a reachable repo', (tester) async {
|
||||
final mock = MockClient((_) async => http.Response('{}', 200));
|
||||
await pumpMirror(tester, httpClient: mock);
|
||||
|
||||
await tester.tap(find.text('Test connection'));
|
||||
await tester.tap(find.bySemanticsLabel('Test connection'));
|
||||
await tester.pump(); // start
|
||||
await tester.pump(); // resolve future + rebuild
|
||||
|
||||
expect(find.textContaining('reachable'), findsOneWidget);
|
||||
expect(spoken('reachable'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Test connection reports an inaccessible repo', (tester) async {
|
||||
final mock = MockClient((_) async => http.Response('', 404));
|
||||
await pumpMirror(tester, httpClient: mock);
|
||||
|
||||
await tester.tap(find.text('Test connection'));
|
||||
await tester.tap(find.bySemanticsLabel('Test connection'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.textContaining('Could not access'), findsOneWidget);
|
||||
expect(spoken('Could not access'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Test connection surfaces a network error', (tester) async {
|
||||
final mock = MockClient((_) async => throw Exception('offline'));
|
||||
await pumpMirror(tester, httpClient: mock);
|
||||
|
||||
await tester.tap(find.text('Test connection'));
|
||||
await tester.tap(find.bySemanticsLabel('Test connection'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.textContaining('Error:'), findsOneWidget);
|
||||
expect(spoken('Error:'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('device flow failure to start shows a message', (tester) async {
|
||||
@ -97,11 +95,11 @@ void main() {
|
||||
httpClient: mock,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Connect GitHub'));
|
||||
await tester.tap(find.bySemanticsLabel('Connect GitHub'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.textContaining('Could not start device flow'), findsOneWidget);
|
||||
expect(spoken('Could not start device flow'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('device flow happy path saves the token', (tester) async {
|
||||
@ -133,7 +131,7 @@ void main() {
|
||||
httpClient: mock,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Connect GitHub'));
|
||||
await tester.tap(find.bySemanticsLabel('Connect GitHub'));
|
||||
await tester.pump(); // requestDeviceCode
|
||||
await tester.pump(); // dialog builds, shows the user code
|
||||
expect(find.text('WXYZ-1234'), findsOneWidget);
|
||||
@ -145,6 +143,6 @@ void main() {
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
expect(find.textContaining('Connected and synced'), findsOneWidget);
|
||||
expect(spoken('Connected and synced'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
@ -6,10 +6,7 @@ void main() {
|
||||
test('returns the token the sign-in function yields', () async {
|
||||
// The injected path: proves the closure is what feeds crdt_sync, and
|
||||
// that no platform channel is touched to get there.
|
||||
expect(
|
||||
await googleIdToken(signInFn: () async => 'a-token'),
|
||||
'a-token',
|
||||
);
|
||||
expect(await googleIdToken(signInFn: () async => 'a-token'), 'a-token');
|
||||
});
|
||||
|
||||
test('passes a null cancellation straight through', () async {
|
||||
|
||||
@ -62,8 +62,8 @@ void main() {
|
||||
final stop = await start();
|
||||
expect(stop, isNotNull);
|
||||
expect(err.toString(), contains('serving http://127.0.0.1:'));
|
||||
expect(db.files.keys, contains(startsWith('todo-sync/notes/')));
|
||||
expect(db.files.keys, contains(startsWith('todo-sync/revs/')));
|
||||
expect(db.files.keys, contains(startsWith('todo-sync/notes-e2e/')));
|
||||
expect(db.files.keys, contains(startsWith('todo-sync/revs-e2e/')));
|
||||
await stop!();
|
||||
});
|
||||
}
|
||||
|
||||
@ -61,7 +61,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('an unmergeable peer log is reported, not hidden', () async {
|
||||
h.db.files['todo-sync/notes/broken~2Ejson'] = 'not a log';
|
||||
h.db.files['todo-sync/notes-e2e/broken~2Ejson'] = 'not a log';
|
||||
expect(await h.refused(c, 'list_tasks', {}), contains('unmergeable'));
|
||||
});
|
||||
}
|
||||
|
||||
@ -38,7 +38,7 @@ void main() {
|
||||
await pumpCapture(tester);
|
||||
await _type(tester);
|
||||
|
||||
await tester.tap(find.byTooltip('New note'));
|
||||
await tester.tap(find.bySemanticsLabel('New note'));
|
||||
await tester.pump(); // one frame: the cue must not wait for anything
|
||||
|
||||
expect(haptics, [_lightImpact]);
|
||||
@ -52,7 +52,7 @@ void main() {
|
||||
await pumpCapture(tester);
|
||||
await _type(tester);
|
||||
|
||||
await tester.tap(find.byTooltip('New note'));
|
||||
await tester.tap(find.bySemanticsLabel('New note'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(_mark, findsOneWidget);
|
||||
@ -74,7 +74,7 @@ void main() {
|
||||
final haptics = recordHaptics(tester);
|
||||
await pumpCapture(tester);
|
||||
|
||||
await tester.tap(find.byTooltip('New note'));
|
||||
await tester.tap(find.bySemanticsLabel('New note'));
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(haptics, isEmpty);
|
||||
@ -87,16 +87,18 @@ void main() {
|
||||
final haptics = recordHaptics(tester);
|
||||
await pumpCapture(tester);
|
||||
await _type(tester);
|
||||
await tester.tap(find.byTooltip('New note'));
|
||||
await tester.tap(find.bySemanticsLabel('New note'));
|
||||
await tester.pump(); // the ticker's clock starts on its first frame
|
||||
await tester.pump(const Duration(milliseconds: 700));
|
||||
await tester.tap(find.byTooltip('New note')); // nothing new to capture
|
||||
await tester.tap(
|
||||
find.bySemanticsLabel('New note'),
|
||||
); // nothing new to capture
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(haptics, hasLength(1));
|
||||
expect(_mark, findsNothing);
|
||||
|
||||
await _type(tester);
|
||||
await tester.tap(find.byTooltip('New note'));
|
||||
await tester.tap(find.bySemanticsLabel('New note'));
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(haptics, hasLength(2));
|
||||
expect(_mark, findsOneWidget);
|
||||
@ -111,7 +113,7 @@ void main() {
|
||||
await pumpCapture(tester);
|
||||
await _type(tester);
|
||||
|
||||
await tester.tap(find.byTooltip('New note'));
|
||||
await tester.tap(find.bySemanticsLabel('New note'));
|
||||
await tester.pump();
|
||||
expect(haptics, [_lightImpact]);
|
||||
|
||||
@ -130,7 +132,7 @@ void main() {
|
||||
await pumpCapture(tester);
|
||||
final before = tester.getTopLeft(find.text('0'));
|
||||
await _type(tester);
|
||||
await tester.tap(find.byTooltip('New note'));
|
||||
await tester.tap(find.bySemanticsLabel('New note'));
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(tester.getTopLeft(find.text('1')), before);
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
|
||||
@ -73,7 +73,7 @@ void main() {
|
||||
final total = spec.sections.length;
|
||||
expect(_barValue(tester), closeTo(1 / total, 1e-9));
|
||||
|
||||
await tester.tap(find.text('Next'));
|
||||
await tester.tap(find.bySemanticsLabel('Next'));
|
||||
await tester.pump();
|
||||
await tester.pump(AppDuration.base ~/ 2);
|
||||
expect(_barValue(tester), inExclusiveRange(1 / total, 2 / total));
|
||||
@ -85,7 +85,7 @@ void main() {
|
||||
testWidgets('remove-animations: the progress bar jumps', (tester) async {
|
||||
disableAnimations(tester);
|
||||
await pumpEditor(tester, initialTemplate: spec);
|
||||
await tester.tap(find.text('Next'));
|
||||
await tester.tap(find.bySemanticsLabel('Next'));
|
||||
await tester.pump();
|
||||
expect(_barValue(tester), closeTo(2 / spec.sections.length, 1e-9));
|
||||
});
|
||||
|
||||
@ -61,7 +61,7 @@ void main() {
|
||||
find.byWidgetPredicate((w) => w is DropdownButton<Priority>),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('High').last);
|
||||
await tester.tap(find.bySemanticsLabel('High').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect((await repo.listNotes()).single.priority, Priority.high);
|
||||
@ -74,7 +74,7 @@ void main() {
|
||||
find.byWidgetPredicate((w) => w is DropdownButton<Status>),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Done').last);
|
||||
await tester.tap(find.bySemanticsLabel('Done').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect((await repo.listNotes()).single.status, Status.done);
|
||||
@ -85,7 +85,7 @@ void main() {
|
||||
) async {
|
||||
final repo = await pumpDetail(tester, seedNote('# T\n\n## what\nold'));
|
||||
|
||||
await tester.tap(find.text('Raw'));
|
||||
await tester.tap(find.bySemanticsLabel('Raw'));
|
||||
await tester.pump();
|
||||
await tester.enterText(find.byType(TextField), '# T\n\n## what\nnew body');
|
||||
await tester.pump();
|
||||
@ -101,20 +101,20 @@ void main() {
|
||||
await tester.enterText(find.byType(TextField), '');
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('Guided'));
|
||||
await tester.tap(find.bySemanticsLabel('Guided'));
|
||||
await tester.pump();
|
||||
expect(find.text('Step 1 of 2'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('Step 1 of 2'), findsOneWidget);
|
||||
// Wizard hides the Status dropdown (onChromeVisibleChanged(false)).
|
||||
expect(find.text('Status'), findsNothing);
|
||||
expect(find.bySemanticsLabel('Status'), findsNothing);
|
||||
|
||||
await tester.tap(find.byType(DropdownButtonFormField<Priority>));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('High').last);
|
||||
await tester.tap(find.bySemanticsLabel('High').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Next'));
|
||||
await tester.tap(find.bySemanticsLabel('Next'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Start'));
|
||||
await tester.tap(find.bySemanticsLabel('Start'));
|
||||
await tester.pump();
|
||||
|
||||
expect((await repo.listNotes()).single.priority, Priority.high);
|
||||
@ -134,11 +134,11 @@ void main() {
|
||||
advancedMode: false,
|
||||
);
|
||||
|
||||
expect(find.text('Template'), findsNothing);
|
||||
expect(find.text('Guided'), findsNothing);
|
||||
expect(find.text('Raw'), findsNothing);
|
||||
expect(find.text('Priority'), findsNothing);
|
||||
expect(find.text('Status'), findsNothing);
|
||||
expect(find.bySemanticsLabel('Template'), findsNothing);
|
||||
expect(find.bySemanticsLabel('Guided'), findsNothing);
|
||||
expect(find.bySemanticsLabel('Raw'), findsNothing);
|
||||
expect(find.bySemanticsLabel('Priority'), findsNothing);
|
||||
expect(find.bySemanticsLabel('Status'), findsNothing);
|
||||
// The body itself is still editable — only the chrome is gone.
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
});
|
||||
@ -146,13 +146,13 @@ void main() {
|
||||
testWidgets('the delete action removes the note and pops', (tester) async {
|
||||
final repo = await pumpDetail(tester, seedNote('# Bye'));
|
||||
|
||||
await tester.tap(find.byTooltip('Delete note'));
|
||||
await tester.tap(find.bySemanticsLabel('Delete note'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Delete is now confirmed. Deleting a note is irreversible and there is no
|
||||
// undo, so a single Return on a focused control must not destroy it.
|
||||
expect(find.text('Delete note?'), findsOneWidget);
|
||||
await tester.tap(find.text('Delete'));
|
||||
expect(find.bySemanticsLabel('Delete note?'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('confirm-delete')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(await repo.listNotes(), isEmpty);
|
||||
@ -162,9 +162,9 @@ void main() {
|
||||
testWidgets('cancelling the delete keeps the note', (tester) async {
|
||||
final repo = await pumpDetail(tester, seedNote('# Keep me'));
|
||||
|
||||
await tester.tap(find.byTooltip('Delete note'));
|
||||
await tester.tap(find.bySemanticsLabel('Delete note'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.tap(find.byKey(const ValueKey('confirm-cancel')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(await repo.listNotes(), hasLength(1));
|
||||
|
||||
@ -4,8 +4,10 @@ import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_template.dart';
|
||||
import 'package:todo/ui/markdown_view.dart';
|
||||
import 'package:todo/ui/note_editor.dart';
|
||||
import 'package:todo/ui/template_glyphs.dart';
|
||||
|
||||
import 'note_editor_harness.dart';
|
||||
import 'text_free_guard.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('guided: typing the title emits an assembled # heading', (
|
||||
@ -13,7 +15,7 @@ void main() {
|
||||
) async {
|
||||
final emitted = await pumpEditor(tester, initialTemplate: spec);
|
||||
|
||||
expect(find.byTooltip('Exit guided'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('Exit guided'), findsOneWidget);
|
||||
await tester.enterText(find.byType(TextField).first, 'Dark mode');
|
||||
await tester.pump();
|
||||
|
||||
@ -28,11 +30,11 @@ void main() {
|
||||
expect(find.byType(LinearProgressIndicator), findsOneWidget);
|
||||
expect(find.text('1 / ${spec.sections.length}'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Next'));
|
||||
await tester.tap(find.bySemanticsLabel('Next'));
|
||||
await tester.pump();
|
||||
expect(find.text('2 / ${spec.sections.length}'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Back'));
|
||||
await tester.tap(find.bySemanticsLabel('Back'));
|
||||
await tester.pump();
|
||||
expect(find.text('1 / ${spec.sections.length}'), findsOneWidget);
|
||||
expect(find.byType(LinearProgressIndicator), findsOneWidget);
|
||||
@ -49,21 +51,22 @@ void main() {
|
||||
for (var i = 0; i < spec.sections.length; i++) {
|
||||
final section = spec.sections[i];
|
||||
expect(find.text('${i + 1} / ${spec.sections.length}'), findsOneWidget);
|
||||
// Each step paints its section glyph; label + helper are spoken.
|
||||
expect(
|
||||
find.text(section.isTitle ? 'title' : section.label),
|
||||
findsWidgets,
|
||||
find.byIcon(sectionIcon(section)),
|
||||
findsOneWidget,
|
||||
reason: 'step ${i + 1} should show "${section.label}"',
|
||||
);
|
||||
expect(find.text(section.helper), findsOneWidget);
|
||||
expect(spoken(section.helper), findsOneWidget);
|
||||
if (i < spec.sections.length - 1) {
|
||||
await tester.tap(find.text('Next'));
|
||||
await tester.tap(find.bySemanticsLabel('Next'));
|
||||
await tester.pump();
|
||||
}
|
||||
}
|
||||
|
||||
// The last step is `read first`; there is no Next past it.
|
||||
expect(spec.sections.last.label, 'read first');
|
||||
expect(find.text('Next'), findsNothing);
|
||||
expect(find.bySemanticsLabel('Next'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('pasting a bare link drops the draft to the blank template', (
|
||||
@ -74,7 +77,7 @@ void main() {
|
||||
initialTemplate: spec,
|
||||
initialMode: NoteEditorMode.raw,
|
||||
);
|
||||
expect(find.text('LLM design spec'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.assignment_outlined), findsOneWidget);
|
||||
|
||||
await tester.enterText(
|
||||
find.byType(TextField).first,
|
||||
@ -83,8 +86,8 @@ void main() {
|
||||
await tester.pump();
|
||||
|
||||
// Freeform now, so there is no seven-step stepper to walk for a link.
|
||||
expect(find.text('Blank'), findsOneWidget);
|
||||
expect(find.text('Guided'), findsNothing);
|
||||
expect(find.byIcon(Icons.note_outlined), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('Guided'), findsNothing);
|
||||
|
||||
// Adding prose puts the spec template back — lossless in both directions.
|
||||
await tester.enterText(
|
||||
@ -92,8 +95,8 @@ void main() {
|
||||
'https://example.com/x — port this to Dart',
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.text('LLM design spec'), findsOneWidget);
|
||||
expect(find.text('Guided'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.assignment_outlined), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('Guided'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('an explicit template choice survives further typing', (
|
||||
@ -105,16 +108,16 @@ void main() {
|
||||
initialMode: NoteEditorMode.raw,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('LLM design spec'));
|
||||
await tester.tap(find.byIcon(Icons.assignment_outlined));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Blank').last);
|
||||
await tester.tap(find.byIcon(Icons.note_outlined).last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Blank'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.note_outlined), findsOneWidget);
|
||||
|
||||
// Prose would normally re-select the spec template; an explicit pick wins.
|
||||
await tester.enterText(find.byType(TextField).first, 'build a thing');
|
||||
await tester.pump();
|
||||
expect(find.text('Blank'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.note_outlined), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('switching to View renders the note as Markdown', (tester) async {
|
||||
@ -126,7 +129,7 @@ void main() {
|
||||
await tester.enterText(find.byType(TextField).first, '# Render me');
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('View'));
|
||||
await tester.tap(find.bySemanticsLabel('View'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(MarkdownView), findsOneWidget);
|
||||
|
||||
@ -6,6 +6,7 @@ import 'package:todo/ui/markdown_view.dart';
|
||||
import 'package:todo/ui/note_editor.dart';
|
||||
|
||||
import 'note_editor_harness.dart';
|
||||
import 'text_free_guard.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
@ -15,7 +16,7 @@ void main() {
|
||||
await tester.enterText(find.byType(TextField).first, 'T');
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byTooltip('Exit guided'));
|
||||
await tester.tap(find.bySemanticsLabel('Exit guided'));
|
||||
await tester.pump();
|
||||
|
||||
final raw = tester.widget<TextField>(find.byType(TextField));
|
||||
@ -38,10 +39,11 @@ void main() {
|
||||
);
|
||||
|
||||
// Structured template + non-conforming raw text → switching is refused.
|
||||
await tester.tap(find.text('Guided'));
|
||||
await tester.tap(find.bySemanticsLabel('Guided'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500)); // snack fades in
|
||||
|
||||
expect(find.textContaining("doesn't match the template"), findsOneWidget);
|
||||
expect(spoken("doesn't match the template"), findsOneWidget);
|
||||
expect(find.byType(LinearProgressIndicator), findsNothing); // still raw
|
||||
});
|
||||
|
||||
@ -56,25 +58,28 @@ void main() {
|
||||
|
||||
// Bare guided -> raw via the back arrow makes the (still conforming)
|
||||
// body the source…
|
||||
await tester.tap(find.byTooltip('Exit guided'));
|
||||
await tester.tap(find.bySemanticsLabel('Exit guided'));
|
||||
await tester.pump();
|
||||
expect(find.byType(LinearProgressIndicator), findsNothing);
|
||||
|
||||
// …then raw -> guided re-parses the conforming body straight back into
|
||||
// the bare stepper — no wizard, since the content isn't empty.
|
||||
await tester.tap(find.text('Guided'));
|
||||
await tester.tap(find.bySemanticsLabel('Guided'));
|
||||
await tester.pump();
|
||||
expect(find.byType(LinearProgressIndicator), findsOneWidget);
|
||||
expect(find.text('Step 1 of 2'), findsNothing); // wizard was skipped
|
||||
expect(
|
||||
find.bySemanticsLabel('Step 1 of 2'),
|
||||
findsNothing,
|
||||
); // wizard was skipped
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('freeform template offers only View and Raw', (tester) async {
|
||||
final emitted = await pumpEditor(tester, initialTemplate: blank);
|
||||
|
||||
expect(find.text('Guided'), findsNothing);
|
||||
expect(find.text('View'), findsOneWidget);
|
||||
expect(find.text('Raw'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('Guided'), findsNothing);
|
||||
expect(find.bySemanticsLabel('View'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('Raw'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'free text');
|
||||
await tester.pump();
|
||||
@ -89,15 +94,20 @@ void main() {
|
||||
initialTemplate: spec,
|
||||
initialMode: NoteEditorMode.raw,
|
||||
);
|
||||
expect(find.text('Guided'), findsOneWidget); // structured: Guided offered
|
||||
expect(
|
||||
find.bySemanticsLabel('Guided'),
|
||||
findsOneWidget,
|
||||
); // structured: Guided offered
|
||||
|
||||
await tester.tap(find.text('LLM design spec').first); // open the menu
|
||||
await tester.tap(
|
||||
find.byIcon(Icons.assignment_outlined).first,
|
||||
); // open the menu
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Blank').last);
|
||||
await tester.tap(find.byIcon(Icons.note_outlined).last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Freeform now: Guided is no longer offered.
|
||||
expect(find.text('Guided'), findsNothing);
|
||||
expect(find.bySemanticsLabel('Guided'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('detects a conforming note (no template given) as guided', (
|
||||
@ -110,7 +120,7 @@ void main() {
|
||||
find.byType(LinearProgressIndicator),
|
||||
findsOneWidget,
|
||||
); // guided by default
|
||||
expect(find.byTooltip('Exit guided'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('Exit guided'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('detects a legacy note (no template given) as freeform raw', (
|
||||
@ -120,7 +130,7 @@ void main() {
|
||||
|
||||
// Non-conforming → blank/raw, no guided stepper offered.
|
||||
expect(find.byType(LinearProgressIndicator), findsNothing);
|
||||
expect(find.text('Guided'), findsNothing);
|
||||
expect(find.bySemanticsLabel('Guided'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('initialMode preview opens directly in the rendered view', (
|
||||
@ -147,7 +157,7 @@ void main() {
|
||||
initialMode: NoteEditorMode.preview,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Raw'));
|
||||
await tester.tap(find.bySemanticsLabel('Raw'));
|
||||
await tester.pump();
|
||||
|
||||
final raw = tester.widget<TextField>(find.byType(TextField));
|
||||
|
||||
@ -6,6 +6,7 @@ import 'package:todo/ui/markdown_view.dart';
|
||||
import 'package:todo/ui/note_editor.dart';
|
||||
|
||||
import 'note_editor_harness.dart';
|
||||
import 'text_free_guard.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
@ -17,10 +18,10 @@ void main() {
|
||||
initialMode: NoteEditorMode.raw,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Guided'));
|
||||
await tester.tap(find.bySemanticsLabel('Guided'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Step 1 of 2'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('Step 1 of 2'), findsOneWidget);
|
||||
expect(find.byType(DropdownButtonFormField<Priority>), findsOneWidget);
|
||||
expect(find.byType(LinearProgressIndicator), findsNothing);
|
||||
},
|
||||
@ -34,17 +35,17 @@ void main() {
|
||||
initialTemplate: spec,
|
||||
initialMode: NoteEditorMode.raw,
|
||||
);
|
||||
await tester.tap(find.text('Guided'));
|
||||
await tester.tap(find.bySemanticsLabel('Guided'));
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('Next'));
|
||||
await tester.tap(find.bySemanticsLabel('Next'));
|
||||
await tester.pump();
|
||||
expect(find.text('Step 2 of 2'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('Step 2 of 2'), findsOneWidget);
|
||||
expect(find.byType(DropdownButtonFormField<String>), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Back'));
|
||||
await tester.tap(find.bySemanticsLabel('Back'));
|
||||
await tester.pump();
|
||||
expect(find.text('Step 1 of 2'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('Step 1 of 2'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
@ -56,19 +57,21 @@ void main() {
|
||||
initialTemplate: spec,
|
||||
initialMode: NoteEditorMode.raw,
|
||||
);
|
||||
await tester.tap(find.text('Guided'));
|
||||
await tester.tap(find.bySemanticsLabel('Guided'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Next'));
|
||||
await tester.tap(find.bySemanticsLabel('Next'));
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('LLM design spec').first); // open the menu
|
||||
await tester.tap(
|
||||
find.byIcon(Icons.assignment_outlined).first,
|
||||
); // open the menu
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Blank'), findsNothing);
|
||||
expect(find.byIcon(Icons.note_outlined), findsNothing);
|
||||
|
||||
// Select the (only) offered template, exercising the dropdown's onChanged.
|
||||
await tester.tap(find.text('LLM design spec').last);
|
||||
await tester.tap(find.byIcon(Icons.assignment_outlined).last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Step 2 of 2'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('Step 2 of 2'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
@ -81,22 +84,22 @@ void main() {
|
||||
initialMode: NoteEditorMode.raw,
|
||||
onPriorityChanged: (p) => committed = p,
|
||||
);
|
||||
await tester.tap(find.text('Guided'));
|
||||
await tester.tap(find.bySemanticsLabel('Guided'));
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byType(DropdownButtonFormField<Priority>));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('High').last);
|
||||
await tester.tap(find.bySemanticsLabel('High').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Next'));
|
||||
await tester.tap(find.bySemanticsLabel('Next'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Start'));
|
||||
await tester.tap(find.bySemanticsLabel('Start'));
|
||||
await tester.pump();
|
||||
|
||||
expect(committed, Priority.high);
|
||||
expect(find.byType(LinearProgressIndicator), findsOneWidget);
|
||||
expect(find.byTooltip('Exit guided'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('Exit guided'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
@ -110,16 +113,19 @@ void main() {
|
||||
initialMode: NoteEditorMode.raw,
|
||||
onChromeVisibleChanged: chromeVisible.add,
|
||||
);
|
||||
await tester.tap(find.text('Guided'));
|
||||
await tester.tap(find.bySemanticsLabel('Guided'));
|
||||
await tester.pump();
|
||||
expect(chromeVisible.last, false);
|
||||
|
||||
await tester.tap(find.byTooltip('Cancel'));
|
||||
await tester.tap(find.bySemanticsLabel('Cancel'));
|
||||
await tester.pump();
|
||||
|
||||
expect(chromeVisible.last, true);
|
||||
expect(find.byType(LinearProgressIndicator), findsNothing);
|
||||
expect(find.text('Raw'), findsOneWidget); // chrome's mode selector is back
|
||||
expect(
|
||||
find.bySemanticsLabel('Raw'),
|
||||
findsOneWidget,
|
||||
); // chrome's mode selector is back
|
||||
});
|
||||
|
||||
testWidgets('exiting bare Guided via the back arrow restores the chrome', (
|
||||
@ -135,11 +141,11 @@ void main() {
|
||||
);
|
||||
expect(find.byType(LinearProgressIndicator), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Exit guided'));
|
||||
await tester.tap(find.bySemanticsLabel('Exit guided'));
|
||||
await tester.pump();
|
||||
|
||||
expect(chromeVisible, contains(true));
|
||||
expect(find.text('Raw'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('Raw'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('last step shows Done instead of Next', (tester) async {
|
||||
@ -147,12 +153,12 @@ void main() {
|
||||
|
||||
// Navigate to the final step.
|
||||
for (var i = 0; i < spec.sections.length - 1; i++) {
|
||||
await tester.tap(find.text('Next'));
|
||||
await tester.tap(find.bySemanticsLabel('Next'));
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
expect(find.text('Done'), findsOneWidget);
|
||||
expect(find.text('Next'), findsNothing);
|
||||
expect(find.bySemanticsLabel('Done'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('Next'), findsNothing);
|
||||
expect(
|
||||
find.text('${spec.sections.length} / ${spec.sections.length}'),
|
||||
findsOneWidget,
|
||||
@ -167,10 +173,10 @@ void main() {
|
||||
await tester.pump();
|
||||
|
||||
for (var i = 0; i < spec.sections.length - 1; i++) {
|
||||
await tester.tap(find.text('Next'));
|
||||
await tester.tap(find.bySemanticsLabel('Next'));
|
||||
await tester.pump();
|
||||
}
|
||||
await tester.tap(find.text('Done'));
|
||||
await tester.tap(find.bySemanticsLabel('Done'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(LinearProgressIndicator), findsNothing);
|
||||
|
||||
@ -7,6 +7,8 @@ import 'package:todo/data/note.dart';
|
||||
import 'package:todo/ui/note_grid_tile.dart';
|
||||
|
||||
import 'notes_list_harness.dart';
|
||||
import 'package:todo/ui/enum_glyphs.dart';
|
||||
import 'package:todo/ui/empty_notes_cue.dart';
|
||||
|
||||
void _phone(WidgetTester t) {
|
||||
t.view
|
||||
@ -53,10 +55,10 @@ void main() {
|
||||
);
|
||||
|
||||
expect(find.text('Buy milk'), findsOneWidget);
|
||||
expect(find.text('High'), findsOneWidget);
|
||||
expect(find.text('In progress'), findsOneWidget);
|
||||
expect(find.byIcon(priorityIcon(Priority.high)), findsOneWidget);
|
||||
expect(find.byIcon(statusIcon(Status.inProgress)), findsOneWidget);
|
||||
expect(find.textContaining('Two litres'), findsNothing);
|
||||
expect(find.textContaining('edited'), findsNothing);
|
||||
expect(find.byIcon(Icons.history), findsNothing);
|
||||
expect(find.byTooltip('Quick actions'), findsNothing);
|
||||
});
|
||||
|
||||
@ -93,7 +95,7 @@ void main() {
|
||||
testWidgets('an empty note reads (empty)', (t) async {
|
||||
_phone(t);
|
||||
await pumpList(t, seed: [note('a', '')]);
|
||||
expect(find.text('(empty)'), findsOneWidget);
|
||||
expect(find.text(kEmptyTitle), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('tap opens the note', (t) async {
|
||||
@ -103,8 +105,8 @@ void main() {
|
||||
await t.tap(find.text('Open me'));
|
||||
await t.pumpAndSettle();
|
||||
|
||||
expect(find.text('Priority'), findsOneWidget);
|
||||
expect(find.text('View'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('Priority'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('View'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('long-press opens the sheet with body, dates and actions', (
|
||||
@ -119,12 +121,18 @@ void main() {
|
||||
await _openSheet(t, 'Buy milk');
|
||||
|
||||
expect(find.textContaining('Two litres of oat'), findsOneWidget);
|
||||
expect(find.textContaining('Edited'), findsOneWidget);
|
||||
expect(find.textContaining('Created'), findsOneWidget);
|
||||
await t.tap(find.text('In progress'));
|
||||
expect(find.byIcon(Icons.history), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(BottomSheet),
|
||||
matching: find.byIcon(Icons.add),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
await t.tap(find.bySemanticsLabel('In progress'));
|
||||
await t.pump();
|
||||
expect((await repo.listNotes()).single.status, Status.inProgress);
|
||||
expect(find.text('Delete note'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('Delete note'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a title-only note adds no body to the sheet', (t) async {
|
||||
@ -134,7 +142,7 @@ void main() {
|
||||
await _openSheet(t, 'Just a title');
|
||||
|
||||
expect(find.text('Just a title'), findsNWidgets(2));
|
||||
expect(find.textContaining('Edited'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.history), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the sheet can delete a grid note', (t) async {
|
||||
@ -142,9 +150,9 @@ void main() {
|
||||
final repo = await pumpList(t, seed: [note('a', 'Delete me')]);
|
||||
|
||||
await _openSheet(t, 'Delete me');
|
||||
await t.tap(find.text('Delete note'));
|
||||
await t.tap(find.bySemanticsLabel('Delete note'));
|
||||
await t.pumpAndSettle();
|
||||
await t.tap(find.text('Delete'));
|
||||
await t.tap(find.byKey(const ValueKey('confirm-delete')));
|
||||
await t.pumpAndSettle();
|
||||
|
||||
expect(await repo.listNotes(), isEmpty);
|
||||
@ -154,7 +162,7 @@ void main() {
|
||||
_phone(t);
|
||||
final repo = await pumpList(t);
|
||||
|
||||
expect(find.textContaining('No notes'), findsOneWidget);
|
||||
expect(find.byType(EmptyNotesCue), findsOneWidget);
|
||||
expect(repo.lastFilter!.statuses, {Status.todo, Status.inProgress});
|
||||
expect(t.widget<Badge>(find.byType(Badge)).isLabelVisible, isFalse);
|
||||
});
|
||||
|
||||
@ -7,13 +7,14 @@ import 'package:todo/ui/notes_list_screen.dart';
|
||||
import 'fake_note_repository.dart';
|
||||
|
||||
import 'notes_list_harness.dart';
|
||||
import 'text_free_guard.dart';
|
||||
|
||||
/// Opens a date field's menu and picks [entry].
|
||||
Future<void> pickRange(WidgetTester tester, String field, String entry) async {
|
||||
await tester.tap(find.byTooltip(field));
|
||||
await tester.tap(find.bySemanticsLabel(field));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.tap(find.text(entry).last);
|
||||
await tester.tap(spoken(entry).last);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
@ -68,10 +69,10 @@ void main() {
|
||||
],
|
||||
);
|
||||
|
||||
expect(find.textContaining('just now'), findsOneWidget);
|
||||
expect(find.textContaining('5m ago'), findsOneWidget);
|
||||
expect(find.textContaining('2h ago'), findsOneWidget);
|
||||
expect(find.textContaining('3d ago'), findsOneWidget);
|
||||
expect(find.text('0m'), findsOneWidget);
|
||||
expect(find.text('5m'), findsOneWidget);
|
||||
expect(find.text('2h'), findsOneWidget);
|
||||
expect(find.text('3d'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Clear all resets the filter sheet to the default', (
|
||||
@ -82,9 +83,9 @@ void main() {
|
||||
await tester.tap(find.byIcon(Icons.filter_list));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.tap(find.text('Done')); // add a non-default status
|
||||
await tester.tap(find.bySemanticsLabel('Done')); // non-default status
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Clear all'));
|
||||
await tester.tap(find.bySemanticsLabel('Clear all'));
|
||||
await tester.pump();
|
||||
await tapApply(tester);
|
||||
await tester.pump();
|
||||
@ -139,7 +140,7 @@ void main() {
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
await tester.tap(find.text('High')); // priority chip toggle
|
||||
await tester.tap(find.bySemanticsLabel('High')); // priority chip toggle
|
||||
await pickRange(tester, 'Last updated', 'Today');
|
||||
await tapApply(tester);
|
||||
await tester.pump();
|
||||
@ -178,10 +179,10 @@ void main() {
|
||||
testWidgets('per-note sheet changes priority via a chip', (tester) async {
|
||||
final repo = await pumpList(tester, seed: [note('a', 'Repriortise me')]);
|
||||
|
||||
await tester.tap(find.byTooltip('Quick actions'));
|
||||
await tester.tap(find.bySemanticsLabel('Quick actions'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.tap(find.text('Low')); // default is Medium → change to Low
|
||||
await tester.tap(find.bySemanticsLabel('Low')); // Medium → Low
|
||||
await tester.pump();
|
||||
|
||||
expect((await repo.listNotes()).single.priority, Priority.low);
|
||||
@ -196,7 +197,7 @@ void main() {
|
||||
|
||||
// The detail screen shows the note title in its app bar plus the
|
||||
// Priority/Status meta dropdowns and the editor mode toggle.
|
||||
expect(find.text('Priority'), findsOneWidget);
|
||||
expect(find.text('View'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('Priority'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('View'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
@ -37,7 +37,7 @@ void main() {
|
||||
) async {
|
||||
await pumpList(tester, seed: [note('a', 'Buy milk')]);
|
||||
await editNoteAndSwipeBack(tester);
|
||||
expect(find.text('Search notes…'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.search), findsOneWidget);
|
||||
expect(textFieldFocused(), isFalse);
|
||||
});
|
||||
|
||||
@ -48,7 +48,7 @@ void main() {
|
||||
await tester.tap(find.byType(TextField).first);
|
||||
await tester.pump();
|
||||
await editNoteAndSwipeBack(tester);
|
||||
expect(find.text('Search notes…'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.search), findsOneWidget);
|
||||
expect(textFieldFocused(), isFalse);
|
||||
});
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:todo/data/app_settings.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/ui/notes_list_screen.dart';
|
||||
import 'package:todo/ui/text_free_localizations.dart';
|
||||
|
||||
import 'fake_note_repository.dart';
|
||||
|
||||
@ -42,6 +43,8 @@ Future<FakeNoteRepository> pumpList(
|
||||
addTearDown(settings.dispose);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
// As in main.dart: Material's own button tooltips are blanked.
|
||||
localizationsDelegates: kTextFreeLocalizations,
|
||||
home: NotesListScreen(repository: repo, appSettings: settings),
|
||||
),
|
||||
);
|
||||
@ -53,7 +56,8 @@ Future<FakeNoteRepository> pumpList(
|
||||
/// sheet, so on a short viewport it legitimately sits below the fold:
|
||||
/// scroll it into view first, as a user would.
|
||||
Future<void> tapApply(WidgetTester tester) async {
|
||||
await tester.ensureVisible(find.text('Apply'));
|
||||
final apply = find.widgetWithIcon(FilledButton, Icons.check);
|
||||
await tester.ensureVisible(apply);
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Apply'));
|
||||
await tester.tap(apply);
|
||||
}
|
||||
|
||||
@ -79,7 +79,10 @@ void main() {
|
||||
if (axisDirectionToAxis(s.axisDirection) == Axis.vertical)
|
||||
expect(s.position.maxScrollExtent, 0);
|
||||
}
|
||||
expect(find.text('Apply').hitTestable(), findsOneWidget);
|
||||
expect(
|
||||
find.widgetWithIcon(FilledButton, Icons.check).hitTestable(),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Repo tasks'), findsNothing);
|
||||
expect(find.text('Ideas'), findsNothing);
|
||||
expect(find.byType(ActionChip), findsNothing);
|
||||
|
||||
@ -53,15 +53,15 @@ void main() {
|
||||
expect(find.text('make check'), findsOneWidget);
|
||||
expect(find.text('claude'), findsOneWidget);
|
||||
expect(find.text('abc 0'), findsOneWidget);
|
||||
expect(find.text('Done check'), findsOneWidget);
|
||||
expect(find.text('Evidence'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.fact_check_outlined), findsOneWidget);
|
||||
expect(find.byIcon(Icons.verified_outlined), findsOneWidget);
|
||||
expect(find.byType(TextField), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a plain idea shows no task fields', (tester) async {
|
||||
await pumpActions(tester, note('i', 'An idea'));
|
||||
expect(find.text('Repo'), findsNothing);
|
||||
expect(find.text('Done check'), findsNothing);
|
||||
expect(find.text('Evidence'), findsNothing);
|
||||
expect(find.byIcon(Icons.folder_outlined), findsNothing);
|
||||
expect(find.byIcon(Icons.fact_check_outlined), findsNothing);
|
||||
expect(find.byIcon(Icons.verified_outlined), findsNothing);
|
||||
});
|
||||
}
|
||||
|
||||
@ -7,6 +7,9 @@ import 'package:todo/ui/notes_list_screen.dart';
|
||||
import 'fake_note_repository.dart';
|
||||
|
||||
import 'notes_list_harness.dart';
|
||||
import 'package:todo/ui/enum_glyphs.dart';
|
||||
import 'package:todo/ui/note_grid_tile.dart';
|
||||
import 'package:todo/ui/empty_notes_cue.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('renders notes with a status · priority · time subtitle', (
|
||||
@ -18,8 +21,8 @@ void main() {
|
||||
);
|
||||
|
||||
expect(find.text('First note'), findsOneWidget);
|
||||
expect(find.textContaining('To do'), findsOneWidget);
|
||||
expect(find.textContaining('High'), findsOneWidget);
|
||||
expect(find.byIcon(statusIcon(Status.todo)), findsOneWidget);
|
||||
expect(find.byIcon(priorityIcon(Priority.high)), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('defaults to hiding Done/Abandoned with no filter badge', (
|
||||
@ -51,7 +54,7 @@ void main() {
|
||||
await tester.tap(find.byIcon(Icons.sort));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300)); // menu open
|
||||
await tester.tap(find.text('Alphabetical').last);
|
||||
await tester.tap(find.bySemanticsLabel('Alphabetical').last);
|
||||
await tester.pump();
|
||||
|
||||
expect(repo.lastSort, NoteSort.alphabetical);
|
||||
@ -63,9 +66,9 @@ void main() {
|
||||
await tester.tap(find.byIcon(Icons.filter_list));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300)); // sheet open
|
||||
await tester.tap(find.text('Done'));
|
||||
await tester.tap(find.bySemanticsLabel('Done'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Apply'));
|
||||
await tapApply(tester);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300)); // sheet close
|
||||
|
||||
@ -77,16 +80,16 @@ void main() {
|
||||
testWidgets('per-note sheet deletes the note', (tester) async {
|
||||
final repo = await pumpList(tester, seed: [note('a', 'Delete me')]);
|
||||
|
||||
await tester.tap(find.byTooltip('Quick actions'));
|
||||
await tester.tap(find.bySemanticsLabel('Quick actions'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300)); // sheet open
|
||||
await tester.tap(find.text('Delete note'));
|
||||
await tester.tap(find.bySemanticsLabel('Delete note'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Delete is now confirmed on this path too: it is irreversible, has no
|
||||
// undo, and is one Tab from a Return in the sheet's chip rows.
|
||||
expect(find.text('Delete note?'), findsOneWidget);
|
||||
await tester.tap(find.text('Delete'));
|
||||
expect(find.bySemanticsLabel('Delete note?'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('confirm-delete')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(await repo.listNotes(), isEmpty);
|
||||
@ -95,12 +98,12 @@ void main() {
|
||||
testWidgets('cancelling the sheet delete keeps the note', (tester) async {
|
||||
final repo = await pumpList(tester, seed: [note('a', 'Keep me')]);
|
||||
|
||||
await tester.tap(find.byTooltip('Quick actions'));
|
||||
await tester.tap(find.bySemanticsLabel('Quick actions'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.tap(find.text('Delete note'));
|
||||
await tester.tap(find.bySemanticsLabel('Delete note'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.tap(find.byKey(const ValueKey('confirm-cancel')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(await repo.listNotes(), hasLength(1));
|
||||
@ -109,10 +112,10 @@ void main() {
|
||||
testWidgets('per-note sheet changes status via a chip', (tester) async {
|
||||
final repo = await pumpList(tester, seed: [note('a', 'Change me')]);
|
||||
|
||||
await tester.tap(find.byTooltip('Quick actions'));
|
||||
await tester.tap(find.bySemanticsLabel('Quick actions'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.tap(find.text('In progress'));
|
||||
await tester.tap(find.bySemanticsLabel('In progress'));
|
||||
await tester.pump();
|
||||
|
||||
expect((await repo.listNotes()).single.status, Status.inProgress);
|
||||
@ -122,6 +125,6 @@ void main() {
|
||||
await pumpList(tester); // no seed
|
||||
// The default filter hides Done/Abandoned, so it's the "no match"
|
||||
// variant rather than "No notes yet" — either way, an empty message.
|
||||
expect(find.textContaining('No notes'), findsOneWidget);
|
||||
expect(find.byType(EmptyNotesCue), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
@ -109,10 +109,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('accepts a binary replaced by an upgrade', () {
|
||||
expect(
|
||||
isTodoWrapper('/opt/todo/bin/todo_desktop (deleted)', []),
|
||||
isTrue,
|
||||
);
|
||||
expect(isTodoWrapper('/opt/todo/bin/todo_desktop (deleted)', []), isTrue);
|
||||
});
|
||||
|
||||
test('accepts a development run out of the repo', () {
|
||||
@ -155,48 +152,36 @@ void main() {
|
||||
|
||||
test('matches a normally separated argument vector', () {
|
||||
expect(
|
||||
hasWindowAttached(
|
||||
[
|
||||
['thorium', '--user-data-dir=$_profile', '--class=todo'],
|
||||
],
|
||||
_profile,
|
||||
),
|
||||
hasWindowAttached([
|
||||
['thorium', '--user-data-dir=$_profile', '--class=todo'],
|
||||
], _profile),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('matches when the flag is the final argument', () {
|
||||
expect(
|
||||
hasWindowAttached(
|
||||
[
|
||||
['thorium', '--user-data-dir=$_profile'],
|
||||
],
|
||||
_profile,
|
||||
),
|
||||
hasWindowAttached([
|
||||
['thorium', '--user-data-dir=$_profile'],
|
||||
], _profile),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not match a sibling profile directory', () {
|
||||
expect(
|
||||
hasWindowAttached(
|
||||
[
|
||||
['thorium', '--user-data-dir=${_profile}2'],
|
||||
],
|
||||
_profile,
|
||||
),
|
||||
hasWindowAttached([
|
||||
['thorium', '--user-data-dir=${_profile}2'],
|
||||
], _profile),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not match when no process mentions the profile', () {
|
||||
expect(
|
||||
hasWindowAttached(
|
||||
[
|
||||
['bash', '-c', 'sleep 1'],
|
||||
],
|
||||
_profile,
|
||||
),
|
||||
hasWindowAttached([
|
||||
['bash', '-c', 'sleep 1'],
|
||||
], _profile),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
@ -91,17 +91,11 @@ void main() {
|
||||
);
|
||||
final action = await guard.resolve(8730);
|
||||
expect(action, isA<AbortWithOwner>());
|
||||
expect(
|
||||
(action as AbortWithOwner).description,
|
||||
contains('unidentified'),
|
||||
);
|
||||
expect((action as AbortWithOwner).description, contains('unidentified'));
|
||||
});
|
||||
|
||||
test('retries when the owner exited mid-probe', () async {
|
||||
final guard = PortGuard(
|
||||
profileDir: _profile,
|
||||
probe: FakeProbe(pid: 42),
|
||||
);
|
||||
final guard = PortGuard(profileDir: _profile, probe: FakeProbe(pid: 42));
|
||||
expect(await guard.resolve(8730), isA<BindNow>());
|
||||
});
|
||||
|
||||
@ -202,20 +196,14 @@ void main() {
|
||||
final probe = FakeProbe(freeAfter: ProcessSignal.sigkill);
|
||||
final guard = PortGuard(profileDir: _profile, probe: probe);
|
||||
expect(await _reap(guard, 42, 8730), isTrue);
|
||||
expect(probe.signalsSent, [
|
||||
ProcessSignal.sigterm,
|
||||
ProcessSignal.sigkill,
|
||||
]);
|
||||
expect(probe.signalsSent, [ProcessSignal.sigterm, ProcessSignal.sigkill]);
|
||||
});
|
||||
|
||||
test('reports failure when the port never comes free', () async {
|
||||
final probe = FakeProbe(inUse: [true]);
|
||||
final guard = PortGuard(profileDir: _profile, probe: probe);
|
||||
expect(await _reap(guard, 42, 8730), isFalse);
|
||||
expect(probe.signalsSent, [
|
||||
ProcessSignal.sigterm,
|
||||
ProcessSignal.sigkill,
|
||||
]);
|
||||
expect(probe.signalsSent, [ProcessSignal.sigterm, ProcessSignal.sigkill]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -19,12 +19,36 @@ import 'package:todo/data/note_repository.dart';
|
||||
import 'package:todo/sync/run_sync.dart';
|
||||
import 'package:todo/sync/sync_settings.dart';
|
||||
|
||||
import 'fake_secure_storage.dart';
|
||||
|
||||
final _headers = <String, String>{};
|
||||
|
||||
/// Stateful GitHub stand-in for the encryption header, the one file the
|
||||
/// sync service itself does not own: PUT stores it, GET serves it back.
|
||||
http.Response? _headerReply(http.Request request) {
|
||||
if (!request.url.path.endsWith('/e2e')) return null;
|
||||
if (request.method == 'PUT') {
|
||||
final body = jsonDecode(request.body) as Map<String, dynamic>;
|
||||
_headers['e2e'] = utf8.decode(base64.decode(body['content'] as String));
|
||||
return http.Response(jsonEncode({'content': {}, 'sha': 'h'}), 200);
|
||||
}
|
||||
final text = _headers['e2e'];
|
||||
if (text == null) return http.Response('', 404);
|
||||
return http.Response(
|
||||
jsonEncode({'content': base64.encode(utf8.encode(text)), 'sha': 'h'}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
const _settings = SyncSettings(owner: 'kuhyx', repo: 'syncs', token: 'tok');
|
||||
|
||||
void main() {
|
||||
late NoteRepository repo;
|
||||
|
||||
setUp(() async {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
installFakeSecureStorage();
|
||||
_headers.clear();
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
repo = await NoteRepository.openInMemory(nodeId: 'me');
|
||||
});
|
||||
@ -39,6 +63,8 @@ void main() {
|
||||
_settings,
|
||||
httpClient: http_testing.MockClient((request) async {
|
||||
paths.add('${request.method} ${request.url.host}');
|
||||
final header = _headerReply(request);
|
||||
if (header != null) return header;
|
||||
return http.Response(jsonEncode({'content': '', 'sha': 'x'}), 200);
|
||||
}),
|
||||
firebaseFactory: () async => null,
|
||||
@ -83,6 +109,8 @@ void main() {
|
||||
_settings,
|
||||
httpClient: http_testing.MockClient((request) async {
|
||||
githubPaths.add(request.method);
|
||||
final header = _headerReply(request);
|
||||
if (header != null) return header;
|
||||
return http.Response(jsonEncode({'content': '', 'sha': 'x'}), 200);
|
||||
}),
|
||||
firebaseFactory: () async => firebase,
|
||||
@ -108,6 +136,8 @@ void main() {
|
||||
repo,
|
||||
_settings,
|
||||
httpClient: http_testing.MockClient((request) async {
|
||||
final header = _headerReply(request);
|
||||
if (header != null) return header;
|
||||
return http.Response(jsonEncode({'content': '', 'sha': 'x'}), 200);
|
||||
}),
|
||||
firebaseFactory: () async => null,
|
||||
@ -158,6 +188,8 @@ void main() {
|
||||
appSettings: const AppSettings(advancedMode: false),
|
||||
analytics: analytics,
|
||||
httpClient: http_testing.MockClient((request) async {
|
||||
final header = _headerReply(request);
|
||||
if (header != null) return header;
|
||||
return http.Response(jsonEncode({'content': '', 'sha': 'x'}), 200);
|
||||
}),
|
||||
firebaseFactory: () async => firebase,
|
||||
|
||||
@ -24,6 +24,7 @@ import 'fake_note_repository.dart';
|
||||
import 'fake_secure_storage.dart';
|
||||
|
||||
import 'settings_screen_harness.dart';
|
||||
import 'text_free_guard.dart';
|
||||
|
||||
/// Stub file picker that returns a fixed in-memory file (no disk I/O, so the
|
||||
/// `_import` flow stays timer-free and deterministic under the widget tester).
|
||||
@ -96,12 +97,12 @@ void main() {
|
||||
],
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Sync settings'));
|
||||
await tester.tap(find.byIcon(Icons.sync));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// _export does real file I/O on desktop, so drive it under runAsync.
|
||||
await tester.runAsync(() async {
|
||||
await tester.tap(find.text('Export notes'));
|
||||
await tester.tap(find.bySemanticsLabel('Export notes'));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
});
|
||||
await tester.pump();
|
||||
@ -116,16 +117,16 @@ void main() {
|
||||
) async {
|
||||
await pumpSettings(tester, repository: _ExplodingRepo());
|
||||
|
||||
await tester.tap(find.text('Sync settings'));
|
||||
await tester.tap(find.byIcon(Icons.sync));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.runAsync(() async {
|
||||
await tester.tap(find.text('Export notes'));
|
||||
await tester.tap(find.bySemanticsLabel('Export notes'));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
});
|
||||
await tester.pump();
|
||||
|
||||
expect(find.textContaining('Export failed'), findsOneWidget);
|
||||
expect(spoken('Export failed'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Import notes reads the picked file and merges', (
|
||||
@ -149,10 +150,10 @@ void main() {
|
||||
|
||||
final repo = await pumpSettings(tester);
|
||||
|
||||
await tester.tap(find.text('Sync settings'));
|
||||
await tester.tap(find.byIcon(Icons.sync));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Import notes'));
|
||||
await tester.tap(find.bySemanticsLabel('Import notes'));
|
||||
await tester.pump(); // openFile resolves (in-memory)
|
||||
await tester.pump(); // read + parse + merge + setState
|
||||
|
||||
@ -165,10 +166,10 @@ void main() {
|
||||
FileSelectorPlatform.instance = _FakeFileSelector(null); // cancelled
|
||||
final repo = await pumpSettings(tester);
|
||||
|
||||
await tester.tap(find.text('Sync settings'));
|
||||
await tester.tap(find.byIcon(Icons.sync));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Import notes'));
|
||||
await tester.tap(find.bySemanticsLabel('Import notes'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
@ -183,14 +184,14 @@ void main() {
|
||||
FileSelectorPlatform.instance = _ThrowingFileSelector();
|
||||
final repo = await pumpSettings(tester);
|
||||
|
||||
await tester.tap(find.text('Sync settings'));
|
||||
await tester.tap(find.byIcon(Icons.sync));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Import notes'));
|
||||
await tester.tap(find.bySemanticsLabel('Import notes'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.textContaining('Import failed'), findsOneWidget);
|
||||
expect(spoken('Import failed'), findsOneWidget);
|
||||
expect(await repo.listNotes(), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
35
test/settings_screen_e2e_lock_test.dart
Normal file
35
test/settings_screen_e2e_lock_test.dart
Normal file
@ -0,0 +1,35 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:todo/sync/run_sync.dart';
|
||||
|
||||
import 'capture_screen_harness.dart' show recordingMock;
|
||||
import 'settings_screen_harness.dart';
|
||||
|
||||
void main() {
|
||||
tearDown(() => e2eLocked.value = false);
|
||||
|
||||
testWidgets('the lock tile follows the last sync', (tester) async {
|
||||
e2eLocked.value = true;
|
||||
await pumpSettings(tester);
|
||||
expect(find.byIcon(Icons.lock), findsOneWidget);
|
||||
e2eLocked.value = false;
|
||||
await tester.pump();
|
||||
expect(find.byIcon(Icons.lock_open), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('saving a passphrase retries the sync, which unlocks', (
|
||||
tester,
|
||||
) async {
|
||||
e2eLocked.value = true;
|
||||
await pumpSettings(tester, httpClient: recordingMock(<String>[]));
|
||||
await tester.tap(find.byIcon(Icons.lock));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byType(TextField).last, 'test-passphrase');
|
||||
await tester.tap(find.byIcon(Icons.check));
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(const Duration(seconds: 1)),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(e2eLocked.value, isFalse);
|
||||
});
|
||||
}
|
||||
@ -26,6 +26,7 @@ import 'package:todo/sync/notes_markdown.dart';
|
||||
import 'package:todo/sync/sync_settings.dart';
|
||||
import 'package:todo/ui/github_mirror_screen.dart';
|
||||
import 'package:todo/ui/settings_screen.dart';
|
||||
import 'package:todo/ui/text_free_localizations.dart';
|
||||
|
||||
import 'fake_note_repository.dart';
|
||||
import 'fake_secure_storage.dart';
|
||||
@ -93,6 +94,8 @@ Future<FakeNoteRepository> pumpSettings(
|
||||
addTearDown(repo.close);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
// As in main.dart: Material's own button tooltips are blanked.
|
||||
localizationsDelegates: kTextFreeLocalizations,
|
||||
home: SettingsScreen(
|
||||
initial: initial,
|
||||
repository: repo,
|
||||
|
||||
@ -61,30 +61,25 @@ class _ThrowingFileSelector extends FileSelectorPlatform
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'Enable advanced toggles and persists locally with no client',
|
||||
(tester) async {
|
||||
final appSettings = ValueNotifier(
|
||||
const AppSettings(advancedMode: false),
|
||||
);
|
||||
await pumpSettings(
|
||||
tester,
|
||||
appSettings: appSettings,
|
||||
firebaseFactory: () async => null,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Enable advanced'));
|
||||
await tester.pump();
|
||||
|
||||
expect(appSettings.value.advancedMode, isTrue);
|
||||
final reloaded = await AppSettings.load();
|
||||
expect(reloaded.advancedMode, isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('Enable advanced mirrors the toggle to Firebase', (
|
||||
testWidgets('Enable advanced toggles and persists locally with no client', (
|
||||
tester,
|
||||
) async {
|
||||
final appSettings = ValueNotifier(const AppSettings(advancedMode: false));
|
||||
await pumpSettings(
|
||||
tester,
|
||||
appSettings: appSettings,
|
||||
firebaseFactory: () async => null,
|
||||
);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.tune));
|
||||
await tester.pump();
|
||||
|
||||
expect(appSettings.value.advancedMode, isTrue);
|
||||
final reloaded = await AppSettings.load();
|
||||
expect(reloaded.advancedMode, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('Enable advanced mirrors the toggle to Firebase', (tester) async {
|
||||
String? putPath;
|
||||
final firebase = FirebaseRestClient(
|
||||
databaseUrl: 'https://x-rtdb.europe-west1.firebasedatabase.app',
|
||||
@ -110,7 +105,7 @@ void main() {
|
||||
firebaseFactory: () async => firebase,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Enable advanced'));
|
||||
await tester.tap(find.byIcon(Icons.tune));
|
||||
await tester.pump();
|
||||
|
||||
expect(appSettings.value.advancedMode, isTrue);
|
||||
@ -120,16 +115,14 @@ void main() {
|
||||
testWidgets(
|
||||
'Enable advanced still persists locally when opening Firebase throws',
|
||||
(tester) async {
|
||||
final appSettings = ValueNotifier(
|
||||
const AppSettings(advancedMode: false),
|
||||
);
|
||||
final appSettings = ValueNotifier(const AppSettings(advancedMode: false));
|
||||
await pumpSettings(
|
||||
tester,
|
||||
appSettings: appSettings,
|
||||
firebaseFactory: () async => throw FirebaseAuthError('wrong account'),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Enable advanced'));
|
||||
await tester.tap(find.byIcon(Icons.tune));
|
||||
await tester.pump();
|
||||
|
||||
// A signed-in-as-wrong-account device must not lose the local toggle.
|
||||
@ -137,9 +130,7 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('Enable advanced logs a settings_toggle event', (
|
||||
tester,
|
||||
) async {
|
||||
testWidgets('Enable advanced logs a settings_toggle event', (tester) async {
|
||||
const analytics = AnalyticsService(nodeId: 'device-a');
|
||||
final appSettings = ValueNotifier(const AppSettings(advancedMode: false));
|
||||
await pumpSettings(
|
||||
@ -149,7 +140,7 @@ void main() {
|
||||
firebaseFactory: () async => null,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Enable advanced'));
|
||||
await tester.tap(find.byIcon(Icons.tune));
|
||||
await tester.pump();
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@ -162,9 +153,9 @@ void main() {
|
||||
await pumpSettings(tester);
|
||||
// The slim screen only links out; neither sync surface's own widgets
|
||||
// should be inlined here anymore.
|
||||
expect(find.text('Sync settings'), findsOneWidget);
|
||||
expect(find.text('Advanced sync (GitHub)'), findsOneWidget);
|
||||
expect(find.text('Connect GitHub'), findsNothing);
|
||||
expect(find.byIcon(Icons.sync), findsOneWidget);
|
||||
expect(find.byIcon(Icons.cloud_sync), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('Connect GitHub'), findsNothing);
|
||||
expect(find.text('Sign in with Google'), findsNothing);
|
||||
});
|
||||
|
||||
@ -173,7 +164,7 @@ void main() {
|
||||
) async {
|
||||
await pumpSettings(tester);
|
||||
|
||||
await tester.tap(find.text('Sync settings'));
|
||||
await tester.tap(find.byIcon(Icons.sync));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(SyncSettingsScreen), findsOneWidget);
|
||||
@ -186,7 +177,7 @@ void main() {
|
||||
const initial = SyncSettings(owner: 'o', repo: 'r', token: 'tok');
|
||||
final repo = await pumpSettings(tester, initial: initial);
|
||||
|
||||
await tester.tap(find.text('Advanced sync (GitHub)'));
|
||||
await tester.tap(find.byIcon(Icons.cloud_sync));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(GitHubMirrorScreen), findsOneWidget);
|
||||
|
||||
@ -64,10 +64,7 @@ void main() {
|
||||
|
||||
final first = await SyncService(stateStore: store).sync(repo, remote);
|
||||
expect(first.pushed, isTrue);
|
||||
expect(remote.writes, [
|
||||
'todo-sync/notes/me.json',
|
||||
'todo-sync/revs/me',
|
||||
]);
|
||||
expect(remote.writes, ['todo-sync/notes/me.json', 'todo-sync/revs/me']);
|
||||
remote.writes.clear();
|
||||
|
||||
final second = await SyncService(stateStore: store).sync(repo, remote);
|
||||
|
||||
@ -27,9 +27,9 @@ void main() {
|
||||
test('persists revisions across a restart', () async {
|
||||
// A fresh store instance stands in for the next app launch: an
|
||||
// in-memory cache would forget every peer and re-download everything.
|
||||
await openSyncStateStoreIn(dir.path).save(
|
||||
const SyncState(pushedRev: 'mine', peerRevs: {'phone': 'theirs'}),
|
||||
);
|
||||
await openSyncStateStoreIn(
|
||||
dir.path,
|
||||
).save(const SyncState(pushedRev: 'mine', peerRevs: {'phone': 'theirs'}));
|
||||
|
||||
final reloaded = await openSyncStateStoreIn(dir.path).load();
|
||||
|
||||
@ -38,9 +38,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('writes beside the log it describes', () async {
|
||||
await openSyncStateStoreIn(
|
||||
dir.path,
|
||||
).save(const SyncState(pushedRev: 'x'));
|
||||
await openSyncStateStoreIn(dir.path).save(const SyncState(pushedRev: 'x'));
|
||||
|
||||
expect(File(p.join(dir.path, kSyncStateFileName)).existsSync(), isTrue);
|
||||
});
|
||||
|
||||
49
test/text_free_guard.dart
Normal file
49
test/text_free_guard.dart
Normal file
@ -0,0 +1,49 @@
|
||||
/// The "no words" guard: kuhy's textgate rule says nothing he sees carries
|
||||
/// app-written text. This walks every painted string in the widget tree and
|
||||
/// fails on any run of two letters that is not note content.
|
||||
///
|
||||
/// Deliberately NOT named `*_test.dart`: it is a helper with no `main()`.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
final RegExp _word = RegExp('[A-Za-z]{2,}');
|
||||
|
||||
/// Finds a node whose spoken (semantics) label contains [part] literally.
|
||||
/// The old visible sentences live on as spoken labels, so tests that used
|
||||
/// `find.textContaining` now look here.
|
||||
Finder spoken(String part) =>
|
||||
find.bySemanticsLabel(RegExp(RegExp.escape(part)));
|
||||
|
||||
/// Every string the tree paints: each [RichText] (which is what [Text] builds)
|
||||
/// and every non-empty [Tooltip] message, since a tooltip is shown on
|
||||
/// long-press. Text being typed lives in an [EditableText] and is the user's,
|
||||
/// so it is never painted through a [RichText] and is not collected.
|
||||
List<String> paintedStrings(WidgetTester tester) => [
|
||||
for (final w in tester.widgetList<RichText>(find.byType(RichText)))
|
||||
w.text.toPlainText(),
|
||||
for (final t in tester.widgetList<Tooltip>(find.byType(Tooltip)))
|
||||
if ((t.message ?? '').isNotEmpty) t.message!,
|
||||
];
|
||||
|
||||
/// Fails if any painted string still holds a word once every [content]
|
||||
/// string (note text, typed values: things kuhy wrote) is removed from it.
|
||||
///
|
||||
/// An empty tree is a failure too: a guard that saw nothing proves nothing.
|
||||
void expectNoChromeWords(
|
||||
WidgetTester tester, {
|
||||
List<String> content = const [],
|
||||
}) {
|
||||
final painted = paintedStrings(tester);
|
||||
expect(painted, isNotEmpty, reason: 'guard saw no painted strings');
|
||||
final offenders = <String>[];
|
||||
for (final s in painted) {
|
||||
var rest = s;
|
||||
for (final c in content) {
|
||||
rest = rest.replaceAll(c, '');
|
||||
}
|
||||
if (_word.hasMatch(rest)) offenders.add(s);
|
||||
}
|
||||
expect(offenders, isEmpty, reason: 'app-written words are painted');
|
||||
}
|
||||
108
test/text_free_list_test.dart
Normal file
108
test/text_free_list_test.dart
Normal file
@ -0,0 +1,108 @@
|
||||
/// Textgate guard for the notes list and the sheets it opens: nothing
|
||||
/// app-written is painted, only note content, numbers and glyphs.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
|
||||
import 'notes_list_harness.dart';
|
||||
import 'text_free_guard.dart';
|
||||
|
||||
const _content = [
|
||||
'Buy milk',
|
||||
'Fix sync',
|
||||
'kuhyx/todo-app',
|
||||
'flutter test',
|
||||
'claude',
|
||||
'green run',
|
||||
'todo-app',
|
||||
];
|
||||
|
||||
final _task = Note(
|
||||
id: 't',
|
||||
text: 'Fix sync',
|
||||
priority: Priority.high,
|
||||
status: Status.inProgress,
|
||||
createdAt: DateTime(2026, 6, 15, 9),
|
||||
updatedAt: DateTime(2026, 6, 15, 9),
|
||||
repo: 'kuhyx/todo-app',
|
||||
doneCheck: 'flutter test',
|
||||
by: 'claude',
|
||||
evidence: 'green run',
|
||||
);
|
||||
|
||||
void _size(WidgetTester t, Size s, double dpr) {
|
||||
t.view
|
||||
..physicalSize = s
|
||||
..devicePixelRatio = dpr;
|
||||
addTearDown(t.view.reset);
|
||||
}
|
||||
|
||||
void _desktop(WidgetTester t) => _size(t, const Size(1600, 1000), 1);
|
||||
|
||||
void _phone(WidgetTester t) => _size(t, const Size(1080, 2400), 2.625);
|
||||
|
||||
Future<void> _settle(WidgetTester t) async {
|
||||
await t.pump();
|
||||
await t.pump(const Duration(milliseconds: 300));
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('the empty list paints no words', (t) async {
|
||||
_desktop(t);
|
||||
await pumpList(t);
|
||||
expectNoChromeWords(t);
|
||||
});
|
||||
|
||||
testWidgets('the filtered-empty list paints no words', (t) async {
|
||||
_desktop(t);
|
||||
await pumpList(
|
||||
t,
|
||||
seed: [note('a', 'Buy milk', status: Status.done)],
|
||||
applyFilter: true,
|
||||
);
|
||||
expectNoChromeWords(t);
|
||||
});
|
||||
|
||||
testWidgets('the desktop list paints only note content', (t) async {
|
||||
_desktop(t);
|
||||
await pumpList(t, seed: [note('a', 'Buy milk'), note('e', ''), _task]);
|
||||
expectNoChromeWords(t, content: _content);
|
||||
});
|
||||
|
||||
testWidgets('the phone grid paints only note content', (t) async {
|
||||
_phone(t);
|
||||
await pumpList(t, seed: [note('a', 'Buy milk'), note('e', ''), _task]);
|
||||
expectNoChromeWords(t, content: _content);
|
||||
});
|
||||
|
||||
testWidgets('the filter sheet paints no words', (t) async {
|
||||
_phone(t);
|
||||
await pumpList(t, seed: [note('a', 'Buy milk')], applyFilter: true);
|
||||
await t.tap(find.byIcon(Icons.filter_list));
|
||||
await _settle(t);
|
||||
expectNoChromeWords(t, content: _content);
|
||||
});
|
||||
|
||||
testWidgets('an opened note paints only its content', (t) async {
|
||||
_desktop(t);
|
||||
await pumpList(t, seed: [_task]);
|
||||
await t.tap(find.text('Fix sync'));
|
||||
await _settle(t);
|
||||
expectNoChromeWords(t, content: _content);
|
||||
});
|
||||
|
||||
for (final (name, title) in [
|
||||
('plain note', 'Buy milk'),
|
||||
('task', 'Fix sync'),
|
||||
]) {
|
||||
testWidgets('the $name sheet paints only note content', (t) async {
|
||||
_phone(t);
|
||||
await pumpList(t, seed: [note('a', 'Buy milk'), _task]);
|
||||
await t.longPress(find.text(title));
|
||||
await _settle(t);
|
||||
expectNoChromeWords(t, content: _content);
|
||||
});
|
||||
}
|
||||
}
|
||||
30
test/text_free_localizations_test.dart
Normal file
30
test/text_free_localizations_test.dart
Normal file
@ -0,0 +1,30 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:todo/ui/text_free_localizations.dart';
|
||||
|
||||
void main() {
|
||||
test('every button tooltip is blank', () {
|
||||
const l = TextFreeMaterialLocalizations();
|
||||
expect([
|
||||
l.backButtonTooltip,
|
||||
l.closeButtonTooltip,
|
||||
l.openAppDrawerTooltip,
|
||||
l.showMenuTooltip,
|
||||
l.moreButtonTooltip,
|
||||
l.deleteButtonTooltip,
|
||||
], everyElement(isEmpty));
|
||||
// Everything else is still the default English.
|
||||
expect(l.okButtonLabel, const DefaultMaterialLocalizations().okButtonLabel);
|
||||
});
|
||||
|
||||
test('the delegate loads them for English only, once', () async {
|
||||
final delegate = kTextFreeLocalizations.single;
|
||||
expect(delegate.isSupported(const Locale('en')), isTrue);
|
||||
expect(delegate.isSupported(const Locale('pl')), isFalse);
|
||||
expect(
|
||||
await delegate.load(const Locale('en')),
|
||||
isA<TextFreeMaterialLocalizations>(),
|
||||
);
|
||||
expect(delegate.shouldReload(delegate), isFalse);
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user