From 804bdd0a6fcef61671c8cea4c526d9f98a953319 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Mon, 8 Jun 2026 00:56:09 +0900 Subject: [PATCH] fix ios agent ui --- .../flare/ui/screen/profile/ProfileScreen.kt | 7 +-- .../flare/ui/screen/home/ProfileScreen.kt | 15 ++--- .../ui/screen/settings/SettingsScreen.kt | 5 +- gradle.properties | 2 +- iosApp/flare/UI/Component/AgentChatView.swift | 6 +- iosApp/flare/UI/FlareRoot.swift | 14 ++++- iosApp/flare/UI/Screen/DiscoverScreen.swift | 11 ++-- iosApp/flare/UI/Screen/ProfileScreen.swift | 2 +- iosApp/flare/UI/Screen/SearchScreen.swift | 54 +++++++++++++++-- .../flare/UI/Screen/SecondaryTabsScreen.swift | 13 +++-- .../settings/AiAgentEnabledPresenter.kt | 5 +- .../settings/AiAgentEnabledPresenterTest.kt | 58 +++++++++++++++++++ 12 files changed, 154 insertions(+), 38 deletions(-) create mode 100644 shared/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/settings/AiAgentEnabledPresenterTest.kt diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/profile/ProfileScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/profile/ProfileScreen.kt index dc904e87d..b047d635e 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/profile/ProfileScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/profile/ProfileScreen.kt @@ -81,6 +81,7 @@ import dev.dimension.flare.ui.component.status.status import dev.dimension.flare.ui.model.UiMedia import dev.dimension.flare.ui.model.UiStrings import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.asText import dev.dimension.flare.ui.model.map import dev.dimension.flare.ui.model.onError import dev.dimension.flare.ui.model.onLoading @@ -414,8 +415,8 @@ internal fun ProfileScreen( } }, ) { - Text( - profileTab.title, + dev.dimension.flare.ui.component.Text( + text = profileTab.name.asText(), modifier = Modifier .padding(8.dp), @@ -663,8 +664,6 @@ private fun profilePresenter( private sealed interface ProfileTabItem { val name: UiStrings - val title: String - get() = name.name data class Timeline( override val name: UiStrings, diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/ProfileScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/ProfileScreen.kt index 78be81809..21e8b70d8 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/ProfileScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/ProfileScreen.kt @@ -58,6 +58,7 @@ import dev.dimension.flare.ui.component.status.status import dev.dimension.flare.ui.model.UiMedia import dev.dimension.flare.ui.model.UiStrings import dev.dimension.flare.ui.model.UiTimelineV2 +import dev.dimension.flare.ui.model.asText import dev.dimension.flare.ui.model.map import dev.dimension.flare.ui.model.onError import dev.dimension.flare.ui.model.onLoading @@ -294,8 +295,8 @@ internal fun ProfileScreen( state.setSelectedTab(index) }, ) { - Text( - tab.title, + dev.dimension.flare.ui.component.Text( + text = tab.name.asText(), ) } } @@ -319,8 +320,8 @@ internal fun ProfileScreen( state.setSelectedTab(index) }, ) { - Text( - tab.title, + dev.dimension.flare.ui.component.Text( + text = tab.name.asText(), ) } } @@ -407,10 +408,6 @@ internal fun ProfileScreen( } } -private val ProfileState.Tab.title: String - get() = - name.name - @Composable private fun presenter( accountType: AccountType, @@ -483,8 +480,6 @@ private fun presenter( private sealed interface ProfileTabItem { val name: UiStrings - val title: String - get() = name.name data class Timeline( override val name: UiStrings, diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt index faae6dd4a..a9f5b68d5 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt @@ -244,6 +244,7 @@ import dev.dimension.flare.ui.presenter.ImportDataPresenter import dev.dimension.flare.ui.presenter.home.ActiveAccountPresenter import dev.dimension.flare.ui.presenter.home.UserState import dev.dimension.flare.ui.presenter.invoke +import dev.dimension.flare.ui.presenter.settings.AiAgentEnabledPresenter import dev.dimension.flare.ui.presenter.settings.AiConfigPresenter import dev.dimension.flare.ui.presenter.settings.AiReasoningEffortOption import dev.dimension.flare.ui.presenter.settings.AiTranslationTestPresenter @@ -1150,7 +1151,7 @@ internal fun SettingsScreen( ) } AnimatedVisibility( - state.aiConfigState.aiAgent, + state.aiAgentEnabledState.enabled, ) { CardExpanderItem( onClick = toAgentHistory, @@ -2253,12 +2254,14 @@ private fun presenter( val appearanceState = appearancePresenter() val storageState = storagePresenter(onExportFilePicker, onImportFilePicker) val aiConfigState = aiConfigPresenter() + val aiAgentEnabledState = remember { AiAgentEnabledPresenter() }.invoke() var aboutExpanded by remember { mutableStateOf(false) } object { val accountState = accountState val appearanceState = appearanceState val aiConfigState = aiConfigState + val aiAgentEnabledState = aiAgentEnabledState val storageState = storageState val aboutExpanded = aboutExpanded diff --git a/gradle.properties b/gradle.properties index 9428060e1..64570f661 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -org.gradle.jvmargs=-Xmx8g -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx16g -Dfile.encoding=UTF-8 android.useAndroidX=true kotlin.code.style=official android.nonTransitiveRClass=true diff --git a/iosApp/flare/UI/Component/AgentChatView.swift b/iosApp/flare/UI/Component/AgentChatView.swift index 5ac85a40b..65faa42c4 100644 --- a/iosApp/flare/UI/Component/AgentChatView.swift +++ b/iosApp/flare/UI/Component/AgentChatView.swift @@ -303,18 +303,20 @@ private final class AgentChatMessagesController: UIViewController, UICollectionV return } + let wasNearBottom = isNearBottom let convertedEndFrame = view.convert(endFrame, from: nil) keyboardBottomInset = max(0, view.bounds.maxY - convertedEndFrame.minY) updateCollectionInsets() - if isNearBottom { + if wasNearBottom { scrollToBottom(animated: false) } } @objc private func keyboardWillHide(_ notification: Notification) { + let wasNearBottom = isNearBottom keyboardBottomInset = 0 updateCollectionInsets() - if isNearBottom { + if wasNearBottom { scrollToBottom(animated: false) } } diff --git a/iosApp/flare/UI/FlareRoot.swift b/iosApp/flare/UI/FlareRoot.swift index a57eebd4c..f33efe311 100644 --- a/iosApp/flare/UI/FlareRoot.swift +++ b/iosApp/flare/UI/FlareRoot.swift @@ -9,6 +9,7 @@ struct FlareRoot: View { @StateObject private var homeTabsPresenter = KotlinPresenter(presenter: HomeTabsPresenter()) @StateObject private var notificationBadgePresenter = KotlinPresenter(presenter: AllNotificationBadgePresenter()) @StateObject private var secondaryTabsPresenter = KotlinPresenter(presenter: SecondaryTabsPresenter()) + @StateObject private var aiAgentEnabledPresenter = KotlinPresenter(presenter: AiAgentEnabledPresenter()) @State var selectedTab: String? var body: some View { @@ -52,7 +53,9 @@ struct FlareRoot: View { .tabPlacement(.sidebarOnly) } } - ForEach(SecondarySidebarStaticRoute.allCases, id: \.self) { route in + ForEach(SecondarySidebarStaticRoute.allCases.filter { route in + route != .agentHistory || aiAgentEnabledPresenter.state.enabled + }, id: \.self) { route in secondarySidebarStaticRoute(route) } } @@ -192,6 +195,7 @@ private enum SecondarySidebarStaticRoute: CaseIterable { case drafts case rssManagement case localHistory + case agentHistory case settings var selectionValue: String { @@ -202,6 +206,8 @@ private enum SecondarySidebarStaticRoute: CaseIterable { return "route:rssManagement" case .localHistory: return "route:localHistory" + case .agentHistory: + return "route:agentHistory" case .settings: return "route:settings" } @@ -215,6 +221,8 @@ private enum SecondarySidebarStaticRoute: CaseIterable { return .rssManagement case .localHistory: return .localHostory + case .agentHistory: + return .agentHistory case .settings: return .settings } @@ -228,6 +236,8 @@ private enum SecondarySidebarStaticRoute: CaseIterable { return "settings_rss_management_title" case .localHistory: return "local_history_title" + case .agentHistory: + return "settings_agent_history_title" case .settings: return "settings_title" } @@ -241,6 +251,8 @@ private enum SecondarySidebarStaticRoute: CaseIterable { return "fa-square-rss" case .localHistory: return "fa-clock-rotate-left" + case .agentHistory: + return "fa-robot" case .settings: return "fa-gear" } diff --git a/iosApp/flare/UI/Screen/DiscoverScreen.swift b/iosApp/flare/UI/Screen/DiscoverScreen.swift index a8350c23b..02b93ba69 100644 --- a/iosApp/flare/UI/Screen/DiscoverScreen.swift +++ b/iosApp/flare/UI/Screen/DiscoverScreen.swift @@ -84,12 +84,11 @@ struct DiscoverScreen: View { } } .searchable(text: $searchText, isPresented: $isSearchPresented) - .safeAreaInset(edge: .bottom) { - if agentEnabled && isSearchPresented { - AskAiSearchAccessory { - askAi() - } - } + .askAiSearchOverlay( + agentEnabled: agentEnabled, + isSearchPresented: isSearchPresented + ) { + askAi() } .searchSuggestions { SearchHistorySuggestions( diff --git a/iosApp/flare/UI/Screen/ProfileScreen.swift b/iosApp/flare/UI/Screen/ProfileScreen.swift index 5a675ac9d..8ad95752d 100644 --- a/iosApp/flare/UI/Screen/ProfileScreen.swift +++ b/iosApp/flare/UI/Screen/ProfileScreen.swift @@ -190,7 +190,7 @@ private struct ProfileTabPicker: View { } private func profileTabTitle(for tab: ProfileState.Tab) -> String { - tab.name.name + tab.name.text } private func profileTimelineID(for tab: ProfileState.Tab) -> String { diff --git a/iosApp/flare/UI/Screen/SearchScreen.swift b/iosApp/flare/UI/Screen/SearchScreen.swift index 1c020f646..9c646fca2 100644 --- a/iosApp/flare/UI/Screen/SearchScreen.swift +++ b/iosApp/flare/UI/Screen/SearchScreen.swift @@ -123,12 +123,11 @@ struct SearchScreen: View { } } .searchable(text: $searchText, isPresented: $isSearchPresented) - .safeAreaInset(edge: .bottom) { - if agentEnabled && isSearchPresented { - AskAiSearchAccessory { - askAi() - } - } + .askAiSearchOverlay( + agentEnabled: agentEnabled, + isSearchPresented: isSearchPresented + ) { + askAi() } .searchSuggestions { SearchHistorySuggestions( @@ -180,6 +179,49 @@ struct SearchScreen: View { } } +private enum SearchAccessoryMetrics { + static let activeSearchFieldHeight: CGFloat = 72 +} + +struct AskAiSearchOverlayModifier: ViewModifier { + let agentEnabled: Bool + let isSearchPresented: Bool + let action: () -> Void + + private var isVisible: Bool { + agentEnabled && isSearchPresented + } + + func body(content: Content) -> some View { + content + .overlay(alignment: .bottom) { + if isVisible { + AskAiSearchAccessory(action: action) + .padding(.bottom, SearchAccessoryMetrics.activeSearchFieldHeight) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .zIndex(1) + } + } + .animation(.easeInOut(duration: 0.2), value: isVisible) + } +} + +extension View { + func askAiSearchOverlay( + agentEnabled: Bool, + isSearchPresented: Bool, + action: @escaping () -> Void + ) -> some View { + modifier( + AskAiSearchOverlayModifier( + agentEnabled: agentEnabled, + isSearchPresented: isSearchPresented, + action: action + ) + ) + } +} + struct AskAiSearchAccessory: View { let action: () -> Void diff --git a/iosApp/flare/UI/Screen/SecondaryTabsScreen.swift b/iosApp/flare/UI/Screen/SecondaryTabsScreen.swift index e3eecbd41..00feda29e 100644 --- a/iosApp/flare/UI/Screen/SecondaryTabsScreen.swift +++ b/iosApp/flare/UI/Screen/SecondaryTabsScreen.swift @@ -5,6 +5,7 @@ struct SecondaryTabsScreen: View { @Environment(\.dismiss) private var dismiss let onTabSelected: (Route) -> Void @StateObject private var presenter = KotlinPresenter(presenter: SecondaryTabsPresenter()) + @StateObject private var aiAgentEnabledPresenter = KotlinPresenter(presenter: AiAgentEnabledPresenter()) var body: some View { Router { _ in List { @@ -62,11 +63,13 @@ struct SecondaryTabsScreen: View { Image("fa-clock-rotate-left") } } - NavigationLink(value: Route.agentHistory) { - Label { - Text("settings_agent_history_title") - } icon: { - Image("fa-robot") + if aiAgentEnabledPresenter.state.enabled { + NavigationLink(value: Route.agentHistory) { + Label { + Text("settings_agent_history_title") + } icon: { + Image("fa-robot") + } } } NavigationLink(value: Route.settings) { diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/AiAgentEnabledPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/AiAgentEnabledPresenter.kt index 8f37ad586..427f7ef18 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/AiAgentEnabledPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/AiAgentEnabledPresenter.kt @@ -22,7 +22,7 @@ public class AiAgentEnabledPresenter : override fun body(): State { val appSettings by appDataStore.appSettingsStore.data.collectAsState(AppSettings(version = "")) return StateImpl( - enabled = appSettings.aiConfig.agent, + enabled = appSettings.aiConfig.isAiAgentEnabled(), ) } @@ -30,3 +30,6 @@ public class AiAgentEnabledPresenter : override val enabled: Boolean, ) : State } + +internal fun AppSettings.AiConfig.isAiAgentEnabled(): Boolean = + agent && (type as? AppSettings.AiConfig.Type.OpenAI)?.model?.isNotBlank() == true diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/settings/AiAgentEnabledPresenterTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/settings/AiAgentEnabledPresenterTest.kt new file mode 100644 index 000000000..7703319d9 --- /dev/null +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/settings/AiAgentEnabledPresenterTest.kt @@ -0,0 +1,58 @@ +package dev.dimension.flare.ui.presenter.settings + +import dev.dimension.flare.data.datastore.model.AppSettings +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AiAgentEnabledPresenterTest { + @Test + fun isAiAgentEnabled_requiresAgentAndOpenAIModel() { + assertTrue( + AppSettings + .AiConfig( + agent = true, + type = + AppSettings.AiConfig.Type.OpenAI( + serverUrl = "", + apiKey = "", + model = "gpt-4.1", + ), + ).isAiAgentEnabled(), + ) + + assertFalse( + AppSettings + .AiConfig( + agent = false, + type = + AppSettings.AiConfig.Type.OpenAI( + serverUrl = "", + apiKey = "", + model = "gpt-4.1", + ), + ).isAiAgentEnabled(), + ) + + assertFalse( + AppSettings + .AiConfig( + agent = true, + type = + AppSettings.AiConfig.Type.OpenAI( + serverUrl = "", + apiKey = "", + model = " ", + ), + ).isAiAgentEnabled(), + ) + + assertFalse( + AppSettings + .AiConfig( + agent = true, + type = AppSettings.AiConfig.Type.OnDevice, + ).isAiAgentEnabled(), + ) + } +}