diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index adaca00..f7dc3cb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,7 @@ jobs: - name: Set Up Flutter uses: subosito/flutter-action@v2 with: - flutter-version: '3.32.5' + flutter-version: '3.35.0' channel: 'stable' #4 Install Dependencies diff --git a/.gitignore b/.gitignore index 7d0676d..f976826 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,7 @@ .svn/ .swiftpm/ migrate_working_dir/ -.AGENTS.md +AGENTS.md # IntelliJ related *.iml diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 5603a2e..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,28 +0,0 @@ -AGENTS GUIDE FOR THIS REPO - -Build, lint, test -- Install: flutter pub get -- Generate code (required for freezed/json): flutter pub run build_runner build --delete-conflicting-outputs -- Analyze (lints): flutter analyze -- Format: dart format . --fix -- Run app: flutter run -d ios | -d android -- Build release: flutter build apk --release; flutter build appbundle --release; flutter build ios --release -- Run all tests: flutter test -- Run single test file: flutter test path/to/test.dart -- Run single test by name: flutter test path/to/test.dart --name "test name substring" - -Code style -- Use Flutter lints (analysis_options.yaml includes package:flutter_lints/flutter.yaml); avoid print, prefer logging/services. Fix analyzer warnings before merging. -- Imports: prefer relative imports within lib/, package:conduit for cross-feature access. Group as: dart:*, package:*, third-party, project; alphabetize within groups; no unused imports. -- Formatting: run dart format .; keep lines readable (< 100–120 cols). No trailing whitespace. Use const where possible. -- Types and null safety: use sound null-safety; avoid dynamic; prefer explicit types for public APIs; use final for immutables. -- Naming: lowerCamelCase for variables/functions, UpperCamelCase for classes/types; file names snake_case.dart; private members with leading _. -- State management: use Riverpod providers in features/* and core/providers; avoid global singletons except services injected via providers. -- Data models: use freezed/json_serializable where applicable; regenerate with build_runner after model changes. -- Error handling: never swallow errors; convert Dio/network/storage errors into domain errors via core/error/* (api_error_handler.dart, user_friendly_error_handler.dart). Surface user-safe messages; log details via services. -- Async/streams: cancel subscriptions; handle connectivity changes via core/services; prefer Future return for async methods. -- UI: keep widgets small and pure; move side effects to controllers/providers; respect theme in shared/theme/*; follow design tokens in shared/theme/app_theme.dart and shared/theme/theme_extensions.dart (Spacing, AppBorderRadius, Elevation, ConduitShadows, AppTypography). - -Repo conventions -- Follow CI versions from .github/workflows/release.yml (Flutter stable 3.32.5, Java 21). Keep pubspec constraints aligned. -- No Cursor/Copilot rule files present. If added later (.cursor/rules or .github/copilot-instructions.md), mirror their guidance here. diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 9042221..a6fcfa5 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -469,6 +469,8 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = X2662V5DT2; ENABLE_BITCODE = NO; @@ -481,6 +483,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = app.cogwheel.conduit; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; @@ -654,6 +657,8 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = X2662V5DT2; ENABLE_BITCODE = NO; @@ -666,6 +671,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = app.cogwheel.conduit; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -679,6 +685,8 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = X2662V5DT2; ENABLE_BITCODE = NO; @@ -691,6 +699,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = app.cogwheel.conduit; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; diff --git a/lib/core/providers/app_providers.dart b/lib/core/providers/app_providers.dart index 3c608fa..6aad60e 100644 --- a/lib/core/providers/app_providers.dart +++ b/lib/core/providers/app_providers.dart @@ -259,6 +259,19 @@ final modelsProvider = FutureProvider>((ref) async { final selectedModelProvider = StateProvider((ref) => null); +// Track if the current model selection is manual (user-selected) or automatic (default) +final isManualModelSelectionProvider = StateProvider((ref) => false); + +// Listen for settings changes and reset manual selection when default model changes +final _settingsWatcherProvider = Provider((ref) { + ref.listen(appSettingsProvider, (previous, next) { + if (previous?.defaultModel != next.defaultModel) { + // Reset manual selection when default model changes + ref.read(isManualModelSelectionProvider.notifier).state = false; + } + }); +}); + // Cache timestamp for conversations to prevent rapid re-fetches final _conversationsCacheTimestamp = StateProvider((ref) => null); @@ -503,16 +516,20 @@ final loadConversationProvider = FutureProvider.family(( // Provider to automatically load and set the default model from user settings or OpenWebUI final defaultModelProvider = FutureProvider((ref) async { + // Initialize the settings watcher + ref.watch(_settingsWatcherProvider); // Watch user settings to refresh when default model changes ref.watch(appSettingsProvider); // Handle reviewer mode first final reviewerMode = ref.watch(reviewerModeProvider); if (reviewerMode) { - // Check if a model is already selected + // Check if a model is manually selected final currentSelected = ref.read(selectedModelProvider); - if (currentSelected != null) { + final isManualSelection = ref.read(isManualModelSelectionProvider); + + if (currentSelected != null && isManualSelection) { foundation.debugPrint( - 'DEBUG: Model already selected in reviewer mode: ${currentSelected.name}', + 'DEBUG: Manual model selected in reviewer mode: ${currentSelected.name}', ); return currentSelected; } @@ -522,7 +539,7 @@ final defaultModelProvider = FutureProvider((ref) async { if (models.isNotEmpty) { final defaultModel = models.first; Future.microtask(() { - if (ref.read(selectedModelProvider) == null) { + if (!ref.read(isManualModelSelectionProvider)) { ref.read(selectedModelProvider.notifier).state = defaultModel; foundation.debugPrint( 'DEBUG: Auto-selected demo model: ${defaultModel.name}', @@ -538,15 +555,6 @@ final defaultModelProvider = FutureProvider((ref) async { if (api == null) return null; try { - // Check if a model is already selected - final currentSelected = ref.read(selectedModelProvider); - if (currentSelected != null) { - foundation.debugPrint( - 'DEBUG: Model already selected: ${currentSelected.name}', - ); - return currentSelected; - } - // Get all available models first final models = await ref.read(modelsProvider.future); if (models.isEmpty) { @@ -554,15 +562,6 @@ final defaultModelProvider = FutureProvider((ref) async { return null; } - // Double-check if a model was selected while we were loading - final checkSelected = ref.read(selectedModelProvider); - if (checkSelected != null) { - foundation.debugPrint( - 'DEBUG: Model was selected during loading: ${checkSelected.name}', - ); - return checkSelected; - } - Model? selectedModel; // First check user's preferred default model @@ -635,8 +634,8 @@ final defaultModelProvider = FutureProvider((ref) async { // Defer the state update to avoid modifying providers during initialization final modelToSet = selectedModel; Future.microtask(() { - // Final check before setting - if (ref.read(selectedModelProvider) == null) { + // Only update if this is not a manual selection + if (!ref.read(isManualModelSelectionProvider)) { ref.read(selectedModelProvider.notifier).state = modelToSet; foundation.debugPrint('DEBUG: Set default model: ${modelToSet.name}'); } @@ -653,7 +652,7 @@ final defaultModelProvider = FutureProvider((ref) async { final fallbackModel = models.first; // Defer the state update Future.microtask(() { - if (ref.read(selectedModelProvider) == null) { + if (!ref.read(isManualModelSelectionProvider)) { ref.read(selectedModelProvider.notifier).state = fallbackModel; foundation.debugPrint( 'DEBUG: Fallback to first available model: ${fallbackModel.name}', diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index 0eb8f66..e2d15dc 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -2530,7 +2530,7 @@ class ApiService { debugPrint('Persistent: Attempting to recover stream $streamId'); // Restart the streaming request _streamSSE(data, streamController, messageId); - }; + } // Declare variables that need to be accessible in catch block String? persistentStreamId; @@ -2929,348 +2929,9 @@ class ApiService { } } - // Enhanced SSE parser that matches OpenWebUI's EventSourceParserStream approach - void _streamChatCompletionEnhanced( - Map data, - StreamController streamController, - String messageId, - ) async { - try { - debugPrint('DEBUG: Making enhanced SSE request to /api/chat/completions'); - final response = await _dio.post( - '/api/chat/completions', - data: data, - options: Options( - responseType: ResponseType.stream, - headers: { - 'Accept': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - }, - receiveTimeout: null, - ), - ); - debugPrint('DEBUG: Enhanced SSE response received, status: ${response.statusCode}'); - if (response.statusCode != 200) { - throw Exception('HTTP ${response.statusCode}: Failed to start streaming'); - } - - // Transform raw stream through SSE parser (like OpenWebUI's pipeline) - final rawStream = response.data.stream as Stream>; - final textStream = StreamController(); - - // Convert bytes to text manually (like TextDecoderStream) - rawStream.listen( - (chunk) { - try { - final text = utf8.decode(chunk); - textStream.add(text); - } catch (e) { - debugPrint('DEBUG: Enhanced SSE decode error: $e'); - } - }, - onDone: () => textStream.close(), - onError: (error) => textStream.addError(error), - ); - - // Apply SSE parsing (like EventSourceParserStream) - textStream.stream - .transform(_createEventSourceTransformer()) // Text → ParsedEvent - .listen( - (event) => _handleSSEEvent(event, streamController), - onDone: () { - debugPrint('DEBUG: Enhanced SSE stream completed'); - streamController.close(); - }, - onError: (error) { - debugPrint('DEBUG: Enhanced SSE stream error: $error'); - streamController.addError(error); - }, - ); - } catch (e) { - debugPrint('DEBUG: Enhanced SSE streaming error: $e'); - streamController.addError(e); - } - } - - // Create a stream transformer that parses SSE events (like EventSourceParserStream) - StreamTransformer> _createEventSourceTransformer() { - String buffer = ''; - - return StreamTransformer>.fromHandlers( - handleData: (chunk, sink) { - buffer += chunk; - final lines = buffer.split('\n'); - buffer = lines.removeLast(); // Keep incomplete line - - String? eventType; - String? data; - String? id; - - for (final line in lines) { - final trimmed = line.trim(); - if (trimmed.isEmpty) { - // Empty line indicates end of event - emit it - if (data != null) { - sink.add({ - 'type': eventType ?? 'message', - 'data': data, - if (id != null) 'id': id, - }); - } - // Reset for next event - eventType = null; - data = null; - id = null; - } else if (trimmed.startsWith('data: ')) { - final eventData = trimmed.substring(6); - data = data == null ? eventData : '$data\n$eventData'; - } else if (trimmed.startsWith('event: ')) { - eventType = trimmed.substring(7); - } else if (trimmed.startsWith('id: ')) { - id = trimmed.substring(4); - } - // Ignore retry: and other fields - } - }, - handleDone: (sink) { - // Handle any remaining data - if (buffer.trim().isNotEmpty) { - sink.add({ - 'type': 'message', - 'data': buffer.trim(), - }); - } - sink.close(); - }, - ); - } - - // Handle individual SSE events (like OpenWebUI's event handler) - void _handleSSEEvent(Map event, StreamController streamController) { - final data = event['data']; - if (data == null) return; - - debugPrint('DEBUG: Enhanced SSE event: ${event['type']}, data: $data'); - - if (data == '[DONE]') { - debugPrint('DEBUG: Enhanced SSE stream finished with [DONE]'); - streamController.close(); - return; - } - - try { - final json = jsonDecode(data) as Map; - - // Handle errors (like OpenWebUI) - if (json.containsKey('error')) { - final error = json['error']; - debugPrint('DEBUG: Enhanced SSE error: $error'); - streamController.addError('Server error: $error'); - return; - } - - // Handle content streaming (like OpenWebUI's choices processing) - if (json.containsKey('choices')) { - final choices = json['choices'] as List?; - if (choices != null && choices.isNotEmpty) { - final choice = choices[0] as Map; - - if (choice.containsKey('delta')) { - final delta = choice['delta'] as Map; - - // Extract content (like OpenWebUI's delta.content) - if (delta.containsKey('content')) { - final content = delta['content'] as String?; - if (content != null && content.isNotEmpty) { - debugPrint('DEBUG: Enhanced SSE content chunk: "$content"'); - streamController.add(content); - } - } - - // Handle tool calls if present - if (delta.containsKey('tool_calls')) { - final toolCalls = delta['tool_calls'] as List?; - if (toolCalls != null && toolCalls.isNotEmpty) { - debugPrint('DEBUG: Enhanced SSE tool calls: $toolCalls'); - // Could emit special events for tool calls if needed - } - } - } - - // Handle finish reason - if (choice.containsKey('finish_reason')) { - final finishReason = choice['finish_reason']; - if (finishReason != null) { - debugPrint('DEBUG: Enhanced SSE finished with reason: $finishReason'); - streamController.close(); - return; - } - } - } - } - - // Handle other event types (sources, usage, etc.) like OpenWebUI - if (json.containsKey('sources')) { - debugPrint('DEBUG: Enhanced SSE sources: ${json['sources']}'); - // Could emit sources events if needed - } - - if (json.containsKey('usage')) { - debugPrint('DEBUG: Enhanced SSE usage: ${json['usage']}'); - // Could emit usage events if needed - } - - } catch (e) { - debugPrint('DEBUG: Enhanced SSE JSON parse error: $e'); - // Continue processing - don't fail the entire stream - } - } - - // Original working SSE streaming implementation - void _streamChatCompletionOriginal( - Map data, - StreamController streamController, - String messageId, - ) async { - try { - debugPrint('DEBUG: Making SSE request to /api/chat/completions'); - - final response = await _dio.post( - '/api/chat/completions', - data: data, - options: Options( - responseType: ResponseType.stream, - headers: { - 'Accept': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - }, - receiveTimeout: null, - ), - ); - - debugPrint('DEBUG: SSE response received, status: ${response.statusCode}'); - debugPrint('DEBUG: SSE response headers: ${response.headers}'); - debugPrint('DEBUG: SSE response content-type: ${response.headers.value('content-type')}'); - - if (response.statusCode != 200) { - throw Exception('HTTP ${response.statusCode}: Failed to start streaming'); - } - - // Process the SSE stream exactly like OpenWebUI frontend - final stream = response.data.stream as Stream>; - String buffer = ''; - - debugPrint('DEBUG: Starting to process SSE stream chunks'); - - await for (final chunk in stream) { - debugPrint('DEBUG: Received SSE chunk of size: ${chunk.length}'); - try { - // Decode chunk to string - final chunkStr = utf8.decode(chunk); - buffer += chunkStr; - - // Process complete lines (SSE format) - final lines = buffer.split('\n'); - buffer = lines.removeLast(); // Keep incomplete line in buffer - - for (final line in lines) { - final trimmedLine = line.trim(); - if (trimmedLine.isEmpty) continue; - - debugPrint('DEBUG: SSE line: $trimmedLine'); - - if (trimmedLine.startsWith('data: ')) { - final jsonStr = trimmedLine.substring(6); // Remove "data: " - - if (jsonStr == '[DONE]') { - debugPrint('DEBUG: SSE stream finished with [DONE]'); - streamController.close(); - return; - } - - try { - final json = jsonDecode(jsonStr) as Map; - debugPrint('DEBUG: SSE JSON: $json'); - - // Process exactly like OpenWebUI - if (json.containsKey('choices')) { - final choices = json['choices'] as List?; - if (choices != null && choices.isNotEmpty) { - final choice = choices[0] as Map; - - if (choice.containsKey('delta')) { - final delta = choice['delta'] as Map; - - // Handle content streaming (word by word) - if (delta.containsKey('content')) { - final content = delta['content'] as String?; - if (content != null && content.isNotEmpty) { - debugPrint('DEBUG: Adding content chunk: "$content"'); - streamController.add(content); - } - } - - // Handle function calls - if (delta.containsKey('tool_calls')) { - final toolCalls = delta['tool_calls'] as List?; - if (toolCalls != null && toolCalls.isNotEmpty) { - debugPrint('DEBUG: Tool calls received: $toolCalls'); - // Handle tool calls if needed - } - } - } - - // Handle finish reason - if (choice.containsKey('finish_reason')) { - final finishReason = choice['finish_reason']; - if (finishReason != null) { - debugPrint('DEBUG: Stream finished with reason: $finishReason'); - streamController.close(); - return; - } - } - } - } else if (json.containsKey('error')) { - // Handle server errors - final error = json['error']; - debugPrint('DEBUG: SSE error: $error'); - streamController.addError('Server error: $error'); - return; - } else { - debugPrint('DEBUG: Unknown SSE JSON format: $json'); - } - } catch (e) { - debugPrint('DEBUG: Error parsing SSE JSON "$jsonStr": $e'); - // Continue processing other lines - } - } else if (trimmedLine.startsWith('event: ') || - trimmedLine.startsWith('id: ') || - trimmedLine.startsWith('retry: ')) { - // Handle other SSE fields (ignore for now) - debugPrint('DEBUG: SSE metadata: $trimmedLine'); - } else { - debugPrint('DEBUG: Unknown SSE line format: $trimmedLine'); - } - } - } catch (e) { - debugPrint('DEBUG: Error processing SSE chunk: $e'); - // Continue processing - } - } - - // Stream ended without [DONE] marker - debugPrint('DEBUG: SSE stream ended unexpectedly'); - streamController.close(); - } catch (e) { - debugPrint('DEBUG: SSE streaming error: $e'); - streamController.addError(e); - } - } // Initialize Socket.IO connection Future _initializeSocket() async { diff --git a/lib/core/services/settings_service.dart b/lib/core/services/settings_service.dart index 5af0b27..7cd3850 100644 --- a/lib/core/services/settings_service.dart +++ b/lib/core/services/settings_service.dart @@ -162,6 +162,11 @@ class SettingsService { } } +/// Sentinel class to detect when defaultModel parameter is not provided +class _DefaultValue { + const _DefaultValue(); +} + /// Data class for app settings class AppSettings { final bool reduceMotion; @@ -189,7 +194,7 @@ class AppSettings { bool? highContrast, bool? largeText, bool? darkMode, - String? defaultModel, + Object? defaultModel = const _DefaultValue(), }) { return AppSettings( reduceMotion: reduceMotion ?? this.reduceMotion, @@ -198,7 +203,7 @@ class AppSettings { highContrast: highContrast ?? this.highContrast, largeText: largeText ?? this.largeText, darkMode: darkMode ?? this.darkMode, - defaultModel: defaultModel ?? this.defaultModel, + defaultModel: defaultModel is _DefaultValue ? this.defaultModel : defaultModel as String?, ); } diff --git a/lib/features/chat/services/voice_input_service.dart b/lib/features/chat/services/voice_input_service.dart index 02b7dad..11f6441 100644 --- a/lib/features/chat/services/voice_input_service.dart +++ b/lib/features/chat/services/voice_input_service.dart @@ -139,7 +139,7 @@ class VoiceInputService { path: filePath, ); // ignore: avoid_print - print('DEBUG: VoiceInputService recording started at: ' + filePath); + print('DEBUG: VoiceInputService recording started at: $filePath'); // Drive intensity from amplitude stream and detect silence // Consider amplitude less than threshold as silence; stop after ~3s of continuous silence @@ -183,7 +183,7 @@ class VoiceInputService { return; } // ignore: avoid_print - print('DEBUG: VoiceInputService recording saved: ' + path); + print('DEBUG: VoiceInputService recording saved: $path'); // Hand off recorded file path to listeners as a special token; UI layer will upload for transcription _textStreamController?.add('[[AUDIO_FILE_PATH]]:$path'); } catch (e) { diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index 19c6b41..5a5d715 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -109,7 +109,7 @@ class _ChatPageState extends ConsumerState { // Try to use the default model provider try { - final model = await ref.read(defaultModelProvider.future); + final Model? model = await ref.read(defaultModelProvider.future); if (model != null) { debugPrint('DEBUG: Model auto-selected via provider: ${model.name}'); } @@ -2170,7 +2170,7 @@ class _VoiceInputSheetState extends ConsumerState<_VoiceInputSheet> { if (text.startsWith('[[AUDIO_FILE_PATH]]:')) { final filePath = text.split(':').skip(1).join(':'); debugPrint( - 'DEBUG: VoiceInputSheet received audio file path: ' + filePath, + 'DEBUG: VoiceInputSheet received audio file path: $filePath', ); _transcribeRecordedFile(filePath); } else { @@ -2237,7 +2237,7 @@ class _VoiceInputSheetState extends ConsumerState<_VoiceInputSheet> { language: language, ); debugPrint( - 'DEBUG: Transcription received: ' + (text.isEmpty ? '[empty]' : text), + 'DEBUG: Transcription received: ${text.isEmpty ? '[empty]' : text}', ); if (!mounted) return; setState(() { diff --git a/lib/features/chat/views/model_selector_page.dart b/lib/features/chat/views/model_selector_page.dart index b95f3d4..22fd517 100644 --- a/lib/features/chat/views/model_selector_page.dart +++ b/lib/features/chat/views/model_selector_page.dart @@ -199,6 +199,7 @@ class _ModelSelectorPageState extends ConsumerState { onTap: () { ref.read(selectedModelProvider.notifier).state = model; + ref.read(isManualModelSelectionProvider.notifier).state = true; Navigator.pop(context); }, ), diff --git a/lib/features/chat/widgets/message_batch_widget.dart b/lib/features/chat/widgets/message_batch_widget.dart index a5631d9..86ca099 100644 --- a/lib/features/chat/widgets/message_batch_widget.dart +++ b/lib/features/chat/widgets/message_batch_widget.dart @@ -839,19 +839,19 @@ class MoreOptionsSheet extends ConsumerWidget { } } - void _showDeleteConfirmation(BuildContext context, WidgetRef ref) { - ThemedDialogs.confirm( + void _showDeleteConfirmation(BuildContext context, WidgetRef ref) async { + final confirmed = await ThemedDialogs.confirm( context, title: 'Delete Messages', message: 'Are you sure you want to delete ${messages.length} message${messages.length == 1 ? '' : 's'}? This action cannot be undone.', confirmText: 'Delete', isDestructive: true, - ).then((confirmed) { - if (confirmed == true) { - _deleteMessages(context, ref); - } - }); + ); + + if (confirmed == true && context.mounted) { + _deleteMessages(context, ref); + } } void _deleteMessages(BuildContext context, WidgetRef ref) async { diff --git a/lib/features/navigation/views/chats_list_page.dart b/lib/features/navigation/views/chats_list_page.dart index 440bca6..3d5dc6f 100644 --- a/lib/features/navigation/views/chats_list_page.dart +++ b/lib/features/navigation/views/chats_list_page.dart @@ -274,16 +274,16 @@ class _ChatsListPageState extends ConsumerState .toList(); // Debug logging - print('🔍 DEBUG: Total conversations: ${uniqueConversations.length} (filtered: ${filteredConversations.length}, original: ${conversations.length})'); - print('🔍 DEBUG: Pinned: ${pinnedConversations.length}'); - print('🔍 DEBUG: Regular: ${regularConversations.length}'); - print('🔍 DEBUG: Folder: ${folderConversations.length}'); - print('🔍 DEBUG: Archived: ${archivedConversations.length}'); + debugPrint('DEBUG: Total conversations: ${uniqueConversations.length} (filtered: ${filteredConversations.length}, original: ${conversations.length})'); + debugPrint('DEBUG: Pinned: ${pinnedConversations.length}'); + debugPrint('DEBUG: Regular: ${regularConversations.length}'); + debugPrint('DEBUG: Folder: ${folderConversations.length}'); + debugPrint('DEBUG: Archived: ${archivedConversations.length}'); // Check first few conversations for folder IDs for (int i = 0; i < uniqueConversations.take(5).length; i++) { final conv = uniqueConversations[i]; - print('🔍 DEBUG: Conv ${i}: id=${conv.id.substring(0, 8)}, folderId=${conv.folderId}, pinned=${conv.pinned}, archived=${conv.archived}'); + debugPrint('DEBUG: Conv $i: id=${conv.id.substring(0, 8)}, folderId=${conv.folderId}, pinned=${conv.pinned}, archived=${conv.archived}'); } return ListView( @@ -336,7 +336,7 @@ class _ChatsListPageState extends ConsumerState }).toList(); }, loading: () => [const SizedBox.shrink()], - error: (_, __) => [const SizedBox.shrink()], + error: (_, stackTrace) => [const SizedBox.shrink()], ), ], @@ -1246,6 +1246,13 @@ class _ChatsListPageState extends ConsumerState } Future _createFolderFromDialog(String name, BuildContext dialogContext) async { + // Store theme values and messenger before async operation + final theme = context.conduitTheme; + final textInverseColor = theme.textInverse; + final successColor = theme.success; + final errorColor = theme.error; + final messenger = ScaffoldMessenger.of(context); + try { final api = ref.read(apiServiceProvider); if (api == null) throw Exception('No API service available'); @@ -1253,31 +1260,33 @@ class _ChatsListPageState extends ConsumerState await api.createFolder(name: name); ref.invalidate(foldersProvider); - if (mounted) { + if (mounted && dialogContext.mounted) { Navigator.pop(dialogContext); - ScaffoldMessenger.of(context).showSnackBar( + } + if (context.mounted) { + messenger.showSnackBar( SnackBar( content: Text( 'Folder "$name" created', style: AppTypography.bodyMediumStyle.copyWith( - color: context.conduitTheme.textInverse, + color: textInverseColor, ), ), - backgroundColor: context.conduitTheme.success, + backgroundColor: successColor, ), ); } } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( + if (context.mounted) { + messenger.showSnackBar( SnackBar( content: Text( 'Failed to create folder: $e', style: AppTypography.bodyMediumStyle.copyWith( - color: context.conduitTheme.textInverse, + color: textInverseColor, ), ), - backgroundColor: context.conduitTheme.error, + backgroundColor: errorColor, ), ); } diff --git a/lib/features/profile/views/profile_page.dart b/lib/features/profile/views/profile_page.dart index 0ad2705..fc73cc9 100644 --- a/lib/features/profile/views/profile_page.dart +++ b/lib/features/profile/views/profile_page.dart @@ -647,8 +647,12 @@ class ProfilePage extends ConsumerWidget { ), ); - if (result is String || result == null) { - await ref.read(appSettingsProvider.notifier).setDefaultModel(result); + // result is non-null only when Save button is pressed + // null means the sheet was dismissed without saving + if (result != null) { + // Handle special case: 'auto-select' should be stored as null + final modelIdToSave = result == 'auto-select' ? null : result; + await ref.read(appSettingsProvider.notifier).setDefaultModel(modelIdToSave); } } @@ -720,7 +724,8 @@ class _DefaultModelBottomSheetState extends ConsumerState<_DefaultModelBottomShe @override void initState() { super.initState(); - _selectedModelId = widget.currentDefaultModelId; + // If no default model is set (null), default to auto-select + _selectedModelId = widget.currentDefaultModelId ?? 'auto-select'; // Add auto-select as first item _filteredModels = [ const Model(id: 'auto-select', name: 'Auto-select'), @@ -812,7 +817,7 @@ class _DefaultModelBottomSheetState extends ConsumerState<_DefaultModelBottomShe ), ), TextButton( - onPressed: () => Navigator.pop(context, _selectedModelId == 'auto-select' ? null : _selectedModelId), + onPressed: () => Navigator.pop(context, _selectedModelId), child: Text( 'Save', style: context.conduitTheme.bodyMedium?.copyWith( diff --git a/pubspec.yaml b/pubspec.yaml index 91b6e6b..87b3def 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: conduit description: Open-source mobile client for Open-WebUI -version: 1.0.2+3 +version: 1.0.1+2 publish_to: 'none' environment: