mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 19:07:02 +01:00
Wires the shared gate (~/utils/scripts/check_file_length.sh) into a new .pre-commit-config.yaml as a local hook, checking only the files in the commit and failing rather than warning. No baseline, no allowlist. The repo had a hand-written .git/hooks/pre-commit running two checks; both are ported verbatim into the config rather than dropped, with the version bump ordered last so it cannot rewrite pubspec.yaml for a commit a gate is still about to reject. scripts/install_hooks.sh now installs pre-commit (and removes the legacy hook) instead of writing its own. .github/workflows/file-length.yml runs the same check on push, kept separate from release-apk.yml so an over-long file fails lint rather than blocking an APK release. Splits the four cleanest violations, all behaviour-preserving: note_template.dart 370 -> 203 + parser (186) firebase_backend.dart 308 -> barrel (24) + project/store/client port_guard.dart 315 -> 232 + linux_system_probe (100) notes_list_screen.dart 698 -> 225 + 5 widget/nav files Where a file had external callers, the original path stays as a barrel that re-exports the split parts, so no call site changed. Verified: 313 tests pass, coverage identical at 99.9% / 1922 lines (per-file too -- notes_list's 281 covered lines are now 81+75+51+41+22+11), flutter analyze clean, and a staged 260-line file makes git commit fail. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
226 lines
7.6 KiB
Dart
226 lines
7.6 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import 'package:todo/data/note.dart';
|
|
import 'package:todo/data/note_repository.dart';
|
|
import 'package:todo/ui/note_tile.dart';
|
|
import 'package:todo/ui/notes_filter_sheet.dart';
|
|
import 'package:todo/ui/notes_list_navigation.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
|
|
/// badge and is what "Clear all" resets to.
|
|
const Set<Status> kDefaultStatuses = {Status.todo, Status.inProgress};
|
|
|
|
/// Searchable, filterable, sortable list of stored/synced notes.
|
|
///
|
|
/// The heavy lifting (WHERE/ORDER BY) lives in [NoteRepository]; this screen
|
|
/// only owns transient view state ([NoteSort] + [NoteFilter]) and rebuilds
|
|
/// 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
|
|
State<NotesListScreen> createState() => _NotesListScreenState();
|
|
}
|
|
|
|
class _NotesListScreenState extends State<NotesListScreen> {
|
|
/// How long to wait after the last keystroke before re-querying, so we
|
|
/// don't spin up a new subscription on every character typed.
|
|
static const _searchDebounce = Duration(milliseconds: 250);
|
|
|
|
final TextEditingController _searchController = TextEditingController();
|
|
Timer? _debounce;
|
|
|
|
NoteSort _sort = NoteSort.priorityDesc;
|
|
|
|
/// Default view hides completed/dropped work: only To do + In progress.
|
|
/// The user can widen this (or clear it) via the filter sheet.
|
|
NoteFilter _filter = const NoteFilter(statuses: kDefaultStatuses);
|
|
|
|
/// Whether [statuses] is exactly the default selection (so the badge can
|
|
/// treat the default view as "unfiltered").
|
|
static bool _statusesAreDefault(Set<Status> statuses) =>
|
|
statuses.length == kDefaultStatuses.length &&
|
|
statuses.containsAll(kDefaultStatuses);
|
|
|
|
/// Number of *user-applied* filter facets, for the badge. Excludes the
|
|
/// default status selection so a fresh list shows no badge.
|
|
int get _badgeCount {
|
|
var count = _filter.activeCount;
|
|
if (_statusesAreDefault(_filter.statuses)) count -= 1;
|
|
return count;
|
|
}
|
|
|
|
/// Memoised stream; only replaced when [_sort]/[_filter] actually change.
|
|
late Stream<List<Note>> _stream;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_stream = widget.repository.watchNotes(sort: _sort, filter: _filter);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_debounce?.cancel();
|
|
_searchController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
/// Rebuilds the watch stream for the current sort + filter. Call only
|
|
/// from handlers that change those, never from [build].
|
|
void _applyState() {
|
|
setState(() {
|
|
_stream = widget.repository.watchNotes(sort: _sort, filter: _filter);
|
|
});
|
|
}
|
|
|
|
void _onSearchChanged(String value) {
|
|
_debounce?.cancel();
|
|
_debounce = Timer(_searchDebounce, () {
|
|
_filter = _filter.copyWith(query: value);
|
|
_applyState();
|
|
});
|
|
}
|
|
|
|
void _setSort(NoteSort sort) {
|
|
if (sort == _sort) return;
|
|
_sort = sort;
|
|
_applyState();
|
|
}
|
|
|
|
/// Opens the filter sheet and adopts the edited filter (text query is
|
|
/// owned by the search box, so it is preserved across the round-trip).
|
|
Future<void> _openFilters() async {
|
|
final edited = await showModalBottomSheet<NoteFilter>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
showDragHandle: true,
|
|
builder: (_) => FilterSheet(initial: _filter),
|
|
);
|
|
if (edited != null) {
|
|
_filter = edited.copyWith(query: _searchController.text);
|
|
_applyState();
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final badgeCount = _badgeCount;
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Notes'),
|
|
actions: [
|
|
PopupMenuButton<NoteSort>(
|
|
tooltip: 'Sort',
|
|
icon: const Icon(Icons.sort),
|
|
initialValue: _sort,
|
|
onSelected: _setSort,
|
|
itemBuilder: (_) => const [
|
|
PopupMenuItem(
|
|
value: NoteSort.modifiedDesc,
|
|
child: Text('Last updated'),
|
|
),
|
|
PopupMenuItem(
|
|
value: NoteSort.createdDesc,
|
|
child: Text('Newest created'),
|
|
),
|
|
PopupMenuItem(
|
|
value: NoteSort.alphabetical,
|
|
child: Text('Alphabetical'),
|
|
),
|
|
PopupMenuItem(
|
|
value: NoteSort.priorityDesc,
|
|
child: Text('Priority'),
|
|
),
|
|
],
|
|
),
|
|
// Filter icon with a badge counting user-applied facets. The
|
|
// trailing padding + inward offset keep the badge from being
|
|
// clipped at the screen edge.
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 8),
|
|
child: Badge(
|
|
isLabelVisible: badgeCount > 0,
|
|
label: Text('$badgeCount'),
|
|
offset: const Offset(-8, 4),
|
|
child: IconButton(
|
|
tooltip: 'Filter',
|
|
icon: const Icon(Icons.filter_list),
|
|
onPressed: _openFilters,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
body: Column(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
|
|
child: TextField(
|
|
controller: _searchController,
|
|
onChanged: _onSearchChanged,
|
|
textInputAction: TextInputAction.search,
|
|
decoration: InputDecoration(
|
|
hintText: 'Search notes…',
|
|
prefixIcon: const Icon(Icons.search),
|
|
isDense: true,
|
|
border: const OutlineInputBorder(),
|
|
suffixIcon: _searchController.text.isEmpty
|
|
? null
|
|
: IconButton(
|
|
tooltip: 'Clear search',
|
|
icon: const Icon(Icons.clear),
|
|
onPressed: () {
|
|
_searchController.clear();
|
|
_onSearchChanged('');
|
|
setState(() {});
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: StreamBuilder<List<Note>>(
|
|
stream: _stream,
|
|
builder: (context, snapshot) {
|
|
final notes = snapshot.data ?? const <Note>[];
|
|
if (notes.isEmpty) {
|
|
return Center(
|
|
child: Text(
|
|
_filter.isEmpty
|
|
? 'No notes yet'
|
|
: 'No notes match these filters',
|
|
),
|
|
);
|
|
}
|
|
return ListView.separated(
|
|
itemCount: notes.length,
|
|
separatorBuilder: (_, _) => const Divider(height: 1),
|
|
itemBuilder: (context, i) => NoteTile(
|
|
key: ValueKey(notes[i].id),
|
|
note: notes[i],
|
|
onTap: () =>
|
|
openNote(context, notes[i], widget.repository),
|
|
onActions: () =>
|
|
openNoteActions(context, notes[i], widget.repository),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// One row in the notes list: first line, then a metadata subtitle.
|