Imported from zakariaf/Shed-Book (
.claude/skills/shed-riverpod-providers/SKILL.md). Install upstream withnpx skills add zakariaf/Shed-Book --skill shed-riverpod-providers. Copyright stays with the author.
Riverpod 2.6.1 — providers, controllers, rebuild scope
Authorities: docs/engineering/02-state-di-navigation.md (owns this area),
docs/engineering/CONVENTIONS.md §3 (the closed provider catalogue),
docs/research/00-tech-decisions.md §5 (the only source of versions). Cite them; do not restate them.
1. The pin — flutter_riverpod: 2.6.1, no caret
^2.6.1 is a defect: CI asserts the exact string in pubspec.yaml and the resolved version in
pubspec.lock. Why (02 §1): riverpod 3.x declares test: ^1.0.0 in runtime dependencies →
analyzer <13 → cannot coexist with drift_dev ≥2.34.1, so flutter pub get fails outright.
WONTFIX upstream. 3.x is uninstallable here, not merely undesirable.
- If
pub getfails with solver output namingtest ^1.0.0, someone raised the pin — revert it. Never loosen the constraint; never take theany-constraint workaround, which resolves only by pinningdrift_devback to 2.34.0. - Always
import 'package:flutter_riverpod/flutter_riverpod.dart';, neverpackage:riverpod/riverpod.dart— it is transitive, not declared, and gate G2 scans direct dependencies. - The whole surface used here:
Provider,FutureProvider,StreamProvider,NotifierProvider,.family,.autoDispose,.select,ProviderScope,ProviderContainer. Nothing else.lib/domain/imports no Riverpod; providers live only inlib/data/,lib/core/,lib/features/.
2. Riverpod 3 is banned — all of it
Assume any Riverpod you did not write is 3.x; every tutorial published after 2025 shows the 3.x form. This is the likeliest way the codebase silently breaks. Full list and CI rows: 02 §2.1–§2.4. The ones actually copied:
- Family args through the constructor. 3.x uses the constructor; 2.6.1 uses
build(Arg arg)with a zero-argument tear-off (C.new) and an inheritedarggetter. Notifier+.autoDispose. 2.6.1 has distinct bases:AutoDisposeNotifier,AutoDisposeAsyncNotifier,AutoDisposeFamilyNotifier,AutoDisposeFamilyAsyncNotifier.ref.mounteddoes not exist. Track disposal with abool _disposedset fromref.onDispose— that is whyWriteControllercarries one (§7).- Never write the type name
Ref. 2.6.1'sRef<State>type parameter is deprecated; let the create-callback parameter infer, and use the inheritedrefinside a notifier. - No codegen.
@riverpod,riverpod_annotation,riverpod_generator,riverpod_lintare internally unresolvable. Providers are hand-written, one line each; drift is the only generator. - Compiles but banned:
StateProvider,StateNotifier,StateNotifierProvider,ChangeNotifierProvider, and everyAsyncValueaccessor —.value,.valueOrNull,.requireValue,.hasValue,.asData. - Absent here:
ProviderContainer.test,WidgetTester.container,Mutation/ref.mutate,hooks_riverpod/flutter_hooks, Riverpod 3 offline persistence,ProviderScope(retry:).
Read references/riverpod3-symptoms.md when a Riverpod snippet fails to compile, when the analyzer
flags a provider/notifier/Ref, or before adapting Riverpod copied from outside this repo. It maps
the exact error text — or the silent wrong behaviour — to the 2.6.1 spelling.
3. Shapes, names, disposal
The spelling card is 02 §3. The closed catalogue of every provider — name, type, file and dispose
policy, one row each, together with the plausible-looking spellings that are banned — is
CONVENTIONS.md §3.1–§3.4. Look yours up there before you declare it: a provider not in that list is
a convention change, not an implementation detail, and a name that reads right is as likely to be a
banned row as a correct one. Never copy a row of §3 into a skill, a comment or a PR description —
cite it, so there is only ever one list.
Auto-dispose policy is a rule about kinds, not a second list of names (02 §4.2):
- keepAlive —
databaseProvider, repositories, gateways, settings, and the hub reads §3.2 marks as such. Reopening SQLite or re-querying the hub on every pop at 03:41 is the wrong trade. .autoDispose.family— per-animal reads and controllers; a season of browsing must not leave 400 live drift subscriptions..autoDispose, always — write controllers.minuteTickProvider—.autoDisposeis load-bearing: nothing ticks when no elapsed time is shown. It yieldsInstant, neverDateTime(R25).ref.keepAlive()is used nowhere. Reaching for it means.autoDisposelanded on a hub provider; remove the.autoDisposeinstead.
Family arguments are the cache key, compared with ==. Use the extension-type ids from lib/domain/ids.dart
(EweId, LambId, LambingId). Never a List, never a hand-written class without verified ==/hashCode
— you create and leak a provider instance per rebuild.
4. One drift statement per screen; fan-in in SQL
combineLatestover drift streams is a build-breaking defect (07 §1.2). drift#3338: two streams written in one transaction can emit at different times, so the combination renders a state that never existed in the database. If two values must agree for the screen to be correct they are one SQL statement —WITH … UNION ALL, not Dart.- One content statement per screen. It may also watch single-row lookups and the app-level
singletons
settingsProvider,entitlementProvider,tagIndexProvider,minuteTickProvider. - Quick Entry's two strips are one provider,
quickEntryDeckProvider, read with.select(R28). - Never
ref.invalidatea drift-backed read provider.watch()already re-emits; a manual invalidate means the write bypassed drift or the query is missing a table inreadsFrom:. The ban is scoped to drift-backed reads (02 §3), and exactly two call sites inlib/are legitimate, neither of them a read:ref.invalidate(minuteTickProvider)onAppLifecycleState.resumed(02 §9.1, R25 — a wall-clock ticker with no database behind it), andref.invalidate(databaseProvider)at step 14 of restore (04 §7,shed-export-and-restore) — the live database file has been replaced, so re-opening it is the point, not a stale-read patch. A third is a defect.stream.invalidatewas narrowed on 2026-08-02 toRegExp(r'ref\.invalidate\((?!minuteTickProvider\)|databaseProvider\))', so both call sites are green and every other argument fires — including in the same two files.CODE-REVIEW-CHECKLIST §1.5is closed by that narrowing, and the[exempt]allowlist is untouched at four lines. Never delete the call to green a gate, and never reach for a fifth[exempt]line: the exception belongs in the rule, where it is legible.
5. watch / read / listen / .select
ref.watch— only inWidget.build()andNotifier.build(). In a callback it creates a subscription per tap that is never released: the app degrades over a night.ref.read— only in callbacks, event handlers, controller methods. Inbuild()the widget stops updating and shows a stale ewe at 3am.ref.listen— only inbuild(), unconditional, at the top, never inside anif. Side effects live here: haptics, the receipt, navigation, screen-reader announcements.- Neither
ref.watch-in-callback norref.read-in-buildis CI-catchable. Check them by eye. - Anything reachable through
.selectis a stored field computed once in the state class's factory — never a getter that allocates a collection. A getter returning a freshListrebuilds on every emission and re-runs the filter once per comparison: strictly worse than no.select. - A stored
Liststill has identity==, so it deduplicates nothing; what it removes is the recomputation during the frame. Select a scalar (s.query,s.matches.length) for real deduplication, and never addlistEqualsto close the gap — it costs more than the rebuild it prevents.
6. Reading an AsyncValue
Exhaustive switch over AsyncData / AsyncError / AsyncLoading, no default:, no accessor
anywhere (02 §4.5). Three consequences that are not stylistic:
AsyncLoadingis never a spinner — a fixed-height placeholder in the same dark colour, so nothing shifts when data lands. A spinning white ring under a head torch is a flashbang.AsyncErroris never silent. WhendatabaseProviderfails every downstream provider isAsyncError; the keypad stays interactive but event buttons become an honest failure row. A tap must never look like it recorded something when it did not.- Never
?? 0on a nullable aggregate — an unknown statistic isnotComputableReason, not zero.
7. The DI graph and WriteController
- Root is
databaseProvider : FutureProvider<AppDatabase>, opened on the first post-frame callback withref.onDispose(db.close). There is noProvider<AppDatabase>and nooverrideWithValue(db). - Asynchrony is contagious upwards: repository providers are
FutureProviders thatawait ref.watch(databaseProvider.future); read providers fold the open away withStreamProvider((ref) async* { … yield* repo.watchX(); }). - Production has zero overrides —
runApp(const ProviderScope(child: ShedBookApp())).overrideWith/overrideWithValueare CI-banned inlib/; they live intest/andtool/seed.dartonly. - The clock is not a provider. One ambient
package:clock, read only throughappNow()inlib/core/time/app_clock.dart(R23). NoclockProvider, noClockinterface, noSystemClock; tests usewithClock(...), never an override. - Tests override leaves, never controllers:
databaseProviderand the seven gateways — a fake repository or controller tests the fake. UseProviderContainer(overrides: […])withaddTearDown(container.dispose)(2.6.1 does not register it for you), an in-memory drift database withcloseStreamsSynchronously: true, handed to the tree viaUncontrolledProviderScope. - Every mutation goes through
WriteController.guard()(02 §7), which assignsstate = const WriteRunning()synchronously, before the firstawait— that assignment is the double-tap gate. A cold gloved thumb through a freezer bag double-fires; without the gate the second fire is a second lambing record. guard()prevents concurrency, not repetition: after completion a second tap is a legitimate second write, and idempotence beyond that is the repository's job. A UI cooldown is not the mechanism — it would drop a real second lamb. Taps are never debounced.WriteDonedeliberately has no==: two identical outcomes in a row must each fireref.listen, because each completed write owes the user its own feedback.- Switch over
WriteOutcomecovers all three variants, nodefault:.WriteRefusedis not a failure — it renders as the calm static upgrade row, never a modal, never as an error. WriteCommitted.warningsis populated by the controller, never by a repository (R53).lib/data/may not importlib/domain/validation/, so a repository is structurally incapable of producing aWarning; it returnsWriteCommitted(insertedId: …)with the default empty list. The controller runs the domain validators against the freshly-watched row and passes theList<Warning>toconfirmSaved. Warnings are flagged, never fixed, and never block a save (spec §12.4).
8. Rebuild scope and performance
- Never store a time-relative value (01 §7.1–§7.2). The storage half of that rule — which values,
and why a column may not hold one — is shed-drift-schema's. This skill owns the render half:
compute from the instant
minuteTickProvideryields, with domain functions takingnowas a parameter and never reading a clock (R24). One app-level ticker, boundary-aligned to the wall-clock minute — never aTimer.periodicper row. QuickEntryScreenis aStatelessWidgetthat watches nothing, so theconst_Keypadis structurally incapable of rebuilding. Making it aConsumerWidgetto "just watch one thing here" destroys every child'sconst-ness. Filter in the controller, once per keystroke — neverwhere+toListinsidebuild().ListView.builderwithitemExtent;ValueKey(id)on rows; noOpacity/ShaderMask/ColorFilterin a row (they forcesaveLayer); never overrideoperator ==on a widget (O(N²)).- Exactly two debounces exist in
lib/: 200 ms on note search, 400 ms on free-text fields. No debounce on the keypad — the match is sub-millisecond and a debounce puts visible lag between thumb and digit. A third is a defect. Measure in profile mode on a real low-end device with the 400-ewe fixture, never the simulator.
9. Gotchas that defy the obvious assumption
- A
Notifierinstance survives its ownbuild()re-run, butstatedoes not. Anything the user typed lives in a private field on the notifier, andstateis seeded from it at the end ofbuild(). Without this, an unrelated flock change silently wipes digits a shepherd just typed — a real 3am data loss. - A controller holds screen state, never data. Mirroring drift rows into state gives two sources of truth and the wrong one wins at 3am.
- Controllers hold no draft — no
save(), noisDirty, nocommit(); every field is its own committed write and there is no Save button in this app. They hold noBuildContext, never navigate, never format for display (noDateFormat, no unit conversion, no terminology lookup), and never import drift. - There is no SnackBar in this app (ruling P2). 02 §4.3/§7 still say "SnackBar" — superseded, and
showSnackBar(is banned everywhere includinglib/core/ui/feedback.dart. CallconfirmSaved/showFailure/showCapRow(R30) fromref.listen; indelible-states-and-feedback owns what they render, so do not describe the receipt from here. - Nothing on the 3am path watches
entitlementProvider(decision #90) — the failure mode is a paywall flash at 3am. WidgetsBindingObserverdoes nothing withoutaddObserver(this)ininitStateandremoveObserver(this)indispose. The mixin compiles and the overrides look valid while no lifecycle callback ever fires — silently killing the resume policy and the wakelock release.Clock.fixedfreezesnow(). Widget tests already have an advancing fake clock; pinningnowmakes every "hours since penned" readout silently measure 0 h. Offset the seed data instead.
Scope — do NOT use this skill for
- Repository methods, event verbs, transactions,
WriteOutcome/ShedFailuremapping → shed-write-path (it points at R53 here rather than repeating it). - drift tables, queries, views,
readsFrom:, migrations → shed-drift-schema. - Screen layout, widget composition,
Navigator, route helpers,PopScope→ shed-screens-and-routing.
Definition of done
-
pubspec.yamlhasflutter_riverpod: 2.6.1, no caret;pubspec.lockcommitted. -
flutter analyze --fatal-infosclean — this alone proves no Riverpod-3-only class or named parameter survives.dart tool/check_policy.dartpasses (it carries therp3.*rows from 02 §2.4). - No
AsyncValueaccessor anywhere; every read site is an exhaustiveswitchwith nodefault:. -
ref.mounted, the type nameRef,@riverpodandshowSnackBar(appear nowhere inlib/. -
lib/has zerooverrideWith/overrideWithValue, zeroref.keepAlive(), and exactly oneref.invalidateper call path —minuteTickProvideron resume, anddatabaseProviderat restore step 14. Neither is a drift-backed read. - Every provider was checked against
CONVENTIONS.md§3 — name, type, file, dispose policy — and against §3.2–§3.4's banned spellings, by opening that file rather than from memory. - No
combineLatestover drift streams; every screen has one content statement. - Every mutation goes through
WriteController.guard(), which assignsWriteRunningbefore its firstawait. Every destructive action has a two-tap test with no pump between the taps, asserting one row. -
ref.watchonly inbuild();ref.readonly in callbacks and controller methods; everyref.listenunconditional at the top ofbuild(). - No derived collection is a getter; every
.selecttarget is a stored field. - No time-relative value is stored, in a table or in controller state.