diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index d3d17b1..ad00056 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) + alias(libs.plugins.ktlint) } kotlin { @@ -59,11 +60,25 @@ android { lint { lintConfig = file("$rootDir/androidApp/android_picasso_lint.xml") } + + flavorDimensions += "environment" + + productFlavors { + create("ci") { + dimension = "environment" + applicationIdSuffix = ".ci" + } + + create("store") { + dimension = "environment" + } + } } dependencies { implementation(project(":shared")) + implementation(libs.android.splash) implementation(libs.androidx.appcompat) implementation(libs.androidx.activity.compose) diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 9516b5f..9bd6ca6 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -4,11 +4,12 @@ @@ -16,4 +17,5 @@ + diff --git a/androidApp/src/main/java/com/mirego/kmp/boilerplate/Greeting.kt b/androidApp/src/main/java/com/mirego/kmp/boilerplate/Greeting.kt deleted file mode 100644 index 404d91e..0000000 --- a/androidApp/src/main/java/com/mirego/kmp/boilerplate/Greeting.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.mirego.kmp.boilerplate - -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.tooling.preview.Preview -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf - -@Composable -fun Greeting(textFlow: Flow) { - val text: String by textFlow.collectAsState("initial") - - Text(text = text) -} - -@Preview -@Composable -fun PreviewGreeting() { - val textFlow = flowOf("Hello, Android 31") - Greeting(textFlow) -} diff --git a/androidApp/src/main/java/com/mirego/kmp/boilerplate/MainActivity.kt b/androidApp/src/main/java/com/mirego/kmp/boilerplate/MainActivity.kt deleted file mode 100644 index bf86377..0000000 --- a/androidApp/src/main/java/com/mirego/kmp/boilerplate/MainActivity.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.mirego.kmp.boilerplate - -import android.os.Bundle -import androidx.activity.compose.setContent -import androidx.appcompat.app.AppCompatActivity - - -class MainActivity : AppCompatActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContent { - Greeting(textFlow = Greeting().greeting()) - } - } -} diff --git a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/AndroidApplication.kt b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/AndroidApplication.kt new file mode 100644 index 0000000..3aa533b --- /dev/null +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/AndroidApplication.kt @@ -0,0 +1,14 @@ +package com.mirego.kmp.boilerplate.app + +import android.app.Application +import com.mirego.kmp.boilerplate.app.bootstrap.AndroidBootstrap +import com.mirego.kmp.boilerplate.bootstrap.Bootstrapper + +class AndroidApplication : Application() { + val bootstrapper = Bootstrapper() + + override fun onCreate() { + super.onCreate() + bootstrapper.initDependencies(AndroidBootstrap(this)) + } +} diff --git a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/MainActivity.kt b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/MainActivity.kt new file mode 100644 index 0000000..6f73876 --- /dev/null +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/MainActivity.kt @@ -0,0 +1,31 @@ +package com.mirego.kmp.boilerplate.app + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.core.view.WindowCompat +import com.mirego.kmp.boilerplate.app.ui.application.ApplicationView +import com.mirego.kmp.boilerplate.bootstrap.Bootstrapper +import com.mirego.kmp.boilerplate.trikot.viewmodels.declarative.compose.getInitialViewModel +import com.mirego.kmp.boilerplate.viewmodel.application.ApplicationViewModel + +class MainActivity : AppCompatActivity() { + private val bootstrapper: Bootstrapper + get() = (applicationContext as AndroidApplication).bootstrapper + + private val viewModel: ApplicationViewModel by lazy { + getInitialViewModel { + bootstrapper.applicationViewModel() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + installSplashScreen() + WindowCompat.setDecorFitsSystemWindows(window, false) + setContent { + ApplicationView(applicationViewModel = viewModel) + } + } +} diff --git a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/bootstrap/AndroidBootstrap.kt b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/bootstrap/AndroidBootstrap.kt new file mode 100644 index 0000000..4beae28 --- /dev/null +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/bootstrap/AndroidBootstrap.kt @@ -0,0 +1,31 @@ +package com.mirego.kmp.boilerplate.app.bootstrap + +import android.content.Context +import com.mirego.kmp.boilerplate.BuildConfig +import com.mirego.kmp.boilerplate.app.resources.AndroidImageProvider +import com.mirego.kmp.boilerplate.bootstrap.AppEnvironment +import com.mirego.kmp.boilerplate.bootstrap.Bootstrap +import com.mirego.kmp.boilerplate.bootstrap.LocaleUtils +import com.mirego.trikot.kword.android.AndroidKWord +import com.mirego.trikot.viewmodels.declarative.configuration.DefaultTextStyleProvider +import com.mirego.trikot.viewmodels.declarative.configuration.TrikotViewModelDeclarative + +class AndroidBootstrap(context: Context) : Bootstrap { + + override val appInformation = AppInformationImpl(context) + + override val environment = when (BuildConfig.FLAVOR.lowercase()) { + "ci" -> AppEnvironment.DEV + "store" -> AppEnvironment.PRODUCTION + else -> AppEnvironment.PRODUCTION + } + + init { + AndroidKWord.setCurrentLanguageCode(LocaleUtils.supportedLanguageCode()) + + TrikotViewModelDeclarative.initialize( + imageProvider = AndroidImageProvider(), + textStyleProvider = DefaultTextStyleProvider() + ) + } +} diff --git a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/bootstrap/AppInformationImpl.kt b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/bootstrap/AppInformationImpl.kt new file mode 100644 index 0000000..e545235 --- /dev/null +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/bootstrap/AppInformationImpl.kt @@ -0,0 +1,17 @@ +package com.mirego.kmp.boilerplate.app.bootstrap + +import android.content.Context +import com.mirego.kmp.boilerplate.BuildConfig +import com.mirego.kmp.boilerplate.bootstrap.AppInformation +import com.mirego.kmp.boilerplate.model.Language +import com.mirego.kmp.boilerplate.model.Locale + +class AppInformationImpl(context: Context) : AppInformation { + override val locale: Locale = Locale( + if (java.util.Locale.getDefault().language.lowercase() == "fr") Language.FRENCH else Language.ENGLISH, + java.util.Locale.getDefault().country + ) + + override val versionNumber: String = "${BuildConfig.VERSION_NAME}.${BuildConfig.VERSION_CODE}" + override val diskCachePath: String = context.cacheDir.toString() +} diff --git a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/resources/AndroidImageProvider.kt b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/resources/AndroidImageProvider.kt new file mode 100644 index 0000000..c96e1e0 --- /dev/null +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/resources/AndroidImageProvider.kt @@ -0,0 +1,9 @@ +package com.mirego.kmp.boilerplate.app.resources + +import android.content.Context +import com.mirego.trikot.viewmodels.declarative.configuration.VMDImageProvider +import com.mirego.trikot.viewmodels.declarative.properties.VMDImageResource + +class AndroidImageProvider : VMDImageProvider { + override fun resourceIdForResource(resource: VMDImageResource, context: Context) = null +} diff --git a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/application/ApplicationView.kt b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/application/ApplicationView.kt new file mode 100644 index 0000000..93aa2f1 --- /dev/null +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/application/ApplicationView.kt @@ -0,0 +1,23 @@ +package com.mirego.kmp.boilerplate.app.ui.application + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.tooling.preview.Preview +import com.mirego.kmp.boilerplate.app.ui.preview.PreviewProvider +import com.mirego.kmp.boilerplate.app.ui.root.RootView +import com.mirego.kmp.boilerplate.viewmodel.application.ApplicationViewModel +import com.mirego.trikot.viewmodels.declarative.compose.extensions.observeAsState + +@Composable +fun ApplicationView(applicationViewModel: ApplicationViewModel) { + val viewModel: ApplicationViewModel by applicationViewModel.observeAsState() + RootView(rootViewModel = viewModel.rootViewModel) +} + +@Preview +@Composable +fun PreviewApplicationView() { + PreviewProvider { + ApplicationView(applicationViewModel = it.createApplication()) + } +} diff --git a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/preview/PreviewProvider.kt b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/preview/PreviewProvider.kt new file mode 100644 index 0000000..7a1925c --- /dev/null +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/preview/PreviewProvider.kt @@ -0,0 +1,18 @@ +package com.mirego.kmp.boilerplate.app.ui.preview + +import androidx.compose.runtime.Composable +import com.mirego.kmp.boilerplate.BuildConfig +import com.mirego.kmp.boilerplate.app.resources.AndroidImageProvider +import com.mirego.kmp.boilerplate.viewmodel.factory.ViewModelFactoryPreview +import com.mirego.trikot.kword.android.PreviewI18N +import com.mirego.trikot.viewmodels.declarative.configuration.TrikotViewModelDeclarative + +@Composable +fun PreviewProvider(content: @Composable (ViewModelFactoryPreview) -> Unit) { + TrikotViewModelDeclarative.initialize(AndroidImageProvider()) + val viewModelFactoryPreview = ViewModelFactoryPreview( + i18N = PreviewI18N(BuildConfig.KWORD_TRANSLATION_FILE_PATH) + ) + + content(viewModelFactoryPreview) +} diff --git a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/root/RootView.kt b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/root/RootView.kt new file mode 100644 index 0000000..540a442 --- /dev/null +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/root/RootView.kt @@ -0,0 +1,39 @@ +package com.mirego.kmp.boilerplate.app.ui.root + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import com.mirego.kmp.boilerplate.app.ui.preview.PreviewProvider +import com.mirego.kmp.boilerplate.viewmodel.root.RootViewModel +import com.mirego.trikot.viewmodels.declarative.compose.extensions.observeAsState + +@Composable +fun RootView(rootViewModel: RootViewModel) { + val viewModel: RootViewModel by rootViewModel.observeAsState() + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.White) + ) { + Text( + modifier = Modifier + .align(Alignment.Center), + text = "Hi" + ) + } +} + +@Preview +@Composable +fun PreviewRootView() { + PreviewProvider { + RootView(rootViewModel = it.createRoot()) + } +} diff --git a/androidApp/src/main/java/com/mirego/kmp/boilerplate/trikot/viewmodels/declarative/compose/VMDViewModelStoreOwnerExtensions.kt b/androidApp/src/main/java/com/mirego/kmp/boilerplate/trikot/viewmodels/declarative/compose/VMDViewModelStoreOwnerExtensions.kt new file mode 100644 index 0000000..02ec836 --- /dev/null +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/trikot/viewmodels/declarative/compose/VMDViewModelStoreOwnerExtensions.kt @@ -0,0 +1,25 @@ +package com.mirego.kmp.boilerplate.trikot.viewmodels.declarative.compose + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStoreOwner +import com.mirego.trikot.viewmodels.declarative.viewmodel.VMDViewModel + +/** + * Use this method to get the initial VMDViewModel. This will make sure that it survives Activity recreation by wrapping it in a androidx.lifecycle.ViewModel + */ +@Suppress("UNCHECKED_CAST") +fun ViewModelStoreOwner.getInitialViewModel(factory: () -> VMD): VMD = + ViewModelProvider( + viewModelStore, + ViewModelProviderFactory(factory) + )[ViewModelWrapper::class.java].wrappedViewModel as VMD + +@Suppress("UNCHECKED_CAST") +private class ViewModelProviderFactory(private val factory: () -> VMD) : ViewModelProvider.Factory { + override fun create(modelClass: Class): VM { + return ViewModelWrapper(factory()) as VM + } +} + +class ViewModelWrapper(val wrappedViewModel: VMD) : ViewModel() diff --git a/build.gradle.kts b/build.gradle.kts index 6ca3283..4d1cd13 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -24,3 +24,12 @@ dependencyCheck { knownExploitedEnabled = true } } + +// CheckCommon with Lint Format +val cclf: Task by tasks.creating { + group = "verification" + description = "Like checkCommon, but adds automatic lint fixing" + dependsOn("shared:ktlintFormat") + dependsOn("androidApp:ktlintFormat") + dependsOn("shared:testReleaseUnitTest") +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d29dcee..4490371 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] androidComposeCompiler = "1.5.3" androidGradlePlugin = "8.1.2" +android-splash = "1.0.1" androidxActivityCompose = "1.8.0" androidxAppcompat = "1.6.1" androidxComposeBom = "2023.10.01" @@ -18,6 +19,7 @@ owasp = "8.4.2" trikot = "5.2.0" [libraries] +android-splash = { module = "androidx.core:core-splashscreen", version.ref = "android-splash" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivityCompose" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidxAppcompat" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBom" } diff --git a/ios/.swiftlint.yml b/ios/.swiftlint.yml index 45613aa..7c22191 100644 --- a/ios/.swiftlint.yml +++ b/ios/.swiftlint.yml @@ -1,23 +1,15 @@ -# Unless otherwise noted, rules are disabled because they reduce the readability of the code -# See https://realm.github.io/SwiftLint/rule-directory.html for more info on each rule - -analyzer_rules: - - explicit_self - - unused_declaration - - unused_import - -# Rule identifiers to exclude from running disabled_rules: - - comment_spacing - force_cast - function_body_length + - discouraged_optional_collection + - type_name + - private_over_fileprivate + - multiple_closures_with_trailing_closure + - inclusive_language + - blanket_disable_command -# All opt-in rules are listed, with the ones we do not want commented out so we can know which rules we don't want and which rules are new when upgrading Swiftlint opt_in_rules: - - anyobject_protocol - array_init - - attributes - #- closure_body_length - closure_end_indentation - closure_spacing - collection_alignment @@ -36,55 +28,36 @@ opt_in_rules: - empty_xctest_method - enum_case_associated_values_count - expiring_todo - #- explicit_acl - #- explicit_enum_raw_value - explicit_init - #- explicit_top_level_acl - #- explicit_type_interface - extension_access_modifier - fallthrough - fatal_error_message - file_header - #- file_name - file_name_no_space - #- file_types_order - first_where - flatmap_over_map_reduce - #- force_unwrapping - function_default_parameter_at_end - identical_operands - implicit_return - #- implicitly_unwrapped_optional - #- indentation_width - joined_default_parameter - last_where - legacy_multiple - legacy_random - - let_var_whitespace - literal_expression_end_indentation - #- lower_acl_than_parent - #- missing_docs - modifier_order - #- multiline_arguments - #- multiline_arguments_brackets - #- multiline_function_chains - multiline_literal_brackets - multiline_parameters - multiline_parameters_brackets - nimble_operator - - no_extension_access_modifier - #- no_grouping_extension - nslocalizedstring_key - nslocalizedstring_require_bundle - number_separator - - object_literal - operator_usage_whitespace - optional_enum_case_matching - overridden_super_call - override_in_extension - pattern_matching_keywords - prefer_self_type_over_type_of_self - - prefer_zero_over_explicit_init - prefixed_toplevel_constant - private_action - private_outlet @@ -97,45 +70,35 @@ opt_in_rules: - reduce_into - redundant_nil_coalescing - redundant_type_annotation - #- required_deinit - #- required_enum_case - single_test_class - sorted_first_last - - sorted_imports - static_operator - - strict_fileprivate - strong_iboutlet - #- switch_case_on_newline - toggle_bool - - trailing_closure - #- type_contents_order - unavailable_function - unneeded_parentheses_in_closure_argument - unowned_variable_capture - untyped_error_in_catch - #- vertical_parameter_alignment_on_call - #- vertical_whitespace_between_cases - #- vertical_whitespace_closing_braces - #- vertical_whitespace_opening_braces - xct_specific_matcher - yoda_condition -# Paths to exclude +analyzer_rules: + - explicit_self + - unused_declaration + - unused_import + excluded: - Pods - Data - R.generated.swift - .bundle - # Add your own exclusion here conditional_returns_on_newline: if_only: true # Use the same deployment target as the project deployment_target: - iOS_deployment_target: 9.0 - tvOS_deployment_target: 9.1 - watchOS_deployment_target: 2.0 + iOS_deployment_target: 15.0 file_length: ignore_comment_only_lines: true @@ -161,6 +124,3 @@ cyclomatic_complexity: discouraged_object_literal: color_literal: false - -attributes: - always_on_line_above: [ "@objc" ] diff --git a/ios/iosApp.xcodeproj/project.pbxproj b/ios/iosApp.xcodeproj/project.pbxproj index 7cc1b97..ce1d6bc 100644 --- a/ios/iosApp.xcodeproj/project.pbxproj +++ b/ios/iosApp.xcodeproj/project.pbxproj @@ -10,9 +10,16 @@ 058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557BA273AAA24004C7B11 /* Assets.xcassets */; }; 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */; }; 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2152FB032600AC8F00CF470E /* iOSApp.swift */; }; - 7555FF83242A565900829871 /* GreetingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7555FF82242A565900829871 /* GreetingView.swift */; }; + 7555FF83242A565900829871 /* ApplicationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7555FF82242A565900829871 /* ApplicationView.swift */; }; + 89011D492AE9A3690073544B /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89011D482AE9A3690073544B /* AppDelegate.swift */; }; + 89011D4B2AE9A4CD0073544B /* BootstrapImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89011D4A2AE9A4CD0073544B /* BootstrapImpl.swift */; }; + 89011D4D2AE9A4FC0073544B /* AppEnvironment+iOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89011D4C2AE9A4FC0073544B /* AppEnvironment+iOS.swift */; }; + 89011D4F2AE9A7340073544B /* AppInitializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89011D4E2AE9A7340073544B /* AppInitializer.swift */; }; + 89011D512AE9A7750073544B /* ImageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89011D502AE9A7750073544B /* ImageProvider.swift */; }; + 89011D552AE9A8150073544B /* AppInformationImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89011D532AE9A8150073544B /* AppInformationImpl.swift */; }; + 89011D5B2AE9B0310073544B /* PreviewHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89011D5A2AE9B0310073544B /* PreviewHelpers.swift */; }; + 895BECFB2AEAB3DC005B1212 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895BECFA2AEAB3DC005B1212 /* RootView.swift */; }; 9B8ACFDB4E332DFCA8B97CBB /* Pods_iosApp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4E4E1328B104D05A50A097EE /* Pods_iosApp.framework */; }; - BC83B466276E4F080053E064 /* FlowUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC83B465276E4F080053E064 /* FlowUtils.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -22,9 +29,16 @@ 308A8A1989CCC0B3DD133EE0 /* Pods-iosApp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.debug.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.debug.xcconfig"; sourceTree = ""; }; 4E4E1328B104D05A50A097EE /* Pods_iosApp.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iosApp.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 7555FF7B242A565900829871 /* iosApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iosApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 7555FF82242A565900829871 /* GreetingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GreetingView.swift; sourceTree = ""; }; + 7555FF82242A565900829871 /* ApplicationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationView.swift; sourceTree = ""; }; 7555FF8C242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - BC83B465276E4F080053E064 /* FlowUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowUtils.swift; sourceTree = ""; }; + 89011D482AE9A3690073544B /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 89011D4A2AE9A4CD0073544B /* BootstrapImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BootstrapImpl.swift; sourceTree = ""; }; + 89011D4C2AE9A4FC0073544B /* AppEnvironment+iOS.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "AppEnvironment+iOS.swift"; sourceTree = ""; }; + 89011D4E2AE9A7340073544B /* AppInitializer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppInitializer.swift; sourceTree = ""; }; + 89011D502AE9A7750073544B /* ImageProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProvider.swift; sourceTree = ""; }; + 89011D532AE9A8150073544B /* AppInformationImpl.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppInformationImpl.swift; sourceTree = ""; }; + 89011D5A2AE9B0310073544B /* PreviewHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewHelpers.swift; sourceTree = ""; }; + 895BECFA2AEAB3DC005B1212 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; E232C917135C2C1E3BC8748A /* Pods-iosApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.release.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -69,22 +83,61 @@ 7555FF7D242A565900829871 /* iosApp */ = { isa = PBXGroup; children = ( + 89011D4E2AE9A7340073544B /* AppInitializer.swift */, + 89011D482AE9A3690073544B /* AppDelegate.swift */, + 2152FB032600AC8F00CF470E /* iOSApp.swift */, + 89011D562AE9AFF90073544B /* Domain */, + 89011D572AE9AFFF0073544B /* UI */, 7555FF8C242A565B00829871 /* Info.plist */, 058557BA273AAA24004C7B11 /* Assets.xcassets */, - 2152FB032600AC8F00CF470E /* iOSApp.swift */, - 7555FF82242A565900829871 /* GreetingView.swift */, - BC83B464276E4EF80053E064 /* Utils */, 058557D7273AAEEB004C7B11 /* Preview Content */, ); path = iosApp; sourceTree = ""; }; - BC83B464276E4EF80053E064 /* Utils */ = { + 89011D562AE9AFF90073544B /* Domain */ = { + isa = PBXGroup; + children = ( + 89011D532AE9A8150073544B /* AppInformationImpl.swift */, + 89011D4C2AE9A4FC0073544B /* AppEnvironment+iOS.swift */, + 89011D4A2AE9A4CD0073544B /* BootstrapImpl.swift */, + 89011D502AE9A7750073544B /* ImageProvider.swift */, + ); + path = Domain; + sourceTree = ""; + }; + 89011D572AE9AFFF0073544B /* UI */ = { + isa = PBXGroup; + children = ( + 89011D582AE9B00F0073544B /* Application */, + 895BECF92AEAB3D1005B1212 /* Root */, + 89011D592AE9B0160073544B /* Previews */, + ); + path = UI; + sourceTree = ""; + }; + 89011D582AE9B00F0073544B /* Application */ = { + isa = PBXGroup; + children = ( + 7555FF82242A565900829871 /* ApplicationView.swift */, + ); + path = Application; + sourceTree = ""; + }; + 89011D592AE9B0160073544B /* Previews */ = { + isa = PBXGroup; + children = ( + 89011D5A2AE9B0310073544B /* PreviewHelpers.swift */, + ); + path = Previews; + sourceTree = ""; + }; + 895BECF92AEAB3D1005B1212 /* Root */ = { isa = PBXGroup; children = ( - BC83B465276E4F080053E064 /* FlowUtils.swift */, + 895BECFA2AEAB3DC005B1212 /* RootView.swift */, ); - path = Utils; + path = Root; sourceTree = ""; }; C8C629BFDC2144230B71E3BC /* Frameworks */ = { @@ -256,9 +309,16 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 89011D492AE9A3690073544B /* AppDelegate.swift in Sources */, + 89011D4D2AE9A4FC0073544B /* AppEnvironment+iOS.swift in Sources */, + 89011D5B2AE9B0310073544B /* PreviewHelpers.swift in Sources */, + 89011D4F2AE9A7340073544B /* AppInitializer.swift in Sources */, 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */, - 7555FF83242A565900829871 /* GreetingView.swift in Sources */, - BC83B466276E4F080053E064 /* FlowUtils.swift in Sources */, + 89011D512AE9A7750073544B /* ImageProvider.swift in Sources */, + 89011D4B2AE9A4CD0073544B /* BootstrapImpl.swift in Sources */, + 895BECFB2AEAB3DC005B1212 /* RootView.swift in Sources */, + 7555FF83242A565900829871 /* ApplicationView.swift in Sources */, + 89011D552AE9A8150073544B /* AppInformationImpl.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/iosApp/AppDelegate.swift b/ios/iosApp/AppDelegate.swift new file mode 100644 index 0000000..0430115 --- /dev/null +++ b/ios/iosApp/AppDelegate.swift @@ -0,0 +1,21 @@ +import Shared +import UIKit + +class AppDelegate: NSObject, UIApplicationDelegate { + let bootstrapper = Bootstrapper() + + lazy var applicationViewModel: ApplicationViewModel = { + bootstrapper.applicationViewModel() + }() + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + let bootstrap = BootstrapImpl() + AppInitializer.initializeComponents(environment: bootstrap.environment) + bootstrapper.doInitDependencies(bootstrap: bootstrap) + + return true + } +} diff --git a/ios/iosApp/AppInitializer.swift b/ios/iosApp/AppInitializer.swift new file mode 100644 index 0000000..8169c04 --- /dev/null +++ b/ios/iosApp/AppInitializer.swift @@ -0,0 +1,23 @@ +import Foundation +import Kingfisher +import Shared +import Trikot + +enum AppInitializer { + static func initializeComponents(environment: AppEnvironment) { + initializeCommon() + initializeKingfisher() + } + + private static func initializeCommon() { + TrikotKword.shared.setCurrentLanguage(LocaleUtils().supportedLanguageCode()) + TrikotViewModelDeclarative.shared.initialize( + imageProvider: ImageProvider(), + spanStyleProvider: DefaultSpanStyleProvider() + ) + } + + private static func initializeKingfisher() { + ImageCache.default.diskStorage.config.sizeLimit = 500 * 1_024 * 1_024 // 500 MB + } +} diff --git a/ios/iosApp/Domain/AppEnvironment+iOS.swift b/ios/iosApp/Domain/AppEnvironment+iOS.swift new file mode 100644 index 0000000..e99657a --- /dev/null +++ b/ios/iosApp/Domain/AppEnvironment+iOS.swift @@ -0,0 +1,43 @@ +import Foundation +import Shared + +extension AppEnvironment { + private static let environmentKey = "Environment" + + static var current: AppEnvironment { + loadCurrentEnvironment() + } + + private static func persistCurrentEnvironment(_ environment: AppEnvironment) { + UserDefaults.standard.set(environment.key, forKey: environmentKey) + UserDefaults.standard.synchronize() + } + + private static var defaultEnvironment: AppEnvironment { + #if DEBUG + return .dev + #else + return .production + #endif + } + + private static func loadCurrentEnvironment() -> AppEnvironment { + if let environmentValue = UserDefaults.standard.string(forKey: environmentKey), let environment = environmentFromString(environmentValue) { + return environment + } else if let environmentValue = Bundle.main.infoDictionary?[environmentKey] as? String, let environment = environmentFromString(environmentValue) { + return environment + } + return defaultEnvironment + } + + private static func environmentFromString(_ stringValue: String) -> AppEnvironment? { + switch stringValue.lowercased() { + case AppEnvironment.dev.key: + return .dev + case AppEnvironment.production.key: + return .production + default: + return nil + } + } +} diff --git a/ios/iosApp/Domain/AppInformationImpl.swift b/ios/iosApp/Domain/AppInformationImpl.swift new file mode 100644 index 0000000..a9476c8 --- /dev/null +++ b/ios/iosApp/Domain/AppInformationImpl.swift @@ -0,0 +1,21 @@ +import Foundation +import Shared + +class AppInformationImpl: AppInformation { + let locale: Shared.Locale + let versionNumber: String + let diskCachePath: String + + init (environmentKey: String) { + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] ?? "" + let build = Bundle.main.infoDictionary?["CFBundleVersion"] ?? "" + versionNumber = "\(version).\(build)" + + let cacheUrl = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true)[0] + diskCachePath = cacheUrl + "/\(environmentKey)/" + + let language = LocaleUtils().supportedLanguageCode() == "fr" ? Shared.Language.french : Shared.Language.english + let regionCode = Foundation.Locale.current.regionCode + locale = Shared.Locale(language: language, regionCode: regionCode) + } +} diff --git a/ios/iosApp/Domain/BootstrapImpl.swift b/ios/iosApp/Domain/BootstrapImpl.swift new file mode 100644 index 0000000..df75fa2 --- /dev/null +++ b/ios/iosApp/Domain/BootstrapImpl.swift @@ -0,0 +1,12 @@ +import Shared +import SwiftUI +import Trikot + +final class BootstrapImpl: Bootstrap { + let appInformation: AppInformation + let environment = AppEnvironment.current + + init() { + appInformation = AppInformationImpl(environmentKey: environment.key) + } +} diff --git a/ios/iosApp/Domain/ImageProvider.swift b/ios/iosApp/Domain/ImageProvider.swift new file mode 100644 index 0000000..24a738f --- /dev/null +++ b/ios/iosApp/Domain/ImageProvider.swift @@ -0,0 +1,9 @@ +import Shared +import SwiftUI +import Trikot + +final class ImageProvider: VMDImageProvider { + func imageForResource(imageResource: VMDImageResource) -> Image? { + nil + } +} diff --git a/ios/iosApp/GreetingView.swift b/ios/iosApp/GreetingView.swift deleted file mode 100644 index 36b0f57..0000000 --- a/ios/iosApp/GreetingView.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Shared -import SwiftUI - -struct GreetingView: View { - - @ObservedObject var greet = ObservableFlowWrapper(Greeting().greeting(), initial: "initial") - - var body: some View { - Text("\(greet.value)") - } -} - -struct GreetingView_Previews: PreviewProvider { - static var previews: some View { - GreetingView() - } -} diff --git a/ios/iosApp/UI/Application/ApplicationView.swift b/ios/iosApp/UI/Application/ApplicationView.swift new file mode 100644 index 0000000..be21bc3 --- /dev/null +++ b/ios/iosApp/UI/Application/ApplicationView.swift @@ -0,0 +1,25 @@ +import Shared +import SwiftUI +import Trikot + +struct ApplicationView: View { + @ObservedObject private var observableViewModel: ObservableViewModelAdapter + + init(viewModel: ApplicationViewModel) { + observableViewModel = viewModel.asObservable() + } + + var viewModel: ApplicationViewModel { + observableViewModel.viewModel + } + + var body: some View { + RootView(viewModel: viewModel.rootViewModel) + } +} + +struct ApplicationView_Previews: PreviewProvider { + static var previews: some View { + ApplicationView(viewModel: factoryPreview().createApplication()) + } +} diff --git a/ios/iosApp/UI/Previews/PreviewHelpers.swift b/ios/iosApp/UI/Previews/PreviewHelpers.swift new file mode 100644 index 0000000..aeef041 --- /dev/null +++ b/ios/iosApp/UI/Previews/PreviewHelpers.swift @@ -0,0 +1,14 @@ +import Shared +import SwiftUI +import Trikot + +func previewi18N(languageCode: String) -> I18N { + let i18N = DefaultI18N() + KwordLoader.shared.setCurrentLanguageCode(i18N: i18N, basePaths: ["translation"], code: languageCode) + return i18N +} + +func factoryPreview(languageCode: String = "en") -> ViewModelFactoryPreview { + let i18N = previewi18N(languageCode: languageCode) + return ViewModelFactoryPreview(i18N: i18N) +} diff --git a/ios/iosApp/UI/Root/RootView.swift b/ios/iosApp/UI/Root/RootView.swift new file mode 100644 index 0000000..b350e7c --- /dev/null +++ b/ios/iosApp/UI/Root/RootView.swift @@ -0,0 +1,25 @@ +import Shared +import SwiftUI +import Trikot + +struct RootView: View { + @ObservedObject private var observableViewModel: ObservableViewModelAdapter + + init(viewModel: RootViewModel) { + observableViewModel = viewModel.asObservable() + } + + var viewModel: RootViewModel { + observableViewModel.viewModel + } + + var body: some View { + Text("Hi") + } +} + +struct RootView_Previews: PreviewProvider { + static var previews: some View { + RootView(viewModel: factoryPreview().createRoot()) + } +} diff --git a/ios/iosApp/Utils/FlowUtils.swift b/ios/iosApp/Utils/FlowUtils.swift deleted file mode 100644 index e6394e0..0000000 --- a/ios/iosApp/Utils/FlowUtils.swift +++ /dev/null @@ -1,21 +0,0 @@ -import Foundation -import Shared - -public class ObservableFlowWrapper: ObservableObject { - - @Published public private(set) var value: T - - private var watcher: Closeable_? - - public init(_ flow: CFlow, initial value: T) { - self.value = value - - watcher = flow.watch { [weak self] t in - self?.value = t - } - } - - deinit { - watcher?.close() - } -} diff --git a/ios/iosApp/iOSApp.swift b/ios/iosApp/iOSApp.swift index 947b0aa..81035d7 100644 --- a/ios/iosApp/iOSApp.swift +++ b/ios/iosApp/iOSApp.swift @@ -2,9 +2,11 @@ import SwiftUI @main struct IOSApp: App { - var body: some Scene { - WindowGroup { - GreetingView() - } - } + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + + var body: some Scene { + WindowGroup { + ApplicationView(viewModel: appDelegate.applicationViewModel) + } + } } diff --git a/shared/src/androidMain/kotlin/com/mirego/kmp/boilerplate/Platform.kt b/shared/src/androidMain/kotlin/com/mirego/kmp/boilerplate/Platform.kt deleted file mode 100644 index 83f3306..0000000 --- a/shared/src/androidMain/kotlin/com/mirego/kmp/boilerplate/Platform.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.mirego.kmp.boilerplate - -actual class Platform actual constructor() { - actual val platform: String = "Android ${android.os.Build.VERSION.SDK_INT}" -} diff --git a/shared/src/androidMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/LocaleUtils.kt b/shared/src/androidMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/LocaleUtils.kt new file mode 100644 index 0000000..def46b5 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/LocaleUtils.kt @@ -0,0 +1,11 @@ +package com.mirego.kmp.boilerplate.bootstrap + +import java.util.Locale + +actual object LocaleUtils { + actual fun supportedLanguageCode() = if (Locale.getDefault().language.lowercase() == "fr") { + "fr" + } else { + "en" + } +} diff --git a/shared/src/androidMain/kotlin/com/mirego/kmp/boilerplate/utils/CFlow.kt b/shared/src/androidMain/kotlin/com/mirego/kmp/boilerplate/utils/CFlow.kt deleted file mode 100644 index 1bfe07e..0000000 --- a/shared/src/androidMain/kotlin/com/mirego/kmp/boilerplate/utils/CFlow.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.mirego.kmp.boilerplate.utils - -import kotlinx.coroutines.flow.Flow - -actual class CFlow internal constructor(origin: Flow) : Flow by origin - -actual fun Flow.wrap(): CFlow = CFlow(this) diff --git a/shared/src/androidUnitTest/kotlin/com.mirego.kmp.boilerplate/AndroidGreetingTest.kt b/shared/src/androidUnitTest/kotlin/com.mirego.kmp.boilerplate/AndroidGreetingTest.kt deleted file mode 100644 index 714b84f..0000000 --- a/shared/src/androidUnitTest/kotlin/com.mirego.kmp.boilerplate/AndroidGreetingTest.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.mirego.kmp.boilerplate - -import kotlin.test.Test -import kotlin.test.assertTrue -import kotlinx.coroutines.flow.single -import kotlinx.coroutines.test.runTest - -class AndroidGreetingTest { - - @Test - fun testExample() = runTest { - assertTrue(Greeting().greeting().single().contains("Android"), "Check Android is mentioned") - } -} diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/Greeting.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/Greeting.kt deleted file mode 100644 index 2b88fdf..0000000 --- a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/Greeting.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.mirego.kmp.boilerplate - -import com.mirego.kmp.boilerplate.utils.CFlow -import com.mirego.kmp.boilerplate.utils.wrap -import kotlinx.coroutines.flow.flowOf - -class Greeting { - fun greeting(): CFlow = flowOf("Hello, ${Platform().platform}!").wrap() -} diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/Platform.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/Platform.kt deleted file mode 100644 index 514e746..0000000 --- a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/Platform.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.mirego.kmp.boilerplate - -expect class Platform() { - val platform: String -} diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/SharedModule.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/SharedModule.kt new file mode 100644 index 0000000..2b06eb8 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/SharedModule.kt @@ -0,0 +1,8 @@ +package com.mirego.kmp.boilerplate + +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Module + +@Module +@ComponentScan +class SharedModule diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/AppEnvironment.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/AppEnvironment.kt new file mode 100644 index 0000000..d31e652 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/AppEnvironment.kt @@ -0,0 +1,12 @@ +package com.mirego.kmp.boilerplate.bootstrap + +enum class AppEnvironment( + val key: String +) { + DEV( + key = "dev" + ), + PRODUCTION( + key = "production" + ) +} diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/AppInformation.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/AppInformation.kt new file mode 100644 index 0000000..0115a4c --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/AppInformation.kt @@ -0,0 +1,9 @@ +package com.mirego.kmp.boilerplate.bootstrap + +import com.mirego.kmp.boilerplate.model.Locale + +interface AppInformation { + val locale: Locale + val versionNumber: String + val diskCachePath: String +} diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/Bootstrap.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/Bootstrap.kt new file mode 100644 index 0000000..4134f86 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/Bootstrap.kt @@ -0,0 +1,6 @@ +package com.mirego.kmp.boilerplate.bootstrap + +interface Bootstrap { + val environment: AppEnvironment + val appInformation: AppInformation +} diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/Bootstrapper.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/Bootstrapper.kt new file mode 100644 index 0000000..e9aecf9 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/Bootstrapper.kt @@ -0,0 +1,29 @@ +package com.mirego.kmp.boilerplate.bootstrap + +import com.mirego.kmp.boilerplate.viewmodel.application.ApplicationViewModel +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import org.koin.core.component.KoinComponent +import org.koin.core.component.get +import org.koin.core.context.startKoin +import org.koin.core.parameter.parametersOf + +class Bootstrapper : KoinComponent { + private val exceptionHandler: CoroutineExceptionHandler = + CoroutineExceptionHandler { _, throwable -> + println("Exception: $throwable") + } + + private val rootCoroutineScope = + CoroutineScope(Dispatchers.Main.immediate + SupervisorJob() + exceptionHandler) + + fun initDependencies(bootstrap: Bootstrap) = startKoin { + configureKoin(bootstrap) + } + + fun applicationViewModel(): ApplicationViewModel = get { + parametersOf(rootCoroutineScope) + } +} diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/LocaleUtils.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/LocaleUtils.kt new file mode 100644 index 0000000..df09234 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/LocaleUtils.kt @@ -0,0 +1,5 @@ +package com.mirego.kmp.boilerplate.bootstrap + +expect object LocaleUtils { + fun supportedLanguageCode(): String +} diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/Module.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/Module.kt new file mode 100644 index 0000000..67f59b8 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/Module.kt @@ -0,0 +1,50 @@ +package com.mirego.kmp.boilerplate.bootstrap + +import com.mirego.kmp.boilerplate.SharedModule +import com.mirego.trikot.kword.I18N +import com.mirego.trikot.kword.KWord +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import org.koin.core.KoinApplication +import org.koin.core.module.Module +import org.koin.core.qualifier.StringQualifier +import org.koin.dsl.module +import org.koin.ksp.generated.module + +fun KoinApplication.configureKoin(bootstrap: Bootstrap) { + modules( + generalModule(bootstrap), + SharedModule().module + ) +} + +@OptIn(ExperimentalSerializationApi::class) +fun buildJson() = Json { + explicitNulls = false + isLenient = true + ignoreUnknownKeys = true +} + +fun generalModule(bootstrap: Bootstrap): Module { + return module { + single { KWord } + single { bootstrap.environment } + single { bootstrap.appInformation } + single { bootstrap.appInformation.locale.language } + single( + StringQualifier(ModuleQualifier.REGION_CODE) + ) { bootstrap.appInformation.locale.regionCode } + single( + StringQualifier(ModuleQualifier.DISK_CACHE_PATH) + ) { + bootstrap.appInformation.diskCachePath + + "/${bootstrap.appInformation.locale.language.toLangCode()}" + } + single { buildJson() } + } +} + +object ModuleQualifier { + const val DISK_CACHE_PATH = "diskCachePath" + const val REGION_CODE = "regionCode" +} diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/model/Locale.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/model/Locale.kt new file mode 100644 index 0000000..8a16df9 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/model/Locale.kt @@ -0,0 +1,16 @@ +package com.mirego.kmp.boilerplate.model + +data class Locale( + val language: Language, + val regionCode: String? +) + +enum class Language { + ENGLISH, + FRENCH; + + fun toLangCode() = when (this) { + ENGLISH -> "en" + FRENCH -> "fr" + } +} diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/utils/CFlow.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/utils/CFlow.kt deleted file mode 100644 index fe4d8d3..0000000 --- a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/utils/CFlow.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.mirego.kmp.boilerplate.utils - -import kotlinx.coroutines.flow.Flow - -expect class CFlow : Flow - -expect fun Flow.wrap(): CFlow diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/application/ApplicationViewModel.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/application/ApplicationViewModel.kt new file mode 100644 index 0000000..a97e442 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/application/ApplicationViewModel.kt @@ -0,0 +1,8 @@ +package com.mirego.kmp.boilerplate.viewmodel.application + +import com.mirego.kmp.boilerplate.viewmodel.root.RootViewModel +import com.mirego.trikot.viewmodels.declarative.viewmodel.VMDViewModel + +interface ApplicationViewModel : VMDViewModel { + val rootViewModel: RootViewModel +} diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/application/ApplicationViewModelImpl.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/application/ApplicationViewModelImpl.kt new file mode 100644 index 0000000..ac68d8a --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/application/ApplicationViewModelImpl.kt @@ -0,0 +1,14 @@ +package com.mirego.kmp.boilerplate.viewmodel.application + +import com.mirego.kmp.boilerplate.viewmodel.factory.ViewModelFactory +import com.mirego.trikot.viewmodels.declarative.viewmodel.VMDViewModelImpl +import kotlinx.coroutines.CoroutineScope +import org.koin.core.annotation.Factory + +@Factory +class ApplicationViewModelImpl( + viewModelFactory: ViewModelFactory, + coroutineScope: CoroutineScope +) : ApplicationViewModel, VMDViewModelImpl(coroutineScope) { + override val rootViewModel = viewModelFactory.createRoot(coroutineScope) +} diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/factory/ViewModelFactory.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/factory/ViewModelFactory.kt new file mode 100644 index 0000000..7952f4f --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/factory/ViewModelFactory.kt @@ -0,0 +1,8 @@ +package com.mirego.kmp.boilerplate.viewmodel.factory + +import com.mirego.kmp.boilerplate.viewmodel.root.RootViewModel +import kotlinx.coroutines.CoroutineScope + +interface ViewModelFactory { + fun createRoot(coroutineScope: CoroutineScope): RootViewModel +} diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/factory/ViewModelFactoryImpl.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/factory/ViewModelFactoryImpl.kt new file mode 100644 index 0000000..c5e5111 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/factory/ViewModelFactoryImpl.kt @@ -0,0 +1,15 @@ +package com.mirego.kmp.boilerplate.viewmodel.factory + +import com.mirego.kmp.boilerplate.viewmodel.root.RootViewModel +import kotlinx.coroutines.CoroutineScope +import org.koin.core.annotation.Single +import org.koin.core.component.KoinComponent +import org.koin.core.component.get +import org.koin.core.parameter.parametersOf + +@Single +class ViewModelFactoryImpl : ViewModelFactory, KoinComponent { + override fun createRoot(coroutineScope: CoroutineScope): RootViewModel = get { + parametersOf(coroutineScope) + } +} diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/factory/ViewModelFactoryPreview.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/factory/ViewModelFactoryPreview.kt new file mode 100644 index 0000000..579ee05 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/factory/ViewModelFactoryPreview.kt @@ -0,0 +1,33 @@ +package com.mirego.kmp.boilerplate.viewmodel.factory + +import com.mirego.kmp.boilerplate.viewmodel.application.ApplicationViewModelImpl +import com.mirego.kmp.boilerplate.viewmodel.root.RootViewModelImpl +import com.mirego.trikot.kword.I18N +import com.mirego.trikot.viewmodels.declarative.util.CoroutineScopeProvider +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope + +@Suppress("unused", "MemberVisibilityCanBePrivate") +class ViewModelFactoryPreview( + private val i18N: I18N +) : ViewModelFactory { + + fun createCoroutineScope() = CoroutineScopeProvider.provideMainWithSuperviserJob( + CoroutineExceptionHandler { _, exception -> + println("CoroutineExceptionHandler got $exception") + } + ) + + fun createApplication() = ApplicationViewModelImpl( + this, + createCoroutineScope() + ) + + override fun createRoot(coroutineScope: CoroutineScope) = createRoot() + + fun createRoot() = RootViewModelImpl( + i18N = i18N, + viewModelFactory = this, + coroutineScope = createCoroutineScope() + ) +} diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/root/RootViewModel.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/root/RootViewModel.kt new file mode 100644 index 0000000..5f717fb --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/root/RootViewModel.kt @@ -0,0 +1,5 @@ +package com.mirego.kmp.boilerplate.viewmodel.root + +import com.mirego.trikot.viewmodels.declarative.viewmodel.VMDViewModel + +interface RootViewModel : VMDViewModel diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/root/RootViewModelImpl.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/root/RootViewModelImpl.kt new file mode 100644 index 0000000..69d1197 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/root/RootViewModelImpl.kt @@ -0,0 +1,14 @@ +package com.mirego.kmp.boilerplate.viewmodel.root + +import com.mirego.kmp.boilerplate.viewmodel.factory.ViewModelFactory +import com.mirego.trikot.kword.I18N +import com.mirego.trikot.viewmodels.declarative.viewmodel.VMDViewModelImpl +import kotlinx.coroutines.CoroutineScope +import org.koin.core.annotation.Factory + +@Factory +class RootViewModelImpl( + i18N: I18N, + viewModelFactory: ViewModelFactory, + coroutineScope: CoroutineScope +) : RootViewModel, VMDViewModelImpl(coroutineScope) diff --git a/shared/src/commonTest/kotlin/com/mirego/kmp/boilerplate/CommonGreetingTest.kt b/shared/src/commonTest/kotlin/com/mirego/kmp/boilerplate/CommonGreetingTest.kt deleted file mode 100644 index 77b73f6..0000000 --- a/shared/src/commonTest/kotlin/com/mirego/kmp/boilerplate/CommonGreetingTest.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.mirego.kmp.boilerplate - -import kotlin.test.Test -import kotlin.test.assertTrue -import kotlinx.coroutines.flow.single -import kotlinx.coroutines.test.runTest - -class CommonGreetingTest { - - @Test - fun testExample() = runTest { - assertTrue(Greeting().greeting().single().contains("Hello"), "Check 'Hello' is mentioned") - } -} diff --git a/shared/src/iosMain/kotlin/com/mirego/kmp/boilerplate/Platform.kt b/shared/src/iosMain/kotlin/com/mirego/kmp/boilerplate/Platform.kt deleted file mode 100644 index f876564..0000000 --- a/shared/src/iosMain/kotlin/com/mirego/kmp/boilerplate/Platform.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.mirego.kmp.boilerplate - -import platform.UIKit.UIDevice - -actual class Platform actual constructor() { - actual val platform: String = - "${UIDevice.currentDevice.systemName()} ${UIDevice.currentDevice.systemVersion}" -} diff --git a/shared/src/iosMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/LocaleUtils.kt b/shared/src/iosMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/LocaleUtils.kt new file mode 100644 index 0000000..a9fc55e --- /dev/null +++ b/shared/src/iosMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/LocaleUtils.kt @@ -0,0 +1,15 @@ +package com.mirego.kmp.boilerplate.bootstrap + +import platform.Foundation.NSLocale +import platform.Foundation.preferredLanguages + +actual object LocaleUtils { + actual fun supportedLanguageCode(): String { + val preferredLanguage: String? = NSLocale.preferredLanguages.firstOrNull() as? String + return if (preferredLanguage?.contains("fr") == true) { + "fr" + } else { + "en" + } + } +} diff --git a/shared/src/iosMain/kotlin/com/mirego/kmp/boilerplate/utils/CFlow.kt b/shared/src/iosMain/kotlin/com/mirego/kmp/boilerplate/utils/CFlow.kt deleted file mode 100644 index 30d013c..0000000 --- a/shared/src/iosMain/kotlin/com/mirego/kmp/boilerplate/utils/CFlow.kt +++ /dev/null @@ -1,34 +0,0 @@ -@file:Suppress("unused") - -package com.mirego.kmp.boilerplate.utils - -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach - -fun interface Closeable { - fun close() -} - -actual class CFlow internal constructor( - private val origin: Flow, - private val dispatcher: CoroutineDispatcher = Dispatchers.Main -) : Flow by origin { - - fun watch(block: (T) -> Unit): Closeable { - val job = Job() - val scope = CoroutineScope(dispatcher + job) - - onEach { - block(it) - }.launchIn(scope) - - return Closeable { job.cancel() } - } -} - -actual fun Flow.wrap(): CFlow = CFlow(this) diff --git a/shared/src/iosTest/kotlin/com/mirego/kmp/boilerplate/IosGreetingTest.kt b/shared/src/iosTest/kotlin/com/mirego/kmp/boilerplate/IosGreetingTest.kt deleted file mode 100644 index 8f79e2d..0000000 --- a/shared/src/iosTest/kotlin/com/mirego/kmp/boilerplate/IosGreetingTest.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.mirego.kmp.boilerplate - -import kotlin.test.Test -import kotlin.test.assertTrue -import kotlinx.coroutines.flow.single -import kotlinx.coroutines.test.runTest - -class IosGreetingTest { - - @Test - fun testExample() = runTest { - assertTrue(Greeting().greeting().single().contains("iOS"), "Check iOS is mentioned") - } -}