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:
Krzysztof Rudnicki 2026-07-20 11:05:13 +02:00
parent 0be07060de
commit f463e50d67
20 changed files with 267 additions and 189 deletions

View File

@ -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

View File

@ -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,

View File

@ -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).

View File

@ -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 ''.

View File

@ -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

View File

@ -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();
}

View File

@ -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,

View File

@ -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

View File

@ -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;
}
}

View File

@ -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,

View File

@ -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,

View File

@ -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

View File

@ -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,

View File

@ -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,

View File

@ -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),

View File

@ -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');
}
}

View File

@ -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:

View File

@ -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

View File

@ -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?) {

View File

@ -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',