diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index aaae48fd1..325fc77f4 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -151,6 +151,7 @@ kotlin { implementation(libs.filekit.dialogs.compose) implementation(libs.filekit.core) implementation(libs.filekit.coil) + implementation(libs.open.ai) // About implementation(libs.aboutLibrariesCore) diff --git a/composeApp/src/androidMain/kotlin/io/middlepoint/morestuff/shared/di/PlatformModule.android.kt b/composeApp/src/androidMain/kotlin/io/middlepoint/morestuff/shared/di/PlatformModule.android.kt index 0ba3aa581..57ff22ada 100644 --- a/composeApp/src/androidMain/kotlin/io/middlepoint/morestuff/shared/di/PlatformModule.android.kt +++ b/composeApp/src/androidMain/kotlin/io/middlepoint/morestuff/shared/di/PlatformModule.android.kt @@ -1,16 +1,19 @@ package io.middlepoint.morestuff.shared.di +import android.content.Context import androidx.core.app.NotificationManagerCompat -import androidx.preference.PreferenceManager +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKeys import androidx.work.WorkManager import co.touchlab.kermit.Logger import com.russhwolf.settings.Settings import com.russhwolf.settings.SharedPreferencesSettings +import io.middlepoint.morestuff.shared.data.DriverFactory import io.middlepoint.morestuff.shared.data.NotifierImpl import io.middlepoint.morestuff.shared.data.SchedulerImpl -import io.middlepoint.morestuff.shared.data.DriverFactory import io.middlepoint.morestuff.shared.data.VoiceToTextParser import io.middlepoint.morestuff.shared.data.VoiceToTextParserImpl +import io.middlepoint.morestuff.shared.data.repository.LlmRepositoryImpl import io.middlepoint.morestuff.shared.domain.service.Notifier import io.middlepoint.morestuff.shared.domain.service.Scheduler import io.middlepoint.morestuff.shared.ui.utils.SharedFunctionsHandler @@ -18,6 +21,7 @@ import org.koin.android.ext.koin.androidApplication import org.koin.core.module.Module import org.koin.core.module.dsl.factoryOf import org.koin.core.module.dsl.singleOf +import org.koin.core.qualifier.named import org.koin.dsl.bind import org.koin.dsl.module @@ -28,15 +32,40 @@ actual val platformModule: Module = module { factoryOf(::VoiceToTextParserImpl) bind VoiceToTextParser::class singleOf(::SchedulerImpl) bind Scheduler::class - singleOf(::NotifierImpl) bind Notifier::class + single { + NotifierImpl( + context = get(), + notificationManager = get(), + logger = get(), + settings = get(named(SharedSettings.Unencrypted)) + ) + } singleOf(::SharedFunctionsHandler) single { WorkManager.getInstance(androidApplication()) } single { NotificationManagerCompat.from(androidApplication()) } - factory { + + single(named(SharedSettings.Encrypted)) { + SharedPreferencesSettings( + EncryptedSharedPreferences.create( + LlmRepositoryImpl.ENCRYPTED_DATABASE_NAME, + MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC), + get(), + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ), + false + ) + } + + single(named(SharedSettings.Unencrypted)) { SharedPreferencesSettings( - PreferenceManager.getDefaultSharedPreferences(androidApplication()) + androidApplication().getSharedPreferences( + "UNENCRYPTED_SETTINGS", + Context.MODE_PRIVATE + ), + false ) } } diff --git a/composeApp/src/commonMain/composeResources/drawable/ic_ai_disabled.xml b/composeApp/src/commonMain/composeResources/drawable/ic_ai_disabled.xml new file mode 100644 index 000000000..b307bdc7b --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/ic_ai_disabled.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/ic_ai_enabled.xml b/composeApp/src/commonMain/composeResources/drawable/ic_ai_enabled.xml new file mode 100644 index 000000000..2b0f4e013 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/ic_ai_enabled.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/commonMain/composeResources/values-es/strings.xml b/composeApp/src/commonMain/composeResources/values-es/strings.xml index 0dd6a7f92..ff37bb756 100644 --- a/composeApp/src/commonMain/composeResources/values-es/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-es/strings.xml @@ -279,6 +279,10 @@ Algunas de estas tareas tienen programaciones que también serán eliminadas. Nombre del scope + API Key + Añade API Key + No establecida + Iniciar sesión con Email Introduce tu email @@ -290,4 +294,6 @@ El email introducido no es válido o no existe. Ha ocurrido un error. Inténtalo de nuevo. Eliminar Scope + + Habla con MoreStuff IA. \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 430a0ba4a..1a760e41f 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -296,6 +296,9 @@ Edit message This task has a schedule that will also be deleted. Some of these tasks have schedules that will also be deleted. + API Key + Enter API Key + Not set Sign in with Email @@ -307,4 +310,5 @@ Verify The email address is invalid or does not exist. An error occurred. Please try again. + Chat with MoreStuff AI. diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/data/Constants.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/data/Constants.kt index a6ab6bd58..bcd02ff91 100644 --- a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/data/Constants.kt +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/data/Constants.kt @@ -13,6 +13,9 @@ object Constants { const val KEY_REVIEW_TIME = "key_review_time" const val KEY_REVIEW_HINT = "key_review_hint" const val KEY_LANGUAGE_INPUT = "key_language_input" + const val UNENCRYPTED_SETTINGS_NAME = "APP_SETTINGS" + const val ENCRYPTED_SETTINGS_NAME = "ENCRYPTED_SETTINGS" + const val KEY_API_KEY = "key_api_key" // Developer keys @@ -25,4 +28,8 @@ object Constants { const val TELEGRAM_INVITE_LINK = "https://t.me/+hzE7jInTlSRiOGVk" const val PRIVACY_POLICY_LINK = "https://bit.ly/3P4Sd3I" + + + const val IA_MODEL_OPEN_AI = "gpt-4o-mini" + const val IA_MODEL_DEEPSEEK = "deepseek-chat" } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/data/middleware/MessageMiddleware.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/data/middleware/MessageMiddleware.kt index f5c62b148..a7a470fa2 100644 --- a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/data/middleware/MessageMiddleware.kt +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/data/middleware/MessageMiddleware.kt @@ -5,6 +5,7 @@ import io.middlepoint.morestuff.shared.domain.enums.ContentType import io.middlepoint.morestuff.shared.domain.redux.state.AppState import io.middlepoint.morestuff.shared.domain.redux.Middleware import io.middlepoint.morestuff.shared.domain.redux.action.MessageAction +import io.middlepoint.morestuff.shared.domain.redux.action.MessageAction.CreateAITaskMessageAction import io.middlepoint.morestuff.shared.domain.redux.action.MessageAction.CreateScheduleMessageAction import io.middlepoint.morestuff.shared.domain.redux.action.NotificationAction import io.middlepoint.morestuff.shared.domain.redux.action.TaskAction @@ -12,6 +13,7 @@ import io.middlepoint.morestuff.shared.domain.redux.store.Action import io.middlepoint.morestuff.shared.domain.redux.store.Dispatch import io.middlepoint.morestuff.shared.domain.redux.store.Next import io.middlepoint.morestuff.shared.domain.redux.store.NoOp +import io.middlepoint.morestuff.shared.domain.usecase.ia.CreateAIMessageUseCase import io.middlepoint.morestuff.shared.domain.usecase.message.CreateMediaMessageUseCase import io.middlepoint.morestuff.shared.domain.usecase.message.CreateMessageUseCase import io.middlepoint.morestuff.shared.domain.usecase.message.CreatePDFMessageUseCase @@ -30,6 +32,7 @@ class MessageMiddleware( private val createPDFMessageUseCase: CreatePDFMessageUseCase, private val deleteMessageUseCase: DeleteMessageUseCase, private val updateMessageContentUseCase: UpdateMessageContentUseCase, + private val createAIMessageUseCase: CreateAIMessageUseCase, ) : Middleware { val logger = Logger.withTag("MessageMiddleware") @@ -60,7 +63,6 @@ class MessageMiddleware( is MessageAction.CreateAppTaskMessageAction -> scope.launch { logger.d { "Creating app task message: ${action.content}" } - createMessageUseCase( action.taskId, action.content, @@ -69,9 +71,25 @@ class MessageMiddleware( scheduleId = null ) logger.d { "App task message created" } + } + is CreateAITaskMessageAction -> scope.launch { + logger.d { "AI request started for task=${action.taskId}" } + dispatch(MessageAction.AIRequestStarted(action.taskId)) + val result = createAIMessageUseCase(action.taskId, action.prompt) + result.fold( + { failure -> + logger.e { "AI request failed: $failure" } + dispatch(MessageAction.AIRequestFailed(action.taskId, failure.toString())) + }, + { message -> + logger.d { "AI request finished, got message id=${message.id}" } + dispatch(MessageAction.AIRequestFinished(action.taskId)) + } + ) } + is MessageAction.CreateFileMessageAction -> scope.launch { createMediaMessageUseCase( action.taskId, diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/data/middleware/SettingsMiddleware.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/data/middleware/SettingsMiddleware.kt index fbfdc8923..204ef6943 100644 --- a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/data/middleware/SettingsMiddleware.kt +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/data/middleware/SettingsMiddleware.kt @@ -15,60 +15,62 @@ import io.middlepoint.morestuff.shared.domain.redux.store.Dispatch import io.middlepoint.morestuff.shared.domain.redux.store.InitStoreAction import io.middlepoint.morestuff.shared.domain.redux.store.Next import io.middlepoint.morestuff.shared.domain.redux.store.NoOp +import io.middlepoint.morestuff.shared.domain.usecase.settings.GetApiKeyUseCase import io.middlepoint.morestuff.shared.domain.usecase.settings.GetAppSettingsUseCase +import io.middlepoint.morestuff.shared.domain.usecase.settings.SaveApiKeyUseCase import io.middlepoint.morestuff.shared.domain.usecase.settings.SaveUserSettingUseCase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch class SettingsMiddleware( - private val getAppSettingsUseCase: GetAppSettingsUseCase, - private val saveUserSettingUseCase: SaveUserSettingUseCase, - ) : Middleware { + private val getAppSettingsUseCase: GetAppSettingsUseCase, + private val saveUserSettingUseCase: SaveUserSettingUseCase, +) : Middleware { - override fun invoke( - state: AppState, - action: Action, - dispatch: Dispatch, - next: Next, - scope: CoroutineScope, - ): Action { - when (action) { - is InitStoreAction -> scope.launch { - dispatch(InitSettings(getAppSettingsUseCase())) - } + override fun invoke( + state: AppState, + action: Action, + dispatch: Dispatch, + next: Next, + scope: CoroutineScope, + ): Action { + when (action) { + is InitStoreAction -> scope.launch { + dispatch(InitSettings(getAppSettingsUseCase())) + } - is EnableDevSettings -> scope.launch { - saveUserSettingUseCase(AppSetting.DevSettings, action.enable) - } + is EnableDevSettings -> scope.launch { + saveUserSettingUseCase(AppSetting.DevSettings, action.enable) + } - is SetSnoozeLimit -> scope.launch { - saveUserSettingUseCase(AppSetting.SnoozeLimit, action.amount) - } + is SetSnoozeLimit -> scope.launch { + saveUserSettingUseCase(AppSetting.SnoozeLimit, action.amount) + } - is SetAppTheme -> scope.launch { - saveUserSettingUseCase(AppSetting.Theme, action.theme.name) - } + is SetAppTheme -> scope.launch { + saveUserSettingUseCase(AppSetting.Theme, action.theme.name) + } - is OnBoardingComplete -> scope.launch { - saveUserSettingUseCase(AppSetting.FirstTime, false) - dispatch(TaskAction.CreateHintTask) - } + is OnBoardingComplete -> scope.launch { + saveUserSettingUseCase(AppSetting.FirstTime, false) + dispatch(TaskAction.CreateHintTask) + } - /*is SettingAction.SetReviewTimeAction -> scope.launch { - saveUserSettingUseCase(AppSetting.ReviewTime, Pair(action.hour, action.minute)) - }*/ + /*is SettingAction.SetReviewTimeAction -> scope.launch { + saveUserSettingUseCase(AppSetting.ReviewTime, Pair(action.hour, action.minute)) + }*/ - is SettingAction.EnableReviewHint -> scope.launch { - saveUserSettingUseCase(AppSetting.ShowHintArrowPriority, action.enable) - } + is SettingAction.EnableReviewHint -> scope.launch { + saveUserSettingUseCase(AppSetting.ShowHintArrowPriority, action.enable) + } - is SettingAction.SetVoiceLanguage -> scope.launch { - saveUserSettingUseCase(AppSetting.VoiceInputLanguage, action.language.name) - } + is SettingAction.SetVoiceLanguage -> scope.launch { + saveUserSettingUseCase(AppSetting.VoiceInputLanguage, action.language.name) + } - else -> NoOp - } - - return next(state, action, dispatch) + else -> NoOp } + + return next(state, action, dispatch) + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/data/repository/LlmRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/data/repository/LlmRepositoryImpl.kt new file mode 100644 index 000000000..6c67fe852 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/data/repository/LlmRepositoryImpl.kt @@ -0,0 +1,129 @@ +package io.middlepoint.morestuff.shared.data.repository + +import com.aallam.openai.api.chat.ChatCompletion +import com.aallam.openai.api.chat.ChatCompletionRequest +import com.aallam.openai.api.chat.ChatMessage +import com.aallam.openai.api.chat.ChatRole +import com.aallam.openai.api.chat.StreamOptions +import com.aallam.openai.api.http.Timeout +import com.aallam.openai.api.image.ImageCreation +import com.aallam.openai.api.image.ImageSize +import com.aallam.openai.api.logging.LogLevel +import com.aallam.openai.api.model.ModelId +import com.aallam.openai.client.LoggingConfig +import com.aallam.openai.client.OpenAI +import com.aallam.openai.client.OpenAIConfig +import com.aallam.openai.client.OpenAIHost +import com.russhwolf.settings.Settings +import com.russhwolf.settings.get +import com.russhwolf.settings.set +import io.middlepoint.morestuff.android.data.Constants.IA_MODEL_DEEPSEEK +import io.middlepoint.morestuff.android.data.Constants.KEY_API_KEY +import io.middlepoint.morestuff.shared.domain.repository.LlmRepository +import io.middlepoint.morestuff.shared.domain.service.logger +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlin.time.Duration.Companion.seconds + +class LlmRepositoryImpl( + private val settings: Settings, +) : LlmRepository { + + private var storedApiKey: String? + get() = settings[KEY_API_KEY] + set(value) { + settings[KEY_API_KEY] = value + } + + override fun saveApiKey(apiKey: String) { + this.storedApiKey = apiKey + } + + override fun getApiKey(): String? { + return storedApiKey + } + + +/* private val openAI by lazy { + val config = OpenAIConfig( + token = storedApiKey ?: throw IllegalStateException("API key not found"), + timeout = Timeout(socket = 60.seconds), + ) + OpenAI(config) + }*/ + + private val openAI by lazy { + val config = OpenAIConfig( + token = storedApiKey ?: throw IllegalStateException("API key not found"), + logging = LoggingConfig(LogLevel.Headers), timeout = Timeout(socket = 60.seconds), + host = OpenAIHost(baseUrl = "https://api.deepseek.com/v1/") + ) + OpenAI(config) + } + + + //TODO: test assistants +/* @OptIn(BetaOpenAI::class) + val assistant = openAI.assistant( + request = AssistantRequest( + name = "Math Tutor", + tools = listOf(AssistantTool.CodeInterpreter), + model = ModelId(IA_MODEL) + ) + )*/ + + override suspend fun generateChatCompletion(prompt: String): String { + val chatCompletionRequest = ChatCompletionRequest( + model = ModelId(IA_MODEL_DEEPSEEK), + messages = listOf( + ChatMessage( + role = ChatRole.Assistant, + content = "You are a helpful assistant!" + ), + ChatMessage( + role = ChatRole.User, + content = prompt + ) + ) + ) + + val completion: ChatCompletion = openAI.chatCompletion(chatCompletionRequest) + return completion.choices.first().message.content ?: "No response" + } + + override suspend fun generateImage(prompt: String): String { + val images = openAI.imageURL( + creation = ImageCreation( + prompt = prompt, + n = 1, + size = ImageSize.is1024x1024 + ) + ) + + return images.first().url + } + + override fun streamChatCompletion(prompt: String): Flow { + val streamOptions = StreamOptions() + val chatCompletionRequest = ChatCompletionRequest( + model = ModelId(IA_MODEL_DEEPSEEK), + messages = listOf( + ChatMessage( + role = ChatRole.User, + content = prompt + ) + ), + streamOptions = streamOptions + ) + + return openAI.chatCompletions(chatCompletionRequest) + .map { completion -> + completion.choices.first().delta?.content ?: "" + } + } + + companion object { + const val ENCRYPTED_DATABASE_NAME = "ENCRYPTED_SETTINGS" + const val encryptedSettingsName = "encryptedSettings" + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/data/repository/MessageRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/data/repository/MessageRepositoryImpl.kt index 6bf0722e5..335b651a1 100644 --- a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/data/repository/MessageRepositoryImpl.kt +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/data/repository/MessageRepositoryImpl.kt @@ -44,14 +44,14 @@ class MessageRepositoryImpl( override fun getTaskChatMessages(taskId: Uuid): List = messageQueries.selectTaskMessagesByContentType( taskId, - listOf(ContentType.TASK_MESSAGE.value, ContentType.APP_TASK_MESSAGE.value), + listOf(ContentType.TASK_MESSAGE.value, ContentType.APP_TASK_MESSAGE.value, ContentType.AI_TASK_MESSAGE.value), mapper = mapper.messageDataMapper ).executeAsList() override fun getTaskChatMessagesFlow(taskId: Uuid): Flow> = messageQueries.selectTaskMessagesByContentType( taskId, - listOf(ContentType.TASK_MESSAGE.value, ContentType.APP_TASK_MESSAGE.value), + listOf(ContentType.TASK_MESSAGE.value, ContentType.APP_TASK_MESSAGE.value, ContentType.AI_TASK_MESSAGE.value), mapper = mapper.messageDataMapper ).asFlow().mapToList(Dispatchers.IO) diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/data/repository/UserRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/data/repository/UserRepositoryImpl.kt index d45e9543c..cbda7aa8c 100644 --- a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/data/repository/UserRepositoryImpl.kt +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/data/repository/UserRepositoryImpl.kt @@ -3,7 +3,13 @@ package io.middlepoint.morestuff.shared.data.repository import com.russhwolf.settings.Settings import io.middlepoint.morestuff.shared.data.settingKey import io.middlepoint.morestuff.shared.domain.enums.AppSetting -import io.middlepoint.morestuff.shared.domain.enums.AppSetting.* +import io.middlepoint.morestuff.shared.domain.enums.AppSetting.DevSettings +import io.middlepoint.morestuff.shared.domain.enums.AppSetting.FirstTime +import io.middlepoint.morestuff.shared.domain.enums.AppSetting.ReviewTime +import io.middlepoint.morestuff.shared.domain.enums.AppSetting.ShowHintArrowPriority +import io.middlepoint.morestuff.shared.domain.enums.AppSetting.SnoozeLimit +import io.middlepoint.morestuff.shared.domain.enums.AppSetting.Theme +import io.middlepoint.morestuff.shared.domain.enums.AppSetting.VoiceInputLanguage import io.middlepoint.morestuff.shared.domain.enums.AppTheme import io.middlepoint.morestuff.shared.domain.enums.Language import io.middlepoint.morestuff.shared.domain.model.AppSettings @@ -22,7 +28,7 @@ class UserRepositoryImpl( snoozeLimit = getSetting(SnoozeLimit, snoozeLimit), //reviewTime = getSetting(ReviewTime, reviewTime), enableReviewHint = getSetting(ShowHintArrowPriority, enableReviewHint), - voiceInputLanguage = getSetting(VoiceInputLanguage, voiceInputLanguage) + voiceInputLanguage = getSetting(VoiceInputLanguage, voiceInputLanguage), ) } @@ -39,6 +45,7 @@ class UserRepositoryImpl( ) ShowHintArrowPriority -> settings.putBoolean(setting.settingKey, settingValue as Boolean) is VoiceInputLanguage -> settings.putString(setting.settingKey, settingValue as String) + } } diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/di/DataModule.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/di/DataModule.kt index 7badd6de7..2828acf41 100644 --- a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/di/DataModule.kt +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/di/DataModule.kt @@ -16,6 +16,7 @@ import io.middlepoint.morestuff.shared.data.createDatabase import io.middlepoint.morestuff.shared.data.mapper.DataMappers import io.middlepoint.morestuff.shared.data.mapper.DataMappersImpl import io.middlepoint.morestuff.shared.data.mapper.MessageDataMap +import io.middlepoint.morestuff.shared.data.repository.LlmRepositoryImpl import io.middlepoint.morestuff.shared.data.repository.MessageRepositoryImpl import io.middlepoint.morestuff.shared.data.repository.PriorityRepositoryImpl import io.middlepoint.morestuff.shared.data.repository.ScheduleRepositoryImpl @@ -28,6 +29,7 @@ import io.middlepoint.morestuff.shared.data.sync.DataSyncManager import io.middlepoint.morestuff.shared.data.sync.DataSyncManagerImpl import io.middlepoint.morestuff.shared.data.utils.MigrationHelper import io.middlepoint.morestuff.shared.domain.DevTools +import io.middlepoint.morestuff.shared.domain.repository.LlmRepository import io.middlepoint.morestuff.shared.domain.repository.MessageRepository import io.middlepoint.morestuff.shared.domain.repository.PriorityRepository import io.middlepoint.morestuff.shared.domain.repository.ScheduleRepository @@ -37,14 +39,30 @@ import io.middlepoint.morestuff.shared.domain.repository.TimeFormatter import io.middlepoint.morestuff.shared.domain.repository.UserRepository import org.koin.core.module.dsl.factoryOf import org.koin.core.module.dsl.singleOf +import org.koin.core.qualifier.named import org.koin.dsl.bind import org.koin.dsl.module +enum class SharedSettings { + Unencrypted, + Encrypted +} + val dataModule = module { single { createDatabase(get()) } - singleOf(::DevToolsImpl) bind DevTools::class + single { + DevToolsImpl( + settings = get(named(SharedSettings.Unencrypted)), + notifier = get(), + dataMigration = get(), + migrationHelper = get(), + dataSyncManager = get(), + ) + } + + //singleOf(::DevToolsImpl) bind DevTools::class singleOf(::DataMappersImpl) bind DataMappers::class singleOf(::MessageDataMap) @@ -69,12 +87,24 @@ val dataModule = module { singleOf(::ScheduleRepositoryImpl) bind ScheduleRepository::class single { - UserRepositoryImpl(settings = get()) + UserRepositoryImpl( + settings = get(named(SharedSettings.Unencrypted)) + ) } singleOf(::ScopeRepositoryImpl) bind ScopeRepository::class + + + single { + LlmRepositoryImpl( + settings = get(named(SharedSettings.Encrypted)), + + ) + } + singleOf(::PriorityRepositoryImpl) bind PriorityRepository::class singleOf(::TimeFormatterImpl) bind TimeFormatter::class + factoryOf(::MigrationHelper) singleOf(::DataSyncManagerImpl) bind DataSyncManager::class @@ -91,8 +121,8 @@ val supabaseModule = module { defaultLogLevel = LogLevel.DEBUG } install(Auth) { - sessionManager = SettingsSessionManager(settings = get()) // TODO: use encrypted settings - codeVerifierCache = SettingsCodeVerifierCache(settings = get()) // TODO: use encrypted settings + sessionManager = SettingsSessionManager(settings = get(named(SharedSettings.Encrypted))) + codeVerifierCache = SettingsCodeVerifierCache(settings = get(named(SharedSettings.Encrypted))) host = BuildConfig.APP_HOST_LOGIN scheme = BuildConfig.APP_SCHEME } diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/di/DomainModule.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/di/DomainModule.kt index e34205b7f..c3e8ea330 100644 --- a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/di/DomainModule.kt +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/di/DomainModule.kt @@ -23,6 +23,14 @@ import io.middlepoint.morestuff.shared.domain.service.AppMessageProvider import io.middlepoint.morestuff.shared.domain.service.HintTaskProvider import io.middlepoint.morestuff.shared.domain.service.OpenGraphFetcher import io.middlepoint.morestuff.shared.domain.service.TimeManager +import io.middlepoint.morestuff.shared.domain.usecase.ia.CreateAIMessageUseCase +import io.middlepoint.morestuff.shared.domain.usecase.ia.CreateAIMessageUseCaseImpl +import io.middlepoint.morestuff.shared.domain.usecase.ia.GenerateChatCompletionUseCase +import io.middlepoint.morestuff.shared.domain.usecase.ia.GenerateChatCompletionUseCaseImpl +import io.middlepoint.morestuff.shared.domain.usecase.ia.GenerateImageUseCase +import io.middlepoint.morestuff.shared.domain.usecase.ia.GenerateImageUseCaseImpl +import io.middlepoint.morestuff.shared.domain.usecase.ia.StreamChatCompletionUseCase +import io.middlepoint.morestuff.shared.domain.usecase.ia.StreamChatCompletionUseCaseImpl import io.middlepoint.morestuff.shared.domain.usecase.message.CheckForUrlMetadataUseCase import io.middlepoint.morestuff.shared.domain.usecase.message.CheckForUrlMetadataUseCaseImpl import io.middlepoint.morestuff.shared.domain.usecase.message.CreateMediaMessageUseCase @@ -101,12 +109,18 @@ import io.middlepoint.morestuff.shared.domain.usecase.scope.UpdateScopesOrderUse import io.middlepoint.morestuff.shared.domain.usecase.scope.UpdateScopesOrderUseCaseImpl import io.middlepoint.morestuff.shared.domain.usecase.settings.CheckFirstTimeUseCase import io.middlepoint.morestuff.shared.domain.usecase.settings.CheckFirstTimeUseCaseImpl +import io.middlepoint.morestuff.shared.domain.usecase.settings.GetApiKeyUseCase +import io.middlepoint.morestuff.shared.domain.usecase.settings.GetApiKeyUseCaseImpl import io.middlepoint.morestuff.shared.domain.usecase.settings.GetAppSettingUseCase import io.middlepoint.morestuff.shared.domain.usecase.settings.GetAppSettingUseCaseImpl import io.middlepoint.morestuff.shared.domain.usecase.settings.GetAppSettingsUseCase import io.middlepoint.morestuff.shared.domain.usecase.settings.GetAppSettingsUseCaseImpl import io.middlepoint.morestuff.shared.domain.usecase.settings.GetAppThemeUseCase import io.middlepoint.morestuff.shared.domain.usecase.settings.GetAppThemeUseCaseImpl +import io.middlepoint.morestuff.shared.domain.usecase.settings.OpenAppSettingsUseCase +import io.middlepoint.morestuff.shared.domain.usecase.settings.OpenAppSettingsUseCaseImpl +import io.middlepoint.morestuff.shared.domain.usecase.settings.SaveApiKeyUseCase +import io.middlepoint.morestuff.shared.domain.usecase.settings.SaveApiKeyUseCaseImpl import io.middlepoint.morestuff.shared.domain.usecase.settings.SaveUserSettingUseCase import io.middlepoint.morestuff.shared.domain.usecase.settings.SaveUserSettingUseCaseImpl import io.middlepoint.morestuff.shared.domain.usecase.task.AddTasksToScopeUseCase @@ -168,13 +182,14 @@ val domainModules } val useCaseModules - get() = buildList { - add(taskUseCases) - add(scheduleUseCases) - add(messageUseCases) - add(settingsUseCases) - add(scopeUseCases) - } + get() = buildList { + add(taskUseCases) + add(scheduleUseCases) + add(messageUseCases) + add(settingsUseCases) + add(scopeUseCases) + add(iaUseCases) + } val serviceModule = module { factoryOf(::AppMessagesProviderImpl) bind AppMessageProvider::class @@ -276,6 +291,12 @@ val messageUseCases = module { } +val iaUseCases = module { + factoryOf(::GenerateChatCompletionUseCaseImpl) bind GenerateChatCompletionUseCase::class + factoryOf(::GenerateImageUseCaseImpl) bind GenerateImageUseCase::class + factoryOf(::StreamChatCompletionUseCaseImpl) bind StreamChatCompletionUseCase::class + factoryOf(::CreateAIMessageUseCaseImpl) bind CreateAIMessageUseCase::class +} val settingsUseCases = module { factoryOf(::GetAppSettingsUseCaseImpl) bind GetAppSettingsUseCase::class @@ -283,6 +304,9 @@ val settingsUseCases = module { factoryOf(::SaveUserSettingUseCaseImpl) bind SaveUserSettingUseCase::class factoryOf(::GetAppThemeUseCaseImpl) bind GetAppThemeUseCase::class factoryOf(::CheckFirstTimeUseCaseImpl) bind CheckFirstTimeUseCase::class + factoryOf(::OpenAppSettingsUseCaseImpl) bind OpenAppSettingsUseCase::class + factoryOf(::GetApiKeyUseCaseImpl) bind GetApiKeyUseCase::class + factoryOf(::SaveApiKeyUseCaseImpl) bind SaveApiKeyUseCase::class } val timeManagerModule = module { diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/enums/ContentType.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/enums/ContentType.kt index 55526131f..19d0cf27c 100644 --- a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/enums/ContentType.kt +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/enums/ContentType.kt @@ -5,7 +5,8 @@ enum class ContentType(val value: Int) { CONFIRM_NEW_TASK(101), TASK_REMINDER(200), TASK_MESSAGE(201), - APP_TASK_MESSAGE(202); + APP_TASK_MESSAGE(202), + AI_TASK_MESSAGE(203); companion object { fun withValue(value: Int) = run { entries.first { it.value == value } } diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/model/Failure.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/model/Failure.kt index 05850a1b8..948384155 100644 --- a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/model/Failure.kt +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/model/Failure.kt @@ -17,5 +17,7 @@ data class OpenGraphMetadataFetchFailure(val message: String?) : FeatureFailure data object TaskReminderCancelled : FeatureFailure -data object ScopeAlreadyExists: Failure -data object NoScope: Failure \ No newline at end of file +data object ScopeAlreadyExists : Failure +data object NoScope : Failure +data object ApiKeyNotFound : Failure +data class AIUnexpectedFailure(val message: String?) : Failure \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/redux/AppStore.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/redux/AppStore.kt index 50906a6b0..095e8e53a 100644 --- a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/redux/AppStore.kt +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/redux/AppStore.kt @@ -1,6 +1,7 @@ package io.middlepoint.morestuff.shared.domain.redux import io.middlepoint.morestuff.shared.domain.redux.state.AppState +import io.middlepoint.morestuff.shared.domain.redux.state.reduceAiMessageState import io.middlepoint.morestuff.shared.domain.redux.state.reduceSettingState import io.middlepoint.morestuff.shared.domain.redux.state.reduceSyncState import io.middlepoint.morestuff.shared.domain.redux.state.reduceUserState @@ -12,6 +13,7 @@ class AppStore(provider: MiddlewareProvider) : SimpleStore( AppState::reduceUserState, AppState::reduceSyncState, AppState::reduceSettingState, + AppState::reduceAiMessageState ), middleware = provider.middlewareOrder ) diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/redux/action/MessageAction.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/redux/action/MessageAction.kt index e6634009c..6ee21a756 100644 --- a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/redux/action/MessageAction.kt +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/redux/action/MessageAction.kt @@ -5,28 +5,37 @@ import io.middlepoint.morestuff.shared.domain.model.Uuid import io.middlepoint.morestuff.shared.domain.redux.store.Action sealed class MessageAction : Action.FeatureAction() { - data class CreateScheduleMessageAction(val scheduleId: Uuid) : MessageAction() - data class CreateUserTaskMessageAction(val taskId: Uuid, val content: String) : - MessageAction() - data class CreateAppTaskMessageAction( - val taskId: Uuid, - val content: String - ) : MessageAction() - - data class CreateFileMessageAction( - val taskId: Uuid, - val file: PlatformFile, - val message: String - ) : MessageAction() - - data class CreatePDFMessageAction( - val taskId: Uuid, - val file: PlatformFile, - val message: String - ) : MessageAction() - - data class DeleteMessageAction(val messageId: Uuid) : MessageAction() - - data class UpdateMessageContentAction(val messageId: Uuid, val content: String) : TaskAction() + data class CreateScheduleMessageAction(val scheduleId: Uuid) : MessageAction() + data class CreateUserTaskMessageAction(val taskId: Uuid, val content: String) : + MessageAction() + data class CreateAppTaskMessageAction( + val taskId: Uuid, + val content: String + ) : MessageAction() + + data class CreateFileMessageAction( + val taskId: Uuid, + val file: PlatformFile, + val message: String + ) : MessageAction() + + data class CreatePDFMessageAction( + val taskId: Uuid, + val file: PlatformFile, + val message: String + ) : MessageAction() + + data class DeleteMessageAction(val messageId: Uuid) : MessageAction() + + data class UpdateMessageContentAction(val messageId: Uuid, val content: String) : TaskAction() + + data class CreateAITaskMessageAction( + val taskId: Uuid, + val prompt: String + ) : MessageAction() + + data class AIRequestStarted(val taskId: Uuid) : MessageAction() + data class AIRequestFinished(val taskId: Uuid) : MessageAction() + data class AIRequestFailed(val taskId: Uuid, val error: String?) :MessageAction() } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/redux/state/AppState.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/redux/state/AppState.kt index 8d971abbf..5bd5fb726 100644 --- a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/redux/state/AppState.kt +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/redux/state/AppState.kt @@ -2,11 +2,13 @@ package io.middlepoint.morestuff.shared.domain.redux.state import io.middlepoint.morestuff.shared.domain.enums.SyncStatus import io.middlepoint.morestuff.shared.domain.enums.isReady +import io.middlepoint.morestuff.shared.domain.model.Uuid data class AppState( val userState: UserState = UserState(), val syncState: SyncState = SyncState(), val settings: AppSettingsState = AppSettingsState(), + val aiMessageState: AiMessageState = AiMessageState(), ) fun AppState.isReady() = diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/redux/state/reduceAiMessageState.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/redux/state/reduceAiMessageState.kt new file mode 100644 index 000000000..60959c716 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/redux/state/reduceAiMessageState.kt @@ -0,0 +1,35 @@ +package io.middlepoint.morestuff.shared.domain.redux.state + +import io.middlepoint.morestuff.shared.domain.redux.store.Action +import io.middlepoint.morestuff.shared.domain.redux.action.MessageAction +import io.middlepoint.morestuff.shared.domain.model.Uuid + + +data class AiMessageState( + val loadingMap: Map = emptyMap(), +) + + + +fun AppState.reduceAiMessageState(action: Action): AppState = when (action) { + + is MessageAction.AIRequestStarted -> copy( + aiMessageState = aiMessageState.copy( + loadingMap = aiMessageState.loadingMap + (action.taskId to true) + ) + ) + + is MessageAction.AIRequestFinished -> copy( + aiMessageState = aiMessageState.copy( + loadingMap = aiMessageState.loadingMap + (action.taskId to false) + ) + ) + + is MessageAction.AIRequestFailed -> copy( + aiMessageState = aiMessageState.copy( + loadingMap = aiMessageState.loadingMap + (action.taskId to false) + ) + ) + + else -> this +} diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/repository/LlmRepository.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/repository/LlmRepository.kt new file mode 100644 index 000000000..150f324c0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/repository/LlmRepository.kt @@ -0,0 +1,11 @@ +package io.middlepoint.morestuff.shared.domain.repository + +import kotlinx.coroutines.flow.Flow + +interface LlmRepository { + suspend fun generateChatCompletion(prompt: String): String + suspend fun generateImage(prompt: String): String + fun streamChatCompletion(prompt: String): Flow + fun saveApiKey(apiKey: String) + fun getApiKey(): String? +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/usecase/ia/CreateAIMessageUseCase.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/usecase/ia/CreateAIMessageUseCase.kt new file mode 100644 index 000000000..d231cfabf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/usecase/ia/CreateAIMessageUseCase.kt @@ -0,0 +1,50 @@ +package io.middlepoint.morestuff.shared.domain.usecase.ia + +import arrow.core.Either +import io.middlepoint.morestuff.shared.domain.enums.ContentType +import io.middlepoint.morestuff.shared.domain.model.AIUnexpectedFailure +import io.middlepoint.morestuff.shared.domain.model.ApiKeyNotFound +import io.middlepoint.morestuff.shared.domain.model.Failure +import io.middlepoint.morestuff.shared.domain.model.Uuid +import io.middlepoint.morestuff.shared.domain.model.core.Message +import io.middlepoint.morestuff.shared.domain.service.logger +import io.middlepoint.morestuff.shared.domain.usecase.message.CreateMessageUseCase + + +interface CreateAIMessageUseCase { + suspend operator fun invoke( + taskId: Uuid, + prompt: String + ): Either +} + +class CreateAIMessageUseCaseImpl( + private val generateChatCompletionUseCase: GenerateChatCompletionUseCase, + private val createMessageUseCase: CreateMessageUseCase, +) : CreateAIMessageUseCase { + + override suspend fun invoke( + taskId: Uuid, + prompt: String + ): Either { + return try { + val aiResponse = generateChatCompletionUseCase(prompt, taskId) + logger.d { "AI response: $aiResponse" } + createMessageUseCase( + taskId = taskId, + contentType = ContentType.AI_TASK_MESSAGE, + messageExtra = null, + title = aiResponse, + scheduleId = null + ) + } catch (e: IllegalStateException) { + if (e.message?.contains("API key not found") == true) { + Either.Left(ApiKeyNotFound) + } else { + Either.Left(AIUnexpectedFailure(e.message)) + } + } catch (e: Exception) { + Either.Left(AIUnexpectedFailure(e.message)) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/usecase/ia/GenerateChatCompletionUseCase.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/usecase/ia/GenerateChatCompletionUseCase.kt new file mode 100644 index 000000000..d4bebbbf5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/usecase/ia/GenerateChatCompletionUseCase.kt @@ -0,0 +1,24 @@ +package io.middlepoint.morestuff.shared.domain.usecase.ia + +import io.middlepoint.morestuff.shared.domain.model.Uuid +import io.middlepoint.morestuff.shared.domain.repository.LlmRepository +import io.middlepoint.morestuff.shared.domain.usecase.message.GetTaskChatMessagesUseCase +import io.middlepoint.morestuff.shared.domain.usecase.task.GetTaskUseCase + +interface GenerateChatCompletionUseCase { + suspend operator fun invoke(prompt: String, taskId: Uuid): String +} + +class GenerateChatCompletionUseCaseImpl( + private val llmRepository: LlmRepository, + private val getTaskChatMessagesUseCase: GetTaskChatMessagesUseCase, + private val getTaskUseCase: GetTaskUseCase +) : GenerateChatCompletionUseCase { + override suspend fun invoke(prompt: String, taskId: Uuid): String { + val messages = getTaskChatMessagesUseCase(taskId).asList() + val task = getTaskUseCase(taskId) + val fullPrompt = "$task $messages\n\nNew message: $prompt" + return llmRepository.generateChatCompletion(fullPrompt) + } + +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/usecase/ia/GenerateImageUseCase.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/usecase/ia/GenerateImageUseCase.kt new file mode 100644 index 000000000..a8462a118 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/usecase/ia/GenerateImageUseCase.kt @@ -0,0 +1,15 @@ +package io.middlepoint.morestuff.shared.domain.usecase.ia + +import io.middlepoint.morestuff.shared.domain.repository.LlmRepository + +interface GenerateImageUseCase { + suspend operator fun invoke(prompt: String): String +} + +class GenerateImageUseCaseImpl( + private val llmRepository: LlmRepository +) : GenerateImageUseCase { + override suspend fun invoke(prompt: String): String { + return llmRepository.generateImage(prompt) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/usecase/ia/StreamChatCompletionUseCase.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/usecase/ia/StreamChatCompletionUseCase.kt new file mode 100644 index 000000000..4a6c19a9e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/usecase/ia/StreamChatCompletionUseCase.kt @@ -0,0 +1,16 @@ +package io.middlepoint.morestuff.shared.domain.usecase.ia + +import io.middlepoint.morestuff.shared.domain.repository.LlmRepository +import kotlinx.coroutines.flow.Flow + +interface StreamChatCompletionUseCase { + operator fun invoke(prompt: String): Flow +} + +class StreamChatCompletionUseCaseImpl( + private val llmRepository: LlmRepository +) : StreamChatCompletionUseCase { + override fun invoke(prompt: String): Flow { + return llmRepository.streamChatCompletion(prompt) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/usecase/settings/GetApiKeyUseCase.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/usecase/settings/GetApiKeyUseCase.kt new file mode 100644 index 000000000..c60826061 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/usecase/settings/GetApiKeyUseCase.kt @@ -0,0 +1,14 @@ +package io.middlepoint.morestuff.shared.domain.usecase.settings + +import io.middlepoint.morestuff.shared.domain.repository.LlmRepository + +interface GetApiKeyUseCase { + operator fun invoke(): String +} + +class GetApiKeyUseCaseImpl( + private val llmRepository: LlmRepository +) : GetApiKeyUseCase { + override fun invoke(): String = + llmRepository.getApiKey() ?: throw IllegalStateException("API key not found") +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/usecase/settings/OpenAppSettingsUseCase.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/usecase/settings/OpenAppSettingsUseCase.kt new file mode 100644 index 000000000..fe1d3c48f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/usecase/settings/OpenAppSettingsUseCase.kt @@ -0,0 +1,23 @@ +package io.middlepoint.morestuff.shared.domain.usecase.settings + +import arrow.core.Either +import io.middlepoint.morestuff.shared.AppSettingsHandler + + +interface OpenAppSettingsUseCase { + operator fun invoke(): Either +} + +class OpenAppSettingsUseCaseImpl( + private val appSettingsHandler: AppSettingsHandler +) : OpenAppSettingsUseCase { + + override fun invoke(): Either { + return try { + appSettingsHandler.openAppSettings() + Either.Right(Unit) + } catch (e: Exception) { + Either.Left("Error opening app settings: ${e.message}") + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/usecase/settings/SaveApiKeyUseCase.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/usecase/settings/SaveApiKeyUseCase.kt new file mode 100644 index 000000000..269d60a75 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/domain/usecase/settings/SaveApiKeyUseCase.kt @@ -0,0 +1,15 @@ +package io.middlepoint.morestuff.shared.domain.usecase.settings + +import io.middlepoint.morestuff.shared.domain.repository.LlmRepository + +interface SaveApiKeyUseCase { + operator fun invoke(apiKey: String) +} + +class SaveApiKeyUseCaseImpl( + private val llmRepository: LlmRepository +) : SaveApiKeyUseCase { + override fun invoke(apiKey: String) { + llmRepository.saveApiKey(apiKey) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/components/CreateScopeBottomSheet.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/components/CreateScopeBottomSheet.kt index e5ddda6cb..c6f841b25 100644 --- a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/components/CreateScopeBottomSheet.kt +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/components/CreateScopeBottomSheet.kt @@ -1,24 +1,21 @@ - - package io.middlepoint.morestuff.shared.ui.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SheetState import androidx.compose.material3.SheetValue import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -31,8 +28,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import io.middlepoint.morestuff.shared.ui.screen.scopes.ScopeTitleEditor import io.middlepoint.morestuff.shared.ui.theme.surfaceContainerElevation import kotlinx.coroutines.delay @@ -41,12 +40,8 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import morestuff.composeapp.generated.resources.Res -import morestuff.composeapp.generated.resources.cancel -import morestuff.composeapp.generated.resources.cd_scopes_icon import morestuff.composeapp.generated.resources.description_create_scope -import morestuff.composeapp.generated.resources.ic_scope_add import morestuff.composeapp.generated.resources.save -import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @OptIn(ExperimentalMaterial3Api::class) @@ -86,23 +81,39 @@ fun CreateScopeBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.surfaceContainerElevation, + dragHandle = { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Spacer(modifier = Modifier.weight(1f)) + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.Center + ) { + BottomSheetDefaults.DragHandle() + } + TextButton( + onClick = { + if (scopeTitle.isNotBlank()) { + onConfirm(scopeTitle.trim()) + keyboardController?.hide() + } + }, + enabled = showSaveAction + ) { + Text(text = stringResource(Res.string.save).uppercase()) + } + } + } ) { Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { - Icon( - painter = painterResource(Res.drawable.ic_scope_add), - contentDescription = stringResource(Res.string.cd_scopes_icon), - modifier = Modifier - .size(50.dp), - tint = MaterialTheme.colorScheme.onSurface - ) - - Spacer( - modifier = Modifier.height(16.dp) - ) Text( text = stringResource(Res.string.description_create_scope), @@ -115,49 +126,28 @@ fun CreateScopeBottomSheet( ) } - Box { - ScopeTitleEditor( - title = scopeTitle, - onTitleChange = { title -> scopeTitle = title.trim() }, - modifier = Modifier.focusRequester(focusRequester) - ) - } + ScopeTitleEditor( + title = scopeTitle, + onTitleChange = { title -> scopeTitle = title }, + modifier = Modifier.focusRequester(focusRequester) + ) - Column( + Row( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End ) { - Button( - onClick = { - if (scopeTitle.isNotBlank()) { - onConfirm(scopeTitle.trim()) - keyboardController?.hide() - } - }, - modifier = Modifier.fillMaxWidth(), - enabled = showSaveAction - ) { - Text(text = stringResource(Res.string.save)) - } - - Spacer(modifier = Modifier.height(8.dp)) - - Button( - onClick = { - keyboardController?.hide() - onDismissRequest() - }, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer - ) - ) { - Text(text = stringResource(Res.string.cancel)) - } - - Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "${(scopeTitle.length)}/12", + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + style = MaterialTheme.typography.bodyLarge.copy( + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + textAlign = TextAlign.End + ), + modifier = Modifier.padding(end = 16.dp) + ) } } } diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/components/input/UserInput.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/components/input/UserInput.kt index 995b4d50f..930b342f9 100644 --- a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/components/input/UserInput.kt +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/components/input/UserInput.kt @@ -1,11 +1,9 @@ package io.middlepoint.morestuff.shared.ui.components.input -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/components/input/UserTextInput.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/components/input/UserTextInput.kt index 1719a02bc..78f8f200f 100644 --- a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/components/input/UserTextInput.kt +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/components/input/UserTextInput.kt @@ -1,6 +1,16 @@ package io.middlepoint.morestuff.shared.ui.components.input +import androidx.compose.animation.Crossfade import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateDp +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope @@ -19,11 +29,14 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.semantics.contentDescription @@ -35,7 +48,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import io.middlepoint.morestuff.shared.ui.extension.clearFocusOnKeyboardDismiss import morestuff.composeapp.generated.resources.Res -import morestuff.composeapp.generated.resources.main_input_hint +import morestuff.composeapp.generated.resources.chat_whit_ai import morestuff.composeapp.generated.resources.task_chat_input_hint import morestuff.composeapp.generated.resources.textfield_desc import org.jetbrains.compose.resources.stringResource @@ -43,89 +56,146 @@ import org.jetbrains.compose.resources.stringResource val LocalBoxWeight = compositionLocalOf { 0.12f } + @Composable fun UserTextInput( - value: TextFieldValue, - onValueChange: (TextFieldValue) -> Unit, - modifier: Modifier = Modifier, - actionsContent: @Composable BoxScope.() -> Unit = {}, - backgroundColor: Color = MaterialTheme.colorScheme.background, - focusRequester: FocusRequester = remember { FocusRequester() }, - startWithFocus: Boolean = false, - inputHint: String = stringResource(Res.string.task_chat_input_hint) + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + modifier: Modifier = Modifier, + actionsContent: @Composable BoxScope.() -> Unit = {}, + leadingContent: @Composable (() -> Unit)? = null, + backgroundColor: Color = MaterialTheme.colorScheme.background, + focusRequester: FocusRequester = remember { FocusRequester() }, + startWithFocus: Boolean = false, + isAIEnabled: Boolean = false, + inputHint: String = stringResource(Res.string.task_chat_input_hint) ) { + val a11ylabel = stringResource(Res.string.textfield_desc) + val boxWeight = LocalBoxWeight.current - val a11ylabel = stringResource(Res.string.textfield_desc) - val boxWeight = LocalBoxWeight.current + val infiniteTransition = rememberInfiniteTransition(label = "neon") + val animatedOffset by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 400f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 2000, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + label = "neonOffset" + ) - LaunchedEffect(Unit) { - if (startWithFocus) { - focusRequester.requestFocus() - } + val aiEnabledTransition = updateTransition( + targetState = isAIEnabled, + label = "aiEnabledTransition" + ) + + val borderWidth by aiEnabledTransition.animateDp( + label = "borderWidth", + transitionSpec = { tween(300) } + ) { enabled -> if (enabled) 2.dp else 0.dp } + + val borderAlpha by aiEnabledTransition.animateFloat( + label = "borderAlpha", + transitionSpec = { tween(300) } + ) { enabled -> if (enabled) 1f else 0f } + + + LaunchedEffect(Unit) { + if (startWithFocus) { + focusRequester.requestFocus() } + } + + val animatedBorder = if (borderAlpha > 0f) { + val animatedNeonGradient = Brush.linearGradient( + colors = listOf( + Color(0xFFBDC6FF).copy(alpha = borderAlpha), + Color.White.copy(alpha = 0.7f * borderAlpha), + Color(0xFFDBCAFF).copy(alpha = borderAlpha) + ), + start = Offset(animatedOffset, 0f), + end = Offset(animatedOffset + 200f, 100f) + ) + BorderStroke(borderWidth, animatedNeonGradient) + } else null - Surface( - modifier = modifier - .fillMaxWidth() - .padding(start = 10.dp, end = 10.dp, bottom = 6.dp) - .animateContentSize(), shape = RoundedCornerShape(42), color = backgroundColor + Surface( + modifier = modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, bottom = 6.dp) + .animateContentSize(), + shape = RoundedCornerShape(42), + color = backgroundColor, + border = animatedBorder, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 42.dp) + .semantics { contentDescription = a11ylabel }, + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier - .fillMaxWidth() - .defaultMinSize(minHeight = 42.dp) - .semantics { - contentDescription = a11ylabel - }, - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically + if (leadingContent != null) { + Box( + modifier = Modifier + .align(Alignment.CenterVertically) ) { - BasicTextField(value = value, - onValueChange = onValueChange, - modifier = Modifier - .clearFocusOnKeyboardDismiss() - .focusRequester(focusRequester) - .weight(0.88f) - .align(Alignment.CenterVertically) - .padding(start = 28.dp, end = 4.dp), - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.Sentences, - keyboardType = KeyboardType.Text - ), - maxLines = 4, - cursorBrush = SolidColor(LocalContentColor.current), - textStyle = LocalTextStyle.current.copy( - color = LocalContentColor.current, fontSize = 18.sp - ), - decorationBox = { innerTextField -> - Box( - contentAlignment = Alignment.CenterStart, - modifier = Modifier.padding(bottom = 6.dp, top = 6.dp) - ) { - if (value.text.isEmpty()) { - Text( - text = inputHint, - modifier = Modifier.align(Alignment.CenterStart), - style = LocalTextStyle.current.copy( - color = LocalContentColor.current.copy(alpha = 0.6f), - fontSize = 18.sp - ) - ) - } - innerTextField() - } - }) - - Box( - modifier = Modifier - .weight(if (value.text.isBlank()) boxWeight else 0.15f) - .align(Alignment.Bottom) - .padding(end = 8.dp) - ) { - actionsContent() + leadingContent() + } + } + + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier + .clearFocusOnKeyboardDismiss() + .focusRequester(focusRequester) + .weight(0.88f) + .align(Alignment.CenterVertically) + .padding(start = 4.dp, end = 4.dp), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + keyboardType = KeyboardType.Text + ), + maxLines = 4, + cursorBrush = SolidColor(LocalContentColor.current), + textStyle = LocalTextStyle.current.copy( + color = LocalContentColor.current, + fontSize = 18.sp + ), + decorationBox = { innerTextField -> + Box( + contentAlignment = Alignment.CenterStart, + modifier = Modifier.padding(bottom = 6.dp, top = 6.dp) + ) { + if (value.text.isEmpty()) { + Crossfade(targetState = isAIEnabled, animationSpec = tween(350)) { aiEnabled -> + Text( + text = if (!aiEnabled) inputHint else stringResource(Res.string.chat_whit_ai), + modifier = Modifier.align(Alignment.CenterStart), + style = LocalTextStyle.current.copy( + color = LocalContentColor.current.copy(alpha = 0.6f), + fontSize = 18.sp + ) + ) + } } + innerTextField() + } } + ) + + Box( + modifier = Modifier + .weight(if (value.text.isBlank()) boxWeight else 0.15f) + .align(Alignment.Bottom) + .padding(end = 8.dp) + ) { + actionsContent() + } } + } } diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/chat/ChatMessages.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/chat/ChatMessages.kt index 14380a765..0984c04bb 100644 --- a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/chat/ChatMessages.kt +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/chat/ChatMessages.kt @@ -40,9 +40,11 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import io.middlepoint.morestuff.shared.domain.enums.ContentType +import io.middlepoint.morestuff.shared.domain.service.logger import io.middlepoint.morestuff.shared.ui.local.ProvideUserInteractionEnabled import io.middlepoint.morestuff.shared.ui.model.MessageUiModel import io.middlepoint.morestuff.shared.ui.screen.chat.items.AppChatItem +import io.middlepoint.morestuff.shared.ui.screen.chat.items.AppChatItemLoading import io.middlepoint.morestuff.shared.ui.screen.chat.items.TaskReminderItem import io.middlepoint.morestuff.shared.ui.screen.chat.items.UserChatItem import kotlinx.coroutines.launch @@ -67,12 +69,21 @@ fun Messages( actions: ChatActions = ChatActions(), userInteractionEnabled: Boolean = true, contentPadding: PaddingValues = PaddingValues(0.dp), + isAILoading: Boolean = false, + ) { val scope = rememberCoroutineScope() var itemsCount by remember { mutableIntStateOf(0) } val enableAutoScroll = isAutoScrollingEnabled(messages.size, itemsCount, scrollState) itemsCount = messages.size + LaunchedEffect(messages) { + logger.d { "Messages in Messages component: ${messages.size}" } + messages.forEach { message -> + logger.d { "Message in component: id=${message.id}, type=${message.contentType}, content='${message.content}'" } + } + } + ProvideUserInteractionEnabled(userInteractionEnabled) { Box(modifier = modifier) { @@ -85,6 +96,11 @@ fun Messages( state = scrollState, contentPadding = contentPadding ) { + if (isAILoading) { + item(key = "ai_loading") { + AppChatItemLoading() + } + } itemsIndexed( items = messages, key = { _, item -> item.id.value }, @@ -110,11 +126,15 @@ fun Messages( ContentType.CONFIRM_NEW_TASK, ContentType.APP_TASK_MESSAGE -> AppChatItem(item, actions) - + ContentType.AI_TASK_MESSAGE -> { + logger.d { "Rendering message: ${item.contentType}, ID=${item.id}" } + AppChatItem(item, actions) + } ContentType.TASK_REMINDER -> TaskReminderItem(item, actions) } } } + } if (enableAutoScroll) { diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/chat/items/AppChatItem.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/chat/items/AppChatItem.kt index 8f1bcd339..39c5595ed 100644 --- a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/chat/items/AppChatItem.kt +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/chat/items/AppChatItem.kt @@ -4,30 +4,20 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Button import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import io.middlepoint.morestuff.shared.domain.enums.ReplyType import io.middlepoint.morestuff.shared.ui.model.MessageUiModel import io.middlepoint.morestuff.shared.ui.screen.chat.ChatActions -import morestuff.composeapp.generated.resources.Res -import morestuff.composeapp.generated.resources.chat_action_today -import morestuff.composeapp.generated.resources.chat_action_tomorrow -import morestuff.composeapp.generated.resources.done -import org.jetbrains.compose.resources.stringResource @Composable fun AppChatItem( @@ -82,6 +72,7 @@ fun AppChatItem( } } + @Composable fun TaskReminderItem(message: MessageUiModel, actions: ChatActions) { Column { diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/chat/items/AppChatItemLoading.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/chat/items/AppChatItemLoading.kt new file mode 100644 index 000000000..3bfefd7f5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/chat/items/AppChatItemLoading.kt @@ -0,0 +1,133 @@ +package io.middlepoint.morestuff.shared.ui.screen.chat.items + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Composable +fun AppChatItemLoading() { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(end = 45.dp), + horizontalArrangement = Arrangement.Start + ) { + Box( + modifier = Modifier + .padding(start = 5.dp, bottom = 4.dp) + ) { + Surface( + onClick = { }, + shape = RoundedCornerShape( + topStart = 14.dp, + topEnd = 14.dp, + bottomEnd = 14.dp, + bottomStart = 5.dp + ), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Column { + TypingDots( + modifier = Modifier + .padding( + start = 18.dp, + end = 18.dp, + top = 12.dp, + bottom = 12.dp + ) + ) + } + } + } + } +} + + +@Composable +fun TypingDots( + modifier: Modifier = Modifier, + dotColor: Color = MaterialTheme.colorScheme.onSecondaryContainer, + dotSize: Dp = 10.dp, + dotCount: Int = 3, + delayMillis: Int = 300, + animationDuration: Int = 600 +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(dotSize / 2) + ) { + repeat(dotCount) { index -> + val infiniteTransition = rememberInfiniteTransition(label = "dotTransition$index") + + val totalDuration = animationDuration + (delayMillis * (dotCount - 1)) + + val scale by infiniteTransition.animateFloat( + initialValue = 0.6f, + targetValue = 0.6f, + animationSpec = infiniteRepeatable( + animation = keyframes { + durationMillis = totalDuration + 0.6f at 0 + 1.2f at index * delayMillis using FastOutSlowInEasing + 1.2f at index * delayMillis + (animationDuration / 2) + 0.6f at index * delayMillis + animationDuration + }, + repeatMode = RepeatMode.Restart + ), + label = "scale$index" + ) + + val alpha by infiniteTransition.animateFloat( + initialValue = 0.3f, + targetValue = 0.3f, + animationSpec = infiniteRepeatable( + animation = keyframes { + durationMillis = totalDuration + 0.3f at 0 + 0.9f at index * delayMillis using FastOutSlowInEasing + 0.9f at index * delayMillis + (animationDuration / 2) + 0.3f at index * delayMillis + animationDuration + }, + repeatMode = RepeatMode.Restart + ), + label = "alpha$index" + ) + + Box( + modifier = Modifier + .size(dotSize) + .graphicsLayer { + scaleX = scale + scaleY = scale + this.alpha = alpha + } + .background( + color = dotColor, + shape = CircleShape + ) + ) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/chat/task/TaskChatModel.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/chat/task/TaskChatModel.kt index 986096612..c6d55b8cd 100644 --- a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/chat/task/TaskChatModel.kt +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/chat/task/TaskChatModel.kt @@ -2,6 +2,7 @@ package io.middlepoint.morestuff.shared.ui.screen.chat.task import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -59,6 +60,12 @@ fun taskChatModel( var editingMessageId by remember { mutableStateOf(initialState.editingMessageId) } var editingMessageContent by remember { mutableStateOf(initialState.editingMessageContent) } var allScopes by remember { mutableStateOf(initialState.allScopes) } + var isAIEnabled by remember { mutableStateOf(initialState.isAIEnabled) } + + val isAILoading by store.state + .map { it.aiMessageState.loadingMap[taskId] ?: false } + .collectAsState(initial = false) + fun updateScopeAfterMove(scopeId: Uuid) { val newScope = allScopes.find { it.id == scopeId } @@ -79,9 +86,10 @@ fun taskChatModel( LaunchedEffect(taskId) { getScopeByTaskIdUseCase(taskId).fold( { failure -> - logger.e { "Error loading scope for task: $failure" } + logger.e { "Error loading scope for task ${taskId.value}: $failure" } }, { scopeDomain -> + logger.d { "✅ Scope loaded for task ${taskId.value}: id=${scopeDomain.id.value}, name=${scopeDomain.name}" } scope = scopeUiMapper.map(scopeDomain) } ) @@ -128,11 +136,22 @@ fun taskChatModel( ) } - is InputText -> { + /*is InputText -> { store.dispatch( MessageAction.CreateUserTaskMessageAction(taskId, content.trim()) ) - } + store.dispatch( + MessageAction.CreateAITaskMessageAction(taskId, content.trim()) + ) + + + }*/ + + is InputText -> { + store.dispatch( + MessageAction.CreateUserTaskMessageAction(taskId, content.trim()) + ) + } is OpenDocument -> { mediaHandler.openPDF(path) @@ -232,6 +251,17 @@ fun taskChatModel( logger.d { "Task moved to new scope: ${newScope.name}" } } } + + is CreateAIMessage -> { + store.dispatch( + MessageAction.CreateAITaskMessageAction(taskId, prompt) + ) + } + + is ActivateAI -> { + isAIEnabled = !isAIEnabled + logger.d { "AI ${if (isAIEnabled) "enabled" else "disabled"} for task: $taskId" } + } } } } @@ -243,6 +273,8 @@ fun taskChatModel( messages = messages, editingMessageId = editingMessageId, editingMessageContent = editingMessageContent, - allScopes = allScopes + allScopes = allScopes, + isAIEnabled = isAIEnabled, + isAILoading = isAILoading, ) } diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/chat/task/TaskChatScreen.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/chat/task/TaskChatScreen.kt index 11c35d9f9..e5d1fa708 100644 --- a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/chat/task/TaskChatScreen.kt +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/chat/task/TaskChatScreen.kt @@ -1,6 +1,9 @@ package io.middlepoint.morestuff.shared.ui.screen.chat.task import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn @@ -121,11 +124,14 @@ import morestuff.composeapp.generated.resources.complete import morestuff.composeapp.generated.resources.confirm_delete import morestuff.composeapp.generated.resources.delete import morestuff.composeapp.generated.resources.edit_message +import morestuff.composeapp.generated.resources.ic_ai_disabled +import morestuff.composeapp.generated.resources.ic_ai_enabled import morestuff.composeapp.generated.resources.restore import morestuff.composeapp.generated.resources.select_image import morestuff.composeapp.generated.resources.select_pdf import morestuff.composeapp.generated.resources.task_chat_complete_message import morestuff.composeapp.generated.resources.task_schedule_deletion_warning_singular +import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject import org.koin.core.parameter.parametersOf @@ -163,6 +169,7 @@ fun TaskChatScreen( is TaskChat -> { val model by viewModel.models.collectAsState() + val isAiEnabled = model.isAIEnabled val chatActions = remember { ChatActions( @@ -202,14 +209,18 @@ fun TaskChatScreen( chatActions = chatActions, modifier = modifier, onBack = onBack, - sendTaskMessage = { viewModel.take(InputText(it)) }, + sendTaskMessage = { + viewModel.take(InputText(it)) + if (isAiEnabled) viewModel.take(TaskChatEvent.CreateAIMessage(it)) + }, imagePicked = { scope.launch { router.push(ImageImport(it)) } }, pdfPicked = { viewModel.take(InputDocument(it, title = "")) }, onCreateNewScope = { title -> viewModel.take(TaskChatEvent.CreateNewScopeForTask(title)) navigation.pop() }, - navigateToCreateScope = navigateToCreateScope + navigateToCreateScope = navigateToCreateScope, + isAIEnabled = isAiEnabled ) } @@ -251,6 +262,7 @@ private fun TaskChatContent( logger: Logger = koinInject(), onCreateNewScope: (String) -> Unit = {}, navigateToCreateScope: (onScopeCreated: (String) -> Unit) -> Unit = {}, + isAIEnabled: Boolean = false ) { val coroutineScope = rememberCoroutineScope() val scrollState = rememberLazyListState() @@ -267,6 +279,7 @@ private fun TaskChatContent( val editingMessageId = model.editingMessageId val editingMessageContent = model.editingMessageContent val allScopes = model.allScopes + val isAILoading = model.isAILoading val focusManager = LocalFocusManager.current var titleLineCount by remember { mutableStateOf(0) } @@ -329,6 +342,16 @@ private fun TaskChatContent( } } + val aiMessages = remember(messages) { + messages.filter { it.contentType == ContentType.AI_TASK_MESSAGE } + } + + LaunchedEffect(aiMessages) { + logger.d { "AI Messages: ${aiMessages.size}" } + aiMessages.forEach { message -> + logger.d { "AI Message: id=${message.id}, content='${message.content}'" } + } + } val scopeName = scope.name @@ -369,8 +392,8 @@ private fun TaskChatContent( modifier = modifier.weight(1f), scrollState = scrollState, contentPadding = contentPadding, + isAILoading = isAILoading, ) - AnimatedVisibility( visible = !task.isComplete, modifier = Modifier.background(Color.Transparent) @@ -406,6 +429,8 @@ private fun TaskChatContent( onUpdateMessage = { content -> onEvent(TaskChatEvent.UpdateMessageContent(content)) }, + isAIEnabled = isAIEnabled, + onToggleAI = { onEvent(TaskChatEvent.ActivateAI) }, modifier = Modifier .fillMaxWidth() .background(Color.Transparent), @@ -475,7 +500,9 @@ private fun TaskChatInput( editingMessageId: Uuid? = null, editingContent: String = "", onCancelEdit: () -> Unit = {}, - onUpdateMessage: (String) -> Unit = {} + onUpdateMessage: (String) -> Unit = {}, + isAIEnabled: Boolean = false, + onToggleAI: () -> Unit = {} ) { val isTextEmpty = remember { mutableStateOf(editingContent.isEmpty()) } val isRecording = remember { mutableStateOf(false) } @@ -521,6 +548,7 @@ private fun TaskChatInput( CompositionLocalProvider(LocalBoxWeight provides weight) { UserTextInput( value = userInputValue, + isAIEnabled = isAIEnabled, onValueChange = { userInputValue = it isTextEmpty.value = it.text.isBlank() @@ -531,6 +559,28 @@ private fun TaskChatInput( }, backgroundColor = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.focusRequester(focusRequester), + leadingContent = { + IconButton( + onClick = onToggleAI, + ) { + Crossfade( + targetState = isAIEnabled, + animationSpec = tween( + durationMillis = 300, + easing = FastOutSlowInEasing + ) + ) { aiEnabled -> + Icon( + painter = if (aiEnabled) + painterResource(Res.drawable.ic_ai_enabled) + else + painterResource(Res.drawable.ic_ai_disabled), + contentDescription = "Toggle AI", + tint = Color.Unspecified + ) + } + } + }, actionsContent = { Row( verticalAlignment = Alignment.CenterVertically, @@ -758,7 +808,7 @@ private fun TaskTopAppBar( }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHighest + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh ) ) } diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/chat/task/TaskChatStateModels.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/chat/task/TaskChatStateModels.kt index a3f1add09..1be9a7f58 100644 --- a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/chat/task/TaskChatStateModels.kt +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/chat/task/TaskChatStateModels.kt @@ -17,6 +17,8 @@ data class TaskChatState( val editingMessageId: Uuid? = null, val editingMessageContent: String = "", val allScopes: List = listOf(), + val isAIEnabled: Boolean = false, + val isAILoading: Boolean = false, ) @Immutable @@ -39,4 +41,6 @@ sealed class TaskChatEvent { data class CreateTaskCompletionMessage(val content: String) : TaskChatEvent() data class MoveTaskToScope(val scopeId: Uuid) : TaskChatEvent() data class CreateNewScopeForTask(val title: String) : TaskChatEvent() + data object ActivateAI : TaskChatEvent() + data class CreateAIMessage(val prompt: String) : TaskChatEvent() } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/chat/task/TaskDetails.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/chat/task/TaskDetails.kt index 5af9a9ac4..9601766ef 100644 --- a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/chat/task/TaskDetails.kt +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/chat/task/TaskDetails.kt @@ -119,7 +119,7 @@ fun TaskDetails( modifier = Modifier .padding(bottom = 18.dp) .animateContentSize(), - color = MaterialTheme.colorScheme.surfaceContainerHighest + color = MaterialTheme.colorScheme.surfaceContainerHigh ) { Box( modifier = Modifier diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/chat/task/TaskSchedule.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/chat/task/TaskSchedule.kt index 73e53a379..d57b5eb29 100644 --- a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/chat/task/TaskSchedule.kt +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/chat/task/TaskSchedule.kt @@ -117,7 +117,7 @@ fun TaskSchedule( Row( modifier = Modifier .fillMaxWidth() - .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) .padding(start = 12.dp, top = 17.dp, bottom = 7.dp), verticalAlignment = Alignment.CenterVertically ) { diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/scopes/CreateScopeScreen.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/scopes/CreateScopeScreen.kt index 84730e01f..a5bd50c29 100644 --- a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/scopes/CreateScopeScreen.kt +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/scopes/CreateScopeScreen.kt @@ -1,10 +1,13 @@ package io.middlepoint.morestuff.shared.ui.screen.scopes +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -33,6 +36,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension import kotlinx.coroutines.delay @@ -141,10 +145,6 @@ fun CreateScopeScreen( color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.bodyLarge.copy(textAlign = TextAlign.Center) ) - - Spacer( - modifier = Modifier.height(16.dp) - ) } Box( @@ -155,12 +155,31 @@ fun CreateScopeScreen( height = Dimension.wrapContent } ) { - ScopeTitleEditor( - title = scopeTitle, - onTitleChange = { title -> scopeTitle = title.trim() }, - modifier = Modifier.focusRequester(focusRequester) - ) + Column { + ScopeTitleEditor( + title = scopeTitle, + onTitleChange = { title -> scopeTitle = title }, + modifier = Modifier.focusRequester(focusRequester) + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "${scopeTitle.length}/12", + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + style = MaterialTheme.typography.bodyLarge.copy( + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + textAlign = TextAlign.End + ) + ) + } + } } + } } } diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/scopes/ScopeTitleEditor.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/scopes/ScopeTitleEditor.kt index 676774fb3..debcc0fa2 100644 --- a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/scopes/ScopeTitleEditor.kt +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/scopes/ScopeTitleEditor.kt @@ -9,15 +9,12 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusManager -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.sp @Composable fun ScopeTitleEditor( @@ -25,9 +22,17 @@ fun ScopeTitleEditor( onTitleChange: (String) -> Unit, modifier: Modifier = Modifier, ) { + + val fontSize = when (title.length) { + in 0..9 -> 55.sp + in 10..12 -> 50.sp + else -> 50.sp + } + CompositionLocalProvider( LocalTextStyle provides MaterialTheme.typography.displayLarge.copy( color = MaterialTheme.colorScheme.onSurface, + fontSize = fontSize, textAlign = TextAlign.Center ), LocalContentColor provides MaterialTheme.colorScheme.onSurface @@ -35,8 +40,8 @@ fun ScopeTitleEditor( BasicTextField( value = title, onValueChange = { - val input = it.trim() - if (input.length <= 9) { + val input = it + if (input.length <= 12) { onTitleChange(input) } }, diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/settings/ApiKeyBottomSheet.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/settings/ApiKeyBottomSheet.kt new file mode 100644 index 000000000..32c97d519 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/settings/ApiKeyBottomSheet.kt @@ -0,0 +1,130 @@ +package io.middlepoint.morestuff.shared.ui.screen.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import io.middlepoint.morestuff.shared.ui.theme.surfaceContainerElevation +import morestuff.composeapp.generated.resources.Res +import morestuff.composeapp.generated.resources.api_key_label +import morestuff.composeapp.generated.resources.api_key_title +import morestuff.composeapp.generated.resources.cancel +import morestuff.composeapp.generated.resources.save +import org.jetbrains.compose.resources.stringResource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ApiKeyBottomSheet( + isVisible: Boolean, + onDismiss: () -> Unit, + currentApiKey: String, + onSave: (String) -> Unit +) { + if (!isVisible) return + + val sheetState = rememberModalBottomSheetState() + var apiKeyText by remember(currentApiKey) { mutableStateOf(currentApiKey) } + var isPasswordVisible by remember { mutableStateOf(false) } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainerElevation, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(Res.string.api_key_title), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(bottom = 16.dp), + color = MaterialTheme.colorScheme.onSurface + ) + + OutlinedTextField( + value = apiKeyText, + onValueChange = { apiKeyText = it }, + label = { Text(stringResource(Res.string.api_key_label)) }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + visualTransformation = if (isPasswordVisible) + VisualTransformation.None + else + PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + onSave(apiKeyText) + onDismiss() + } + ), + trailingIcon = { + IconButton(onClick = { isPasswordVisible = !isPasswordVisible }) { + Icon( + imageVector = if (isPasswordVisible) + Icons.Default.VisibilityOff + else + Icons.Default.Visibility, + contentDescription = if (isPasswordVisible) + "Hide API Key" + else + "Show API Key" + ) + } + } + ) + + Spacer(modifier = Modifier.height(24.dp)) + Button( + onClick = { + onSave(apiKeyText) + onDismiss() + }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + ) { + Text(stringResource(Res.string.save)) + } + Spacer(modifier = Modifier.height(16.dp)) + TextButton( + onClick = onDismiss, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + ) { + Text(stringResource(Res.string.cancel)) + } + Spacer(modifier = Modifier.height(16.dp)) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/settings/SettingsModel.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/settings/SettingsModel.kt index deec23a85..6d82a1f54 100644 --- a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/settings/SettingsModel.kt +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/settings/SettingsModel.kt @@ -6,19 +6,25 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import io.middlepoint.morestuff.shared.data.middleware.AuthMiddleware import io.middlepoint.morestuff.shared.domain.enums.AppTheme import io.middlepoint.morestuff.shared.domain.enums.Language import io.middlepoint.morestuff.shared.domain.redux.AppStore import io.middlepoint.morestuff.shared.domain.redux.action.SettingAction import io.middlepoint.morestuff.shared.domain.redux.action.UserAction +import io.middlepoint.morestuff.shared.domain.usecase.settings.GetApiKeyUseCase +import io.middlepoint.morestuff.shared.domain.usecase.settings.OpenAppSettingsUseCase +import io.middlepoint.morestuff.shared.domain.usecase.settings.SaveApiKeyUseCase import kotlinx.coroutines.flow.Flow +import org.koin.compose.koinInject @Composable fun settingsModel( - initialState: SettingsState, - events: Flow, - store: AppStore + initialState: SettingsState, + events: Flow, + openAppSettingsUseCase: OpenAppSettingsUseCase = koinInject(), + saveApiKeyUseCase: SaveApiKeyUseCase = koinInject(), + getApiKeyUseCase: GetApiKeyUseCase = koinInject(), + store: AppStore ): SettingsState { var state by remember { mutableStateOf(initialState) } @@ -41,11 +47,28 @@ fun settingsModel( store.dispatch(SettingAction.SetVoiceLanguage(Language[event.index])) state.copy(inputVoiceLanguage = Language[event.index]) } + is SettingsEvent.SetApiKey -> { + saveApiKeyUseCase(event.apiKey) + state.copy(apiKey = event.apiKey) + } + is SettingsEvent.GetApiKey -> { + try { + val apiKey = getApiKeyUseCase() + state.copy(apiKey = apiKey) + } catch (e: IllegalStateException) { + state + } + } SettingsEvent.SignOut -> { store.dispatch(UserAction.SignOut) state } + + SettingsEvent.OpenAppSettings -> { + openAppSettingsUseCase() + state + } } } } diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/settings/SettingsScreen.kt index 42d5b1dca..8dac9edb2 100644 --- a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/settings/SettingsScreen.kt @@ -19,6 +19,8 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Logout import androidx.compose.material.icons.filled.ColorLens import androidx.compose.material.icons.filled.DeveloperBoard +import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.ModeStandby import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Translate @@ -50,6 +52,7 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.DialogProperties @@ -89,6 +92,8 @@ import io.middlepoint.morestuff.shared.ui.screen.scopes.ScopesScreen import io.middlepoint.morestuff.shared.ui.theme.surfaceContainerElevation import kotlinx.coroutines.launch import morestuff.composeapp.generated.resources.Res +import morestuff.composeapp.generated.resources.api_key_not_set +import morestuff.composeapp.generated.resources.api_key_title import morestuff.composeapp.generated.resources.button_enable import morestuff.composeapp.generated.resources.button_skip import morestuff.composeapp.generated.resources.cd_schedule_icon @@ -151,7 +156,9 @@ fun SettingsScreen( showDevSettings = { router.push(Developer) }, showScopesSettings = { router.push(Scopes) }, showLibraries = { router.push(AboutLibraries) }, - signOut = { viewModel.take(SettingsEvent.SignOut) } + signOut = { viewModel.take(SettingsEvent.SignOut) }, + openAppSettings = {viewModel.take(SettingsEvent.OpenAppSettings)}, + setApiKey = { apiKey -> viewModel.take(SettingsEvent.SetApiKey(apiKey)) } ) } @@ -177,7 +184,9 @@ fun SettingsContent( signOut: () -> Unit, showLibraries: () -> Unit, showDevSettings: () -> Unit, - showScopesSettings: () -> Unit + showScopesSettings: () -> Unit, + openAppSettings: () -> Unit, + setApiKey: (String) -> Unit ) { val scrollState = rememberScrollState() @@ -237,6 +246,16 @@ fun SettingsContent( ) ) + if (Platform.Android == platform) { + LanguageSettings(onClick = openAppSettings ) + } + + ApiKeySettings( + apiKey = model.apiKey, + onApiKeyChange = setApiKey + ) + + if (model.devSettings) { SettingsMenuLink( title = { @@ -270,6 +289,56 @@ fun SettingsContent( } + +@Composable +fun ApiKeySettings( + apiKey: String, + onApiKeyChange: (String) -> Unit, +) { + var showBottomSheet by remember { mutableStateOf(false) } + + + if (showBottomSheet) { + ApiKeyBottomSheet( + isVisible = true, + onDismiss = { showBottomSheet = false }, + currentApiKey = apiKey, + onSave = onApiKeyChange + ) + } + + + SettingsMenuLink( + title = { + Text(text = stringResource(Res.string.api_key_title)) + }, + subtitle = { + Text( + text = if (apiKey.isNotEmpty()) { + val visiblePart = apiKey.take(4) + val hiddenPart = "*".repeat(minOf(apiKey.length - 4, 8)) + "$visiblePart$hiddenPart" + } else { + stringResource(Res.string.api_key_not_set) + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + onClick = { showBottomSheet = true }, + icon = { + Icon( + imageVector = Icons.Default.Key, + contentDescription = "API Key" + ) + }, + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceContainer + ) + ) +} + + @Composable private fun SelectTheme( themeSelected: (Int) -> Unit, @@ -288,7 +357,7 @@ private fun SelectTheme( title = { Text( text = stringResource(Res.string.select_theme), - style = MaterialTheme.typography.titleLarge.copy( + style = MaterialTheme.typography.bodyLarge.copy( color = MaterialTheme.colorScheme.onSurface ), ) @@ -603,6 +672,23 @@ private fun ScopeSettings(onClick: () -> Unit) { ) } +@Composable +private fun LanguageSettings(onClick: () -> Unit) { + SettingsMenuLink( + title = { Text(text = "Language") }, + onClick = onClick, + icon = { + Icon( + imageVector = Icons.Default.Language, + contentDescription = "Scopes" + ) + }, + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceContainer + ) + ) +} + @Composable @OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/settings/SettingsViewModel.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/settings/SettingsViewModel.kt index 4cf5d7eab..5b47d9ce8 100644 --- a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/settings/SettingsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/settings/SettingsViewModel.kt @@ -1,12 +1,14 @@ package io.middlepoint.morestuff.shared.ui.screen.settings import androidx.compose.runtime.Composable -import io.middlepoint.morestuff.shared.ui.MoleculeViewModel import io.middlepoint.morestuff.shared.domain.redux.AppStore +import io.middlepoint.morestuff.shared.domain.usecase.settings.GetApiKeyUseCase +import io.middlepoint.morestuff.shared.ui.MoleculeViewModel import kotlinx.coroutines.flow.SharedFlow class SettingsViewModel( private val store: AppStore, + private val getApiKeyUseCase: GetApiKeyUseCase ) : MoleculeViewModel() { override val initialState: SettingsState = with(store.state.value.settings) { @@ -15,10 +17,19 @@ class SettingsViewModel( snoozeLimit = snoozeLimit, devSettings = devSettings, //reviewTime = reviewTime, - inputVoiceLanguage = voiceInputLanguage + inputVoiceLanguage = voiceInputLanguage, + apiKey = getInitialApiKey() ) } + private fun getInitialApiKey(): String { + return try { + getApiKeyUseCase() + } catch (e: IllegalStateException) { + "" + } + } + @Composable override fun models(events: SharedFlow): SettingsState { return settingsModel( diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/settings/State.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/settings/State.kt index 4272d87dc..286dd0e41 100644 --- a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/settings/State.kt +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/screen/settings/State.kt @@ -9,7 +9,9 @@ data class SettingsState( val appTheme: AppTheme = AppTheme.System, val snoozeLimit: Int = 0, val devSettings: Boolean = false, - val inputVoiceLanguage: Language = Language.Device + //val reviewTime: Pair = Pair(9, 0), + val inputVoiceLanguage: Language = Language.Device, + val apiKey: String = "" ) sealed class SettingsEvent { @@ -18,4 +20,7 @@ sealed class SettingsEvent { data class EnableDevSettings(val enable: Boolean = true) : SettingsEvent() data class SelectLanguage(val index: Int) : SettingsEvent() data object SignOut: SettingsEvent() + data object OpenAppSettings : SettingsEvent() + data class SetApiKey(val apiKey: String) : SettingsEvent() + data object GetApiKey : SettingsEvent() } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/theme/Color.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/theme/Color.kt index 568c0ef2b..8faf3619a 100644 --- a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/theme/Color.kt +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/theme/Color.kt @@ -23,7 +23,7 @@ val md_theme_light_background = Color(0xFFFFFBFF) val md_theme_light_onBackground = Color(0xFF1B1B1F) val md_theme_light_surface = Color(0xFFFCF8FD) val md_theme_light_onSurface = Color(0xFF1B1B1F) -val md_theme_light_surfaceVariant = Color(0xFFE4E1E6) +val md_theme_light_surfaceVariant = Color(0xFFE3E1EC) val md_theme_light_onSurfaceVariant = Color(0xFF46464F) val md_theme_light_inverseSurface = Color(0xFF303034) val md_theme_light_inverseOnSurface = Color(0xFFF3F0F4) @@ -31,6 +31,7 @@ val md_theme_light_inversePrimary = Color(0xFFBDC2FF) val md_theme_light_surfaceTint = Color(0xFF4955B7) val md_theme_light_outlineVariant = Color(0xFFC7C5D0) val md_theme_light_scrim = Color(0xFF000000) +val md_theme_light_surface_container_high = Color(0xFFEAE7EF) val md_theme_dark_primary = Color(0xFFBDC2FF) val md_theme_dark_onPrimary = Color(0xFF142187) @@ -53,11 +54,13 @@ val md_theme_dark_background = Color(0xFF1B1B1F) val md_theme_dark_onBackground = Color(0xFFE4E1E6) val md_theme_dark_surface = Color(0xFF131316) val md_theme_dark_onSurface = Color(0xFFC8C5CA) -val md_theme_dark_surfaceVariant = Color(0xFF353438) +val md_theme_dark_surfaceVariant = Color(0xFF46464F) val md_theme_dark_onSurfaceVariant = Color(0xFFC7C5D0) val md_theme_dark_inverseSurface = Color(0xFFE4E1E6) val md_theme_dark_inverseOnSurface = Color(0xFF1B1B1F) -val md_theme_dark_inversePrimary = Color(0xFF4955B7) +val md_theme_dark_inversePrimary = Color(0xFF545A92 ) val md_theme_dark_surfaceTint = Color(0xFFBDC2FF) val md_theme_dark_outlineVariant = Color(0xFF46464F) val md_theme_dark_scrim = Color(0xFF000000) +val md_theme_dark_surface_container_high = Color(0xFF29292F) + diff --git a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/theme/Theme.kt b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/theme/Theme.kt index 7bc14143c..367f2bbcd 100644 --- a/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/theme/Theme.kt +++ b/composeApp/src/commonMain/kotlin/io/middlepoint/morestuff/shared/ui/theme/Theme.kt @@ -42,6 +42,7 @@ val LightColors = lightColorScheme( surfaceTint = md_theme_light_surfaceTint, outlineVariant = md_theme_light_outlineVariant, scrim = md_theme_light_scrim, + surfaceContainerHigh = md_theme_light_surface_container_high, ) @@ -75,6 +76,7 @@ val DarkColors = darkColorScheme( surfaceTint = md_theme_dark_surfaceTint, outlineVariant = md_theme_dark_outlineVariant, scrim = md_theme_dark_scrim, + surfaceContainerHigh = md_theme_dark_surface_container_high, ) val ColorScheme.surfaceContainerElevation: Color diff --git a/composeApp/src/iosMain/kotlin/io/middlepoint/morestuff/shared/di/PlatformModule.ios.kt b/composeApp/src/iosMain/kotlin/io/middlepoint/morestuff/shared/di/PlatformModule.ios.kt index 1c3479ba2..a6c90e97f 100644 --- a/composeApp/src/iosMain/kotlin/io/middlepoint/morestuff/shared/di/PlatformModule.ios.kt +++ b/composeApp/src/iosMain/kotlin/io/middlepoint/morestuff/shared/di/PlatformModule.ios.kt @@ -1,11 +1,14 @@ package io.middlepoint.morestuff.shared.di import co.touchlab.kermit.Logger +import com.russhwolf.settings.ExperimentalSettingsImplementation +import com.russhwolf.settings.KeychainSettings import com.russhwolf.settings.NSUserDefaultsSettings import com.russhwolf.settings.Settings import io.middlepoint.morestuff.shared.data.DriverFactory import io.middlepoint.morestuff.shared.data.VoiceToTextParser import io.middlepoint.morestuff.shared.data.VoiceToTextParserImpl +import io.middlepoint.morestuff.shared.data.repository.LlmRepositoryImpl import io.middlepoint.morestuff.shared.domain.service.Notifier import io.middlepoint.morestuff.shared.domain.service.NotifierImpl import io.middlepoint.morestuff.shared.domain.service.Scheduler @@ -14,11 +17,13 @@ import io.middlepoint.morestuff.shared.ui.utils.SharedFunctionsHandler import org.koin.core.module.Module import org.koin.core.module.dsl.factoryOf import org.koin.core.module.dsl.singleOf +import org.koin.core.qualifier.named import org.koin.dsl.bind import org.koin.dsl.module import platform.Foundation.NSUserDefaults +@OptIn(ExperimentalSettingsImplementation::class) actual val platformModule: Module = module { factory { Logger.withTag(it.getOrNull() ?: "MoreStuff-iOS") } factoryOf(::DriverFactory) @@ -27,7 +32,16 @@ actual val platformModule: Module = module { singleOf(::SchedulerImpl) bind Scheduler::class singleOf(::NotifierImpl) bind Notifier::class singleOf(::SharedFunctionsHandler) - single { + + single(named(SharedSettings.Encrypted)) { + KeychainSettings(service = "ENCRYPTED_SETTINGS") + } + + single(named(SharedSettings.Encrypted)) { + KeychainSettings(service = LlmRepositoryImpl.ENCRYPTED_DATABASE_NAME) + } + + single(named(SharedSettings.Unencrypted)) { val delegate: NSUserDefaults = NSUserDefaults.standardUserDefaults NSUserDefaultsSettings(delegate) } diff --git a/composeApp/src/iosMain/kotlin/io/middlepoint/morestuff/shared/domain/service/Scheduler.ios.kt b/composeApp/src/iosMain/kotlin/io/middlepoint/morestuff/shared/domain/service/Scheduler.ios.kt index 6a24c8089..275ccafeb 100644 --- a/composeApp/src/iosMain/kotlin/io/middlepoint/morestuff/shared/domain/service/Scheduler.ios.kt +++ b/composeApp/src/iosMain/kotlin/io/middlepoint/morestuff/shared/domain/service/Scheduler.ios.kt @@ -9,6 +9,9 @@ import platform.UserNotifications.UNCalendarNotificationTrigger import platform.UserNotifications.UNMutableNotificationContent import platform.UserNotifications.UNNotificationRequest import platform.UserNotifications.UNUserNotificationCenter +import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime class SchedulerImpl( private val timeManager: TimeManager, @@ -24,7 +27,8 @@ class SchedulerImpl( override fun scheduleAtExact(scheduleId: Uuid, scheduleTime: String, taskTitle: String, taskId: Uuid) { logger.i { "Scheduling notification with scheduleId=$scheduleId at $scheduleTime" } - val localDateTime = LocalDateTime.parse(scheduleTime) + val localDateTime = Instant.parse(scheduleTime).toLocalDateTime(TimeZone.currentSystemDefault()) + val triggerDate = NSDateComponents().apply { setYear(localDateTime.year.toLong()) setMonth(localDateTime.monthNumber.toLong()) @@ -37,7 +41,7 @@ class SchedulerImpl( val content = UNMutableNotificationContent().apply { setTitle("Reminder") setBody(taskTitle) - setUserInfo(mapOf("scheduleId" to scheduleId.toString(), "taskId" to taskId.toString())) + setUserInfo(mapOf("scheduleId" to scheduleId.value, "taskId" to taskId.value)) } val trigger = UNCalendarNotificationTrigger.triggerWithDateMatchingComponents( @@ -87,58 +91,62 @@ class SchedulerImpl( }*/ } -/* override fun scheduleReviewWorker(hour: Int, minute: Int) { - val currentTime = timeManager.nowLocalDateTime - val scheduleTime = - if (currentTime.hour > hour || (currentTime.hour == hour && currentTime.minute >= minute)) { - timeManager.tomorrowLocalDateTime(hour, minute) - } else { - timeManager.todayLocalDateTime(hour, minute) - } - - val triggerDate = NSDateComponents().apply { - setYear(scheduleTime.year.toLong()) - setMonth(scheduleTime.monthNumber.toLong()) - setDay(scheduleTime.dayOfMonth.toLong()) - setHour(scheduleTime.hour.toLong()) - setMinute(scheduleTime.minute.toLong()) - } - - - val content = UNMutableNotificationContent() - content.setTitle("Review Reminder") - content.setBody("It's time for your scheduled review.") +/* override fun cancelSchedule(scheduleId: Uuid) { + TODO("Not yet implemented") + }*/ + + override fun cancelSchedule(scheduleId: Uuid) { + UNUserNotificationCenter.currentNotificationCenter() + .removePendingNotificationRequestsWithIdentifiers( + listOf(getScheduleWorkTag(scheduleId)) + ) + logger.i { "Cancelled notification with scheduleId= $scheduleId" } + } + + /* override fun scheduleReviewWorker(hour: Int, minute: Int) { + val currentTime = timeManager.nowLocalDateTime + val scheduleTime = + if (currentTime.hour > hour || (currentTime.hour == hour && currentTime.minute >= minute)) { + timeManager.tomorrowLocalDateTime(hour, minute) + } else { + timeManager.todayLocalDateTime(hour, minute) + } + + val triggerDate = NSDateComponents().apply { + setYear(scheduleTime.year.toLong()) + setMonth(scheduleTime.monthNumber.toLong()) + setDay(scheduleTime.dayOfMonth.toLong()) + setHour(scheduleTime.hour.toLong()) + setMinute(scheduleTime.minute.toLong()) + } + + + val content = UNMutableNotificationContent() + content.setTitle("Review Reminder") + content.setBody("It's time for your scheduled review.") + + + val trigger = UNCalendarNotificationTrigger.triggerWithDateMatchingComponents( + dateComponents = triggerDate, + repeats = false + ) + + val request = UNNotificationRequest.requestWithIdentifier( + identifier = PRIORITY_REVIEW_WORK, + content = content, + trigger = trigger + ) + + + UNUserNotificationCenter.currentNotificationCenter() + .addNotificationRequest(request) { error -> + error?.let { + println("Error scheduling review worker: ${it.localizedDescription}") + } + } + }*/ - val trigger = UNCalendarNotificationTrigger.triggerWithDateMatchingComponents( - dateComponents = triggerDate, - repeats = false - ) - - val request = UNNotificationRequest.requestWithIdentifier( - identifier = PRIORITY_REVIEW_WORK, - content = content, - trigger = trigger - ) - - - UNUserNotificationCenter.currentNotificationCenter() - .addNotificationRequest(request) { error -> - error?.let { - println("Error scheduling review worker: ${it.localizedDescription}") - } - } - }*/ - - override fun cancelSchedule(scheduleId: Long) { - UNUserNotificationCenter.currentNotificationCenter() - .removePendingNotificationRequestsWithIdentifiers( - listOf(getScheduleWorkTag(scheduleId)) - ) - logger.i { "Cancelled notification with scheduleId= $scheduleId" } - - } - override fun cancelPlannedPriorityUpdate() { UNUserNotificationCenter.currentNotificationCenter() .removePendingNotificationRequestsWithIdentifiers( @@ -146,7 +154,7 @@ class SchedulerImpl( ) } - private fun getScheduleWorkTag(scheduleId: Long) = "SCHEDULE_$scheduleId" + private fun getScheduleWorkTag(scheduleId: Uuid) = "SCHEDULE_$scheduleId" companion object { private const val PLANNED_PRIORITY_WORK = "SmartReminder" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 073ea84c4..53a2a5047 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -59,6 +59,7 @@ kottie = "1.9.6-alpha02" calf = "0.7.1" filekit = "0.10.0-beta01" supabase-bom = "3.1.3" +openAi = "4.0.1" [libraries] @@ -148,6 +149,7 @@ filekit-dialogs = { module = "io.github.vinceglb:filekit-dialogs", version.ref = filekit-dialogs-compose = { module = "io.github.vinceglb:filekit-dialogs-compose", version.ref = "filekit" } filekit-core = { module = "io.github.vinceglb:filekit-core", version.ref = "filekit" } filekit-coil = { module = "io.github.vinceglb:filekit-coil", version.ref = "filekit" } +open-ai = { module = "com.aallam.openai:openai-client", version.ref = "openAi" } # Testing mockk-common = { module = "io.mockk:mockk-common", version.ref = "mockk-common" } diff --git a/iosApp/MoreStuffShare/ShareViewController.swift b/iosApp/MoreStuffShare/ShareViewController.swift index 26a226928..a06b7fec7 100644 --- a/iosApp/MoreStuffShare/ShareViewController.swift +++ b/iosApp/MoreStuffShare/ShareViewController.swift @@ -103,8 +103,24 @@ class ShareViewController: UIViewController { if let url = item as? URL { print("Received URL: \(url)") let urlString = url.absoluteString - self.sharedFilePath = "\(self.docPath)/shared_url.txt" - try? urlString.write(toFile: self.sharedFilePath, atomically: true, encoding: .utf8) + + // Check if the URL points to an image + if urlString.hasSuffix(".jpg") || urlString.hasSuffix(".jpeg") || urlString.hasSuffix(".png") { + // Handle as an image + self.sharedFilePath = "\(self.docPath)/\(url.lastPathComponent)" + do { + // Download the image data and save it + let imageData = try Data(contentsOf: url) + try imageData.write(to: URL(fileURLWithPath: self.sharedFilePath)) + print("Image saved at: \(self.sharedFilePath)") + } catch { + print("Error saving image: \(error.localizedDescription)") + } + } else { + // Handle as a URL + self.sharedFilePath = "\(self.docPath)/shared_url.txt" + try? urlString.write(toFile: self.sharedFilePath, atomically: true, encoding: .utf8) + } } else if let error = error { print("Error loading URL: \(error.localizedDescription)") } diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index d65fdeb76..6093af0cc 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -108,10 +108,10 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele withCompletionHandler completionHandler: @escaping () -> Void) { let userInfo = response.notification.request.content.userInfo - if let taskIdString = userInfo["taskId"] as? String, let taskId = Int(taskIdString) { - navigateToTaskChat(taskId: taskId) - //cancelSchedule(taskId: taskId) + if let taskIdString = userInfo["taskId"] as? String { + navigateToTaskChat(taskId: taskIdString) } + if let scheduleId = userInfo["scheduleId"] as? String { center.removePendingNotificationRequests(withIdentifiers: ["SCHEDULE_\(scheduleId)"]) } @@ -127,22 +127,23 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele if let scheduleId = userInfo["scheduleId"] as? String { center.removePendingNotificationRequests(withIdentifiers: ["SCHEDULE_\(scheduleId)"]) } - if let taskIdString = userInfo["taskId"] as? String, let taskId = Int(taskIdString) { - cancelSchedule(taskId: taskId) + if let taskIdString = userInfo["taskId"] as? String { + cancelSchedule(taskId: taskIdString) } completionHandler([.banner, .sound]) } - private func navigateToTaskChat(taskId: Int) { - DispatchQueue.main.async { - self.navigationHelper.navigateToTaskChat(taskId: Int64(taskId)) - } + private func navigateToTaskChat(taskId: String) { + DispatchQueue.main.async { + self.navigationHelper.navigateToTaskChat(taskId: taskId) } + } + - private func cancelSchedule(taskId: Int){ + private func cancelSchedule(taskId: String){ DispatchQueue.main.async { - self.navigationHelper.cancelTaskSchedule(taskId: Int64(taskId)) + self.navigationHelper.cancelTaskSchedule(taskId: taskId) } } diff --git a/shared/src/androidMain/kotlin/io/middlepoint/morestuff/shared/AndroidAppSettingsHandlerImpl.kt b/shared/src/androidMain/kotlin/io/middlepoint/morestuff/shared/AndroidAppSettingsHandlerImpl.kt new file mode 100644 index 000000000..0c21f755f --- /dev/null +++ b/shared/src/androidMain/kotlin/io/middlepoint/morestuff/shared/AndroidAppSettingsHandlerImpl.kt @@ -0,0 +1,29 @@ + +package io.middlepoint.morestuff.shared + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import co.touchlab.kermit.Logger + + +class AndroidAppSettingsHandlerImpl( + private val context: Context, + private val logger: Logger +) : AppSettingsHandler { + + override fun openAppSettings() { + try { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + val uri = Uri.fromParts("package", context.packageName, null) + data = uri + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + logger.d("App settings opened successfully") + } catch (e: Exception) { + logger.e("Error opening app settings", e) + } + } +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/io/middlepoint/morestuff/shared/SharedModule.android.kt b/shared/src/androidMain/kotlin/io/middlepoint/morestuff/shared/SharedModule.android.kt index 16e44c83a..e88f4669f 100644 --- a/shared/src/androidMain/kotlin/io/middlepoint/morestuff/shared/SharedModule.android.kt +++ b/shared/src/androidMain/kotlin/io/middlepoint/morestuff/shared/SharedModule.android.kt @@ -14,4 +14,6 @@ actual val sharedModule: Module factoryOf(::ShareHelperImpl) bind ShareHelper::class factoryOf(::DataMigrationHelperImpl) bind DataMigrationHelper::class factoryOf(::MediaHandlerImpl) bind MediaHandler::class + factoryOf(::AndroidAppSettingsHandlerImpl) bind AppSettingsHandler::class + } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/io/middlepoint/morestuff/shared/AppSettingsHandler.kt b/shared/src/commonMain/kotlin/io/middlepoint/morestuff/shared/AppSettingsHandler.kt new file mode 100644 index 000000000..d8df933f5 --- /dev/null +++ b/shared/src/commonMain/kotlin/io/middlepoint/morestuff/shared/AppSettingsHandler.kt @@ -0,0 +1,5 @@ +package io.middlepoint.morestuff.shared + +interface AppSettingsHandler { + fun openAppSettings() +} \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/io/middlepoint/morestuff/shared/AppSettingsHandlerImpl.kt b/shared/src/iosMain/kotlin/io/middlepoint/morestuff/shared/AppSettingsHandlerImpl.kt new file mode 100644 index 000000000..f6d239ee0 --- /dev/null +++ b/shared/src/iosMain/kotlin/io/middlepoint/morestuff/shared/AppSettingsHandlerImpl.kt @@ -0,0 +1,7 @@ +package io.middlepoint.morestuff.shared + +class IosAppSettingsHandlerImpl( +) : AppSettingsHandler { + + override fun openAppSettings() {} +} \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/io/middlepoint/morestuff/shared/SharedModule.ios.kt b/shared/src/iosMain/kotlin/io/middlepoint/morestuff/shared/SharedModule.ios.kt index 5e51fa7eb..f7714e43f 100644 --- a/shared/src/iosMain/kotlin/io/middlepoint/morestuff/shared/SharedModule.ios.kt +++ b/shared/src/iosMain/kotlin/io/middlepoint/morestuff/shared/SharedModule.ios.kt @@ -14,4 +14,5 @@ actual val sharedModule: Module factoryOf(::ShareHelperImpl) bind ShareHelper::class factoryOf(::DataMigrationHelperImpl) bind DataMigrationHelper::class factoryOf(::MediaHandlerImpl) bind MediaHandler::class + factoryOf(::IosAppSettingsHandlerImpl) bind AppSettingsHandler::class } \ No newline at end of file