mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 19:07:02 +01:00
Switch to very_good_analysis, matching the rest of the Flutter ecosystem
Standardizes on the same stricter lint set + shared analysis_options.yaml used by diet-guard/app and the new habit_stack app. Fixes all 127 issues surfaced: dart fix --apply for imports/cascades/sorting, unawaited() on fire-and-forget Futures, bare catch(e) narrowed to on Exception catch(e) (matching diet_guard's existing pattern), one documented ignore for a deliberate TypeError catch around untrusted peer JSON, and missing public-member doc comments throughout. No behavior change: 174/174 tests pass, 100% line coverage maintained. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
0be07060de
commit
f463e50d67
@ -1,28 +1,32 @@
|
||||
# This file configures the analyzer, which statically analyzes Dart code to
|
||||
# check for errors, warnings, and lints.
|
||||
#
|
||||
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
||||
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
||||
# invoked from the command line by running `flutter analyze`.
|
||||
include: package:very_good_analysis/analysis_options.yaml
|
||||
|
||||
# The following line activates a set of recommended lints for Flutter apps,
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
analyzer:
|
||||
language:
|
||||
strict-casts: true
|
||||
strict-inference: true
|
||||
strict-raw-types: true
|
||||
errors:
|
||||
# Promote key lints to errors so they fail CI, not just warn.
|
||||
missing_required_param: error
|
||||
unnecessary_null_comparison: error
|
||||
dead_code: error
|
||||
invalid_annotation_target: error
|
||||
exclude:
|
||||
- build/**
|
||||
- "**/*.g.dart"
|
||||
- "**/*.freezed.dart"
|
||||
- "test/**"
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
# included above or to enable additional rules. A list of all available lints
|
||||
# and their documentation is published at https://dart.dev/lints.
|
||||
#
|
||||
# Instead of disabling a lint rule for the entire project in the
|
||||
# section below, it can also be suppressed for a single line of code
|
||||
# or a specific dart file by using the `// ignore: name_of_lint` and
|
||||
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||
# producing the lint.
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
# very_good_analysis enables most rules; add extras it doesn't include.
|
||||
always_use_package_imports: true
|
||||
avoid_print: true
|
||||
avoid_relative_lib_imports: true
|
||||
cancel_subscriptions: true
|
||||
close_sinks: true
|
||||
comment_references: false # conflicts with typical Flutter API docs style
|
||||
directives_ordering: true
|
||||
lines_longer_than_80_chars: true
|
||||
public_member_api_docs: true
|
||||
unawaited_futures: true
|
||||
|
||||
@ -11,8 +11,13 @@ library;
|
||||
/// Every note always has a priority (there is no "none"); [medium] is the
|
||||
/// default. Stored as the integer [value] so ordering is trivial in SQL.
|
||||
enum Priority {
|
||||
/// Lowest priority tier.
|
||||
low(1, 'Low'),
|
||||
|
||||
/// Default priority tier for a new note.
|
||||
medium(2, 'Medium'),
|
||||
|
||||
/// Highest priority tier.
|
||||
high(3, 'High');
|
||||
|
||||
const Priority(this.value, this.label);
|
||||
@ -42,9 +47,16 @@ enum Priority {
|
||||
/// Stored as the integer [value]. [todo] is the default (0) so existing
|
||||
/// notes created before this field existed read back as "to do".
|
||||
enum Status {
|
||||
/// Default status for a new or legacy note.
|
||||
todo(0, 'To do'),
|
||||
|
||||
/// Actively being worked on.
|
||||
inProgress(1, 'In progress'),
|
||||
|
||||
/// Finished.
|
||||
done(2, 'Done'),
|
||||
|
||||
/// Deliberately dropped, not finished.
|
||||
abandoned(3, 'Abandoned');
|
||||
|
||||
const Status(this.value, this.label);
|
||||
@ -67,6 +79,7 @@ enum Status {
|
||||
|
||||
/// An immutable idea/note record.
|
||||
class Note {
|
||||
/// Creates a [Note] from its stored fields.
|
||||
const Note({
|
||||
required this.id,
|
||||
required this.text,
|
||||
@ -76,6 +89,21 @@ class Note {
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
/// Builds a [Note] from a raw CRDT query row.
|
||||
///
|
||||
/// Timestamps are stored as ISO-8601 strings for human-readable,
|
||||
/// lexicographically sortable values.
|
||||
factory Note.fromRow(Map<String, Object?> row) {
|
||||
return Note(
|
||||
id: row['id']! as String,
|
||||
text: (row['text'] as String?) ?? '',
|
||||
priority: Priority.fromValue(row['priority'] as int?),
|
||||
status: Status.fromValue(row['status'] as int?),
|
||||
createdAt: DateTime.parse(row['created_at']! as String),
|
||||
updatedAt: DateTime.parse(row['updated_at']! as String),
|
||||
);
|
||||
}
|
||||
|
||||
/// Stable unique id (UUID v4). Also the CRDT primary key.
|
||||
final String id;
|
||||
|
||||
@ -94,21 +122,6 @@ class Note {
|
||||
/// When the note's content was last modified locally.
|
||||
final DateTime updatedAt;
|
||||
|
||||
/// Builds a [Note] from a raw CRDT query row.
|
||||
///
|
||||
/// Timestamps are stored as ISO-8601 strings for human-readable,
|
||||
/// lexicographically sortable values.
|
||||
factory Note.fromRow(Map<String, Object?> row) {
|
||||
return Note(
|
||||
id: row['id'] as String,
|
||||
text: (row['text'] as String?) ?? '',
|
||||
priority: Priority.fromValue(row['priority'] as int?),
|
||||
status: Status.fromValue(row['status'] as int?),
|
||||
createdAt: DateTime.parse(row['created_at'] as String),
|
||||
updatedAt: DateTime.parse(row['updated_at'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns a copy with selected fields replaced.
|
||||
Note copyWith({
|
||||
String? text,
|
||||
|
||||
@ -8,15 +8,27 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
// Only SqliteCrdt/CrdtTableExecutor are needed for the legacy-DB migration;
|
||||
// hide its Hlc so the crdt_sync Hlc (the store's clock type) is unambiguous.
|
||||
import 'package:sqlite_crdt/sqlite_crdt.dart' hide Hlc;
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import 'note.dart';
|
||||
|
||||
/// How the history list should be ordered.
|
||||
enum NoteSort { createdDesc, modifiedDesc, alphabetical, priorityDesc }
|
||||
enum NoteSort {
|
||||
/// Newest created first.
|
||||
createdDesc,
|
||||
|
||||
/// Most recently modified first.
|
||||
modifiedDesc,
|
||||
|
||||
/// A-Z by note text.
|
||||
alphabetical,
|
||||
|
||||
/// Highest [Priority] first.
|
||||
priorityDesc,
|
||||
}
|
||||
|
||||
/// Summary of an [NoteRepository.importNotes] run, for user feedback.
|
||||
class ImportOutcome {
|
||||
/// Creates an [ImportOutcome] from its per-category counts.
|
||||
const ImportOutcome({
|
||||
required this.added,
|
||||
required this.updated,
|
||||
@ -391,6 +403,7 @@ class _MemoryPersistence implements LogPersistence {
|
||||
/// constraint. Lives in the data layer so the filtering it drives never leaks
|
||||
/// into the UI. Construct copies with [copyWith] when toggling one facet.
|
||||
class NoteFilter {
|
||||
/// Creates a [NoteFilter] from its constraint fields.
|
||||
const NoteFilter({
|
||||
this.query = '',
|
||||
this.priorities = const {},
|
||||
@ -410,12 +423,16 @@ class NoteFilter {
|
||||
/// Notes must have one of these statuses. Empty means "any status".
|
||||
final Set<Status> statuses;
|
||||
|
||||
/// Inclusive lower/upper bounds (by calendar day) on the creation date.
|
||||
/// Inclusive lower bound (by calendar day) on the creation date.
|
||||
final DateTime? createdFrom;
|
||||
|
||||
/// Inclusive upper bound (by calendar day) on the creation date.
|
||||
final DateTime? createdTo;
|
||||
|
||||
/// Inclusive lower/upper bounds (by calendar day) on the last-updated date.
|
||||
/// Inclusive lower bound (by calendar day) on the last-updated date.
|
||||
final DateTime? updatedFrom;
|
||||
|
||||
/// Inclusive upper bound (by calendar day) on the last-updated date.
|
||||
final DateTime? updatedTo;
|
||||
|
||||
/// True when no constraint is active (the unfiltered, full list).
|
||||
|
||||
@ -25,6 +25,7 @@ library;
|
||||
|
||||
/// One field of a structured template.
|
||||
class TemplateSection {
|
||||
/// Creates a [TemplateSection] from its field/label/helper text.
|
||||
const TemplateSection({
|
||||
required this.key,
|
||||
required this.label,
|
||||
@ -62,6 +63,7 @@ class TemplateSection {
|
||||
/// A named template: an ordered list of [sections]. A template with no
|
||||
/// sections is *freeform* (a single plain-text body, no structure).
|
||||
class NoteTemplate {
|
||||
/// Creates a [NoteTemplate] from its id, label, and [sections].
|
||||
const NoteTemplate({
|
||||
required this.id,
|
||||
required this.label,
|
||||
@ -105,7 +107,7 @@ class NoteTemplate {
|
||||
label: 'where',
|
||||
hint: 'repo + files/paths, or new app: <name>',
|
||||
helper:
|
||||
"Repo + target files/paths (not terminal dumps), or "
|
||||
'Repo + target files/paths (not terminal dumps), or '
|
||||
"'new app: <name>', and platform(s).",
|
||||
),
|
||||
TemplateSection(
|
||||
@ -202,6 +204,7 @@ class NoteTemplate {
|
||||
|
||||
/// Result of parsing stored text against a template.
|
||||
class ParsedNote {
|
||||
/// Creates a [ParsedNote] from its parsed [values] and [conforms] flag.
|
||||
const ParsedNote({required this.values, required this.conforms});
|
||||
|
||||
/// Section-key -> value (trimmed). Sections absent from the text map to ''.
|
||||
|
||||
@ -9,8 +9,8 @@ import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
|
||||
import 'data/note_repository.dart';
|
||||
import 'ui/capture_screen.dart';
|
||||
import 'package:todo/data/note_repository.dart';
|
||||
import 'package:todo/ui/capture_screen.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@ -31,8 +31,10 @@ Future<void> main() async {
|
||||
/// Root widget. Holds the single [NoteRepository] instance and hands it
|
||||
/// to the screens that need it.
|
||||
class TodoApp extends StatelessWidget {
|
||||
/// Creates a [TodoApp] wrapping the given [repository].
|
||||
const TodoApp({required this.repository, super.key});
|
||||
|
||||
/// The single, app-wide note store, injected into every screen.
|
||||
final NoteRepository repository;
|
||||
|
||||
@override
|
||||
|
||||
@ -6,6 +6,7 @@ import 'package:http/http.dart' as http;
|
||||
/// First-stage response of the GitHub OAuth Device Flow: the code the user
|
||||
/// types on github.com and the URL to type it into.
|
||||
class DeviceCodeResponse {
|
||||
/// Creates a [DeviceCodeResponse] from its stored fields.
|
||||
const DeviceCodeResponse({
|
||||
required this.deviceCode,
|
||||
required this.userCode,
|
||||
@ -14,6 +15,17 @@ class DeviceCodeResponse {
|
||||
required this.expiresIn,
|
||||
});
|
||||
|
||||
/// Builds a [DeviceCodeResponse] from GitHub's device-code JSON response.
|
||||
factory DeviceCodeResponse.fromJson(Map<String, dynamic> json) {
|
||||
return DeviceCodeResponse(
|
||||
deviceCode: json['device_code'] as String,
|
||||
userCode: json['user_code'] as String,
|
||||
verificationUri: json['verification_uri'] as String,
|
||||
interval: (json['interval'] as int?) ?? 5,
|
||||
expiresIn: (json['expires_in'] as int?) ?? 900,
|
||||
);
|
||||
}
|
||||
|
||||
/// Opaque code the client polls with (not shown to the user).
|
||||
final String deviceCode;
|
||||
|
||||
@ -28,24 +40,17 @@ class DeviceCodeResponse {
|
||||
|
||||
/// Seconds until [deviceCode] expires.
|
||||
final int expiresIn;
|
||||
|
||||
factory DeviceCodeResponse.fromJson(Map<String, dynamic> json) {
|
||||
return DeviceCodeResponse(
|
||||
deviceCode: json['device_code'] as String,
|
||||
userCode: json['user_code'] as String,
|
||||
verificationUri: json['verification_uri'] as String,
|
||||
interval: (json['interval'] as int?) ?? 5,
|
||||
expiresIn: (json['expires_in'] as int?) ?? 900,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Raised when the device-flow authorization fails or is declined.
|
||||
class DeviceAuthException implements Exception {
|
||||
/// Creates a [DeviceAuthException] from GitHub's [code] and [message].
|
||||
DeviceAuthException(this.code, this.message);
|
||||
|
||||
/// GitHub error code, e.g. `access_denied`, `expired_token`.
|
||||
final String code;
|
||||
|
||||
/// Human-readable description of [code].
|
||||
final String message;
|
||||
|
||||
@override
|
||||
@ -63,6 +68,7 @@ class DeviceAuthException implements Exception {
|
||||
/// - POST https://github.com/login/device/code
|
||||
/// - POST https://github.com/login/oauth/access_token
|
||||
class GitHubDeviceAuth {
|
||||
/// Creates a [GitHubDeviceAuth] for the given OAuth App [clientId].
|
||||
GitHubDeviceAuth({
|
||||
required this.clientId,
|
||||
this.scope = 'repo',
|
||||
@ -72,6 +78,7 @@ class GitHubDeviceAuth {
|
||||
// Indirection so tests can skip real waiting between polls.
|
||||
_delay = delay ?? Future<void>.delayed;
|
||||
|
||||
/// The GitHub OAuth App's public client id.
|
||||
final String clientId;
|
||||
|
||||
/// OAuth scope requested. `repo` is required for private-repo contents.
|
||||
@ -142,5 +149,6 @@ class GitHubDeviceAuth {
|
||||
throw DeviceAuthException('expired_token', 'Device code expired.');
|
||||
}
|
||||
|
||||
/// Closes the underlying HTTP client. Call once when done polling.
|
||||
void close() => _http.close();
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import '../data/note.dart';
|
||||
import 'notes_markdown.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/sync/notes_markdown.dart';
|
||||
|
||||
/// Keeps an always-current Markdown backup of all notes on local disk, and
|
||||
/// recovers from it on launch.
|
||||
@ -17,6 +17,7 @@ import 'notes_markdown.dart';
|
||||
/// fires — never per keystroke — so serialising every note stays off the
|
||||
/// typing hot path and the cost is independent of how fast the user types.
|
||||
class LocalBackup {
|
||||
/// Creates a [LocalBackup] wiring the note [fetch]/[reader]/[writer].
|
||||
LocalBackup({
|
||||
required this.fetch,
|
||||
required this.reader,
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../data/note.dart';
|
||||
|
||||
/// Serialises notes to (and parses them back from) a single Markdown file.
|
||||
///
|
||||
/// The whole document is valid Markdown: each note is preceded by an HTML
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
|
||||
import '../data/note_repository.dart';
|
||||
import 'package:todo/data/note_repository.dart';
|
||||
|
||||
/// Outcome of a sync run, for surfacing in the UI.
|
||||
class SyncResult {
|
||||
/// Creates a [SyncResult] from a completed sync run's outcome.
|
||||
const SyncResult({
|
||||
required this.mergedDevices,
|
||||
required this.pushed,
|
||||
@ -40,6 +41,7 @@ class SyncResult {
|
||||
/// format changed from a `sqlite_crdt` changeset to a crdt_sync `Log`, so the
|
||||
/// 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'});
|
||||
|
||||
/// Directory in the repo under which per-device note-log files live.
|
||||
@ -91,7 +93,13 @@ class SyncService {
|
||||
return logFromJson(text);
|
||||
} on FormatException {
|
||||
return null;
|
||||
} on TypeError {
|
||||
}
|
||||
// crdt_sync's logFromJson throws a raw TypeError (not a custom
|
||||
// exception) for a wrong-shape JSON payload from an untrusted peer
|
||||
// file; must be caught too so one bad device file never aborts the
|
||||
// whole sync (see doc comment above).
|
||||
// ignore: avoid_catching_errors
|
||||
on TypeError {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
/// until a secure write is confirmed (so we degrade to — never below — the old
|
||||
/// behaviour when no secret service is available).
|
||||
class SyncSettings {
|
||||
/// Creates a [SyncSettings] from its stored fields.
|
||||
const SyncSettings({
|
||||
required this.owner,
|
||||
required this.repo,
|
||||
@ -17,8 +18,13 @@ class SyncSettings {
|
||||
this.clientId = '',
|
||||
});
|
||||
|
||||
/// GitHub account or org that owns [repo].
|
||||
final String owner;
|
||||
|
||||
/// GitHub repository name notes sync to.
|
||||
final String repo;
|
||||
|
||||
/// GitHub token used to read/write [repo] (kept in the OS keystore).
|
||||
final String token;
|
||||
|
||||
/// GitHub OAuth App client id used by the device-flow "Connect" button.
|
||||
@ -82,7 +88,7 @@ class SyncSettings {
|
||||
String? secure;
|
||||
try {
|
||||
secure = await _secure.read(key: _secureToken);
|
||||
} catch (_) {
|
||||
} on Exception {
|
||||
// No secret service available — fall back to the legacy plaintext copy.
|
||||
secure = null;
|
||||
}
|
||||
@ -95,6 +101,7 @@ class SyncSettings {
|
||||
return legacy;
|
||||
}
|
||||
|
||||
/// Persists these settings, writing [token] to the OS keystore.
|
||||
Future<void> save() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_kOwner, owner);
|
||||
@ -119,11 +126,12 @@ class SyncSettings {
|
||||
await _secure.write(key: _secureToken, value: token);
|
||||
}
|
||||
return true;
|
||||
} catch (_) {
|
||||
} on Exception {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a copy with the given fields replaced, others unchanged.
|
||||
SyncSettings copyWith({
|
||||
String? owner,
|
||||
String? repo,
|
||||
|
||||
@ -1,23 +1,22 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_repository.dart';
|
||||
import 'package:todo/data/note_template.dart';
|
||||
import 'package:todo/sync/local_backup.dart';
|
||||
import 'package:todo/sync/sync_service.dart';
|
||||
import 'package:todo/sync/sync_settings.dart';
|
||||
import 'package:todo/ui/note_editor.dart';
|
||||
import 'package:todo/ui/notes_list_screen.dart';
|
||||
import 'package:todo/ui/settings_screen.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../data/note.dart';
|
||||
import '../data/note_repository.dart';
|
||||
import '../data/note_template.dart';
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import '../sync/local_backup.dart';
|
||||
import '../sync/sync_service.dart';
|
||||
import '../sync/sync_settings.dart';
|
||||
import 'note_editor.dart';
|
||||
import 'notes_list_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
|
||||
/// The landing screen: an always-focused text box for jotting an idea.
|
||||
///
|
||||
/// Per the product goal "no interruptions, immediate", text is persisted
|
||||
@ -26,6 +25,7 @@ import 'settings_screen.dart';
|
||||
/// action finalises the current idea and clears the field for the next
|
||||
/// one (remote sync will hook in here later).
|
||||
class CaptureScreen extends StatefulWidget {
|
||||
/// Creates a [CaptureScreen] backed by [repository].
|
||||
const CaptureScreen({
|
||||
required this.repository,
|
||||
this.httpClient,
|
||||
@ -33,6 +33,7 @@ class CaptureScreen extends StatefulWidget {
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The store the captured note is persisted to on every keystroke.
|
||||
final NoteRepository repository;
|
||||
|
||||
/// Injectable HTTP client for the sync path. Production leaves this null
|
||||
@ -116,7 +117,7 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
// Recover from the local backup first (covers an empty DB after a wipe),
|
||||
// then keep the backup current as notes change. The change tick is O(1) per
|
||||
// write; the backup pulls the notes itself only when its debounce fires.
|
||||
_recoverFromBackup();
|
||||
unawaited(_recoverFromBackup());
|
||||
_changesSub = widget.repository.changes.listen(
|
||||
(_) => _localBackup.schedule(),
|
||||
);
|
||||
@ -124,19 +125,22 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
// fires on writes), so a deleted/stale BACKLOG.md is regenerated even if
|
||||
// the user makes no edits this session.
|
||||
_localBackup.schedule();
|
||||
_restoreSyncStatus();
|
||||
SyncSettings.load().then((s) {
|
||||
if (!mounted) return;
|
||||
setState(() => _settings = s);
|
||||
_autoSync(); // pull on launch so a reinstalled device recovers its notes
|
||||
});
|
||||
unawaited(_restoreSyncStatus());
|
||||
unawaited(
|
||||
SyncSettings.load().then((s) {
|
||||
if (!mounted) return;
|
||||
setState(() => _settings = s);
|
||||
// Pull on launch so a reinstalled device recovers its notes.
|
||||
unawaited(_autoSync());
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_autoSyncDebounce?.cancel();
|
||||
_changesSub?.cancel();
|
||||
unawaited(_changesSub?.cancel());
|
||||
_localBackup.dispose();
|
||||
_lastSavedAt.dispose();
|
||||
_lastSync.dispose();
|
||||
@ -220,7 +224,7 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
if (state == AppLifecycleState.paused) {
|
||||
// Immediate: Android may kill the app before a debounce timer fires.
|
||||
_autoSyncDebounce?.cancel();
|
||||
_autoSync();
|
||||
unawaited(_autoSync());
|
||||
} else if (state == AppLifecycleState.inactive ||
|
||||
state == AppLifecycleState.hidden) {
|
||||
// Focus loss — the only background signal Flutter Linux delivers
|
||||
@ -247,7 +251,7 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
try {
|
||||
final result = await _syncService.sync(widget.repository, client);
|
||||
await _recordSyncStatus(ok: true, detail: _describe(result));
|
||||
} catch (e) {
|
||||
} on Exception catch (e) {
|
||||
// Offline or a transient GitHub error: still no snackbar (this path
|
||||
// must never interrupt capture), but the status line shows it.
|
||||
await _recordSyncStatus(ok: false, detail: '$e');
|
||||
@ -279,9 +283,11 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
}
|
||||
|
||||
void _openList() {
|
||||
Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => NotesListScreen(repository: widget.repository),
|
||||
unawaited(
|
||||
Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => NotesListScreen(repository: widget.repository),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -306,7 +312,7 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
final detail = _describe(result);
|
||||
await _recordSyncStatus(ok: true, detail: detail);
|
||||
_showSnack('Synced: $detail');
|
||||
} catch (e) {
|
||||
} on Exception catch (e) {
|
||||
await _recordSyncStatus(ok: false, detail: '$e');
|
||||
_showSnack('Sync failed: $e');
|
||||
} finally {
|
||||
@ -547,6 +553,7 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
|
||||
/// Outcome of a sync attempt, shown in the capture screen's status line.
|
||||
class SyncStatus {
|
||||
/// Creates a [SyncStatus] from a completed sync attempt's outcome.
|
||||
const SyncStatus({
|
||||
required this.time,
|
||||
required this.ok,
|
||||
|
||||
@ -11,8 +11,10 @@ import 'package:flutter/material.dart';
|
||||
/// The whole rendered note is wrapped in a [SelectionArea] so it can be copied
|
||||
/// out in one go (the note is meant to be pasted into an LLM).
|
||||
class MarkdownView extends StatelessWidget {
|
||||
/// Creates a [MarkdownView] rendering [text].
|
||||
const MarkdownView({required this.text, super.key});
|
||||
|
||||
/// The raw markdown source to render.
|
||||
final String text;
|
||||
|
||||
@override
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../data/note.dart';
|
||||
import '../data/note_repository.dart';
|
||||
import '../data/note_template.dart';
|
||||
import 'note_editor.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_repository.dart';
|
||||
import 'package:todo/data/note_template.dart';
|
||||
import 'package:todo/ui/note_editor.dart';
|
||||
|
||||
/// Full-screen view of a single note: read it in full, edit its body through
|
||||
/// the guided [NoteEditor], change its priority/status, or delete it.
|
||||
@ -12,13 +12,17 @@ import 'note_editor.dart';
|
||||
/// there is no explicit save button. The template is detected from the note's
|
||||
/// text, falling back to a raw editor for freeform/legacy notes.
|
||||
class NoteDetailScreen extends StatefulWidget {
|
||||
/// Creates a [NoteDetailScreen] editing [note].
|
||||
const NoteDetailScreen({
|
||||
required this.note,
|
||||
required this.repository,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The note being viewed/edited.
|
||||
final Note note;
|
||||
|
||||
/// The store edits persist to.
|
||||
final NoteRepository repository;
|
||||
|
||||
@override
|
||||
@ -134,7 +138,6 @@ class _MetaDropdown<T> extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return DropdownButtonFormField<T>(
|
||||
initialValue: value,
|
||||
isDense: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
isDense: true,
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../data/note.dart';
|
||||
import '../data/note_template.dart';
|
||||
import 'markdown_view.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_template.dart';
|
||||
import 'package:todo/ui/markdown_view.dart';
|
||||
|
||||
/// Which view of the note the editor is currently showing.
|
||||
enum NoteEditorMode {
|
||||
@ -40,6 +40,7 @@ enum NoteEditorMode {
|
||||
/// to return to Raw); [onChromeVisibleChanged] tells the parent screen to
|
||||
/// hide its own priority/status row in sync.
|
||||
class NoteEditor extends StatefulWidget {
|
||||
/// Creates a [NoteEditor] with the given callbacks and initial state.
|
||||
const NoteEditor({
|
||||
required this.onChanged,
|
||||
required this.priority,
|
||||
@ -184,7 +185,7 @@ class _NoteEditorState extends State<NoteEditor> {
|
||||
/// Ensures a controller exists for every section of [template].
|
||||
void _ensureControllers(NoteTemplate template) {
|
||||
for (final s in template.sections) {
|
||||
_section.putIfAbsent(s.key, () => TextEditingController());
|
||||
_section.putIfAbsent(s.key, TextEditingController.new);
|
||||
}
|
||||
}
|
||||
|
||||
@ -349,7 +350,6 @@ class _NoteEditorState extends State<NoteEditor> {
|
||||
children: [
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _template.id,
|
||||
isDense: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Template',
|
||||
isDense: true,
|
||||
@ -434,7 +434,6 @@ class _NoteEditorState extends State<NoteEditor> {
|
||||
return [
|
||||
DropdownButtonFormField<Priority>(
|
||||
initialValue: _wizardPriority,
|
||||
isDense: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Priority',
|
||||
isDense: true,
|
||||
@ -465,7 +464,6 @@ class _NoteEditorState extends State<NoteEditor> {
|
||||
return [
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _wizardTemplate.id,
|
||||
isDense: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Template',
|
||||
isDense: true,
|
||||
|
||||
@ -2,10 +2,10 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../data/note.dart';
|
||||
import '../data/note_repository.dart';
|
||||
import '../data/note_template.dart';
|
||||
import 'note_detail_screen.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_repository.dart';
|
||||
import 'package:todo/data/note_template.dart';
|
||||
import 'package:todo/ui/note_detail_screen.dart';
|
||||
|
||||
/// The default status selection: hide completed/dropped work. This is the
|
||||
/// app's notion of "unfiltered", so it does not count towards the filter
|
||||
@ -19,8 +19,10 @@ const Set<Status> kDefaultStatuses = {Status.todo, Status.inProgress};
|
||||
/// the watch stream when that state changes. The stream is memoised so a
|
||||
/// rebuild (e.g. a search keystroke) does not churn a new DB subscription.
|
||||
class NotesListScreen extends StatefulWidget {
|
||||
/// Creates a [NotesListScreen] backed by [repository].
|
||||
const NotesListScreen({required this.repository, super.key});
|
||||
|
||||
/// The store this screen searches/filters/sorts.
|
||||
final NoteRepository repository;
|
||||
|
||||
@override
|
||||
@ -351,7 +353,7 @@ class _NoteActionsSheetState extends State<_NoteActionsSheet> {
|
||||
labelOf: (s) => s.label,
|
||||
onSelected: (s) {
|
||||
setState(() => _status = s);
|
||||
_persist();
|
||||
unawaited(_persist());
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@ -362,7 +364,7 @@ class _NoteActionsSheetState extends State<_NoteActionsSheet> {
|
||||
labelOf: (p) => p.label,
|
||||
onSelected: (p) {
|
||||
setState(() => _priority = p);
|
||||
_persist();
|
||||
unawaited(_persist());
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@ -1,20 +1,20 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:file_selector/file_selector.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:todo/data/note_repository.dart';
|
||||
import 'package:todo/sync/github_device_auth.dart';
|
||||
import 'package:todo/sync/notes_markdown.dart';
|
||||
import 'package:todo/sync/sync_service.dart';
|
||||
import 'package:todo/sync/sync_settings.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../data/note_repository.dart';
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import '../sync/github_device_auth.dart';
|
||||
import '../sync/notes_markdown.dart';
|
||||
import '../sync/sync_service.dart';
|
||||
import '../sync/sync_settings.dart';
|
||||
|
||||
/// Settings screen for GitHub sync configuration and note backup.
|
||||
///
|
||||
/// Primary sync path: the "Connect GitHub" button runs the OAuth **device
|
||||
@ -22,6 +22,7 @@ import '../sync/sync_settings.dart';
|
||||
/// remains as a fallback. The Backup section exports/imports all notes as a
|
||||
/// single Markdown file (see [NotesMarkdown]).
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
/// Creates a [SettingsScreen] pre-filled with [initial] settings.
|
||||
const SettingsScreen({
|
||||
required this.initial,
|
||||
required this.repository,
|
||||
@ -29,7 +30,10 @@ class SettingsScreen extends StatefulWidget {
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The sync settings loaded when this screen was opened.
|
||||
final SyncSettings initial;
|
||||
|
||||
/// The store backup export/import reads from and writes to.
|
||||
final NoteRepository repository;
|
||||
|
||||
/// Optional HTTP client for the GitHub calls (test-connection and device
|
||||
@ -100,7 +104,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
await _current.save();
|
||||
await _syncAfterConnect();
|
||||
}
|
||||
} catch (e) {
|
||||
} on Exception catch (e) {
|
||||
if (mounted) setState(() => _status = 'Could not start device flow: $e');
|
||||
} finally {
|
||||
auth.close();
|
||||
@ -126,7 +130,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
'device(s)). Your notes are up to date.',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
} on Exception catch (e) {
|
||||
if (mounted) setState(() => _status = 'Connected, but sync failed: $e');
|
||||
} finally {
|
||||
client.close();
|
||||
@ -152,7 +156,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
? 'Connected — repo is reachable.'
|
||||
: 'Could not access ${s.owner}/${s.repo}. Check token scope.',
|
||||
);
|
||||
} catch (e) {
|
||||
} on Exception catch (e) {
|
||||
setState(() => _status = 'Error: $e');
|
||||
} finally {
|
||||
client.close();
|
||||
@ -201,7 +205,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
} on Exception catch (e) {
|
||||
if (mounted) setState(() => _status = 'Export failed: $e');
|
||||
}
|
||||
}
|
||||
@ -231,7 +235,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
'${outcome.updated} updated, ${outcome.skipped} unchanged',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
} on Exception catch (e) {
|
||||
if (mounted) setState(() => _status = 'Import failed: $e');
|
||||
}
|
||||
}
|
||||
@ -382,14 +386,14 @@ class _DeviceCodeDialogState extends State<_DeviceCodeDialog> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_poll();
|
||||
unawaited(_poll());
|
||||
}
|
||||
|
||||
Future<void> _poll() async {
|
||||
try {
|
||||
final token = await widget.auth.pollForToken(widget.device);
|
||||
if (mounted) Navigator.of(context).pop(token);
|
||||
} catch (e) {
|
||||
} on Exception catch (e) {
|
||||
if (mounted) setState(() => _error = '$e');
|
||||
}
|
||||
}
|
||||
|
||||
24
pubspec.lock
24
pubspec.lock
@ -255,14 +255,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.14.4"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -409,14 +401,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -878,6 +862,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
very_good_analysis:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: very_good_analysis
|
||||
sha256: "481af67ab5877af20325251dc215a4ebac7666a1c8cf09198ffd457bc612b33d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.3.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
63
pubspec.yaml
63
pubspec.yaml
@ -28,12 +28,6 @@ environment:
|
||||
# the latest version available on pub.dev. To see which dependencies have newer
|
||||
# versions available, run `flutter pub outdated`.
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
# Shared CRDT sync (LogStore + Record/Log merge + GitHub transport). The
|
||||
# primary store since the migration off sqlite_crdt.
|
||||
crdt_sync:
|
||||
@ -41,43 +35,48 @@ dependencies:
|
||||
url: https://github.com/kuhyx/utils
|
||||
ref: crdt_sync_dart-v0.3.0
|
||||
path: crdt_sync_dart
|
||||
# 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
|
||||
path_provider: ^2.1.5
|
||||
path: ^1.9.1
|
||||
uuid: ^4.5.3
|
||||
sqflite_common_ffi: ^2.4.2
|
||||
http: ^1.6.0
|
||||
shared_preferences: ^2.5.5
|
||||
url_launcher: ^6.3.2
|
||||
share_plus: ^13.1.0
|
||||
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
file_selector: ^1.1.0
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# Stores the GitHub sync token in the OS keystore (Android Keystore /
|
||||
# libsecret) instead of plaintext SharedPreferences.
|
||||
flutter_secure_storage: ^10.3.1
|
||||
http: ^1.6.0
|
||||
path: ^1.9.1
|
||||
path_provider: ^2.1.5
|
||||
share_plus: ^13.1.0
|
||||
shared_preferences: ^2.5.5
|
||||
sqflite_common_ffi: ^2.4.2
|
||||
# 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
|
||||
url_launcher: ^6.3.2
|
||||
uuid: ^4.5.3
|
||||
dev_dependencies:
|
||||
# Virtual clock for unit-testing debounced timers (e.g. LocalBackup).
|
||||
fake_async: ^1.3.3
|
||||
|
||||
# Plugin platform interfaces, depended on directly so tests can swap in
|
||||
# fakes for the file picker and URL launcher (see settings_screen_test.dart).
|
||||
file_selector_platform_interface: ^2.7.0
|
||||
|
||||
# Generates the Android launcher icons from assets/icon/ (see
|
||||
# python_pkg/app_icons in testsAndMisc for the shared style).
|
||||
flutter_launcher_icons: ^0.14.4
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
# Virtual clock for unit-testing debounced timers (e.g. LocalBackup).
|
||||
fake_async: ^1.3.3
|
||||
|
||||
# The "flutter_lints" package below contains a set of recommended lints to
|
||||
# encourage good coding practices. The lint set provided by the package is
|
||||
# activated in the `analysis_options.yaml` file located at the root of your
|
||||
# package. See that file for information about deactivating specific lint
|
||||
# rules and activating additional ones.
|
||||
flutter_lints: ^6.0.0
|
||||
|
||||
# Plugin platform interfaces, depended on directly so tests can swap in
|
||||
# fakes for the file picker and URL launcher (see settings_screen_test.dart).
|
||||
file_selector_platform_interface: ^2.7.0
|
||||
url_launcher_platform_interface: ^2.3.2
|
||||
plugin_platform_interface: ^2.1.8
|
||||
url_launcher_platform_interface: ^2.3.2
|
||||
|
||||
# Stricter than flutter_lints -- standardized across this ecosystem's
|
||||
# Flutter apps (see project memory: all Flutter apps use
|
||||
# very_good_analysis, not flutter_lints).
|
||||
very_good_analysis: ^10.2.0
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
||||
@ -25,10 +25,11 @@ Future<void> main() async {
|
||||
exit(1);
|
||||
}
|
||||
final dc = jsonDecode(code.body) as Map<String, dynamic>;
|
||||
stdout.writeln(
|
||||
'>>> Open ${dc['verification_uri']} and enter: ${dc['user_code']}',
|
||||
);
|
||||
stdout.writeln('Polling for the token...');
|
||||
stdout
|
||||
..writeln(
|
||||
'>>> Open ${dc['verification_uri']} and enter: ${dc['user_code']}',
|
||||
)
|
||||
..writeln('Polling for the token...');
|
||||
|
||||
final deviceCode = dc['device_code'] as String;
|
||||
var interval = (dc['interval'] as int?) ?? 5;
|
||||
@ -59,12 +60,13 @@ Future<void> main() async {
|
||||
'User-Agent': 'todo-app-sync',
|
||||
},
|
||||
);
|
||||
stdout.writeln('repo $_owner/$_repo access status: ${repo.statusCode}');
|
||||
stdout.writeln(
|
||||
repo.statusCode == 200
|
||||
? 'CHAIN OK — device flow + token + repo access all work.'
|
||||
: 'TOKEN CANNOT READ REPO: ${repo.body}',
|
||||
);
|
||||
stdout
|
||||
..writeln('repo $_owner/$_repo access status: ${repo.statusCode}')
|
||||
..writeln(
|
||||
repo.statusCode == 200
|
||||
? 'CHAIN OK — device flow + token + repo access all work.'
|
||||
: 'TOKEN CANNOT READ REPO: ${repo.body}',
|
||||
);
|
||||
exit(repo.statusCode == 200 ? 0 : 2);
|
||||
}
|
||||
switch (body['error'] as String?) {
|
||||
|
||||
@ -8,15 +8,15 @@
|
||||
// Run: GH_TOKEN=$(gh auth token) dart run tool/sync_smoke.dart
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_repository.dart';
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:todo/sync/sync_service.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
final token = Platform.environment['GH_TOKEN'];
|
||||
if (token == null || token.isEmpty) {
|
||||
stderr.writeln('Set GH_TOKEN (e.g. GH_TOKEN=\$(gh auth token)).');
|
||||
stderr.writeln(r'Set GH_TOKEN (e.g. GH_TOKEN=$(gh auth token)).');
|
||||
exit(2);
|
||||
}
|
||||
|
||||
@ -32,20 +32,26 @@ Future<void> main() async {
|
||||
await _insert(deviceA, 'Idea from device A @ $stamp');
|
||||
await _insert(deviceB, 'Idea from device B @ $stamp');
|
||||
|
||||
stdout.writeln('Device A nodeId: ${deviceA.nodeId}');
|
||||
stdout.writeln('Device B nodeId: ${deviceB.nodeId}');
|
||||
stdout
|
||||
..writeln('Device A nodeId: ${deviceA.nodeId}')
|
||||
..writeln('Device B nodeId: ${deviceB.nodeId}');
|
||||
|
||||
// Sync order: A pushes, B pulls A + pushes, A pulls B. Both converge.
|
||||
final ghA = client();
|
||||
final ghB = client();
|
||||
stdout.writeln('A.sync(): ${await service.sync(deviceA, ghA)}');
|
||||
stdout.writeln('B.sync(): ${await service.sync(deviceB, ghB)}');
|
||||
stdout.writeln('A.sync(): ${await service.sync(deviceA, ghA)}');
|
||||
final aFirstSync = await service.sync(deviceA, ghA);
|
||||
final bSync = await service.sync(deviceB, ghB);
|
||||
final aSecondSync = await service.sync(deviceA, ghA);
|
||||
stdout
|
||||
..writeln('A.sync(): $aFirstSync')
|
||||
..writeln('B.sync(): $bSync')
|
||||
..writeln('A.sync(): $aSecondSync');
|
||||
|
||||
final aNotes = (await deviceA.listNotes()).map((n) => n.text).toSet();
|
||||
final bNotes = (await deviceB.listNotes()).map((n) => n.text).toSet();
|
||||
stdout.writeln('\nDevice A sees: $aNotes');
|
||||
stdout.writeln('Device B sees: $bNotes');
|
||||
stdout
|
||||
..writeln('\nDevice A sees: $aNotes')
|
||||
..writeln('Device B sees: $bNotes');
|
||||
|
||||
final expected = {
|
||||
'Idea from device A @ $stamp',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user