From 20ab8f7fe8dde2c6d1a426fb7449c1309d188221 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Thu, 16 Apr 2026 21:50:09 +0900 Subject: [PATCH] add share menu in ios --- .../flare/ui/screen/compose/ComposeScreen.kt | 10 +- .../flare/ui/controllers/ComposeUIHelper.kt | 99 +- gradle.properties | 2 +- iosApp/Flare.xcodeproj/project.pbxproj | 457 ++++++- .../FlareShareExtension.entitlements | 10 + iosApp/FlareShareExtension/Info.plist | 27 + .../ShareViewController.swift | 59 + .../Screen => FlareUI}/AltTextEditSheet.swift | 0 iosApp/FlareUI/ComposeContent.swift | 1057 +++++++++++++++++ .../UI/Component => FlareUI}/EmojiPopup.swift | 32 +- .../{flare/Common => FlareUI}/Formatter.swift | 6 +- .../FoundationModelOnDeviceAI.swift | 10 +- .../KotlinPresenter.swift | 8 +- .../PlatformTextRenderer.swift | 34 +- .../StatusVisibilityView.swift | 11 +- iosApp/flare/Flare.entitlements | 10 + iosApp/flare/FlareApp.swift | 6 +- iosApp/flare/UI/Component/AvatarView.swift | 6 +- iosApp/flare/UI/Component/RichText.swift | 1 + .../Component/Status/StatusShareSheet.swift | 1 + .../UI/Component/Status/StatusView.swift | 1 + .../UI/Component/Status/TranslateView.swift | 1 + iosApp/flare/UI/Component/TabIcon.swift | 1 + iosApp/flare/UI/FlareRoot.swift | 1 + iosApp/flare/UI/FlareTheme.swift | 1 + iosApp/flare/UI/Route/Router.swift | 1 + .../UI/Screen/AccountManagementScreen.swift | 1 + iosApp/flare/UI/Screen/AiConfigScreen.swift | 1 + iosApp/flare/UI/Screen/AllFeedScreen.swift | 1 + iosApp/flare/UI/Screen/AllListScreen.swift | 1 + .../flare/UI/Screen/AntennasListScreen.swift | 1 + iosApp/flare/UI/Screen/AppLogScreen.swift | 1 + iosApp/flare/UI/Screen/AppearanceScreen.swift | 1 + .../flare/UI/Screen/BlueskyReportSheet.swift | 1 + .../flare/UI/Screen/ChannelListScreen.swift | 1 + iosApp/flare/UI/Screen/ComposeScreen.swift | 901 +------------- iosApp/flare/UI/Screen/CreateListScreen.swift | 1 + iosApp/flare/UI/Screen/DMListScreen.swift | 1 + .../UI/Screen/DeepLinkAccountPicker.swift | 1 + iosApp/flare/UI/Screen/DiscoverScreen.swift | 1 + iosApp/flare/UI/Screen/DraftBoxScreen.swift | 1 + .../UI/Screen/EditListMemberScreen.swift | 1 + iosApp/flare/UI/Screen/EditListScreen.swift | 1 + .../UI/Screen/EditUserInListScreen.swift | 1 + .../flare/UI/Screen/GroupConfigScreen.swift | 1 + .../flare/UI/Screen/HomeTimelineScreen.swift | 1 + iosApp/flare/UI/Screen/ImportOPMLScreen.swift | 1 + .../flare/UI/Screen/LocalFilterScreen.swift | 1 + .../flare/UI/Screen/LocalHistoryScreen.swift | 1 + .../flare/UI/Screen/MisskeyReportSheet.swift | 1 + .../flare/UI/Screen/NostrRelaysScreen.swift | 1 + .../flare/UI/Screen/NotificationScreen.swift | 1 + iosApp/flare/UI/Screen/ProfileScreen.swift | 1 + iosApp/flare/UI/Screen/RssDetailScreen.swift | 1 + iosApp/flare/UI/Screen/RssScreen.swift | 1 + iosApp/flare/UI/Screen/SearchScreen.swift | 1 + .../flare/UI/Screen/SecondaryTabsScreen.swift | 1 + .../UI/Screen/ServiceSelectionScreen.swift | 1 + .../UI/Screen/StatusAddReactionSheet.swift | 1 + .../flare/UI/Screen/StatusDetailScreen.swift | 1 + .../flare/UI/Screen/StatusMediaScreen.swift | 1 + iosApp/flare/UI/Screen/StorageScreen.swift | 1 + .../flare/UI/Screen/TabSettingsScreen.swift | 1 + iosApp/flare/UI/Screen/TimelineScreen.swift | 1 + .../UI/Screen/TwitterArticleScreen.swift | 1 + iosApp/flare/UI/Screen/UserListScreen.swift | 1 + iosApp/flare/UI/Screen/VVOStatusScreen.swift | 1 + .../data/database/DriverFactory.apple.kt | 196 ++- .../data/io/ApplePlatformPathProducer.kt | 35 +- .../dimension/flare/common/GlobalConfig.kt | 5 + .../flare/data/database/DriverFactory.kt | 5 - .../flare/data/database/ProvideDatabase.kt | 4 +- .../dimension/flare/data/network/Ktorfit.kt | 19 +- .../dev/dimension/flare/di/CommonModule.kt | 26 + 74 files changed, 2075 insertions(+), 1008 deletions(-) create mode 100644 iosApp/FlareShareExtension/FlareShareExtension.entitlements create mode 100644 iosApp/FlareShareExtension/Info.plist create mode 100644 iosApp/FlareShareExtension/ShareViewController.swift rename iosApp/{flare/UI/Screen => FlareUI}/AltTextEditSheet.swift (100%) create mode 100644 iosApp/FlareUI/ComposeContent.swift rename iosApp/{flare/UI/Component => FlareUI}/EmojiPopup.swift (80%) rename iosApp/{flare/Common => FlareUI}/Formatter.swift (64%) rename iosApp/{flare/Common => FlareUI}/FoundationModelOnDeviceAI.swift (68%) rename iosApp/{flare/UI/Component => FlareUI}/KotlinPresenter.swift (83%) rename iosApp/{flare/Common => FlareUI}/PlatformTextRenderer.swift (86%) rename iosApp/{flare/UI/Component/Status => FlareUI}/StatusVisibilityView.swift (58%) create mode 100644 iosApp/flare/Flare.entitlements create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/common/GlobalConfig.kt diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/compose/ComposeScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/compose/ComposeScreen.kt index 880305d6f0..9d08c4b62f 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/compose/ComposeScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/compose/ComposeScreen.kt @@ -132,21 +132,13 @@ fun ShortcutComposeRoute( initialText: String = "", initialMedias: ImmutableList = persistentListOf(), ) { - val activeAccountState by producePresenter(key = "shortcut_compose_active_account") { - activeAccountPresenter() - } - val accountType = - activeAccountState.user - .takeSuccess() - ?.let { AccountType.Specific(it.key) } - ?: AccountType.Guest FlareTheme { CompositionLocalProvider( LocalContentColor provides MaterialTheme.colorScheme.onBackground, ) { ComposeScreen( onBack = onBack, - accountType = accountType, + accountType = null, initialText = initialText, initialMedias = initialMedias, ) diff --git a/compose-ui/src/iosMain/kotlin/dev/dimension/flare/ui/controllers/ComposeUIHelper.kt b/compose-ui/src/iosMain/kotlin/dev/dimension/flare/ui/controllers/ComposeUIHelper.kt index dab9569228..4a7a6c57da 100644 --- a/compose-ui/src/iosMain/kotlin/dev/dimension/flare/ui/controllers/ComposeUIHelper.kt +++ b/compose-ui/src/iosMain/kotlin/dev/dimension/flare/ui/controllers/ComposeUIHelper.kt @@ -5,13 +5,18 @@ import coil3.SingletonImageLoader import coil3.annotation.ExperimentalCoilApi import coil3.network.ktor3.KtorNetworkFetcherFactory import coil3.request.crossfade +import dev.dimension.flare.common.GlobalConfig import dev.dimension.flare.common.InAppNotification import dev.dimension.flare.common.Message import dev.dimension.flare.common.SwiftOnDeviceAI +import dev.dimension.flare.data.database.migrateDatabase import dev.dimension.flare.data.network.ktorClient import dev.dimension.flare.di.KoinHelper import dev.dimension.flare.ui.humanizer.SwiftFormatter +import dev.dimension.flare.ui.render.PlatformText +import dev.dimension.flare.ui.render.RenderContent import dev.dimension.flare.ui.render.SwiftPlatformTextRenderer +import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -19,6 +24,7 @@ import kotlinx.coroutines.withContext import org.koin.core.context.startKoin import org.koin.dsl.bind import org.koin.dsl.module +import platform.Foundation.NSArray public object ComposeUIHelper { @OptIn(ExperimentalCoilApi::class) @@ -27,7 +33,13 @@ public object ComposeUIHelper { swiftFormatter: SwiftFormatter, swiftPlatformTextRenderer: SwiftPlatformTextRenderer, swiftOnDeviceAI: SwiftOnDeviceAI, + isMainApp: Boolean, ) { + if (isMainApp) { + migrateDatabase() + } else { + GlobalConfig.disableLogging = true + } startKoin { modules(KoinHelper.modules()) modules( @@ -47,20 +59,79 @@ public object ComposeUIHelper { }, ) } - SingletonImageLoader.setSafe { context -> - ImageLoader - .Builder(context) - .components { - add( - KtorNetworkFetcherFactory( - httpClient = - ktorClient { - useDefaultTransformers = false - }, - ), - ) - }.crossfade(true) - .build() + if (isMainApp) { + SingletonImageLoader.setSafe { context -> + ImageLoader + .Builder(context) + .components { + add( + KtorNetworkFetcherFactory( + httpClient = + ktorClient { + useDefaultTransformers = false + }, + ), + ) + }.crossfade(true) + .build() + } + } + } + + public fun initializeLite() { + GlobalConfig.disableLogging = true + startKoin { + modules(KoinHelper.modules()) + modules( + module { + single { + object : InAppNotification { + override fun onProgress( + message: Message, + progress: Int, + total: Int, + ) { + } + + override fun onSuccess(message: Message) { + } + + override fun onError( + message: Message, + throwable: Throwable, + ) { + } + } + } bind InAppNotification::class + single { + object : SwiftFormatter { + override fun formatNumber(number: Long): String = number.toString() + } + } bind SwiftFormatter::class + single { + object : SwiftOnDeviceAI { + override suspend fun isAvailable(): Boolean = false + + override suspend fun translate( + source: String, + targetLanguage: String, + prompt: String, + ): String? = null + + override suspend fun tldr( + source: String, + targetLanguage: String, + prompt: String, + ): String? = null + } + } bind SwiftOnDeviceAI::class + single { + object : SwiftPlatformTextRenderer { + override fun render(renderRuns: ImmutableList): PlatformText = NSArray() + } + } bind SwiftPlatformTextRenderer::class + }, + ) } } } diff --git a/gradle.properties b/gradle.properties index bc800839a7..826bce2791 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -org.gradle.jvmargs=-Xmx8g -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx64g -Dfile.encoding=UTF-8 android.useAndroidX=true kotlin.code.style=official android.nonTransitiveRClass=true diff --git a/iosApp/Flare.xcodeproj/project.pbxproj b/iosApp/Flare.xcodeproj/project.pbxproj index cd40fb2522..9e7f8f651a 100644 --- a/iosApp/Flare.xcodeproj/project.pbxproj +++ b/iosApp/Flare.xcodeproj/project.pbxproj @@ -15,10 +15,80 @@ 06C6B5482E853AAF00CCD388 /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = 06C6B5472E853AAF00CCD388 /* SwiftUIIntrospect */; }; 06C7FC172E7D474900A0D01A /* LazyPager in Frameworks */ = {isa = PBXBuildFile; productRef = 06C7FC162E7D474900A0D01A /* LazyPager */; }; 06F36AAD2E8F7BEC00F5905F /* SwiftUIBackports in Frameworks */ = {isa = PBXBuildFile; productRef = 06F36AAC2E8F7BEC00F5905F /* SwiftUIBackports */; }; + 06FD30712F8F9D3C00275D3D /* FlareShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 06FD30672F8F9D3C00275D3D /* FlareShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 06FD30822F8F9D9B00275D3D /* FlareUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 06FD307C2F8F9D9B00275D3D /* FlareUI.framework */; }; + 06FD30832F8F9D9B00275D3D /* FlareUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 06FD307C2F8F9D9B00275D3D /* FlareUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 06FD30912F8F9DD700275D3D /* FlareUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 06FD307C2F8F9D9B00275D3D /* FlareUI.framework */; }; + 06FD30922F8F9DD700275D3D /* FlareUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 06FD307C2F8F9D9B00275D3D /* FlareUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 06FD30A02F8FA48800275D3D /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = 06FD309F2F8FA48800275D3D /* SwiftUIIntrospect */; }; + 06FD30A22F8FA49300275D3D /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 06FD30A12F8FA49300275D3D /* Kingfisher */; }; + 06FD30A42F8FA49800275D3D /* SwiftUIBackports in Frameworks */ = {isa = PBXBuildFile; productRef = 06FD30A32F8FA49800275D3D /* SwiftUIBackports */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 06FD306F2F8F9D3C00275D3D /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 06E433F62E6A9A2600CD0826 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 06FD30662F8F9D3C00275D3D; + remoteInfo = FlareShareExtension; + }; + 06FD30802F8F9D9B00275D3D /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 06E433F62E6A9A2600CD0826 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 06FD307B2F8F9D9B00275D3D; + remoteInfo = FlareUI; + }; + 06FD30932F8F9DD700275D3D /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 06E433F62E6A9A2600CD0826 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 06FD307B2F8F9D9B00275D3D; + remoteInfo = FlareUI; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 06FD30722F8F9D3C00275D3D /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 06FD30712F8F9D3C00275D3D /* FlareShareExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; + 06FD30872F8F9D9B00275D3D /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 06FD30832F8F9D9B00275D3D /* FlareUI.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + 06FD30952F8F9DD700275D3D /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 06FD30922F8F9DD700275D3D /* FlareUI.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 06E433FE2E6A9A2600CD0826 /* Flare.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Flare.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 06FD30672F8F9D3C00275D3D /* FlareShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FlareShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 06FD307C2F8F9D9B00275D3D /* FlareUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = FlareUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -29,6 +99,29 @@ ); target = 06E433FD2E6A9A2600CD0826 /* Flare */; }; + 06E7D3A62F90BA5E006E5834 /* Exceptions for "Flare" folder in "FlareShareExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Assets.xcassets, + Localizable.xcstrings, + ); + target = 06FD30662F8F9D3C00275D3D /* FlareShareExtension */; + }; + 06E7D3A72F90BA5E006E5834 /* Exceptions for "Flare" folder in "FlareUI" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Assets.xcassets, + Localizable.xcstrings, + ); + target = 06FD307B2F8F9D9B00275D3D /* FlareUI */; + }; + 06FD30762F8F9D3C00275D3D /* Exceptions for "FlareShareExtension" folder in "FlareShareExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 06FD30662F8F9D3C00275D3D /* FlareShareExtension */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -36,10 +129,25 @@ isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( 06E434172E6A9DE000CD0826 /* Exceptions for "Flare" folder in "Flare" target */, + 06E7D3A62F90BA5E006E5834 /* Exceptions for "Flare" folder in "FlareShareExtension" target */, + 06E7D3A72F90BA5E006E5834 /* Exceptions for "Flare" folder in "FlareUI" target */, ); path = Flare; sourceTree = ""; }; + 06FD30682F8F9D3C00275D3D /* FlareShareExtension */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 06FD30762F8F9D3C00275D3D /* Exceptions for "FlareShareExtension" folder in "FlareShareExtension" target */, + ); + path = FlareShareExtension; + sourceTree = ""; + }; + 06FD307D2F8F9D9B00275D3D /* FlareUI */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = FlareUI; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -50,6 +158,7 @@ 068923482E82A80700981D8E /* Flow in Frameworks */, 06C7FC172E7D474900A0D01A /* LazyPager in Frameworks */, 0672EE122E8689C70092AD1F /* Drops in Frameworks */, + 06FD30822F8F9D9B00275D3D /* FlareUI.framework in Frameworks */, 06C6B5482E853AAF00CCD388 /* SwiftUIIntrospect in Frameworks */, 0646B2622E7151A700535A3E /* Kingfisher in Frameworks */, 068B19382EB7490E0093DFC5 /* WaterfallGrids in Frameworks */, @@ -58,6 +167,24 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 06FD30642F8F9D3C00275D3D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 06FD30912F8F9DD700275D3D /* FlareUI.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 06FD30792F8F9D9B00275D3D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 06FD30A22F8FA49300275D3D /* Kingfisher in Frameworks */, + 06FD30A42F8FA49800275D3D /* SwiftUIBackports in Frameworks */, + 06FD30A02F8FA48800275D3D /* SwiftUIIntrospect in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -65,6 +192,9 @@ isa = PBXGroup; children = ( 06E434002E6A9A2600CD0826 /* Flare */, + 06FD30682F8F9D3C00275D3D /* FlareShareExtension */, + 06FD307D2F8F9D9B00275D3D /* FlareUI */, + 06FD30902F8F9DD700275D3D /* Frameworks */, 06E433FF2E6A9A2600CD0826 /* Products */, ); sourceTree = ""; @@ -73,12 +203,31 @@ isa = PBXGroup; children = ( 06E433FE2E6A9A2600CD0826 /* Flare.app */, + 06FD30672F8F9D3C00275D3D /* FlareShareExtension.appex */, + 06FD307C2F8F9D9B00275D3D /* FlareUI.framework */, ); name = Products; sourceTree = ""; }; + 06FD30902F8F9DD700275D3D /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; /* End PBXGroup section */ +/* Begin PBXHeadersBuildPhase section */ + 06FD30772F8F9D9B00275D3D /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + /* Begin PBXNativeTarget section */ 06E433FD2E6A9A2600CD0826 /* Flare */ = { isa = PBXNativeTarget; @@ -88,10 +237,14 @@ 06E433FA2E6A9A2600CD0826 /* Sources */, 06E433FB2E6A9A2600CD0826 /* Frameworks */, 06E433FC2E6A9A2600CD0826 /* Resources */, + 06FD30722F8F9D3C00275D3D /* Embed Foundation Extensions */, + 06FD30872F8F9D9B00275D3D /* Embed Frameworks */, ); buildRules = ( ); dependencies = ( + 06FD30702F8F9D3C00275D3D /* PBXTargetDependency */, + 06FD30812F8F9D9B00275D3D /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 06E434002E6A9A2600CD0826 /* Flare */, @@ -111,6 +264,57 @@ productReference = 06E433FE2E6A9A2600CD0826 /* Flare.app */; productType = "com.apple.product-type.application"; }; + 06FD30662F8F9D3C00275D3D /* FlareShareExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 06FD30752F8F9D3C00275D3D /* Build configuration list for PBXNativeTarget "FlareShareExtension" */; + buildPhases = ( + 06FD30632F8F9D3C00275D3D /* Sources */, + 06FD30642F8F9D3C00275D3D /* Frameworks */, + 06FD30652F8F9D3C00275D3D /* Resources */, + 06FD30952F8F9DD700275D3D /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 06FD30942F8F9DD700275D3D /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 06FD30682F8F9D3C00275D3D /* FlareShareExtension */, + ); + name = FlareShareExtension; + packageProductDependencies = ( + ); + productName = FlareShareExtension; + productReference = 06FD30672F8F9D3C00275D3D /* FlareShareExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; + 06FD307B2F8F9D9B00275D3D /* FlareUI */ = { + isa = PBXNativeTarget; + buildConfigurationList = 06FD30842F8F9D9B00275D3D /* Build configuration list for PBXNativeTarget "FlareUI" */; + buildPhases = ( + 06FD30A52F8FA4C400275D3D /* Build Kotlin */, + 06FD30772F8F9D9B00275D3D /* Headers */, + 06FD30782F8F9D9B00275D3D /* Sources */, + 06FD30792F8F9D9B00275D3D /* Frameworks */, + 06FD307A2F8F9D9B00275D3D /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 06FD307D2F8F9D9B00275D3D /* FlareUI */, + ); + name = FlareUI; + packageProductDependencies = ( + 06FD309F2F8FA48800275D3D /* SwiftUIIntrospect */, + 06FD30A12F8FA49300275D3D /* Kingfisher */, + 06FD30A32F8FA49800275D3D /* SwiftUIBackports */, + ); + productName = FlareUI; + productReference = 06FD307C2F8F9D9B00275D3D /* FlareUI.framework */; + productType = "com.apple.product-type.framework"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -118,12 +322,18 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 2600; + LastSwiftUpdateCheck = 2640; LastUpgradeCheck = 2600; TargetAttributes = { 06E433FD2E6A9A2600CD0826 = { CreatedOnToolsVersion = 26.0; }; + 06FD30662F8F9D3C00275D3D = { + CreatedOnToolsVersion = 26.4; + }; + 06FD307B2F8F9D9B00275D3D = { + CreatedOnToolsVersion = 26.4; + }; }; }; buildConfigurationList = 06E433F92E6A9A2600CD0826 /* Build configuration list for PBXProject "Flare" */; @@ -152,6 +362,8 @@ projectRoot = ""; targets = ( 06E433FD2E6A9A2600CD0826 /* Flare */, + 06FD30662F8F9D3C00275D3D /* FlareShareExtension */, + 06FD307B2F8F9D9B00275D3D /* FlareUI */, ); }; /* End PBXProject section */ @@ -164,6 +376,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 06FD30652F8F9D3C00275D3D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 06FD307A2F8F9D9B00275D3D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -185,6 +411,24 @@ shellPath = /bin/sh; shellScript = "# Type a script or drag a script file from your workspace to insert its path.\ncd \"$SRCROOT/..\"\n./gradlew :compose-ui:embedAndSignAppleFrameworkForXcode\n"; }; + 06FD30A52F8FA4C400275D3D /* Build Kotlin */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Build Kotlin"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# Type a script or drag a script file from your workspace to insert its path.\ncd \"$SRCROOT/..\"\n./gradlew :compose-ui:embedAndSignAppleFrameworkForXcode\n"; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -195,8 +439,40 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 06FD30632F8F9D3C00275D3D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 06FD30782F8F9D9B00275D3D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 06FD30702F8F9D3C00275D3D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 06FD30662F8F9D3C00275D3D /* FlareShareExtension */; + targetProxy = 06FD306F2F8F9D3C00275D3D /* PBXContainerItemProxy */; + }; + 06FD30812F8F9D9B00275D3D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 06FD307B2F8F9D9B00275D3D /* FlareUI */; + targetProxy = 06FD30802F8F9D9B00275D3D /* PBXContainerItemProxy */; + }; + 06FD30942F8F9DD700275D3D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 06FD307B2F8F9D9B00275D3D /* FlareUI */; + targetProxy = 06FD30932F8F9DD700275D3D /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 06E434072E6A9A2700CD0826 /* Debug */ = { isa = XCBuildConfiguration; @@ -329,6 +605,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; + CODE_SIGN_ENTITLEMENTS = Flare/Flare.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 38; DEVELOPMENT_TEAM = 7LFDZ96332; @@ -369,6 +646,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; + CODE_SIGN_ENTITLEMENTS = Flare/Flare.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 38; DEVELOPMENT_TEAM = 7LFDZ96332; @@ -403,6 +681,150 @@ }; name = Release; }; + 06FD30732F8F9D3C00275D3D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = FlareShareExtension/FlareShareExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 7LFDZ96332; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = FlareShareExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = FlareShareExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.dimension.flare.FlareShareExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 06FD30742F8F9D3C00275D3D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = FlareShareExtension/FlareShareExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 7LFDZ96332; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = FlareShareExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = FlareShareExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.dimension.flare.FlareShareExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 06FD30852F8F9D9B00275D3D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUILD_LIBRARY_FOR_DISTRIBUTION = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 7LFDZ96332; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + PRODUCT_BUNDLE_IDENTIFIER = dev.dimension.flare.FlareUI; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INSTALL_MODULE = YES; + SWIFT_INSTALL_OBJC_HEADER = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug; + }; + 06FD30862F8F9D9B00275D3D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUILD_LIBRARY_FOR_DISTRIBUTION = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 7LFDZ96332; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + PRODUCT_BUNDLE_IDENTIFIER = dev.dimension.flare.FlareUI; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INSTALL_MODULE = YES; + SWIFT_INSTALL_OBJC_HEADER = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -424,6 +846,24 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 06FD30752F8F9D3C00275D3D /* Build configuration list for PBXNativeTarget "FlareShareExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 06FD30732F8F9D3C00275D3D /* Debug */, + 06FD30742F8F9D3C00275D3D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 06FD30842F8F9D9B00275D3D /* Build configuration list for PBXNativeTarget "FlareUI" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 06FD30852F8F9D9B00275D3D /* Debug */, + 06FD30862F8F9D9B00275D3D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ @@ -542,6 +982,21 @@ package = 06F36AAB2E8F7BEC00F5905F /* XCRemoteSwiftPackageReference "iOS-Backports" */; productName = SwiftUIBackports; }; + 06FD309F2F8FA48800275D3D /* SwiftUIIntrospect */ = { + isa = XCSwiftPackageProductDependency; + package = 06C6B5462E853AAF00CCD388 /* XCRemoteSwiftPackageReference "swiftui-introspect" */; + productName = SwiftUIIntrospect; + }; + 06FD30A12F8FA49300275D3D /* Kingfisher */ = { + isa = XCSwiftPackageProductDependency; + package = 0646B2602E7151A700535A3E /* XCRemoteSwiftPackageReference "Kingfisher" */; + productName = Kingfisher; + }; + 06FD30A32F8FA49800275D3D /* SwiftUIBackports */ = { + isa = XCSwiftPackageProductDependency; + package = 06F36AAB2E8F7BEC00F5905F /* XCRemoteSwiftPackageReference "iOS-Backports" */; + productName = SwiftUIBackports; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 06E433F62E6A9A2600CD0826 /* Project object */; diff --git a/iosApp/FlareShareExtension/FlareShareExtension.entitlements b/iosApp/FlareShareExtension/FlareShareExtension.entitlements new file mode 100644 index 0000000000..97e2d67530 --- /dev/null +++ b/iosApp/FlareShareExtension/FlareShareExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.dev.dimension.flare + + + diff --git a/iosApp/FlareShareExtension/Info.plist b/iosApp/FlareShareExtension/Info.plist new file mode 100644 index 0000000000..de559488e9 --- /dev/null +++ b/iosApp/FlareShareExtension/Info.plist @@ -0,0 +1,27 @@ + + + + + NSExtension + + NSExtensionAttributes + + NSExtensionActivationRule + + NSExtensionActivationSupportsImageWithMaxCount + 4 + NSExtensionActivationSupportsText + + NSExtensionActivationSupportsWebPageWithMaxCount + 1 + NSExtensionActivationSupportsWebURLWithMaxCount + 1 + + + NSExtensionPointIdentifier + com.apple.share-services + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).ShareViewController + + + diff --git a/iosApp/FlareShareExtension/ShareViewController.swift b/iosApp/FlareShareExtension/ShareViewController.swift new file mode 100644 index 0000000000..c2fbc29678 --- /dev/null +++ b/iosApp/FlareShareExtension/ShareViewController.swift @@ -0,0 +1,59 @@ +import UIKit +import Social +import FlareUI +import KotlinSharedUI +import SwiftUI +import UniformTypeIdentifiers + +class ShareViewController: UIViewController { + private static var didInitKoin = false + override func viewDidLoad() { + super.viewDidLoad() + if !Self.didInitKoin { + Self.didInitKoin = true + ComposeUIHelper.shared.initializeLite() + } + // Extract shared data (e.g., URL or Text) + guard let extensionItem = extensionContext?.inputItems.first as? NSExtensionItem, + let itemProvider = extensionItem.attachments else { + self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil) + return + } + + // Host the SwiftUI View + let contentView = UIHostingController( + rootView: ComposeView(itemProvider: itemProvider, dismiss: { + self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil) + }) + ) + self.addChild(contentView) + self.view.addSubview(contentView.view) + contentView.view.frame = self.view.bounds + } +} + +struct ComposeView: View { + let itemProvider: [NSItemProvider] + let dismiss: () -> Void + @ObservedObject private var presenter = KotlinPresenter(presenter: ComposePresenter(accountType: nil)) + + var body: some View { + NavigationStack { + ComposeContent(itemProvider: itemProvider, state: presenter.state, dismiss: dismiss) { _ in + EmptyView() + } + } + } +} + +class EmptyInAppNotification: InAppNotification { + func onSuccess(message: Message) { + + } + func onError(message: Message, throwable: KotlinThrowable) { + + } + func onProgress(message: Message, progress: Int32, total: Int32) { + + } +} diff --git a/iosApp/flare/UI/Screen/AltTextEditSheet.swift b/iosApp/FlareUI/AltTextEditSheet.swift similarity index 100% rename from iosApp/flare/UI/Screen/AltTextEditSheet.swift rename to iosApp/FlareUI/AltTextEditSheet.swift diff --git a/iosApp/FlareUI/ComposeContent.swift b/iosApp/FlareUI/ComposeContent.swift new file mode 100644 index 0000000000..a5db2bd342 --- /dev/null +++ b/iosApp/FlareUI/ComposeContent.swift @@ -0,0 +1,1057 @@ +import SwiftUI +import KotlinSharedUI +import PhotosUI +internal import Kingfisher +internal import SwiftUIIntrospect +internal import SwiftUIBackports + +public struct ComposeContent: View { + public let dismiss: () -> Void + public let composeStatus: ComposeStatus? + public let state: ComposeState + public let itemProvider: [NSItemProvider]? + @ViewBuilder public let statusContent: (UiTimelineV2.Post) -> StatusContent + @FocusState private var keyboardFocused: Bool + @FocusState private var cwKeyboardFocused: Bool + @State private var viewModel = ComposeInputViewModel() + @State private var uiTextView: UITextView? + @State private var pendingCursor: Int? + @State private var showDraftConfirmation = false + + public init( + itemProvider: [NSItemProvider]? = nil, + composeStatus: ComposeStatus? = nil, + state: ComposeState, + dismiss: @escaping () -> Void, + @ViewBuilder statusContent: @escaping (UiTimelineV2.Post) -> StatusContent + ) { + self.itemProvider = itemProvider + self.composeStatus = composeStatus + self.state = state + self.statusContent = statusContent + self.dismiss = dismiss + } + + public var body: some View { + VStack( + spacing: 8 + ) { + accountSelectionView + composeBody + .safeAreaInset(edge: .bottom) { + bottomBar + } + .onChange(of: state.loadedDraftState) { _, newValue in + if case .success(let loadedDraft) = onEnum(of: newValue) { + applyDraft(loadedDraft.data) + state.consumeLoadedDraft() + } + } + } + .onChange(of: state.initialTextState) { _, newValue in + if case .success(let initialText) = onEnum(of: newValue) { + viewModel.text = initialText.data.text + pendingCursor = Int(initialText.data.cursorPosition) + applyCursorIfPossible() + } + } + .onChange(of: state.composeConfig) { _, newValue in + if case .success(let config) = onEnum(of: newValue), let media = config.data.media { + viewModel.mediaViewModel.maxSize = Int(media.maxCount) + viewModel.mediaViewModel.enableAltText = media.altTextMaxLength > 0 + viewModel.mediaViewModel.altTextMaxLength = Int(media.altTextMaxLength) + } + } + .onChange(of: viewModel.text) { oldValue, newValue in + state.setText(value: newValue) + } + .onChange(of: viewModel.mediaViewModel.items.count) { _, newValue in + state.setMediaSize(value: Int32(newValue)) + } + .toolbarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + switch onEnum(of: state.composeStatus) { + case .none: + Text("compose_title_new") + case .quote: + Text("compose_title_quote") + case .reply: + Text("compose_title_reply") + } + } + ToolbarItem(placement: .cancellationAction) { + Button { + if hasDraftContent { + showDraftConfirmation = true + } else { + dismiss() + } + } label: { + Label { + Text("compose_button_cancel") + } icon: { + Image(systemName: "xmark") + } + } + .confirmationDialog("Draft", isPresented: $showDraftConfirmation, titleVisibility: .visible) { + if #available(iOS 26.0, *) { + Button("Save Draft", role: .confirm) { + saveDraft(shouldDismiss: true) + } + } else { + Button("Save Draft") { + saveDraft(shouldDismiss: true) + } + } + Button("Cancel", role: .cancel) { + dismiss() + } + } message: { + Text("Save your current content as a draft before leaving?") + } + } + ToolbarItem(placement: .confirmationAction) { + Button { + send() + } label: { + Label { + Text("compose_button_send") + } icon: { + Image(systemName: "paperplane.fill") + } + } + .disabled(!state.canSend) + } + } + } + + private var composeBody: some View { + ScrollView { + VStack( + spacing: 8 + ) { + if viewModel.enableCW { + TextField(text: $viewModel.contentWarning) { + Text("compose_cw_placeholder") + } + .textFieldStyle(.plain) + .focused($cwKeyboardFocused) + Divider() + } + + TextField(text: $viewModel.text, axis: .vertical) { + Text("compose_placeholder") + } + .introspect(.textField(axis: .vertical), on: .iOS(.v16, .v17, .v18, .v26)) { textField in + self.uiTextView = textField + applyCursorIfPossible() + } + .textFieldStyle(.plain) + .focused($keyboardFocused) + .onAppear { + keyboardFocused = true + } + Spacer() + if viewModel.mediaViewModel.items.count > 0 { + ScrollView(.horizontal) { + HStack { + ForEach(viewModel.mediaViewModel.items) { item in + ComposeMediaItemView(item: item, mediaViewModel: viewModel.mediaViewModel) + } + } + } + if case .success(let config) = onEnum(of: state.composeConfig), let media = config.data.media, media.canSensitive { + Toggle(isOn: $viewModel.mediaViewModel.sensitive, label: { + Text("compose_media_mark_sensitive") + }) + } + } + if viewModel.pollViewModel.enabled { + HStack( + spacing: 8 + ) { + Picker("compose_poll_type", selection: $viewModel.pollViewModel.pollType) { + Text("compose_poll_type_single") + .tag(ComposePollType.single) + Text("compose_poll_type_multiple") + .tag(ComposePollType.multiple) + } + .pickerStyle(.segmented) + Button { + withAnimation { + viewModel.pollViewModel.add() + } + } label: { + Image("fa-plus") + }.disabled(viewModel.pollViewModel.choices.count >= 4) + } + ForEach($viewModel.pollViewModel.choices) { $choice in + HStack( + spacing: 8 + ) { + TextField(text: $choice.text) { + Text("compose_poll_choice_placeholder") + } + .textFieldStyle(.roundedBorder) + Button { + withAnimation { + viewModel.pollViewModel.remove(choice: choice) + } + } label: { + Image("fa-delete-left") + } + .disabled(viewModel.pollViewModel.choices.count <= 2) + } + } + HStack { + Spacer() + Menu { + ForEach(viewModel.pollViewModel.allExpiration, id: \.self) { expiration in + Button(action: { + viewModel.pollViewModel.expired = expiration + }, label: { + Text(expiration.rawValue) + }) + } + } label: { + Text("compose_poll_expiration") + Text(viewModel.pollViewModel.expired.rawValue) + } + } + } + if let replyState = state.replyState, + case .success(let reply) = onEnum(of: replyState), + let content = reply.data as? UiTimelineV2.Post { + statusContent(content) + } + } + .padding(.horizontal) + } + .scrollIndicators(.hidden) + .task { + if let itemProvider { + for item in itemProvider { + if item.hasItemConformingToTypeIdentifier(UTType.image.identifier) { + if let data = try? await item.loadFileRepresentation(forTypeIdentifier: UTType.image.identifier) { + viewModel.mediaViewModel.update(urls: [data]) + } + } else if item.hasItemConformingToTypeIdentifier(UTType.url.identifier) { + if let url = try? await item.loadItem(forTypeIdentifier: UTType.url.identifier) as? URL { + viewModel.text = url.absoluteString + } + } else if item.hasItemConformingToTypeIdentifier(UTType.text.identifier) { + if let text = try? await item.loadItem(forTypeIdentifier: UTType.text.identifier) as? String { + viewModel.text = text + } + } + } + } + } + } + + private var bottomBar: some View { + HStack { + ScrollView(.horizontal) { + HStack( + spacing: 16, + ) { + if !viewModel.pollViewModel.enabled, case .success(let config) = onEnum(of: state.composeConfig), config.data.media != nil { + PhotosPicker( + selection: $viewModel.mediaViewModel.selectedItems, + maxSelectionCount: viewModel.mediaViewModel.maxSize, + matching: .any(of: [.images, .videos, .livePhotos]) + ) { + Image("fa-image") + } + .onChange(of: viewModel.mediaViewModel.selectedItems) { oldValue, newValue in + viewModel.mediaViewModel.update(selectedItems: newValue) + } + } + if viewModel.mediaViewModel.selectedItems.count == 0, case .success(let config) = onEnum(of: state.composeConfig), config.data.poll != nil { + Button(action: { + withAnimation { + viewModel.togglePoll() + } + }, label: { + Image("fa-square-poll-horizontal") + }) + } + if case .success(let visibilityState) = onEnum(of: state.visibilityState), visibilityState.data.allVisibilities.count > 1 { + Menu { + ForEach(visibilityState.data.allVisibilities, id: \.self) { visibility in + Button { + visibilityState.data.setVisibility(value: visibility) + } label: { + Label { + Text(visibility.title) + } icon: { + StatusVisibilityView(data: visibility) + } + Text(visibility.desc) + } + } + } label: { + StatusVisibilityView(data: visibilityState.data.visibility) + } + } + if case .success(let config) = onEnum(of: state.composeConfig), config.data.contentWarning != nil { + Button(action: { + withAnimation { + viewModel.toggleCW() + if viewModel.enableCW { + cwKeyboardFocused = true + } else { + keyboardFocused = true + } + } + }, label: { + Image("fa-circle-exclamation") + }) + } + if case .success(let emojiState) = onEnum(of: state.emojiState), emojiState.data.size > 0 { + Button(action: { + viewModel.showEmojiPanel() + }, label: { + Image("fa-face-smile") + }) + .popover(isPresented: $viewModel.showEmoji) { + NavigationStack { + EmojiPopup(data: emojiState.data) { item in + viewModel.addEmoji(emoji: item) + insert(item.insertText) + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button( + role: .cancel + ) { + viewModel.showEmoji = false + } label: { + Label { + Text("Cancel") + } icon: { + Image("fa-xmark") + } + } + } + } + } + } + } + + if case .success(let config) = onEnum(of: state.composeConfig), let languageConfig = config.data.language { + Menu { + ForEach(languageConfig.sortedIsoCodes, id: \.self) { code in + Button { + if languageConfig.maxCount > 1 { + if viewModel.languages.contains(code) { + if viewModel.languages.count > 1 { + viewModel.languages.removeAll { $0 == code } + } + } else { + if viewModel.languages.count < languageConfig.maxCount { + viewModel.languages.append(code) + } + } + } else { + viewModel.languages = [code] + } + } label: { + HStack { + Text(Locale.current.localizedString(forLanguageCode: code) ?? code) + if viewModel.languages.contains(code) { + Image(systemName: "checkmark") + } + } + } + } + } label: { + if let first = viewModel.languages.first, viewModel.languages.count == 1 { + Text(first.uppercased()) + .font(.caption) + .bold() + .padding(4) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color.primary, lineWidth: 1) + ) + } else { + Image(systemName: "globe") + } + } + } + } + .font(.title) + .buttonStyle(.plain) + } + .scrollIndicators(.hidden) + Spacer() + if case .success(let config) = onEnum(of: state.composeConfig), let maxLength = config.data.text?.maxLength { + Text("\(viewModel.text.count)/\(maxLength)") + .foregroundStyle(viewModel.text.count > maxLength ? .red : .gray) + } + } + .padding() + .backport + .glassEffect(.regular, in: .capsule, fallbackBackground: .regularMaterial) + .padding() + } + + private var accountSelectionView: some View { + ScrollView(.horizontal) { + HStack { + if case .success(let users) = onEnum(of: state.selectedUsers) { + ForEach(Array(users.data.enumerated()), id: \.offset) { element in + if let userState = element.element as? UiState, case .success(let userData) = onEnum(of: userState) { + let user = userData.data + Label { + Text(user.handle.canonical) + } icon: { + KFImage(URL(string: user.avatar)) + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color(.secondarySystemBackground)) + .clipShape(.rect(cornerRadius: 16)) + .onTapGesture { + state.selectAccount(accountKey: user.key) + } + } + } + } + if case .success(let others) = onEnum(of: state.otherUsers) { + Menu { + ForEach(Array(others.data.enumerated()), id: \.offset) { element in + if let userState = element.element as? UiState, case .success(let userData) = onEnum(of: userState) { + let user = userData.data + Button { + state.selectAccount(accountKey: user.key) + } label: { + Label { + Text(user.handle.canonical) + } icon: { + KFImage(URL(string: user.avatar)) + .resizable() + .scaledToFit() + .frame(maxWidth: 20, maxHeight: 20) + } + } + } + } + } label: { + Image("fa-plus") + } + } + } + .padding(.horizontal) + } + .scrollIndicators(.hidden) + } + + private func applyCursorIfPossible() { + guard let textView = uiTextView, textView.isFirstResponder, let pendingCursor else { return } + let length = textView.text.count + let clamped = NSRange(location: max(0, min(pendingCursor, length)), + length: 0) + textView.selectedRange = clamped + textView.scrollRangeToVisible(clamped) + self.pendingCursor = nil + } + + private func insert(_ s: String) { + guard let textView = uiTextView else { return } + + if textView.markedTextRange != nil { return } + + let sel = textView.selectedRange + let current = textView.text ?? "" + let ns = current as NSString + let newText = ns.replacingCharacters(in: sel, with: s) + + textView.text = newText + viewModel.text = newText + + let newLocation = sel.location + (s as NSString).length + textView.selectedRange = NSRange(location: newLocation, length: 0) + textView.scrollRangeToVisible(NSRange(location: max(0, newLocation - 1), length: 1)) + } + + private func getComposeData() -> ComposeData { + ComposeData( + content: viewModel.text, + visibility: getVisibility(), + language: viewModel.languages, + medias: getMedia(), + sensitive: viewModel.mediaViewModel.sensitive, + spoilerText: viewModel.contentWarning, + poll: getPoll(), + localOnly: false, + referenceStatus: getReferenceStatus() + ) + } + + private var hasDraftContent: Bool { + !viewModel.text.isEmpty || + !viewModel.contentWarning.isEmpty || + !viewModel.mediaViewModel.items.isEmpty || + viewModel.pollViewModel.enabled + } + + private func send() { + let data = getComposeData() + state.send(data: data) { dispatched in + if dispatched.boolValue { + dismiss() + } + } + } + + private func saveDraft(shouldDismiss: Bool = false) { + state.saveDraft(data: getComposeData()) { dispatched in + guard dispatched.boolValue else { return } + if shouldDismiss { + dismiss() + } + } + } + + private func applyDraft(_ draft: UiDraft) { + let data = draft.data + viewModel.text = data.content + viewModel.contentWarning = data.spoilerText ?? "" + viewModel.enableCW = !(data.spoilerText ?? "").isEmpty + if !data.language.isEmpty { + viewModel.languages = data.language + } + viewModel.mediaViewModel.sensitive = data.sensitive + viewModel.mediaViewModel.restore(draftMedias: draft.medias) + viewModel.apply(poll: data.poll) + if case .success(let visibilityState) = onEnum(of: state.visibilityState) { + visibilityState.data.setVisibility(value: data.visibility) + } + } + + private func getMedia() -> [ComposeData.Media] { + return viewModel.mediaViewModel.items.compactMap { item in + return .init( + file: .init(name: item.fileName, data: KotlinByteArray.from(data: item.getData()!), type: item.type), + altText: item.altText.isEmpty ? nil : item.altText + ) + } + } + private func getReferenceStatus() -> ComposeData.ReferenceStatus? { + return if let data = state.composeStatus { + ComposeData.ReferenceStatus(composeStatus: data) + } else { + nil + } + } + private func getPoll() -> ComposeData.Poll? { + return if viewModel.pollViewModel.enabled { + ComposeData.Poll(options: viewModel.pollViewModel.choices.map { item in item.text }, expiredAfter: viewModel.pollViewModel.expired.inWholeMilliseconds, multiple: viewModel.pollViewModel.pollType == ComposePollType.multiple) + } else { + nil + } + } + private func getVisibility() -> UiTimelineV2.PostVisibility { + switch onEnum(of: state.visibilityState) { + case .success(let success): return success.data.visibility + default: return .public + } + } +} + +@Observable +class ComposeInputViewModel { + var text: String = "" + var contentWarning: String = "" + var enableCW = false + var showEmoji = false + var pollViewModel = PollViewModel() + var mediaViewModel = MediaViewModel() +// var visibility: UiTimeline.ItemContentStatusTopEndContentVisibilityType = .public + var languages: [String] = { + if let code = Locale.current.language.languageCode?.identifier { + return [code] + } + return ["en"] + }() + + + func showEmojiPanel() { + showEmoji = true + } + func toggleCW() { + enableCW = !enableCW + } + func togglePoll() { + if pollViewModel.enabled { + pollViewModel = PollViewModel() + } else { + pollViewModel.enabled = true + } + } + func apply(poll: ComposeData.Poll?) { + guard let poll else { + pollViewModel = PollViewModel() + return + } + + pollViewModel.enabled = true + pollViewModel.pollType = poll.multiple ? .multiple : .single + pollViewModel.expired = ComposePollExpired(milliseconds: poll.expiredAfter) ?? .minutes5 + pollViewModel.choices = poll.options.map(PollChoice.init) + while pollViewModel.choices.count < 2 { + pollViewModel.choices.append(PollChoice()) + } + } + func addEmoji(emoji: UiEmoji) { +// text += " :" + emoji.shortcode + ": " + showEmoji = false + } +} + + +@Observable +class MediaViewModel { + var selectedItems: [PhotosPickerItem] = [] + var items: [MediaItem] = [] + var sensitive = false + var maxSize = 4 + var enableAltText = true + var altTextMaxLength = 500 + func update(selectedItems: [PhotosPickerItem]) { + items.append(contentsOf: selectedItems.filter { item in + !self.items.contains { $0.id == item.itemIdentifier } + }.map { item in + MediaItem(item: item) + }) + items = Array(items.prefix(maxSize)) + } + func update(urls: [URL]) { + items.append(contentsOf: urls.map { url in + MediaItem(url: url) + }) + items = Array(items.prefix(maxSize)) + } + func update(image: UIImage) { + items.append(contentsOf: [MediaItem(image: image)]) + items = Array(items.prefix(maxSize)) + } + func restore(draftMedias: [UiDraftMedia]) { + items = draftMedias.compactMap(MediaItem.init(draftMedia:)) + } + func remove(item: MediaItem) { + if let index = items.firstIndex(of: item) { + items.remove(at: index) + if selectedItems.indices.contains(index) { + selectedItems.remove(at: index) + } + } + } +} + +@Observable +class MediaItem: Equatable, Identifiable { + static func == (lhs: MediaItem, rhs: MediaItem) -> Bool { + lhs.id == rhs.id + } + + var url: URL? + var image: UIImage? + private var data: Data? + var altText: String = "" + let id: String + let fileName: String + var type: FileType = .other + + func getData() -> Data? { + if let data { + return data + } else if let url, let newData = try? Data(contentsOf: url) { + return newData + } else if let image, let newData = image.jpegData(compressionQuality: 0.8) { + return newData + } + return nil + } + + init(image: UIImage) { + self.id = UUID().uuidString + self.fileName = "image_\(id).jpg" + self.type = .image + self.data = nil + self.image = image + self.url = nil + } + + init(url: URL) { + self.id = url.absoluteString + self.fileName = url.lastPathComponent + self.type = url.pathExtension.lowercased() == "mp4" ? .video : .image + self.data = nil + self.image = nil + self.url = url +// if let data = try? Data(contentsOf: url) { +// self.data = data +// if let uiImage = UIImage(data: data) { +// self.image = uiImage +// } +// } + } + + init(item: PhotosPickerItem) { +// self.item = item + self.id = item.itemIdentifier ?? UUID().uuidString + self.fileName = item.itemIdentifier ?? UUID().uuidString + + if let contentType = item.supportedContentTypes.first { + if contentType.conforms(to: .image) { + self.type = .image + } else if contentType.conforms(to: .movie) { + self.type = .video + } + } + + item.loadTransferable(type: Data.self) { result in + do { + if let data = try result.get() { + if let uiImage = UIImage(data: data) { + DispatchQueue.main.async { + self.data = data + self.image = uiImage + } + } + } + } catch { + } + } + } + + init?(draftMedia: UiDraftMedia) { + let fileURL = URL(fileURLWithPath: draftMedia.cachePath) + guard let data = try? Data(contentsOf: fileURL) else { + return nil + } + + self.id = draftMedia.cachePath + self.fileName = draftMedia.fileName ?? fileURL.lastPathComponent + self.data = data + self.altText = draftMedia.altText ?? "" + + switch draftMedia.type { + case .image: + self.type = .image + self.image = UIImage(data: data) + case .video: + self.type = .video + default: + self.type = .other + } + } +} + +@Observable +class PollViewModel { + var enabled = false + var pollType = ComposePollType.single + var choices: [PollChoice] = [PollChoice(), PollChoice()] + var expired = ComposePollExpired.minutes5 + let allExpiration = [ + ComposePollExpired.minutes5, + ComposePollExpired.minutes30, + ComposePollExpired.hours1, + ComposePollExpired.hours6, + ComposePollExpired.hours12, + ComposePollExpired.days1, + ComposePollExpired.days3, + ComposePollExpired.days7 + ] + func add() { + if choices.count < 4 { + choices.append(PollChoice()) + } + } + func remove(choice: PollChoice) { + if choices.count > 2 { + choices.removeAll { value in + value.id == choice.id + } + } + } +} + +@Observable +class PollChoice: Identifiable { + var text = "" + + init(text: String = "") { + self.text = text + } +} + +enum ComposePollType { + case single + case multiple +} + +enum ComposePollExpired: String { + case minutes5 + case minutes30 + case hours1 + case hours6 + case hours12 + case days1 + case days3 + case days7 + init?(milliseconds: Int64) { + switch milliseconds { + case 5 * 60 * 1000: + self = .minutes5 + case 30 * 60 * 1000: + self = .minutes30 + case 1 * 60 * 60 * 1000: + self = .hours1 + case 6 * 60 * 60 * 1000: + self = .hours6 + case 12 * 60 * 60 * 1000: + self = .hours12 + case 24 * 60 * 60 * 1000: + self = .days1 + case 3 * 24 * 60 * 60 * 1000: + self = .days3 + case 7 * 24 * 60 * 60 * 1000: + self = .days7 + default: + return nil + } + } + var inWholeMilliseconds: Int64 { + switch self { + case .minutes5: + return 5 * 60 * 1000 + case .minutes30: + return 30 * 60 * 1000 + case .hours1: + return 1 * 60 * 60 * 1000 + case .hours6: + return 6 * 60 * 60 * 1000 + case .hours12: + return 12 * 60 * 60 * 1000 + case .days1: + return 24 * 60 * 60 * 1000 + case .days3: + return 3 * 24 * 60 * 60 * 1000 + case .days7: + return 7 * 24 * 60 * 60 * 1000 + } + } +} + +extension UiDraftStatus { + fileprivate var title: String { + switch self { + case .draft: + return "Draft" + case .failed: + return "Failed" + case .sending: + return "Sending" + default: + return "Draft" + } + } + + fileprivate var tint: Color { + switch self { + case .draft: + return .secondary + case .failed: + return .red + case .sending: + return .orange + default: + return .secondary + } + } +} + +struct ComposeMediaItemView: View { + let item: MediaItem + var mediaViewModel: MediaViewModel + @State private var showAltTextEditor = false + + var body: some View { + if let url = item.url { + KFImage(url) + .resizable() + .scaledToFill() + .frame(width: 128, height: 128) + .cornerRadius(8) + .overlay(alignment: .bottomLeading) { + if mediaViewModel.enableAltText && !item.altText.isEmpty { + Text("ALT") + .font(.caption2) + .bold() + .foregroundStyle(.black) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background(.white.opacity(0.8)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .padding(4) + } + } + .onTapGesture { + if mediaViewModel.enableAltText { + showAltTextEditor = true + } + } + .contextMenu { + Button(action: { + withAnimation { + mediaViewModel.remove(item: item) + } + }, label: { + Label { + Text("delete") + } icon: { + Image("fa-trash") + } + }) + + if mediaViewModel.enableAltText { + Button { + showAltTextEditor = true + } label: { + Label("Edit Description", systemImage: "pencil") + } + } + } + .sheet(isPresented: $showAltTextEditor) { + AltTextEditSheet(item: item, maxLength: mediaViewModel.altTextMaxLength) + } + } else if let image = item.image { + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: 128, height: 128) + .cornerRadius(8) + .overlay(alignment: .bottomLeading) { + if mediaViewModel.enableAltText && !item.altText.isEmpty { + Text("ALT") + .font(.caption2) + .bold() + .foregroundStyle(.black) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background(.white.opacity(0.8)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .padding(4) + } + } + .onTapGesture { + if mediaViewModel.enableAltText { + showAltTextEditor = true + } + } + .contextMenu { + Button(action: { + withAnimation { + mediaViewModel.remove(item: item) + } + }, label: { + Label { + Text("delete") + } icon: { + Image("fa-trash") + } + }) + + if mediaViewModel.enableAltText { + Button { + showAltTextEditor = true + } label: { + Label("Edit Description", systemImage: "pencil") + } + } + } + .sheet(isPresented: $showAltTextEditor) { + AltTextEditSheet(item: item, maxLength: mediaViewModel.altTextMaxLength) + } + } + } +} + +extension UiTimelineV2.PostVisibility { + var title: LocalizedStringResource { + switch self { + case .public: + return LocalizedStringResource("status_visibility_public") + case .home: + return LocalizedStringResource("status_visibility_home") + case .followers: + return LocalizedStringResource("status_visibility_followers") + case .specified: + return LocalizedStringResource("status_visibility_specified") + case .channel: + return LocalizedStringResource("status_visibility_public") + } + } + var desc: LocalizedStringResource { + switch self { + case .public: + return LocalizedStringResource("status_visibility_public_description") + case .home: + return LocalizedStringResource("status_visibility_home_description") + case .followers: + return LocalizedStringResource("status_visibility_followers_description") + case .specified: + return LocalizedStringResource("status_visibility_specified_description") + case .channel: + return LocalizedStringResource("status_visibility_public_description") + } + } +} + +extension KotlinByteArray { + static func from(data: Data) -> KotlinByteArray { + let swiftByteArray = [UInt8](data) + return swiftByteArray + .map(Int8.init(bitPattern:)) + .enumerated() + .reduce(into: KotlinByteArray(size: Int32(swiftByteArray.count))) { result, row in + result.set(index: Int32(row.offset), value: row.element) + } + } +} + +extension NSItemProvider { + func loadFileRepresentation(forTypeIdentifier typeIdentifier: String) async throws -> URL { + try await withCheckedThrowingContinuation { continuation in + self.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { url, error in + if let error = error { + return continuation.resume(throwing: error) + } + + guard let url = url else { + return continuation.resume(throwing: NSError()) + } + + let localURL = FileManager.default.temporaryDirectory.appendingPathComponent(url.lastPathComponent) + try? FileManager.default.removeItem(at: localURL) + + do { + try FileManager.default.copyItem(at: url, to: localURL) + } catch { + return continuation.resume(throwing: error) + } + + continuation.resume(returning: localURL) + }.resume() + } + } +} diff --git a/iosApp/flare/UI/Component/EmojiPopup.swift b/iosApp/FlareUI/EmojiPopup.swift similarity index 80% rename from iosApp/flare/UI/Component/EmojiPopup.swift rename to iosApp/FlareUI/EmojiPopup.swift index a602572726..dae9daeb47 100644 --- a/iosApp/flare/UI/Component/EmojiPopup.swift +++ b/iosApp/FlareUI/EmojiPopup.swift @@ -1,22 +1,25 @@ import SwiftUI import KotlinSharedUI -import SwiftUIBackports +internal import SwiftUIBackports +internal import Kingfisher -struct EmojiPopup: View { +public struct EmojiPopup: View { @StateObject private var presenter: KotlinPresenter @State private var filterText: String = "" - let data: EmojiData - let onItemClicked: (UiEmoji) -> Void + public let data: EmojiData + public let onItemClicked: (UiEmoji) -> Void - var body: some View { + public var body: some View { List { - StateView(state: presenter.state.history) { history in - let items = history.cast(UiEmoji.self) - if !items.isEmpty { + if case .success(let historyData) = onEnum(of: presenter.state.history) { + let history = historyData.data + if history.count > 0 { Section { LazyVGrid(columns: [GridItem(.adaptive(minimum: 48))], spacing: 8) { - ForEach(items, id: \.shortcode) { item in - NetworkImage(data: item.url) + ForEach(Array(history.enumerated()), id: \.offset) { element in + let item = element.element as! UiEmoji + KFImage(URL(string: item.url)) + .resizable() .scaledToFit() .frame(width: 32, height: 32) .onTapGesture { @@ -46,7 +49,8 @@ struct EmojiPopup: View { Section { LazyVGrid(columns: [GridItem(.adaptive(minimum: 48))], spacing: 8) { ForEach(value, id: \.shortcode) { item in - NetworkImage(data: item.url) + KFImage(URL(string: item.url)) + .resizable() .scaledToFit() .frame(width: 32, height: 32) .onTapGesture { @@ -86,7 +90,8 @@ struct EmojiSection: View { Section(isExpanded: $isExpanded) { LazyVGrid(columns: [GridItem(.adaptive(minimum: 48))], spacing: 8) { ForEach(value, id: \.shortcode) { item in - NetworkImage(data: item.url) + KFImage(URL(string: item.url)) + .resizable() .scaledToFit() .frame(width: 32, height: 32) .onTapGesture { @@ -100,7 +105,8 @@ struct EmojiSection: View { } } } -extension EmojiPopup { + +public extension EmojiPopup { init( data: EmojiData, onItemClicked: @escaping (UiEmoji) -> Void diff --git a/iosApp/flare/Common/Formatter.swift b/iosApp/FlareUI/Formatter.swift similarity index 64% rename from iosApp/flare/Common/Formatter.swift rename to iosApp/FlareUI/Formatter.swift index 54b6dd4c10..7aed6aae33 100644 --- a/iosApp/flare/Common/Formatter.swift +++ b/iosApp/FlareUI/Formatter.swift @@ -1,11 +1,11 @@ import Foundation import KotlinSharedUI -class Formatter: SwiftFormatter { +public class PlatformFormatter: SwiftFormatter { private init() {} - static let shared = Formatter() - nonisolated func formatNumber(number: Int64) -> String { + public static let shared = PlatformFormatter() + nonisolated public func formatNumber(number: Int64) -> String { return Int(number) .formatted( .number diff --git a/iosApp/flare/Common/FoundationModelOnDeviceAI.swift b/iosApp/FlareUI/FoundationModelOnDeviceAI.swift similarity index 68% rename from iosApp/flare/Common/FoundationModelOnDeviceAI.swift rename to iosApp/FlareUI/FoundationModelOnDeviceAI.swift index 1f43d15b48..57a84c3f10 100644 --- a/iosApp/flare/Common/FoundationModelOnDeviceAI.swift +++ b/iosApp/FlareUI/FoundationModelOnDeviceAI.swift @@ -2,23 +2,23 @@ import Foundation import KotlinSharedUI import FoundationModels -final class FoundationModelOnDeviceAI: SwiftOnDeviceAI { +public final class FoundationModelOnDeviceAI: SwiftOnDeviceAI { private init() {} - static let shared = FoundationModelOnDeviceAI() + public static let shared = FoundationModelOnDeviceAI() - func __isAvailable() async throws -> KotlinBoolean { + public func __isAvailable() async throws -> KotlinBoolean { if #available(iOS 26.0, *) { return KotlinBoolean(bool: SystemLanguageModel.default.isAvailable) } return KotlinBoolean(bool: false) } - func __translate(source: String, targetLanguage: String, prompt: String) async throws -> String? { + public func __translate(source: String, targetLanguage: String, prompt: String) async throws -> String? { return await generateText(prompt: prompt) } - func __tldr(source: String, targetLanguage: String, prompt: String) async throws -> String? { + public func __tldr(source: String, targetLanguage: String, prompt: String) async throws -> String? { return await generateText(prompt: prompt) } diff --git a/iosApp/flare/UI/Component/KotlinPresenter.swift b/iosApp/FlareUI/KotlinPresenter.swift similarity index 83% rename from iosApp/flare/UI/Component/KotlinPresenter.swift rename to iosApp/FlareUI/KotlinPresenter.swift index c0916c9d40..ec6a98e85f 100644 --- a/iosApp/flare/UI/Component/KotlinPresenter.swift +++ b/iosApp/FlareUI/KotlinPresenter.swift @@ -6,12 +6,12 @@ import Foundation // which is a heavy work since Kotlin Presenter is not designed to do so // so we keep using the old ObservableObject and @StateObject // see: https://github.com/Dimillian/IceCubesApp/issues/2033 -final class KotlinPresenter: ObservableObject { +public final class KotlinPresenter: ObservableObject { private var subscribers = Set() var presenter: PresenterBase - let key = UUID().uuidString + public let key = UUID().uuidString - init(presenter: PresenterBase) { + public init(presenter: PresenterBase) { self.presenter = presenter self.state = presenter.models.value self.presenter.models.toPublisher().receive(on: DispatchQueue.main).sink { [weak self] newState in @@ -21,7 +21,7 @@ final class KotlinPresenter: ObservableObject { } @Published - var state: T + public var state: T // @MainActor deinit { diff --git a/iosApp/flare/Common/PlatformTextRenderer.swift b/iosApp/FlareUI/PlatformTextRenderer.swift similarity index 86% rename from iosApp/flare/Common/PlatformTextRenderer.swift rename to iosApp/FlareUI/PlatformTextRenderer.swift index 1c27f5cb79..680a61c9ed 100644 --- a/iosApp/flare/Common/PlatformTextRenderer.swift +++ b/iosApp/FlareUI/PlatformTextRenderer.swift @@ -1,12 +1,12 @@ import SwiftUI @preconcurrency import KotlinSharedUI -class PlatformTextContent: NSObject {} +public class PlatformTextContent: NSObject {} -final class PlatformTextTextContent: PlatformTextContent { - let runs: [PlatformTextRun] - let alignment: TextAlignment? - let isBlockQuote: Bool +public final class PlatformTextTextContent: PlatformTextContent { + public let runs: [PlatformTextRun] + public let alignment: TextAlignment? + public let isBlockQuote: Bool init(runs: [PlatformTextRun], alignment: TextAlignment?, isBlockQuote: Bool) { self.runs = runs @@ -16,9 +16,9 @@ final class PlatformTextTextContent: PlatformTextContent { } } -final class PlatformTextBlockImageContent: PlatformTextContent { - let url: String - let href: String? +public final class PlatformTextBlockImageContent: PlatformTextContent { + public let url: String + public let href: String? init(url: String, href: String?) { self.url = url @@ -27,10 +27,10 @@ final class PlatformTextBlockImageContent: PlatformTextContent { } } -class PlatformTextRun: NSObject {} +public class PlatformTextRun: NSObject {} -final class PlatformTextAttributedRun: PlatformTextRun { - let text: AttributedString +public final class PlatformTextAttributedRun: PlatformTextRun { + public let text: AttributedString init(text: AttributedString) { self.text = text @@ -38,9 +38,9 @@ final class PlatformTextAttributedRun: PlatformTextRun { } } -final class PlatformTextImageRun: PlatformTextRun { - let url: String - let alt: String +public final class PlatformTextImageRun: PlatformTextRun { + public let url: String + public let alt: String init(url: String, alt: String) { self.url = url @@ -49,12 +49,12 @@ final class PlatformTextImageRun: PlatformTextRun { } } -final class PlatformTextRenderer: SwiftPlatformTextRenderer { - static let shared = PlatformTextRenderer() +public final class PlatformTextRenderer: SwiftPlatformTextRenderer { + public static let shared = PlatformTextRenderer() private init() {} - func render(renderRuns: [RenderContent]) -> [Any] { + public func render(renderRuns: [RenderContent]) -> [Any] { dispatchPrecondition(condition: .onQueue(.main)) let context = RenderContext() diff --git a/iosApp/flare/UI/Component/Status/StatusVisibilityView.swift b/iosApp/FlareUI/StatusVisibilityView.swift similarity index 58% rename from iosApp/flare/UI/Component/Status/StatusVisibilityView.swift rename to iosApp/FlareUI/StatusVisibilityView.swift index 969451d245..815f5b866d 100644 --- a/iosApp/flare/UI/Component/Status/StatusVisibilityView.swift +++ b/iosApp/FlareUI/StatusVisibilityView.swift @@ -1,15 +1,20 @@ import SwiftUI import KotlinSharedUI -struct StatusVisibilityView: View { +public struct StatusVisibilityView: View { let data: UiTimelineV2.PostVisibility - var body: some View { + + public init(data: UiTimelineV2.PostVisibility) { + self.data = data + } + + public var body: some View { switch data { case .public: Image("fa-globe") case .home: Image("fa-lock-open") case .followers: Image("fa-lock") case .specified: Image("fa-at") - case .channel: Image(.faTv) + case .channel: Image("fa-tv") } } } diff --git a/iosApp/flare/Flare.entitlements b/iosApp/flare/Flare.entitlements new file mode 100644 index 0000000000..97e2d67530 --- /dev/null +++ b/iosApp/flare/Flare.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.dev.dimension.flare + + + diff --git a/iosApp/flare/FlareApp.swift b/iosApp/flare/FlareApp.swift index 4020131232..2c19e68ad1 100644 --- a/iosApp/flare/FlareApp.swift +++ b/iosApp/flare/FlareApp.swift @@ -1,6 +1,7 @@ import SwiftUI import KotlinSharedUI import AVFAudio +import FlareUI @main struct FlareApp: App { @@ -8,9 +9,10 @@ struct FlareApp: App { configureAudioSessionForMixing() ComposeUIHelper.shared.initialize( inAppNotification: SwiftInAppNotification.shared, - swiftFormatter: Formatter.shared, + swiftFormatter: PlatformFormatter.shared, swiftPlatformTextRenderer: PlatformTextRenderer.shared, - swiftOnDeviceAI: FoundationModelOnDeviceAI.shared + swiftOnDeviceAI: FoundationModelOnDeviceAI.shared, + isMainApp: true, ) } var body: some Scene { diff --git a/iosApp/flare/UI/Component/AvatarView.swift b/iosApp/flare/UI/Component/AvatarView.swift index 940c205dd3..759652fec5 100644 --- a/iosApp/flare/UI/Component/AvatarView.swift +++ b/iosApp/flare/UI/Component/AvatarView.swift @@ -1,10 +1,10 @@ import SwiftUI import KotlinSharedUI -struct AvatarView: View { +public struct AvatarView: View { @Environment(\.appearanceSettings.avatarShape) private var avatarShape - let data: String - var body: some View { + public let data: String + public var body: some View { NetworkImage(data: data) .clipShape(avatarShape == .circle ? AnyShape(.circle) : AnyShape(.rect(cornerRadius: 8))) } diff --git a/iosApp/flare/UI/Component/RichText.swift b/iosApp/flare/UI/Component/RichText.swift index 6bc7e56d25..a953c26e81 100644 --- a/iosApp/flare/UI/Component/RichText.swift +++ b/iosApp/flare/UI/Component/RichText.swift @@ -1,6 +1,7 @@ import SwiftUI import KotlinSharedUI import Kingfisher +import FlareUI struct RichText: View { let text: UiRichText diff --git a/iosApp/flare/UI/Component/Status/StatusShareSheet.swift b/iosApp/flare/UI/Component/Status/StatusShareSheet.swift index 1fd815ff26..8947b3d94b 100644 --- a/iosApp/flare/UI/Component/Status/StatusShareSheet.swift +++ b/iosApp/flare/UI/Component/Status/StatusShareSheet.swift @@ -2,6 +2,7 @@ import SwiftUI import KotlinSharedUI import SwiftUIBackports import UniformTypeIdentifiers +import FlareUI struct StatusShareSheet: View { let statusKey: MicroBlogKey diff --git a/iosApp/flare/UI/Component/Status/StatusView.swift b/iosApp/flare/UI/Component/Status/StatusView.swift index fb104c69b6..5bfbab6813 100644 --- a/iosApp/flare/UI/Component/Status/StatusView.swift +++ b/iosApp/flare/UI/Component/Status/StatusView.swift @@ -1,6 +1,7 @@ import SwiftUI import KotlinSharedUI import SwiftUIBackports +import FlareUI struct StatusView: View { @Environment(\.appearanceSettings.fullWidthPost) private var fullWidthPost diff --git a/iosApp/flare/UI/Component/Status/TranslateView.swift b/iosApp/flare/UI/Component/Status/TranslateView.swift index cc88d7f868..d6210ac2e5 100644 --- a/iosApp/flare/UI/Component/Status/TranslateView.swift +++ b/iosApp/flare/UI/Component/Status/TranslateView.swift @@ -1,5 +1,6 @@ import SwiftUI import KotlinSharedUI +import FlareUI struct StatusTranslateView: View { @Environment(\.aiConfig) private var aiConfig diff --git a/iosApp/flare/UI/Component/TabIcon.swift b/iosApp/flare/UI/Component/TabIcon.swift index 2e51145679..fdcf1d755d 100644 --- a/iosApp/flare/UI/Component/TabIcon.swift +++ b/iosApp/flare/UI/Component/TabIcon.swift @@ -1,5 +1,6 @@ import SwiftUI import KotlinSharedUI +import FlareUI struct TabTitle: View { let title: TitleType diff --git a/iosApp/flare/UI/FlareRoot.swift b/iosApp/flare/UI/FlareRoot.swift index 834c4e0f5d..7da4cc7aaf 100644 --- a/iosApp/flare/UI/FlareRoot.swift +++ b/iosApp/flare/UI/FlareRoot.swift @@ -1,6 +1,7 @@ import SwiftUI import KotlinSharedUI import SwiftUIBackports +import FlareUI @available(iOS 18.0, *) struct FlareRoot: View { diff --git a/iosApp/flare/UI/FlareTheme.swift b/iosApp/flare/UI/FlareTheme.swift index 4a06e164f8..dbea9aa33b 100644 --- a/iosApp/flare/UI/FlareTheme.swift +++ b/iosApp/flare/UI/FlareTheme.swift @@ -2,6 +2,7 @@ import SwiftUI import KotlinSharedUI import Foundation import Combine +import FlareUI struct FlareTheme: View { @ViewBuilder let content: () -> Content diff --git a/iosApp/flare/UI/Route/Router.swift b/iosApp/flare/UI/Route/Router.swift index 450b05da38..dae1fe9fd7 100644 --- a/iosApp/flare/UI/Route/Router.swift +++ b/iosApp/flare/UI/Route/Router.swift @@ -2,6 +2,7 @@ import SwiftUI import KotlinSharedUI import LazyPager import Combine +import FlareUI struct Router: View { @Environment(\.openURL) private var openURL diff --git a/iosApp/flare/UI/Screen/AccountManagementScreen.swift b/iosApp/flare/UI/Screen/AccountManagementScreen.swift index e270bdb52d..bab6540baf 100644 --- a/iosApp/flare/UI/Screen/AccountManagementScreen.swift +++ b/iosApp/flare/UI/Screen/AccountManagementScreen.swift @@ -1,5 +1,6 @@ import SwiftUI import KotlinSharedUI +import FlareUI struct AccountManagementScreen: View { @StateObject private var presenter = KotlinPresenter(presenter: AccountManagementPresenter()) diff --git a/iosApp/flare/UI/Screen/AiConfigScreen.swift b/iosApp/flare/UI/Screen/AiConfigScreen.swift index b3bd3c37f2..449fc78e99 100644 --- a/iosApp/flare/UI/Screen/AiConfigScreen.swift +++ b/iosApp/flare/UI/Screen/AiConfigScreen.swift @@ -1,5 +1,6 @@ import SwiftUI import KotlinSharedUI +import FlareUI struct AiConfigScreen: View { @StateObject private var presenter = KotlinPresenter(presenter: AiConfigPresenter()) diff --git a/iosApp/flare/UI/Screen/AllFeedScreen.swift b/iosApp/flare/UI/Screen/AllFeedScreen.swift index e41722ec46..7c0f45e901 100644 --- a/iosApp/flare/UI/Screen/AllFeedScreen.swift +++ b/iosApp/flare/UI/Screen/AllFeedScreen.swift @@ -1,5 +1,6 @@ import SwiftUI @preconcurrency import KotlinSharedUI +import FlareUI struct AllFeedScreen: View { @StateObject private var presenter: KotlinPresenter diff --git a/iosApp/flare/UI/Screen/AllListScreen.swift b/iosApp/flare/UI/Screen/AllListScreen.swift index 988f341bf6..7cac958050 100644 --- a/iosApp/flare/UI/Screen/AllListScreen.swift +++ b/iosApp/flare/UI/Screen/AllListScreen.swift @@ -1,5 +1,6 @@ import SwiftUI @preconcurrency import KotlinSharedUI +import FlareUI struct AllListScreen: View { @StateObject private var presenter: KotlinPresenter diff --git a/iosApp/flare/UI/Screen/AntennasListScreen.swift b/iosApp/flare/UI/Screen/AntennasListScreen.swift index 6cda582742..77656313f5 100644 --- a/iosApp/flare/UI/Screen/AntennasListScreen.swift +++ b/iosApp/flare/UI/Screen/AntennasListScreen.swift @@ -1,5 +1,6 @@ import SwiftUI @preconcurrency import KotlinSharedUI +import FlareUI struct AntennasListScreen: View { let accountType: AccountType diff --git a/iosApp/flare/UI/Screen/AppLogScreen.swift b/iosApp/flare/UI/Screen/AppLogScreen.swift index fd521c9e95..79a6839ba3 100644 --- a/iosApp/flare/UI/Screen/AppLogScreen.swift +++ b/iosApp/flare/UI/Screen/AppLogScreen.swift @@ -1,5 +1,6 @@ import SwiftUI import KotlinSharedUI +import FlareUI struct AppLogScreen: View { @StateObject private var presenter = KotlinPresenter(presenter: DevModePresenter()) diff --git a/iosApp/flare/UI/Screen/AppearanceScreen.swift b/iosApp/flare/UI/Screen/AppearanceScreen.swift index 651dff04ac..0756291381 100644 --- a/iosApp/flare/UI/Screen/AppearanceScreen.swift +++ b/iosApp/flare/UI/Screen/AppearanceScreen.swift @@ -1,5 +1,6 @@ import SwiftUI import KotlinSharedUI +import FlareUI struct AppearanceScreen: View { @AppStorage("pref_timeline_use_compose_view") private var useComposeView: Bool = false diff --git a/iosApp/flare/UI/Screen/BlueskyReportSheet.swift b/iosApp/flare/UI/Screen/BlueskyReportSheet.swift index 25f53b4005..3cdfb04091 100644 --- a/iosApp/flare/UI/Screen/BlueskyReportSheet.swift +++ b/iosApp/flare/UI/Screen/BlueskyReportSheet.swift @@ -1,5 +1,6 @@ import SwiftUI import KotlinSharedUI +import FlareUI struct BlueskyReportSheet: View { @Environment(\.dismiss) private var dismiss diff --git a/iosApp/flare/UI/Screen/ChannelListScreen.swift b/iosApp/flare/UI/Screen/ChannelListScreen.swift index ff9dfc9eea..51b50de1d9 100644 --- a/iosApp/flare/UI/Screen/ChannelListScreen.swift +++ b/iosApp/flare/UI/Screen/ChannelListScreen.swift @@ -1,5 +1,6 @@ import SwiftUI @preconcurrency import KotlinSharedUI +import FlareUI struct ChannelListScreen: View { @StateObject private var presenter: KotlinPresenter diff --git a/iosApp/flare/UI/Screen/ComposeScreen.swift b/iosApp/flare/UI/Screen/ComposeScreen.swift index 8d43585d4d..d9495a955b 100644 --- a/iosApp/flare/UI/Screen/ComposeScreen.swift +++ b/iosApp/flare/UI/Screen/ComposeScreen.swift @@ -3,370 +3,30 @@ import KotlinSharedUI import PhotosUI import SwiftUIIntrospect import SwiftUIBackports +import FlareUI struct ComposeScreen: View { @Environment(\.dismiss) var dismiss let accountType: AccountType? let composeStatus: ComposeStatus? let draftGroupId: String? - @FocusState private var keyboardFocused: Bool - @FocusState private var cwKeyboardFocused: Bool @StateObject private var presenter: KotlinPresenter - @State private var viewModel = ComposeInputViewModel() - @State private var uiTextView: UITextView? - @State private var pendingCursor: Int? @State private var showDraftSheet = false - @State private var showDraftConfirmation = false var body: some View { - VStack( - spacing: 8 - ) { - accountSelectionView - ScrollView { - VStack( - spacing: 8 - ) { - if viewModel.enableCW { - TextField(text: $viewModel.contentWarning) { - Text("compose_cw_placeholder") - } - .textFieldStyle(.plain) - .focused($cwKeyboardFocused) - Divider() - } - - TextField(text: $viewModel.text, axis: .vertical) { - Text("compose_placeholder") - } - .introspect(.textField(axis: .vertical), on: .iOS(.v16, .v17, .v18, .v26)) { textField in - self.uiTextView = textField - applyCursorIfPossible() - } - .textFieldStyle(.plain) - .focused($keyboardFocused) - .onAppear { - keyboardFocused = true - } - Spacer() - if viewModel.mediaViewModel.items.count > 0 { - ScrollView(.horizontal) { - HStack { - ForEach(viewModel.mediaViewModel.items) { item in - ComposeMediaItemView(item: item, mediaViewModel: viewModel.mediaViewModel) - } - } - } - StateView(state: presenter.state.composeConfig) { config in - if let media = config.media, media.canSensitive { - Toggle(isOn: $viewModel.mediaViewModel.sensitive, label: { - Text("compose_media_mark_sensitive") - }) - } - } - } - if viewModel.pollViewModel.enabled { - HStack( - spacing: 8 - ) { - Picker("compose_poll_type", selection: $viewModel.pollViewModel.pollType) { - Text("compose_poll_type_single") - .tag(ComposePollType.single) - Text("compose_poll_type_multiple") - .tag(ComposePollType.multiple) - } - .pickerStyle(.segmented) - Button { - withAnimation { - viewModel.pollViewModel.add() - } - } label: { - Image("fa-plus") - }.disabled(viewModel.pollViewModel.choices.count >= 4) - } - ForEach($viewModel.pollViewModel.choices) { $choice in - HStack( - spacing: 8 - ) { - TextField(text: $choice.text) { - Text("compose_poll_choice_placeholder") - } - .textFieldStyle(.roundedBorder) - Button { - withAnimation { - viewModel.pollViewModel.remove(choice: choice) - } - } label: { - Image("fa-delete-left") - } - .disabled(viewModel.pollViewModel.choices.count <= 2) - } - } - HStack { - Spacer() - Menu { - ForEach(viewModel.pollViewModel.allExpiration, id: \.self) { expiration in - Button(action: { - viewModel.pollViewModel.expired = expiration - }, label: { - Text(expiration.rawValue) - }) - } - } label: { - Text("compose_poll_expiration") - Text(viewModel.pollViewModel.expired.rawValue) - } - } - } - if let replyState = presenter.state.replyState, - case .success(let reply) = onEnum(of: replyState), - let content = reply.data as? UiTimelineV2.Post { - StatusView(data: content, isQuote: true, showMedia: false, forceHideActions: true) - .padding() - .clipShape(.rect(cornerRadius: 16)) - .overlay( - RoundedRectangle(cornerRadius: 16) - .stroke(Color(.separator), lineWidth: 1) - ) - } - } - .padding(.horizontal) - } - .scrollIndicators(.hidden) - .safeAreaInset(edge: .bottom) { - HStack { - ScrollView(.horizontal) { - HStack( - spacing: 16, - ) { - if !viewModel.pollViewModel.enabled { - StateView(state: presenter.state.composeConfig) { config in - if config.media != nil { - PhotosPicker( - selection: Binding(get: { - viewModel.mediaViewModel.selectedItems - }, set: { value in - viewModel.mediaViewModel.selectedItems = value - viewModel.mediaViewModel.update() - }), - maxSelectionCount: viewModel.mediaViewModel.maxSize, - matching: .any(of: [.images, .videos, .livePhotos])) { - Image("fa-image") - } - } - } - } - if viewModel.mediaViewModel.selectedItems.count == 0 { - StateView(state: presenter.state.composeConfig) { config in - if config.poll != nil { - Button(action: { - withAnimation { - viewModel.togglePoll() - } - }, label: { - Image("fa-square-poll-horizontal") - }) - } - } - } - StateView(state: presenter.state.visibilityState) { visibilityState in - Menu { - ForEach(visibilityState.allVisibilities, id: \.self) { visibility in - Button { - visibilityState.setVisibility(value: visibility) - } label: { - Label { - Text(visibility.title) - } icon: { - StatusVisibilityView(data: visibility) - } - Text(visibility.desc) - } - } - } label: { - StatusVisibilityView(data: visibilityState.visibility) - } - } - StateView(state: presenter.state.composeConfig) { config in - if config.contentWarning != nil { - Button(action: { - withAnimation { - viewModel.toggleCW() - if viewModel.enableCW { - cwKeyboardFocused = true - } else { - keyboardFocused = true - } - } - }, label: { - Image("fa-circle-exclamation") - }) - } - } - StateView(state: presenter.state.emojiState) { emojis in - Button(action: { - viewModel.showEmojiPanel() - }, label: { - Image("fa-face-smile") - }) - .popover(isPresented: $viewModel.showEmoji) { - NavigationStack { - EmojiPopup(data: emojis) { item in - viewModel.addEmoji(emoji: item) - insert(item.insertText) - } - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button( - role: .cancel - ) { - viewModel.showEmoji = false - } label: { - Label { - Text("Cancel") - } icon: { - Image("fa-xmark") - } - } - } - } - } - } - } - StateView(state: presenter.state.composeConfig) { config in - if let languageConfig = config.language { - Menu { - ForEach(languageConfig.sortedIsoCodes, id: \.self) { code in - Button { - if languageConfig.maxCount > 1 { - if viewModel.languages.contains(code) { - if viewModel.languages.count > 1 { - viewModel.languages.removeAll { $0 == code } - } - } else { - if viewModel.languages.count < languageConfig.maxCount { - viewModel.languages.append(code) - } - } - } else { - viewModel.languages = [code] - } - } label: { - HStack { - Text(Locale.current.localizedString(forLanguageCode: code) ?? code) - if viewModel.languages.contains(code) { - Image(systemName: "checkmark") - } - } - } - } - } label: { - if let first = viewModel.languages.first, viewModel.languages.count == 1 { - Text(first.uppercased()) - .font(.caption) - .bold() - .padding(4) - .overlay( - RoundedRectangle(cornerRadius: 4) - .stroke(Color.primary, lineWidth: 1) - ) - } else { - Image(systemName: "globe") - } - } - } - } - } - .font(.title) - .buttonStyle(.plain) - } - .scrollIndicators(.hidden) - Spacer() - StateView(state: presenter.state.composeConfig) { config in - if let maxLength = config.text?.maxLength { - Text("\(viewModel.text.count)/\(maxLength)") - .foregroundStyle(viewModel.text.count > maxLength ? .red : .gray) - } - } - } - .padding() - .backport - .glassEffect(.regular, in: .capsule, fallbackBackground: .regularMaterial) + ComposeContent(composeStatus: composeStatus, state: presenter.state, dismiss: { dismiss() }) { item in + StatusView(data: item, isQuote: true, showMedia: false, forceHideActions: true) .padding() - } - - } - .onChange(of: presenter.state.initialTextState) { oldValue, newValue in - if case .success(let initialText) = onEnum(of: newValue) { - viewModel.text = initialText.data.text - pendingCursor = Int(initialText.data.cursorPosition) - applyCursorIfPossible() - } - } - .onSuccessOf(of: presenter.state.composeConfig) { config in - if let media = config.media { - viewModel.mediaViewModel.maxSize = Int(media.maxCount) - viewModel.mediaViewModel.enableAltText = media.altTextMaxLength > 0 - viewModel.mediaViewModel.altTextMaxLength = Int(media.altTextMaxLength) - } - } - .onChange(of: presenter.state.loadedDraftState) { _, newValue in - guard let newValue, case .success(let loadedDraft) = onEnum(of: newValue) else { return } - applyDraft(loadedDraft.data) - presenter.state.consumeLoadedDraft() - } - .onChange(of: viewModel.text) { oldValue, newValue in - presenter.state.setText(value: newValue) - } - .onChange(of: viewModel.mediaViewModel.items.count) { _, newValue in - presenter.state.setMediaSize(value: Int32(newValue)) + .clipShape(.rect(cornerRadius: 16)) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Color(.separator), lineWidth: 1) + ) } .sheet(isPresented: $showDraftSheet) { draftSheet } - .toolbarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .principal) { - switch onEnum(of: presenter.state.composeStatus) { - case .none: - Text("compose_title_new") - case .quote: - Text("compose_title_quote") - case .reply: - Text("compose_title_reply") - } - } - ToolbarItem(placement: .cancellationAction) { - Button { - if hasDraftContent { - showDraftConfirmation = true - } else { - dismiss() - } - } label: { - Label { - Text("compose_button_cancel") - } icon: { - Image(systemName: "xmark") - } - } - .confirmationDialog("Draft", isPresented: $showDraftConfirmation, titleVisibility: .visible) { - if #available(iOS 26.0, *) { - Button("Save Draft", role: .confirm) { - saveDraft(shouldDismiss: true) - } - } else { - Button("Save Draft") { - saveDraft(shouldDismiss: true) - } - } - Button("Cancel", role: .cancel) { - dismiss() - } - } message: { - Text("Save your current content as a draft before leaving?") - } - } if presenter.state.showDraft { ToolbarItem(placement: .topBarTrailing) { Button { @@ -376,187 +36,6 @@ struct ComposeScreen: View { } } } - ToolbarItem(placement: .confirmationAction) { - Button { - send() - } label: { - Label { - Text("compose_button_send") - } icon: { - Image(systemName: "paperplane.fill") - } - } - .disabled(!presenter.state.canSend) - } - } - } - - private var accountSelectionView: some View { - ScrollView(.horizontal) { - HStack { - StateView(state: presenter.state.selectedUsers) { users in - let items = (users as NSArray).cast(UiState.self) - ForEach(Array(items.enumerated()), id: \.offset) { _, userState in - StateView(state: userState) { user in - Label { - Text(user.handle.canonical) - } icon: { - AvatarView(data: user.avatar) - .scaledToFit() - .frame(width: 20, height: 20) - } - .padding(.horizontal) - .padding(.vertical, 8) - .background(Color(.secondarySystemBackground)) - .clipShape(.rect(cornerRadius: 16)) - .onTapGesture { - presenter.state.selectAccount(accountKey: user.key) - } - } - } - } - StateView(state: presenter.state.otherUsers) { others in - let items = (others as NSArray).cast(UiState.self) - if !items.isEmpty { - Menu { - ForEach(Array(items.enumerated()), id: \.offset) { _, userState in - StateView(state: userState) { user in - Button { - presenter.state.selectAccount(accountKey: user.key) - } label: { - Label { - Text(user.handle.canonical) - } icon: { - AvatarView(data: user.avatar) - .scaledToFit() - .frame(maxWidth: 20, maxHeight: 20) - } - } - } - } - } label: { - Image("fa-plus") - } - } - } - } - .padding(.horizontal) - } - .scrollIndicators(.hidden) - } - - private func applyCursorIfPossible() { - guard let textView = uiTextView, textView.isFirstResponder, let pendingCursor else { return } - let length = textView.text.count - let clamped = NSRange(location: max(0, min(pendingCursor, length)), - length: 0) - textView.selectedRange = clamped - textView.scrollRangeToVisible(clamped) - self.pendingCursor = nil - } - - private func insert(_ s: String) { - guard let textView = uiTextView else { return } - - if textView.markedTextRange != nil { return } - - let sel = textView.selectedRange - let current = textView.text ?? "" - let ns = current as NSString - let newText = ns.replacingCharacters(in: sel, with: s) - - textView.text = newText - viewModel.text = newText - - let newLocation = sel.location + (s as NSString).length - textView.selectedRange = NSRange(location: newLocation, length: 0) - textView.scrollRangeToVisible(NSRange(location: max(0, newLocation - 1), length: 1)) - } - - private func getComposeData() -> ComposeData { - ComposeData( - content: viewModel.text, - visibility: getVisibility(), - language: viewModel.languages, - medias: getMedia(), - sensitive: viewModel.mediaViewModel.sensitive, - spoilerText: viewModel.contentWarning, - poll: getPoll(), - localOnly: false, - referenceStatus: getReferenceStatus() - ) - } - - private var hasDraftContent: Bool { - !viewModel.text.isEmpty || - !viewModel.contentWarning.isEmpty || - !viewModel.mediaViewModel.items.isEmpty || - viewModel.pollViewModel.enabled - } - - private func send() { - let data = getComposeData() - presenter.state.send(data: data) { dispatched in - if dispatched.boolValue { - dismiss() - } - } - } - - private func saveDraft(shouldDismiss: Bool = false) { - presenter.state.saveDraft(data: getComposeData()) { dispatched in - guard dispatched.boolValue else { return } - if shouldDismiss { - dismiss() - } - } - } - - private func applyDraft(_ draft: UiDraft) { - let data = draft.data - viewModel.text = data.content - viewModel.contentWarning = data.spoilerText ?? "" - viewModel.enableCW = !(data.spoilerText ?? "").isEmpty - if !data.language.isEmpty { - viewModel.languages = data.language - } - viewModel.mediaViewModel.sensitive = data.sensitive - viewModel.mediaViewModel.restore(draftMedias: draft.medias) - viewModel.apply(poll: data.poll) - if case .success(let visibilityState) = onEnum(of: presenter.state.visibilityState) { - visibilityState.data.setVisibility(value: data.visibility) - } - } - - private func getMedia() -> [ComposeData.Media] { - return viewModel.mediaViewModel.items.compactMap { item in - guard let data = item.data else { - return nil - } - return .init( - file: .init(name: item.fileName, data: KotlinByteArray.from(data: data), type: item.type), - altText: item.altText.isEmpty ? nil : item.altText - ) - } - } - private func getReferenceStatus() -> ComposeData.ReferenceStatus? { - return if let data = presenter.state.composeStatus { - ComposeData.ReferenceStatus(composeStatus: data) - } else { - nil - } - } - private func getPoll() -> ComposeData.Poll? { - return if viewModel.pollViewModel.enabled { - ComposeData.Poll(options: viewModel.pollViewModel.choices.map { item in item.text }, expiredAfter: viewModel.pollViewModel.expired.inWholeMilliseconds, multiple: viewModel.pollViewModel.pollType == ComposePollType.multiple) - } else { - nil - } - } - private func getVisibility() -> UiTimelineV2.PostVisibility { - switch onEnum(of: presenter.state.visibilityState) { - case .success(let success): return success.data.visibility - default: return .public } } @@ -569,283 +48,8 @@ struct ComposeScreen: View { } .presentationDetents([.medium, .large]) } - -} - -@Observable -class ComposeInputViewModel { - var text: String = "" - var contentWarning: String = "" - var enableCW = false - var showEmoji = false - var pollViewModel = PollViewModel() - var mediaViewModel = MediaViewModel() -// var visibility: UiTimeline.ItemContentStatusTopEndContentVisibilityType = .public - var languages: [String] = { - if let code = Locale.current.language.languageCode?.identifier { - return [code] - } - return ["en"] - }() - - - func showEmojiPanel() { - showEmoji = true - } - func toggleCW() { - enableCW = !enableCW - } - func togglePoll() { - if pollViewModel.enabled { - pollViewModel = PollViewModel() - } else { - pollViewModel.enabled = true - } - } - func apply(poll: ComposeData.Poll?) { - guard let poll else { - pollViewModel = PollViewModel() - return - } - - pollViewModel.enabled = true - pollViewModel.pollType = poll.multiple ? .multiple : .single - pollViewModel.expired = ComposePollExpired(milliseconds: poll.expiredAfter) ?? .minutes5 - pollViewModel.choices = poll.options.map(PollChoice.init) - while pollViewModel.choices.count < 2 { - pollViewModel.choices.append(PollChoice()) - } - } - func addEmoji(emoji: UiEmoji) { -// text += " :" + emoji.shortcode + ": " - showEmoji = false - } -} - - -@Observable -class MediaViewModel { - var selectedItems: [PhotosPickerItem] = [] - var items: [MediaItem] = [] - var sensitive = false - var maxSize = 4 - var enableAltText = true - var altTextMaxLength = 500 - func update() { - if selectedItems.count > maxSize { - selectedItems = Array(selectedItems[(selectedItems.count - 4)...(selectedItems.count - 1)]) - } else { - selectedItems = selectedItems - } - items = selectedItems.map { item in - MediaItem(item: item) - } - } - func restore(draftMedias: [UiDraftMedia]) { - selectedItems = [] - items = draftMedias.compactMap(MediaItem.init(draftMedia:)) - } - func remove(item: MediaItem) { - if let index = items.firstIndex(of: item) { - items.remove(at: index) - if selectedItems.indices.contains(index) { - selectedItems.remove(at: index) - } - } - } -} - -@Observable -class MediaItem: Equatable, Identifiable { - static func == (lhs: MediaItem, rhs: MediaItem) -> Bool { - lhs.id == rhs.id - } - let item: PhotosPickerItem? - var image: UIImage? - var data: Data? - var altText: String = "" - let id: String - let fileName: String - var type: FileType = .other - - init(item: PhotosPickerItem) { - self.item = item - self.id = item.itemIdentifier ?? UUID().uuidString - self.fileName = item.itemIdentifier ?? UUID().uuidString - - if let contentType = item.supportedContentTypes.first { - if contentType.conforms(to: .image) { - self.type = .image - } else if contentType.conforms(to: .movie) { - self.type = .video - } - } - - item.loadTransferable(type: Data.self) { result in - do { - if let data = try result.get() { - if let uiImage = UIImage(data: data) { - DispatchQueue.main.async { - self.data = data - self.image = uiImage - } - } - } - } catch { - } - } - } - - init?(draftMedia: UiDraftMedia) { - let fileURL = URL(fileURLWithPath: draftMedia.cachePath) - guard let data = try? Data(contentsOf: fileURL) else { - return nil - } - - self.item = nil - self.id = draftMedia.cachePath - self.fileName = draftMedia.fileName ?? fileURL.lastPathComponent - self.data = data - self.altText = draftMedia.altText ?? "" - - switch draftMedia.type { - case .image: - self.type = .image - self.image = UIImage(data: data) - case .video: - self.type = .video - default: - self.type = .other - } - } -} - -@Observable -class PollViewModel { - var enabled = false - var pollType = ComposePollType.single - var choices: [PollChoice] = [PollChoice(), PollChoice()] - var expired = ComposePollExpired.minutes5 - let allExpiration = [ - ComposePollExpired.minutes5, - ComposePollExpired.minutes30, - ComposePollExpired.hours1, - ComposePollExpired.hours6, - ComposePollExpired.hours12, - ComposePollExpired.days1, - ComposePollExpired.days3, - ComposePollExpired.days7 - ] - func add() { - if choices.count < 4 { - choices.append(PollChoice()) - } - } - func remove(choice: PollChoice) { - if choices.count > 2 { - choices.removeAll { value in - value.id == choice.id - } - } - } } -@Observable -class PollChoice: Identifiable { - var text = "" - - init(text: String = "") { - self.text = text - } -} - -enum ComposePollType { - case single - case multiple -} - -enum ComposePollExpired: String { - case minutes5 - case minutes30 - case hours1 - case hours6 - case hours12 - case days1 - case days3 - case days7 - init?(milliseconds: Int64) { - switch milliseconds { - case 5 * 60 * 1000: - self = .minutes5 - case 30 * 60 * 1000: - self = .minutes30 - case 1 * 60 * 60 * 1000: - self = .hours1 - case 6 * 60 * 60 * 1000: - self = .hours6 - case 12 * 60 * 60 * 1000: - self = .hours12 - case 24 * 60 * 60 * 1000: - self = .days1 - case 3 * 24 * 60 * 60 * 1000: - self = .days3 - case 7 * 24 * 60 * 60 * 1000: - self = .days7 - default: - return nil - } - } - var inWholeMilliseconds: Int64 { - switch self { - case .minutes5: - return 5 * 60 * 1000 - case .minutes30: - return 30 * 60 * 1000 - case .hours1: - return 1 * 60 * 60 * 1000 - case .hours6: - return 6 * 60 * 60 * 1000 - case .hours12: - return 12 * 60 * 60 * 1000 - case .days1: - return 24 * 60 * 60 * 1000 - case .days3: - return 3 * 24 * 60 * 60 * 1000 - case .days7: - return 7 * 24 * 60 * 60 * 1000 - } - } -} - -extension UiDraftStatus { - fileprivate var title: String { - switch self { - case .draft: - return "Draft" - case .failed: - return "Failed" - case .sending: - return "Sending" - default: - return "Draft" - } - } - - fileprivate var tint: Color { - switch self { - case .draft: - return .secondary - case .failed: - return .red - case .sending: - return .orange - default: - return .secondary - } - } -} - - extension ComposeScreen { init(accountType: AccountType?, composeStatus: ComposeStatus? = nil, draftGroupId: String? = nil) { self.accountType = accountType @@ -854,92 +58,3 @@ extension ComposeScreen { self._presenter = .init(wrappedValue: .init(presenter: ComposePresenter(accountType: accountType, status: composeStatus, draftGroupId: draftGroupId))) } } - -struct ComposeMediaItemView: View { - let item: MediaItem - var mediaViewModel: MediaViewModel - @State private var showAltTextEditor = false - - var body: some View { - if let image = item.image { - Image(uiImage: image) - .resizable() - .scaledToFill() - .frame(width: 128, height: 128) - .cornerRadius(8) - .overlay(alignment: .bottomLeading) { - if mediaViewModel.enableAltText && !item.altText.isEmpty { - Text("ALT") - .font(.caption2) - .bold() - .foregroundStyle(.black) - .padding(.horizontal, 4) - .padding(.vertical, 2) - .background(.white.opacity(0.8)) - .clipShape(RoundedRectangle(cornerRadius: 4)) - .padding(4) - } - } - .onTapGesture { - if mediaViewModel.enableAltText { - showAltTextEditor = true - } - } - .contextMenu { - Button(action: { - withAnimation { - mediaViewModel.remove(item: item) - } - }, label: { - Label { - Text("delete") - } icon: { - Image("fa-trash") - } - }) - - if mediaViewModel.enableAltText { - Button { - showAltTextEditor = true - } label: { - Label("Edit Description", systemImage: "pencil") - } - } - } - .sheet(isPresented: $showAltTextEditor) { - AltTextEditSheet(item: item, maxLength: mediaViewModel.altTextMaxLength) - } - } - } -} - -extension UiTimelineV2.PostVisibility { - var title: LocalizedStringResource { - switch self { - case .public: - return LocalizedStringResource("status_visibility_public") - case .home: - return LocalizedStringResource("status_visibility_home") - case .followers: - return LocalizedStringResource("status_visibility_followers") - case .specified: - return LocalizedStringResource("status_visibility_specified") - case .channel: - return LocalizedStringResource("status_visibility_public") - } - } - var desc: LocalizedStringResource { - switch self { - case .public: - return LocalizedStringResource("status_visibility_public_description") - case .home: - return LocalizedStringResource("status_visibility_home_description") - case .followers: - return LocalizedStringResource("status_visibility_followers_description") - case .specified: - return LocalizedStringResource("status_visibility_specified_description") - case .channel: - return LocalizedStringResource("status_visibility_public_description") - } - } -} diff --git a/iosApp/flare/UI/Screen/CreateListScreen.swift b/iosApp/flare/UI/Screen/CreateListScreen.swift index 6328714aad..a58ce698b0 100644 --- a/iosApp/flare/UI/Screen/CreateListScreen.swift +++ b/iosApp/flare/UI/Screen/CreateListScreen.swift @@ -1,6 +1,7 @@ import SwiftUI @preconcurrency import KotlinSharedUI import PhotosUI +import FlareUI struct CreateListScreen: View { @Environment(\.dismiss) private var dismiss diff --git a/iosApp/flare/UI/Screen/DMListScreen.swift b/iosApp/flare/UI/Screen/DMListScreen.swift index 0be3668d95..fbd2a466cb 100644 --- a/iosApp/flare/UI/Screen/DMListScreen.swift +++ b/iosApp/flare/UI/Screen/DMListScreen.swift @@ -1,6 +1,7 @@ import SwiftUI import SwiftUIBackports @preconcurrency import KotlinSharedUI +import FlareUI struct DMListScreen: View { let accountType: AccountType diff --git a/iosApp/flare/UI/Screen/DeepLinkAccountPicker.swift b/iosApp/flare/UI/Screen/DeepLinkAccountPicker.swift index 3bec2d45f4..b40067d258 100644 --- a/iosApp/flare/UI/Screen/DeepLinkAccountPicker.swift +++ b/iosApp/flare/UI/Screen/DeepLinkAccountPicker.swift @@ -1,6 +1,7 @@ import SwiftUI import KotlinSharedUI import SwiftUIBackports +import FlareUI struct DeepLinkAccountPicker: View { @Environment(\.dismiss) private var dismiss diff --git a/iosApp/flare/UI/Screen/DiscoverScreen.swift b/iosApp/flare/UI/Screen/DiscoverScreen.swift index 23a57f95d2..3c3ad6734e 100644 --- a/iosApp/flare/UI/Screen/DiscoverScreen.swift +++ b/iosApp/flare/UI/Screen/DiscoverScreen.swift @@ -1,6 +1,7 @@ import SwiftUI @preconcurrency import KotlinSharedUI import Flow +import FlareUI struct DiscoverScreen: View { @Environment(\.openURL) private var openURL diff --git a/iosApp/flare/UI/Screen/DraftBoxScreen.swift b/iosApp/flare/UI/Screen/DraftBoxScreen.swift index 020d32baad..acf89af2ce 100644 --- a/iosApp/flare/UI/Screen/DraftBoxScreen.swift +++ b/iosApp/flare/UI/Screen/DraftBoxScreen.swift @@ -1,5 +1,6 @@ import SwiftUI import KotlinSharedUI +import FlareUI struct DraftBoxScreen: View { let onEditDraft: (String) -> Void diff --git a/iosApp/flare/UI/Screen/EditListMemberScreen.swift b/iosApp/flare/UI/Screen/EditListMemberScreen.swift index 43c98050e2..fe439c0d37 100644 --- a/iosApp/flare/UI/Screen/EditListMemberScreen.swift +++ b/iosApp/flare/UI/Screen/EditListMemberScreen.swift @@ -1,5 +1,6 @@ import SwiftUI import KotlinSharedUI +import FlareUI struct EditListMemberScreen: View { @Environment(\.dismiss) var dismiss diff --git a/iosApp/flare/UI/Screen/EditListScreen.swift b/iosApp/flare/UI/Screen/EditListScreen.swift index 12a4dd8d7b..f1d2dd04f1 100644 --- a/iosApp/flare/UI/Screen/EditListScreen.swift +++ b/iosApp/flare/UI/Screen/EditListScreen.swift @@ -1,6 +1,7 @@ import SwiftUI @preconcurrency import KotlinSharedUI import PhotosUI +import FlareUI struct EditListScreen: View { @Environment(\.dismiss) var dismiss diff --git a/iosApp/flare/UI/Screen/EditUserInListScreen.swift b/iosApp/flare/UI/Screen/EditUserInListScreen.swift index f6d0cacb82..df62e2ac08 100644 --- a/iosApp/flare/UI/Screen/EditUserInListScreen.swift +++ b/iosApp/flare/UI/Screen/EditUserInListScreen.swift @@ -1,5 +1,6 @@ import SwiftUI import KotlinSharedUI +import FlareUI struct EditUserInListScreen: View { @Environment(\.dismiss) private var dismiss diff --git a/iosApp/flare/UI/Screen/GroupConfigScreen.swift b/iosApp/flare/UI/Screen/GroupConfigScreen.swift index c7775490b1..324e19808d 100644 --- a/iosApp/flare/UI/Screen/GroupConfigScreen.swift +++ b/iosApp/flare/UI/Screen/GroupConfigScreen.swift @@ -1,6 +1,7 @@ import SwiftUI import KotlinSharedUI import SwiftUIBackports +import FlareUI struct GroupConfigScreen: View { @Environment(\.dismiss) private var dismiss diff --git a/iosApp/flare/UI/Screen/HomeTimelineScreen.swift b/iosApp/flare/UI/Screen/HomeTimelineScreen.swift index 89c21a7fca..710c270566 100644 --- a/iosApp/flare/UI/Screen/HomeTimelineScreen.swift +++ b/iosApp/flare/UI/Screen/HomeTimelineScreen.swift @@ -1,6 +1,7 @@ import SwiftUI @preconcurrency import KotlinSharedUI import SwiftUIBackports +import FlareUI struct HomeTimelineScreen: View { let toServiceSelect: () -> Void diff --git a/iosApp/flare/UI/Screen/ImportOPMLScreen.swift b/iosApp/flare/UI/Screen/ImportOPMLScreen.swift index 0c287f471b..b0592e3df8 100644 --- a/iosApp/flare/UI/Screen/ImportOPMLScreen.swift +++ b/iosApp/flare/UI/Screen/ImportOPMLScreen.swift @@ -1,5 +1,6 @@ import SwiftUI import KotlinSharedUI +import FlareUI struct ImportOPMLScreen: View { @Environment(\.dismiss) private var dismiss diff --git a/iosApp/flare/UI/Screen/LocalFilterScreen.swift b/iosApp/flare/UI/Screen/LocalFilterScreen.swift index 539a406a77..ecc04c1928 100644 --- a/iosApp/flare/UI/Screen/LocalFilterScreen.swift +++ b/iosApp/flare/UI/Screen/LocalFilterScreen.swift @@ -1,5 +1,6 @@ import SwiftUI import KotlinSharedUI +import FlareUI struct LocalFilterScreen: View { @StateObject private var presenter = KotlinPresenter(presenter: LocalFilterPresenter()) diff --git a/iosApp/flare/UI/Screen/LocalHistoryScreen.swift b/iosApp/flare/UI/Screen/LocalHistoryScreen.swift index 764e1001e1..e14126183e 100644 --- a/iosApp/flare/UI/Screen/LocalHistoryScreen.swift +++ b/iosApp/flare/UI/Screen/LocalHistoryScreen.swift @@ -1,5 +1,6 @@ import SwiftUI import KotlinSharedUI +import FlareUI struct LocalHistoryScreen: View { @StateObject private var presenter = KotlinPresenter(presenter: LocalCacheSearchPresenter()) diff --git a/iosApp/flare/UI/Screen/MisskeyReportSheet.swift b/iosApp/flare/UI/Screen/MisskeyReportSheet.swift index 9550d43474..2911cd54eb 100644 --- a/iosApp/flare/UI/Screen/MisskeyReportSheet.swift +++ b/iosApp/flare/UI/Screen/MisskeyReportSheet.swift @@ -1,5 +1,6 @@ import SwiftUI import KotlinSharedUI +import FlareUI struct MisskeyReportSheet: View { @Environment(\.dismiss) private var dismiss diff --git a/iosApp/flare/UI/Screen/NostrRelaysScreen.swift b/iosApp/flare/UI/Screen/NostrRelaysScreen.swift index 77660a61d9..50481c0713 100644 --- a/iosApp/flare/UI/Screen/NostrRelaysScreen.swift +++ b/iosApp/flare/UI/Screen/NostrRelaysScreen.swift @@ -1,5 +1,6 @@ import SwiftUI @preconcurrency import KotlinSharedUI +import FlareUI struct NostrRelaysScreen: View { let accountKey: MicroBlogKey diff --git a/iosApp/flare/UI/Screen/NotificationScreen.swift b/iosApp/flare/UI/Screen/NotificationScreen.swift index 4f4e33c1f3..dcf46bd8d2 100644 --- a/iosApp/flare/UI/Screen/NotificationScreen.swift +++ b/iosApp/flare/UI/Screen/NotificationScreen.swift @@ -1,6 +1,7 @@ import SwiftUI import SwiftUIBackports @preconcurrency import KotlinSharedUI +import FlareUI struct NotificationScreen: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass diff --git a/iosApp/flare/UI/Screen/ProfileScreen.swift b/iosApp/flare/UI/Screen/ProfileScreen.swift index 255966a977..56287f687e 100644 --- a/iosApp/flare/UI/Screen/ProfileScreen.swift +++ b/iosApp/flare/UI/Screen/ProfileScreen.swift @@ -1,6 +1,7 @@ import SwiftUI import SwiftUIBackports @preconcurrency import KotlinSharedUI +import FlareUI struct ProfileScreen: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass diff --git a/iosApp/flare/UI/Screen/RssDetailScreen.swift b/iosApp/flare/UI/Screen/RssDetailScreen.swift index ea001321d8..6e9d8b0903 100644 --- a/iosApp/flare/UI/Screen/RssDetailScreen.swift +++ b/iosApp/flare/UI/Screen/RssDetailScreen.swift @@ -3,6 +3,7 @@ import KotlinSharedUI import SafariServices import SwiftUIBackports import WebKit +import FlareUI struct RssDetailScreen: View { @State private var webViewHeight: CGFloat = .zero diff --git a/iosApp/flare/UI/Screen/RssScreen.swift b/iosApp/flare/UI/Screen/RssScreen.swift index 0c31f9736f..56604ea792 100644 --- a/iosApp/flare/UI/Screen/RssScreen.swift +++ b/iosApp/flare/UI/Screen/RssScreen.swift @@ -1,6 +1,7 @@ import SwiftUI import KotlinSharedUI import UniformTypeIdentifiers +import FlareUI struct RssScreen: View { @StateObject private var presenter = KotlinPresenter(presenter: RssListWithTabsPresenter()) diff --git a/iosApp/flare/UI/Screen/SearchScreen.swift b/iosApp/flare/UI/Screen/SearchScreen.swift index 9f212c25c1..11ef3a5f54 100644 --- a/iosApp/flare/UI/Screen/SearchScreen.swift +++ b/iosApp/flare/UI/Screen/SearchScreen.swift @@ -1,5 +1,6 @@ import SwiftUI @preconcurrency import KotlinSharedUI +import FlareUI struct SearchScreen: View { @Environment(\.openURL) private var openURL diff --git a/iosApp/flare/UI/Screen/SecondaryTabsScreen.swift b/iosApp/flare/UI/Screen/SecondaryTabsScreen.swift index 8dcc7bf243..e5f603039b 100644 --- a/iosApp/flare/UI/Screen/SecondaryTabsScreen.swift +++ b/iosApp/flare/UI/Screen/SecondaryTabsScreen.swift @@ -1,5 +1,6 @@ import SwiftUI import KotlinSharedUI +import FlareUI struct SecondaryTabsScreen: View { @Environment(\.dismiss) private var dismiss diff --git a/iosApp/flare/UI/Screen/ServiceSelectionScreen.swift b/iosApp/flare/UI/Screen/ServiceSelectionScreen.swift index d723759a7b..ef182be79c 100644 --- a/iosApp/flare/UI/Screen/ServiceSelectionScreen.swift +++ b/iosApp/flare/UI/Screen/ServiceSelectionScreen.swift @@ -2,6 +2,7 @@ import SwiftUI import KotlinSharedUI import AuthenticationServices import WebKit +import FlareUI struct ServiceSelectionScreen : View { @Environment(\.webAuthenticationSession) private var webAuthenticationSession diff --git a/iosApp/flare/UI/Screen/StatusAddReactionSheet.swift b/iosApp/flare/UI/Screen/StatusAddReactionSheet.swift index 356b3f4501..80233b8f0f 100644 --- a/iosApp/flare/UI/Screen/StatusAddReactionSheet.swift +++ b/iosApp/flare/UI/Screen/StatusAddReactionSheet.swift @@ -1,5 +1,6 @@ import SwiftUI import KotlinSharedUI +import FlareUI struct StatusAddReactionSheet: View { let accountType: AccountType diff --git a/iosApp/flare/UI/Screen/StatusDetailScreen.swift b/iosApp/flare/UI/Screen/StatusDetailScreen.swift index ef03ba5d78..ec41bfec7b 100644 --- a/iosApp/flare/UI/Screen/StatusDetailScreen.swift +++ b/iosApp/flare/UI/Screen/StatusDetailScreen.swift @@ -1,5 +1,6 @@ import SwiftUI @preconcurrency import KotlinSharedUI +import FlareUI struct StatusDetailScreen: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass diff --git a/iosApp/flare/UI/Screen/StatusMediaScreen.swift b/iosApp/flare/UI/Screen/StatusMediaScreen.swift index f05111d4ef..f8cd1108dd 100644 --- a/iosApp/flare/UI/Screen/StatusMediaScreen.swift +++ b/iosApp/flare/UI/Screen/StatusMediaScreen.swift @@ -6,6 +6,7 @@ import Photos import Kingfisher import SwiftUIBackports import VideoPlayer +import FlareUI struct StatusMediaScreen: View { @Environment(\.appearanceSettings) private var appearanceSettings diff --git a/iosApp/flare/UI/Screen/StorageScreen.swift b/iosApp/flare/UI/Screen/StorageScreen.swift index 73a25c8415..8c6caacb7f 100644 --- a/iosApp/flare/UI/Screen/StorageScreen.swift +++ b/iosApp/flare/UI/Screen/StorageScreen.swift @@ -3,6 +3,7 @@ import SwiftUI import Kingfisher import UniformTypeIdentifiers import Drops +import FlareUI struct StorageScreen: View { private let storagePresenter: StoragePresenter diff --git a/iosApp/flare/UI/Screen/TabSettingsScreen.swift b/iosApp/flare/UI/Screen/TabSettingsScreen.swift index 859d3b08eb..1c80949eda 100644 --- a/iosApp/flare/UI/Screen/TabSettingsScreen.swift +++ b/iosApp/flare/UI/Screen/TabSettingsScreen.swift @@ -1,5 +1,6 @@ import SwiftUI import KotlinSharedUI +import FlareUI struct TabSettingsScreen: View { @StateObject private var presenter = KotlinPresenter(presenter: SettingsPresenter()) @Environment(\.dismiss) private var dismiss diff --git a/iosApp/flare/UI/Screen/TimelineScreen.swift b/iosApp/flare/UI/Screen/TimelineScreen.swift index ae2c841bee..e93841a695 100644 --- a/iosApp/flare/UI/Screen/TimelineScreen.swift +++ b/iosApp/flare/UI/Screen/TimelineScreen.swift @@ -1,5 +1,6 @@ import SwiftUI @preconcurrency import KotlinSharedUI +import FlareUI struct TimelineScreen: View { let tabItem: TimelineTabItem diff --git a/iosApp/flare/UI/Screen/TwitterArticleScreen.swift b/iosApp/flare/UI/Screen/TwitterArticleScreen.swift index 116ff56c43..66ec0b2e18 100644 --- a/iosApp/flare/UI/Screen/TwitterArticleScreen.swift +++ b/iosApp/flare/UI/Screen/TwitterArticleScreen.swift @@ -1,5 +1,6 @@ import SwiftUI import KotlinSharedUI +import FlareUI struct TwitterArticleScreen: View { @StateObject private var presenter: KotlinPresenter diff --git a/iosApp/flare/UI/Screen/UserListScreen.swift b/iosApp/flare/UI/Screen/UserListScreen.swift index 9e7aafa69a..5c6b0df105 100644 --- a/iosApp/flare/UI/Screen/UserListScreen.swift +++ b/iosApp/flare/UI/Screen/UserListScreen.swift @@ -1,5 +1,6 @@ import SwiftUI @preconcurrency import KotlinSharedUI +import FlareUI struct UserListScreen: View { @Environment(\.openURL) private var openURL diff --git a/iosApp/flare/UI/Screen/VVOStatusScreen.swift b/iosApp/flare/UI/Screen/VVOStatusScreen.swift index 53e9e7d099..80b62c3151 100644 --- a/iosApp/flare/UI/Screen/VVOStatusScreen.swift +++ b/iosApp/flare/UI/Screen/VVOStatusScreen.swift @@ -1,5 +1,6 @@ import SwiftUI import KotlinSharedUI +import FlareUI struct VVOStatusScreen: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass diff --git a/shared/src/appleMain/kotlin/dev/dimension/flare/data/database/DriverFactory.apple.kt b/shared/src/appleMain/kotlin/dev/dimension/flare/data/database/DriverFactory.apple.kt index 07a84193e4..08b64a4067 100644 --- a/shared/src/appleMain/kotlin/dev/dimension/flare/data/database/DriverFactory.apple.kt +++ b/shared/src/appleMain/kotlin/dev/dimension/flare/data/database/DriverFactory.apple.kt @@ -6,48 +6,200 @@ import kotlinx.cinterop.ExperimentalForeignApi import platform.Foundation.NSApplicationSupportDirectory import platform.Foundation.NSFileManager import platform.Foundation.NSSearchPathForDirectoriesInDomains +import platform.Foundation.NSURL +import platform.Foundation.NSUserDefaults import platform.Foundation.NSUserDomainMask -import platform.Foundation.stringWithString + +internal const val APP_GROUP_ID = "group.dev.dimension.flare" + +private const val MIGRATION_STATE_NONE = "none" +private const val MIGRATION_STATE_COPYING = "copying" +private const val MIGRATION_STATE_COPIED = "copied" +private const val MIGRATION_STATE_FINISHED = "finished" internal actual class DriverFactory { actual inline fun createBuilder( name: String, isCache: Boolean, ): RoomDatabase.Builder { - val dbFilePath = databaseDirPath() + "/$name" + val dbFilePath = + if (isCache) { + "${legacyDatabaseDirPath()}/$name" + } else { + "${sharedDatabaseDirPath()}/$name" + } + return Room.databaseBuilder( name = dbFilePath, ) } + internal fun legacyDatabaseDirPath(): String = iosAppSupportDirPath("databases") + @OptIn(ExperimentalForeignApi::class) - actual fun deleteDatabase( - name: String, - isCache: Boolean, - ) { - val dbFilePath = databaseDirPath() + "/$name" - val dbFile = platform.Foundation.NSString.stringWithString(dbFilePath) - val fileManager = NSFileManager.defaultManager() - if (fileManager.fileExistsAtPath(dbFile)) { - fileManager.removeItemAtPath(dbFile, null) + internal fun sharedDatabaseDirPath(): String = + appGroupDirPath( + groupId = APP_GROUP_ID, + folder = "databases", + ) +} + +public fun migrateDatabase() { + prepareRoomMigrationIfNeeded(APP_DATABASE_NAME) + finalizeRoomMigrationIfNeeded(APP_DATABASE_NAME) +} + +@OptIn(ExperimentalForeignApi::class) +private fun prepareRoomMigrationIfNeeded(name: String) { + val state = getMigrationState(name) + val oldBasePath = "${iosAppSupportDirPath("databases")}/$name" + val newBasePath = "${appGroupDirPath(APP_GROUP_ID, "databases")}/$name" + + val oldExists = sqliteFileFamily(oldBasePath).any { fileExists(it) } + val newExists = sqliteFileFamily(newBasePath).any { fileExists(it) } + + when (state) { + MIGRATION_STATE_FINISHED -> { + return + } + + MIGRATION_STATE_COPIED -> { + return + } + + MIGRATION_STATE_COPYING -> { + deleteSqliteFamily(newBasePath) + setMigrationState(name, MIGRATION_STATE_NONE) } } - internal fun databaseDirPath(): String = iosDirPath("databases") + if (!oldExists) { + return + } - @OptIn(kotlinx.cinterop.ExperimentalForeignApi::class, kotlinx.cinterop.UnsafeNumber::class) - internal fun iosDirPath(folder: String): String { - val paths = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, true) - val documentsDirectory = paths[0] as String + if (newExists) { + setMigrationState(name, MIGRATION_STATE_COPIED) + return + } - val databaseDirectory = "$documentsDirectory/$folder" + setMigrationState(name, MIGRATION_STATE_COPYING) + try { + copySqliteFamily( + fromBasePath = oldBasePath, + toBasePath = newBasePath, + ) + setMigrationState(name, MIGRATION_STATE_COPIED) + } catch (t: Throwable) { + deleteSqliteFamily(newBasePath) + setMigrationState(name, MIGRATION_STATE_NONE) + throw t + } +} + +@OptIn(ExperimentalForeignApi::class) +internal fun finalizeRoomMigrationIfNeeded(name: String) { + val state = getMigrationState(name) + if (state != MIGRATION_STATE_COPIED) return - val fileManager = NSFileManager.defaultManager() + val oldBasePath = "${iosAppSupportDirPath("databases")}/$name" + val newBasePath = "${appGroupDirPath(APP_GROUP_ID, "databases")}/$name" + + if (!fileExists(newBasePath)) return + + deleteSqliteFamily(oldBasePath) + setMigrationState(name, MIGRATION_STATE_FINISHED) +} - if (!fileManager.fileExistsAtPath(databaseDirectory)) { - fileManager.createDirectoryAtPath(databaseDirectory, true, null, null) - }; // Create folder +private fun migrationStateKey(name: String): String = "room_migration_state_$name" - return databaseDirectory +@OptIn(ExperimentalForeignApi::class) +private fun getMigrationState(name: String): String { + val defaults = NSUserDefaults(suiteName = APP_GROUP_ID) + return defaults.stringForKey(migrationStateKey(name)) ?: MIGRATION_STATE_NONE +} + +@OptIn(ExperimentalForeignApi::class) +private fun setMigrationState( + name: String, + state: String, +) { + val defaults = NSUserDefaults(suiteName = APP_GROUP_ID) + defaults.setObject(state, forKey = migrationStateKey(name)) + defaults.synchronize() +} + +private fun sqliteFileFamily(basePath: String): List = + listOf( + basePath, + "$basePath-wal", + "$basePath-shm", + ) + +@OptIn(ExperimentalForeignApi::class) +private fun copySqliteFamily( + fromBasePath: String, + toBasePath: String, +) { + val fileManager = NSFileManager.defaultManager + + deleteSqliteFamily(toBasePath) + + sqliteFileFamily(fromBasePath).zip(sqliteFileFamily(toBasePath)).forEach { (src, dst) -> + if (fileExists(src)) { + val ok = fileManager.copyItemAtPath(src, dst, null) + check(ok) { "Failed to copy $src to $dst" } + } + } +} + +@OptIn(ExperimentalForeignApi::class) +private fun deleteSqliteFamily(basePath: String) { + val fileManager = NSFileManager.defaultManager + sqliteFileFamily(basePath).forEach { path -> + if (fileExists(path)) { + fileManager.removeItemAtPath(path, null) + } } } + +@OptIn(ExperimentalForeignApi::class) +private fun fileExists(path: String): Boolean = NSFileManager.defaultManager.fileExistsAtPath(path) + +@OptIn(ExperimentalForeignApi::class) +internal fun appGroupDirPath( + groupId: String, + folder: String, +): String { + val fileManager = NSFileManager.defaultManager + val containerUrl: NSURL = + fileManager + .containerURLForSecurityApplicationGroupIdentifier(groupId) + ?: error("App Group not configured: $groupId") + + val dir = "${requireNotNull(containerUrl.path)}/$folder" + + if (!fileManager.fileExistsAtPath(dir)) { + fileManager.createDirectoryAtPath(dir, true, null, null) + } + + return dir +} + +@OptIn(ExperimentalForeignApi::class) +internal fun iosAppSupportDirPath(folder: String): String { + val paths = + NSSearchPathForDirectoriesInDomains( + NSApplicationSupportDirectory, + NSUserDomainMask, + true, + ) + val appSupportDirectory = paths[0] as String + val dir = "$appSupportDirectory/$folder" + + val fileManager = NSFileManager.defaultManager + if (!fileManager.fileExistsAtPath(dir)) { + fileManager.createDirectoryAtPath(dir, true, null, null) + } + + return dir +} diff --git a/shared/src/appleMain/kotlin/dev/dimension/flare/data/io/ApplePlatformPathProducer.kt b/shared/src/appleMain/kotlin/dev/dimension/flare/data/io/ApplePlatformPathProducer.kt index 1dbb701250..2a71d9e021 100644 --- a/shared/src/appleMain/kotlin/dev/dimension/flare/data/io/ApplePlatformPathProducer.kt +++ b/shared/src/appleMain/kotlin/dev/dimension/flare/data/io/ApplePlatformPathProducer.kt @@ -1,5 +1,6 @@ package dev.dimension.flare.data.io +import dev.dimension.flare.data.database.APP_GROUP_ID import kotlinx.cinterop.ExperimentalForeignApi import okio.Path import okio.Path.Companion.toPath @@ -9,15 +10,19 @@ import platform.Foundation.NSURL import platform.Foundation.NSUserDomainMask internal class ApplePlatformPathProducer : PlatformPathProducer { - override fun dataStoreFile(fileName: String): Path = "${fileDirectory()}/$fileName".toPath() + override fun dataStoreFile(fileName: String): Path = "${documentDirectory()}/$fileName".toPath() override fun draftMediaFile( groupId: String, fileName: String, - ): Path = "${fileDirectory()}/draft_media/$groupId/$fileName".toPath() + ): Path { + val dir = "${appGroupDirectory()}/draft_media/$groupId" + ensureDirectory(dir) + return "$dir/$fileName".toPath() + } @OptIn(ExperimentalForeignApi::class) - private fun fileDirectory(): String { + private fun documentDirectory(): String { val documentDirectory: NSURL? = NSFileManager.defaultManager.URLForDirectory( directory = NSDocumentDirectory, @@ -26,6 +31,28 @@ internal class ApplePlatformPathProducer : PlatformPathProducer { create = false, error = null, ) - return requireNotNull(documentDirectory).path!! + return requireNotNull(documentDirectory?.path) + } + + @OptIn(ExperimentalForeignApi::class) + private fun appGroupDirectory(): String { + val containerUrl = + NSFileManager.defaultManager + .containerURLForSecurityApplicationGroupIdentifier(APP_GROUP_ID) + ?: error("App Group not configured: $APP_GROUP_ID") + return requireNotNull(containerUrl.path) + } + + @OptIn(ExperimentalForeignApi::class) + private fun ensureDirectory(path: String) { + val fileManager = NSFileManager.defaultManager + if (!fileManager.fileExistsAtPath(path)) { + fileManager.createDirectoryAtPath( + path = path, + withIntermediateDirectories = true, + attributes = null, + error = null, + ) + } } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/common/GlobalConfig.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/common/GlobalConfig.kt new file mode 100644 index 0000000000..7191b3ecbc --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/common/GlobalConfig.kt @@ -0,0 +1,5 @@ +package dev.dimension.flare.common + +public object GlobalConfig { + public var disableLogging: Boolean = false +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/DriverFactory.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/DriverFactory.kt index 311a3ccd41..fd23b1debe 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/DriverFactory.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/DriverFactory.kt @@ -7,9 +7,4 @@ internal expect class DriverFactory { name: String, isCache: Boolean = false, ): RoomDatabase.Builder - - fun deleteDatabase( - name: String, - isCache: Boolean, - ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/ProvideDatabase.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/ProvideDatabase.kt index 13dd09e3d2..6d1ae7ac19 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/ProvideDatabase.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/database/ProvideDatabase.kt @@ -6,9 +6,11 @@ import dev.dimension.flare.data.database.cache.CacheDatabase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO +internal const val APP_DATABASE_NAME = "app.db" + internal fun provideAppDatabase(driverFactory: DriverFactory): AppDatabase = driverFactory - .createBuilder("app.db") + .createBuilder(APP_DATABASE_NAME) .addMigrations(AppDatabase.MIGRATION_8_9) .setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/Ktorfit.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/Ktorfit.kt index fd7ee6e6c1..85cbc9465b 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/Ktorfit.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/network/Ktorfit.kt @@ -4,6 +4,7 @@ import de.jensklingenberg.ktorfit.converter.CallConverterFactory import de.jensklingenberg.ktorfit.converter.FlowConverterFactory import de.jensklingenberg.ktorfit.converter.ResponseConverterFactory import dev.dimension.flare.common.BuildConfig +import dev.dimension.flare.common.GlobalConfig import dev.dimension.flare.common.JSON import dev.dimension.flare.data.network.mastodon.api.model.MastodonPagingConverterFactory import dev.dimension.flare.data.repository.DebugRepository @@ -51,14 +52,16 @@ public fun ktorClient( ): HttpClient = HttpClient(httpClientEngine) { config.invoke(this) - install(Logging) { - logger = FlareLogger - level = - if (BuildConfig.debug) { - LogLevel.ALL - } else { - LogLevel.BODY - } + if (!GlobalConfig.disableLogging) { + install(Logging) { + logger = FlareLogger + level = + if (BuildConfig.debug) { + LogLevel.ALL + } else { + LogLevel.BODY + } + } } } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/di/CommonModule.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/di/CommonModule.kt index 1020f04076..249e5cabc2 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/di/CommonModule.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/di/CommonModule.kt @@ -65,3 +65,29 @@ internal val commonModule = singleOf(::AiCompletionService) single { OnlinePreTranslationService(get(), get(), get(), get()) } } + +internal val liteModule = + module { + singleOf(::AccountRepository) + singleOf(::provideAppDatabase) + single { + DraftMediaStore(get()) + } + single { + DraftRepository( + database = get(), + draftMediaStore = get(), + ) + } + single { CoroutineScope(Dispatchers.IO) } + singleOf(::SaveDraftUseCase) + singleOf(::RestoreDraftUseCase) + single { + SendDraftUseCase( + draftRepository = get(), + accountRepository = get(), + draftMediaStore = get(), + ) + } + singleOf(::ComposeUseCase) + }