feat(auth): Improve user fetching with caching and background refresh
This commit is contained in:
@@ -3,15 +3,55 @@ import 'package:flutter/foundation.dart';
|
||||
/// Subset of the backend `/api/config` response the app cares about.
|
||||
@immutable
|
||||
class BackendConfig {
|
||||
const BackendConfig({this.enableWebsocket});
|
||||
const BackendConfig({
|
||||
this.enableWebsocket,
|
||||
this.enableAudioInput,
|
||||
this.enableAudioOutput,
|
||||
this.sttProvider,
|
||||
this.ttsProvider,
|
||||
this.ttsVoice,
|
||||
this.defaultSttLocale,
|
||||
this.audioSampleRate,
|
||||
this.audioFrameSize,
|
||||
this.vadEnabled,
|
||||
});
|
||||
|
||||
/// Mirrors `features.enable_websocket` from OpenWebUI.
|
||||
final bool? enableWebsocket;
|
||||
final bool? enableAudioInput;
|
||||
final bool? enableAudioOutput;
|
||||
final String? sttProvider;
|
||||
final String? ttsProvider;
|
||||
final String? ttsVoice;
|
||||
final String? defaultSttLocale;
|
||||
final int? audioSampleRate;
|
||||
final int? audioFrameSize;
|
||||
final bool? vadEnabled;
|
||||
|
||||
/// Returns a copy with updated fields.
|
||||
BackendConfig copyWith({bool? enableWebsocket}) {
|
||||
BackendConfig copyWith({
|
||||
bool? enableWebsocket,
|
||||
bool? enableAudioInput,
|
||||
bool? enableAudioOutput,
|
||||
String? sttProvider,
|
||||
String? ttsProvider,
|
||||
String? ttsVoice,
|
||||
String? defaultSttLocale,
|
||||
int? audioSampleRate,
|
||||
int? audioFrameSize,
|
||||
bool? vadEnabled,
|
||||
}) {
|
||||
return BackendConfig(
|
||||
enableWebsocket: enableWebsocket ?? this.enableWebsocket,
|
||||
enableAudioInput: enableAudioInput ?? this.enableAudioInput,
|
||||
enableAudioOutput: enableAudioOutput ?? this.enableAudioOutput,
|
||||
sttProvider: sttProvider ?? this.sttProvider,
|
||||
ttsProvider: ttsProvider ?? this.ttsProvider,
|
||||
ttsVoice: ttsVoice ?? this.ttsVoice,
|
||||
defaultSttLocale: defaultSttLocale ?? this.defaultSttLocale,
|
||||
audioSampleRate: audioSampleRate ?? this.audioSampleRate,
|
||||
audioFrameSize: audioFrameSize ?? this.audioFrameSize,
|
||||
vadEnabled: vadEnabled ?? this.vadEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,28 +77,114 @@ class BackendConfig {
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'enable_websocket': enableWebsocket,
|
||||
'enable_audio_input': enableAudioInput,
|
||||
'enable_audio_output': enableAudioOutput,
|
||||
'stt_provider': sttProvider,
|
||||
'tts_provider': ttsProvider,
|
||||
'tts_voice': ttsVoice,
|
||||
'default_stt_locale': defaultSttLocale,
|
||||
'audio_sample_rate': audioSampleRate,
|
||||
'audio_frame_size': audioFrameSize,
|
||||
'vad_enabled': vadEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
static BackendConfig fromJson(Map<String, dynamic> json) {
|
||||
bool? enableWebsocket;
|
||||
bool? enableAudioInput;
|
||||
bool? enableAudioOutput;
|
||||
String? sttProvider;
|
||||
String? ttsProvider;
|
||||
String? ttsVoice;
|
||||
String? defaultSttLocale;
|
||||
int? audioSampleRate;
|
||||
int? audioFrameSize;
|
||||
bool? vadEnabled;
|
||||
// Try canonical format first
|
||||
final value = json['enable_websocket'];
|
||||
if (value is bool) {
|
||||
enableWebsocket = value;
|
||||
}
|
||||
|
||||
final audioIn = json['enable_audio_input'];
|
||||
if (audioIn is bool) enableAudioInput = audioIn;
|
||||
final audioOut = json['enable_audio_output'];
|
||||
if (audioOut is bool) enableAudioOutput = audioOut;
|
||||
|
||||
final stt = json['stt_provider'];
|
||||
if (stt is String) sttProvider = stt;
|
||||
final tts = json['tts_provider'];
|
||||
if (tts is String) ttsProvider = tts;
|
||||
final ttsVoiceValue = json['tts_voice'];
|
||||
if (ttsVoiceValue is String) ttsVoice = ttsVoiceValue;
|
||||
|
||||
final defaultLocale = json['default_stt_locale'];
|
||||
if (defaultLocale is String) defaultSttLocale = defaultLocale;
|
||||
|
||||
final sampleRate = json['audio_sample_rate'];
|
||||
if (sampleRate is int) audioSampleRate = sampleRate;
|
||||
final frameSize = json['audio_frame_size'];
|
||||
if (frameSize is int) audioFrameSize = frameSize;
|
||||
|
||||
final vad = json['vad_enabled'];
|
||||
if (vad is bool) vadEnabled = vad;
|
||||
|
||||
// Fallback to nested format for backwards compatibility
|
||||
if (enableWebsocket == null) {
|
||||
final features = json['features'];
|
||||
if (features is Map<String, dynamic>) {
|
||||
final nestedValue = features['enable_websocket'];
|
||||
if (nestedValue is bool) {
|
||||
enableWebsocket = nestedValue;
|
||||
}
|
||||
final features = json['features'];
|
||||
if (features is Map<String, dynamic>) {
|
||||
final nestedValue = features['enable_websocket'];
|
||||
if (nestedValue is bool && enableWebsocket == null) {
|
||||
enableWebsocket = nestedValue;
|
||||
}
|
||||
final nestedAudioIn = features['enable_audio_input'];
|
||||
if (nestedAudioIn is bool && enableAudioInput == null) {
|
||||
enableAudioInput = nestedAudioIn;
|
||||
}
|
||||
final nestedAudioOut = features['enable_audio_output'];
|
||||
if (nestedAudioOut is bool && enableAudioOutput == null) {
|
||||
enableAudioOutput = nestedAudioOut;
|
||||
}
|
||||
final nestedStt = features['stt_provider'];
|
||||
if (nestedStt is String && sttProvider == null) {
|
||||
sttProvider = nestedStt;
|
||||
}
|
||||
final nestedTts = features['tts_provider'];
|
||||
if (nestedTts is String && ttsProvider == null) {
|
||||
ttsProvider = nestedTts;
|
||||
}
|
||||
final nestedVoice = features['tts_voice'];
|
||||
if (nestedVoice is String && ttsVoice == null) {
|
||||
ttsVoice = nestedVoice;
|
||||
}
|
||||
final nestedLocale = features['default_stt_locale'];
|
||||
if (nestedLocale is String && defaultSttLocale == null) {
|
||||
defaultSttLocale = nestedLocale;
|
||||
}
|
||||
final nestedSample = features['audio_sample_rate'];
|
||||
if (nestedSample is int && audioSampleRate == null) {
|
||||
audioSampleRate = nestedSample;
|
||||
}
|
||||
final nestedFrame = features['audio_frame_size'];
|
||||
if (nestedFrame is int && audioFrameSize == null) {
|
||||
audioFrameSize = nestedFrame;
|
||||
}
|
||||
final nestedVad = features['vad_enabled'];
|
||||
if (nestedVad is bool && vadEnabled == null) {
|
||||
vadEnabled = nestedVad;
|
||||
}
|
||||
}
|
||||
|
||||
return BackendConfig(enableWebsocket: enableWebsocket);
|
||||
return BackendConfig(
|
||||
enableWebsocket: enableWebsocket,
|
||||
enableAudioInput: enableAudioInput,
|
||||
enableAudioOutput: enableAudioOutput,
|
||||
sttProvider: sttProvider,
|
||||
ttsProvider: ttsProvider,
|
||||
ttsVoice: ttsVoice,
|
||||
defaultSttLocale: defaultSttLocale,
|
||||
audioSampleRate: audioSampleRate,
|
||||
audioFrameSize: audioFrameSize,
|
||||
vadEnabled: vadEnabled,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,17 +521,88 @@ final apiTokenUpdaterProvider = Provider<void>((ref) {
|
||||
@Riverpod(keepAlive: true)
|
||||
Future<User?> currentUser(Ref ref) async {
|
||||
final api = ref.read(apiServiceProvider);
|
||||
final isAuthenticated = ref.watch(isAuthenticatedProvider2);
|
||||
final authState = ref.watch(authStateManagerProvider);
|
||||
final isAuthenticated = authState.maybeWhen(
|
||||
data: (state) => state.isAuthenticated,
|
||||
orElse: () => false,
|
||||
);
|
||||
|
||||
if (api == null || !isAuthenticated) return null;
|
||||
|
||||
// Fast path: use user already in auth state.
|
||||
final authUser = authState.maybeWhen(
|
||||
data: (state) => state.user,
|
||||
orElse: () => null,
|
||||
);
|
||||
if (authUser != null) return authUser;
|
||||
|
||||
// Next: try cached user from storage, then refresh in the background.
|
||||
final storage = ref.read(optimizedStorageServiceProvider);
|
||||
final cachedUser = await _getCachedUserWithAvatar(storage);
|
||||
if (cachedUser != null) {
|
||||
final lastRefresh = ref.read(_lastUserRefreshProvider);
|
||||
final now = DateTime.now();
|
||||
final shouldRefresh =
|
||||
lastRefresh == null ||
|
||||
now.difference(lastRefresh) > const Duration(minutes: 5);
|
||||
|
||||
if (shouldRefresh) {
|
||||
Future.microtask(() async {
|
||||
final fresh = await _refreshCurrentUser(ref);
|
||||
if (fresh != null && ref.mounted) {
|
||||
ref.read(_lastUserRefreshProvider.notifier).set(now);
|
||||
ref.invalidate(currentUserProvider);
|
||||
}
|
||||
});
|
||||
}
|
||||
return cachedUser;
|
||||
}
|
||||
|
||||
// Fallback: fetch fresh.
|
||||
final fresh = await _refreshCurrentUser(ref);
|
||||
if (fresh != null) {
|
||||
ref.read(_lastUserRefreshProvider.notifier).set(DateTime.now());
|
||||
}
|
||||
return fresh;
|
||||
}
|
||||
|
||||
Future<User?> _getCachedUserWithAvatar(OptimizedStorageService storage) async {
|
||||
final cachedUser = await storage.getLocalUser();
|
||||
if (cachedUser == null) return null;
|
||||
final cachedAvatar = await storage.getLocalUserAvatar();
|
||||
if (cachedAvatar == null ||
|
||||
cachedAvatar.isEmpty ||
|
||||
cachedUser.profileImage == cachedAvatar) {
|
||||
return cachedUser;
|
||||
}
|
||||
return cachedUser.copyWith(profileImage: cachedAvatar);
|
||||
}
|
||||
|
||||
Future<User?> _refreshCurrentUser(Ref ref) async {
|
||||
final api = ref.read(apiServiceProvider);
|
||||
if (api == null) return null;
|
||||
|
||||
try {
|
||||
return await api.getCurrentUser();
|
||||
} catch (e) {
|
||||
final user = await api.getCurrentUser();
|
||||
final storage = ref.read(optimizedStorageServiceProvider);
|
||||
await storage.saveLocalUser(user);
|
||||
if (user.profileImage != null && user.profileImage!.isNotEmpty) {
|
||||
await storage.saveLocalUserAvatar(user.profileImage);
|
||||
}
|
||||
return user;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class _LastUserRefresh extends _$LastUserRefresh {
|
||||
@override
|
||||
DateTime? build() => null;
|
||||
|
||||
void set(DateTime? timestamp) => state = timestamp;
|
||||
}
|
||||
|
||||
// Helper provider to force refresh auth state - now using unified system
|
||||
final refreshAuthStateProvider = Provider<void>((ref) {
|
||||
// This provider can be invalidated to force refresh the unified auth system
|
||||
|
||||
@@ -16,7 +16,6 @@ import '../providers/chat_providers.dart';
|
||||
import '../../../core/utils/debug_logger.dart';
|
||||
import '../../../core/utils/user_display_name.dart';
|
||||
import '../../../core/utils/model_icon_utils.dart';
|
||||
import '../../auth/providers/unified_auth_providers.dart';
|
||||
import '../../../core/utils/android_assistant_handler.dart';
|
||||
import '../widgets/modern_chat_input.dart';
|
||||
import '../widgets/user_message_bubble.dart';
|
||||
@@ -1128,8 +1127,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
data: (user) => user,
|
||||
orElse: () => null,
|
||||
);
|
||||
final authUser = ref.watch(currentUserProvider2);
|
||||
final user = userFromProfile ?? authUser;
|
||||
final user = userFromProfile;
|
||||
String? greetingName;
|
||||
if (user != null) {
|
||||
final derived = deriveUserDisplayName(user, fallback: '').trim();
|
||||
|
||||
@@ -17,7 +17,6 @@ import '../../../shared/widgets/themed_dialogs.dart';
|
||||
import 'package:conduit/l10n/app_localizations.dart';
|
||||
import '../../../core/utils/user_display_name.dart';
|
||||
import '../../../core/utils/model_icon_utils.dart';
|
||||
import '../../auth/providers/unified_auth_providers.dart';
|
||||
import '../../../core/utils/user_avatar_utils.dart';
|
||||
import '../../../shared/utils/conversation_context_menu.dart';
|
||||
import '../../../shared/widgets/user_avatar.dart';
|
||||
@@ -1483,8 +1482,7 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
|
||||
data: (u) => u,
|
||||
orElse: () => null,
|
||||
);
|
||||
final authUser = ref.watch(currentUserProvider2);
|
||||
final user = userFromProfile ?? authUser;
|
||||
final user = userFromProfile;
|
||||
final api = ref.watch(apiServiceProvider);
|
||||
|
||||
String initialFor(String name) {
|
||||
|
||||
@@ -8,7 +8,6 @@ import '../../../core/providers/app_providers.dart';
|
||||
import '../../../core/utils/user_display_name.dart';
|
||||
import '../../../shared/theme/theme_extensions.dart';
|
||||
import '../../../shared/widgets/sheet_handle.dart';
|
||||
import '../../auth/providers/unified_auth_providers.dart';
|
||||
|
||||
class OnboardingSheet extends ConsumerStatefulWidget {
|
||||
const OnboardingSheet({super.key});
|
||||
@@ -73,8 +72,7 @@ class _OnboardingSheetState extends ConsumerState<OnboardingSheet> {
|
||||
data: (user) => user,
|
||||
orElse: () => null,
|
||||
);
|
||||
final authUser = ref.watch(currentUserProvider2);
|
||||
final user = userFromProfile ?? authUser;
|
||||
final user = userFromProfile;
|
||||
final greetingName = deriveUserDisplayName(user);
|
||||
final pages = _buildPages(l10n, greetingName);
|
||||
final pageCount = pages.length;
|
||||
|
||||
Reference in New Issue
Block a user