Skip to main content
Issues are grouped by area. Each entry documents the symptom, the root cause, and the exact fix applied so the same mistake is never repeated.

Dependency / version conflicts

riverpod_generator ^4 requires source_gen ^3, but freezed ^2 requires source_gen ^2. They cannot coexist.Fix: upgrade freezed and freezed_annotation to ^3.x at the same time as riverpod_generator ^4.x.
riverpod_generator ^4 requires analyzer ^9, but drift_dev >=2.32.0 bumped to analyzer ^10.Fix: pin drift_dev (and drift) below 2.32.0 until riverpod_generator releases support for analyzer ^10:
drift: ">=2.28.1 <2.32.0"
drift_dev: ">=2.28.1 <2.32.0"

Riverpod v3

In Riverpod v3 / riverpod_generator ^4, class-based providers drop the Notifier suffix from their generated variable name.
Classv2 provider namev3 provider name
AuthNotifierauthNotifierProviderauthProvider
GatewayNotifiergatewayNotifierProvidergatewayProvider
RouterNotifierrouterNotifierProviderrouterProvider
MessageSendNotifiermessageSendNotifierProvidermessageSendProvider
Function-based providers (lowercase function names) are unchanged.Fix: global search-and-replace across the codebase for each renamed provider after running build_runner.
The old AsyncValue.value (which threw on error/loading) was removed. valueOrNull was renamed to value.
// v2
final state = ref.watch(authProvider).valueOrNull;

// v3
final state = ref.watch(authProvider).value;
Fix: global search-and-replace .valueOrNull.value for all Riverpod AsyncValue usages. Do not rename valueOrNull on custom non-Riverpod types (e.g. a Result<T> class with its own valueOrNull getter).
@riverpod (auto-dispose) providers can be paused or disposed between await points. Any ref.read() call after an await will throw this error at runtime.Two rules to follow:
  1. Action/service notifiers that span async work must be keepAlive: true. MessageSendNotifier, GatewayNotifier, and RouterNotifier are examples — they are not tied to a single widget’s lifecycle.
  2. Capture all ref.read() results before the first await.
// ❌ BAD — ref.read after await will throw if provider was rebuilt/disposed
Future<void> sendText(...) async {
  final repo = ref.read(messageRepositoryProvider);
  await repo.insertOutbound(...);
  ref.read(gatewayProvider.notifier).send({...}); // 💥 ref may be dead here
}

// ✅ GOOD — all refs resolved before any await
Future<void> sendText(...) async {
  final repo = ref.read(messageRepositoryProvider);
  final gateway = ref.read(gatewayProvider.notifier); // captured early
  await repo.insertOutbound(...);
  gateway.send({...}); // safe — local variable, not going through ref
}
In Riverpod v3, package:riverpod_annotation/riverpod_annotation.dart re-exports everything from flutter_riverpod. Files that import both will get an unnecessary_import lint warning.Fix: remove import 'package:flutter_riverpod/flutter_riverpod.dart' from any file that already imports riverpod_annotation.
Symptom: a keepAlive: true notifier obtains a dependency via ref.read(someProvider) inside build(). The dependency is auto-dispose. Because ref.read does not create a subscription, Riverpod has no active listener on someProvider and disposes it immediately after build() returns — even though the notifier is still alive.Concrete example: RecordingNotifier (keepAlive) used ref.read(audioRecorderProvider) to obtain the AudioRecorder. audioRecorderProvider is auto-dispose. With no active listener, Riverpod called recorder.dispose() right away. The very next call to startRecording() threw PlatformException: Record has not yet been created or has already been disposed.Fix: replace ref.read with ref.watch inside build() to keep the dependency alive for the full lifetime of the notifier:
// ❌ BAD — audioRecorderProvider is disposed immediately; recorder is a dangling reference
@Riverpod(keepAlive: true)
class RecordingNotifier extends _$RecordingNotifier {
  late AudioRecorder _recorder;

  @override
  RecordingState build() {
    _recorder = ref.read(audioRecorderProvider); // no subscription → immediate dispose
    ...
  }
}

// ✅ GOOD — ref.watch keeps audioRecorderProvider alive as long as RecordingNotifier lives
@Riverpod(keepAlive: true)
class RecordingNotifier extends _$RecordingNotifier {
  late AudioRecorder _recorder;

  @override
  RecordingState build() {
    _recorder = ref.watch(audioRecorderProvider); // subscription → stays alive
    // Do NOT call _recorder.dispose() in ref.onDispose —
    // audioRecorderProvider manages its own lifecycle.
    ...
  }
}
Rule of thumb: if a keepAlive notifier needs a service for its entire lifetime, always ref.watch that service inside build(). Using ref.read inside build() is only safe for one-shot reads where you don’t need the value to stay alive.

Freezed v3

