diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index a439442..11662c3 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -19,7 +19,7 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("com.android.application") version "8.7.0" apply false - id("org.jetbrains.kotlin.android") version "1.8.22" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false } include(":app") diff --git a/devtools_options.yaml b/devtools_options.yaml index fa0b357..4dcfde9 100644 --- a/devtools_options.yaml +++ b/devtools_options.yaml @@ -1,3 +1,4 @@ description: This file stores settings for Dart & Flutter DevTools. documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states extensions: + - shared_preferences: true \ No newline at end of file diff --git a/l10n.yaml b/l10n.yaml index 38be614..5beb99d 100644 --- a/l10n.yaml +++ b/l10n.yaml @@ -3,6 +3,5 @@ template-arb-file: app_en.arb output-dir: lib/l10n/gen preferred-supported-locales: [ en ] untranslated-messages-file: untranslated_messages.json -synthetic-package: false nullable-getter: false format: true diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a43cf3e..50b6ad7 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -3,197 +3,192 @@ "appTitle": "Ollama", "@appTitle": { "description": "Title of the application", - "context": "Visible in the side bar" + "context": "app:sidebar" }, "optionNewChat": "New Chat", "@optionNewChat": { "description": "Text displayed for new chat option", - "context": "Visible in the side bar" + "context": "app:sidebar" }, "optionSettings": "Settings", "@optionSettings": { "description": "Text displayed for settings option", - "context": "Visible in the side bar" + "context": "app:sidebar" }, "optionInstallPwa": "Install Webapp", "@optionInstallPwa": { "description": "Text displayed for install PWA option", - "context": "Visible in the side bar" + "context": "app:sidebar" }, "optionNoChatFound": "No chats found", "@optionNoChatFound": { "description": "Text displayed when no chats are found", - "context": "Visible in the side bar" + "context": "app:sidebar" }, "tipPrefix": "Tip: ", "@tipPrefix": { "description": "Prefix for tips", - "context": "Visible in the sidebar" + "context": "app:sidebar" }, - "tip0": "Edit messages by long taping on them", + "tip0": "Edit messages by long tapping on them", "@tip0": { "description": "First tip displayed in the sidebar", - "context": "Visible in the sidebar" + "context": "app:sidebar" }, "tip1": "Delete messages by double tapping on them", "@tip1": { "description": "Second tip displayed in the sidebar", - "context": "Visible in the sidebar" + "context": "app:sidebar" }, "tip2": "You can change the theme in settings", "@tip2": { "description": "Third tip displayed in the sidebar", - "context": "Visible in the sidebar" + "context": "app:sidebar" }, "tip3": "Select a multimodal model to input images", "@tip3": { "description": "Fourth tip displayed in the sidebar", - "context": "Visible in the sidebar" + "context": "app:sidebar" }, "tip4": "Chats are automatically saved", "@tip4": { "description": "Fifth tip displayed in the sidebar", - "context": "Visible in the sidebar" + "context": "app:sidebar" }, "deleteChat": "Delete", "@deleteChat": { "description": "Text displayed for delete chat option", - "context": "Visible in the chat view, desktop only" + "context": "app:sidebar" }, "renameChat": "Rename", "@renameChat": { "description": "Text displayed for rename chat option", - "context": "Visible in the chat view, desktop only" + "context": "app:sidebar" }, "takeImage": "Take Image", "@takeImage": { "description": "Text displayed for take image button", - "context": "Visible in attachment menu" + "context": "app:chat:attachment" }, "uploadImage": "Upload Image", "@uploadImage": { "description": "Text displayed for image upload button", - "context": "Visible in attachment menu" + "context": "app:chat:attachment" }, "notAValidImage": "Not a valid image", "@notAValidImage": { "description": "Text displayed when an image is not valid", - "context": "Visible in the chat view" + "context": "app:chat:attachment" }, "imageOnlyConversation": "Image Only Conversation", "@imageOnlyConversation": { "description": "Title, if 'Generate Title' is executed on a conversation with no text messages", - "context": "Visible in the chat view" + "context": "app:chat:title" }, "messageInputPlaceholder": "Message", "@messageInputPlaceholder": { "description": "Placeholder text for message input", - "context": "Visible in the chat view" + "context": "app:chat:input" }, "tooltipAttachment": "Add attachment", "@tooltipAttachment": { "description": "Tooltip for attachment button", - "context": "Visible in the chat view" + "context": "app:chat:tooltips" }, "tooltipSend": "Send", "@tooltipSend": { "description": "Tooltip for send button", - "context": "Visible in the chat view" + "context": "app:chat:tooltips" }, "tooltipSave": "Save", "@tooltipSave": { "description": "Tooltip for save button", - "context": "Visible in the chat view" + "context": "app:chat:tooltips" }, "tooltipLetAIThink": "Let AI think", "@tooltipLetAIThink": { "description": "Tooltip for let AI think button", - "context": "Visible in the chat view" - }, - "tooltipAddHostHeaders": "Add host headers", - "@tooltipAddHostHeaders": { - "description": "Tooltip for add host headers button", - "context": "Visible in settings view" + "context": "app:chat:tooltips" }, "tooltipReset": "Reset current chat", "@tooltipReset": { "description": "Tooltip for reset button", - "context": "Visible in the chat view" + "context": "app:chat:tooltips" }, "tooltipOptions": "Show options", "@tooltipOptions": { "description": "Tooltip for options button", - "context": "Visible in the chat view" + "context": "app:chat:tooltips" }, "noModelSelected": "No model selected", "@noModelSelected": { "description": "Text displayed when no model is selected", - "context": "Visible in the chat view" + "context": "app:chat" }, - "noHostSelected": "No host selected, open setting to set one", + "noHostSelected": "No host selected, open settings to set one", "@noHostSelected": { "description": "Text displayed when no host is selected", - "context": "Visible in the chat view, opens the settings dialog when clicked" + "context": "app:chat" }, "noSelectedModel": "", "@noSelectedModel": { "description": "Text displayed when no model is selected", - "context": "Visible in the chat view, opens the model dialog when clicked" + "context": "app:chat" }, "newChatTitle": "Unnamed Chat", "@newChatTitle": { "description": "Title of a new chat", - "context": "Visible in the new chat dialog" + "context": "app:chat:title" }, "modelDialogAddModel": "Add", "@modelDialogAddModel": { "description": "Text displayed for add model button", - "context": "Visible in the model dialog" + "context": "app:modelDialog" }, "modelDialogAddPromptTitle": "Add new model", "@modelDialogAddPromptTitle": { "description": "Title of the add model dialog", - "context": "Visible in the model dialog" + "context": "app:modelDialog" }, - "modelDialogAddPromptDescription": "This can have either be a normal name (e.g. 'llama3') or name and tag (e.g. 'llama3:70b').", + "modelDialogAddPromptDescription": "This can either be a normal name (e.g. 'llama3') or a name and tag (e.g. 'llama3:70b').", "@modelDialogAddPromptDescription": { "description": "Description of the add model dialog", - "context": "Visible in the model dialog" + "context": "app:modelDialog" }, "modelDialogAddPromptAlreadyExists": "Model already exists", "@modelDialogAddPromptAlreadyExists": { "description": "Text displayed when the model already exists", - "context": "Visible in the model dialog" + "context": "app:modelDialog" }, "modelDialogAddPromptInvalid": "Invalid model name", "@modelDialogAddPromptInvalid": { "description": "Text displayed when the model name is invalid", - "context": "Visible in the model dialog" + "context": "app:modelDialog" }, "modelDialogAddAllowanceTitle": "Allow Proxy", "@modelDialogAddAllowanceTitle": { "description": "Title of the allow proxy dialog", - "context": "Visible in the model dialog" + "context": "app:modelDialog" }, - "modelDialogAddAllowanceDescription": "Ollama App must check if the entered model is valid. For that, we normally send a web request to the Ollama model list and check the status code, but because you're using the web client, we can't do that directly. Instead, the app will send the request to a different api, hosted by JHubi1, to check for us.\nThis is a one-time request and will only be sent when you add a new model.\nYour IP address will be sent with the request and might be stored for up to ten minutes to prevent spamming with potential harmful intentions.\nIf you accept, your selection will be remembered in the future; if not, nothing will be sent and the model won't be added.", + "modelDialogAddAllowanceDescription": "Ollama App must check if the entered model is valid. To do that, we normally send a web request to the Ollama model list and check the status code, but because you're using the web client, we can't do that directly. Instead, the app will send the request to a different API, hosted by JHubi1, to check for us.\nThis is a one-time request and will only be sent when you add a new model.\nYour IP address will be sent with the request and might be stored for up to ten minutes to prevent spamming with potentially harmful intentions.\nIf you accept, your selection will be remembered for the future; if not, nothing will be sent and the model won't be added.", "@modelDialogAddAllowanceDescription": { "description": "Description of the allow proxy dialog", - "context": "Visible in the model dialog" + "context": "app:modelDialog" }, "modelDialogAddAllowanceAllow": "Allow", "@modelDialogAddAllowanceAllow": { "description": "Text displayed for allow button, should be capitalized", - "context": "Visible in the model dialog" + "context": "app:modelDialog" }, "modelDialogAddAllowanceDeny": "Deny", "@modelDialogAddAllowanceDeny": { "description": "Text displayed for deny button, should be capitalized", - "context": "Visible in the model dialog" + "context": "app:modelDialog" }, "modelDialogAddAssuranceTitle": "Add {model}?", "@modelDialogAddAssuranceTitle": { "description": "Title of the add model assurance dialog", - "context": "Visible in the model dialog", + "context": "app:modelDialog", "placeholders": { "model": { "type": "String", @@ -201,10 +196,10 @@ } } }, - "modelDialogAddAssuranceDescription": "Pressing 'Add' will download the model '{model}' directly from the Ollama server to your host.\nThis can take a while depending on your internet connection. The action cannot be canceled.\nIf the app is closed during the download, it'll resume if you enter the name into the model dialog again.", + "modelDialogAddAssuranceDescription": "Pressing 'Add' will download the model '{model}' directly from the Ollama server to your host.\nThis can take a while depending on your internet connection. The action cannot be canceled.\nIf the app is closed during the download, it will resume if you enter the name into the model dialog again.", "@modelDialogAddAssuranceDescription": { "description": "Description of the add model assurance dialog", - "context": "Visible in the model dialog", + "context": "app:modelDialog", "placeholders": { "model": { "type": "String", @@ -215,22 +210,22 @@ "modelDialogAddAssuranceAdd": "Add", "@modelDialogAddAssuranceAdd": { "description": "Text displayed for add button, should be capitalized", - "context": "Visible in the model dialog" + "context": "app:modelDialog" }, "modelDialogAddAssuranceCancel": "Cancel", "@modelDialogAddAssuranceCancel": { "description": "Text displayed for cancel button, should be capitalized", - "context": "Visible in the model dialog" + "context": "app:modelDialog" }, "modelDialogAddDownloadPercentLoading": "loading progress", "@modelDialogAddDownloadPercentLoading": { "description": "Text displayed while loading the download progress", - "context": "Visible in the model dialog; 'loading progress in the moment'" + "context": "app:modelDialog:download" }, "modelDialogAddDownloadPercent": "download at {percent}%", "@modelDialogAddDownloadPercent": { "description": "Text displayed while downloading a model", - "context": "Visible in the model dialog; download is at x percent", + "context": "app:modelDialog:download", "placeholders": { "percent": { "type": "String", @@ -241,162 +236,187 @@ "modelDialogAddDownloadFailed": "Disconnected, try again", "@modelDialogAddDownloadFailed": { "description": "Text displayed when the download of a model fails", - "context": "Visible in the model dialog" + "context": "app:modelDialog:download" }, "modelDialogAddDownloadSuccess": "Download successful", "@modelDialogAddDownloadSuccess": { "description": "Text displayed when the download of a model is successful", - "context": "Visible in the model dialog" + "context": "app:modelDialog:download" }, "deleteDialogTitle": "Delete Chat", "@deleteDialogTitle": { "description": "Title of the delete dialog", - "context": "Visible in the delete dialog" + "context": "app:deleteDialog" }, "deleteDialogDescription": "Are you sure you want to continue? This will wipe all memory of this chat and cannot be undone.\nTo disable this dialog, visit the settings.", "@deleteDialogDescription": { "description": "Description of the delete dialog", - "context": "Visible in the delete dialog" + "context": "app:deleteDialog" }, "deleteDialogDelete": "Delete", "@deleteDialogDelete": { "description": "Text displayed for delete button, should be capitalized", - "context": "Visible in the delete dialog" + "context": "app:deleteDialog" }, "deleteDialogCancel": "Cancel", "@deleteDialogCancel": { "description": "Text displayed for cancel button, should be capitalized", - "context": "Visible in the delete dialog" + "context": "app:deleteDialog" + }, + "errorGuardTitle": "ErrorGuard", + "@errorGuardTitle": { + "description": "Title of the error guard dialog. Do not translate if not required!", + "context": "app:errorGuard" + }, + "errorGuardDetails": "Details", + "@errorGuardDetails": { + "description": "Text displayed for details button and the details section in the error guard dialog", + "context": "app:errorGuard" + }, + "errorGuardException": "Exception", + "@errorGuardException": { + "description": "Text displayed for exception section in the error guard dialog. Reference: https://en.wikipedia.org/wiki/Exception_handling", + "context": "app:errorGuard" + }, + "errorGuardStackTrace": "Stack Trace", + "@errorGuardStackTrace": { + "description": "Text displayed for stack trace section in the error guard dialog. Use the commonly agreed on spelling in your language, e.g. 'Stacktrace' in German; use Wikipedia as reference: https://en.wikipedia.org/wiki/Stack_trace", + "context": "app:errorGuard" + }, + "errorGuardReport": "Report", + "@errorGuardReport": { + "description": "Text displayed for report button in the error guard dialog", + "context": "app:errorGuard" }, "dialogEnterNewTitle": "Enter new title", "@dialogEnterNewTitle": { "description": "Text displayed as description for new title input", - "context": "Visible in the rename dialog" + "context": "app:renameDialog" }, "dialogEditMessageTitle": "Edit message", "@dialogEditMessageTitle": { "description": "Title of the edit message dialog", - "context": "Visible in the edit message dialog" + "context": "app:editMessageDialog" }, "settingsTitleBehavior": "Behavior", "@settingsTitleBehavior": { "description": "Title of the behavior settings section", - "context": "Visible in the settings view" + "context": "app:settings" }, "settingsDescriptionBehavior": "Change the behavior of the AI to your liking.", "@settingsDescriptionBehavior": { "description": "Description of the behavior settings section", - "context": "Visible in the settings view" + "context": "app:settings" }, "settingsTitleInterface": "Interface", "@settingsTitleInterface": { "description": "Title of the interface settings section", - "context": "Visible in the settings view" + "context": "app:settings" }, "settingsDescriptionInterface": "Edit how Ollama App looks and behaves.", "@settingsDescriptionInterface": { "description": "Description of the interface settings section", - "context": "Visible in the settings view" + "context": "app:settings" }, "settingsTitleVoice": "Voice", "@settingsTitleVoice": { "description": "Title of the voice settings section. Do not translate if not required!", - "context": "Visible in the settings view" + "context": "app:settings" }, "settingsDescriptionVoice": "Enable voice mode and configure voice settings.", "@settingsDescriptionVoice": { "description": "Description of the voice settings section", - "context": "Visible in the settings view" + "context": "app:settings" }, "settingsTitleExport": "Export", "@settingsTitleExport": { "description": "Title of the export settings section", - "context": "Visible in the settings view" + "context": "app:settings" }, "settingsDescriptionExport": "Export and import your chat history.", "@settingsDescriptionExport": { "description": "Description of the export settings section", - "context": "Visible in the settings view" + "context": "app:settings" }, "settingsTitleAbout": "About", "@settingsTitleAbout": { "description": "Title of the about settings section", - "context": "Visible in the settings view" + "context": "app:settings" }, "settingsDescriptionAbout": "Check for updates and learn more about Ollama App.", "@settingsDescriptionAbout": { "description": "Description of the about settings section", - "context": "Visible in the settings view" + "context": "app:settings" }, "settingsSavedAutomatically": "Settings are saved automatically", "@settingsSavedAutomatically": { "description": "Text displayed when settings are saved automatically", - "context": "Visible in the settings view" + "context": "app:settings" }, "settingsExperimentalAlpha": "alpha", "@settingsExperimentalAlpha": { - "description": "Text displayed when a feature is in alpha", - "context": "Visible in the settings view" + "description": "Text displayed when a feature is in alpha phase", + "context": "app:settings" }, "settingsExperimentalAlphaDescription": "This feature is in alpha and may not work as intended or expected.\nCritical issues and/or permanent critical damage to device and/or used services cannot be ruled out.\nUse at your own risk. No liability on the part of the app author.", "@settingsExperimentalAlphaDescription": { "description": "Description of the alpha feature", - "context": "Visible in the settings view" + "context": "app:settings" }, "settingsExperimentalAlphaFeature": "Alpha feature, hold to learn more", "@settingsExperimentalAlphaFeature": { "description": "Text displayed when a feature is in alpha", - "context": "Visible in the settings view" + "context": "app:settings" }, "settingsExperimentalBeta": "beta", "@settingsExperimentalBeta": { "description": "Text displayed when a feature is in beta", - "context": "Visible in the settings view" + "context": "app:settings" }, - "settingsExperimentalBetaDescription": "This feature is in beta and may not work intended or expected.\nLess severe issues may or may not occur. Damage shouldn't be critical.\nUse at your own risk.", + "settingsExperimentalBetaDescription": "This feature is in beta and may not work as intended or expected.\nLess severe issues may or may not occur. Damage shouldn't be critical.\nUse at your own risk.", "@settingsExperimentalBetaDescription": { "description": "Description of the beta feature", - "context": "Visible in the settings view" + "context": "app:settings" }, "settingsExperimentalBetaFeature": "Beta feature, hold to learn more", "@settingsExperimentalBetaFeature": { "description": "Text displayed when a feature is in beta", - "context": "Visible in the settings view" + "context": "app:settings" }, "settingsExperimentalDeprecated": "deprecated", "@settingsExperimentalDeprecated": { "description": "Text displayed when a feature is deprecated", - "context": "Visible in the settings view" + "context": "app:settings" }, "settingsExperimentalDeprecatedDescription": "This feature is deprecated and will be removed in a future version.\nIt may not work as intended or expected. Use at your own risk.", "@settingsExperimentalDeprecatedDescription": { "description": "Description of the deprecated feature", - "context": "Visible in the settings view" + "context": "app:settings" }, "settingsExperimentalDeprecatedFeature": "Deprecated feature, hold to learn more", "@settingsExperimentalDeprecatedFeature": { "description": "Text displayed when a feature is deprecated", - "context": "Visible in the settings view" + "context": "app:settings" }, "settingsHost": "Host", "@settingsHost": { "description": "Text displayed as description for host input", - "context": "Visible in the settings view" + "context": "app:settings:host" }, "settingsHostValid": "Valid Host", "@settingsHostValid": { "description": "Text displayed when the host is valid", - "context": "Visible in the settings view" + "context": "app:settings:host" }, "settingsHostChecking": "Checking Host", "@settingsHostChecking": { "description": "Text displayed when the host is being checked", - "context": "Visible in the settings view" + "context": "app:settings:host" }, - "settingsHostInvalid": "Issue: {type, select, url{Invalid URL} host{Invalid Host} timeout{Request Failed. Server issues} ratelimit{Too many requests} other{Request Failed}}", + "settingsHostInvalid": "Issue: {type, select, url{Invalid URL} host{Invalid Host} timeout{Request failed. Server issues} ratelimit{Too many requests} other{Request Failed}}", "@settingsHostInvalid": { "description": "Text displayed when the host is invalid", - "context": "Visible in the settings view", + "context": "app:settings:host", "placeholders": { "type": { "type": "String", @@ -404,20 +424,25 @@ } } }, + "tooltipAddHostHeaders": "Add host headers", + "@tooltipAddHostHeaders": { + "description": "Tooltip for add host headers button", + "context": "app:settings:host" + }, "settingsHostHeaderTitle": "Set host header", "@settingsHostHeaderTitle": { "description": "Text displayed as description for host header input", - "context": "Visible in the settings view" + "context": "app:settings:host" }, "settingsHostHeaderInvalid": "The entered text isn't a valid header JSON object", "@settingsHostHeaderInvalid": { "description": "Text displayed when the host header is invalid", - "context": "Visible in the settings view" + "context": "app:settings:host" }, - "settingsHostInvalidDetailed": "{type, select, url{The URL you entered is invalid. It isn't an a standardized URL format.} other{The host you entered is invalid. It cannot be reached. Please check the host and try again.}}", + "settingsHostInvalidDetailed": "{type, select, url{The URL you entered is invalid. It isn't in a standardized URL format.} other{The host you entered is invalid. It cannot be reached. Please check the host and try again.}}", "@settingsHostInvalidDetailed": { "description": "Text displayed when the host is invalid", - "context": "Visible in the settings view", + "context": "app:settings:host", "placeholders": { "type": { "type": "String", @@ -428,92 +453,92 @@ "settingsSystemMessage": "System message", "@settingsSystemMessage": { "description": "Text displayed as description for system message input", - "context": "Visible in the settings view" + "context": "app:settings:behavior" }, "settingsUseSystem": "Use system message", "@settingsUseSystem": { "description": "Text displayed as description for use system message toggle", - "context": "Visible in the settings view" + "context": "app:settings:behavior" }, - "settingsUseSystemDescription": "Disables setting the system message above and use the one of the model instead. Can be useful for models with model files", + "settingsUseSystemDescription": "Disables setting the system message above and uses the one from the model's Modelfile instead. Can be useful for models with model files.", "@settingsUseSystemDescription": { "description": "Description of the use system message toggle", - "context": "Visible in the settings view by long pressing the toggle" + "context": "app:settings:behavior" }, "settingsDisableMarkdown": "Disable markdown", "@settingsDisableMarkdown": { "description": "Text displayed as description for disable markdown toggle", - "context": "Visible in the settings view" + "context": "app:settings:behavior" }, "settingsBehaviorNotUpdatedForOlderChats": "Behavior settings are not updated for older chats", "@settingsBehaviorNotUpdatedForOlderChats": { "description": "Text displayed when behavior settings are not updated for older chats", - "context": "Visible in the settings view" + "context": "app:settings:behavior" }, "settingsShowModelTags": "Show model tags", "@settingsShowModelTags": { "description": "Text displayed as description for show model tags toggle", - "context": "Visible in the settings view" + "context": "app:settings:interface" }, "settingsPreloadModels": "Preload models", "@settingsPreloadModels": { "description": "Text displayed as description for preload models toggle", - "context": "Visible in the settings view" + "context": "app:settings:interface" }, "settingsResetOnModelChange": "Reset on model change", "@settingsResetOnModelChange": { "description": "Text displayed as description for reset on model change toggle", - "context": "Visible in the settings view" + "context": "app:settings:interface" }, "settingsRequestTypeStream": "Stream", "@settingsRequestTypeStream": { "description": "Text displayed as description for stream request type. Do not translate if not required!", - "context": "Visible in the settings view" + "context": "app:settings:interface:request" }, "settingsRequestTypeRequest": "Request", "@settingsRequestTypeRequest": { "description": "Text displayed as description for request request type. Do not translate if not required!", - "context": "Visible in the settings view" + "context": "app:settings:interface:request" }, "settingsGenerateTitles": "Generate titles", "@settingsGenerateTitles": { "description": "Text displayed as description for generate titles toggle", - "context": "Visible in the settings view" + "context": "app:settings:interface" }, "settingsEnableEditing": "Message editing", "@settingsEnableEditing": { "description": "Text displayed as description for enable editing toggle", - "context": "Visible in the settings view" + "context": "app:settings:interface" }, "settingsAskBeforeDelete": "Ask before chat deletion", "@settingsAskBeforeDelete": { "description": "Text displayed as description for ask before deletion toggle", - "context": "Visible in the settings view" + "context": "app:settings:interface" }, "settingsShowTips": "Show tips in sidebar", "@settingsShowTips": { "description": "Text displayed as description for show tips toggle", - "context": "Visible in the settings view" + "context": "app:settings:interface" }, "settingsKeepModelLoadedAlways": "Keep model always loaded", "@settingsKeepModelLoadedAlways": { "description": "Text displayed as description for keep model loaded always toggle", - "context": "Visible in the settings view" + "context": "app:settings:interface:keepModelLoaded" }, "settingsKeepModelLoadedNever": "Don't keep model loaded", "@settingsKeepModelLoadedNever": { "description": "Text displayed as description for don't keep model loaded toggle", - "context": "Visible in the settings view" + "context": "app:settings:interface:keepModelLoaded" }, "settingsKeepModelLoadedFor": "Set specific time to keep model loaded", "@settingsKeepModelLoadedFor": { "description": "Text displayed as description for keep model loaded for toggle", - "context": "Visible in the settings view" + "context": "app:settings:interface:keepModelLoaded" }, "settingsKeepModelLoadedSet": "Keep model loaded for {minutes} minutes", "@settingsKeepModelLoadedSet": { "description": "Text displayed as description for keep model loaded for set time toggle", - "context": "Visible in the settings view", + "context": "app:settings:interface:keepModelLoaded", "placeholders": { "minutes": { "type": "String", @@ -524,192 +549,192 @@ "settingsTimeoutMultiplier": "Timeout multiplier", "@settingsTimeoutMultiplier": { "description": "Text displayed as title for the timeout multiplier section", - "context": "Visible in the settings view" + "context": "app:settings:interface:timeout" }, "settingsTimeoutMultiplierDescription": "Select the multiplier that is applied to every timeout value in the app. Can be useful with a slow internet connection or a slow host.", "@settingsTimeoutMultiplierDescription": { "description": "Description of the timeout multiplier section", - "context": "Visible in the settings view" + "context": "app:settings:interface:timeout" }, "settingsTimeoutMultiplierExample": "E.g. message timeout:", "@settingsTimeoutMultiplierExample": { "description": "Example for the timeout multiplier", - "context": "Visible in the settings view" + "context": "app:settings:interface:timeout" }, "settingsEnableHapticFeedback": "Enable haptic feedback", "@settingsEnableHapticFeedback": { "description": "Text displayed as description for enable haptic feedback toggle", - "context": "Visible in the settings view" + "context": "app:settings:interface" }, "settingsMaximizeOnStart": "Start maximized", "@settingsMaximizeOnStart": { "description": "Text displayed as description for maximize on start toggle", - "context": "Visible in the settings view" + "context": "app:settings:interface" }, "settingsBrightnessSystem": "System", "@settingsBrightnessSystem": { "description": "Text displayed as description for system brightness option", - "context": "Visible in the settings view" + "context": "app:settings:interface:brightness" }, "settingsBrightnessLight": "Light", "@settingsBrightnessLight": { "description": "Text displayed as description for light brightness option", - "context": "Visible in the settings view" + "context": "app:settings:interface:brightness" }, "settingsBrightnessDark": "Dark", "@settingsBrightnessDark": { "description": "Text displayed as description for dark brightness option", - "context": "Visible in the settings view" + "context": "app:settings:interface:brightness" }, "settingsThemeDevice": "Device", "@settingsThemeDevice": { "description": "Text displayed as description for device theme option", - "context": "Visible in the settings view" + "context": "app:settings:interface:theme" }, "settingsThemeOllama": "Ollama", "@settingsThemeOllama": { "description": "Text displayed as description for Ollama theme option", - "context": "Visible in the settings view" + "context": "app:settings:interface:theme" }, "settingsTemporaryFixes": "Temporary interface fixes", "@settingsTemporaryFixes": { "description": "Text displayed as description for temporary fixes section", - "context": "Visible in the settings view" + "context": "app:settings:interface:temporaryFixes" }, "settingsTemporaryFixesDescription": "Enable temporary fixes for interface issues.\nLong press on the individual options to learn more.", "@settingsTemporaryFixesDescription": { "description": "Description of the temporary fixes section", - "context": "Visible in the settings view" + "context": "app:settings:interface:temporaryFixes" }, "settingsTemporaryFixesInstructions": "Do not toggle any of these settings unless you know what you are doing! The given solutions might not work as expected.\nThey cannot be seen as final or should be judged as such. Issues might occur.", "@settingsTemporaryFixesInstructions": { "description": "Instructions and warnings for the temporary fixes", - "context": "Visible in the settings view" + "context": "app:settings:interface:temporaryFixes" }, "settingsTemporaryFixesNoFixes": "No fixes available", "@settingsTemporaryFixesNoFixes": { "description": "Text displayed when no fixes are available", - "context": "Visible in the settings view" + "context": "app:settings:interface:temporaryFixes" }, "settingsVoicePermissionLoading": "Loading voice permissions ...", "@settingsVoicePermissionLoading": { "description": "Text displayed while loading voice permissions", - "context": "Visible in the settings view" + "context": "app:settings:voice" }, "settingsVoiceTtsNotSupported": "Text-to-speech not supported", "@settingsVoiceTtsNotSupported": { "description": "Text displayed when text-to-speech is not supported", - "context": "Visible in the settings view" + "context": "app:settings:voice" }, - "settingsVoiceTtsNotSupportedDescription": "Text-to-speech services are not supported for the selected language. Select a different language in the language drawer to reenable them.\nOther services like voice recognition and AI thinking will still work as usual, but interaction might not be as fluent.", + "settingsVoiceTtsNotSupportedDescription": "Text-to-speech services are not supported for the selected language. Select a different language in the language drawer to re-enable them.\nOther services like voice recognition and AI thinking will still work as usual, but interaction might not be as fluent.", "@settingsVoiceTtsNotSupportedDescription": { "description": "Description of the text-to-speech not supported message", - "context": "Visible in the settings view" + "context": "app:settings:voice" }, "settingsVoicePermissionNot": "Permissions not granted", "@settingsVoicePermissionNot": { "description": "Text displayed when voice permissions are not granted", - "context": "Visible in the settings view" + "context": "app:settings:voice" }, "settingsVoiceNotEnabled": "Voice mode not enabled", "@settingsVoiceNotEnabled": { "description": "Text displayed when voice mode is not enabled", - "context": "Visible in the settings view" + "context": "app:settings:voice" }, "settingsVoiceNotSupported": "Voice mode not supported", "@settingsVoiceNotSupported": { "description": "Text displayed when voice mode is not supported", - "context": "Visible in the settings view" + "context": "app:settings:voice" }, "settingsVoiceEnable": "Enable voice mode", "@settingsVoiceEnable": { "description": "Text displayed as description for enable voice mode toggle", - "context": "Visible in the settings view" + "context": "app:settings:voice" }, "settingsVoiceNoLanguage": "No language selected", "@settingsVoiceNoLanguage": { "description": "Text displayed when no language is selected", - "context": "Visible in the settings view" + "context": "app:settings:voice" }, "settingsVoiceLimitLanguage": "Limit to selected language", "@settingsVoiceLimitLanguage": { "description": "Text displayed as description for limit language toggle", - "context": "Visible in the settings view" + "context": "app:settings:voice" }, "settingsVoicePunctuation": "Enable AI punctuation", "@settingsVoicePunctuation": { "description": "Text displayed as description for enable AI punctuation toggle", - "context": "Visible in the settings view" + "context": "app:settings:voice" }, "settingsExportChats": "Export chats", "@settingsExportChats": { "description": "Text displayed as description for export chats button", - "context": "Visible in the settings view" + "context": "app:settings:export" }, "settingsExportChatsSuccess": "Chats exported successfully", "@settingsExportChatsSuccess": { "description": "Text displayed when chats are exported successfully", - "context": "Visible in the settings view" + "context": "app:settings:export" }, "settingsImportChats": "Import chats", "@settingsImportChats": { "description": "Text displayed as description for import chats button", - "context": "Visible in the settings view" + "context": "app:settings:export" }, "settingsImportChatsTitle": "Import", "@settingsImportChatsTitle": { "description": "Title of the import dialog", - "context": "Visible in the settings view" + "context": "app:settings:export" }, "settingsImportChatsDescription": "The following step will import the chats from the selected file. This will overwrite all currently available chats.\nDo you want to continue?", "@settingsImportChatsDescription": { "description": "Description of the import dialog", - "context": "Visible in the settings view" + "context": "app:settings:export" }, "settingsImportChatsImport": "Import and Erase", "@settingsImportChatsImport": { "description": "Text displayed for import button, should be capitalized", - "context": "Visible in the settings view" + "context": "app:settings:export" }, "settingsImportChatsCancel": "Cancel", "@settingsImportChatsCancel": { "description": "Text displayed for cancel button, should be capitalized", - "context": "Visible in the settings view" + "context": "app:settings:export" }, "settingsImportChatsSuccess": "Chats imported successfully", "@settingsImportChatsSuccess": { "description": "Text displayed when chats are imported successfully", - "context": "Visible in the settings view" + "context": "app:settings:export" }, - "settingsExportInfo": "This options allows you to export and import your chat history. This can be useful if you want to transfer your chat history to another device or backup your chat history", + "settingsExportInfo": "These options allow you to export and import your chat history. This can be useful if you want to transfer your chat history to another device or back up your chat history", "@settingsExportInfo": { "description": "Information displayed for export and import options", - "context": "Visible in the settings view" + "context": "app:settings:export" }, - "settingsExportWarning": "Multiple chat histories won't be merged! You'll loose your current chat history if you import a new one", + "settingsExportWarning": "Multiple chat histories won't be merged! You'll lose your current chat history if you import a new one.", "@settingsExportWarning": { "description": "Warning displayed for export and import options", - "context": "Visible in the settings view" + "context": "app:settings:export" }, "settingsUpdateCheck": "Check for updates", "@settingsUpdateCheck": { "description": "Text displayed as description for check for updates button", - "context": "Visible in the settings view" + "context": "app:settings:info" }, "settingsUpdateChecking": "Checking for updates ...", "@settingsUpdateChecking": { "description": "Text displayed while looking for updates", - "context": "Visible in the settings view" + "context": "app:settings:info" }, "settingsUpdateLatest": "You are on the latest version", "@settingsUpdateLatest": { "description": "Text displayed when the app is up to date", - "context": "Visible in the settings view" + "context": "app:settings:info" }, "settingsUpdateAvailable": "Update available (v{version})", "@settingsUpdateAvailable": { "description": "Text displayed when an update is available", - "context": "Visible in the settings view", + "context": "app:settings:info", "placeholders": { "version": { "type": "String", @@ -720,62 +745,62 @@ "settingsUpdateRateLimit": "Can't check, API rate limit exceeded", "@settingsUpdateRateLimit": { "description": "Text displayed when the API rate limit is exceeded", - "context": "Visible in the settings view" + "context": "app:settings:info" }, "settingsUpdateIssue": "An issue occurred", "@settingsUpdateIssue": { "description": "Text displayed when an issue occurs while checking for updates", - "context": "Visible in the settings view" + "context": "app:settings:info" }, "settingsUpdateDialogTitle": "New version available", "@settingsUpdateDialogTitle": { "description": "Title of the update dialog", - "context": "Visible in the settings view" + "context": "app:settings:info" }, - "settingsUpdateDialogDescription": "A new version of Ollama is available. Do you want to download and install it now?", + "settingsUpdateDialogDescription": "A new version of Ollama App is available. Do you want to download and install it now?", "@settingsUpdateDialogDescription": { "description": "Description of the update dialog", - "context": "Visible in the settings view" + "context": "app:settings:info" }, "settingsUpdateChangeLog": "Change Log", "@settingsUpdateChangeLog": { "description": "Text displayed as description for change log button", - "context": "Visible in the settings view" + "context": "app:settings:info" }, "settingsUpdateDialogUpdate": "Update", "@settingsUpdateDialogUpdate": { "description": "Text displayed for update button, should be capitalized", - "context": "Visible in the settings view" + "context": "app:settings:info" }, "settingsUpdateDialogCancel": "Cancel", "@settingsUpdateDialogCancel": { "description": "Text displayed for cancel button, should be capitalized", - "context": "Visible in the settings view" + "context": "app:settings:info" }, "settingsCheckForUpdates": "Check for updates on open", "@settingsCheckForUpdates": { "description": "Text displayed as description for check for updates toggle", - "context": "Visible in the settings view" + "context": "app:settings:info" }, "settingsGithub": "GitHub", "@settingsGithub": { "description": "Text displayed as description for GitHub button", - "context": "Visible in the settings view" + "context": "app:settings:info" }, "settingsReportIssue": "Report Issue", "@settingsReportIssue": { "description": "Text displayed as description for report issue button", - "context": "Visible in the settings view" + "context": "app:settings:info" }, "settingsLicenses": "Licenses", "@settingsLicenses": { "description": "Text displayed as description for licenses button", - "context": "Visible in the settings view" + "context": "app:settings:info" }, "settingsVersion": "Ollama App v{version}", "@settingsVersion": { "description": "Text displayed as description for version", - "context": "Visible in the settings view", + "context": "app:settings:info", "placeholders": { "version": { "type": "String", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 7c55b87..073285e 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -67,7 +67,7 @@ import 'app_localizations_zh.dart'; /// property. abstract class AppLocalizations { AppLocalizations(String locale) - : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); final String localeName; @@ -90,11 +90,11 @@ abstract class AppLocalizations { /// of delegates is preferred or required. static const List> localizationsDelegates = >[ - delegate, - GlobalMaterialLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - ]; + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; /// A list of this localizations delegate's supported locales. static const List supportedLocales = [ @@ -103,7 +103,7 @@ abstract class AppLocalizations { Locale('fa'), Locale('it'), Locale('tr'), - Locale('zh') + Locale('zh'), ]; /// Title of the application @@ -145,7 +145,7 @@ abstract class AppLocalizations { /// First tip displayed in the sidebar /// /// In en, this message translates to: - /// **'Edit messages by long taping on them'** + /// **'Edit messages by long tapping on them'** String get tip0; /// Second tip displayed in the sidebar @@ -238,12 +238,6 @@ abstract class AppLocalizations { /// **'Let AI think'** String get tooltipLetAIThink; - /// Tooltip for add host headers button - /// - /// In en, this message translates to: - /// **'Add host headers'** - String get tooltipAddHostHeaders; - /// Tooltip for reset button /// /// In en, this message translates to: @@ -265,7 +259,7 @@ abstract class AppLocalizations { /// Text displayed when no host is selected /// /// In en, this message translates to: - /// **'No host selected, open setting to set one'** + /// **'No host selected, open settings to set one'** String get noHostSelected; /// Text displayed when no model is selected @@ -295,7 +289,7 @@ abstract class AppLocalizations { /// Description of the add model dialog /// /// In en, this message translates to: - /// **'This can have either be a normal name (e.g. \'llama3\') or name and tag (e.g. \'llama3:70b\').'** + /// **'This can either be a normal name (e.g. \'llama3\') or a name and tag (e.g. \'llama3:70b\').'** String get modelDialogAddPromptDescription; /// Text displayed when the model already exists @@ -319,7 +313,7 @@ abstract class AppLocalizations { /// Description of the allow proxy dialog /// /// In en, this message translates to: - /// **'Ollama App must check if the entered model is valid. For that, we normally send a web request to the Ollama model list and check the status code, but because you\'re using the web client, we can\'t do that directly. Instead, the app will send the request to a different api, hosted by JHubi1, to check for us.\nThis is a one-time request and will only be sent when you add a new model.\nYour IP address will be sent with the request and might be stored for up to ten minutes to prevent spamming with potential harmful intentions.\nIf you accept, your selection will be remembered in the future; if not, nothing will be sent and the model won\'t be added.'** + /// **'Ollama App must check if the entered model is valid. To do that, we normally send a web request to the Ollama model list and check the status code, but because you\'re using the web client, we can\'t do that directly. Instead, the app will send the request to a different API, hosted by JHubi1, to check for us.\nThis is a one-time request and will only be sent when you add a new model.\nYour IP address will be sent with the request and might be stored for up to ten minutes to prevent spamming with potentially harmful intentions.\nIf you accept, your selection will be remembered for the future; if not, nothing will be sent and the model won\'t be added.'** String get modelDialogAddAllowanceDescription; /// Text displayed for allow button, should be capitalized @@ -343,7 +337,7 @@ abstract class AppLocalizations { /// Description of the add model assurance dialog /// /// In en, this message translates to: - /// **'Pressing \'Add\' will download the model \'{model}\' directly from the Ollama server to your host.\nThis can take a while depending on your internet connection. The action cannot be canceled.\nIf the app is closed during the download, it\'ll resume if you enter the name into the model dialog again.'** + /// **'Pressing \'Add\' will download the model \'{model}\' directly from the Ollama server to your host.\nThis can take a while depending on your internet connection. The action cannot be canceled.\nIf the app is closed during the download, it will resume if you enter the name into the model dialog again.'** String modelDialogAddAssuranceDescription(String model); /// Text displayed for add button, should be capitalized @@ -406,6 +400,36 @@ abstract class AppLocalizations { /// **'Cancel'** String get deleteDialogCancel; + /// Title of the error guard dialog. Do not translate if not required! + /// + /// In en, this message translates to: + /// **'ErrorGuard'** + String get errorGuardTitle; + + /// Text displayed for details button and the details section in the error guard dialog + /// + /// In en, this message translates to: + /// **'Details'** + String get errorGuardDetails; + + /// Text displayed for exception section in the error guard dialog. Reference: https://en.wikipedia.org/wiki/Exception_handling + /// + /// In en, this message translates to: + /// **'Exception'** + String get errorGuardException; + + /// Text displayed for stack trace section in the error guard dialog. Use the commonly agreed on spelling in your language, e.g. 'Stacktrace' in German; use Wikipedia as reference: https://en.wikipedia.org/wiki/Stack_trace + /// + /// In en, this message translates to: + /// **'Stack Trace'** + String get errorGuardStackTrace; + + /// Text displayed for report button in the error guard dialog + /// + /// In en, this message translates to: + /// **'Report'** + String get errorGuardReport; + /// Text displayed as description for new title input /// /// In en, this message translates to: @@ -484,7 +508,7 @@ abstract class AppLocalizations { /// **'Settings are saved automatically'** String get settingsSavedAutomatically; - /// Text displayed when a feature is in alpha + /// Text displayed when a feature is in alpha phase /// /// In en, this message translates to: /// **'alpha'** @@ -511,7 +535,7 @@ abstract class AppLocalizations { /// Description of the beta feature /// /// In en, this message translates to: - /// **'This feature is in beta and may not work intended or expected.\nLess severe issues may or may not occur. Damage shouldn\'t be critical.\nUse at your own risk.'** + /// **'This feature is in beta and may not work as intended or expected.\nLess severe issues may or may not occur. Damage shouldn\'t be critical.\nUse at your own risk.'** String get settingsExperimentalBetaDescription; /// Text displayed when a feature is in beta @@ -559,9 +583,15 @@ abstract class AppLocalizations { /// Text displayed when the host is invalid /// /// In en, this message translates to: - /// **'Issue: {type, select, url{Invalid URL} host{Invalid Host} timeout{Request Failed. Server issues} ratelimit{Too many requests} other{Request Failed}}'** + /// **'Issue: {type, select, url{Invalid URL} host{Invalid Host} timeout{Request failed. Server issues} ratelimit{Too many requests} other{Request Failed}}'** String settingsHostInvalid(String type); + /// Tooltip for add host headers button + /// + /// In en, this message translates to: + /// **'Add host headers'** + String get tooltipAddHostHeaders; + /// Text displayed as description for host header input /// /// In en, this message translates to: @@ -577,7 +607,7 @@ abstract class AppLocalizations { /// Text displayed when the host is invalid /// /// In en, this message translates to: - /// **'{type, select, url{The URL you entered is invalid. It isn\'t an a standardized URL format.} other{The host you entered is invalid. It cannot be reached. Please check the host and try again.}}'** + /// **'{type, select, url{The URL you entered is invalid. It isn\'t in a standardized URL format.} other{The host you entered is invalid. It cannot be reached. Please check the host and try again.}}'** String settingsHostInvalidDetailed(String type); /// Text displayed as description for system message input @@ -595,7 +625,7 @@ abstract class AppLocalizations { /// Description of the use system message toggle /// /// In en, this message translates to: - /// **'Disables setting the system message above and use the one of the model instead. Can be useful for models with model files'** + /// **'Disables setting the system message above and uses the one from the model\'s Modelfile instead. Can be useful for models with model files.'** String get settingsUseSystemDescription; /// Text displayed as description for disable markdown toggle @@ -787,7 +817,7 @@ abstract class AppLocalizations { /// Description of the text-to-speech not supported message /// /// In en, this message translates to: - /// **'Text-to-speech services are not supported for the selected language. Select a different language in the language drawer to reenable them.\nOther services like voice recognition and AI thinking will still work as usual, but interaction might not be as fluent.'** + /// **'Text-to-speech services are not supported for the selected language. Select a different language in the language drawer to re-enable them.\nOther services like voice recognition and AI thinking will still work as usual, but interaction might not be as fluent.'** String get settingsVoiceTtsNotSupportedDescription; /// Text displayed when voice permissions are not granted @@ -883,13 +913,13 @@ abstract class AppLocalizations { /// Information displayed for export and import options /// /// In en, this message translates to: - /// **'This options allows you to export and import your chat history. This can be useful if you want to transfer your chat history to another device or backup your chat history'** + /// **'These options allow you to export and import your chat history. This can be useful if you want to transfer your chat history to another device or back up your chat history'** String get settingsExportInfo; /// Warning displayed for export and import options /// /// In en, this message translates to: - /// **'Multiple chat histories won\'t be merged! You\'ll loose your current chat history if you import a new one'** + /// **'Multiple chat histories won\'t be merged! You\'ll lose your current chat history if you import a new one.'** String get settingsExportWarning; /// Text displayed as description for check for updates button @@ -937,7 +967,7 @@ abstract class AppLocalizations { /// Description of the update dialog /// /// In en, this message translates to: - /// **'A new version of Ollama is available. Do you want to download and install it now?'** + /// **'A new version of Ollama App is available. Do you want to download and install it now?'** String get settingsUpdateDialogDescription; /// Text displayed as description for change log button @@ -1000,13 +1030,13 @@ class _AppLocalizationsDelegate @override bool isSupported(Locale locale) => [ - 'de', - 'en', - 'fa', - 'it', - 'tr', - 'zh' - ].contains(locale.languageCode); + 'de', + 'en', + 'fa', + 'it', + 'tr', + 'zh', + ].contains(locale.languageCode); @override bool shouldReload(_AppLocalizationsDelegate old) => false; @@ -1030,8 +1060,9 @@ AppLocalizations lookupAppLocalizations(Locale locale) { } throw FlutterError( - 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' - 'an issue with the localizations generation tool. Please file an issue ' - 'on GitHub with a reproducible sample app and the gen-l10n configuration ' - 'that was used.'); + 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.', + ); } diff --git a/lib/l10n/gen/app_localizations_de.dart b/lib/l10n/gen/app_localizations_de.dart index aa1e952..606409f 100644 --- a/lib/l10n/gen/app_localizations_de.dart +++ b/lib/l10n/gen/app_localizations_de.dart @@ -74,9 +74,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get tooltipLetAIThink => 'Lass KI denken'; - @override - String get tooltipAddHostHeaders => 'Host-Header festlegen'; - @override String get tooltipReset => 'Aktuellen Chat zurücksetzen'; @@ -168,6 +165,21 @@ class AppLocalizationsDe extends AppLocalizations { @override String get deleteDialogCancel => 'Abbrechen'; + @override + String get errorGuardTitle => 'ErrorGuard'; + + @override + String get errorGuardDetails => 'Details'; + + @override + String get errorGuardException => 'Exception'; + + @override + String get errorGuardStackTrace => 'Stack Trace'; + + @override + String get errorGuardReport => 'Report'; + @override String get dialogEnterNewTitle => 'Gib bitte einen neuen Titel ein'; @@ -257,19 +269,19 @@ class AppLocalizationsDe extends AppLocalizations { @override String settingsHostInvalid(String type) { - String _temp0 = intl.Intl.selectLogic( - type, - { - 'url': 'Ungültige URL', - 'host': 'Ungültiger Host', - 'timeout': 'Request Fehlgeschlagen. Server Fehler', - 'ratelimit': 'Zu viele Anfragen', - 'other': 'Request Fehlgeschlagen', - }, - ); + String _temp0 = intl.Intl.selectLogic(type, { + 'url': 'Ungültige URL', + 'host': 'Ungültiger Host', + 'timeout': 'Request Fehlgeschlagen. Server Fehler', + 'ratelimit': 'Zu viele Anfragen', + 'other': 'Request Fehlgeschlagen', + }); return 'Fehler: $_temp0'; } + @override + String get tooltipAddHostHeaders => 'Host-Header festlegen'; + @override String get settingsHostHeaderTitle => 'Host-Header festlegen'; @@ -279,15 +291,12 @@ class AppLocalizationsDe extends AppLocalizations { @override String settingsHostInvalidDetailed(String type) { - String _temp0 = intl.Intl.selectLogic( - type, - { - 'url': - 'Die eingegebene URL ist ungültig. Es handelt sich nicht um ein standardisiertes URL-Format.', - 'other': - 'Der eingegebene Host ist ungültig. Er kann nicht erreicht werden. Bitte überprüfe den Host und versuche es erneut.', - }, - ); + String _temp0 = intl.Intl.selectLogic(type, { + 'url': + 'Die eingegebene URL ist ungültig. Es handelt sich nicht um ein standardisiertes URL-Format.', + 'other': + 'Der eingegebene Host ist ungültig. Er kann nicht erreicht werden. Bitte überprüfe den Host und versuche es erneut.', + }); return '$_temp0'; } diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 1fe4b67..9a1ab3d 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -27,7 +27,7 @@ class AppLocalizationsEn extends AppLocalizations { String get tipPrefix => 'Tip: '; @override - String get tip0 => 'Edit messages by long taping on them'; + String get tip0 => 'Edit messages by long tapping on them'; @override String get tip1 => 'Delete messages by double tapping on them'; @@ -74,9 +74,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get tooltipLetAIThink => 'Let AI think'; - @override - String get tooltipAddHostHeaders => 'Add host headers'; - @override String get tooltipReset => 'Reset current chat'; @@ -87,7 +84,7 @@ class AppLocalizationsEn extends AppLocalizations { String get noModelSelected => 'No model selected'; @override - String get noHostSelected => 'No host selected, open setting to set one'; + String get noHostSelected => 'No host selected, open settings to set one'; @override String get noSelectedModel => ''; @@ -103,7 +100,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get modelDialogAddPromptDescription => - 'This can have either be a normal name (e.g. \'llama3\') or name and tag (e.g. \'llama3:70b\').'; + 'This can either be a normal name (e.g. \'llama3\') or a name and tag (e.g. \'llama3:70b\').'; @override String get modelDialogAddPromptAlreadyExists => 'Model already exists'; @@ -116,7 +113,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get modelDialogAddAllowanceDescription => - 'Ollama App must check if the entered model is valid. For that, we normally send a web request to the Ollama model list and check the status code, but because you\'re using the web client, we can\'t do that directly. Instead, the app will send the request to a different api, hosted by JHubi1, to check for us.\nThis is a one-time request and will only be sent when you add a new model.\nYour IP address will be sent with the request and might be stored for up to ten minutes to prevent spamming with potential harmful intentions.\nIf you accept, your selection will be remembered in the future; if not, nothing will be sent and the model won\'t be added.'; + 'Ollama App must check if the entered model is valid. To do that, we normally send a web request to the Ollama model list and check the status code, but because you\'re using the web client, we can\'t do that directly. Instead, the app will send the request to a different API, hosted by JHubi1, to check for us.\nThis is a one-time request and will only be sent when you add a new model.\nYour IP address will be sent with the request and might be stored for up to ten minutes to prevent spamming with potentially harmful intentions.\nIf you accept, your selection will be remembered for the future; if not, nothing will be sent and the model won\'t be added.'; @override String get modelDialogAddAllowanceAllow => 'Allow'; @@ -131,7 +128,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String modelDialogAddAssuranceDescription(String model) { - return 'Pressing \'Add\' will download the model \'$model\' directly from the Ollama server to your host.\nThis can take a while depending on your internet connection. The action cannot be canceled.\nIf the app is closed during the download, it\'ll resume if you enter the name into the model dialog again.'; + return 'Pressing \'Add\' will download the model \'$model\' directly from the Ollama server to your host.\nThis can take a while depending on your internet connection. The action cannot be canceled.\nIf the app is closed during the download, it will resume if you enter the name into the model dialog again.'; } @override @@ -167,6 +164,21 @@ class AppLocalizationsEn extends AppLocalizations { @override String get deleteDialogCancel => 'Cancel'; + @override + String get errorGuardTitle => 'ErrorGuard'; + + @override + String get errorGuardDetails => 'Details'; + + @override + String get errorGuardException => 'Exception'; + + @override + String get errorGuardStackTrace => 'Stack Trace'; + + @override + String get errorGuardReport => 'Report'; + @override String get dialogEnterNewTitle => 'Enter new title'; @@ -227,7 +239,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settingsExperimentalBetaDescription => - 'This feature is in beta and may not work intended or expected.\nLess severe issues may or may not occur. Damage shouldn\'t be critical.\nUse at your own risk.'; + 'This feature is in beta and may not work as intended or expected.\nLess severe issues may or may not occur. Damage shouldn\'t be critical.\nUse at your own risk.'; @override String get settingsExperimentalBetaFeature => @@ -255,19 +267,19 @@ class AppLocalizationsEn extends AppLocalizations { @override String settingsHostInvalid(String type) { - String _temp0 = intl.Intl.selectLogic( - type, - { - 'url': 'Invalid URL', - 'host': 'Invalid Host', - 'timeout': 'Request Failed. Server issues', - 'ratelimit': 'Too many requests', - 'other': 'Request Failed', - }, - ); + String _temp0 = intl.Intl.selectLogic(type, { + 'url': 'Invalid URL', + 'host': 'Invalid Host', + 'timeout': 'Request failed. Server issues', + 'ratelimit': 'Too many requests', + 'other': 'Request Failed', + }); return 'Issue: $_temp0'; } + @override + String get tooltipAddHostHeaders => 'Add host headers'; + @override String get settingsHostHeaderTitle => 'Set host header'; @@ -277,15 +289,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String settingsHostInvalidDetailed(String type) { - String _temp0 = intl.Intl.selectLogic( - type, - { - 'url': - 'The URL you entered is invalid. It isn\'t an a standardized URL format.', - 'other': - 'The host you entered is invalid. It cannot be reached. Please check the host and try again.', - }, - ); + String _temp0 = intl.Intl.selectLogic(type, { + 'url': + 'The URL you entered is invalid. It isn\'t in a standardized URL format.', + 'other': + 'The host you entered is invalid. It cannot be reached. Please check the host and try again.', + }); return '$_temp0'; } @@ -297,7 +306,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settingsUseSystemDescription => - 'Disables setting the system message above and use the one of the model instead. Can be useful for models with model files'; + 'Disables setting the system message above and uses the one from the model\'s Modelfile instead. Can be useful for models with model files.'; @override String get settingsDisableMarkdown => 'Disable markdown'; @@ -401,7 +410,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settingsVoiceTtsNotSupportedDescription => - 'Text-to-speech services are not supported for the selected language. Select a different language in the language drawer to reenable them.\nOther services like voice recognition and AI thinking will still work as usual, but interaction might not be as fluent.'; + 'Text-to-speech services are not supported for the selected language. Select a different language in the language drawer to re-enable them.\nOther services like voice recognition and AI thinking will still work as usual, but interaction might not be as fluent.'; @override String get settingsVoicePermissionNot => 'Permissions not granted'; @@ -451,11 +460,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settingsExportInfo => - 'This options allows you to export and import your chat history. This can be useful if you want to transfer your chat history to another device or backup your chat history'; + 'These options allow you to export and import your chat history. This can be useful if you want to transfer your chat history to another device or back up your chat history'; @override String get settingsExportWarning => - 'Multiple chat histories won\'t be merged! You\'ll loose your current chat history if you import a new one'; + 'Multiple chat histories won\'t be merged! You\'ll lose your current chat history if you import a new one.'; @override String get settingsUpdateCheck => 'Check for updates'; @@ -482,7 +491,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settingsUpdateDialogDescription => - 'A new version of Ollama is available. Do you want to download and install it now?'; + 'A new version of Ollama App is available. Do you want to download and install it now?'; @override String get settingsUpdateChangeLog => 'Change Log'; diff --git a/lib/l10n/gen/app_localizations_fa.dart b/lib/l10n/gen/app_localizations_fa.dart index ffec49c..efb6df1 100644 --- a/lib/l10n/gen/app_localizations_fa.dart +++ b/lib/l10n/gen/app_localizations_fa.dart @@ -74,9 +74,6 @@ class AppLocalizationsFa extends AppLocalizations { @override String get tooltipLetAIThink => 'Let AI think'; - @override - String get tooltipAddHostHeaders => 'Add host headers'; - @override String get tooltipReset => 'Reset current chat'; @@ -167,6 +164,21 @@ class AppLocalizationsFa extends AppLocalizations { @override String get deleteDialogCancel => 'Cancel'; + @override + String get errorGuardTitle => 'ErrorGuard'; + + @override + String get errorGuardDetails => 'Details'; + + @override + String get errorGuardException => 'Exception'; + + @override + String get errorGuardStackTrace => 'Stack Trace'; + + @override + String get errorGuardReport => 'Report'; + @override String get dialogEnterNewTitle => 'Enter new title'; @@ -255,19 +267,19 @@ class AppLocalizationsFa extends AppLocalizations { @override String settingsHostInvalid(String type) { - String _temp0 = intl.Intl.selectLogic( - type, - { - 'url': 'Invalid URL', - 'host': 'Invalid Host', - 'timeout': 'Request Failed. Server issues', - 'ratelimit': 'Too many requests', - 'other': 'Request Failed', - }, - ); + String _temp0 = intl.Intl.selectLogic(type, { + 'url': 'Invalid URL', + 'host': 'Invalid Host', + 'timeout': 'Request Failed. Server issues', + 'ratelimit': 'Too many requests', + 'other': 'Request Failed', + }); return 'Issue: $_temp0'; } + @override + String get tooltipAddHostHeaders => 'Add host headers'; + @override String get settingsHostHeaderTitle => 'Set host header'; @@ -277,15 +289,12 @@ class AppLocalizationsFa extends AppLocalizations { @override String settingsHostInvalidDetailed(String type) { - String _temp0 = intl.Intl.selectLogic( - type, - { - 'url': - 'The URL you entered is invalid. It isn\'t an a standardized URL format.', - 'other': - 'The host you entered is invalid. It cannot be reached. Please check the host and try again.', - }, - ); + String _temp0 = intl.Intl.selectLogic(type, { + 'url': + 'The URL you entered is invalid. It isn\'t an a standardized URL format.', + 'other': + 'The host you entered is invalid. It cannot be reached. Please check the host and try again.', + }); return '$_temp0'; } diff --git a/lib/l10n/gen/app_localizations_it.dart b/lib/l10n/gen/app_localizations_it.dart index adf11e7..5b7c52d 100644 --- a/lib/l10n/gen/app_localizations_it.dart +++ b/lib/l10n/gen/app_localizations_it.dart @@ -75,9 +75,6 @@ class AppLocalizationsIt extends AppLocalizations { @override String get tooltipLetAIThink => 'Lasciamo che sia IA a pensare'; - @override - String get tooltipAddHostHeaders => 'Aggiungi host headers'; - @override String get tooltipReset => 'Reimposta la chat corrente'; @@ -170,6 +167,21 @@ class AppLocalizationsIt extends AppLocalizations { @override String get deleteDialogCancel => 'Annulla'; + @override + String get errorGuardTitle => 'ErrorGuard'; + + @override + String get errorGuardDetails => 'Details'; + + @override + String get errorGuardException => 'Exception'; + + @override + String get errorGuardStackTrace => 'Stack Trace'; + + @override + String get errorGuardReport => 'Report'; + @override String get dialogEnterNewTitle => 'Immetti nuovo titolo'; @@ -259,19 +271,19 @@ class AppLocalizationsIt extends AppLocalizations { @override String settingsHostInvalid(String type) { - String _temp0 = intl.Intl.selectLogic( - type, - { - 'url': 'URL invalido', - 'host': 'Host invalido', - 'timeout': 'Richiesta fallita. Problema col server', - 'ratelimit': 'Troppe richieste', - 'other': 'Richiesta fallita', - }, - ); + String _temp0 = intl.Intl.selectLogic(type, { + 'url': 'URL invalido', + 'host': 'Host invalido', + 'timeout': 'Richiesta fallita. Problema col server', + 'ratelimit': 'Troppe richieste', + 'other': 'Richiesta fallita', + }); return 'Problema: $_temp0'; } + @override + String get tooltipAddHostHeaders => 'Aggiungi host headers'; + @override String get settingsHostHeaderTitle => 'Imposta header host'; @@ -281,15 +293,12 @@ class AppLocalizationsIt extends AppLocalizations { @override String settingsHostInvalidDetailed(String type) { - String _temp0 = intl.Intl.selectLogic( - type, - { - 'url': - 'L\'URL inserito non è valido. Non è un formato URL standardizzato.', - 'other': - 'L\'host inserito non è valido. Non può essere raggiunto. Controlla l\'host e riprova.', - }, - ); + String _temp0 = intl.Intl.selectLogic(type, { + 'url': + 'L\'URL inserito non è valido. Non è un formato URL standardizzato.', + 'other': + 'L\'host inserito non è valido. Non può essere raggiunto. Controlla l\'host e riprova.', + }); return '$_temp0'; } diff --git a/lib/l10n/gen/app_localizations_tr.dart b/lib/l10n/gen/app_localizations_tr.dart index e092f4b..2549b11 100644 --- a/lib/l10n/gen/app_localizations_tr.dart +++ b/lib/l10n/gen/app_localizations_tr.dart @@ -74,9 +74,6 @@ class AppLocalizationsTr extends AppLocalizations { @override String get tooltipLetAIThink => 'AI\'\'nın düşünmesine izin ver'; - @override - String get tooltipAddHostHeaders => 'Ana bilgisayar başlıkları ekle'; - @override String get tooltipReset => 'Mevcut sohbeti sıfırla'; @@ -169,6 +166,21 @@ class AppLocalizationsTr extends AppLocalizations { @override String get deleteDialogCancel => 'İptal'; + @override + String get errorGuardTitle => 'ErrorGuard'; + + @override + String get errorGuardDetails => 'Details'; + + @override + String get errorGuardException => 'Exception'; + + @override + String get errorGuardStackTrace => 'Stack Trace'; + + @override + String get errorGuardReport => 'Report'; + @override String get dialogEnterNewTitle => 'Yeni başlık girin'; @@ -257,19 +269,19 @@ class AppLocalizationsTr extends AppLocalizations { @override String settingsHostInvalid(String type) { - String _temp0 = intl.Intl.selectLogic( - type, - { - 'url': 'Geçersiz URL', - 'host': 'Geçersiz Ana Bilgisayar', - 'timeout': 'İstek Başarısız. Sunucu sorunları', - 'ratelimit': 'Çok fazla istek', - 'other': 'İstek Başarısız', - }, - ); + String _temp0 = intl.Intl.selectLogic(type, { + 'url': 'Geçersiz URL', + 'host': 'Geçersiz Ana Bilgisayar', + 'timeout': 'İstek Başarısız. Sunucu sorunları', + 'ratelimit': 'Çok fazla istek', + 'other': 'İstek Başarısız', + }); return 'Sorun: $_temp0'; } + @override + String get tooltipAddHostHeaders => 'Ana bilgisayar başlıkları ekle'; + @override String get settingsHostHeaderTitle => 'Ana bilgisayar başlığını ayarla'; @@ -279,14 +291,11 @@ class AppLocalizationsTr extends AppLocalizations { @override String settingsHostInvalidDetailed(String type) { - String _temp0 = intl.Intl.selectLogic( - type, - { - 'url': 'Girdiğiniz URL geçersiz. Standart bir URL formatında değil.', - 'other': - 'Girdiğiniz ana bilgisayar geçersiz. Ulaşılamıyor. Lütfen ana bilgisayarı kontrol edin ve tekrar deneyin.', - }, - ); + String _temp0 = intl.Intl.selectLogic(type, { + 'url': 'Girdiğiniz URL geçersiz. Standart bir URL formatında değil.', + 'other': + 'Girdiğiniz ana bilgisayar geçersiz. Ulaşılamıyor. Lütfen ana bilgisayarı kontrol edin ve tekrar deneyin.', + }); return '$_temp0'; } diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index db7666a..ad60958 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -74,9 +74,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get tooltipLetAIThink => '让AI思考'; - @override - String get tooltipAddHostHeaders => '设置主机请求头'; - @override String get tooltipReset => '重置当前聊天'; @@ -167,6 +164,21 @@ class AppLocalizationsZh extends AppLocalizations { @override String get deleteDialogCancel => '取消'; + @override + String get errorGuardTitle => 'ErrorGuard'; + + @override + String get errorGuardDetails => 'Details'; + + @override + String get errorGuardException => 'Exception'; + + @override + String get errorGuardStackTrace => 'Stack Trace'; + + @override + String get errorGuardReport => 'Report'; + @override String get dialogEnterNewTitle => '输入新标题'; @@ -247,18 +259,18 @@ class AppLocalizationsZh extends AppLocalizations { @override String settingsHostInvalid(String type) { - String _temp0 = intl.Intl.selectLogic( - type, - { - 'url': '无效的URL', - 'host': '无效的主机地址', - 'timeout': '请求失败。服务器问题', - 'other': '请求失败', - }, - ); + String _temp0 = intl.Intl.selectLogic(type, { + 'url': '无效的URL', + 'host': '无效的主机地址', + 'timeout': '请求失败。服务器问题', + 'other': '请求失败', + }); return '问题:$_temp0'; } + @override + String get tooltipAddHostHeaders => '设置主机请求头'; + @override String get settingsHostHeaderTitle => '设置主机请求头'; @@ -267,13 +279,10 @@ class AppLocalizationsZh extends AppLocalizations { @override String settingsHostInvalidDetailed(String type) { - String _temp0 = intl.Intl.selectLogic( - type, - { - 'url': '您输入的 URL 无效。它不是一个标准的 URL 格式。', - 'other': '您输入的主机地址无效。无法连接。请检查主机地址并再试一次', - }, - ); + String _temp0 = intl.Intl.selectLogic(type, { + 'url': '您输入的 URL 无效。它不是一个标准的 URL 格式。', + 'other': '您输入的主机地址无效。无法连接。请检查主机地址并再试一次', + }); return '$_temp0'; } diff --git a/lib/main.dart b/lib/main.dart index 57f4071..5f0b2cd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,42 +1,26 @@ -import 'dart:convert'; +import 'dart:async'; import 'dart:io'; -import 'dart:math'; import 'package:bitsdojo_window/bitsdojo_window.dart'; -import 'package:dynamic_color/dynamic_color.dart'; -import 'package:file_picker/file_picker.dart'; +import 'package:dynamic_system_colors/dynamic_system_colors.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -// ignore: depend_on_referenced_packages import 'package:flutter_chat_types/flutter_chat_types.dart' as types; -import 'package:flutter_chat_ui/flutter_chat_ui.dart'; -import 'package:flutter_displaymode/flutter_displaymode.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; +// import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:flutter_tts/flutter_tts.dart'; -import 'package:image_picker/image_picker.dart'; -// ignore: depend_on_referenced_packages -import 'package:markdown/markdown.dart' as md; import 'package:permission_handler/permission_handler.dart'; import 'package:pwa_install/pwa_install.dart' as pwa; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:shared_preferences/util/legacy_to_async_migration_util.dart'; import 'package:speech_to_text/speech_to_text.dart'; import 'package:universal_html/html.dart' as html; -import 'package:url_launcher/url_launcher.dart'; import 'package:uuid/uuid.dart'; -import 'package:version/version.dart'; -import 'package:visibility_detector/visibility_detector.dart'; import 'l10n/gen/app_localizations.dart'; -import 'screens/settings.dart'; -import 'screens/voice.dart'; -import 'screens/welcome.dart'; +import 'screens/main.dart'; import 'services/clients.dart'; -import 'services/desktop.dart'; -import 'services/haptic.dart'; -import 'services/sender.dart'; -import 'services/setter.dart'; +import 'services/responsive.dart'; import 'services/theme.dart'; -import 'services/update.dart'; // client configuration @@ -48,9 +32,9 @@ const String fixedHost = "http://example.com:11434"; // use model or not, if false selector is shown const bool useModel = false; // model name as string, must be valid ollama model! -const String fixedModel = "gemma"; +const String fixedModel = "gemma3"; // recommended models, shown with a star in model selector -const List recommendedModels = ["gemma", "llama3"]; +const List recommendedModels = ["gemma3", "llama3.3"]; // allow opening of settings const bool allowSettings = true; // allow multiple chats @@ -58,19 +42,14 @@ const bool allowMultipleChats = true; // client configuration end -SharedPreferences? prefs; +Completer prefsReady = Completer(); +SharedPreferencesWithCache? prefs; -String? model; String? host; -bool multimodal = false; - -List messages = []; -String? chatUuid; bool chatAllowed = true; String hoveredChat = ""; -GlobalKey? chatKey; final user = types.User(id: const Uuid().v4()); final assistant = types.User(id: const Uuid().v4()); @@ -91,7 +70,7 @@ void Function(void Function())? setGlobalState; void Function(void Function())? setMainAppState; void main() { - pwa.PWAInstall().setup(installCallback: () {}); + pwa.PWAInstall().setup(); try { HttpOverrides.global = OllamaHttpOverrides(); @@ -99,23 +78,18 @@ void main() { runApp(const App()); - if (desktopFeature()) { + if (LayoutFeature.desktop()) { doWhenWindowReady(() { appWindow.minSize = const Size(600, 450); appWindow.size = const Size(1200, 650); - appWindow.alignment = Alignment.center; - if (prefs!.getBool("maximizeOnStart") ?? false) { - appWindow.maximize(); - } + if (prefs!.getBool("maximizeOnStart") ?? false) appWindow.maximize(); appWindow.show(); }); } } class App extends StatefulWidget { - const App({ - super.key, - }); + const App({super.key}); @override State createState() => _AppState(); @@ -126,16 +100,20 @@ class _AppState extends State { void initState() { super.initState(); - Future load() async { - try { - await FlutterDisplayMode.setHighRefreshRate(); - } catch (_) {} + if (kIsWeb) html.querySelector(".loader")?.remove(); + // FlutterDisplayMode.setHighRefreshRate().catchError((_) {}); + Future load() async { SharedPreferences.setPrefix("ollama."); - var tmp = await SharedPreferences.getInstance(); - setState(() { - prefs = tmp; - }); + await migrateLegacySharedPreferencesToSharedPreferencesAsyncIfNecessary( + legacySharedPreferencesInstance: await SharedPreferences.getInstance(), + sharedPreferencesAsyncOptions: const SharedPreferencesOptions(), + migrationCompletedKey: "migrationCompleted", + ); + prefs = await SharedPreferencesWithCache.create( + cacheOptions: const SharedPreferencesWithCacheOptions(), + ); + prefsReady.complete(); try { if ((await Permission.bluetoothConnect.isGranted) && @@ -157,1514 +135,26 @@ class _AppState extends State { @override Widget build(BuildContext context) { return DynamicColorBuilder( - builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) { - colorSchemeLight = lightDynamic; - colorSchemeDark = darkDynamic; - return StatefulBuilder(builder: (context, setState) { - setMainAppState = setState; - return MaterialApp( - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - localeListResolutionCallback: (deviceLocales, supportedLocales) { - if (deviceLocales != null) { - for (var locale in deviceLocales) { - var newLocale = Locale(locale.languageCode); - if (supportedLocales.contains(newLocale)) { - return locale; - } - } - } - return const Locale("en"); - }, - onGenerateTitle: (context) { - return AppLocalizations.of(context).appTitle; - }, - theme: themeLight(), - darkTheme: themeDark(), - themeMode: themeMode(), - home: const MainApp()); - }); - }); - } -} - -class MainApp extends StatefulWidget { - const MainApp({super.key}); - - @override - State createState() => _MainAppState(); -} - -class _MainAppState extends State { - int tipId = Random().nextInt(5); - - List sidebar(BuildContext context, Function setState) { - var padding = EdgeInsets.only( - left: desktopLayoutRequired(context) ? 17 : 12, - right: desktopLayoutRequired(context) ? 17 : 12); - return List.from([ - (desktopLayoutNotRequired(context) || kIsWeb) - ? const SizedBox(height: 8) - : const SizedBox.shrink(), - desktopLayoutNotRequired(context) - ? const SizedBox.shrink() - : (Padding( - padding: padding, - child: InkWell( - enableFeedback: false, - customBorder: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(50))), - onTap: () async { - // ester egg? gimmick? not sure if it should be kept - return; - // ignore: dead_code - if (sidebarIconSize != 1) return; - setState(() { - sidebarIconSize = 0.8; - }); - await Future.delayed(const Duration(milliseconds: 200)); - setState(() { - sidebarIconSize = 1.2; - }); - await Future.delayed(const Duration(milliseconds: 200)); - setState(() { - sidebarIconSize = 1; - }); - }, - child: Padding( - padding: const EdgeInsets.only(top: 16, bottom: 16), - child: Row(children: [ - Padding( - padding: const EdgeInsets.only(left: 16, right: 12), - child: AnimatedScale( - scale: sidebarIconSize, - duration: const Duration(milliseconds: 400), - child: const ImageIcon( - AssetImage("assets/logo512.png")))), - Expanded( - child: Text(AppLocalizations.of(context).appTitle, - softWrap: false, - overflow: TextOverflow.fade, - style: - const TextStyle(fontWeight: FontWeight.w500)), - ), - const SizedBox(width: 16), - ]))))), - (desktopLayoutNotRequired(context) || - (!allowMultipleChats && !allowSettings)) - ? const SizedBox.shrink() - : Divider( - color: desktopLayout(context) - ? Theme.of(context).colorScheme.onSurface.withAlpha(20) - : null), - allowMultipleChats - ? (Padding( - padding: padding, - child: InkWell( - enableFeedback: false, - customBorder: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(50))), - onTap: () { - selectionHaptic(); - if (!desktopLayout(context)) { - Navigator.of(context).pop(); - } - if (!chatAllowed && model != null) return; - chatUuid = null; - messages = []; - setState(() {}); - }, - child: Padding( - padding: const EdgeInsets.only(top: 16, bottom: 16), - child: Row(children: [ - const Padding( - padding: EdgeInsets.only(left: 16, right: 12), - child: Icon(Icons.add_rounded)), - Expanded( - child: Text( - AppLocalizations.of(context).optionNewChat, - softWrap: false, - overflow: TextOverflow.fade, - style: - const TextStyle(fontWeight: FontWeight.w500)), - ), - const SizedBox(width: 16), - ]))))) - : const SizedBox.shrink(), - allowSettings - ? (Padding( - padding: padding, - child: InkWell( - enableFeedback: false, - customBorder: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(50))), - onTap: () { - selectionHaptic(); - if (!desktopLayout(context)) { - Navigator.of(context).pop(); - } - setState(() { - settingsOpen = true; - }); - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const ScreenSettings())); - }, - child: Padding( - padding: const EdgeInsets.only(top: 16, bottom: 16), - child: Row(children: [ - Padding( - padding: const EdgeInsets.only(left: 16, right: 12), - child: (updateStatus == "ok" && - updateDetectedOnStart && - (Version.parse(latestVersion ?? "1.0.0") > - Version.parse( - currentVersion ?? "2.0.0"))) - ? const Badge(child: Icon(Icons.dns_rounded)) - : const Icon(Icons.dns_rounded)), - Expanded( - child: Text( - AppLocalizations.of(context).optionSettings, - softWrap: false, - overflow: TextOverflow.fade, - style: - const TextStyle(fontWeight: FontWeight.w500)), - ), - const SizedBox(width: 16), - ]))))) - : const SizedBox.shrink(), - (pwa.PWAInstall().installPromptEnabled && - pwa.PWAInstall().launchMode == pwa.LaunchMode.browser) - ? (Padding( - padding: padding, - child: InkWell( - enableFeedback: false, - customBorder: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(50))), - onTap: () { - selectionHaptic(); - if (!desktopLayout(context)) { - Navigator.of(context).pop(); - } - pwa.PWAInstall().onAppInstalled = () { - WidgetsBinding.instance.addPostFrameCallback((_) { - pwa.setLaunchModePWA(); - setMainAppState!(() {}); - }); - }; - pwa.PWAInstall().promptInstall_(); - setState(() {}); - }, - child: Padding( - padding: const EdgeInsets.only(top: 16, bottom: 16), - child: Row(children: [ - Padding( - padding: const EdgeInsets.only(left: 16, right: 12), - child: desktopLayoutNotRequired(context) - ? const Icon(Icons.install_desktop_rounded) - : const Icon(Icons.install_mobile_rounded)), - Expanded( - child: Text( - AppLocalizations.of(context).optionInstallPwa, - softWrap: false, - overflow: TextOverflow.fade, - style: - const TextStyle(fontWeight: FontWeight.w500)), - ), - const SizedBox(width: 16), - ]))))) - : const SizedBox.shrink(), - (desktopLayoutNotRequired(context) && - (!allowMultipleChats && !allowSettings)) - ? const SizedBox.shrink() - : Divider( - color: desktopLayout(context) - ? Theme.of(context).colorScheme.onSurface.withAlpha(20) - : null), - ((prefs?.getStringList("chats") ?? []).isNotEmpty) - ? const SizedBox.shrink() - : (Padding( - padding: padding, - child: InkWell( - enableFeedback: false, - customBorder: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(50))), - onTap: selectionHaptic, - child: Padding( - padding: const EdgeInsets.only(top: 16, bottom: 16), - child: Row(children: [ - const Padding( - padding: EdgeInsets.only(left: 16, right: 12), - child: Icon(Icons.question_mark_rounded, - color: Colors.grey)), - Expanded( - child: Text( - AppLocalizations.of(context).optionNoChatFound, - softWrap: false, - overflow: TextOverflow.fade, - style: const TextStyle( - fontWeight: FontWeight.w500, - color: Colors.grey)), - ), - const SizedBox(width: 16), - ]))))), - Builder(builder: (context) { - var tip = (tipId == 0) - ? AppLocalizations.of(context).tip0 - : (tipId == 1) - ? AppLocalizations.of(context).tip1 - : (tipId == 2) - ? AppLocalizations.of(context).tip2 - : (tipId == 3) - ? AppLocalizations.of(context).tip3 - : AppLocalizations.of(context).tip4; - return (!(prefs?.getBool("tips") ?? true) || - (prefs?.getStringList("chats") ?? []).isNotEmpty || - !allowSettings) - ? const SizedBox.shrink() - : (Padding( - padding: padding, - child: InkWell( - splashFactory: NoSplash.splashFactory, - highlightColor: Colors.transparent, - enableFeedback: false, - hoverColor: Colors.transparent, - onTap: () { - selectionHaptic(); - var tmpTip = tipId; - while (tmpTip == tipId) { - tipId = Random().nextInt(5); - } - setState(() {}); - }, - child: Padding( - padding: const EdgeInsets.only(top: 16, bottom: 16), - child: Row(children: [ - const Padding( - padding: EdgeInsets.only(left: 16, right: 12), - child: Icon(Icons.tips_and_updates_rounded, - color: Colors.grey)), - Expanded( - child: Text( - AppLocalizations.of(context).tipPrefix + tip, - softWrap: true, - maxLines: 3, - overflow: TextOverflow.fade, - style: const TextStyle( - fontWeight: FontWeight.w500, - color: Colors.grey)), - ), - const SizedBox(width: 16), - ])), - ))); - }), - ]) - ..addAll((prefs?.getStringList("chats") ?? []).map((item) { - var child = Padding( - padding: padding, - child: InkWell( - enableFeedback: false, - customBorder: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(50))), - onTap: () { - selectionHaptic(); - if (!desktopLayoutRequired(context)) { - Navigator.of(context).pop(); - } - if (!chatAllowed) return; - if (chatUuid == jsonDecode(item)["uuid"]) return; - loadChat(jsonDecode(item)["uuid"], setState); - chatUuid = jsonDecode(item)["uuid"]; - }, - onHover: (value) { - setState(() { - if (value) { - hoveredChat = jsonDecode(item)["uuid"]; - } else { - hoveredChat = ""; - } - }); - }, - onLongPress: (desktopFeature() || - (kIsWeb && desktopLayoutNotRequired(context))) - ? null - : () async { - selectionHaptic(); - if (!chatAllowed && - chatUuid == jsonDecode(item)["uuid"]) { - return; - } - if (!allowSettings) return; - String oldTitle = jsonDecode(item)["title"]; - var newTitle = await prompt(context, - title: AppLocalizations.of(context) - .dialogEnterNewTitle, - value: oldTitle, - uuid: jsonDecode(item)["uuid"]); - var tmp = prefs!.getStringList("chats") ?? []; - for (var i = 0; i < tmp.length; i++) { - if (jsonDecode((prefs!.getStringList("chats") ?? - [])[i])["uuid"] == - jsonDecode(item)["uuid"]) { - var tmp2 = jsonDecode(tmp[i]); - tmp2["title"] = newTitle; - tmp[i] = jsonEncode(tmp2); - break; - } - } - prefs!.setStringList("chats", tmp); - setState(() {}); - }, - child: Padding( - padding: const EdgeInsets.only(top: 16, bottom: 16), - child: Row(children: [ - allowMultipleChats - ? Padding( - padding: - const EdgeInsets.only(left: 16, right: 16), - child: Icon((chatUuid == jsonDecode(item)["uuid"]) - ? Icons.location_on_rounded - : Icons.restore_rounded)) - : const SizedBox(width: 16), - Expanded( - child: Text(jsonDecode(item)["title"], - softWrap: false, - maxLines: 1, - overflow: TextOverflow.fade, - style: - const TextStyle(fontWeight: FontWeight.w500)), - ), - AnimatedSwitcher( - duration: const Duration(milliseconds: 100), - child: - (((desktopFeature() || - (kIsWeb && - desktopLayoutNotRequired( - context))) && - (hoveredChat == - jsonDecode(item)["uuid"])) || - !allowMultipleChats) - ? Padding( - padding: const EdgeInsets.only( - left: 16, right: 16), - child: SizedBox( - height: 24, - width: 24, - child: IconButton( - tooltip: allowMultipleChats - ? allowSettings - ? AppLocalizations.of(context) - .tooltipOptions - : AppLocalizations.of(context) - .deleteChat - : AppLocalizations.of(context) - .tooltipReset, - onPressed: () { - if (!chatAllowed && - chatUuid == - jsonDecode(item)["uuid"]) { - return; - } - if (!allowMultipleChats) { - for (var i = 0; - i < - (prefs!.getStringList( - "chats") ?? - []) - .length; - i++) { - if (jsonDecode((prefs! - .getStringList( - "chats") ?? - [])[i])["uuid"] == - jsonDecode(item)["uuid"]) { - var tmp = prefs! - .getStringList("chats")! - ..removeAt(i); - prefs!.setStringList( - "chats", tmp); - break; - } - } - messages = []; - chatUuid = null; - if (!desktopLayoutRequired( - context)) { - Navigator.of(context).pop(); - } - setState(() {}); - return; - } - if (!allowSettings) { - deleteChatDialog( - context, setState, - additionalCondition: false, - uuid: - jsonDecode(item)["uuid"], - popSidebar: true); - return; - } - if (!desktopLayoutRequired( - context)) { - Navigator.of(context).pop(); - } - showModalBottomSheet( - context: context, - builder: (context) { - return Container( - padding: - const EdgeInsets.only( - left: 16, - right: 16, - top: 16), - child: Column( - mainAxisSize: - MainAxisSize.min, - children: [ - SizedBox( - width: double - .infinity, - child: OutlinedButton - .icon( - onPressed: - () { - Navigator.of(context) - .pop(); - deleteChatDialog( - context, - setState, - uuid: jsonDecode(item)["uuid"], - popSidebar: true); - }, - icon: const Icon(Icons - .delete_forever_rounded), - label: Text( - AppLocalizations.of(context).deleteChat))), - const SizedBox( - height: 8), - SizedBox( - width: double - .infinity, - child: OutlinedButton - .icon( - onPressed: - () async { - Navigator.of(context) - .pop(); - String - oldTitle = - jsonDecode(item)["title"]; - var newTitle = await prompt( - context, - title: AppLocalizations.of(context).dialogEnterNewTitle, - value: oldTitle, - uuid: jsonDecode(item)["uuid"]); - var tmp = - prefs!.getStringList("chats") ?? []; - for (var i = 0; - i < tmp.length; - i++) { - if (jsonDecode((prefs!.getStringList("chats") ?? [])[i])["uuid"] == - jsonDecode(item)["uuid"]) { - var tmp2 = jsonDecode(tmp[i]); - tmp2["title"] = newTitle; - tmp[i] = jsonEncode(tmp2); - break; - } - } - prefs!.setStringList( - "chats", - tmp); - setState( - () {}); - }, - icon: const Icon(Icons - .edit_rounded), - label: Text( - AppLocalizations.of(context).renameChat))), - const SizedBox( - height: 16) - ])); - }); - }, - hoverColor: Colors.transparent, - highlightColor: Colors.transparent, - icon: Transform.translate( - offset: const Offset(-8, -8), - // ignore const suggestion, because values could be not const - // ignore: prefer_const_constructors - child: Icon(allowMultipleChats - ? allowSettings - ? Icons.more_horiz_rounded - : Icons.close_rounded - : Icons.restart_alt_rounded), - ), - ), - )) - : const SizedBox(width: 16)), - ])))); - return (desktopFeature() || - (kIsWeb && desktopLayoutNotRequired(context))) || - !allowMultipleChats - ? child - : Dismissible( - key: Key(jsonDecode(item)["uuid"]), - direction: chatAllowed - ? DismissDirection.startToEnd - : DismissDirection.none, - confirmDismiss: (direction) async { - if (!chatAllowed && chatUuid == jsonDecode(item)["uuid"]) { - return false; - } - return deleteChatDialog(context, setState, takeAction: false); - }, - onDismissed: (direction) { - selectionHaptic(); - for (var i = 0; - i < (prefs!.getStringList("chats") ?? []).length; - i++) { - if (jsonDecode( - (prefs!.getStringList("chats") ?? [])[i])["uuid"] == - jsonDecode(item)["uuid"]) { - var tmp = prefs!.getStringList("chats")!..removeAt(i); - prefs!.setStringList("chats", tmp); - break; - } - } - if (chatUuid == jsonDecode(item)["uuid"]) { - messages = []; - chatUuid = null; - if (!desktopLayoutRequired(context)) { - Navigator.of(context).pop(); - } - } - setState(() {}); - }, - child: child); - }).toList()); - } - - @override - void initState() { - super.initState(); - mainContext = context; - - if (kIsWeb) { - html.querySelector(".loader")?.remove(); - } - - WidgetsBinding.instance.addPostFrameCallback( - (_) async { - if (prefs == null) { - await Future.doWhile( - () => Future.delayed(const Duration(milliseconds: 1)).then((_) { - return prefs == null; - })); - } - - if (!(allowSettings || useHost)) { - showDialog( - // ignore: use_build_context_synchronously - context: context, - builder: (context) { - // ignore: prefer_const_constructors - return PopScope( - canPop: false, - // ignore: prefer_const_constructors - child: Dialog.fullscreen( - backgroundColor: Colors.black, - // ignore: prefer_const_constructors - child: Padding( - padding: const EdgeInsets.all(16), - // ignore: prefer_const_constructors - child: Text( - "*Build Error:*\n\nuseHost: $useHost\nallowSettings: $allowSettings\n\nYou created this build? One of them must be set to true or the app is not functional!\n\nYou received this build by someone else? Please contact them and report the issue.", - style: const TextStyle( - color: Colors.red, - fontFamily: "monospace"))))); - }); - } - - // prefs!.remove("welcomeFinished"); - if (!(prefs!.getBool("welcomeFinished") ?? false) && allowSettings) { - // ignore: use_build_context_synchronously - Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (context) => const ScreenWelcome())); - return; - } - - if (!allowMultipleChats && - (prefs!.getStringList("chats") ?? []).isNotEmpty) { - chatUuid = - jsonDecode((prefs!.getStringList("chats") ?? [])[0])["uuid"]; - loadChat(chatUuid!, setState); - } - - setState(() { - model = useModel ? fixedModel : prefs!.getString("model"); - chatAllowed = !(model == null); - multimodal = prefs?.getBool("multimodal") ?? false; - host = useHost ? fixedHost : prefs?.getString("host"); - }); - - if (host == null) { - // ignore: use_build_context_synchronously - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - // ignore: use_build_context_synchronously - content: Text(AppLocalizations.of(context).noHostSelected), - showCloseIcon: true)); - } - - setState(() {}); - if (prefs!.getBool("checkUpdateOnSettingsOpen") ?? true) { - updateDetectedOnStart = await checkUpdate(setState); - } + builder: (ColorScheme? dynamicLight, ColorScheme? dynamicDark) { + return ThemeBuilder( + data: ThemeBuilderData( + dynamicLight: dynamicLight, + dynamicDark: dynamicDark, + ), + builder: (themeMode, themeLight, themeDark) { + return MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + onGenerateTitle: (context) => + AppLocalizations.of(context).appTitle, + theme: themeLight, + darkTheme: themeDark, + themeMode: themeMode, + home: const ScreenMain(), + ); + }, + ); }, ); } - - @override - Widget build(BuildContext context) { - Widget selector = InkWell( - onTap: !useModel - ? () { - if (host == null) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: - Text(AppLocalizations.of(context).noHostSelected), - showCloseIcon: true)); - return; - } - setModel(context, setState); - } - : null, - splashFactory: NoSplash.splashFactory, - highlightColor: Colors.transparent, - enableFeedback: false, - hoverColor: Colors.transparent, - child: SizedBox( - height: 200, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - (model ?? - AppLocalizations.of(context).noSelectedModel) - .split(":")[0], - overflow: TextOverflow.fade, - style: const TextStyle( - fontFamily: "monospace", fontSize: 16))), - useModel - ? const SizedBox.shrink() - : const Icon(Icons.expand_more_rounded) - ]))); - - return WindowBorder( - color: Theme.of(context).colorScheme.surface, - child: Scaffold( - appBar: AppBar( - titleSpacing: 0, - title: Row( - children: desktopFeature() - ? desktopLayoutRequired(context) - ? [ - SizedBox( - width: 304, height: 200, child: MoveWindow()), - SizedBox( - height: 200, - child: AnimatedOpacity( - opacity: menuVisible ? 1.0 : 0.0, - duration: - const Duration(milliseconds: 300), - child: VerticalDivider( - width: 2, - color: Theme.of(context) - .colorScheme - .onSurface - .withAlpha(20)))), - AnimatedOpacity( - opacity: desktopTitleVisible ? 1.0 : 0.0, - duration: desktopTitleVisible - ? const Duration(milliseconds: 300) - : Duration.zero, - child: Padding( - padding: const EdgeInsets.all(16), - child: selector, - ), - ), - Expanded( - child: SizedBox( - height: 200, child: MoveWindow())) - ] - : [ - SizedBox( - width: 90, height: 200, child: MoveWindow()), - Expanded( - child: SizedBox( - height: 200, child: MoveWindow())), - selector, - Expanded( - child: SizedBox( - height: 200, child: MoveWindow())) - ] - : desktopLayoutRequired(context) - ? [ - // bottom left tile - const SizedBox(width: 304, height: 200), - SizedBox( - height: 200, - child: AnimatedOpacity( - opacity: menuVisible ? 1.0 : 0.0, - duration: - const Duration(milliseconds: 300), - child: VerticalDivider( - width: 2, - color: Theme.of(context) - .colorScheme - .onSurface - .withAlpha(20)))), - AnimatedOpacity( - opacity: desktopTitleVisible ? 1.0 : 0.0, - duration: desktopTitleVisible - ? const Duration(milliseconds: 300) - : Duration.zero, - child: Padding( - padding: const EdgeInsets.all(16), - child: selector, - ), - ), - const Expanded(child: SizedBox(height: 200)) - ] - : [Expanded(child: selector)]), - actions: desktopControlsActions(context, [ - const SizedBox(width: 4), - allowMultipleChats - ? IconButton( - enableFeedback: false, - onPressed: () { - selectionHaptic(); - if (!chatAllowed) return; - deleteChatDialog(context, setState, - additionalCondition: messages.isNotEmpty); - }, - icon: const Icon(Icons.restart_alt_rounded)) - : const SizedBox.shrink() - ]), - bottom: PreferredSize( - preferredSize: const Size.fromHeight(1), - child: (!chatAllowed && model != null) - ? const LinearProgressIndicator() - : desktopLayout(context) - ? AnimatedOpacity( - opacity: menuVisible ? 1.0 : 0.0, - duration: const Duration(milliseconds: 300), - child: Divider( - height: 2, - color: Theme.of(context) - .colorScheme - .onSurface - .withAlpha(20))) - : const SizedBox.shrink()), - automaticallyImplyLeading: !desktopLayoutRequired(context)), - body: Row( - children: [ - desktopLayoutRequired(context) - ? SizedBox( - width: 304, - height: double.infinity, - child: VisibilityDetector( - key: const Key("menuVisible"), - onVisibilityChanged: (VisibilityInfo info) { - if (settingsOpen) return; - menuVisible = info.visibleFraction > 0; - try { - setState(() {}); - } catch (_) {} - }, - child: AnimatedOpacity( - opacity: menuVisible ? 1.0 : 0.0, - duration: const Duration(milliseconds: 300), - child: ListView( - children: sidebar(context, setState))))) - : const SizedBox.shrink(), - desktopLayout(context) - ? AnimatedOpacity( - opacity: menuVisible ? 1.0 : 0.0, - duration: const Duration(milliseconds: 300), - child: VerticalDivider( - width: 2, - color: Theme.of(context) - .colorScheme - .onSurface - .withAlpha(20))) - : const SizedBox.shrink(), - Expanded( - child: Center( - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.max, - children: [ - Flexible( - child: Container( - constraints: const BoxConstraints(maxWidth: 1000), - child: Chat( - messages: messages, - key: chatKey, - textMessageBuilder: (p0, - {required messageWidth, required showName}) { - var white = - const TextStyle(color: Colors.white); - var greyed = false; - var text = p0.text; - if (text.trim() == "") { - text = - "_Empty AI response, try restarting conversation_"; - greyed = true; - } - return Padding( - padding: const EdgeInsets.only( - left: 20, - right: 23, - top: 17, - bottom: 17), - child: Theme( - data: Theme.of(context).copyWith( - scrollbarTheme: - const ScrollbarThemeData( - thumbColor: - WidgetStatePropertyAll( - Colors.grey))), - child: MarkdownBody( - data: text, - onTapLink: (text, href, title) async { - selectionHaptic(); - try { - var url = Uri.parse(href!); - if (await canLaunchUrl(url)) { - launchUrl( - mode: LaunchMode - .inAppBrowserView, - url); - } else { - throw Exception(); - } - } catch (_) { - // ignore: use_build_context_synchronously - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar( - content: Text( - AppLocalizations.of( - // ignore: use_build_context_synchronously - context) - .settingsHostInvalid( - "url")), - showCloseIcon: true)); - } - }, - extensionSet: md.ExtensionSet( - md.ExtensionSet.gitHubFlavored - .blockSyntaxes, - [ - md.EmojiSyntax(), - ...md.ExtensionSet.gitHubFlavored - .inlineSyntaxes - ], - ), - imageBuilder: (uri, title, alt) { - Widget errorImage = InkWell( - onTap: () { - selectionHaptic(); - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar( - content: Text( - AppLocalizations.of( - context) - .notAValidImage), - showCloseIcon: true)); - }, - child: Container( - decoration: BoxDecoration( - borderRadius: - BorderRadius - .circular(8), - color: Theme.of(context) - .brightness == - Brightness.light - ? Colors.white - : Colors.black), - padding: - const EdgeInsets.only( - left: 100, - right: 100, - top: 32), - child: const Image( - image: AssetImage( - "assets/logo512error.png")))); - if (uri.isAbsolute) { - return Image.network( - uri.toString(), errorBuilder: - (context, error, - stackTrace) { - return errorImage; - }); - } else { - return errorImage; - } - }, - styleSheet: (p0.author == user) - ? MarkdownStyleSheet( - p: const TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: - FontWeight.w500), - blockquoteDecoration: - BoxDecoration( - color: Colors.grey[800], - borderRadius: - BorderRadius.circular( - 8), - ), - code: const TextStyle( - color: Colors.black, - backgroundColor: - Colors.white), - codeblockDecoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular( - 8)), - h1: white, - h2: white, - h3: white, - h4: white, - h5: white, - h6: white, - listBullet: white, - horizontalRuleDecoration: BoxDecoration( - border: Border( - top: BorderSide( - color: Colors - .grey[800]!, - width: 1))), - tableBorder: TableBorder.all( - color: Colors.white), - tableBody: white) - : (Theme.of(context).brightness == - Brightness.light) - ? MarkdownStyleSheet( - p: TextStyle( - color: greyed - ? Colors.grey - : Colors.black, - fontSize: 16, - fontWeight: - FontWeight.w500), - blockquoteDecoration: - BoxDecoration( - color: Colors.grey[200], - borderRadius: - BorderRadius - .circular(8), - ), - code: const TextStyle( - color: Colors.white, - backgroundColor: Colors.black), - codeblockDecoration: BoxDecoration(color: Colors.black, borderRadius: BorderRadius.circular(8)), - horizontalRuleDecoration: BoxDecoration(border: Border(top: BorderSide(color: Colors.grey[200]!, width: 1)))) - : MarkdownStyleSheet( - p: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w500), - blockquoteDecoration: BoxDecoration( - color: - Colors.grey[800]!, - borderRadius: - BorderRadius - .circular(8), - ), - code: const TextStyle(color: Colors.black, backgroundColor: Colors.white), - codeblockDecoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8)), - horizontalRuleDecoration: BoxDecoration(border: Border(top: BorderSide(color: Colors.grey[200]!, width: 1))))), - )); - }, - imageMessageBuilder: (p0, - {required messageWidth}) { - return SizedBox( - width: - desktopLayout(context) ? 360.0 : 160.0, - child: MarkdownBody( - data: "![${p0.name}](${p0.uri})")); - }, - disableImageGallery: true, - emptyState: Center( - child: VisibilityDetector( - key: const Key("logoVisible"), - onVisibilityChanged: - (VisibilityInfo info) { - if (settingsOpen) return; - logoVisible = info.visibleFraction > 0; - try { - setState(() {}); - } catch (_) {} - }, - child: AnimatedOpacity( - opacity: logoVisible ? 1.0 : 0.0, - duration: - const Duration(milliseconds: 500), - child: const ImageIcon( - AssetImage("assets/logo512.png"), - size: 44)))), - onSendPressed: (p0) { - send(p0.text, context, setState); - }, - onMessageDoubleTap: (context, p1) { - selectionHaptic(); - if (!chatAllowed) return; - if (p1.author == assistant) return; - for (var i = 0; i < messages.length; i++) { - if (messages[i].id == p1.id) { - var messageList = - (jsonDecode(jsonEncode(messages)) - as List) - .reversed - .toList(); - var found = false; - var index = []; - for (var j = 0; - j < messageList.length; - j++) { - if (messageList[j]["id"] == p1.id) { - found = true; - } - if (found) { - index.add(messageList[j]["id"]); - } - } - for (var j = 0; j < index.length; j++) { - for (var k = 0; - k < messages.length; - k++) { - if (messages[k].id == index[j]) { - messages.removeAt(k); - } - } - } - break; - } - } - saveChat(chatUuid!, setState); - setState(() {}); - }, - onMessageLongPress: (context, p1) async { - selectionHaptic(); - - if (!(prefs!.getBool("enableEditing") ?? - true)) { - return; - } - - var index = -1; - if (!chatAllowed) return; - for (var i = 0; i < messages.length; i++) { - if (messages[i].id == p1.id) { - index = i; - break; - } - } - - var text = - (messages[index] as types.TextMessage).text; - var input = await prompt( - context, - title: AppLocalizations.of(context) - .dialogEditMessageTitle, - value: text, - keyboard: TextInputType.multiline, - maxLines: (text.length >= 100) - ? 10 - : ((text.length >= 50) ? 5 : 3), - ); - if (input == "") return; - - messages[index] = types.TextMessage( - author: p1.author, - createdAt: p1.createdAt, - id: p1.id, - text: input, - ); - setState(() {}); - }, - onAttachmentPressed: (!multimodal) - ? (prefs?.getBool("voiceModeEnabled") ?? - false) - ? (model != null) - ? () { - selectionHaptic(); - setGlobalState = setState; - settingsOpen = true; - logoVisible = false; - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => - const ScreenVoice())); - } - : null - : null - : () { - selectionHaptic(); - if (!chatAllowed || model == null) { - return; - } - if (desktopFeature()) { - FilePicker.platform - .pickFiles(type: FileType.image) - .then((value) async { - if (value == null) return; - if (!multimodal) return; - - var encoded = base64.encode( - await File( - value.files.first.path!) - .readAsBytes()); - messages.insert( - 0, - types.ImageMessage( - author: user, - id: const Uuid().v4(), - name: value.files.first.name, - size: value.files.first.size, - uri: - "data:image/png;base64,$encoded")); - - setState(() {}); - }); - - return; - } - showModalBottomSheet( - context: context, - builder: (context) { - return Container( - width: double.infinity, - padding: const EdgeInsets.only( - left: 16, - right: 16, - top: 16), - child: Column( - mainAxisSize: - MainAxisSize.min, - children: [ - (prefs?.getBool( - "voiceModeEnabled") ?? - false) - ? SizedBox( - width: double - .infinity, - child: OutlinedButton - .icon( - onPressed: - () async { - selectionHaptic(); - Navigator.of(context) - .pop(); - setGlobalState = - setState; - settingsOpen = - true; - logoVisible = - false; - Navigator.of(context).push(MaterialPageRoute( - builder: (context) => - const ScreenVoice())); - }, - icon: const Icon( - Icons - .headphones_rounded), - label: Text( - AppLocalizations.of(context) - .settingsTitleVoice))) - : const SizedBox - .shrink(), - (prefs?.getBool( - "voiceModeEnabled") ?? - false) - ? const SizedBox( - height: 8) - : const SizedBox - .shrink(), - SizedBox( - width: - double.infinity, - child: OutlinedButton - .icon( - onPressed: - () async { - selectionHaptic(); - - Navigator.of( - context) - .pop(); - var result = - await ImagePicker() - .pickImage( - source: ImageSource - .camera, - ); - if (result == - null) { - return; - } - - var bytes = - await result - .readAsBytes(); - var image = - await decodeImageFromList( - bytes); - - var message = - types - .ImageMessage( - author: - user, - createdAt: - DateTime.now() - .millisecondsSinceEpoch, - height: image - .height - .toDouble(), - id: const Uuid() - .v4(), - name: result - .name, - size: bytes - .length, - uri: result - .path, - width: image - .width - .toDouble(), - ); - - messages.insert( - 0, - message); - setState( - () {}); - selectionHaptic(); - }, - icon: const Icon( - Icons - .photo_camera_rounded), - label: Text(AppLocalizations.of( - context) - .takeImage))), - const SizedBox(height: 8), - SizedBox( - width: - double.infinity, - child: OutlinedButton - .icon( - onPressed: - () async { - selectionHaptic(); - - Navigator.of( - context) - .pop(); - var result = - await ImagePicker() - .pickImage( - source: ImageSource - .gallery, - ); - if (result == - null) { - return; - } - - var bytes = - await result - .readAsBytes(); - var image = - await decodeImageFromList( - bytes); - - var message = - types - .ImageMessage( - author: - user, - createdAt: - DateTime.now() - .millisecondsSinceEpoch, - height: image - .height - .toDouble(), - id: const Uuid() - .v4(), - name: result - .name, - size: bytes - .length, - uri: result - .path, - width: image - .width - .toDouble(), - ); - - messages.insert( - 0, - message); - setState( - () {}); - selectionHaptic(); - }, - icon: const Icon( - Icons - .image_rounded), - label: Text(AppLocalizations.of( - context) - .uploadImage))) - ])); - }); - }, - l10n: ChatL10nEn( - inputPlaceholder: AppLocalizations.of(context) - .messageInputPlaceholder, - attachmentButtonAccessibilityLabel: - AppLocalizations.of(context) - .tooltipAttachment, - sendButtonAccessibilityLabel: - AppLocalizations.of(context).tooltipSend), - inputOptions: InputOptions( - keyboardType: TextInputType.multiline, - onTextChanged: (p0) { - setState(() { - sendable = p0.trim().isNotEmpty; - }); - }, - sendButtonVisibilityMode: desktopFeature() - ? SendButtonVisibilityMode.always - : sendable - ? SendButtonVisibilityMode.always - : SendButtonVisibilityMode.hidden), - user: user, - hideBackgroundOnEmojiMessages: false, - theme: (Theme.of(context).brightness == - Brightness.light) - ? DefaultChatTheme( - backgroundColor: - themeLight().colorScheme.surface, - primaryColor: - themeLight().colorScheme.primary, - attachmentButtonIcon: !multimodal - ? (prefs?.getBool("voiceModeEnabled") ?? false) - ? Icon(Icons.headphones_rounded, color: Theme.of(context).iconTheme.color) - : null - : Icon(Icons.add_a_photo_rounded, color: Theme.of(context).iconTheme.color), - sendButtonIcon: SizedBox( - height: 24, - child: CircleAvatar( - backgroundColor: Theme.of(context) - .iconTheme - .color, - radius: 12, - child: Icon( - Icons.arrow_upward_rounded, - color: (prefs?.getBool( - "useDeviceTheme") ?? - false) - ? Theme.of(context) - .colorScheme - .surface - : null)), - ), - sendButtonMargin: EdgeInsets.zero, - attachmentButtonMargin: EdgeInsets.zero, - inputBackgroundColor: themeLight().colorScheme.onSurface.withAlpha(10), - inputTextColor: themeLight().colorScheme.onSurface, - inputBorderRadius: BorderRadius.circular(32), - inputPadding: const EdgeInsets.all(16), - inputMargin: EdgeInsets.only(left: !desktopFeature(web: true) ? 8 : 6, right: !desktopFeature(web: true) ? 8 : 6, bottom: (MediaQuery.of(context).viewInsets.bottom == 0.0 && !desktopFeature(web: true)) ? 0 : 8), - messageMaxWidth: (MediaQuery.of(context).size.width >= 1000) - ? (MediaQuery.of(context).size.width >= 1600) - ? (MediaQuery.of(context).size.width >= 2200) - ? 1900 - : 1300 - : 700 - : 440) - : DarkChatTheme( - backgroundColor: themeDark().colorScheme.surface, - primaryColor: themeDark().colorScheme.primary.withAlpha(40), - secondaryColor: themeDark().colorScheme.primary.withAlpha(20), - attachmentButtonIcon: !multimodal - ? (prefs?.getBool("voiceModeEnabled") ?? false) - ? Icon(Icons.headphones_rounded, color: Theme.of(context).iconTheme.color) - : null - : Icon(Icons.add_a_photo_rounded, color: Theme.of(context).iconTheme.color), - sendButtonIcon: SizedBox( - height: 24, - child: CircleAvatar( - backgroundColor: Theme.of(context) - .iconTheme - .color, - radius: 12, - child: Icon( - Icons.arrow_upward_rounded, - color: (prefs?.getBool( - "useDeviceTheme") ?? - false) - ? Theme.of(context) - .colorScheme - .surface - : null)), - ), - sendButtonMargin: EdgeInsets.zero, - attachmentButtonMargin: EdgeInsets.zero, - inputBackgroundColor: themeDark().colorScheme.onSurface.withAlpha(40), - inputTextColor: themeDark().colorScheme.onSurface, - inputBorderRadius: BorderRadius.circular(32), - inputPadding: const EdgeInsets.all(16), - inputMargin: EdgeInsets.only(left: !desktopFeature(web: true) ? 8 : 6, right: !desktopFeature(web: true) ? 8 : 6, bottom: (MediaQuery.of(context).viewInsets.bottom == 0.0 && !desktopFeature(web: true)) ? 0 : 8), - messageMaxWidth: (MediaQuery.of(context).size.width >= 1000) - ? (MediaQuery.of(context).size.width >= 1600) - ? (MediaQuery.of(context).size.width >= 2200) - ? 1900 - : 1300 - : 700 - : 440)), - ), - ), - ], - ), - ), - ), - ], - ), - drawerEdgeDragWidth: (prefs?.getBool("fixCodeblockScroll") ?? false) - ? null - : (desktopLayout(context) - ? null - : MediaQuery.of(context).size.width), - drawer: Builder(builder: (context) { - if (desktopLayoutRequired(context) && !settingsOpen) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (Navigator.of(context).canPop()) { - Navigator.of(context).pop(); - } - }); - } - return NavigationDrawer( - onDestinationSelected: (value) { - if (value == 1) { - } else if (value == 2) {} - }, - selectedIndex: 1, - children: sidebar(context, setState)); - })), - ); - } } diff --git a/lib/screens/main.dart b/lib/screens/main.dart new file mode 100644 index 0000000..8a5433c --- /dev/null +++ b/lib/screens/main.dart @@ -0,0 +1,1932 @@ +import 'package:flutter/material.dart'; +import 'package:ollama_dart/ollama_dart.dart'; + +// import 'package:flutter_chat_types/flutter_chat_types.dart' as types; +// import 'package:flutter_chat_ui/flutter_chat_ui.dart' as chat_ui; +// import 'package:flutter_markdown/flutter_markdown.dart'; +// import 'package:image_picker/image_picker.dart'; +// import 'package:markdown/markdown.dart' as md; +// import 'package:pwa_install/pwa_install.dart' as pwa; +// import 'package:url_launcher/url_launcher.dart'; +// import 'package:uuid/uuid.dart'; +// import 'package:version/version.dart'; +// import 'package:visibility_detector/visibility_detector.dart'; + +// import '../l10n/gen/app_localizations.dart'; +// import '../main.dart'; +import '../l10n/gen/app_localizations.dart'; +import '../main.dart'; +import '../services/chat.dart'; +import '../services/error.dart'; +import '../services/model.dart'; +import '../services/theme.dart'; +// import '../services/model.dart'; +// import '../services/preferences.dart'; +// import '../worker/desktop.dart'; +// import '../worker/haptic.dart'; +// import '../worker/sender.dart'; +// import '../worker/setter.dart'; +// import '../worker/theme.dart'; +// import '../worker/update.dart'; +// import 'settings.dart'; +// import 'voice.dart'; +// import 'welcome.dart'; + +// class ScreenMain extends StatefulWidget { +// const ScreenMain({super.key}); + +// @override +// State createState() => _ScreenMainState(); +// } + +// class _ScreenMainState extends State { +// int tipId = Random().nextInt(5); + +// List sidebar(BuildContext context, Function setState) { +// var padding = EdgeInsets.only( +// left: desktopLayoutRequired(context) ? 17 : 12, +// right: desktopLayoutRequired(context) ? 17 : 12, +// ); +// return List.from([ +// (desktopLayoutNotRequired(context) || kIsWeb) +// ? const SizedBox(height: 8) +// : const SizedBox.shrink(), +// desktopLayoutNotRequired(context) +// ? const SizedBox.shrink() +// : (Padding( +// padding: padding, +// child: InkWell( +// enableFeedback: false, +// customBorder: const RoundedRectangleBorder( +// borderRadius: BorderRadius.all(Radius.circular(50)), +// ), +// onTap: () async { +// // ester egg? gimmick? not sure if it should be kept +// return; +// // ignore: dead_code +// if (sidebarIconSize != 1) return; +// setState(() { +// sidebarIconSize = 0.8; +// }); +// await Future.delayed(const Duration(milliseconds: 200)); +// setState(() { +// sidebarIconSize = 1.2; +// }); +// await Future.delayed(const Duration(milliseconds: 200)); +// setState(() { +// sidebarIconSize = 1; +// }); +// }, +// child: Padding( +// padding: const EdgeInsets.only(top: 16, bottom: 16), +// child: Row( +// children: [ +// Padding( +// padding: const EdgeInsets.only(left: 16, right: 12), +// child: AnimatedScale( +// scale: sidebarIconSize, +// duration: const Duration(milliseconds: 400), +// child: const ImageIcon( +// AssetImage("assets/logo512.png"), +// ), +// ), +// ), +// Expanded( +// child: Text( +// AppLocalizations.of(context).appTitle, +// softWrap: false, +// overflow: TextOverflow.fade, +// style: const TextStyle(fontWeight: FontWeight.w500), +// ), +// ), +// const SizedBox(width: 16), +// ], +// ), +// ), +// ), +// )), +// (desktopLayoutNotRequired(context) || +// (!allowMultipleChats && !allowSettings)) +// ? const SizedBox.shrink() +// : Divider( +// color: desktopLayout(context) +// ? Theme.of(context).colorScheme.onSurface.withAlpha(20) +// : null, +// ), +// allowMultipleChats +// ? (Padding( +// padding: padding, +// child: InkWell( +// enableFeedback: false, +// customBorder: const RoundedRectangleBorder( +// borderRadius: BorderRadius.all(Radius.circular(50)), +// ), +// onTap: () { +// selectionHaptic(); +// if (!desktopLayout(context)) { +// Navigator.of(context).pop(); +// } +// ChatManager.instance.currentChatId = null; +// }, +// child: Padding( +// padding: const EdgeInsets.only(top: 16, bottom: 16), +// child: Row( +// children: [ +// const Padding( +// padding: EdgeInsets.only(left: 16, right: 12), +// child: Icon(Icons.add_rounded), +// ), +// Expanded( +// child: Text( +// AppLocalizations.of(context).optionNewChat, +// softWrap: false, +// overflow: TextOverflow.fade, +// style: const TextStyle(fontWeight: FontWeight.w500), +// ), +// ), +// const SizedBox(width: 16), +// ], +// ), +// ), +// ), +// )) +// : const SizedBox.shrink(), +// allowSettings +// ? (Padding( +// padding: padding, +// child: InkWell( +// enableFeedback: false, +// customBorder: const RoundedRectangleBorder( +// borderRadius: BorderRadius.all(Radius.circular(50)), +// ), +// onTap: () { +// selectionHaptic(); +// if (!desktopLayout(context)) { +// Navigator.of(context).pop(); +// } +// setState(() { +// settingsOpen = true; +// }); +// Navigator.push( +// context, +// MaterialPageRoute( +// builder: (context) => const ScreenSettings(), +// ), +// ); +// }, +// child: Padding( +// padding: const EdgeInsets.only(top: 16, bottom: 16), +// child: Row( +// children: [ +// Padding( +// padding: const EdgeInsets.only(left: 16, right: 12), +// child: +// (updateStatus == "ok" && +// updateDetectedOnStart && +// (Version.parse(latestVersion ?? "1.0.0") > +// Version.parse(currentVersion ?? "2.0.0"))) +// ? const Badge(child: Icon(Icons.dns_rounded)) +// : const Icon(Icons.dns_rounded), +// ), +// Expanded( +// child: Text( +// AppLocalizations.of(context).optionSettings, +// softWrap: false, +// overflow: TextOverflow.fade, +// style: const TextStyle(fontWeight: FontWeight.w500), +// ), +// ), +// const SizedBox(width: 16), +// ], +// ), +// ), +// ), +// )) +// : const SizedBox.shrink(), +// (pwa.PWAInstall().installPromptEnabled && +// pwa.PWAInstall().launchMode == pwa.LaunchMode.browser) +// ? (Padding( +// padding: padding, +// child: InkWell( +// enableFeedback: false, +// customBorder: const RoundedRectangleBorder( +// borderRadius: BorderRadius.all(Radius.circular(50)), +// ), +// onTap: () { +// selectionHaptic(); +// if (!desktopLayout(context)) { +// Navigator.of(context).pop(); +// } +// pwa.PWAInstall().onAppInstalled = () { +// WidgetsBinding.instance.addPostFrameCallback((_) { +// pwa.setLaunchModePWA(); +// setMainAppState!(() {}); +// }); +// }; +// pwa.PWAInstall().promptInstall_(); +// setState(() {}); +// }, +// child: Padding( +// padding: const EdgeInsets.only(top: 16, bottom: 16), +// child: Row( +// children: [ +// Padding( +// padding: const EdgeInsets.only(left: 16, right: 12), +// child: desktopLayoutNotRequired(context) +// ? const Icon(Icons.install_desktop_rounded) +// : const Icon(Icons.install_mobile_rounded), +// ), +// Expanded( +// child: Text( +// AppLocalizations.of(context).optionInstallPwa, +// softWrap: false, +// overflow: TextOverflow.fade, +// style: const TextStyle(fontWeight: FontWeight.w500), +// ), +// ), +// const SizedBox(width: 16), +// ], +// ), +// ), +// ), +// )) +// : const SizedBox.shrink(), +// (desktopLayoutNotRequired(context) && +// (!allowMultipleChats && !allowSettings)) +// ? const SizedBox.shrink() +// : Divider( +// color: desktopLayout(context) +// ? Theme.of(context).colorScheme.onSurface.withAlpha(20) +// : null, +// ), +// ((prefs?.getStringList("chats") ?? []).isNotEmpty) +// ? const SizedBox.shrink() +// : (Padding( +// padding: padding, +// child: InkWell( +// enableFeedback: false, +// customBorder: const RoundedRectangleBorder( +// borderRadius: BorderRadius.all(Radius.circular(50)), +// ), +// onTap: selectionHaptic, +// child: Padding( +// padding: const EdgeInsets.only(top: 16, bottom: 16), +// child: Row( +// children: [ +// const Padding( +// padding: EdgeInsets.only(left: 16, right: 12), +// child: Icon( +// Icons.question_mark_rounded, +// color: Colors.grey, +// ), +// ), +// Expanded( +// child: Text( +// AppLocalizations.of(context).optionNoChatFound, +// softWrap: false, +// overflow: TextOverflow.fade, +// style: const TextStyle( +// fontWeight: FontWeight.w500, +// color: Colors.grey, +// ), +// ), +// ), +// const SizedBox(width: 16), +// ], +// ), +// ), +// ), +// )), +// Builder( +// builder: (context) { +// var tip = (tipId == 0) +// ? AppLocalizations.of(context).tip0 +// : (tipId == 1) +// ? AppLocalizations.of(context).tip1 +// : (tipId == 2) +// ? AppLocalizations.of(context).tip2 +// : (tipId == 3) +// ? AppLocalizations.of(context).tip3 +// : AppLocalizations.of(context).tip4; +// return (!(prefs?.getBool("tips") ?? true) || +// (prefs?.getStringList("chats") ?? []).isNotEmpty || +// !allowSettings) +// ? const SizedBox.shrink() +// : (Padding( +// padding: padding, +// child: InkWell( +// splashFactory: NoSplash.splashFactory, +// highlightColor: Colors.transparent, +// enableFeedback: false, +// hoverColor: Colors.transparent, +// onTap: () { +// selectionHaptic(); +// var tmpTip = tipId; +// while (tmpTip == tipId) { +// tipId = Random().nextInt(5); +// } +// setState(() {}); +// }, +// child: Padding( +// padding: const EdgeInsets.only(top: 16, bottom: 16), +// child: Row( +// children: [ +// const Padding( +// padding: EdgeInsets.only(left: 16, right: 12), +// child: Icon( +// Icons.tips_and_updates_rounded, +// color: Colors.grey, +// ), +// ), +// Expanded( +// child: Text( +// AppLocalizations.of(context).tipPrefix + tip, +// softWrap: true, +// maxLines: 3, +// overflow: TextOverflow.fade, +// style: const TextStyle( +// fontWeight: FontWeight.w500, +// color: Colors.grey, +// ), +// ), +// ), +// const SizedBox(width: 16), +// ], +// ), +// ), +// ), +// )); +// }, +// ), +// ])..addAll( +// (prefs?.getStringList("chats") ?? []).map((item) { +// var child = Padding( +// padding: padding, +// child: InkWell( +// enableFeedback: false, +// customBorder: const RoundedRectangleBorder( +// borderRadius: BorderRadius.all(Radius.circular(50)), +// ), +// onTap: () { +// selectionHaptic(); +// if (!desktopLayoutRequired(context)) { +// Navigator.of(context).pop(); +// } +// if (!chatAllowed) return; +// if (chatUuid == jsonDecode(item)["uuid"]) return; +// loadChat(jsonDecode(item)["uuid"], setState); +// chatUuid = jsonDecode(item)["uuid"]; +// }, +// onHover: (value) { +// setState(() { +// if (value) { +// hoveredChat = jsonDecode(item)["uuid"]; +// } else { +// hoveredChat = ""; +// } +// }); +// }, +// onLongPress: +// (desktopFeature() || +// (kIsWeb && desktopLayoutNotRequired(context))) +// ? null +// : () async { +// selectionHaptic(); +// if (!chatAllowed && chatUuid == jsonDecode(item)["uuid"]) { +// return; +// } +// if (!allowSettings) return; +// String oldTitle = jsonDecode(item)["title"]; +// var newTitle = await prompt( +// context, +// title: AppLocalizations.of(context).dialogEnterNewTitle, +// value: oldTitle, +// uuid: jsonDecode(item)["uuid"], +// ); +// var tmp = prefs!.getStringList("chats") ?? []; +// for (var i = 0; i < tmp.length; i++) { +// if (jsonDecode( +// (prefs!.getStringList("chats") ?? [])[i], +// )["uuid"] == +// jsonDecode(item)["uuid"]) { +// var tmp2 = jsonDecode(tmp[i]); +// tmp2["title"] = newTitle; +// tmp[i] = jsonEncode(tmp2); +// break; +// } +// } +// prefs!.setStringList("chats", tmp); +// setState(() {}); +// }, +// child: Padding( +// padding: const EdgeInsets.only(top: 16, bottom: 16), +// child: Row( +// children: [ +// allowMultipleChats +// ? Padding( +// padding: const EdgeInsets.only(left: 16, right: 16), +// child: Icon( +// (chatUuid == jsonDecode(item)["uuid"]) +// ? Icons.location_on_rounded +// : Icons.restore_rounded, +// ), +// ) +// : const SizedBox(width: 16), +// Expanded( +// child: Text( +// jsonDecode(item)["title"], +// softWrap: false, +// maxLines: 1, +// overflow: TextOverflow.fade, +// style: const TextStyle(fontWeight: FontWeight.w500), +// ), +// ), +// AnimatedSwitcher( +// duration: const Duration(milliseconds: 100), +// child: +// (((desktopFeature() || +// (kIsWeb && +// desktopLayoutNotRequired(context))) && +// (hoveredChat == jsonDecode(item)["uuid"])) || +// !allowMultipleChats) +// ? Padding( +// padding: const EdgeInsets.only(left: 16, right: 16), +// child: SizedBox( +// height: 24, +// width: 24, +// child: IconButton( +// tooltip: allowMultipleChats +// ? allowSettings +// ? AppLocalizations.of( +// context, +// ).tooltipOptions +// : AppLocalizations.of( +// context, +// ).deleteChat +// : AppLocalizations.of(context).tooltipReset, +// onPressed: () { +// if (!chatAllowed && +// chatUuid == jsonDecode(item)["uuid"]) { +// return; +// } +// if (!allowMultipleChats) { +// for ( +// var i = 0; +// i < +// (prefs!.getStringList("chats") ?? []) +// .length; +// i++ +// ) { +// if (jsonDecode( +// (prefs!.getStringList("chats") ?? +// [])[i], +// )["uuid"] == +// jsonDecode(item)["uuid"]) { +// var tmp = prefs!.getStringList("chats")! +// ..removeAt(i); +// prefs!.setStringList("chats", tmp); +// break; +// } +// } +// messages = []; +// chatUuid = null; +// if (!desktopLayoutRequired(context)) { +// Navigator.of(context).pop(); +// } +// setState(() {}); +// return; +// } +// if (!allowSettings) { +// showDeleteChatDialog( +// context, +// uuid: jsonDecode(item)["uuid"], +// ); +// return; +// } +// if (!desktopLayoutRequired(context)) { +// Navigator.of(context).pop(); +// } +// showModalBottomSheet( +// context: context, +// builder: (context) { +// return Container( +// padding: const EdgeInsets.only( +// left: 16, +// right: 16, +// top: 16, +// ), +// child: Column( +// mainAxisSize: MainAxisSize.min, +// children: [ +// SizedBox( +// width: double.infinity, +// child: OutlinedButton.icon( +// onPressed: () { +// Navigator.of(context).pop(); +// showDeleteChatDialog( +// context, +// uuid: jsonDecode( +// item, +// )["uuid"], +// onDelete: () { +// if (!desktopLayoutRequired( +// context, +// )) { +// Navigator.of( +// context, +// ).pop(); +// } +// }, +// ); +// }, +// icon: const Icon( +// Icons.delete_forever_rounded, +// ), +// label: Text( +// AppLocalizations.of( +// context, +// ).deleteChat, +// ), +// ), +// ), +// const SizedBox(height: 8), +// SizedBox( +// width: double.infinity, +// child: OutlinedButton.icon( +// onPressed: () async { +// Navigator.of(context).pop(); +// String oldTitle = jsonDecode( +// item, +// )["title"]; +// var newTitle = await prompt( +// context, +// title: AppLocalizations.of( +// context, +// ).dialogEnterNewTitle, +// value: oldTitle, +// uuid: jsonDecode( +// item, +// )["uuid"], +// ); +// var tmp = +// prefs!.getStringList( +// "chats", +// ) ?? +// []; +// for ( +// var i = 0; +// i < tmp.length; +// i++ +// ) { +// if (jsonDecode( +// (prefs!.getStringList( +// "chats", +// ) ?? +// [])[i], +// )["uuid"] == +// jsonDecode( +// item, +// )["uuid"]) { +// var tmp2 = jsonDecode( +// tmp[i], +// ); +// tmp2["title"] = newTitle; +// tmp[i] = jsonEncode(tmp2); +// break; +// } +// } +// prefs!.setStringList( +// "chats", +// tmp, +// ); +// setState(() {}); +// }, +// icon: const Icon( +// Icons.edit_rounded, +// ), +// label: Text( +// AppLocalizations.of( +// context, +// ).renameChat, +// ), +// ), +// ), +// const SizedBox(height: 16), +// ], +// ), +// ); +// }, +// ); +// }, +// hoverColor: Colors.transparent, +// highlightColor: Colors.transparent, +// icon: Transform.translate( +// offset: const Offset(-8, -8), +// // ignore const suggestion, because values could be not const +// // ignore: prefer_const_constructors +// child: Icon( +// allowMultipleChats +// ? allowSettings +// ? Icons.more_horiz_rounded +// : Icons.close_rounded +// : Icons.restart_alt_rounded, +// ), +// ), +// ), +// ), +// ) +// : const SizedBox(width: 16), +// ), +// ], +// ), +// ), +// ), +// ); +// return (desktopFeature() || +// (kIsWeb && desktopLayoutNotRequired(context))) || +// !allowMultipleChats +// ? child +// : Dismissible( +// key: Key(jsonDecode(item)["uuid"]), +// direction: chatAllowed +// ? DismissDirection.startToEnd +// : DismissDirection.none, +// confirmDismiss: (direction) async { +// if (!chatAllowed && chatUuid == jsonDecode(item)["uuid"]) { +// return false; +// } +// return showDeleteChatDialog( +// context, +// uuid: jsonDecode(item)["uuid"], +// ); +// }, +// onDismissed: (direction) { +// selectionHaptic(); +// for ( +// var i = 0; +// i < (prefs!.getStringList("chats") ?? []).length; +// i++ +// ) { +// if (jsonDecode( +// (prefs!.getStringList("chats") ?? [])[i], +// )["uuid"] == +// jsonDecode(item)["uuid"]) { +// var tmp = prefs!.getStringList("chats")!..removeAt(i); +// prefs!.setStringList("chats", tmp); +// break; +// } +// } +// if (chatUuid == jsonDecode(item)["uuid"]) { +// messages = []; +// chatUuid = null; +// if (!desktopLayoutRequired(context)) { +// Navigator.of(context).pop(); +// } +// } +// setState(() {}); +// }, +// child: child, +// ); +// }).toList(), +// ); +// } + +// @override +// void initState() { +// super.initState(); +// mainContext = context; + +// WidgetsBinding.instance.addPostFrameCallback((_) async { +// if (prefs == null) { +// await Future.doWhile( +// () => Future.delayed(const Duration(milliseconds: 1)).then((_) { +// return prefs == null; +// }), +// ); +// } + +// if (!mounted) return; + +// if (!(allowSettings || useHost)) { +// showDialog( +// context: context, +// builder: (context) { +// return const PopScope( +// canPop: false, +// child: Dialog.fullscreen( +// backgroundColor: Colors.black, +// child: Padding( +// padding: EdgeInsets.all(16), +// child: Text( +// "*Build Error:*\n\nuseHost: $useHost\nallowSettings: $allowSettings\n\nYou created this build? One of them must be set to true or the app is not functional!\n\nYou received this build by someone else? Please contact them and report the issue.", +// style: TextStyle( +// color: Colors.red, +// fontFamily: "monospace", +// ), +// ), +// ), +// ), +// ); +// }, +// ); +// } + +// // prefs!.remove("welcomeFinished"); +// if (!Preferences.welcomeFinished && allowSettings) { +// Navigator.of(context).pushReplacement( +// MaterialPageRoute(builder: (context) => const ScreenWelcome()), +// ); +// return; +// } + +// if (!allowMultipleChats && +// (prefs!.getStringList("chats") ?? []).isNotEmpty) { +// chatUuid = jsonDecode((prefs!.getStringList("chats") ?? [])[0])["uuid"]; +// loadChat(chatUuid!, setState); +// } + +// setState(() { +// model = useModel ? fixedModel : prefs!.getString("model"); +// chatAllowed = !(model == null); +// multimodal = prefs?.getBool("multimodal") ?? false; +// host = useHost ? fixedHost : prefs?.getString("host"); +// }); + +// if (host == null) { +// // ignore: use_build_context_synchronously +// ScaffoldMessenger.of(context).showSnackBar( +// SnackBar( +// // ignore: use_build_context_synchronously +// content: Text(AppLocalizations.of(context).noHostSelected), +// showCloseIcon: true, +// ), +// ); +// } + +// setState(() {}); +// if (prefs!.getBool("checkUpdateOnSettingsOpen") ?? true) { +// updateDetectedOnStart = await checkUpdate(setState); +// } +// }); +// } + +// @override +// Widget build(BuildContext context) { +// Widget selector = InkWell( +// onTap: !useModel +// ? () { +// if (host == null) { +// ScaffoldMessenger.of(context).showSnackBar( +// SnackBar( +// content: Text(AppLocalizations.of(context).noHostSelected), +// showCloseIcon: true, +// ), +// ); +// return; +// } +// setModel(context, setState); +// } +// : null, +// splashFactory: NoSplash.splashFactory, +// highlightColor: Colors.transparent, +// enableFeedback: false, +// hoverColor: Colors.transparent, +// child: SizedBox( +// height: 200, +// child: Row( +// mainAxisAlignment: MainAxisAlignment.center, +// mainAxisSize: MainAxisSize.min, +// children: [ +// Flexible( +// child: Text( +// (model ?? AppLocalizations.of(context).noSelectedModel).split( +// ":", +// )[0], +// overflow: TextOverflow.fade, +// style: const TextStyle(fontFamily: "monospace", fontSize: 16), +// ), +// ), +// useModel +// ? const SizedBox.shrink() +// : const Icon(Icons.expand_more_rounded), +// ], +// ), +// ), +// ); + +// return WindowBorder( +// color: Theme.of(context).colorScheme.surface, +// child: Scaffold( +// appBar: AppBar( +// titleSpacing: 0, +// title: Row( +// children: desktopFeature() +// ? desktopLayoutRequired(context) +// ? [ +// SizedBox( +// width: 304, +// height: 200, +// child: MoveWindow(), +// ), +// SizedBox( +// height: 200, +// child: AnimatedOpacity( +// opacity: menuVisible ? 1.0 : 0.0, +// duration: const Duration(milliseconds: 300), +// child: VerticalDivider( +// width: 2, +// color: Theme.of( +// context, +// ).colorScheme.onSurface.withAlpha(20), +// ), +// ), +// ), +// AnimatedOpacity( +// opacity: desktopTitleVisible ? 1.0 : 0.0, +// duration: desktopTitleVisible +// ? const Duration(milliseconds: 300) +// : Duration.zero, +// child: Padding( +// padding: const EdgeInsets.all(16), +// child: selector, +// ), +// ), +// Expanded( +// child: SizedBox(height: 200, child: MoveWindow()), +// ), +// ] +// : [ +// SizedBox(width: 90, height: 200, child: MoveWindow()), +// Expanded( +// child: SizedBox(height: 200, child: MoveWindow()), +// ), +// selector, +// Expanded( +// child: SizedBox(height: 200, child: MoveWindow()), +// ), +// ] +// : desktopLayoutRequired(context) +// ? [ +// // bottom left tile +// const SizedBox(width: 304, height: 200), +// SizedBox( +// height: 200, +// child: AnimatedOpacity( +// opacity: menuVisible ? 1.0 : 0.0, +// duration: const Duration(milliseconds: 300), +// child: VerticalDivider( +// width: 2, +// color: Theme.of( +// context, +// ).colorScheme.onSurface.withAlpha(20), +// ), +// ), +// ), +// AnimatedOpacity( +// opacity: desktopTitleVisible ? 1.0 : 0.0, +// duration: desktopTitleVisible +// ? const Duration(milliseconds: 300) +// : Duration.zero, +// child: Padding( +// padding: const EdgeInsets.all(16), +// child: selector, +// ), +// ), +// const Expanded(child: SizedBox(height: 200)), +// ] +// : [Expanded(child: selector)], +// ), +// actions: desktopControlsActions(context, [ +// const SizedBox(width: 4), +// allowMultipleChats +// ? IconButton( +// enableFeedback: false, +// onPressed: () { +// selectionHaptic(); +// if (!chatAllowed) return; +// if (messages.isNotEmpty) showDeleteChatDialog(context); +// }, +// icon: const Icon(Icons.restart_alt_rounded), +// ) +// : const SizedBox.shrink(), +// ]), +// bottom: PreferredSize( +// preferredSize: const Size.fromHeight(1), +// child: (!chatAllowed && model != null) +// ? const LinearProgressIndicator() +// : desktopLayout(context) +// ? AnimatedOpacity( +// opacity: menuVisible ? 1.0 : 0.0, +// duration: const Duration(milliseconds: 300), +// child: Divider( +// height: 2, +// color: Theme.of( +// context, +// ).colorScheme.onSurface.withAlpha(20), +// ), +// ) +// : const SizedBox.shrink(), +// ), +// automaticallyImplyLeading: !desktopLayoutRequired(context), +// ), +// body: Row( +// children: [ +// desktopLayoutRequired(context) +// ? SizedBox( +// width: 304, +// height: double.infinity, +// child: VisibilityDetector( +// key: const Key("menuVisible"), +// onVisibilityChanged: (VisibilityInfo info) { +// if (settingsOpen) return; +// menuVisible = info.visibleFraction > 0; +// try { +// setState(() {}); +// } catch (_) {} +// }, +// child: AnimatedOpacity( +// opacity: menuVisible ? 1.0 : 0.0, +// duration: const Duration(milliseconds: 300), +// child: ListView(children: sidebar(context, setState)), +// ), +// ), +// ) +// : const SizedBox.shrink(), +// desktopLayout(context) +// ? AnimatedOpacity( +// opacity: menuVisible ? 1.0 : 0.0, +// duration: const Duration(milliseconds: 300), +// child: VerticalDivider( +// width: 2, +// color: Theme.of( +// context, +// ).colorScheme.onSurface.withAlpha(20), +// ), +// ) +// : const SizedBox.shrink(), +// Expanded( +// child: Center( +// child: Column( +// crossAxisAlignment: CrossAxisAlignment.center, +// mainAxisSize: MainAxisSize.max, +// children: [ +// Flexible( +// child: Container( +// constraints: const BoxConstraints(maxWidth: 1000), +// child: chat_ui.Chat( +// messages: messages, +// key: chatKey, +// textMessageBuilder: +// (p0, {required messageWidth, required showName}) { +// var white = const TextStyle( +// color: Colors.white, +// ); +// var greyed = false; +// var text = p0.text; +// if (text.trim() == "") { +// text = +// "_Empty AI response, try restarting conversation_"; +// greyed = true; +// } +// return Padding( +// padding: const EdgeInsets.only( +// left: 20, +// right: 23, +// top: 17, +// bottom: 17, +// ), +// child: Theme( +// data: Theme.of(context).copyWith( +// scrollbarTheme: const ScrollbarThemeData( +// thumbColor: WidgetStatePropertyAll( +// Colors.grey, +// ), +// ), +// ), +// child: MarkdownBody( +// data: text, +// onTapLink: (text, href, title) async { +// selectionHaptic(); +// try { +// var url = Uri.parse(href!); +// if (await canLaunchUrl(url)) { +// launchUrl( +// mode: LaunchMode.inAppBrowserView, +// url, +// ); +// } else { +// throw Exception(); +// } +// } catch (_) { +// // ignore: use_build_context_synchronously +// ScaffoldMessenger.of( +// context, +// ).showSnackBar( +// SnackBar( +// content: Text( +// AppLocalizations.of( +// // ignore: use_build_context_synchronously +// context, +// ).settingsHostInvalid("url"), +// ), +// showCloseIcon: true, +// ), +// ); +// } +// }, +// extensionSet: md.ExtensionSet( +// md +// .ExtensionSet +// .gitHubFlavored +// .blockSyntaxes, +// [ +// md.EmojiSyntax(), +// ...md +// .ExtensionSet +// .gitHubFlavored +// .inlineSyntaxes, +// ], +// ), +// imageBuilder: (uri, title, alt) { +// Widget errorImage = InkWell( +// onTap: () { +// selectionHaptic(); +// ScaffoldMessenger.of( +// context, +// ).showSnackBar( +// SnackBar( +// content: Text( +// AppLocalizations.of( +// context, +// ).notAValidImage, +// ), +// showCloseIcon: true, +// ), +// ); +// }, +// child: Container( +// decoration: BoxDecoration( +// borderRadius: +// BorderRadius.circular(8), +// color: +// Theme.of( +// context, +// ).brightness == +// Brightness.light +// ? Colors.white +// : Colors.black, +// ), +// padding: const EdgeInsets.only( +// left: 100, +// right: 100, +// top: 32, +// ), +// child: const Image( +// image: AssetImage( +// "assets/logo512error.png", +// ), +// ), +// ), +// ); +// if (uri.isAbsolute) { +// return Image.network( +// uri.toString(), +// errorBuilder: +// (context, error, stackTrace) { +// return errorImage; +// }, +// ); +// } else { +// return errorImage; +// } +// }, +// styleSheet: (p0.author == user) +// ? MarkdownStyleSheet( +// p: const TextStyle( +// color: Colors.white, +// fontSize: 16, +// fontWeight: FontWeight.w500, +// ), +// blockquoteDecoration: +// BoxDecoration( +// color: Colors.grey[800], +// borderRadius: +// BorderRadius.circular( +// 8, +// ), +// ), +// code: const TextStyle( +// color: Colors.black, +// backgroundColor: Colors.white, +// ), +// codeblockDecoration: +// BoxDecoration( +// color: Colors.white, +// borderRadius: +// BorderRadius.circular( +// 8, +// ), +// ), +// h1: white, +// h2: white, +// h3: white, +// h4: white, +// h5: white, +// h6: white, +// listBullet: white, +// horizontalRuleDecoration: +// BoxDecoration( +// border: Border( +// top: BorderSide( +// color: +// Colors.grey[800]!, +// width: 1, +// ), +// ), +// ), +// tableBorder: TableBorder.all( +// color: Colors.white, +// ), +// tableBody: white, +// ) +// : (Theme.of(context).brightness == +// Brightness.light) +// ? MarkdownStyleSheet( +// p: TextStyle( +// color: greyed +// ? Colors.grey +// : Colors.black, +// fontSize: 16, +// fontWeight: FontWeight.w500, +// ), +// blockquoteDecoration: +// BoxDecoration( +// color: Colors.grey[200], +// borderRadius: +// BorderRadius.circular( +// 8, +// ), +// ), +// code: const TextStyle( +// color: Colors.white, +// backgroundColor: Colors.black, +// ), +// codeblockDecoration: +// BoxDecoration( +// color: Colors.black, +// borderRadius: +// BorderRadius.circular( +// 8, +// ), +// ), +// horizontalRuleDecoration: +// BoxDecoration( +// border: Border( +// top: BorderSide( +// color: +// Colors.grey[200]!, +// width: 1, +// ), +// ), +// ), +// ) +// : MarkdownStyleSheet( +// p: const TextStyle( +// color: Colors.white, +// fontSize: 16, +// fontWeight: FontWeight.w500, +// ), +// blockquoteDecoration: +// BoxDecoration( +// color: Colors.grey[800]!, +// borderRadius: +// BorderRadius.circular( +// 8, +// ), +// ), +// code: const TextStyle( +// color: Colors.black, +// backgroundColor: Colors.white, +// ), +// codeblockDecoration: +// BoxDecoration( +// color: Colors.white, +// borderRadius: +// BorderRadius.circular( +// 8, +// ), +// ), +// horizontalRuleDecoration: +// BoxDecoration( +// border: Border( +// top: BorderSide( +// color: +// Colors.grey[200]!, +// width: 1, +// ), +// ), +// ), +// ), +// ), +// ), +// ); +// }, +// imageMessageBuilder: (p0, {required messageWidth}) { +// return SizedBox( +// width: desktopLayout(context) ? 360.0 : 160.0, +// child: MarkdownBody( +// data: "![${p0.name}](${p0.uri})", +// ), +// ); +// }, +// disableImageGallery: true, +// emptyState: Center( +// child: VisibilityDetector( +// key: const Key("logoVisible"), +// onVisibilityChanged: (VisibilityInfo info) { +// if (settingsOpen) return; +// logoVisible = info.visibleFraction > 0; +// try { +// setState(() {}); +// } catch (_) {} +// }, +// child: AnimatedOpacity( +// opacity: logoVisible ? 1.0 : 0.0, +// duration: const Duration(milliseconds: 500), +// child: const ImageIcon( +// AssetImage("assets/logo512.png"), +// size: 44, +// ), +// ), +// ), +// ), +// onSendPressed: (p0) { +// send(p0.text, context, setState); +// }, +// onMessageDoubleTap: (context, p1) { +// selectionHaptic(); +// if (!chatAllowed) return; +// if (p1.author == assistant) return; +// for (var i = 0; i < messages.length; i++) { +// if (messages[i].id == p1.id) { +// var messageList = +// (jsonDecode(jsonEncode(messages)) as List) +// .reversed +// .toList(); +// var found = false; +// var index = []; +// for (var j = 0; j < messageList.length; j++) { +// if (messageList[j]["id"] == p1.id) { +// found = true; +// } +// if (found) { +// index.add(messageList[j]["id"]); +// } +// } +// for (var j = 0; j < index.length; j++) { +// for (var k = 0; k < messages.length; k++) { +// if (messages[k].id == index[j]) { +// messages.removeAt(k); +// } +// } +// } +// break; +// } +// } +// saveChat(chatUuid!, setState); +// setState(() {}); +// }, +// onMessageLongPress: (context, p1) async { +// selectionHaptic(); + +// if (!(prefs!.getBool("enableEditing") ?? true)) { +// return; +// } + +// var index = -1; +// if (!chatAllowed) return; +// for (var i = 0; i < messages.length; i++) { +// if (messages[i].id == p1.id) { +// index = i; +// break; +// } +// } + +// var text = +// (messages[index] as types.TextMessage).text; +// var input = await prompt( +// context, +// title: AppLocalizations.of( +// context, +// ).dialogEditMessageTitle, +// value: text, +// keyboard: TextInputType.multiline, +// maxLines: (text.length >= 100) +// ? 10 +// : ((text.length >= 50) ? 5 : 3), +// ); +// if (input == "") return; + +// messages[index] = types.TextMessage( +// author: p1.author, +// createdAt: p1.createdAt, +// id: p1.id, +// text: input, +// ); +// setState(() {}); +// }, +// onAttachmentPressed: (!multimodal) +// ? (prefs?.getBool("voiceModeEnabled") ?? false) +// ? (model != null) +// ? () { +// selectionHaptic(); +// setGlobalState = setState; +// settingsOpen = true; +// logoVisible = false; +// Navigator.of(context).push( +// MaterialPageRoute( +// builder: (context) => +// const ScreenVoice(), +// ), +// ); +// } +// : null +// : null +// : () { +// selectionHaptic(); +// if (!chatAllowed || model == null) { +// return; +// } +// if (desktopFeature()) { +// FilePicker.platform +// .pickFiles(type: FileType.image) +// .then((value) async { +// if (value == null) return; +// if (!multimodal) return; + +// var encoded = base64.encode( +// await File( +// value.files.first.path!, +// ).readAsBytes(), +// ); +// messages.insert( +// 0, +// types.ImageMessage( +// author: user, +// id: const Uuid().v4(), +// name: value.files.first.name, +// size: value.files.first.size, +// uri: +// "data:image/png;base64,$encoded", +// ), +// ); + +// setState(() {}); +// }); + +// return; +// } +// showModalBottomSheet( +// context: context, +// builder: (context) { +// return Container( +// width: double.infinity, +// padding: const EdgeInsets.only( +// left: 16, +// right: 16, +// top: 16, +// ), +// child: Column( +// mainAxisSize: MainAxisSize.min, +// children: [ +// (prefs?.getBool( +// "voiceModeEnabled", +// ) ?? +// false) +// ? SizedBox( +// width: double.infinity, +// child: OutlinedButton.icon( +// onPressed: () async { +// selectionHaptic(); +// Navigator.of( +// context, +// ).pop(); +// setGlobalState = +// setState; +// settingsOpen = true; +// logoVisible = false; +// Navigator.of( +// context, +// ).push( +// MaterialPageRoute( +// builder: (context) => +// const ScreenVoice(), +// ), +// ); +// }, +// icon: const Icon( +// Icons +// .headphones_rounded, +// ), +// label: Text( +// AppLocalizations.of( +// context, +// ).settingsTitleVoice, +// ), +// ), +// ) +// : const SizedBox.shrink(), +// (prefs?.getBool( +// "voiceModeEnabled", +// ) ?? +// false) +// ? const SizedBox(height: 8) +// : const SizedBox.shrink(), +// SizedBox( +// width: double.infinity, +// child: OutlinedButton.icon( +// onPressed: () async { +// selectionHaptic(); + +// Navigator.of(context).pop(); +// var result = +// await ImagePicker() +// .pickImage( +// source: ImageSource +// .camera, +// ); +// if (result == null) { +// return; +// } + +// var bytes = await result +// .readAsBytes(); +// var image = +// await decodeImageFromList( +// bytes, +// ); + +// var message = types.ImageMessage( +// author: user, +// createdAt: DateTime.now() +// .millisecondsSinceEpoch, +// height: image.height +// .toDouble(), +// id: const Uuid().v4(), +// name: result.name, +// size: bytes.length, +// uri: result.path, +// width: image.width +// .toDouble(), +// ); + +// messages.insert(0, message); +// setState(() {}); +// selectionHaptic(); +// }, +// icon: const Icon( +// Icons.photo_camera_rounded, +// ), +// label: Text( +// AppLocalizations.of( +// context, +// ).takeImage, +// ), +// ), +// ), +// const SizedBox(height: 8), +// SizedBox( +// width: double.infinity, +// child: OutlinedButton.icon( +// onPressed: () async { +// selectionHaptic(); + +// Navigator.of(context).pop(); +// var result = +// await ImagePicker() +// .pickImage( +// source: ImageSource +// .gallery, +// ); +// if (result == null) { +// return; +// } + +// var bytes = await result +// .readAsBytes(); +// var image = +// await decodeImageFromList( +// bytes, +// ); + +// var message = types.ImageMessage( +// author: user, +// createdAt: DateTime.now() +// .millisecondsSinceEpoch, +// height: image.height +// .toDouble(), +// id: const Uuid().v4(), +// name: result.name, +// size: bytes.length, +// uri: result.path, +// width: image.width +// .toDouble(), +// ); + +// messages.insert(0, message); +// setState(() {}); +// selectionHaptic(); +// }, +// icon: const Icon( +// Icons.image_rounded, +// ), +// label: Text( +// AppLocalizations.of( +// context, +// ).uploadImage, +// ), +// ), +// ), +// ], +// ), +// ); +// }, +// ); +// }, +// l10n: chat_ui.ChatL10nEn( +// inputPlaceholder: AppLocalizations.of( +// context, +// ).messageInputPlaceholder, +// attachmentButtonAccessibilityLabel: +// AppLocalizations.of(context).tooltipAttachment, +// sendButtonAccessibilityLabel: AppLocalizations.of( +// context, +// ).tooltipSend, +// ), +// inputOptions: chat_ui.InputOptions( +// keyboardType: TextInputType.multiline, +// onTextChanged: (p0) { +// setState(() { +// sendable = p0.trim().isNotEmpty; +// }); +// }, +// sendButtonVisibilityMode: desktopFeature() +// ? chat_ui.SendButtonVisibilityMode.always +// : sendable +// ? chat_ui.SendButtonVisibilityMode.always +// : chat_ui.SendButtonVisibilityMode.hidden, +// ), +// user: user, +// hideBackgroundOnEmojiMessages: false, +// theme: +// (Theme.of(context).brightness == Brightness.light) +// ? chat_ui.DefaultChatTheme( +// backgroundColor: +// themeLight().colorScheme.surface, +// primaryColor: +// themeLight().colorScheme.primary, +// attachmentButtonIcon: !multimodal +// ? (prefs?.getBool("voiceModeEnabled") ?? +// false) +// ? Icon( +// Icons.headphones_rounded, +// color: Theme.of( +// context, +// ).iconTheme.color, +// ) +// : null +// : Icon( +// Icons.add_a_photo_rounded, +// color: Theme.of( +// context, +// ).iconTheme.color, +// ), +// sendButtonIcon: SizedBox( +// height: 24, +// child: CircleAvatar( +// backgroundColor: Theme.of( +// context, +// ).iconTheme.color, +// radius: 12, +// child: Icon( +// Icons.arrow_upward_rounded, +// color: +// (prefs?.getBool("useDeviceTheme") ?? +// false) +// ? Theme.of( +// context, +// ).colorScheme.surface +// : null, +// ), +// ), +// ), +// sendButtonMargin: EdgeInsets.zero, +// attachmentButtonMargin: EdgeInsets.zero, +// inputBackgroundColor: themeLight() +// .colorScheme +// .onSurface +// .withAlpha(10), +// inputTextColor: +// themeLight().colorScheme.onSurface, +// inputBorderRadius: BorderRadius.circular(32), +// inputPadding: const EdgeInsets.all(16), +// inputMargin: EdgeInsets.only( +// left: !desktopFeature(web: true) ? 8 : 6, +// right: !desktopFeature(web: true) ? 8 : 6, +// bottom: +// (MediaQuery.of( +// context, +// ).viewInsets.bottom == +// 0.0 && +// !desktopFeature(web: true)) +// ? 0 +// : 8, +// ), +// messageMaxWidth: +// (MediaQuery.of(context).size.width >= +// 1000) +// ? (MediaQuery.of(context).size.width >= +// 1600) +// ? (MediaQuery.of( +// context, +// ).size.width >= +// 2200) +// ? 1900 +// : 1300 +// : 700 +// : 440, +// ) +// : chat_ui.DarkChatTheme( +// backgroundColor: +// themeDark().colorScheme.surface, +// primaryColor: themeDark().colorScheme.primary +// .withAlpha(40), +// secondaryColor: themeDark() +// .colorScheme +// .primary +// .withAlpha(20), +// attachmentButtonIcon: !multimodal +// ? (prefs?.getBool("voiceModeEnabled") ?? +// false) +// ? Icon( +// Icons.headphones_rounded, +// color: Theme.of( +// context, +// ).iconTheme.color, +// ) +// : null +// : Icon( +// Icons.add_a_photo_rounded, +// color: Theme.of( +// context, +// ).iconTheme.color, +// ), +// sendButtonIcon: SizedBox( +// height: 24, +// child: CircleAvatar( +// backgroundColor: Theme.of( +// context, +// ).iconTheme.color, +// radius: 12, +// child: Icon( +// Icons.arrow_upward_rounded, +// color: +// (prefs?.getBool("useDeviceTheme") ?? +// false) +// ? Theme.of( +// context, +// ).colorScheme.surface +// : null, +// ), +// ), +// ), +// sendButtonMargin: EdgeInsets.zero, +// attachmentButtonMargin: EdgeInsets.zero, +// inputBackgroundColor: themeDark() +// .colorScheme +// .onSurface +// .withAlpha(40), +// inputTextColor: +// themeDark().colorScheme.onSurface, +// inputBorderRadius: BorderRadius.circular(32), +// inputPadding: const EdgeInsets.all(16), +// inputMargin: EdgeInsets.only( +// left: !desktopFeature(web: true) ? 8 : 6, +// right: !desktopFeature(web: true) ? 8 : 6, +// bottom: +// (MediaQuery.of( +// context, +// ).viewInsets.bottom == +// 0.0 && +// !desktopFeature(web: true)) +// ? 0 +// : 8, +// ), +// messageMaxWidth: +// (MediaQuery.of(context).size.width >= +// 1000) +// ? (MediaQuery.of(context).size.width >= +// 1600) +// ? (MediaQuery.of( +// context, +// ).size.width >= +// 2200) +// ? 1900 +// : 1300 +// : 700 +// : 440, +// ), +// ), +// ), +// ), +// ], +// ), +// ), +// ), +// ], +// ), +// drawer: Builder( +// builder: (context) { +// if (desktopLayoutRequired(context) && !settingsOpen) { +// WidgetsBinding.instance.addPostFrameCallback((_) { +// if (Navigator.of(context).canPop()) { +// Navigator.of(context).pop(); +// } +// }); +// } +// return NavigationDrawer( +// onDestinationSelected: (value) { +// if (value == 1) { +// } else if (value == 2) {} +// }, +// selectedIndex: 1, +// children: sidebar(context, setState), +// ); +// }, +// ), +// ), +// ); +// } +// } + +class ScreenMain extends StatefulWidget { + const ScreenMain({super.key}); + + @override + State createState() => _ScreenMainState(); +} + +class _ScreenMainState extends State { + @override + void initState() { + super.initState(); + + host = "https://raspimainollama.tunler.net"; + ChatManager.instance.addListener(onUpdate); + ModelManager.instance.addListener(onUpdate); + + prefsReady.future.then((_) async { + if (!mounted) return; + errorGuard( + context, + "Q3L4Z1X6", + () async { + await ModelManager.instance.loadModels(); + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("Models: ${ModelManager.instance.models.length}"), + ), + ); + }, + errorMessage: errorGuardErrorMessageWithFallbackSingle( + OllamaClientException, + "Unable to load models", + ), + enableReporting: false, + ); + + await ChatManager.instance.loadChats(); + if (!mounted) return; + // ScaffoldMessenger.of(context).showSnackBar( + // SnackBar(content: Text("Chats: ${ChatManager.instance.chats.length}")), + // ); + }); + } + + @override + void dispose() { + ChatManager.instance.removeListener(onUpdate); + ModelManager.instance.removeListener(onUpdate); + super.dispose(); + } + + void onUpdate() { + if (mounted) setState(() {}); + } + + bool test = false; + double testSlider = 0.5; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: ListView( + children: [ + const ListTile(title: ThemeModeSwitch()), + const ListTile(title: ThemeSwitch()), + ...ChatManager.instance.chats.map( + (chat) => ScreenMainChatTile(chat: chat), + ), + ListTile( + title: const Text("Add chat"), + onTap: () async { + if (ModelManager.instance.models.isEmpty) return; + var chat = ChatManager.instance.createChat( + context: context, + model: ModelManager.instance.models.first, + ); + + await errorGuard( + context, + "M49WC9CW", + () async { + var msg = TextMessage( + "Hi!", // "Hello! Please explain what you can do.", + sender: MessageSender.user, + ); + return chat.send(msg); + }, + errorMessage: errorGuardErrorMessageWithFallbackSingle( + OllamaClientException, + "Unable to send message", + ), + enableReporting: false, + ); + + if (!context.mounted || !chat.alive) return; + await errorGuard( + context, + "W97BM0DJ", + () async => chat.generateTitle(context: context), + errorMessage: errorGuardErrorMessageWithFallbackSingle( + OllamaClientException, + "Unable to generate chat title", + ), + ); + }, + ), + ], + ), + ); + } +} + +class ScreenMainChatTile extends StatefulWidget { + final Chat chat; + + const ScreenMainChatTile({super.key, required this.chat}); + + @override + State createState() => _ScreenMainChatTileState(); +} + +class _ScreenMainChatTileState extends State { + @override + void initState() { + super.initState(); + widget.chat.addListener(onChange); + } + + @override + void dispose() { + widget.chat.removeListener(onChange); + super.dispose(); + } + + void onChange() { + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + return ListTile( + title: ChatText( + widget.chat.title.emptyOn(AppLocalizations.of(context).newChatTitle), + placeholder: Text(AppLocalizations.of(context).newChatTitle), + ), + subtitle: (widget.chat.messages.isEmpty) + ? const SizedBox.shrink() + : ChatText( + (widget.chat.messages.last as TextMessage).content, + placeholder: const Text(""), + ), + onTap: () => ChatManager.instance.deleteChat(widget.chat), + ); + } +} + +extension on String { + String emptyOn(String other) => (this == other) ? "" : this; +} diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart index 6de5059..6fc1102 100644 --- a/lib/screens/settings.dart +++ b/lib/screens/settings.dart @@ -11,10 +11,10 @@ import 'package:version/version.dart'; import '../l10n/gen/app_localizations.dart'; import '../main.dart'; -import '../services/desktop.dart'; -import '../services/haptic.dart'; -import '../services/setter.dart'; -import '../services/update.dart'; +import '../worker/desktop.dart'; +import '../worker/haptic.dart'; +import '../worker/setter.dart'; +import '../worker/update.dart'; import 'settings/about.dart'; import 'settings/behavior.dart'; import 'settings/export.dart'; diff --git a/lib/screens/settings/about.dart b/lib/screens/settings/about.dart index 678df55..1f5025d 100644 --- a/lib/screens/settings/about.dart +++ b/lib/screens/settings/about.dart @@ -7,9 +7,9 @@ import 'package:version/version.dart'; import '../../l10n/gen/app_localizations.dart'; import '../../main.dart'; -import '../../services/desktop.dart'; -import '../../services/haptic.dart'; -import '../../services/update.dart'; +import '../../worker/desktop.dart'; +import '../../worker/haptic.dart'; +import '../../worker/update.dart'; import '../settings.dart'; class ScreenSettingsAbout extends StatefulWidget { diff --git a/lib/screens/settings/behavior.dart b/lib/screens/settings/behavior.dart index ac8f836..3341048 100644 --- a/lib/screens/settings/behavior.dart +++ b/lib/screens/settings/behavior.dart @@ -4,8 +4,8 @@ import 'package:flutter/material.dart'; import '../../l10n/gen/app_localizations.dart'; import '../../main.dart'; -import '../../services/desktop.dart'; -import '../../services/haptic.dart'; +import '../../worker/desktop.dart'; +import '../../worker/haptic.dart'; import '../settings.dart'; class ScreenSettingsBehavior extends StatefulWidget { diff --git a/lib/screens/settings/export.dart b/lib/screens/settings/export.dart index 72ff60c..3cb99b8 100644 --- a/lib/screens/settings/export.dart +++ b/lib/screens/settings/export.dart @@ -12,8 +12,8 @@ import 'package:universal_html/html.dart' as html; import '../../l10n/gen/app_localizations.dart'; import '../../main.dart'; -import '../../services/desktop.dart'; -import '../../services/haptic.dart'; +import '../../worker/desktop.dart'; +import '../../worker/haptic.dart'; import '../settings.dart'; class ScreenSettingsExport extends StatefulWidget { diff --git a/lib/screens/settings/interface.dart b/lib/screens/settings/interface.dart index f016991..4019ebc 100644 --- a/lib/screens/settings/interface.dart +++ b/lib/screens/settings/interface.dart @@ -7,9 +7,9 @@ import 'package:url_launcher/url_launcher.dart'; import '../../l10n/gen/app_localizations.dart'; import '../../main.dart'; -import '../../services/desktop.dart'; -import '../../services/haptic.dart'; -import '../../services/theme.dart'; +import '../../worker/desktop.dart'; +import '../../worker/haptic.dart'; +import '../../worker/theme.dart'; import '../settings.dart'; class ScreenSettingsInterface extends StatefulWidget { diff --git a/lib/screens/settings/voice.dart b/lib/screens/settings/voice.dart index 7f4f711..21e26f7 100644 --- a/lib/screens/settings/voice.dart +++ b/lib/screens/settings/voice.dart @@ -7,8 +7,8 @@ import 'package:permission_handler/permission_handler.dart'; import '../../l10n/gen/app_localizations.dart'; import '../../main.dart'; -import '../../services/haptic.dart'; -import '../../services/theme.dart'; +import '../../worker/haptic.dart'; +import '../../worker/theme.dart'; import '../settings.dart'; class ScreenSettingsVoice extends StatefulWidget { diff --git a/lib/screens/voice.dart b/lib/screens/voice.dart index db9dd17..913e8fa 100644 --- a/lib/screens/voice.dart +++ b/lib/screens/voice.dart @@ -6,10 +6,10 @@ import 'package:speech_to_text/speech_to_text.dart' as stt; import '../l10n/gen/app_localizations.dart'; import '../main.dart'; import '../services/clients.dart'; -import '../services/haptic.dart'; -import '../services/sender.dart'; -import '../services/setter.dart'; -import '../services/theme.dart'; +import '../worker/haptic.dart'; +import '../worker/sender.dart'; +import '../worker/setter.dart'; +import '../worker/theme.dart'; import 'settings/voice.dart'; class ScreenVoice extends StatefulWidget { diff --git a/lib/screens/welcome.dart b/lib/screens/welcome.dart index 044b6fe..f2ef95c 100644 --- a/lib/screens/welcome.dart +++ b/lib/screens/welcome.dart @@ -3,7 +3,8 @@ import 'package:smooth_page_indicator/smooth_page_indicator.dart'; import 'package:transparent_image/transparent_image.dart'; import '../main.dart'; -import '../services/theme.dart'; +import '../worker/theme.dart'; +import 'main.dart'; class ScreenWelcome extends StatefulWidget { const ScreenWelcome({super.key}); @@ -56,8 +57,10 @@ class _ScreenWelcomeState extends State { curve: Curves.easeInOut); } else { prefs!.setBool("welcomeFinished", true); - Navigator.pushReplacement(context, - MaterialPageRoute(builder: (context) => const MainApp())); + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => const ScreenMain())); } }, child: (page < 2) diff --git a/lib/services/chat.dart b/lib/services/chat.dart new file mode 100644 index 0000000..dfe630d --- /dev/null +++ b/lib/services/chat.dart @@ -0,0 +1,681 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:dartx/dartx.dart'; +import 'package:flutter/material.dart'; +import 'package:ollama_dart/ollama_dart.dart' as ollama; +import 'package:uuid/uuid.dart'; + +import '../l10n/gen/app_localizations.dart'; +import '../main.dart'; +import 'clients.dart' as clients; +import 'haptic.dart'; +import 'model.dart'; +import 'preferences.dart'; + +enum MessageSender { user, assistant } + +class Message extends ChangeNotifier { + final String id; + + final MessageSender sender; + final DateTime createdAt; + + Message({required this.sender, DateTime? createdAt}) + : id = const Uuid().v4(), + createdAt = createdAt ?? DateTime.now(); + + void modify() => notifyListeners(); + + Map toJson() => + throw UnimplementedError("toJson() must be implemented in subclasses"); + + @override + String toString() => toJson().toString(); + + @override + operator ==(Object other) { + return other is Message && other.id == id; + } + + @override + int get hashCode => id.hashCode; +} + +class TextMessage extends Message { + bool _locked = false; + + String _content; + String get content => _content; + + bool _includesError = false; + bool get includesError => _includesError; + + TextMessage(String content, {required super.sender, super.createdAt}) + : _content = content; + + @override + void modify({String? content}) { + if (_locked) throw StateError("Cannot modify a locked message."); + if (content != null) _content = content; + super.modify(); + } + + @override + Map toJson() => { + "type": "text", + "role": sender.name, + "content": content, + "includesError": includesError, + "createdAt": createdAt.toUtc().millisecondsSinceEpoch, + }; + + factory TextMessage.fromStream( + Stream stream, { + required MessageSender sender, + Completer? completer, + void Function(String content)? onContent, + }) { + return TextMessage("", sender: sender, createdAt: DateTime.now()) + .._contentFromStream(stream, completer: completer, onContent: onContent); + } + + Future _contentFromStream( + Stream stream, { + Completer? completer, + void Function(String content)? onContent, + }) async { + assert(!_locked, "Cannot modify a locked message."); + _locked = true; + + try { + await for (var response in stream) { + if (completer?.isCompleted ?? false) return; + _content += response.message.content; + chatHaptic(); + + notifyListeners(); + onContent?.call(_content); + } + } catch (e, s) { + if (completer?.isCompleted ?? false) return; + completer?.completeError(e, s); + } + if (completer?.isCompleted ?? false) return; + _content = _content.trim(); + + Future.delayed(const Duration(milliseconds: 250), heavyHaptic); + completer?.complete(); + _locked = false; + } +} + +class ImageMessage extends Message { + final Uri image; + final String? name; + + ImageMessage({ + required this.image, + this.name, + required super.sender, + super.createdAt, + }); + + @override + Map toJson() => { + "type": "image", + "role": sender.name, + "image": image.toString(), + "name": name, + "createdAt": createdAt.toUtc().millisecondsSinceEpoch, + }; +} + +class Chat extends ChangeNotifier { + Completer? completer; + + bool get alive => ChatManager.instance.chats.contains(this); + bool get active => alive && ChatManager.instance.currentChatId == id; + + final String id; + final DateTime createdAt; + + String? _modelName; + String? get modelName => _modelName; + set modelName(String? name) { + assert(alive, "Chat must be alive to be modified."); + + _modelName = name; + if (ChatManager.instance.currentChatId == id) { + ModelManager.instance.currentModelName = name; + } + notifyListeners(); + } + + Model? get model => ModelManager.instance.models.firstOrNullWhere( + (m) => m.name == _modelName, + ); + set model(Model? model) => modelName = model?.name; + + String _title; + String get title => _title; + set title(String title) { + assert(alive, "Chat must be alive to be modified."); + + _title = title; + notifyListeners(); + } + + final Set _messages; + Set get messages => Set.unmodifiable(_messages); + + final String? system; + + Chat._({ + required String? modelName, + required String title, + required this.createdAt, + Set? messages, + String? system, + }) : id = const Uuid().v4(), + _modelName = modelName, + _title = title, + _messages = messages ?? {}, + system = system ?? Preferences.instance.system { + addListener(ChatManager.instance.notifyListeners); + for (var m in _messages) { + m.addListener(notifyListeners); + } + } + + List toApi() { + var messages = []; + var images = []; + + var systemMessage = system; + if (systemMessage != null) { + messages.add( + ollama.Message(role: ollama.MessageRole.system, content: systemMessage), + ); + } + + for (var message in _messages) { + switch (message) { + case TextMessage message: + messages.add( + ollama.Message( + role: message.sender == MessageSender.user + ? ollama.MessageRole.user + : ollama.MessageRole.assistant, + content: message.content, + images: images.map((e) => e.image.toString()).toList(), + ), + ); + images.clear(); + case ImageMessage message: + images.add(message); + } + } + + return messages; + } + + Map toJson() => { + "id": id, + "model": model?.name, + "createdAt": createdAt.toUtc().millisecondsSinceEpoch, + "title": title, + "messages": _messages.map((e) => e.toJson()).toList(), + "system": system, + }; + + Future generateTitle({ + required BuildContext context, + bool? think = false, + }) async { + assert(alive, "Chat must be alive to be modified."); + assert(model != null, "Chat model must be set to generate a title."); + + if (_messages.isEmpty || + model == null || + !Preferences.instance.generateTitles) { + _title = AppLocalizations.of(mainContext!).newChatTitle; + return; + } + + var effectiveThink = + (think ?? false) && + model!.capabilities.contains(ModelCapability.thinking); + + var content = jsonEncode( + (toJson()["messages"] as List>) + .map( + (e) => e + ..removeWhere( + (k, _) => !["type", "role", "content", "name"].contains(k), + ), + ) + .toList(), + ); + var request = ollama.GenerateChatCompletionRequest( + model: modelName!, + messages: [ + const ollama.Message( + role: ollama.MessageRole.system, + content: + "Generate a two to five word title for the conversation provided by the user. " + "If an object or person is very important in the conversation, put it in the title as well; keep the focus on the main subject. Also make an assumption about things happening in the conversation following the messages provided. " + "You must not put the assistant in the focus and you must not put the word 'assistant' in the title! " + "Use a factual, formal tone; don't make the title dramatic using dramatic words. Preferably use nouns and adjectives, not verbs. Also avoid using words like 'simple' or 'easy' to not belittle the user or their problem. " + "Do preferably use title case. You must not use markdown or any other formatting language! You must not use emojis or any other symbols! You must not use general clauses like 'assistance', 'help' or 'session' in your title!\n\n" + "Example bad titles compared to good titles:\n\n~~User Introduces Themselves~~ -> User Introduction\n~~User Asks for Help with a Problem~~ -> Problem Help\n~~User has a _**big**_ Problem~~ -> Big Problem", + ), + ollama.Message( + role: ollama.MessageRole.user, + content: "```json\n$content\n```", + ), + ], + keepAlive: Preferences.instance.keepAlive, + think: effectiveThink, + ); + + ollama.GenerateChatCompletionResponse generated; + try { + generated = await clients.ollamaClient + .generateChatCompletion(request: request) + .timeout(TimeoutMultiplier.long); + } catch (e, s) { + if (alive) Error.throwWithStackTrace(e, s); + return; + } + var newTitle = generated.message.content; + newTitle = newTitle.replaceAll("\n", " "); + + for (var term in [ + '"', + "'", + "*", + "_", + ".", + ",", + "!", + "?", + ":", + ";", + "(", + ")", + "[", + "]", + "{", + "}", + "<", + ">", + ]) { + newTitle = newTitle.replaceAll(term, ""); + } + + while (newTitle.contains(" " * 2)) { + newTitle = newTitle.replaceAll(" " * 2, " " * 1); + } + + title = newTitle.trim(); + } + + Future send( + Message message, { + bool awaitCompletion = true, + bool? think, + void Function(String)? onContent, + }) async { + assert(alive, "Chat must be alive to be modified."); + + assert(model != null, "Chat model must be set to send messages."); + if (model == null) return; + + _messages.add(message..addListener(notifyListeners)); + + var finalThink = + (think ?? Preferences.instance.thinking) && + model!.capabilities.contains(ModelCapability.thinking); + + if (message is TextMessage && message.sender == MessageSender.user) { + completer = Completer(); + var message = TextMessage.fromStream( + clients.ollamaClient.generateChatCompletionStream( + request: ollama.GenerateChatCompletionRequest( + model: model!.name, + messages: toApi(), + stream: true, + keepAlive: Preferences.instance.keepAlive, + think: finalThink, + ), + ), + sender: MessageSender.assistant, + completer: completer, + onContent: onContent, + )..addListener(notifyListeners); + _messages.add(message); + + var future = completer!.future + .then((_) => ChatManager.instance.saveChats()) + .catchError((e, s) { + message + .._includesError = true + ..modify(); + Error.throwWithStackTrace(e, s); + }); + if (awaitCompletion) await future; + } + + notifyListeners(); + await ChatManager.instance.saveChats(); + } + + void deleteMessage(Message message) { + assert(alive, "Chat must be alive to be modified."); + + _messages.remove(message); + message.removeListener(notifyListeners); + notifyListeners(); + } +} + +class ChatManager extends ChangeNotifier { + static final ChatManager _instance = ChatManager._(); + static ChatManager get instance => _instance; + + String? _currentChatId; + String? get currentChatId => _currentChatId; + set currentChatId(String? id) { + _currentChatId = id; + if (id != null) { + ModelManager.instance.currentModelName = _chats + .singleWhere((e) => e.id == id) + .model! + .name; + } + notifyListeners(); + } + + Chat? get currentChat => _currentChatId == null + ? null + : _chats.singleWhere((e) => e.id == _currentChatId); + set currentChat(Chat? chat) => currentChatId = chat?.id; + + final Set _chats = {}; + Set get chats => Set.unmodifiable(_chats); + + ChatManager._(); + + Future loadChats() async { + DateTime getDateTimeFromMilliseconds(int? milliseconds) { + if (milliseconds == null) return DateTime.now(); + return DateTime.fromMillisecondsSinceEpoch(milliseconds, isUtc: true); + } + + var stored = prefs!.getStringList("chats") ?? []; + _chats.clear(); + for (var chatJson in stored) { + try { + var chatData = jsonDecode(chatJson); + + var messages = {}; + String? system; + for (var message in chatData["messages"]) { + if (message["role"] == "system") { + system = message["content"]; + } + + switch (message["type"]) { + case "text": + messages.add( + TextMessage( + message["content"], + sender: MessageSender.values.byName(message["role"]), + createdAt: getDateTimeFromMilliseconds(message["createdAt"]), + ), + ); + case "image": + messages.add( + ImageMessage( + image: Uri.parse(message["image"]), + name: message["name"], + sender: MessageSender.values.byName(message["role"]), + createdAt: getDateTimeFromMilliseconds(message["createdAt"]), + ), + ); + } + } + + _chats.add( + Chat._( + modelName: chatData["model"], + createdAt: getDateTimeFromMilliseconds(chatData["createdAt"]), + title: chatData["title"], + messages: messages, + system: system, + ), + ); + } catch (_) { + rethrow; + } + } + notifyListeners(); + } + + Future saveChats() async { + prefs!.setStringList( + "chats", + _chats.map((c) => jsonEncode(c.toJson())).toList(), + ); + } + + Chat createChat({ + required BuildContext? context, + required Model? model, + String? title, + String? system, + }) { + assert( + allowMultipleChats || chats.isEmpty, + "Cannot create a new chat when multiple chats are not allowed and there is already a chat.", + ); + + var chat = Chat._( + modelName: model?.name, + createdAt: DateTime.now(), + title: + title ?? + ((context != null) + ? AppLocalizations.of(context).newChatTitle + : "Unnamed Chat"), + system: system ?? Preferences.instance.system, + ); + _chats.add(chat); + _currentChatId = chat.id; + + notifyListeners(); + saveChats(); + + return chat; + } + + void deleteChat(Chat chat) { + _chats.remove(chat); + if (chat.completer?.isCompleted == false) chat.completer!.complete(); + chat.removeListener(notifyListeners); + + if (_currentChatId == chat.id) _currentChatId = null; + + notifyListeners(); + saveChats(); + } +} + +// MARK: Delete Chat Dialog + +Future showDeleteChatDialog( + BuildContext context, { + Chat? chat, + FutureOr Function()? onDelete, +}) { + chat ??= ChatManager.instance.currentChat; + var completer = Completer(); + if (Preferences.instance.askBeforeDeletion) { + showDialog( + context: context, + builder: (context) { + return DeleteChatDialog( + chat: chat!, + onDelete: onDelete, + completer: completer, + ); + }, + ); + } else { + ChatManager.instance.deleteChat(chat!); + } + return completer.future; +} + +class DeleteChatDialog extends StatelessWidget { + final Chat chat; + final FutureOr Function()? onDelete; + final Completer completer; + + const DeleteChatDialog({ + super.key, + required this.chat, + required this.onDelete, + required this.completer, + }); + + @override + Widget build(BuildContext context) { + return PopScope( + onPopInvokedWithResult: (_, _) { + if (!completer.isCompleted) completer.complete(false); + }, + child: AlertDialog( + title: Text(AppLocalizations.of(context).deleteDialogTitle), + content: Text(AppLocalizations.of(context).deleteDialogDescription), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(AppLocalizations.of(context).deleteDialogCancel), + ), + TextButton( + onPressed: () { + ChatManager.instance.deleteChat(chat); + completer.complete(true); + onDelete?.call(); + Navigator.of(context).pop(); + }, + child: Text(AppLocalizations.of(context).deleteDialogDelete), + ), + ], + ), + ); + } +} + +// MARK: Chat Text Widget + +class ChatText extends StatefulWidget { + final String content; + final Duration? flyInDuration; + final Widget? placeholder; + + const ChatText( + this.content, { + super.key, + this.flyInDuration, + this.placeholder, + }); + + @override + State createState() => _ChatTextState(); +} + +class _ChatTextState extends State with TickerProviderStateMixin { + late List _words; + late final List _controllers; + + @override + void initState() { + super.initState(); + _words = _splitWords(widget.content); + _controllers = List.generate(_words.length, (_) => null); + } + + @override + void dispose() { + for (var c in _controllers) { + c?.dispose(); + } + super.dispose(); + } + + List _splitWords(String s) { + if (s.trim().isEmpty) return []; + return s.split(RegExp(r"(?=\s+)")); + } + + @override + Widget build(BuildContext context) { + var newWords = _splitWords( + widget.content.replaceFirst( + RegExp("^${RegExp.escape(_words.join())}"), + "", + ), + ); + + for (var word in newWords.asMap().entries) { + _words.add(word.value); + _controllers.add( + AnimationController( + value: 0, + vsync: this, + duration: widget.flyInDuration ?? Durations.medium1, + ) + ..addListener(() { + var index = word.key + _words.length - newWords.length; + if (_controllers[index]?.value == 1) { + _controllers[index]!.dispose(); + _controllers[index] = null; + } + + setState(() {}); + }) + ..forward(), + ); + } + + var finalColor = + Theme.of(context).textTheme.bodyMedium?.color ?? Colors.black; + return (_words.isEmpty && widget.placeholder != null) + ? widget.placeholder! + : AnimatedSize( + alignment: Alignment.topLeft, + duration: Durations.short2, + child: Text.rich( + TextSpan( + children: _words + .asMap() + .entries + .map( + (e) => TextSpan( + text: e.value, + style: TextStyle( + color: finalColor.withValues( + alpha: _controllers[e.key]?.value, + ), + ), + ), + ) + .toList(), + ), + ), + ); + } +} diff --git a/lib/services/error.dart b/lib/services/error.dart new file mode 100644 index 0000000..febc6c1 --- /dev/null +++ b/lib/services/error.dart @@ -0,0 +1,726 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:dartx/dartx.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:ollama_dart/ollama_dart.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../l10n/gen/app_localizations.dart'; + +final _logIdRegex = RegExp(r"^[A-Z0-9]{2,16}$"); + +typedef ErrorGuardErrorMessageGenerator = String Function(Object exception); +typedef ErrorGuardDetailsMessageGenerator = + String? Function(Object exception, StackTrace stackTrace); +typedef ErrorGuardIgnoreIfGenerator = bool Function(Object exception); + +String _defaultErrorMessage(Object exception) => switch (exception) { + OllamaClientException _ => "Unknown client error", + AssertionError _ => + exception.toString().split(": ").elementAtOrNull(4) ?? + "An assertion failed", + TimeoutException _ => "Request timed out", + SocketException _ || HttpException _ => "Could not connect to server", + TlsException _ => "Could not establish secure connection", + StateError _ => "Invalid state encountered", + _ => "An unknown error occurred", +}; +String? _defaultDetailsMessage( + Object exception, + StackTrace stackTrace, +) => switch (exception) { + OllamaClientException e => + e.body.toString().startsWith("ClientException with SocketException") + ? "A network error occurred while trying to connect to the server." + "\n\nYou may check your network connection or server reachability and try again." + : "The Ollama API client received a faulty response with code `${e.code}`." + "\n\nPlease check your Ollama server or proxy configuration and try again.", + AssertionError _ => + "An assertion failed, meaning that the app is misconfigured or a bug occurred." + "\n\nAssertions are used to check for conditions that should never happen." + "\n\n", + TimeoutException _ => + "Time ran out while waiting for a response from the server." + "\n\nThis might be caused by a slow or unresponsive server, or a network issue.\nYou may try increasing the Timeout Multiplier in the settings.", + SocketException _ || HttpException _ => + "A ${exception.runtimeType.toString().split(RegExp(r"(?=[A-Z])")).join(" ").toLowerCase()} might be caused by a slow or unresponsive server, or a network issue." + "\n\nYou may check your network connection and try again.", + TlsException _ => + "An error occurred while trying to establish a secure connection via TLS." + "\n\nThis might be caused by an invalid or expired certificate, though this should not happen. Please report this issue to the developers.", + StateError _ => + "Now, this is not good.\n\nYou should report this issue to the developers. Try restarting the app.", + _ => null, +}; +bool _defaultIgnoreIf(Object exception) => false; + +ErrorGuardErrorMessageGenerator errorGuardErrorMessageWithFallback( + String? Function(Object exception) errorMessage, +) => (Object exception) { + try { + return errorMessage.call(exception)!; + } catch (e) { + return _defaultErrorMessage(exception); + } +}; +ErrorGuardDetailsMessageGenerator errorGuardDetailsMessageWithFallback( + ErrorGuardDetailsMessageGenerator detailsMessage, +) => (Object exception, StackTrace stackTrace) { + try { + // throws error if null, so catch block is called + return detailsMessage.call(exception, stackTrace)!; + } catch (_) { + return _defaultDetailsMessage(exception, stackTrace); + } +}; + +ErrorGuardErrorMessageGenerator errorGuardErrorMessageWithFallbackSingle( + Type exceptionType, + String message, +) => errorGuardErrorMessageWithFallback( + (exception) => (exception.runtimeType == exceptionType) ? message : null, +); +ErrorGuardDetailsMessageGenerator errorGuardDetailsMessageWithFallbackSingle( + Type exception, + String message, +) => errorGuardDetailsMessageWithFallback( + (e, _) => (e.runtimeType == exception) ? message : null, +); + +/// Runs the given [action] and catches any exceptions that occur. +/// +/// If the execution of [action] is successful, the result of the function is +/// returned. +/// +/// If an exception occurs, a SnackBar is shown with the error message. The user +/// can then view more details about the error and where is occurred and is able +/// to report it. +/// +/// If this function returns `null`, all code flow following this call should +/// be skipped. +/// +/// ***Important:*** You must catch all async functions carefully! Do this using +/// [Future.catchError] with [Error.throwWithStackTrace] as the callback. +/// +/// [logId] is an optional identifier for this [errorGuard] call. It is +/// displayed in the UI dialog and submitted with the report, if done. This +/// should not be a word, but rather a random string. This should be constant +/// across app restarts or re-compiles. It must be a string of 2 to 16 +/// alphanumeric characters, starting with a letter. Other values will be +/// ignored. The recommended length is 8 characters. +/// +/// To generate a random log ID, run: `dart run tools/logid.dart` +/// +/// The [errorMessage] function is used to generate the error message. This +/// should be a short message that describes the error in a user-friendly way. +/// It should not contain any technical details or stack traces, but rather +/// a simple description of what task went wrong. The [errorMessage.exception] +/// parameter should only be used to determine the type of error and should not +/// be used to generate the message directly. The message should not be longer +/// than about 60 characters, while it is recommended to keep it under 35 +/// characters. +/// An example would be "Unable to connect to the server" or "Server didn't +/// return a valid response". +/// +/// The [detailsMessage] function is used to generate a more detailed message. +/// This should contain more information about the error, such as the +/// circumstances and maybe a prediction of the cause. This message should not +/// include any exception or stack trace information, because those are printed +/// separately, but rather a more detailed description of the error. The message +/// can be longer, it is not limited to a specific length, but should still be +/// direct and to the point. This message may contain a link to information +/// about the error, such as a documentation page or a Wiki article separated +/// by a new paragraph using the `` syntax. +/// An example would be "The server might be down for maintenance" or "This +/// might be because of an invalid server configuration". +/// +/// Both [errorMessage] and [detailsMessage] support a rudimentary Markdown-like +/// formatting for inline code (`` `code` ``), italic (`*italic*`) and links +/// (``). +/// +/// [enableReporting] can be used to disable the reporting feature. This can be +/// useful if it's certain that the error is not caused by a bug in the app, +/// but rather by a user error or a misconfiguration. [forceReporting] can be +/// used to force the reporting of the error. +Future errorGuard( + BuildContext context, + String? logId, + FutureOr Function() action, { + ErrorGuardErrorMessageGenerator errorMessage = _defaultErrorMessage, + ErrorGuardDetailsMessageGenerator detailsMessage = _defaultDetailsMessage, + ErrorGuardIgnoreIfGenerator ignoreIf = _defaultIgnoreIf, + bool instantDialog = false, + bool enableDetails = true, + bool enableReporting = true, + bool forceReporting = false, +}) async { + assert( + logId == null || _logIdRegex.hasMatch(logId), + "`logId` must be a string of 2 to 16 alphanumeric characters", + ); + + assert( + enableDetails || !instantDialog, + "`enableDetails` must be `true` if `instantDialog` is `true`", + ); + if (instantDialog) enableDetails = true; + + assert( + enableReporting || !forceReporting, + "`enableReporting` must be `true` if `forceReporting` is true", + ); + if (forceReporting) { + enableReporting = true; + instantDialog = true; + } + + try { + return await Future.value( + action.call(), + ).catchError(Error.throwWithStackTrace); + } catch (exception, stackTrace) { + if (context.mounted && !ignoreIf.call(exception)) { + var dateTime = DateTime.now(); + var colorScheme = Theme.of(context).colorScheme; + + logId = logId?.toUpperCase(); + if (logId != null && !_logIdRegex.hasMatch(logId)) { + logId = null; + } + + String? exceptionText; + try { + exceptionText = exception.toString().trim(); + if (exceptionText.isEmpty) { + exceptionText = null; + } + } catch (_) {} + + String errorMessageText; + try { + errorMessageText = errorMessage.call(exception); + errorMessageText = errorMessageText.trim(); + assert(errorMessageText.isNotEmpty, "Error message must not be empty"); + if (!errorMessageText.endsWith(".")) errorMessageText += "."; + } catch (_) { + errorMessageText = _defaultErrorMessage(exception).trim(); + } + + String? detailsMessageText; + try { + detailsMessageText = detailsMessage.call(exception, stackTrace); + detailsMessageText = detailsMessageText?.trim(); + if (detailsMessageText != null) { + assert( + detailsMessageText.isNotEmpty, + "Details message must not be empty", + ); + if (detailsMessageText.isEmpty) { + detailsMessageText = null; + } else if (!detailsMessageText.endsWith(".") && + !detailsMessageText.endsWith(">")) { + detailsMessageText += "."; + } + } + } catch (_) { + detailsMessageText = _defaultDetailsMessage(exception, stackTrace); + } + + void showErrorDialog() { + if (!context.mounted) return; + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => _ErrorGuardDetailsDialog( + logId: logId, + dateTime: dateTime, + exception: exceptionText, + stackTrace: stackTrace, + errorMessage: errorMessageText, + detailsMessage: detailsMessageText, + enableReporting: enableReporting, + forceReporting: forceReporting, + ), + ); + } + + if (instantDialog) { + showErrorDialog(); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + _removeNewlines(errorMessageText), + style: TextStyle(color: colorScheme.onErrorContainer), + ), + backgroundColor: colorScheme.errorContainer, + action: !enableDetails + ? null + : SnackBarAction( + label: AppLocalizations.of(context).errorGuardDetails, + textColor: colorScheme.onErrorContainer, + onPressed: showErrorDialog, + ), + ), + ); + } + } + return null; + } +} + +String _removeNewlines(String content) => + content.replaceAll(RegExp(r"\s*\n\s*"), " "); + +class _ErrorGuardDetailsDialog extends StatefulWidget { + final String? logId; + final DateTime dateTime; + final String? exception; + final StackTrace stackTrace; + final String errorMessage; + final String? detailsMessage; + final bool enableReporting; + final bool forceReporting; + + const _ErrorGuardDetailsDialog({ + required this.logId, + required this.dateTime, + required this.exception, + required this.stackTrace, + required this.errorMessage, + required this.detailsMessage, + required this.enableReporting, + required this.forceReporting, + }); + + @override + State<_ErrorGuardDetailsDialog> createState() => + _ErrorGuardDetailsDialogState(); +} + +class _ErrorGuardDetailsDialogState extends State<_ErrorGuardDetailsDialog> { + @override + Widget build(BuildContext context) { + return PopScope( + canPop: !widget.forceReporting, + child: AlertDialog( + title: Stack( + children: [ + Align( + alignment: Alignment.centerRight, + child: Transform.translate( + offset: const Offset(0, 2), + child: Text( + widget.dateTime + .toIso8601String() + .split(".") + .first + .replaceFirst("T", "\n"), + textAlign: TextAlign.end, + style: Theme.of(context).textTheme.labelSmall, + ), + ), + ), + Text(AppLocalizations.of(context).errorGuardTitle), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.logId != null) + Transform.translate( + offset: const Offset(0, -20), + child: Text("@${widget.logId}"), + ), + + Text.rich(_contentFormat(context, widget.errorMessage)), + const SizedBox(height: 8), + if (widget.detailsMessage != null) ...[ + _ErrorGuardDetailsPanel( + icon: const Icon(Icons.announcement_outlined), + title: AppLocalizations.of(context).errorGuardDetails, + content: widget.detailsMessage!, + isExpanded: true, + monospaced: false, + ), + const SizedBox(height: 8), + ], + _ErrorGuardDetailsPanel( + icon: const Icon(Icons.cancel_outlined), + title: AppLocalizations.of(context).errorGuardException, + content: + widget.exception ?? "Could not retrieve exception message", + isExpanded: kDebugMode, + monospaced: widget.exception != null, + italic: widget.exception == null, + ), + if (kDebugMode) ...[ + const SizedBox(height: 8), + _ErrorGuardDetailsPanel( + icon: const Icon(Icons.format_list_numbered), + title: AppLocalizations.of(context).errorGuardStackTrace, + content: widget.stackTrace.toString(), + ), + ], + ], + ), + actions: [ + if (widget.enableReporting) + TextButton.icon( + onPressed: _report, + onLongPress: () => + Clipboard.setData(ClipboardData(text: _reportText())), + icon: const Icon(Icons.bug_report), + label: Text(AppLocalizations.of(context).errorGuardReport), + ), + if (!widget.forceReporting) + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(MaterialLocalizations.of(context).closeButtonLabel), + ), + ], + scrollable: true, + alignment: Alignment.bottomCenter, + ), + ); + } + + static TextSpan _contentFormat(BuildContext context, String content) { + content = content.trim().split("\n").map((l) => l.trim()).join("\n"); + if (content.isEmpty) return const TextSpan(text: ""); + // content = content.replaceAll(RegExp(r"(?=)"), "\u{00AD}"); + + var paragraphs = content.split("\n\n").map((p) => p.trim()).toList(); + + InlineSpan parseInline(String text) { + var root = { + "type": "root", + "children": >[], + }; + var stack = >[root]; + var buf = StringBuffer(); + + void flushBufferToCurrent() { + if (buf.isEmpty) return; + var textNode = {"type": "text", "text": buf.toString()}; + (stack.last["children"] as List).add(textNode); + buf.clear(); + } + + var i = 0; + while (i < text.length) { + var ch = text[i]; + if (ch == "\\" && i + 1 < text.length) { + buf.write(text[i + 1]); + i += 2; + continue; + } + + var inCode = stack.last["type"] == "code"; + if (inCode) { + if (ch == "`") { + flushBufferToCurrent(); + stack.removeLast(); + i++; + } else { + buf.write(ch); + i++; + } + continue; + } + + if (ch == '<') { + var endIndex = text.indexOf('>', i + 1); + if (endIndex != -1) { + var url = text.substring(i + 1, endIndex); + if (url.startsWith('https://') || url.startsWith('http://')) { + flushBufferToCurrent(); + var linkNode = {"type": "link", "url": url}; + (stack.last["children"] as List).add(linkNode); + i = endIndex + 1; + continue; + } + } + } + + if (ch == "`") { + flushBufferToCurrent(); + var codeNode = {"type": "code", "children": >[]}; + (stack.last["children"] as List).add(codeNode); + stack.add(codeNode); + i++; + continue; + } + + if (ch == "*") { + flushBufferToCurrent(); + if (stack.last["type"] == "italic") { + stack.removeLast(); + } else { + var n = {"type": "italic", "children": >[]}; + (stack.last['children'] as List).add(n); + stack.add(n); + } + i++; + continue; + } + + buf.write(ch); + i++; + } + + flushBufferToCurrent(); + + InlineSpan build(Map node) { + var type = node["type"] as String; + if (type == "text") { + return TextSpan(text: node["text"] as String); + } + + if (type == "link") { + var url = node["url"] as String; + return TextSpan( + text: url, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () => launchUrl(Uri.parse(url)), + ); + } + + var children = (node["children"] as List) + .map((c) => build(c as Map)) + .toList(); + + switch (type) { + case "root": + return TextSpan(children: children); + case "italic": + return TextSpan( + children: children, + style: const TextStyle(fontStyle: FontStyle.italic), + ); + case "code": + var codeText = (node["children"] as List) + .where((c) => (c as Map)["type"] == "text") + .map((c) => (c as Map)["text"] as String) + .join(); + var widget = DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor), + borderRadius: BorderRadius.circular(4), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: Text( + codeText, + style: const TextStyle(fontFamily: "monospace"), + ), + ), + ); + return WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: widget, + ); + default: + return TextSpan(children: children); + } + } + + return build(root); + } + + return TextSpan( + children: List.generate(paragraphs.length * 2 - 1, (index) { + if (index.isOdd) { + return const TextSpan( + text: "\n\n", + style: TextStyle(height: 0.5, color: Colors.transparent), + ); + } + var text = paragraphs[index ~/ 2]; + return parseInline(text); + }), + ); + } + + String _reportText() => + """ +An exception was thrown during the execution of the app. + +
+Exception + +${(widget.exception != null) ? "```\n${widget.exception}\n```" : "> Not available"} + +
+ +
+Stack Trace + +``` +${widget.stackTrace.toString().trim()} +``` + +
+ +--- + +The app suggested the following cause of the issue: + +- ***Error Message:*** ${_removeNewlines(widget.errorMessage)} +- ***Details Message:*** ${_removeNewlines((widget.detailsMessage ?? "None provided").trim())}""" + .trim(); + + void _report() { + var url = + "https://github.com/JHubi1/ollama-app/issues/new?template=bug.yaml"; + + url += + "&description=${Uri.encodeComponent('Received error: "${widget.errorMessage.replaceFirst(RegExp(r".$"), "")}"${(widget.logId != null) ? " (@${widget.logId})" : ""}')}"; + + var contextText = _reportText(); + url += "&context=${Uri.encodeComponent(contextText)}"; + + Clipboard.setData(ClipboardData(text: url)); + launchUrl(Uri.parse(url)); + } +} + +class _ErrorGuardDetailsPanel extends StatefulWidget { + final Widget? icon; + final String title; + final String content; + final bool isExpanded; + final bool monospaced; + final bool italic; + + const _ErrorGuardDetailsPanel({ + this.icon, + required this.title, + required this.content, + this.isExpanded = false, + this.monospaced = true, + this.italic = false, + }); + + @override + State<_ErrorGuardDetailsPanel> createState() => + _ErrorGuardDetailsPanelState(); +} + +class _ErrorGuardDetailsPanelState extends State<_ErrorGuardDetailsPanel> + with TickerProviderStateMixin { + bool _expanded = false; + late final AnimationController _animationController; + late final Animation _animation; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + value: widget.isExpanded ? 1.0 : 0.0, + duration: kThemeAnimationDuration, + vsync: this, + ); + _expanded = widget.isExpanded; + + _animation = CurvedAnimation( + parent: _animationController, + curve: Curves.fastEaseInToSlowEaseOut, + ); + } + + void _toggleAnimation() { + setState(() { + _expanded = !_expanded; + _expanded + ? _animationController.forward() + : _animationController.reverse(); + }); + } + + Widget _monospacedContent({required Widget child}) { + if (!widget.monospaced) return child; + return SizedBox( + width: double.infinity, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: GestureDetector( + onLongPress: () { + Feedback.forLongPress(context); + Clipboard.setData(ClipboardData(text: widget.content.trim())); + }, + child: child, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + + return DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: theme.dividerColor), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: widget.icon, + title: Text(widget.title), + trailing: ExpandIcon( + onPressed: (_) => _toggleAnimation(), + isExpanded: _expanded, + padding: EdgeInsets.zero, + ), + onTap: _toggleAnimation, + dense: true, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: const EdgeInsets.only(left: 12), + ), + SizeTransition( + sizeFactor: _animation, + axisAlignment: -1, + child: _monospacedContent( + child: Padding( + padding: const EdgeInsets.only(left: 16, right: 16, bottom: 12), + child: widget.monospaced + ? Text( + widget.content.trim(), + style: const TextStyle( + fontFamily: "monospace", + height: kTextHeightNone, + ), + ) + : Text.rich( + _ErrorGuardDetailsDialogState._contentFormat( + context, + widget.content.trim(), + ), + textAlign: TextAlign.justify, + style: TextStyle( + fontStyle: widget.italic ? FontStyle.italic : null, + color: widget.italic ? theme.disabledColor : null, + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/services/haptic.dart b/lib/services/haptic.dart index d73210c..675752a 100644 --- a/lib/services/haptic.dart +++ b/lib/services/haptic.dart @@ -1,24 +1,38 @@ import 'package:flutter/services.dart'; -import '../main.dart'; + +import 'preferences.dart'; void lightHaptic() { - if (!(prefs!.getBool("enableHaptic") ?? true)) return; + if (!Preferences.instance.enableHaptic) return; HapticFeedback.lightImpact(); } void mediumHaptic() { - if (!(prefs!.getBool("enableHaptic") ?? true)) return; + if (!Preferences.instance.enableHaptic) return; HapticFeedback.mediumImpact(); } void heavyHaptic() { - if (!(prefs!.getBool("enableHaptic") ?? true)) return; + if (!Preferences.instance.enableHaptic) return; HapticFeedback.heavyImpact(); } void selectionHaptic() { - if (!(prefs!.getBool("enableHaptic") ?? true)) return; - // same name but for better experience, change behavior - HapticFeedback.lightImpact(); - // HapticFeedback.selectionClick(); + if (!Preferences.instance.enableHaptic) return; + HapticFeedback.selectionClick(); +} + +// MARK: Chat Haptic + +const Duration _hapticChatDelay = Duration(milliseconds: 45); +DateTime _lastHapticChat = DateTime.fromMillisecondsSinceEpoch(0); + +void chatHaptic() { + if (!Preferences.instance.enableHaptic) return; + + var now = DateTime.now(); + if (now.difference(_lastHapticChat) < _hapticChatDelay) return; + _lastHapticChat = now; + + HapticFeedback.selectionClick(); } diff --git a/lib/services/model.dart b/lib/services/model.dart new file mode 100644 index 0000000..2da9733 --- /dev/null +++ b/lib/services/model.dart @@ -0,0 +1,182 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:ollama_dart/ollama_dart.dart' as ollama; + +import '../main.dart'; +import 'clients.dart' as clients; +import 'preferences.dart'; + +typedef ModelCapability = ollama.Capability; + +class Model { + final String name; + + String _family; + String get family => _family; + + Set _families; + Set get families => Set.unmodifiable(_families); + + Set _capabilities; + Set get capabilities => Set.unmodifiable(_capabilities); + + Model._( + this.name, { + required String family, + Set families = const {}, + Set capabilities = const {}, + }) : _family = family, + _families = families, + _capabilities = capabilities; + + factory Model.fromApi({required ollama.Model model, ollama.ModelInfo? info}) { + return Model._( + model.model!, + family: model.details!.family!, + families: model.details!.families?.toSet() ?? {}, + capabilities: info?.capabilities?.toSet() ?? {}, + ); + } + + Future updateData() async { + var data = await clients.ollamaClient.showModelInfo( + request: ollama.ModelInfoRequest(model: name), + ); + + // just in case, but should always be present + if (data.details != null) { + _family = data.details!.family!; + _families = data.details!.families?.toSet() ?? {}; + } + _capabilities = data.capabilities!.toSet(); + } + + Future loadIntoMemory() async { + // unable to use [ollamaClient] here, because the library does not support + // sending a [GenerateChatCompletionRequest] without [messages], which is + // required for loading, otherwise a message will be generated + + var headers = { + "Content-Type": "application/json", + ...(jsonDecode(prefs!.getString("hostHeaders") ?? "{}") as Map), + }; + var body = { + "model": name, + "keep_alive": int.parse(prefs!.getString("keepAlive") ?? "300"), + }; + + await clients.httpClient + .post( + Uri.parse("${clients.ollamaClient.baseUrl}/api/generate"), + headers: headers, + body: jsonEncode(body), + ) + .timeout(TimeoutMultiplier.medium); + } + + @override + operator ==(Object other) { + return other is Model && other.name == name; + } + + @override + int get hashCode => name.hashCode; +} + +class ModelManager extends ChangeNotifier { + static final ModelManager _instance = ModelManager._(); + static ModelManager get instance => _instance; + + bool _initialized = false; + bool get initialized => _initialized; + + String? _currentModelName; + String? get currentModelName => _currentModelName; + set currentModelName(String? name) { + _currentModelName = name; + Preferences.instance.model = name; + notifyListeners(); + } + + Model? get currentModel => _currentModelName == null + ? null + : _models.singleWhere((e) => e.name == _currentModelName); + set currentModel(Model? model) => currentModelName = model?.name; + + final Set _models = {}; + Set get models => Set.unmodifiable(_models); + + ModelManager._() : _currentModelName = Preferences.instance.model; + + Future loadModels({bool fetchCapabilitiesInBackground = true}) async { + var data = await clients.ollamaClient.listModels(); + _models.clear(); + for (var model in data.models!) { + _models.add(Model.fromApi(model: model)); + } + + _initialized = true; + notifyListeners(); + + if (fetchCapabilitiesInBackground) { + compute((_) async { + for (var model in _instance.models) { + await model.updateData().catchError((_) {}); + } + }, null); + } + } +} + +// MARK: Model Set Dialog + +// class ModelSetDialog extends StatefulWidget { +// final bool contentOnly; + +// const ModelSetDialog({super.key, this.contentOnly = false}); + +// @override +// State createState() => _ModelSetDialogState(); +// } + +// class _ModelSetDialogState extends State { +// @override +// void initState() { +// super.initState(); +// ModelManager.instance.addListener(onChange); +// ModelManager.instance.loadModels(); +// } + +// @override +// void dispose() { +// ModelManager.instance.removeListener(onChange); +// super.dispose(); +// } + +// void onChange() { +// setState(() {}); +// } + +// @override +// Widget build(BuildContext context) { +// Widget main = Placeholder(); + +// if (widget.contentOnly) { +// return main; +// } else { +// return AlertDialog(); +// } +// } +// } + +// void showModelSetDialog(BuildContext context) { +// if (Display.of(context).isDesktop) { +// showModalBottomSheet( +// context: context, +// builder: (_) => const ModelSetDialog(contentOnly: true), +// ); +// } else { +// showDialog(context: context, builder: (_) => const ModelSetDialog()); +// } +// } diff --git a/lib/services/preferences.dart b/lib/services/preferences.dart new file mode 100644 index 0000000..edc8d32 --- /dev/null +++ b/lib/services/preferences.dart @@ -0,0 +1,140 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; + +import '../main.dart'; + +class Preferences extends ChangeNotifier { + static final Preferences _instance = Preferences._(); + static Preferences get instance => _instance; + + Preferences._() { + if (!prefsReady.isCompleted) { + prefsReady.future.then((_) => notifyListeners()); + } + } + + String? get host => prefs?.getString("host") ?? (useHost ? fixedHost : null); + set host(String? value) { + if (value == null || value.isEmpty) { + prefs?.remove("host"); + } else { + prefs?.setString("host", value); + } + notifyListeners(); + } + + Map get hostHeaders => + (jsonDecode(prefs?.getString("hostHeaders") ?? "{}") as Map).cast(); + set hostHeaders(Map value) { + if (value.isEmpty) { + prefs?.remove("hostHeaders"); + } else { + prefs?.setString("hostHeaders", jsonEncode(value)); + } + notifyListeners(); + } + + double get timeoutMultiplier => prefs?.getDouble("timeoutMultiplier") ?? 1.0; + set timeoutMultiplier(double value) { + prefs?.setDouble("timeoutMultiplier", value); + notifyListeners(); + } + + bool get useSystem => prefs?.getBool("useSystem") ?? true; + set useSystem(bool value) { + prefs?.setBool("useSystem", value); + notifyListeners(); + } + + String? get system => useSystem + ? prefs?.getString("system") ?? "You are a helpful assistant." + : null; + set system(String? value) { + if (value == null || value.isEmpty) { + prefs?.remove("system"); + } else { + prefs?.setString("system", value); + } + notifyListeners(); + } + + bool get thinking => prefs?.getBool("thinking") ?? true; + set thinking(bool value) { + prefs?.setBool("thinking", value); + notifyListeners(); + } + + bool get generateTitles => prefs?.getBool("generateTitles") ?? true; + set generateTitles(bool value) { + prefs?.setBool("generateTitles", value); + notifyListeners(); + } + + bool get askBeforeDeletion => prefs!.getBool("askBeforeDeletion") ?? false; + set askBeforeDeletion(bool value) { + prefs!.setBool("askBeforeDeletion", value); + notifyListeners(); + } + + int get keepAlive => int.parse(prefs!.getString("keepAlive") ?? "300"); + set keepAlive(int value) { + prefs!.setString("keepAlive", value.toString()); + notifyListeners(); + } + + String? get model => prefs?.getString("model"); + set model(String? value) { + if (value == null || value.isEmpty) { + prefs?.remove("model"); + } else { + prefs?.setString("model", value); + } + notifyListeners(); + } + + bool get welcomeFinished => prefs?.getBool("welcomeFinished") ?? false; + set welcomeFinished(bool value) { + prefs?.setBool("welcomeFinished", value); + notifyListeners(); + } + + bool get enableHaptic => prefs?.getBool("enableHaptic") ?? true; + set enableHaptic(bool value) { + prefs?.setBool("enableHaptic", value); + notifyListeners(); + } + + ThemeMode get themeMode => + ThemeMode.values.byName(prefs?.getString("themeMode") ?? "system"); + set themeMode(ThemeMode value) { + prefs?.setString("themeMode", value.name); + notifyListeners(); + } + + bool get themeSystem => prefs?.getBool("themeSystem") ?? false; + set themeSystem(bool value) { + prefs?.setBool("themeSystem", value); + notifyListeners(); + } +} + +class TimeoutMultiplier { + static Duration calculate(Duration base) => + base * Preferences.instance.timeoutMultiplier; + + /// Short time interval. Equals 3 seconds with default multiplier. + /// + /// This multiplier should not be used for AI tasks, as it is too short. + /// Instead, use it for quick checks or UI updates. + static Duration get short => calculate(const Duration(seconds: 3)); + + /// Medium time interval. Equals 10 seconds with default multiplier. + static Duration get medium => calculate(const Duration(seconds: 10)); + + /// Long time interval. Equals 30 seconds with default multiplier. + static Duration get long => calculate(const Duration(seconds: 30)); + + /// Very long time interval. Equals 60 seconds with default multiplier. + static Duration get veryLong => calculate(const Duration(seconds: 60)); +} diff --git a/lib/services/responsive.dart b/lib/services/responsive.dart new file mode 100644 index 0000000..fa155fd --- /dev/null +++ b/lib/services/responsive.dart @@ -0,0 +1,33 @@ +import 'dart:io' as io; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class Display { + late final Size size; + + Display.from(BuildContext context) { + size = MediaQuery.sizeOf(context); + } + + bool get isMobile => size.width < 600; + + bool get isTabletOrLess => size.width < 1200; + bool get isTablet => size.width >= 600 && size.width < 1200; + bool get isTabletOrMore => size.width >= 600; + + bool get isDesktop => size.width >= 1200; +} + +class LayoutFeature { + LayoutFeature(); + + static bool desktop({bool allowWeb = false}) { + if (!kIsWeb) { + return io.Platform.isWindows || + io.Platform.isLinux || + io.Platform.isMacOS; + } + return allowWeb && kIsWeb; + } +} diff --git a/lib/services/theme.dart b/lib/services/theme.dart index 3dd5333..db3bd72 100644 --- a/lib/services/theme.dart +++ b/lib/services/theme.dart @@ -1,78 +1,211 @@ import 'package:flutter/material.dart'; -import '../main.dart'; +import '../l10n/gen/app_localizations.dart'; +import 'preferences.dart'; +import 'responsive.dart'; -ColorScheme? colorSchemeLight; -ColorScheme? colorSchemeDark; +typedef ThemeBuilderBuilder = + Widget Function( + ThemeMode themeMode, + ThemeData themeLight, + ThemeData themeDark, + ); -ThemeData themeModifier(ThemeData theme) { - return theme.copyWith( - // https://docs.flutter.dev/platform-integration/android/predictive-back#set-up-your-app +class ThemeBuilderData { + ThemeBuilderData? _current; + ThemeBuilderData? get current => _current; + + final ColorScheme? dynamicLight; + final ColorScheme? dynamicDark; + + ThemeBuilderData({required this.dynamicLight, required this.dynamicDark}) { + _current = this; + } + + ThemeData themeModifier(BuildContext context, ThemeData theme) { + var isMobile = Display.from(context).isMobile; + return theme.copyWith( pageTransitionsTheme: const PageTransitionsTheme( builders: { TargetPlatform.android: PredictiveBackPageTransitionsBuilder(), }, ), - sliderTheme: theme.sliderTheme.copyWith(year2023: false)); -} + sliderTheme: theme.sliderTheme.copyWith(year2023: false), + progressIndicatorTheme: theme.progressIndicatorTheme.copyWith( + year2023: false, + ), + snackBarTheme: theme.snackBarTheme.copyWith( + behavior: isMobile ? null : SnackBarBehavior.floating, + width: isMobile ? null : 288, + ), + listTileTheme: theme.listTileTheme.copyWith( + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + ), + ); + } -ThemeData themeCurrent(BuildContext context) { - if (themeMode() == ThemeMode.system) { - if (MediaQuery.of(context).platformBrightness == Brightness.light) { - return themeLight(); + ThemeData themeLight() { + if (Preferences.instance.themeSystem && dynamicLight != null) { + return ThemeData.from(colorScheme: dynamicLight!); } else { - return themeDark(); + return ThemeData.from( + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.black, + dynamicSchemeVariant: DynamicSchemeVariant.content, + ), + ); } - } else { - if (themeMode() == ThemeMode.light) { - return themeLight(); + } + + ThemeData themeDark() { + if (Preferences.instance.themeSystem && dynamicDark != null) { + return ThemeData.from(colorScheme: dynamicDark!); } else { - return themeDark(); + return ThemeData.from( + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.white, + dynamicSchemeVariant: DynamicSchemeVariant.content, + brightness: Brightness.dark, + ).copyWith(surface: Colors.black), + ); } } } -ThemeData themeLight() { - if (!(prefs?.getBool("useDeviceTheme") ?? false) || - colorSchemeLight == null) { - return themeModifier(ThemeData.from( - colorScheme: const ColorScheme( - brightness: Brightness.light, - primary: Colors.black, - onPrimary: Colors.white, - secondary: Colors.white, - onSecondary: Colors.black, - error: Colors.red, - onError: Colors.white, - surface: Colors.white, - onSurface: Colors.black))); - } else { - return themeModifier(ThemeData.from(colorScheme: colorSchemeLight!)); +class ThemeBuilder extends StatefulWidget { + final ThemeBuilderData data; + final ThemeBuilderBuilder builder; + + const ThemeBuilder({super.key, required this.data, required this.builder}); + + @override + State createState() => _ThemeBuilderState(); +} + +class _ThemeBuilderState extends State { + @override + void initState() { + super.initState(); + Preferences.instance.addListener(onChange); + } + + @override + void dispose() { + Preferences.instance.removeListener(onChange); + super.dispose(); + } + + void onChange() { + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + return widget.builder.call( + Preferences.instance.themeMode, + widget.data.themeModifier(context, widget.data.themeLight()), + widget.data.themeModifier(context, widget.data.themeDark()), + ); } } -ThemeData themeDark() { - if (!(prefs?.getBool("useDeviceTheme") ?? false) || colorSchemeDark == null) { - return themeModifier(ThemeData.from( - colorScheme: const ColorScheme( - brightness: Brightness.dark, - primary: Colors.white, - onPrimary: Colors.black, - secondary: Colors.black, - onSecondary: Colors.white, - error: Colors.red, - onError: Colors.black, - surface: Colors.black, - onSurface: Colors.white))); - } else { - return themeModifier(ThemeData.from(colorScheme: colorSchemeDark!)); +class ThemeModeSwitch extends StatefulWidget { + const ThemeModeSwitch({super.key}); + + @override + State createState() => _ThemeModeSwitchState(); +} + +class _ThemeModeSwitchState extends State { + @override + void initState() { + super.initState(); + Preferences.instance.addListener(onChange); + } + + @override + void dispose() { + Preferences.instance.removeListener(onChange); + super.dispose(); + } + + void onChange() { + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + return SegmentedButton( + segments: [ + ButtonSegment( + value: "dark", + icon: const Icon(Icons.dark_mode), + label: Text(AppLocalizations.of(context).settingsBrightnessDark), + ), + ButtonSegment( + value: "system", + icon: const Icon(Icons.brightness_auto), + label: Text(AppLocalizations.of(context).settingsBrightnessSystem), + ), + ButtonSegment( + value: "light", + icon: const Icon(Icons.light_mode), + label: Text(AppLocalizations.of(context).settingsBrightnessLight), + ), + ], + selected: switch (Preferences.instance.themeMode) { + ThemeMode.light => {"light"}, + ThemeMode.dark => {"dark"}, + _ => {"system"}, + }, + onSelectionChanged: (selection) => Preferences.instance.themeMode = + ThemeMode.values.byName(selection.first), + ); } } -ThemeMode themeMode() { - return ((prefs?.getString("brightness") ?? "system") == "system") - ? ThemeMode.system - : ((prefs!.getString("brightness") == "dark") - ? ThemeMode.dark - : ThemeMode.light); +class ThemeSwitch extends StatefulWidget { + const ThemeSwitch({super.key}); + + @override + State createState() => _ThemeSwitchState(); +} + +class _ThemeSwitchState extends State { + @override + void initState() { + super.initState(); + Preferences.instance.addListener(onChange); + } + + @override + void dispose() { + Preferences.instance.removeListener(onChange); + super.dispose(); + } + + void onChange() { + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + return SegmentedButton( + segments: [ + ButtonSegment( + value: "system", + icon: const Icon(Icons.app_shortcut), + label: Text(AppLocalizations.of(context).settingsThemeDevice), + ), + ButtonSegment( + value: "ollama", + icon: const ImageIcon(AssetImage("assets/logo512.png")), + label: Text(AppLocalizations.of(context).settingsThemeOllama), + ), + ], + selected: Preferences.instance.themeSystem ? {"system"} : {"ollama"}, + onSelectionChanged: (selection) => + Preferences.instance.themeSystem = selection.first == "system", + ); + } } diff --git a/lib/widgets/list_tile_setter.dart b/lib/widgets/list_tile_setter.dart new file mode 100644 index 0000000..031dba3 --- /dev/null +++ b/lib/widgets/list_tile_setter.dart @@ -0,0 +1,161 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +Widget? _defaultListTileSetterTitle(dynamic value) { + try { + return Text("Value of type ${value.runtimeType.toString().trim()}"); + } catch (e) { + return null; + } +} + +Widget? _defaultListTileSetterSubtitleBuilder(dynamic value) { + try { + return Text(value.toString()); + } catch (e) { + return null; + } +} + +class ListTileSetter extends StatefulWidget { + final T initialValue; + final ValueChanged? onChanged; + final FutureOr Function(T oldValue) action; + + final Widget? leading; + final Widget? Function(T value)? titleBuilder; + final Widget? Function(T value)? subtitleBuilder; + final Widget? trailing; + final bool? isThreeLine; + final bool? dense; + final VisualDensity? visualDensity; + final ShapeBorder? shape; + final ListTileStyle? style; + final Color? selectedColor; + final Color? iconColor; + final Color? textColor; + final TextStyle? titleTextStyle; + final TextStyle? subtitleTextStyle; + final TextStyle? leadingAndTrailingTextStyle; + final EdgeInsetsGeometry? contentPadding; + final bool enabled; + final ValueChanged? onFocusChange; + final MouseCursor? mouseCursor; + final bool Function(T value)? selected; + final Color? focusColor; + final Color? hoverColor; + final Color? splashColor; + final FocusNode? focusNode; + final bool autofocus; + final Color? tileColor; + final Color? selectedTileColor; + final bool? enableFeedback; + final double? horizontalTitleGap; + final double? minVerticalPadding; + final double? minLeadingWidth; + final double? minTileHeight; + final ListTileTitleAlignment? titleAlignment; + + const ListTileSetter({ + super.key, + required this.initialValue, + this.onChanged, + required this.action, + + this.leading, + this.titleBuilder = _defaultListTileSetterTitle, + this.subtitleBuilder = _defaultListTileSetterSubtitleBuilder, + this.trailing, + + this.isThreeLine, + this.dense, + this.visualDensity, + this.shape, + this.style, + this.selectedColor, + this.iconColor, + this.textColor, + this.titleTextStyle, + this.subtitleTextStyle, + this.leadingAndTrailingTextStyle, + this.contentPadding, + this.enabled = true, + this.onFocusChange, + this.mouseCursor, + this.selected, + this.focusColor, + this.hoverColor, + this.splashColor, + this.focusNode, + this.autofocus = false, + this.tileColor, + this.selectedTileColor, + this.enableFeedback, + this.horizontalTitleGap, + this.minVerticalPadding, + this.minLeadingWidth, + this.minTileHeight, + this.titleAlignment, + }); + + @override + State> createState() => _ListTileSetterState(); +} + +class _ListTileSetterState extends State> { + late T value; + + @override + void initState() { + super.initState(); + value = widget.initialValue; + } + + @override + Widget build(BuildContext context) { + return ListTile( + leading: widget.leading, + title: widget.titleBuilder?.call(value), + subtitle: widget.subtitleBuilder?.call(value), + trailing: widget.trailing, + isThreeLine: widget.isThreeLine, + dense: widget.dense, + visualDensity: widget.visualDensity, + shape: widget.shape, + style: widget.style, + selectedColor: widget.selectedColor, + iconColor: widget.iconColor, + textColor: widget.textColor, + titleTextStyle: widget.titleTextStyle, + subtitleTextStyle: widget.subtitleTextStyle, + leadingAndTrailingTextStyle: widget.leadingAndTrailingTextStyle, + contentPadding: widget.contentPadding, + enabled: widget.enabled, + onFocusChange: widget.onFocusChange, + mouseCursor: widget.mouseCursor, + selected: widget.selected?.call(value) ?? false, + focusColor: widget.focusColor, + hoverColor: widget.hoverColor, + splashColor: widget.splashColor, + focusNode: widget.focusNode, + autofocus: widget.autofocus, + tileColor: widget.tileColor, + selectedTileColor: widget.selectedTileColor, + enableFeedback: widget.enableFeedback, + horizontalTitleGap: widget.horizontalTitleGap, + minVerticalPadding: widget.minVerticalPadding, + minLeadingWidth: widget.minLeadingWidth, + minTileHeight: widget.minTileHeight, + titleAlignment: widget.titleAlignment, + onTap: () async { + value = await widget.action(value); + if (widget.onChanged != null) widget.onChanged!.call(value); + + if (!mounted || !context.mounted) return; + setState(() {}); + FocusScope.of(context).requestFocus(widget.focusNode); + }, + ); + } +} diff --git a/lib/widgets/list_tile_slide.dart b/lib/widgets/list_tile_slide.dart new file mode 100644 index 0000000..54e9bbf --- /dev/null +++ b/lib/widgets/list_tile_slide.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; + +class ListTileSlide extends StatelessWidget { + const ListTileSlide({ + super.key, + required this.value, + this.secondaryTrackValue, + this.onChanged, + this.valueMin, + this.valueMax, + this.divisions, + this.thumbColor, + this.overlayColor, + this.mouseCursor, + this.focusNode, + this.allowedInteraction, + this.autofocus = false, + this.leading, + this.trailing, + this.tileColor, + this.title, + this.subtitle, + this.secondary, + this.isThreeLine, + this.dense = false, + this.contentPadding, + this.selected = false, + this.controlAffinity = ListTileControlAffinity.platform, + this.shape, + this.selectedTileColor, + this.visualDensity, + this.enableFeedback, + }); + + final double value; + final double? secondaryTrackValue; + final ValueChanged? onChanged; + final double? valueMin; + final double? valueMax; + final int? divisions; + final Color? thumbColor; + final WidgetStateProperty? overlayColor; + final MouseCursor? mouseCursor; + final FocusNode? focusNode; + final SliderInteraction? allowedInteraction; + final bool autofocus; + final Widget? leading; + final Widget? trailing; + final Color? tileColor; + final Widget? title; + final Widget? subtitle; + final Widget? secondary; + final bool? isThreeLine; + final bool? dense; + final EdgeInsetsGeometry? contentPadding; + final bool selected; + final ListTileControlAffinity? controlAffinity; + final ShapeBorder? shape; + final Color? selectedTileColor; + final VisualDensity? visualDensity; + final bool? enableFeedback; + + @override + Widget build(BuildContext context) { + var content = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (subtitle != null) subtitle!, + Slider( + value: value, + secondaryTrackValue: (value / (valueMax ?? 1.0)) < 0.98 + ? secondaryTrackValue + : 0, + onChanged: onChanged, + min: valueMin ?? 0.0, + max: valueMax ?? 1.0, + divisions: divisions, + thumbColor: thumbColor, + overlayColor: overlayColor, + mouseCursor: mouseCursor, + focusNode: focusNode, + autofocus: autofocus, + allowedInteraction: allowedInteraction, + padding: EdgeInsets.zero, + ), + ], + ); + + var theme = Theme.of(context); + var switchTheme = SliderTheme.of(context); + var effectiveActiveColor = + thumbColor ?? switchTheme.thumbColor ?? theme.colorScheme.secondary; + + return MergeSemantics( + child: ListTile( + selectedColor: effectiveActiveColor, + leading: leading, + title: title, + subtitle: content, + trailing: trailing, + isThreeLine: isThreeLine, + dense: dense, + contentPadding: contentPadding, + enabled: onChanged != null, + selected: selected, + selectedTileColor: selectedTileColor, + autofocus: autofocus, + shape: shape, + tileColor: tileColor, + visualDensity: visualDensity, + ), + ); + } +} diff --git a/lib/widgets/list_tile_switch.dart b/lib/widgets/list_tile_switch.dart new file mode 100644 index 0000000..457e8cb --- /dev/null +++ b/lib/widgets/list_tile_switch.dart @@ -0,0 +1,363 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +class ListTileSwitchInteractive extends StatelessWidget { + const ListTileSwitchInteractive({ + super.key, + required this.value, + required this.onChanged, + required this.onTap, + this.onLongPress, + this.activeThumbColor, + this.activeTrackColor, + this.inactiveThumbColor, + this.inactiveTrackColor, + this.activeThumbImage, + this.onActiveThumbImageError, + this.inactiveThumbImage, + this.onInactiveThumbImageError, + this.thumbColor, + this.trackColor, + this.trackOutlineColor, + this.thumbIcon, + this.materialTapTargetSize, + this.dragStartBehavior = DragStartBehavior.start, + this.mouseCursor, + this.overlayColor, + this.splashRadius, + this.focusNode, + this.onFocusChange, + this.autofocus = false, + this.tileColor, + this.title, + this.subtitle, + this.isThreeLine, + this.dense, + this.contentPadding, + this.secondary, + this.selected = false, + this.controlAffinity, + this.shape, + this.selectedTileColor, + this.visualDensity, + this.enableFeedback, + this.hoverColor, + }); + + final bool value; + final ValueChanged? onChanged; + final GestureTapCallback? onTap; + final GestureLongPressCallback? onLongPress; + final Color? activeThumbColor; + final Color? activeTrackColor; + final Color? inactiveThumbColor; + final Color? inactiveTrackColor; + final ImageProvider? activeThumbImage; + final ImageErrorListener? onActiveThumbImageError; + final ImageProvider? inactiveThumbImage; + final ImageErrorListener? onInactiveThumbImageError; + final WidgetStateProperty? thumbColor; + final WidgetStateProperty? trackColor; + final WidgetStateProperty? trackOutlineColor; + final WidgetStateProperty? thumbIcon; + final MaterialTapTargetSize? materialTapTargetSize; + final DragStartBehavior dragStartBehavior; + final MouseCursor? mouseCursor; + final WidgetStateProperty? overlayColor; + final double? splashRadius; + final FocusNode? focusNode; + final ValueChanged? onFocusChange; + final bool autofocus; + final Color? tileColor; + final Widget? title; + final Widget? subtitle; + final Widget? secondary; + final bool? isThreeLine; + final bool? dense; + final EdgeInsetsGeometry? contentPadding; + final bool selected; + final ListTileControlAffinity? controlAffinity; + final ShapeBorder? shape; + final Color? selectedTileColor; + final VisualDensity? visualDensity; + final bool? enableFeedback; + final Color? hoverColor; + + @override + Widget build(BuildContext context) { + var listTileTheme = ListTileTheme.of(context); + var effectiveControlAffinity = + controlAffinity ?? + listTileTheme.controlAffinity ?? + ListTileControlAffinity.platform; + + var controlChildren = [ + const SizedBox(height: 32, child: VerticalDivider()), + const SizedBox(width: 8), + Switch( + value: value, + onChanged: onTap != null + ? onChanged != null + ? (value) { + Feedback.forTap(context); + onChanged!(value); + } + : null + : null, + activeThumbColor: activeThumbColor, + activeThumbImage: activeThumbImage, + inactiveThumbImage: inactiveThumbImage, + materialTapTargetSize: + materialTapTargetSize ?? MaterialTapTargetSize.shrinkWrap, + activeTrackColor: activeTrackColor, + inactiveTrackColor: inactiveTrackColor, + inactiveThumbColor: inactiveThumbColor, + autofocus: autofocus, + onFocusChange: onFocusChange, + onActiveThumbImageError: onActiveThumbImageError, + onInactiveThumbImageError: onInactiveThumbImageError, + thumbColor: thumbColor, + trackColor: trackColor, + trackOutlineColor: trackOutlineColor, + thumbIcon: thumbIcon, + dragStartBehavior: dragStartBehavior, + mouseCursor: mouseCursor, + splashRadius: splashRadius, + overlayColor: overlayColor, + ), + ]; + var control = ExcludeFocus( + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: (effectiveControlAffinity == ListTileControlAffinity.leading) + ? controlChildren.reversed.toList() + : controlChildren, + ), + ); + + Widget? leading; + Widget? trailing; + (leading, trailing) = switch (effectiveControlAffinity) { + ListTileControlAffinity.leading => (control, secondary), + ListTileControlAffinity.trailing || + ListTileControlAffinity.platform => (secondary, control), + }; + + var theme = Theme.of(context); + var switchTheme = SwitchTheme.of(context); + var states = {if (selected) WidgetState.selected}; + var effectiveActiveColor = + activeThumbColor ?? + switchTheme.thumbColor?.resolve(states) ?? + theme.colorScheme.secondary; + + var effectiveContentPadding = + contentPadding ?? + EdgeInsets.only( + left: effectiveControlAffinity == ListTileControlAffinity.leading + ? 16 + : (listTileTheme.contentPadding?.horizontal ?? (16.0 * 2)) / 2, + right: effectiveControlAffinity != ListTileControlAffinity.leading + ? 16 + : (listTileTheme.contentPadding?.horizontal ?? (24.0 * 2)) / 2, + ); + + return MergeSemantics( + child: ListTile( + selectedColor: effectiveActiveColor, + leading: leading, + title: title, + subtitle: subtitle, + trailing: trailing, + isThreeLine: isThreeLine, + dense: dense, + contentPadding: effectiveContentPadding, + enabled: onTap != null, + onTap: onTap, + onLongPress: onLongPress, + selected: selected, + selectedTileColor: selectedTileColor, + autofocus: autofocus, + shape: shape, + tileColor: tileColor, + visualDensity: visualDensity, + focusNode: focusNode, + onFocusChange: onFocusChange, + enableFeedback: enableFeedback, + hoverColor: hoverColor, + ), + ); + } +} + +/// Tis is better than [SwitchListTile] because [contentPadding] is actually +/// set correctly +class ListTileSwitch extends StatelessWidget { + const ListTileSwitch({ + super.key, + required this.value, + required this.onChanged, + this.activeThumbColor, + this.activeTrackColor, + this.inactiveThumbColor, + this.inactiveTrackColor, + this.activeThumbImage, + this.onActiveThumbImageError, + this.inactiveThumbImage, + this.onInactiveThumbImageError, + this.thumbColor, + this.trackColor, + this.trackOutlineColor, + this.thumbIcon, + this.materialTapTargetSize, + this.dragStartBehavior = DragStartBehavior.start, + this.mouseCursor, + this.overlayColor, + this.splashRadius, + this.focusNode, + this.onFocusChange, + this.autofocus = false, + this.tileColor, + this.title, + this.subtitle, + this.isThreeLine, + this.dense, + this.contentPadding, + this.secondary, + this.selected = false, + this.controlAffinity, + this.shape, + this.selectedTileColor, + this.visualDensity, + this.enableFeedback, + this.hoverColor, + }); + + final bool value; + final ValueChanged? onChanged; + final Color? activeThumbColor; + final Color? activeTrackColor; + final Color? inactiveThumbColor; + final Color? inactiveTrackColor; + final ImageProvider? activeThumbImage; + final ImageErrorListener? onActiveThumbImageError; + final ImageProvider? inactiveThumbImage; + final ImageErrorListener? onInactiveThumbImageError; + final WidgetStateProperty? thumbColor; + final WidgetStateProperty? trackColor; + final WidgetStateProperty? trackOutlineColor; + final WidgetStateProperty? thumbIcon; + final MaterialTapTargetSize? materialTapTargetSize; + final DragStartBehavior dragStartBehavior; + final MouseCursor? mouseCursor; + final WidgetStateProperty? overlayColor; + final double? splashRadius; + final FocusNode? focusNode; + final ValueChanged? onFocusChange; + final bool autofocus; + final Color? tileColor; + final Widget? title; + final Widget? subtitle; + final Widget? secondary; + final bool? isThreeLine; + final bool? dense; + final EdgeInsetsGeometry? contentPadding; + final bool selected; + final ListTileControlAffinity? controlAffinity; + final ShapeBorder? shape; + final Color? selectedTileColor; + final VisualDensity? visualDensity; + final bool? enableFeedback; + final Color? hoverColor; + + @override + Widget build(BuildContext context) { + var listTileTheme = ListTileTheme.of(context); + var effectiveControlAffinity = + controlAffinity ?? + listTileTheme.controlAffinity ?? + ListTileControlAffinity.platform; + + var control = ExcludeFocus( + child: IgnorePointer( + child: Switch( + value: value, + onChanged: (_) {}, + activeThumbColor: activeThumbColor, + activeThumbImage: activeThumbImage, + inactiveThumbImage: inactiveThumbImage, + materialTapTargetSize: + materialTapTargetSize ?? MaterialTapTargetSize.shrinkWrap, + activeTrackColor: activeTrackColor, + inactiveTrackColor: inactiveTrackColor, + inactiveThumbColor: inactiveThumbColor, + autofocus: autofocus, + onFocusChange: onFocusChange, + onActiveThumbImageError: onActiveThumbImageError, + onInactiveThumbImageError: onInactiveThumbImageError, + thumbColor: thumbColor, + trackColor: trackColor, + trackOutlineColor: trackOutlineColor, + thumbIcon: thumbIcon, + dragStartBehavior: dragStartBehavior, + mouseCursor: mouseCursor, + splashRadius: splashRadius, + overlayColor: overlayColor, + ), + ), + ); + + Widget? leading; + Widget? trailing; + (leading, trailing) = switch (effectiveControlAffinity) { + ListTileControlAffinity.leading => (control, secondary), + ListTileControlAffinity.trailing || + ListTileControlAffinity.platform => (secondary, control), + }; + + var theme = Theme.of(context); + var switchTheme = SwitchTheme.of(context); + var states = {if (selected) WidgetState.selected}; + var effectiveActiveColor = + activeThumbColor ?? + switchTheme.thumbColor?.resolve(states) ?? + theme.colorScheme.secondary; + + var effectiveContentPadding = + contentPadding ?? + EdgeInsets.only( + left: effectiveControlAffinity == ListTileControlAffinity.leading + ? 16 + : (listTileTheme.contentPadding?.horizontal ?? (16.0 * 2)) / 2, + right: effectiveControlAffinity != ListTileControlAffinity.leading + ? 16 + : (listTileTheme.contentPadding?.horizontal ?? (16.0 * 2)) / 2, + ); + + return MergeSemantics( + child: ListTile( + selectedColor: effectiveActiveColor, + leading: leading, + title: title, + subtitle: subtitle, + trailing: trailing, + isThreeLine: isThreeLine, + dense: dense, + contentPadding: effectiveContentPadding, + enabled: onChanged != null, + onTap: onChanged != null ? () => onChanged!(!value) : null, + selected: selected, + selectedTileColor: selectedTileColor, + autofocus: autofocus, + shape: shape, + tileColor: tileColor, + visualDensity: visualDensity, + focusNode: focusNode, + onFocusChange: onFocusChange, + enableFeedback: enableFeedback, + hoverColor: hoverColor, + ), + ); + } +} diff --git a/lib/services/desktop.dart b/lib/worker/desktop.dart similarity index 100% rename from lib/services/desktop.dart rename to lib/worker/desktop.dart diff --git a/lib/services/sender.dart b/lib/worker/sender.dart similarity index 99% rename from lib/services/sender.dart rename to lib/worker/sender.dart index 5023ddd..c8cded5 100644 --- a/lib/services/sender.dart +++ b/lib/worker/sender.dart @@ -11,7 +11,7 @@ import 'package:uuid/uuid.dart'; import '../l10n/gen/app_localizations.dart'; import '../main.dart'; -import 'clients.dart'; +import '../services/clients.dart'; import 'haptic.dart'; import 'setter.dart'; // import 'package:scroll_to_index/scroll_to_index.dart'; diff --git a/lib/services/setter.dart b/lib/worker/setter.dart similarity index 93% rename from lib/services/setter.dart rename to lib/worker/setter.dart index d1cf4e6..b0b1fa4 100644 --- a/lib/services/setter.dart +++ b/lib/worker/setter.dart @@ -4,7 +4,6 @@ import 'dart:io'; import 'package:dartx/dartx.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -// ignore: depend_on_referenced_packages import 'package:flutter_chat_types/flutter_chat_types.dart' as types; import 'package:http/http.dart' as http; import 'package:ollama_dart/ollama_dart.dart' as llama; @@ -12,7 +11,7 @@ import 'package:uuid/uuid.dart'; import '../l10n/gen/app_localizations.dart'; import '../main.dart'; -import 'clients.dart'; +import '../services/clients.dart'; import 'desktop.dart'; import 'haptic.dart'; import 'sender.dart'; @@ -44,9 +43,7 @@ void setModel(BuildContext context, Function setState) { } addIndex = models.length; - // ignore: use_build_context_synchronously models.add(AppLocalizations.of(context).modelDialogAddModel); - // ignore: use_build_context_synchronously modelsReal.add(AppLocalizations.of(context).modelDialogAddModel); modal.add(false); @@ -671,74 +668,6 @@ void loadChat(String uuid, Function setState) { setState(() {}); } -Future deleteChatDialog(BuildContext context, Function setState, - {bool takeAction = true, - bool? additionalCondition, - String? uuid, - bool popSidebar = false}) async { - additionalCondition ??= true; - uuid ??= chatUuid; - - var returnValue = false; - void delete(BuildContext context) { - returnValue = true; - if (takeAction) { - for (var i = 0; i < (prefs!.getStringList("chats") ?? []).length; i++) { - if (jsonDecode((prefs!.getStringList("chats") ?? [])[i])["uuid"] == - uuid) { - var tmp = prefs!.getStringList("chats")!..removeAt(i); - prefs!.setStringList("chats", tmp); - break; - } - } - if (chatUuid == uuid) { - messages = []; - chatUuid = null; - if (!desktopLayoutRequired(context) && - Navigator.of(context).canPop() && - popSidebar) { - Navigator.of(context).pop(); - } - } - } - } - - if ((prefs!.getBool("askBeforeDeletion") ?? false) && additionalCondition) { - await showDialog( - context: context, - builder: (context) { - return StatefulBuilder(builder: (context, setLocalState) { - return AlertDialog( - title: Text(AppLocalizations.of(context).deleteDialogTitle), - content: Column(mainAxisSize: MainAxisSize.min, children: [ - Text(AppLocalizations.of(context).deleteDialogDescription), - ]), - actions: [ - TextButton( - onPressed: () { - selectionHaptic(); - Navigator.of(context).pop(); - }, - child: Text( - AppLocalizations.of(context).deleteDialogCancel)), - TextButton( - onPressed: () { - selectionHaptic(); - Navigator.of(context).pop(); - delete(context); - }, - child: - Text(AppLocalizations.of(context).deleteDialogDelete)) - ]); - }); - }); - } else { - delete(context); - } - setState(() {}); - return returnValue; -} - Future prompt(BuildContext context, {String description = "", String value = "", diff --git a/lib/services/update.dart b/lib/worker/update.dart similarity index 99% rename from lib/services/update.dart rename to lib/worker/update.dart index 91cbe87..21b7540 100644 --- a/lib/services/update.dart +++ b/lib/worker/update.dart @@ -10,7 +10,7 @@ import 'package:version/version.dart'; import '../l10n/gen/app_localizations.dart'; import '../main.dart'; -import 'clients.dart'; +import '../services/clients.dart'; import 'desktop.dart'; import 'haptic.dart'; diff --git a/pubspec.lock b/pubspec.lock index 317dd6c..ab24652 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -13,10 +13,10 @@ packages: dependency: transitive description: name: async - sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" url: "https://pub.dev" source: hosted - version: "2.12.0" + version: "2.13.0" bitsdojo_window: dependency: "direct main" description: @@ -133,10 +133,10 @@ packages: dependency: "direct main" description: name: datetime_loop - sha256: "123784357129a92c85bdfc14a65d3e2f86e69860974eedb48bebc8f93b6f60cb" + sha256: "2112dd246d786a7753ac510e2a6e9dfbe7f990090b5a75c66f67a0a23e6e3a4c" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.3.1" diffutil_dart: dependency: transitive description: @@ -153,14 +153,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" - dynamic_color: + dynamic_system_colors: dependency: "direct main" description: - name: dynamic_color - sha256: eae98052fa6e2826bdac3dd2e921c6ce2903be15c6b7f8b6d8a5d49b5086298d + name: dynamic_system_colors + sha256: "43794e658fa88cbdec9f397dd1afd2eb69b6c9717e99b93b16ba37c3aa3b3a8c" url: "https://pub.dev" source: hosted - version: "1.7.0" + version: "1.8.0" equatable: dependency: transitive description: @@ -173,10 +173,10 @@ packages: dependency: transitive description: name: fake_async - sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.3.3" ffi: dependency: transitive description: @@ -213,18 +213,18 @@ packages: dependency: transitive description: name: file_selector_android - sha256: f3a3d48a36d1640b4dca22a086f26b426c246925a80eddc2953120775fbcf86a + sha256: "3015702ab73987000e7ff2df5ddc99666d2bcd65cdb243f59da35729d3be6cff" url: "https://pub.dev" source: hosted - version: "0.5.1+13" + version: "0.5.1+15" file_selector_ios: dependency: transitive description: name: file_selector_ios - sha256: "94b98ad950b8d40d96fee8fa88640c2e4bd8afcdd4817993bd04e20310f45420" + sha256: fe9f52123af16bba4ad65bd7e03defbbb4b172a38a8e6aaa2a869a0c56a5f5fb url: "https://pub.dev" source: hosted - version: "0.5.3+1" + version: "0.5.3+2" file_selector_linux: dependency: transitive description: @@ -237,10 +237,10 @@ packages: dependency: transitive description: name: file_selector_macos - sha256: "271ab9986df0c135d45c3cdb6bd0faa5db6f4976d3e4b437cf7d0f258d941bfc" + sha256: "19124ff4a3d8864fdc62072b6a2ef6c222d55a3404fe14893a3c02744907b60c" url: "https://pub.dev" source: hosted - version: "0.9.4+2" + version: "0.9.4+4" file_selector_platform_interface: dependency: transitive description: @@ -343,10 +343,10 @@ packages: dependency: "direct main" description: name: flutter_markdown - sha256: e7bbc718adc9476aa14cfddc1ef048d2e21e4e8f18311aaac723266db9f9e7b5 + sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27" url: "https://pub.dev" source: hosted - version: "0.7.6+2" + version: "0.7.7+1" flutter_parsed_text: dependency: transitive description: @@ -359,10 +359,10 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: "5a1e6fb2c0561958d7e4c33574674bda7b77caaca7a33b758876956f2902eea3" + sha256: "6382ce712ff69b0f719640ce957559dde459e55ecd433c767e06d139ddf16cab" url: "https://pub.dev" source: hosted - version: "2.0.27" + version: "2.0.29" flutter_test: dependency: "direct dev" description: flutter @@ -371,12 +371,11 @@ packages: flutter_tts: dependency: "direct main" description: - path: "." - ref: "607fd86d44592407f9df5c980614c7bf7141f4f8" - resolved-ref: "607fd86d44592407f9df5c980614c7bf7141f4f8" - url: "https://github.com/mumu-lhl/flutter_tts.git" - source: git - version: "4.2.2" + name: flutter_tts + sha256: bdf2fc4483e74450dc9fc6fe6a9b6a5663e108d4d0dad3324a22c8e26bf48af4 + url: "https://pub.dev" + source: hosted + version: "4.2.3" flutter_web_plugins: dependency: transitive description: flutter @@ -394,18 +393,18 @@ packages: dependency: transitive description: name: html - sha256: "1fc58edeaec4307368c60d59b7e15b9d658b57d7f3125098b6294153c75337ec" + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" url: "https://pub.dev" source: hosted - version: "0.15.5" + version: "0.15.6" http: dependency: "direct main" description: name: http - sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" + sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.5.0" http_parser: dependency: transitive description: @@ -414,78 +413,86 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + hyphenatorx: + dependency: "direct main" + description: + name: hyphenatorx + sha256: "9afe950d84e56fde91b9129bf3c95e86e70f19a20e24ba6458ee1d028a64816e" + url: "https://pub.dev" + source: hosted + version: "1.5.11" image_picker: dependency: "direct main" description: name: image_picker - sha256: "021834d9c0c3de46bf0fe40341fa07168407f694d9b2bb18d532dc1261867f7a" + sha256: "736eb56a911cf24d1859315ad09ddec0b66104bc41a7f8c5b96b4e2620cf5041" url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.2.0" image_picker_android: dependency: transitive description: name: image_picker_android - sha256: "8bd392ba8b0c8957a157ae0dc9fcf48c58e6c20908d5880aea1d79734df090e9" + sha256: e83b2b05141469c5e19d77e1dfa11096b6b1567d09065b2265d7c6904560050c url: "https://pub.dev" source: hosted - version: "0.8.12+22" + version: "0.8.13" image_picker_for_web: dependency: transitive description: name: image_picker_for_web - sha256: "717eb042ab08c40767684327be06a5d8dbb341fe791d514e4b92c7bbe1b7bb83" + sha256: "40c2a6a0da15556dc0f8e38a3246064a971a9f512386c3339b89f76db87269b6" url: "https://pub.dev" source: hosted - version: "3.0.6" + version: "3.1.0" image_picker_ios: dependency: transitive description: name: image_picker_ios - sha256: "05da758e67bc7839e886b3959848aa6b44ff123ab4b28f67891008afe8ef9100" + sha256: eb06fe30bab4c4497bad449b66448f50edcc695f1c59408e78aa3a8059eb8f0e url: "https://pub.dev" source: hosted - version: "0.8.12+2" + version: "0.8.13" image_picker_linux: dependency: transitive description: name: image_picker_linux - sha256: "4ed1d9bb36f7cd60aa6e6cd479779cc56a4cb4e4de8f49d487b1aaad831300fa" + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" url: "https://pub.dev" source: hosted - version: "0.2.1+1" + version: "0.2.2" image_picker_macos: dependency: transitive description: name: image_picker_macos - sha256: "1b90ebbd9dcf98fb6c1d01427e49a55bd96b5d67b8c67cf955d60a5de74207c1" + sha256: d58cd9d67793d52beefd6585b12050af0a7663c0c2a6ece0fb110a35d6955e04 url: "https://pub.dev" source: hosted - version: "0.2.1+2" + version: "0.2.2" image_picker_platform_interface: dependency: transitive description: name: image_picker_platform_interface - sha256: "886d57f0be73c4b140004e78b9f28a8914a09e50c2d816bdd0520051a71236a0" + sha256: "9f143b0dba3e459553209e20cc425c9801af48e6dfa4f01a0fcf927be3f41665" url: "https://pub.dev" source: hosted - version: "2.10.1" + version: "2.11.0" image_picker_windows: dependency: transitive description: name: image_picker_windows - sha256: "6ad07afc4eb1bc25f3a01084d28520496c4a3bb0cb13685435838167c9dcedeb" + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae url: "https://pub.dev" source: hosted - version: "0.2.1+1" + version: "0.2.2" intl: dependency: "direct main" description: name: intl - sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" url: "https://pub.dev" source: hosted - version: "0.19.0" + version: "0.20.2" js: dependency: transitive description: @@ -506,26 +513,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec + sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0" url: "https://pub.dev" source: hosted - version: "10.0.8" + version: "11.0.1" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.9" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" linkify: dependency: transitive description: @@ -594,18 +601,18 @@ packages: dependency: "direct main" description: name: package_info_plus - sha256: "7976bfe4c583170d6cdc7077e3237560b364149fcd268b5f53d95a991963b191" + sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" url: "https://pub.dev" source: hosted - version: "8.3.0" + version: "8.3.1" package_info_plus_platform_interface: dependency: transitive description: name: package_info_plus_platform_interface - sha256: "6c935fb612dff8e3cc9632c2b301720c77450a126114126ffaafe28d2e87956c" + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" url: "https://pub.dev" source: hosted - version: "3.2.0" + version: "3.2.1" path: dependency: transitive description: @@ -666,10 +673,10 @@ packages: dependency: transitive description: name: permission_handler_apple - sha256: f84a188e79a35c687c132a0a0556c254747a08561e99ab933f12f6ca71ef3c98 + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 url: "https://pub.dev" source: hosted - version: "9.4.6" + version: "9.4.7" permission_handler_html: dependency: transitive description: @@ -738,18 +745,18 @@ packages: dependency: "direct main" description: name: shared_preferences - sha256: "846849e3e9b68f3ef4b60c60cf4b3e02e9321bc7f4d8c4692cf87ffa82fc8a3a" + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" url: "https://pub.dev" source: hosted - version: "2.5.2" + version: "2.5.3" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "3ec7210872c4ba945e3244982918e502fa2bfb5230dff6832459ca0e1879b7ad" + sha256: "5bcf0772a761b04f8c6bf814721713de6f3e5d9d89caf8d3fe031b02a342379e" url: "https://pub.dev" source: hosted - version: "2.4.8" + version: "2.4.11" shared_preferences_foundation: dependency: transitive description: @@ -823,10 +830,10 @@ packages: dependency: "direct main" description: name: speech_to_text - sha256: "6cf8f284997490ebef1d68f8707bef57dcf083f43c0f915cc285428520bfe6be" + sha256: c07557664974afa061f221d0d4186935bea4220728ea9446702825e8b988db04 url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "7.3.0" speech_to_text_platform_interface: dependency: transitive description: @@ -835,6 +842,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + speech_to_text_windows: + dependency: transitive + description: + name: speech_to_text_windows + sha256: "2c9846d18253c7bbe059a276297ef9f27e8a2745dead32192525beb208195072" + url: "https://pub.dev" + source: hosted + version: "1.0.0+beta.8" sprintf: dependency: transitive description: @@ -879,10 +894,10 @@ packages: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.6" time: dependency: transitive description: @@ -927,26 +942,26 @@ packages: dependency: "direct main" description: name: url_launcher - sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 url: "https://pub.dev" source: hosted - version: "6.3.1" + version: "6.3.2" url_launcher_android: dependency: transitive description: name: url_launcher_android - sha256: "1d0eae19bd7606ef60fe69ef3b312a437a16549476c42321d5dc1506c9ca3bf4" + sha256: "0aedad096a85b49df2e4725fa32118f9fa580f3b14af7a2d2221896a02cd5656" url: "https://pub.dev" source: hosted - version: "6.3.15" + version: "6.3.17" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "16a513b6c12bb419304e72ea0ae2ab4fed569920d1c7cb850263fe3acc824626" + sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7 url: "https://pub.dev" source: hosted - version: "6.3.2" + version: "6.3.4" url_launcher_linux: dependency: transitive description: @@ -959,10 +974,10 @@ packages: dependency: transitive description: name: url_launcher_macos - sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" + sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f url: "https://pub.dev" source: hosted - version: "3.2.2" + version: "3.2.3" url_launcher_platform_interface: dependency: transitive description: @@ -975,10 +990,10 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "3ba963161bd0fe395917ba881d320b9c4f6dd3c4a233da62ab18a5025c85f1e9" + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.1" url_launcher_windows: dependency: transitive description: @@ -999,10 +1014,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" version: dependency: "direct main" description: @@ -1023,10 +1038,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" url: "https://pub.dev" source: hosted - version: "14.3.1" + version: "15.0.2" web: dependency: transitive description: @@ -1039,10 +1054,10 @@ packages: dependency: transitive description: name: win32 - sha256: dc6ecaa00a7c708e5b4d10ee7bec8c270e9276dfcab1783f57e9962d7884305f + sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" url: "https://pub.dev" source: hosted - version: "5.12.0" + version: "5.14.0" xdg_directories: dependency: transitive description: @@ -1052,5 +1067,5 @@ packages: source: hosted version: "1.1.0" sdks: - dart: ">=3.7.0 <4.0.0" - flutter: ">=3.29.0" + dart: ">=3.8.1 <4.0.0" + flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml index da62f24..67c9e79 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,8 +4,8 @@ publish_to: 'none' version: 1.2.0+9 environment: - sdk: '>=3.3.4 <4.0.0' - flutter: 3.29.0 + sdk: '>=3.8.1 <4.0.0' + flutter: 3.35.0 dependencies: flutter: @@ -36,18 +36,18 @@ dependencies: flutter_displaymode: ^0.6.0 duration_picker: ^1.2.0 speech_to_text: ^7.0.0 - flutter_tts: - git: - url: https://github.com/mumu-lhl/flutter_tts.git - ref: 607fd86d44592407f9df5c980614c7bf7141f4f8 + flutter_tts: ^4.2.3 permission_handler: ^11.3.1 datetime_loop: ^1.2.0 - dynamic_color: ^1.7.0 universal_html: ^2.2.4 pwa_install: ^0.0.5 - flutter_chat_types: any markdown: any + hyphenatorx: ^1.5.11 + + # dynamic_color: ^1.7.0 + dynamic_system_colors: ^1.8.0 + dev_dependencies: flutter_test: sdk: flutter diff --git a/scripts/base64.dart b/tools/base64.dart similarity index 88% rename from scripts/base64.dart rename to tools/base64.dart index f6d0f5a..51520c0 100644 --- a/scripts/base64.dart +++ b/tools/base64.dart @@ -1,3 +1,4 @@ +// developer tool // ignore_for_file: avoid_print import 'dart:convert'; diff --git a/tools/logid.dart b/tools/logid.dart new file mode 100644 index 0000000..0d94708 --- /dev/null +++ b/tools/logid.dart @@ -0,0 +1,39 @@ +// developer tool +// ignore_for_file: avoid_print + +import 'dart:math'; + +void main() { + var random = Random.secure(); + var logId = ""; + + const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + const numbers = "0123456789"; + + var last1 = ""; + var last2 = ""; + + for (var i = 0; i < 8; i++) { + var doLetter = random.nextBool(); + + if (i == 0) { + doLetter = true; // first character is always a letter + } else if (numbers.contains(last1) && numbers.contains(last2)) { + doLetter = true; + } else if (letters.contains(last1) && letters.contains(last2)) { + doLetter = false; + } + + String nextChar; + if (doLetter) { + nextChar = letters[random.nextInt(letters.length)]; + } else { + nextChar = numbers[random.nextInt(numbers.length)]; + } + + logId += nextChar; + (last2, last1) = (last1, nextChar); + } + + print(logId); +} diff --git a/untranslated_messages.json b/untranslated_messages.json index 9e26dfe..fa1646c 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -1 +1,41 @@ -{} \ No newline at end of file +{ + "de": [ + "errorGuardTitle", + "errorGuardDetails", + "errorGuardException", + "errorGuardStackTrace", + "errorGuardReport" + ], + + "fa": [ + "errorGuardTitle", + "errorGuardDetails", + "errorGuardException", + "errorGuardStackTrace", + "errorGuardReport" + ], + + "it": [ + "errorGuardTitle", + "errorGuardDetails", + "errorGuardException", + "errorGuardStackTrace", + "errorGuardReport" + ], + + "tr": [ + "errorGuardTitle", + "errorGuardDetails", + "errorGuardException", + "errorGuardStackTrace", + "errorGuardReport" + ], + + "zh": [ + "errorGuardTitle", + "errorGuardDetails", + "errorGuardException", + "errorGuardStackTrace", + "errorGuardReport" + ] +} diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 11facde..8a14be8 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,10 +7,11 @@ #include "generated_plugin_registrant.h" #include -#include +#include #include #include #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { @@ -24,6 +25,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FlutterTtsPlugin")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + SpeechToTextWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SpeechToTextWindows")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 02ed174..5e1e076 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,10 +4,11 @@ list(APPEND FLUTTER_PLUGIN_LIST bitsdojo_window_windows - dynamic_color + dynamic_system_colors file_selector_windows flutter_tts permission_handler_windows + speech_to_text_windows url_launcher_windows )