From 481888d2c87799c1d13bcc1e12c25a29fbff7eb6 Mon Sep 17 00:00:00 2001 From: Syer10 Date: Thu, 17 Nov 2022 20:50:26 -0500 Subject: [PATCH] Add iOS module --- ios/build.gradle.kts | 160 ++++++++++++++++++ .../kotlin/ca/gosyer/jui/ios/AppComponent.kt | 41 +++++ .../kotlin/ca/gosyer/jui/ios/AppMigrations.kt | 32 ++++ .../kotlin/ca/gosyer/jui/ios/Main.kt | 146 ++++++++++++++++ settings.gradle.kts | 3 +- 5 files changed, 381 insertions(+), 1 deletion(-) create mode 100644 ios/build.gradle.kts create mode 100644 ios/src/uikitMain/kotlin/ca/gosyer/jui/ios/AppComponent.kt create mode 100644 ios/src/uikitMain/kotlin/ca/gosyer/jui/ios/AppMigrations.kt create mode 100644 ios/src/uikitMain/kotlin/ca/gosyer/jui/ios/Main.kt diff --git a/ios/build.gradle.kts b/ios/build.gradle.kts new file mode 100644 index 00000000..adccb7a9 --- /dev/null +++ b/ios/build.gradle.kts @@ -0,0 +1,160 @@ +import org.jetbrains.compose.compose +import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget + +@Suppress("DSL_SCOPE_VIOLATION") +plugins { + id(libs.plugins.kotlin.multiplatform.get().pluginId) + id(libs.plugins.ksp.get().pluginId) + id(libs.plugins.compose.get().pluginId) + id(libs.plugins.buildkonfig.get().pluginId) + id(libs.plugins.kotlinter.get().pluginId) + id(libs.plugins.aboutLibraries.get().pluginId) +} + +kotlin { + val configuration: KotlinNativeTarget.() -> Unit = { + binaries { + executable { + entryPoint = "ca.gosyer.jui.ios.main" + freeCompilerArgs = freeCompilerArgs + listOf( + "-linker-option", "-framework", "-linker-option", "Metal", + "-linker-option", "-framework", "-linker-option", "CoreText", + "-linker-option", "-framework", "-linker-option", "CoreGraphics" + ) + // TODO: the current compose binary surprises LLVM, so disable checks for now. + freeCompilerArgs = freeCompilerArgs + "-Xdisable-phases=VerifyBitcode" + } + } + } + iosX64("uikitX64", configuration) + iosArm64("uikitArm64", configuration) + iosSimulatorArm64("uikitSimulatorArm64", configuration) + + sourceSets { + val commonMain by getting + val commonTest by getting + val uikitMain by creating { + dependsOn(commonMain) + dependencies { + implementation(projects.core) + implementation(projects.i18n) + implementation(projects.domain) + implementation(projects.data) + implementation(projects.uiCore) + implementation(projects.presentation) + + // UI (Compose) + implementation(compose.foundation) + implementation(compose.runtime) + implementation(compose.material) + implementation(compose.ui) + implementation(compose.animation) + implementation(compose("org.jetbrains.compose.ui:ui-util")) + implementation(libs.voyager.core) + implementation(libs.voyager.navigation) + implementation(libs.voyager.transitions) + implementation(libs.accompanist.pager) + implementation(libs.accompanist.pagerIndicators) + implementation(libs.accompanist.flowLayout) + implementation(libs.imageloader) + implementation(libs.materialDialogs.core) + + // Threading + implementation(libs.coroutines.core) + + // Json + implementation(libs.serialization.json.core) + implementation(libs.serialization.json.okio) + + // Dependency Injection + implementation(libs.kotlinInject.runtime) + + // Http client + implementation(libs.ktor.core) + implementation(libs.ktor.darwin) + implementation(libs.ktor.contentNegotiation) + implementation(libs.ktor.serialization.json) + implementation(libs.ktor.logging) + implementation(libs.ktor.websockets) + implementation(libs.ktor.auth) + + // Ktorfit + implementation(libs.ktorfit.lib) + + // Logging + implementation(libs.logging.kmlogging) + + // Storage + implementation(libs.okio) + + // Preferences + implementation(libs.multiplatformSettings.core) + implementation(libs.multiplatformSettings.serialization) + implementation(libs.multiplatformSettings.coroutines) + + // Utility + implementation(libs.dateTime) + implementation(libs.immutableCollections) + implementation(libs.kds) + + // Localization + implementation(libs.moko.core) + //implementation(libs.moko.compose) + + // Testing + /*testImplementation(kotlin("test-junit")) + testImplementation(compose("org.jetbrains.compose.ui:ui-test-junit4")) + testImplementation(libs.coroutines.test)*/ + } + } + val uikitTest by creating { + dependsOn(commonTest) + } + + listOf( + "uikitX64", + "uikitArm64", + "uikitSimulatorArm64", + ).forEach { + getByName(it + "Main").dependsOn(uikitMain) + getByName(it + "Test").dependsOn(uikitTest) + } + } +} + +compose.experimental { + uikit.application { + bundleIdPrefix = "ca.gosyer.jui.app.ios" + projectName = "Tachidesk-JUI" + // ./gradlew :app:ios:iosDeployIPhone13Debug + deployConfigurations { + simulator("IPhone13") { + device = org.jetbrains.compose.experimental.dsl.IOSDevices.IPHONE_13 + } + } + } +} + +dependencies { + listOf( + "kspUikitArm64", + "kspUikitSimulatorArm64", + "kspUikitX64" + ).forEach { + add(it, libs.kotlinInject.compiler) + add(it, libs.ktorfit.ksp) + } +} + +kotlin { + targets.withType { + binaries.all { + // TODO: the current compose binary surprises LLVM, so disable checks for now. + freeCompilerArgs = freeCompilerArgs + "-Xdisable-phases=VerifyBitcode" + } + } +} + +buildkonfig { + packageName = "ca.gosyer.jui.ios.build" +} \ No newline at end of file diff --git a/ios/src/uikitMain/kotlin/ca/gosyer/jui/ios/AppComponent.kt b/ios/src/uikitMain/kotlin/ca/gosyer/jui/ios/AppComponent.kt new file mode 100644 index 00000000..39bc78f7 --- /dev/null +++ b/ios/src/uikitMain/kotlin/ca/gosyer/jui/ios/AppComponent.kt @@ -0,0 +1,41 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.jui.ios + +import ca.gosyer.jui.core.di.AppScope +import ca.gosyer.jui.data.DataComponent +import ca.gosyer.jui.domain.DomainComponent +import ca.gosyer.jui.ui.ViewModelComponent +import ca.gosyer.jui.ui.base.UiComponent +import ca.gosyer.jui.uicore.vm.ContextWrapper +import me.tatarka.inject.annotations.Component +import me.tatarka.inject.annotations.Provides + +@AppScope +@Component +abstract class AppComponent( + @get:Provides + val context: ContextWrapper +) : ViewModelComponent, DataComponent, DomainComponent, UiComponent { + + abstract val appMigrations: AppMigrations + + @get:AppScope + @get:Provides + protected val appMigrationsFactory: AppMigrations + get() = AppMigrations(migrationPreferences, contextWrapper) + + val bind: ViewModelComponent + @Provides get() = this + + companion object { + private var appComponentInstance: AppComponent? = null + + fun getInstance(context: ContextWrapper) = appComponentInstance ?: create(context) + .also { appComponentInstance = it } + } +} diff --git a/ios/src/uikitMain/kotlin/ca/gosyer/jui/ios/AppMigrations.kt b/ios/src/uikitMain/kotlin/ca/gosyer/jui/ios/AppMigrations.kt new file mode 100644 index 00000000..792f80c5 --- /dev/null +++ b/ios/src/uikitMain/kotlin/ca/gosyer/jui/ios/AppMigrations.kt @@ -0,0 +1,32 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.jui.ios + +import ca.gosyer.jui.domain.migration.service.MigrationPreferences +import ca.gosyer.jui.ios.build.BuildKonfig +import ca.gosyer.jui.uicore.vm.ContextWrapper +import me.tatarka.inject.annotations.Inject + +class AppMigrations @Inject constructor( + private val migrationPreferences: MigrationPreferences, + private val contextWrapper: ContextWrapper +) { + + fun runMigrations(): Boolean { + val oldVersion = migrationPreferences.appVersion().get() + if (oldVersion < BuildKonfig.MIGRATION_CODE) { + migrationPreferences.appVersion().set(BuildKonfig.MIGRATION_CODE) + + // Fresh install + if (oldVersion == 0) { + return false + } + return true + } + return false + } +} diff --git a/ios/src/uikitMain/kotlin/ca/gosyer/jui/ios/Main.kt b/ios/src/uikitMain/kotlin/ca/gosyer/jui/ios/Main.kt new file mode 100644 index 00000000..75a70491 --- /dev/null +++ b/ios/src/uikitMain/kotlin/ca/gosyer/jui/ios/Main.kt @@ -0,0 +1,146 @@ +package ca.gosyer.jui.ios + +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Application +import ca.gosyer.jui.ui.base.theme.AppTheme +import ca.gosyer.jui.ui.main.MainMenu +import ca.gosyer.jui.uicore.vm.ContextWrapper +import ca.gosyer.jui.uicore.vm.Length +import kotlinx.cinterop.autoreleasepool +import kotlinx.cinterop.cstr +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.toCValues +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import platform.Foundation.NSStringFromClass +import platform.UIKit.UIApplication +import platform.UIKit.UIApplicationDelegateProtocol +import platform.UIKit.UIApplicationDelegateProtocolMeta +import platform.UIKit.UIApplicationMain +import platform.UIKit.UIResponder +import platform.UIKit.UIResponderMeta +import platform.UIKit.UIScreen +import platform.UIKit.UIWindow +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +fun main() { + val args = emptyArray() + memScoped { + val argc = args.size + 1 + val argv = (arrayOf("skikoApp") + args).map { it.cstr.ptr }.toCValues() + autoreleasepool { + UIApplicationMain(argc, argv, null, NSStringFromClass(SkikoAppDelegate)) + } + } +} + +class SkikoAppDelegate @OverrideInit constructor() : UIResponder(), UIApplicationDelegateProtocol { + companion object : UIResponderMeta(), UIApplicationDelegateProtocolMeta + + private var _window: UIWindow? = null + override fun window() = _window + override fun setWindow(window: UIWindow?) { + _window = window + } + + private val context = ContextWrapper() + + private val appComponent = AppComponent.getInstance(context) + + init { + appComponent.migrations.runMigrations() + appComponent.appMigrations.runMigrations() + + appComponent.downloadService.init() + appComponent.libraryUpdateService.init() + } + + val uiHooks = appComponent.hooks + + override fun application(application: UIApplication, didFinishLaunchingWithOptions: Map?): Boolean { + window = UIWindow(frame = UIScreen.mainScreen.bounds).apply { + rootViewController = Application("Tachidesk-JUI") { + CompositionLocalProvider(*uiHooks) { + AppTheme { + Box(Modifier.fillMaxSize()) { + MainMenu() + ToastOverlay( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 64.dp), + context = context + ) + } + } + } + } + makeKeyAndVisible() + } + return true + } +} + + +@Composable +fun ToastOverlay(modifier: Modifier, context: ContextWrapper) { + var toast by remember { mutableStateOf?>(null) } + LaunchedEffect(Unit) { + context.toasts + .onEach { + toast = it + } + .launchIn(this) + } + LaunchedEffect(toast) { + if (toast != null) { + delay( + when (toast?.second) { + Length.SHORT -> 2.seconds + Length.LONG -> 5.seconds + else -> Duration.ZERO + } + ) + toast = null + } + } + @Suppress("NAME_SHADOWING") + (Crossfade( + toast?.first, + modifier = modifier + ) { toast -> + if (toast != null) { + Card( + Modifier.sizeIn(maxWidth = 200.dp), + shape = CircleShape, + backgroundColor = Color.DarkGray + ) { + Text( + toast, + Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + color = Color.White, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + fontSize = 12.sp, + textAlign = TextAlign.Center + ) + } + } + }) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index fb3dc80e..a01bcb5f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,7 +16,6 @@ dependencyResolutionManagement { } rootProject.name = "Tachidesk-JUI" -include("desktop") include("core") include("i18n") include("data") @@ -24,6 +23,8 @@ include("domain") include("ui-core") include("presentation") include("android") +include("desktop") +include("ios") enableFeaturePreview("VERSION_CATALOGS") enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")