refactor: improve app start time

This commit is contained in:
cogwheel0
2025-09-16 20:10:53 +05:30
parent f80930685c
commit ac12eca6b5
13 changed files with 150 additions and 188 deletions

View File

@@ -99,10 +99,9 @@ class AuthStateManager extends StateNotifier<AuthState> {
if (token != null && token.isNotEmpty) {
DebugLogger.auth('Found stored token during initialization');
// Validate token before setting authenticated state
final isValid = await _validateToken(token);
DebugLogger.auth('Token validation result: $isValid');
if (isValid) {
// Fast path: trust token format to avoid blocking startup on network
final formatOk = _isValidTokenFormat(token);
if (formatOk) {
state = state.copyWith(
status: AuthStatus.authenticated,
token: token,
@@ -110,14 +109,23 @@ class AuthStateManager extends StateNotifier<AuthState> {
clearError: true,
);
// Update API service with token
// Update API service with token and load user data in background
_updateApiServiceToken(token);
// Load user data in background
_loadUserData();
// Background server validation; if it fails, invalidate token gracefully
Future.microtask(() async {
try {
final ok = await _validateToken(token);
DebugLogger.auth('Deferred token validation result: $ok');
if (!ok) {
await onTokenInvalidated();
}
} catch (_) {}
});
} else {
// Token is invalid, clear it
DebugLogger.auth('Token validation failed, deleting token');
// Token format invalid; clear and require login
DebugLogger.auth('Token format invalid, deleting token');
await storage.deleteAuthToken();
state = state.copyWith(
status: AuthStatus.unauthenticated,

View File

@@ -682,137 +682,104 @@ final defaultModelProvider = FutureProvider<Model?>((ref) async {
if (api == null) return null;
try {
// Get all available models first
// Respect manual selection if present
if (ref.read(isManualModelSelectionProvider)) {
final current = ref.read(selectedModelProvider);
if (current != null) return current;
}
// 1) Fast path: read stored default model ID directly and select optimistically
try {
final storedDefaultId = await SettingsService.getDefaultModel();
if (storedDefaultId != null && storedDefaultId.isNotEmpty) {
if (!ref.read(isManualModelSelectionProvider)) {
final placeholder = Model(
id: storedDefaultId,
name: storedDefaultId,
supportsStreaming: true,
);
ref.read(selectedModelProvider.notifier).state = placeholder;
}
// Reconcile against real models in background
Future.microtask(() async {
try {
final models = await ref.read(modelsProvider.future);
Model? resolved;
try {
resolved = models.firstWhere((m) => m.id == storedDefaultId);
} catch (_) {
final byName = models
.where((m) => m.name == storedDefaultId)
.toList();
if (byName.length == 1) resolved = byName.first;
}
resolved ??= models.isNotEmpty ? models.first : null;
if (resolved != null && !ref.read(isManualModelSelectionProvider)) {
ref.read(selectedModelProvider.notifier).state = resolved;
foundation.debugPrint(
'DEBUG: Reconciled default model to ${resolved.name}',
);
}
} catch (_) {}
});
return ref.read(selectedModelProvider);
}
} catch (_) {}
// 2) Fast server path: query server default ID without listing all models
try {
final serverDefault = await api.getDefaultModel();
if (serverDefault != null && serverDefault.isNotEmpty) {
if (!ref.read(isManualModelSelectionProvider)) {
final placeholder = Model(
id: serverDefault,
name: serverDefault,
supportsStreaming: true,
);
ref.read(selectedModelProvider.notifier).state = placeholder;
}
// Reconcile against real models in background
Future.microtask(() async {
try {
final models = await ref.read(modelsProvider.future);
Model? resolved;
try {
resolved = models.firstWhere((m) => m.id == serverDefault);
} catch (_) {
final byName = models
.where((m) => m.name == serverDefault)
.toList();
if (byName.length == 1) resolved = byName.first;
}
resolved ??= models.isNotEmpty ? models.first : null;
if (resolved != null && !ref.read(isManualModelSelectionProvider)) {
ref.read(selectedModelProvider.notifier).state = resolved;
foundation.debugPrint(
'DEBUG: Reconciled server default to ${resolved.name}',
);
}
} catch (_) {}
});
return ref.read(selectedModelProvider);
}
} catch (_) {}
// 3) Fallback: fetch models and pick first available
final models = await ref.read(modelsProvider.future);
if (models.isEmpty) {
foundation.debugPrint('DEBUG: No models available');
return null;
}
Model? selectedModel;
// First check user's preferred default model (ID only). If an older
// name-based value is found, migrate it once to the correct ID.
final userSettings = ref.read(appSettingsProvider);
final userDefaultModelId = userSettings.defaultModel;
if (userDefaultModelId != null && userDefaultModelId.isNotEmpty) {
try {
// Exact ID match only
selectedModel = models.firstWhere(
(model) => model.id == userDefaultModelId,
);
foundation.debugPrint(
'DEBUG: Found user default model by ID: ${selectedModel.name}',
);
} catch (e) {
// Attempt a one-time migration if the stored value was a model name
// from older versions. Only migrate on exact, unique name match.
final nameMatches = models
.where((m) => m.name == userDefaultModelId)
.toList();
if (nameMatches.length == 1) {
selectedModel = nameMatches.first;
foundation.debugPrint(
'DEBUG: Migrating user default model name to ID: '
'${nameMatches.first.name} -> ${nameMatches.first.id}',
);
// Persist the migrated ID
await ref
.read(appSettingsProvider.notifier)
.setDefaultModel(nameMatches.first.id);
} else {
foundation.debugPrint(
'DEBUG: User default model "$userDefaultModelId" not found by ID and '
'no unique name match. Ignoring.',
);
selectedModel =
null; // Will fall back to server default or first model
}
}
}
// If no user default or user default not found, try server's default model
if (selectedModel == null) {
try {
final defaultModelId = await api.getDefaultModel();
if (defaultModelId != null && defaultModelId.isNotEmpty) {
// Find the model that matches the default model ID (ID only)
try {
selectedModel = models.firstWhere(
(model) => model.id == defaultModelId,
);
foundation.debugPrint(
'DEBUG: Found server default model by ID: ${selectedModel.name}',
);
} catch (e) {
// If server returned a name instead of ID, attempt exact name match.
final byName = models
.where((m) => m.name == defaultModelId)
.toList();
if (byName.length == 1) {
selectedModel = byName.first;
foundation.debugPrint(
'DEBUG: Server default "$defaultModelId" matched by name; '
'selected ${selectedModel.name} (${selectedModel.id})',
);
} else {
foundation.debugPrint(
'DEBUG: Server default model "$defaultModelId" not found by ID; '
'falling back to first available',
);
selectedModel = models.first;
}
}
} else {
// No server default, use first available model
selectedModel = models.first;
foundation.debugPrint(
'DEBUG: No server default model, using first available: ${selectedModel.name}',
);
}
} catch (apiError) {
foundation.debugPrint(
'DEBUG: Failed to get default model from server: $apiError',
);
// Use first available model as fallback
selectedModel = models.first;
foundation.debugPrint(
'DEBUG: Using first available model as fallback: ${selectedModel.name}',
);
}
}
// Update selection immediately inside provider context
final selectedModel = models.first;
if (!ref.read(isManualModelSelectionProvider)) {
ref.read(selectedModelProvider.notifier).state = selectedModel;
foundation.debugPrint('DEBUG: Set default model: ${selectedModel.name}');
foundation.debugPrint(
'DEBUG: Set default model (fallback): ${selectedModel.name}',
);
}
return selectedModel;
} catch (e) {
foundation.debugPrint('DEBUG: Error setting default model: $e');
// Final fallback: try to select any available model
try {
final models = await ref.read(modelsProvider.future);
if (models.isNotEmpty) {
final fallbackModel = models.first;
if (!ref.read(isManualModelSelectionProvider)) {
ref.read(selectedModelProvider.notifier).state = fallbackModel;
foundation.debugPrint(
'DEBUG: Fallback to first available model: ${fallbackModel.name}',
);
}
return fallbackModel;
}
} catch (fallbackError) {
foundation.debugPrint(
'DEBUG: Error in fallback model selection: $fallbackError',
);
}
return null;
}
});
@@ -826,7 +793,7 @@ final backgroundModelLoadProvider = Provider<void>((ref) {
// Schedule background loading without blocking
Future.microtask(() async {
// Wait a bit to ensure auth is complete
await Future.delayed(const Duration(milliseconds: 1500));
await Future.delayed(const Duration(milliseconds: 200));
foundation.debugPrint('DEBUG: Starting background model loading');

View File

@@ -279,9 +279,27 @@ class _ChatPageState extends ConsumerState<ChatPage> {
}
void _handleMessageSend(String text, dynamic selectedModel) async {
// Resolve model on-demand if none selected yet
if (selectedModel == null) {
try {
// Prefer already-loaded models
List<Model> models;
final modelsAsync = ref.read(modelsProvider);
if (modelsAsync.hasValue) {
models = modelsAsync.value!;
} else {
models = await ref.read(modelsProvider.future);
}
if (models.isNotEmpty) {
selectedModel = models.first;
ref.read(selectedModelProvider.notifier).state = selectedModel;
}
} catch (_) {
// If models cannot be resolved, bail out without sending
return;
}
if (selectedModel == null) return;
}
final isOnline = ref.read(isOnlineProvider);
final isReviewerMode = ref.read(reviewerModeProvider);
@@ -884,16 +902,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
textAlign: TextAlign.center,
).animate().fadeIn(delay: const Duration(milliseconds: 150)),
const SizedBox(height: Spacing.sm),
Text(
l10n.typeBelowToBegin,
style: theme.textTheme.bodyLarge?.copyWith(
color: context.conduitTheme.textSecondary,
fontWeight: FontWeight.w400,
),
textAlign: TextAlign.center,
).animate().fadeIn(delay: const Duration(milliseconds: 300)),
],
),
),
@@ -945,8 +953,8 @@ class _ChatPageState extends ConsumerState<ChatPage> {
});
}
// Focus composer on app startup once, when a model is selected
if (!_didStartupFocus && selectedModel != null) {
// Focus composer on app startup once
if (!_didStartupFocus) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final current = ref.read(inputFocusTriggerProvider);
@@ -1425,7 +1433,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
},
child: ModernChatInput(
enabled:
selectedModel != null &&
(isOnline || ref.watch(reviewerModeProvider)),
onSendMessage: (text) =>
_handleMessageSend(text, selectedModel),

View File

@@ -34,6 +34,7 @@
"noFilesYet": "Noch keine Dateien",
"uploadDocsPrompt": "Lade Dokumente hoch, um sie in deinen Unterhaltungen mit Conduit zu verwenden",
"uploadFirstFile": "Erste Datei hochladen",
"attachments": "Anhänge",
"knowledgeBaseEmpty": "Wissensdatenbank ist leer",
"createCollectionsPrompt": "Erstelle Sammlungen verwandter Dokumente zur einfachen Referenz",
"chooseSourcePhoto": "Quelle auswählen",
@@ -91,7 +92,7 @@
"next": "Weiter",
"done": "Fertig",
"onboardStartTitle": "Hallo, {username}",
"onboardStartSubtitle": "Wähle ein Modell und tippe los. Tippe jederzeit auf Neuer Chat.",
"onboardStartSubtitle": "Wähle ein Modell, um loszulegen. Tippe jederzeit auf Neuer Chat.",
"onboardStartBullet1": "Modellname oben antippen, um zu wechseln",
"onboardStartBullet2": "Mit Neuer Chat den Kontext zurücksetzen",
"onboardAttachTitle": "Kontext hinzufügen",
@@ -204,7 +205,6 @@
"failedToDeleteFolder": "Ordner konnte nicht gelöscht werden",
"aboutApp": "Über die App",
"aboutAppSubtitle": "Conduit Informationen und Links",
"typeBelowToBegin": "Unten tippen, um zu beginnen",
"web": "Web",
"imageGen": "Bild-Gen",
"pinned": "Angeheftet",

View File

@@ -204,7 +204,7 @@
"@done": {"description": "Onboarding: finish the flow."},
"onboardStartTitle": "Hello, {username}",
"@onboardStartTitle": {"description": "Onboarding card: start chatting title.", "placeholders": {"username": {"type": "String", "example": "Alex"}}},
"onboardStartSubtitle": "Choose a model, then type below to begin. Tap New Chat anytime.",
"onboardStartSubtitle": "Choose a model to get started. Tap New Chat anytime.",
"@onboardStartSubtitle": {"description": "Onboarding card: brief guidance to begin a chat."},
"onboardStartBullet1": "Tap the model name in the top bar to switch models",
"@onboardStartBullet1": {"description": "Bullet: how to switch models."},
@@ -417,8 +417,6 @@
"@aboutApp": {"description": "Settings tile title to view app information."},
"aboutAppSubtitle": "Conduit information and links",
"@aboutAppSubtitle": {"description": "Subtitle/description for the About section."},
"typeBelowToBegin": "Type below to begin",
"@typeBelowToBegin": {"description": "Hint shown in empty chat input area."},
"web": "Web",
"@web": {"description": "Tab/section label for web features."},
"imageGen": "Image Gen",

View File

@@ -34,6 +34,7 @@
"noFilesYet": "Pas encore de fichiers",
"uploadDocsPrompt": "Importez des documents à utiliser dans vos conversations avec Conduit",
"uploadFirstFile": "Importer votre premier fichier",
"attachments": "Pièces jointes",
"knowledgeBaseEmpty": "La base de connaissances est vide",
"createCollectionsPrompt": "Créez des collections de documents liés pour une référence facile",
"chooseSourcePhoto": "Choisir la source",
@@ -91,7 +92,7 @@
"next": "Suivant",
"done": "Terminé",
"onboardStartTitle": "Bonjour, {username}",
"onboardStartSubtitle": "Choisissez un modèle puis commencez à écrire. Touchez Nouveau chat à tout moment.",
"onboardStartSubtitle": "Choisissez un modèle pour commencer. Touchez Nouveau chat à tout moment.",
"onboardStartBullet1": "Touchez le nom du modèle en haut pour changer",
"onboardStartBullet2": "Utilisez Nouveau chat pour réinitialiser le contexte",
"onboardAttachTitle": "Ajouter du contexte",
@@ -204,7 +205,6 @@
"failedToDeleteFolder": "Échec de la suppression du dossier",
"aboutApp": "À propos de lapplication",
"aboutAppSubtitle": "Informations et liens Conduit",
"typeBelowToBegin": "Saisissez cidessous pour commencer",
"web": "Web",
"imageGen": "Gén. image",
"pinned": "Épinglé",

View File

@@ -34,6 +34,7 @@
"noFilesYet": "Ancora nessun file",
"uploadDocsPrompt": "Carica documenti da usare nelle conversazioni con Conduit",
"uploadFirstFile": "Carica il tuo primo file",
"attachments": "Allegati",
"knowledgeBaseEmpty": "La base di conoscenza è vuota",
"createCollectionsPrompt": "Crea raccolte di documenti correlati per un rapido riferimento",
"chooseSourcePhoto": "Scegli origine",
@@ -91,7 +92,7 @@
"next": "Avanti",
"done": "Fatto",
"onboardStartTitle": "Ciao, {username}",
"onboardStartSubtitle": "Scegli un modello e inizia a scrivere. Tocca Nuova chat in qualsiasi momento.",
"onboardStartSubtitle": "Scegli un modello per iniziare. Tocca Nuova chat in qualsiasi momento.",
"onboardStartBullet1": "Tocca il nome del modello in alto per cambiare",
"onboardStartBullet2": "Usa Nuova chat per azzerare il contesto",
"onboardAttachTitle": "Aggiungi contesto",
@@ -204,7 +205,6 @@
"failedToDeleteFolder": "Impossibile eliminare la cartella",
"aboutApp": "Informazioni sullapp",
"aboutAppSubtitle": "Informazioni e link di Conduit",
"typeBelowToBegin": "Scrivi qui sotto per iniziare",
"web": "Web",
"imageGen": "Gen. immagini",
"pinned": "Fissati",

View File

@@ -657,7 +657,7 @@ abstract class AppLocalizations {
/// Onboarding card: brief guidance to begin a chat.
///
/// In en, this message translates to:
/// **'Choose a model, then type below to begin. Tap New Chat anytime.'**
/// **'Choose a model to get started. Tap New Chat anytime.'**
String get onboardStartSubtitle;
/// Bullet: how to switch models.
@@ -1218,12 +1218,6 @@ abstract class AppLocalizations {
/// **'Conduit information and links'**
String get aboutAppSubtitle;
/// Hint shown in empty chat input area.
///
/// In en, this message translates to:
/// **'Type below to begin'**
String get typeBelowToBegin;
/// Tab/section label for web features.
///
/// In en, this message translates to:

View File

@@ -125,7 +125,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get uploadFirstFile => 'Erste Datei hochladen';
@override
String get attachments => 'Attachments';
String get attachments => 'Anhänge';
@override
String get knowledgeBaseEmpty => 'Wissensdatenbank ist leer';
@@ -320,7 +320,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get onboardStartSubtitle =>
'Wähle ein Modell und tippe los. Tippe jederzeit auf Neuer Chat.';
'Wähle ein Modell, um loszulegen. Tippe jederzeit auf Neuer Chat.';
@override
String get onboardStartBullet1 => 'Modellname oben antippen, um zu wechseln';
@@ -621,9 +621,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get aboutAppSubtitle => 'Conduit Informationen und Links';
@override
String get typeBelowToBegin => 'Unten tippen, um zu beginnen';
@override
String get web => 'Web';

View File

@@ -316,7 +316,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get onboardStartSubtitle =>
'Choose a model, then type below to begin. Tap New Chat anytime.';
'Choose a model to get started. Tap New Chat anytime.';
@override
String get onboardStartBullet1 =>
@@ -616,9 +616,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get aboutAppSubtitle => 'Conduit information and links';
@override
String get typeBelowToBegin => 'Type below to begin';
@override
String get web => 'Web';

View File

@@ -124,7 +124,7 @@ class AppLocalizationsFr extends AppLocalizations {
String get uploadFirstFile => 'Importer votre premier fichier';
@override
String get attachments => 'Attachments';
String get attachments => 'Pièces jointes';
@override
String get knowledgeBaseEmpty => 'La base de connaissances est vide';
@@ -321,7 +321,7 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get onboardStartSubtitle =>
'Choisissez un modèle puis commencez à écrire. Touchez Nouveau chat à tout moment.';
'Choisissez un modèle pour commencer. Touchez Nouveau chat à tout moment.';
@override
String get onboardStartBullet1 =>
@@ -626,9 +626,6 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get aboutAppSubtitle => 'Informations et liens Conduit';
@override
String get typeBelowToBegin => 'Saisissez cidessous pour commencer';
@override
String get web => 'Web';

View File

@@ -123,7 +123,7 @@ class AppLocalizationsIt extends AppLocalizations {
String get uploadFirstFile => 'Carica il tuo primo file';
@override
String get attachments => 'Attachments';
String get attachments => 'Allegati';
@override
String get knowledgeBaseEmpty => 'La base di conoscenza è vuota';
@@ -316,7 +316,7 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get onboardStartSubtitle =>
'Scegli un modello e inizia a scrivere. Tocca Nuova chat in qualsiasi momento.';
'Scegli un modello per iniziare. Tocca Nuova chat in qualsiasi momento.';
@override
String get onboardStartBullet1 =>
@@ -619,9 +619,6 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get aboutAppSubtitle => 'Informazioni e link di Conduit';
@override
String get typeBelowToBegin => 'Scrivi qui sotto per iniziare';
@override
String get web => 'Web';

View File

@@ -26,7 +26,9 @@ void main() async {
// Enable edge-to-edge globally (back-compat on pre-Android 15)
// Pairs with Activity's EdgeToEdge.enable and our SafeArea usage.
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
// Do not block first frame on system UI mode; apply shortly after startup
// ignore: discarded_futures
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
final sharedPrefs = await SharedPreferences.getInstance();
const secureStorage = FlutterSecureStorage(
@@ -65,7 +67,8 @@ class _ConduitAppState extends ConsumerState<ConduitApp> {
@override
void initState() {
super.initState();
_initializeAppState();
// Defer heavy provider initialization to after first frame to render UI sooner
WidgetsBinding.instance.addPostFrameCallback((_) => _initializeAppState());
}
Widget _buildInitialLoadingSkeleton(BuildContext context) {
@@ -142,14 +145,11 @@ class _ConduitAppState extends ConsumerState<ConduitApp> {
return MediaQuery(
data: MediaQuery.of(context).copyWith(
// Ensure proper text scaling for edge-to-edge
textScaler: MediaQuery.of(context).textScaler.clamp(
minScaleFactor: 0.8,
maxScaleFactor: 1.3,
),
),
child: OfflineIndicator(
child: child ?? const SizedBox.shrink(),
textScaler: MediaQuery.of(
context,
).textScaler.clamp(minScaleFactor: 0.8, maxScaleFactor: 1.3),
),
child: OfflineIndicator(child: child ?? const SizedBox.shrink()),
);
},
home: _getInitialPageWithReactiveState(),