In freezed v3, the generated _$Foo mixin declares abstract field getters. A plain class Foo with _$Foo is a concrete class with unimplemented abstract members — this is a compile error.Affected pattern: any @freezed class Foo with _$Foo that has a single factory constructor (= _Foo).
// v2 — no longer compiles with freezed v3
@freezed
class Channel with _$Channel {
  const factory Channel({...}) = _Channel;
}

// v3 fix
@freezed
abstract class Channel with _$Channel {
  const factory Channel({...}) = _Channel;
}
Not affected: multi-variant union types (e.g. AuthState with unauthenticated / pairing / authenticated / error) — the mixin for unions has no abstract field getters, so class continues to work.
In freezed v3, pattern-matching methods (when, map, maybeWhen, etc.) were moved from instance methods on the class to an extension (e.g. extension AuthStatePatterns on AuthState).Extension methods require a direct import of the library that defines them. Importing a file that imports the freezed file is not sufficient.
// router_notifier.dart only imported auth_notifier.dart, not auth_state.dart directly.
// auth_notifier.dart uses `import` (not `export`) for auth_state.dart, so the
// AuthStatePatterns extension was not in scope.
// Error: "The method 'when' isn't defined for the type 'AuthState'"

// Fix: add a direct import to bring the extension into scope
import 'auth/auth_state.dart';

Audio recording

Symptom: startRecording() throws silently or the record package falls back to a broken state on Firefox/older Chrome. The browser’s MediaRecorder API only supports a subset of MIME types per browser:
EncoderChromeFirefoxSafari
opus (webm/opus)
aacLc (mp4/aac)
wav
Hardcoding AudioEncoder.aacLc works on Chrome but breaks on Firefox.Fix: probe encoders in preference order using isEncoderSupported and pick the first supported one:
Future<rec.AudioEncoder> _chooseEncoder() async {
  if (!kIsWeb) return rec.AudioEncoder.aacLc;
  for (final encoder in const [
    rec.AudioEncoder.opus,
    rec.AudioEncoder.aacLc,
    rec.AudioEncoder.wav,
  ]) {
    if (await _recorder.isEncoderSupported(encoder)) return encoder;
  }
  return rec.AudioEncoder.opus; // fallback, should never reach here
}
Also pass a plain filename (no extension) to _recorder.start on web — the file extension on web is cosmetic only; the actual content type comes from the encoder:
await _recorder.start(config, path: 'audio'); // ✅ web
Symptom: sliding left to cancel recording works inconsistently — sometimes it cancels, sometimes it doesn’t. The console shows DartError: Bad state: Future already completed.Root cause: _onLongPressMoveUpdate fires on every pointer-move event while the finger is moving. Each event called unawaited(cancelRecording()) concurrently. The first call set isRecording.value = true during the await _recorder.stop() gap, so the second call also passed the if (!isRecording.value) return guard and attempted to complete the same Completer a second time.Fix — two-part:
  1. Synchronous state mutation before any await in both the notifier and the recorder implementation:
// RecordingNotifier.cancelRecording()
Future<void> cancelRecording() async {
  if (state is! RecordingActive) return;
  _cleanupTimer();
  // Set idle BEFORE the await so any concurrent call sees RecordingIdle and returns early.
  state = RecordingIdle(webPermissionGranted: _webPermissionGranted);
  await _recorder.cancelRecording();
}

// AudioRecorderImpl.cancelRecording()
Future<void> cancelRecording() async {
  if (!isRecording.value) return;
  // Mark stopped synchronously — prevents concurrent calls from completing the Completer twice.
  isRecording.value = false;
  _maxTimer?.cancel();
  _safeCompleteAutoStop(null);
  await _recorder.stop();
  ...
}
  1. Safe Completer completion — complete exactly once, then null out:
void _safeCompleteAutoStop(AudioRecordingResult? result) {
  final c = _autoStopCompleter;
  if (c != null && !c.isCompleted) c.complete(result);
  _autoStopCompleter = null;
}
Apply the same pattern to stopRecording() so it is also re-entry safe.
Symptom: tracking the swipe distance manually (e.g. storing details.globalPosition.dx at onLongPressStart and computing the delta on each onLongPressMoveUpdate) gives inconsistent results. The delta calculation can drift if the recorded start position is stale.Fix: use LongPressMoveUpdateDetails.offsetFromOrigin.dx which Flutter computes directly from the gesture origin. It is always relative to the long-press start point regardless of when you read it:
void _onLongPressMoveUpdate(LongPressMoveUpdateDetails details) {
  if (ref.read(recordingProvider) is! RecordingActive) return;
  // Negative dx = moved left. No manual delta needed.
  if (details.offsetFromOrigin.dx < _cancelThreshold) {
    unawaited(ref.read(recordingProvider.notifier).cancelRecording());
  }
}
A threshold of -50.0 logical pixels is a good starting point (vs. the initially tried -80.0 which required too large a swipe).