diff --git a/analysis_options.yaml b/analysis_options.yaml index 0d29021..bac0810 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -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 diff --git a/lib/data/note.dart b/lib/data/note.dart index 8d9bd76..b764ee4 100644 --- a/lib/data/note.dart +++ b/lib/data/note.dart @@ -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 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 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, diff --git a/lib/data/note_repository.dart b/lib/data/note_repository.dart index 92a593a..8cb2b83 100644 --- a/lib/data/note_repository.dart +++ b/lib/data/note_repository.dart @@ -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 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). diff --git a/lib/data/note_template.dart b/lib/data/note_template.dart index 312a1d4..5a25997 100644 --- a/lib/data/note_template.dart +++ b/lib/data/note_template.dart @@ -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: ', helper: - "Repo + target files/paths (not terminal dumps), or " + 'Repo + target files/paths (not terminal dumps), or ' "'new app: ', 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 ''. diff --git a/lib/main.dart b/lib/main.dart index cb825dc..a6924a0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -31,8 +31,10 @@ Future 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 diff --git a/lib/sync/github_device_auth.dart b/lib/sync/github_device_auth.dart index d812ad5..f22ca5c 100644 --- a/lib/sync/github_device_auth.dart +++ b/lib/sync/github_device_auth.dart @@ -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 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 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.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(); } diff --git a/lib/sync/local_backup.dart b/lib/sync/local_backup.dart index 72f5fca..6e1bb7e 100644 --- a/lib/sync/local_backup.dart +++ b/lib/sync/local_backup.dart @@ -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, diff --git a/lib/sync/notes_markdown.dart b/lib/sync/notes_markdown.dart index a611e75..7a7d998 100644 --- a/lib/sync/notes_markdown.dart +++ b/lib/sync/notes_markdown.dart @@ -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 diff --git a/lib/sync/sync_service.dart b/lib/sync/sync_service.dart index cadae3a..4d8e3bc 100644 --- a/lib/sync/sync_service.dart +++ b/lib/sync/sync_service.dart @@ -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; } } diff --git a/lib/sync/sync_settings.dart b/lib/sync/sync_settings.dart index 40715fa..793e9ee 100644 --- a/lib/sync/sync_settings.dart +++ b/lib/sync/sync_settings.dart @@ -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 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, diff --git a/lib/ui/capture_screen.dart b/lib/ui/capture_screen.dart index 27db511..a3f1919 100644 --- a/lib/ui/capture_screen.dart +++ b/lib/ui/capture_screen.dart @@ -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 // 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 // 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 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 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 } void _openList() { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => NotesListScreen(repository: widget.repository), + unawaited( + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => NotesListScreen(repository: widget.repository), + ), ), ); } @@ -306,7 +312,7 @@ class _CaptureScreenState extends State 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 /// 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, diff --git a/lib/ui/markdown_view.dart b/lib/ui/markdown_view.dart index 01eee16..9180422 100644 --- a/lib/ui/markdown_view.dart +++ b/lib/ui/markdown_view.dart @@ -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 diff --git a/lib/ui/note_detail_screen.dart b/lib/ui/note_detail_screen.dart index 487c80e..0ef7be4 100644 --- a/lib/ui/note_detail_screen.dart +++ b/lib/ui/note_detail_screen.dart @@ -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 extends StatelessWidget { Widget build(BuildContext context) { return DropdownButtonFormField( initialValue: value, - isDense: true, decoration: InputDecoration( labelText: label, isDense: true, diff --git a/lib/ui/note_editor.dart b/lib/ui/note_editor.dart index aa3e961..9b79472 100644 --- a/lib/ui/note_editor.dart +++ b/lib/ui/note_editor.dart @@ -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 { /// 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 { children: [ DropdownButtonFormField( initialValue: _template.id, - isDense: true, decoration: const InputDecoration( labelText: 'Template', isDense: true, @@ -434,7 +434,6 @@ class _NoteEditorState extends State { return [ DropdownButtonFormField( initialValue: _wizardPriority, - isDense: true, decoration: const InputDecoration( labelText: 'Priority', isDense: true, @@ -465,7 +464,6 @@ class _NoteEditorState extends State { return [ DropdownButtonFormField( initialValue: _wizardTemplate.id, - isDense: true, decoration: const InputDecoration( labelText: 'Template', isDense: true, diff --git a/lib/ui/notes_list_screen.dart b/lib/ui/notes_list_screen.dart index 061e666..262e973 100644 --- a/lib/ui/notes_list_screen.dart +++ b/lib/ui/notes_list_screen.dart @@ -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 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), diff --git a/lib/ui/settings_screen.dart b/lib/ui/settings_screen.dart index 29ac3d1..a82ec33 100644 --- a/lib/ui/settings_screen.dart +++ b/lib/ui/settings_screen.dart @@ -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 { 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 { '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 { ? '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 { ); } } - } catch (e) { + } on Exception catch (e) { if (mounted) setState(() => _status = 'Export failed: $e'); } } @@ -231,7 +235,7 @@ class _SettingsScreenState extends State { '${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 _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'); } } diff --git a/pubspec.lock b/pubspec.lock index fa49fbe..063038d 100644 --- a/pubspec.lock +++ b/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: diff --git a/pubspec.yaml b/pubspec.yaml index f572762..b7285c7 100644 --- a/pubspec.yaml +++ b/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 diff --git a/tool/device_flow_check.dart b/tool/device_flow_check.dart index c7e35fc..bfc16a9 100644 --- a/tool/device_flow_check.dart +++ b/tool/device_flow_check.dart @@ -25,10 +25,11 @@ Future main() async { exit(1); } final dc = jsonDecode(code.body) as Map; - 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 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?) { diff --git a/tool/sync_smoke.dart b/tool/sync_smoke.dart index 7b1ea9a..25a7a28 100644 --- a/tool/sync_smoke.dart +++ b/tool/sync_smoke.dart @@ -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 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 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',