Per channel skeleton

This commit is contained in:
2026-03-01 20:10:38 +08:00
parent b9153a070f
commit 029e671367
13 changed files with 380 additions and 153 deletions
+58 -25
View File
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'stream_recovery.dart';
import 'supabase_provider.dart';
final realtimeControllerProvider = ChangeNotifierProvider<RealtimeController>((
@@ -15,18 +16,22 @@ final realtimeControllerProvider = ChangeNotifierProvider<RealtimeController>((
return controller;
});
/// Simplified realtime controller for app-lifecycle awareness.
/// Individual streams now handle their own recovery via [StreamRecoveryWrapper].
/// This controller only coordinates:
/// - App lifecycle transitions (background/foreground)
/// - Auth token refreshes
/// - Global connection state notification (for UI indicators)
/// Per-channel realtime controller for UI skeleton indicators.
///
/// Individual streams handle their own recovery via [StreamRecoveryWrapper].
/// This controller aggregates channel-level status so the UI can show
/// per-channel skeleton shimmers (e.g. only the tasks list shimmers when
/// the `tasks` channel is recovering, not the whole app).
///
/// Coordinates:
/// - Per-channel recovering state for pinpoint skeleton indicators
/// - Auth token refreshes for realtime connections
class RealtimeController extends ChangeNotifier {
final SupabaseClient _client;
bool _disposed = false;
/// Global flag: true if any stream is recovering; used for subtle UI indicator.
bool isAnyStreamRecovering = false;
/// Channels currently in a recovering/polling/stale state.
final Set<String> _recoveringChannels = {};
RealtimeController(this._client) {
_init();
@@ -34,12 +39,8 @@ class RealtimeController extends ChangeNotifier {
void _init() {
try {
// Listen for auth changes; ensure tokens are fresh for realtime.
// Individual streams will handle their own reconnection.
_client.auth.onAuthStateChange.listen((data) {
final event = data.event;
// Only refresh token on existing session refreshes, not immediately after sign-in
// (sign-in already provides a fresh token)
if (event == AuthChangeEvent.tokenRefreshed) {
_ensureTokenFresh();
}
@@ -49,12 +50,9 @@ class RealtimeController extends ChangeNotifier {
}
}
/// Ensure auth token is fresh for upcoming realtime operations.
/// This is called after token refresh events, not immediately after sign-in.
Future<void> _ensureTokenFresh() async {
if (_disposed) return;
try {
// Defensive: only refresh if the method exists (SDK version compatibility)
final authDynamic = _client.auth as dynamic;
if (authDynamic.refreshSession != null) {
await authDynamic.refreshSession?.call();
@@ -64,24 +62,59 @@ class RealtimeController extends ChangeNotifier {
}
}
/// Notify that a stream is starting recovery. Used for global UI indicator.
void markStreamRecovering() {
if (!isAnyStreamRecovering) {
isAnyStreamRecovering = true;
// ── Per-channel status ─────────────────────────────────────────────────
/// Whether a specific channel is currently recovering.
bool isChannelRecovering(String channel) =>
_recoveringChannels.contains(channel);
/// Global flag: true if **any** channel is recovering. Useful for global
/// indicators (e.g. dashboard) where per-channel granularity isn't needed.
bool get isAnyStreamRecovering => _recoveringChannels.isNotEmpty;
/// The set of channels currently recovering, for UI display.
Set<String> get recoveringChannels => Set.unmodifiable(_recoveringChannels);
/// Mark a channel as recovering. Called by [StreamRecoveryWrapper] via its
/// [ChannelStatusCallback].
void markChannelRecovering(String channel) {
if (_recoveringChannels.add(channel)) {
notifyListeners();
}
}
/// Notify that stream recovery completed. If all streams recovered, update state.
void markStreamRecovered() {
// In practice, individual streams notify their own status via statusChanges.
// This is kept for potential future global coordination.
if (isAnyStreamRecovering) {
isAnyStreamRecovering = false;
/// Mark a channel as recovered. Called when realtime reconnects
/// successfully.
void markChannelRecovered(String channel) {
if (_recoveringChannels.remove(channel)) {
notifyListeners();
}
}
/// Convenience callback suitable for [StreamRecoveryWrapper.onStatusChanged].
///
/// Routes [StreamConnectionStatus] to the appropriate mark method.
void handleChannelStatus(String channel, StreamConnectionStatus status) {
if (status == StreamConnectionStatus.connected) {
markChannelRecovered(channel);
} else {
markChannelRecovering(channel);
}
}
// ── Legacy compat ─────────────────────────────────────────────────────
/// @deprecated Use [markChannelRecovering] instead.
void markStreamRecovering() {
// Kept for backward compatibility; maps to a synthetic channel.
markChannelRecovering('_global');
}
/// @deprecated Use [markChannelRecovered] instead.
void markStreamRecovered() {
markChannelRecovered('_global');
}
@override
void dispose() {
_disposed = true;