From 15902e6d8e23cb21f13791b58b7e7e7b8add82ff Mon Sep 17 00:00:00 2001 From: Nagarjuna Date: Tue, 2 Sep 2025 10:57:20 +0530 Subject: [PATCH 1/3] chore: sync missing root files and folders from KMP project template --- .github/workflows/pr-check-kmp.yml | 2 +- .../org/mifos/mobile/HierarchyTemplate.kt | 179 +++ .../org/mifos/mobile/KotlinMultiplatform.kt | 2 +- cmp-android/prodRelease-badging.txt | 178 +-- config/detekt/detekt.yml | 20 +- core-base/analytics/.gitignore | 1 + core-base/analytics/README.md | 343 +++++ core-base/analytics/build.gradle.kts | 59 + core-base/analytics/consumer-rules.pro | 0 .../core/base/analytics/di/AnalyticsModule.kt | 21 + .../src/androidMain/AndroidManifest.xml | 11 + .../di/AnalyticsModule.kt | 26 + .../core/base/analytics/AnalyticsEvent.kt | 349 +++++ .../base/analytics/AnalyticsExtensions.kt | 215 +++ .../core/base/analytics/AnalyticsHelper.kt | 357 +++++ .../base/analytics/NoOpAnalyticsHelper.kt | 52 + .../core/base/analytics/PerformanceTracker.kt | 283 ++++ .../base/analytics/StubAnalyticsHelper.kt | 62 + .../core/base/analytics/TestingUtils.kt | 234 ++++ .../template/core/base/analytics/UiHelpers.kt | 106 ++ .../core/base/analytics/ValidationUtils.kt | 403 ++++++ .../core/base/analytics/di/AnalyticsModule.kt | 14 + .../analytics/di/AnalyticsModule.desktop.kt | 22 + .../di/AnalyticsModule.js.kt | 29 + .../AnalyticsModule.native.kt | 28 + .../base/analytics/FirebaseAnalyticsHelper.kt | 117 ++ .../analytics/di/AnalyticsModule.wasmJs.kt | 22 + core-base/common/.gitignore | 1 + core-base/common/README.md | 1 + core-base/common/build.gradle.kts | 47 + core-base/common/consumer-rules.pro | 0 .../src/androidMain/AndroidManifest.xml | 13 + .../core/base/common/Parcelize.android.kt | 29 + .../base/common/di/CommonModule.android.kt | 20 + .../common/manager/DispatchManagerImpl.kt | 31 + .../template/core/base/common/DataState.kt | 52 + .../core/base/common/DataStateExtensions.kt | 129 ++ .../core/base/common/ImageExtension.kt | 102 ++ .../template/core/base/common/Parcelize.kt | 41 + .../core/base/common/di/CommonModule.kt | 19 + .../base/common/manager/DispatcherManager.kt | 38 + .../core/base/common/Parcelize.nonAndroid.kt | 46 + .../base/common/di/CommonModule.nonAndroid.kt | 20 + .../common/manager/DispatchManagerImpl.kt | 31 + core-base/database/.gitignore | 1 + core-base/database/README.md | 220 ++++ core-base/database/build.gradle.kts | 47 + .../core/base/database/AppDatabaseFactory.kt | 86 ++ .../template/core/base/database/Room.kt | 236 ++++ .../core/base/database/TypeConverter.kt | 60 + .../core/base/database/AppDatabaseFactory.kt | 123 ++ .../core/base/database/AppDatabaseFactory.kt | 160 +++ .../core/base/database/Room.nonJsCommon.kt | 71 + .../database/TypeConverter.nonJsCommon.kt | 22 + core-base/datastore/README.md | 282 ++++ core-base/datastore/build.gradle.kts | 36 + .../core/base/datastore/cache/CacheManager.kt | 76 ++ .../base/datastore/cache/LruCacheManager.kt | 132 ++ .../datastore/contracts/CacheableDataStore.kt | 98 ++ .../base/datastore/contracts/DataStore.kt | 64 + .../contracts/DataStoreChangeEvent.kt | 96 ++ .../datastore/contracts/ReactiveDataStore.kt | 81 ++ .../datastore/contracts/TypedDataStore.kt | 83 ++ .../base/datastore/di/CoreDatastoreModule.kt | 57 + .../exceptions/DataStoreExceptions.kt | 91 ++ .../datastore/extensions/FlowExtensions.kt | 162 +++ .../datastore/factory/DataStoreFactory.kt | 282 ++++ .../handlers/PrimitiveTypeHandlers.kt | 162 +++ .../base/datastore/handlers/TypeHandler.kt | 57 + .../base/datastore/reactive/ChangeNotifier.kt | 57 + .../reactive/DefaultChangeNotifier.kt | 232 ++++ .../reactive/DefaultValueObserver.kt | 79 ++ .../reactive/PreferenceFlowOperators.kt | 130 ++ .../base/datastore/reactive/ValueObserver.kt | 54 + .../DefaultReactivePreferencesRepository.kt | 152 +++ .../repository/PreferencesRepository.kt | 99 ++ .../ReactivePreferencesRepository.kt | 82 ++ .../JsonSerializationStrategy.kt | 109 ++ .../serialization/SerializationStrategy.kt | 21 + .../datastore/store/BasicPreferencesStore.kt | 208 +++ .../datastore/store/CachedPreferencesStore.kt | 371 ++++++ .../store/ReactiveUserPreferencesDataStore.kt | 414 ++++++ .../validation/DefaultPreferencesValidator.kt | 71 + .../validation/PreferencesValidator.kt | 41 + .../DataStoreComprehensiveFeatureTest.kt | 872 +++++++++++++ .../EnhancedUserPreferencesDataStoreTest.kt | 133 ++ .../ReactiveUserPreferencesDataStoreTest.kt | 220 ++++ .../datastore/UserPreferencesDataStoreTest.kt | 113 ++ .../datastore/cache/LRUCacheManagerTest.kt | 60 + .../datastore/extension/FlowExtensionsTest.kt | 99 ++ .../integration/ReactiveIntegrationTest.kt | 155 +++ .../notification/DefaultChangeNotifierTest.kt | 61 + .../operators/PreferenceFlowOperatorsTest.kt | 203 +++ .../performance/ReactivePerformanceTest.kt | 121 ++ ...ltReactiveUserPreferencesRepositoryTest.kt | 169 +++ .../JsonSerializationStrategyTest.kt | 39 + .../DefaultPreferencesValidatorTest.kt | 41 + core-base/designsystem/.gitignore | 1 + core-base/designsystem/README.md | 341 +++++ core-base/designsystem/build.gradle.kts | 49 + .../base/designsystem/KptMaterialTheme.kt | 184 +++ .../core/base/designsystem/KptTheme.kt | 69 + .../base/designsystem/KptThemeExtensions.kt | 577 +++++++++ .../designsystem/component/BounceAnimation.kt | 174 +++ .../component/KptAnimationSpecs.kt | 204 +++ .../component/KptShimmerLoadingBox.kt | 103 ++ .../designsystem/component/KptSnackbarHost.kt | 52 + .../designsystem/component/KptTopAppBar.kt | 394 ++++++ .../designsystem/component/SlideTransition.kt | 54 + .../designsystem/core/ComponentStateHolder.kt | 113 ++ .../base/designsystem/core/KptComponent.kt | 204 +++ .../core/KptTopAppBarConfiguration.kt | 291 +++++ .../layout/AdaptiveListDetailPaneScaffold.kt | 115 ++ .../AdaptiveNavigableListDetailScaffold.kt | 353 +++++ ...AdaptiveNavigableSupportingPaneScaffold.kt | 103 ++ .../layout/AdaptiveNavigationSuiteScaffold.kt | 87 ++ .../base/designsystem/layout/KptFlowColumn.kt | 104 ++ .../base/designsystem/layout/KptFlowRow.kt | 104 ++ .../core/base/designsystem/layout/KptGrid.kt | 184 +++ .../designsystem/layout/KptMasonryGrid.kt | 59 + .../layout/KptResponsiveLayout.kt | 154 +++ .../designsystem/layout/KptSidebarLayout.kt | 107 ++ .../base/designsystem/layout/KptSplitPane.kt | 64 + .../core/base/designsystem/layout/KptStack.kt | 30 + .../designsystem/theme/KptColorSchemeImpl.kt | 412 ++++++ core-base/network/.gitignore | 1 + core-base/network/README.md | 149 +++ core-base/network/build.gradle.kts | 52 + .../network/KtorHttpClient.android.kt | 18 + .../mifos/corebase/network/KtorHttpClient.kt | 162 +++ .../mifos/corebase/network/NetworkError.kt | 58 + .../mifos/corebase/network/NetworkResult.kt | 39 + .../factory/ResultSuspendConverterFactory.kt | 118 ++ .../network/KtorHttpClient.desktop.kt | 18 + .../corebase/network/KtorHttpClient.js.kt | 18 + .../corebase/network/KtorHttpClient.native.kt | 18 + .../corebase/network/KtorHttpClient.wasmJs.kt | 18 + core-base/platform/.gitignore | 1 + core-base/platform/README.md | 723 +++++++++++ core-base/platform/build.gradle.kts | 61 + core-base/platform/consumer-rules.pro | 0 .../platform/LocalManagerProviders.android.kt | 52 + .../platform/context/AppContext.android.kt | 48 + .../GarbageCollectionManager.android.kt | 14 + .../base/platform/intent/IntentManagerImpl.kt | 297 +++++ .../platform/review/AppReviewManagerImpl.kt | 79 ++ .../platform/update/AppUpdateManagerImpl.kt | 137 ++ .../base/platform/utils/AndroidBuildUtils.kt | 25 + .../base/platform/LocalManagerProviders.kt | 60 + .../core/base/platform/context/AppContext.kt | 36 + .../core/base/platform/di/PlatformModule.kt | 21 + .../garbage/GarbageCollectionManager.kt | 20 + .../garbage/GarbageCollectionManagerImpl.kt | 49 + .../base/platform/intent/IntentManager.kt | 81 ++ .../core/base/platform/model/MimeType.kt | 239 ++++ .../base/platform/review/AppReviewManager.kt | 53 + .../base/platform/update/AppUpdateManager.kt | 49 + .../platform/LocalManagerProviders.native.kt | 31 + .../platform/context/AppContext.native.kt | 27 + .../GarbageCollectionManager.nonAndroid.kt | 13 + .../base/platform/intent/IntentManagerImpl.kt | 48 + .../platform/review/AppReviewManagerImpl.kt | 22 + .../platform/update/AppUpdateManagerImpl.kt | 18 + core-base/ui/.gitignore | 1 + core-base/ui/README.md | 854 ++++++++++++ core-base/ui/build.gradle.kts | 82 ++ core-base/ui/consumer-rules.pro | 0 .../core/base/ui/JankStatsExtensions.kt | 86 ++ .../core/base/ui/ReportDrawnExt.android.kt | 17 + .../core/base/ui/ShareUtils.android.kt | 105 ++ .../template/core/base/ui/BackgroundEvent.kt | 17 + .../template/core/base/ui/BaseViewModel.kt | 100 ++ .../template/core/base/ui/EventsEffect.kt | 67 + .../template/core/base/ui/ImageLoaderExt.kt | 96 ++ .../core/base/ui/JankStatsExtension.kt | 16 + .../core/base/ui/LifecycleEventEffect.kt | 41 + .../core/base/ui/NavGraphBuilderExtensions.kt | 104 ++ .../template/core/base/ui/ReportDrawnExt.kt | 21 + .../template/core/base/ui/ShareUtils.kt | 42 + .../template/core/base/ui/SharedElementExt.kt | 33 + .../kotlin/template/core/base/ui/StringExt.kt | 21 + .../template/core/base/ui/Transition.kt | 394 ++++++ .../core/base/ui/ShareUtils.desktop.kt | 44 + .../template/core/base/ui/ShareUtils.kt | 45 + .../core/base/ui/ShareUtils.native.kt | 45 + .../core/base/ui/JankStatsExtension.jvmJs.kt | 20 + .../core/base/ui/ReportDrawnExt.jvmJs.kt | 22 + gradle/libs.versions.toml | 32 + keystore-manager.sh | 1147 +++++++++++++++++ secrets.env | 118 ++ settings.gradle.kts | 9 + sync-dirs.sh | 587 +++++++++ 192 files changed, 22574 insertions(+), 97 deletions(-) create mode 100644 build-logic/convention/src/main/kotlin/org/mifos/mobile/HierarchyTemplate.kt create mode 100644 core-base/analytics/.gitignore create mode 100644 core-base/analytics/README.md create mode 100644 core-base/analytics/build.gradle.kts create mode 100644 core-base/analytics/consumer-rules.pro create mode 100644 core-base/analytics/src/androidDemo/kotlin/template/core/base/analytics/di/AnalyticsModule.kt create mode 100644 core-base/analytics/src/androidMain/AndroidManifest.xml create mode 100644 core-base/analytics/src/androidProd/kotlin/template.core.base.analytics/di/AnalyticsModule.kt create mode 100644 core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/AnalyticsEvent.kt create mode 100644 core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/AnalyticsExtensions.kt create mode 100644 core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/AnalyticsHelper.kt create mode 100644 core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/NoOpAnalyticsHelper.kt create mode 100644 core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/PerformanceTracker.kt create mode 100644 core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/StubAnalyticsHelper.kt create mode 100644 core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/TestingUtils.kt create mode 100644 core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/UiHelpers.kt create mode 100644 core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/ValidationUtils.kt create mode 100644 core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/di/AnalyticsModule.kt create mode 100644 core-base/analytics/src/desktopMain/kotlin/template/core/base/analytics/di/AnalyticsModule.desktop.kt create mode 100644 core-base/analytics/src/jsMain/kotlin/template.core.base.analytics/di/AnalyticsModule.js.kt create mode 100644 core-base/analytics/src/nativeMain/kotlin/template.core.base.analytics.di/AnalyticsModule.native.kt create mode 100644 core-base/analytics/src/nonJsCommonMain/kotlin/template/core/base/analytics/FirebaseAnalyticsHelper.kt create mode 100644 core-base/analytics/src/wasmJsMain/kotlin/template/core/base/analytics/di/AnalyticsModule.wasmJs.kt create mode 100644 core-base/common/.gitignore create mode 100644 core-base/common/README.md create mode 100644 core-base/common/build.gradle.kts create mode 100644 core-base/common/consumer-rules.pro create mode 100644 core-base/common/src/androidMain/AndroidManifest.xml create mode 100644 core-base/common/src/androidMain/kotlin/template/core/base/common/Parcelize.android.kt create mode 100644 core-base/common/src/androidMain/kotlin/template/core/base/common/di/CommonModule.android.kt create mode 100644 core-base/common/src/androidMain/kotlin/template/core/base/common/manager/DispatchManagerImpl.kt create mode 100644 core-base/common/src/commonMain/kotlin/template/core/base/common/DataState.kt create mode 100644 core-base/common/src/commonMain/kotlin/template/core/base/common/DataStateExtensions.kt create mode 100644 core-base/common/src/commonMain/kotlin/template/core/base/common/ImageExtension.kt create mode 100644 core-base/common/src/commonMain/kotlin/template/core/base/common/Parcelize.kt create mode 100644 core-base/common/src/commonMain/kotlin/template/core/base/common/di/CommonModule.kt create mode 100644 core-base/common/src/commonMain/kotlin/template/core/base/common/manager/DispatcherManager.kt create mode 100644 core-base/common/src/nonAndroidMain/kotlin/template/core/base/common/Parcelize.nonAndroid.kt create mode 100644 core-base/common/src/nonAndroidMain/kotlin/template/core/base/common/di/CommonModule.nonAndroid.kt create mode 100644 core-base/common/src/nonAndroidMain/kotlin/template/core/base/common/manager/DispatchManagerImpl.kt create mode 100644 core-base/database/.gitignore create mode 100644 core-base/database/README.md create mode 100644 core-base/database/build.gradle.kts create mode 100644 core-base/database/src/androidMain/kotlin/template/core/base/database/AppDatabaseFactory.kt create mode 100644 core-base/database/src/commonMain/kotlin/template/core/base/database/Room.kt create mode 100644 core-base/database/src/commonMain/kotlin/template/core/base/database/TypeConverter.kt create mode 100644 core-base/database/src/desktopMain/kotlin/template/core/base/database/AppDatabaseFactory.kt create mode 100644 core-base/database/src/nativeMain/kotlin/template/core/base/database/AppDatabaseFactory.kt create mode 100644 core-base/database/src/nonJsCommonMain/kotlin/template/core/base/database/Room.nonJsCommon.kt create mode 100644 core-base/database/src/nonJsCommonMain/kotlin/template/core/base/database/TypeConverter.nonJsCommon.kt create mode 100644 core-base/datastore/README.md create mode 100644 core-base/datastore/build.gradle.kts create mode 100644 core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/cache/CacheManager.kt create mode 100644 core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/cache/LruCacheManager.kt create mode 100644 core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/contracts/CacheableDataStore.kt create mode 100644 core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/contracts/DataStore.kt create mode 100644 core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/contracts/DataStoreChangeEvent.kt create mode 100644 core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/contracts/ReactiveDataStore.kt create mode 100644 core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/contracts/TypedDataStore.kt create mode 100644 core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/di/CoreDatastoreModule.kt create mode 100644 core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/exceptions/DataStoreExceptions.kt create mode 100644 core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/extensions/FlowExtensions.kt create mode 100644 core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/factory/DataStoreFactory.kt create mode 100644 core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/handlers/PrimitiveTypeHandlers.kt create mode 100644 core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/handlers/TypeHandler.kt create mode 100644 core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/reactive/ChangeNotifier.kt create mode 100644 core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/reactive/DefaultChangeNotifier.kt create mode 100644 core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/reactive/DefaultValueObserver.kt create mode 100644 core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/reactive/PreferenceFlowOperators.kt create mode 100644 core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/reactive/ValueObserver.kt create mode 100644 core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/repository/DefaultReactivePreferencesRepository.kt create mode 100644 core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/repository/PreferencesRepository.kt create mode 100644 core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/repository/ReactivePreferencesRepository.kt create mode 100644 core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/serialization/JsonSerializationStrategy.kt create mode 100644 core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/serialization/SerializationStrategy.kt create mode 100644 core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/store/BasicPreferencesStore.kt create mode 100644 core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/store/CachedPreferencesStore.kt create mode 100644 core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/store/ReactiveUserPreferencesDataStore.kt create mode 100644 core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/validation/DefaultPreferencesValidator.kt create mode 100644 core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/validation/PreferencesValidator.kt create mode 100644 core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/DataStoreComprehensiveFeatureTest.kt create mode 100644 core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/EnhancedUserPreferencesDataStoreTest.kt create mode 100644 core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/ReactiveUserPreferencesDataStoreTest.kt create mode 100644 core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/UserPreferencesDataStoreTest.kt create mode 100644 core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/cache/LRUCacheManagerTest.kt create mode 100644 core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/extension/FlowExtensionsTest.kt create mode 100644 core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/integration/ReactiveIntegrationTest.kt create mode 100644 core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/notification/DefaultChangeNotifierTest.kt create mode 100644 core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/operators/PreferenceFlowOperatorsTest.kt create mode 100644 core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/performance/ReactivePerformanceTest.kt create mode 100644 core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/repository/DefaultReactiveUserPreferencesRepositoryTest.kt create mode 100644 core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/serialization/JsonSerializationStrategyTest.kt create mode 100644 core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/validation/DefaultPreferencesValidatorTest.kt create mode 100644 core-base/designsystem/.gitignore create mode 100644 core-base/designsystem/README.md create mode 100644 core-base/designsystem/build.gradle.kts create mode 100644 core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/KptMaterialTheme.kt create mode 100644 core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/KptTheme.kt create mode 100644 core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/KptThemeExtensions.kt create mode 100644 core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/component/BounceAnimation.kt create mode 100644 core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/component/KptAnimationSpecs.kt create mode 100644 core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/component/KptShimmerLoadingBox.kt create mode 100644 core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/component/KptSnackbarHost.kt create mode 100644 core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/component/KptTopAppBar.kt create mode 100644 core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/component/SlideTransition.kt create mode 100644 core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/core/ComponentStateHolder.kt create mode 100644 core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/core/KptComponent.kt create mode 100644 core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/core/KptTopAppBarConfiguration.kt create mode 100644 core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/AdaptiveListDetailPaneScaffold.kt create mode 100644 core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/AdaptiveNavigableListDetailScaffold.kt create mode 100644 core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/AdaptiveNavigableSupportingPaneScaffold.kt create mode 100644 core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/AdaptiveNavigationSuiteScaffold.kt create mode 100644 core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/KptFlowColumn.kt create mode 100644 core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/KptFlowRow.kt create mode 100644 core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/KptGrid.kt create mode 100644 core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/KptMasonryGrid.kt create mode 100644 core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/KptResponsiveLayout.kt create mode 100644 core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/KptSidebarLayout.kt create mode 100644 core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/KptSplitPane.kt create mode 100644 core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/KptStack.kt create mode 100644 core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/theme/KptColorSchemeImpl.kt create mode 100644 core-base/network/.gitignore create mode 100644 core-base/network/README.md create mode 100644 core-base/network/build.gradle.kts create mode 100644 core-base/network/src/androidMain/kotlin/org/mifos/corebase/network/KtorHttpClient.android.kt create mode 100644 core-base/network/src/commonMain/kotlin/org/mifos/corebase/network/KtorHttpClient.kt create mode 100644 core-base/network/src/commonMain/kotlin/org/mifos/corebase/network/NetworkError.kt create mode 100644 core-base/network/src/commonMain/kotlin/org/mifos/corebase/network/NetworkResult.kt create mode 100644 core-base/network/src/commonMain/kotlin/org/mifos/corebase/network/factory/ResultSuspendConverterFactory.kt create mode 100644 core-base/network/src/desktopMain/kotlin/org/mifos/corebase/network/KtorHttpClient.desktop.kt create mode 100644 core-base/network/src/jsMain/kotlin/org/mifos/corebase/network/KtorHttpClient.js.kt create mode 100644 core-base/network/src/nativeMain/kotlin/org/mifos/corebase/network/KtorHttpClient.native.kt create mode 100644 core-base/network/src/wasmJsMain/kotlin/org/mifos/corebase/network/KtorHttpClient.wasmJs.kt create mode 100644 core-base/platform/.gitignore create mode 100644 core-base/platform/README.md create mode 100644 core-base/platform/build.gradle.kts create mode 100644 core-base/platform/consumer-rules.pro create mode 100644 core-base/platform/src/androidMain/kotlin/template/core/base/platform/LocalManagerProviders.android.kt create mode 100644 core-base/platform/src/androidMain/kotlin/template/core/base/platform/context/AppContext.android.kt create mode 100644 core-base/platform/src/androidMain/kotlin/template/core/base/platform/garbage/GarbageCollectionManager.android.kt create mode 100644 core-base/platform/src/androidMain/kotlin/template/core/base/platform/intent/IntentManagerImpl.kt create mode 100644 core-base/platform/src/androidMain/kotlin/template/core/base/platform/review/AppReviewManagerImpl.kt create mode 100644 core-base/platform/src/androidMain/kotlin/template/core/base/platform/update/AppUpdateManagerImpl.kt create mode 100644 core-base/platform/src/androidMain/kotlin/template/core/base/platform/utils/AndroidBuildUtils.kt create mode 100644 core-base/platform/src/commonMain/kotlin/template/core/base/platform/LocalManagerProviders.kt create mode 100644 core-base/platform/src/commonMain/kotlin/template/core/base/platform/context/AppContext.kt create mode 100644 core-base/platform/src/commonMain/kotlin/template/core/base/platform/di/PlatformModule.kt create mode 100644 core-base/platform/src/commonMain/kotlin/template/core/base/platform/garbage/GarbageCollectionManager.kt create mode 100644 core-base/platform/src/commonMain/kotlin/template/core/base/platform/garbage/GarbageCollectionManagerImpl.kt create mode 100644 core-base/platform/src/commonMain/kotlin/template/core/base/platform/intent/IntentManager.kt create mode 100644 core-base/platform/src/commonMain/kotlin/template/core/base/platform/model/MimeType.kt create mode 100644 core-base/platform/src/commonMain/kotlin/template/core/base/platform/review/AppReviewManager.kt create mode 100644 core-base/platform/src/commonMain/kotlin/template/core/base/platform/update/AppUpdateManager.kt create mode 100644 core-base/platform/src/nonAndroidMain/kotlin/template/core/base/platform/LocalManagerProviders.native.kt create mode 100644 core-base/platform/src/nonAndroidMain/kotlin/template/core/base/platform/context/AppContext.native.kt create mode 100644 core-base/platform/src/nonAndroidMain/kotlin/template/core/base/platform/garbage/GarbageCollectionManager.nonAndroid.kt create mode 100644 core-base/platform/src/nonAndroidMain/kotlin/template/core/base/platform/intent/IntentManagerImpl.kt create mode 100644 core-base/platform/src/nonAndroidMain/kotlin/template/core/base/platform/review/AppReviewManagerImpl.kt create mode 100644 core-base/platform/src/nonAndroidMain/kotlin/template/core/base/platform/update/AppUpdateManagerImpl.kt create mode 100644 core-base/ui/.gitignore create mode 100644 core-base/ui/README.md create mode 100644 core-base/ui/build.gradle.kts create mode 100644 core-base/ui/consumer-rules.pro create mode 100644 core-base/ui/src/androidMain/kotlin/template/core/base/ui/JankStatsExtensions.kt create mode 100644 core-base/ui/src/androidMain/kotlin/template/core/base/ui/ReportDrawnExt.android.kt create mode 100644 core-base/ui/src/androidMain/kotlin/template/core/base/ui/ShareUtils.android.kt create mode 100644 core-base/ui/src/commonMain/kotlin/template/core/base/ui/BackgroundEvent.kt create mode 100644 core-base/ui/src/commonMain/kotlin/template/core/base/ui/BaseViewModel.kt create mode 100644 core-base/ui/src/commonMain/kotlin/template/core/base/ui/EventsEffect.kt create mode 100644 core-base/ui/src/commonMain/kotlin/template/core/base/ui/ImageLoaderExt.kt create mode 100644 core-base/ui/src/commonMain/kotlin/template/core/base/ui/JankStatsExtension.kt create mode 100644 core-base/ui/src/commonMain/kotlin/template/core/base/ui/LifecycleEventEffect.kt create mode 100644 core-base/ui/src/commonMain/kotlin/template/core/base/ui/NavGraphBuilderExtensions.kt create mode 100644 core-base/ui/src/commonMain/kotlin/template/core/base/ui/ReportDrawnExt.kt create mode 100644 core-base/ui/src/commonMain/kotlin/template/core/base/ui/ShareUtils.kt create mode 100644 core-base/ui/src/commonMain/kotlin/template/core/base/ui/SharedElementExt.kt create mode 100644 core-base/ui/src/commonMain/kotlin/template/core/base/ui/StringExt.kt create mode 100644 core-base/ui/src/commonMain/kotlin/template/core/base/ui/Transition.kt create mode 100644 core-base/ui/src/desktopMain/java/template/core/base/ui/ShareUtils.desktop.kt create mode 100644 core-base/ui/src/jsCommonMain/kotlin/template/core/base/ui/ShareUtils.kt create mode 100644 core-base/ui/src/nativeMain/kotlin/template/core/base/ui/ShareUtils.native.kt create mode 100644 core-base/ui/src/nonAndroidMain/kotlin/template/core/base/ui/JankStatsExtension.jvmJs.kt create mode 100644 core-base/ui/src/nonAndroidMain/kotlin/template/core/base/ui/ReportDrawnExt.jvmJs.kt create mode 100644 keystore-manager.sh create mode 100644 secrets.env create mode 100644 sync-dirs.sh diff --git a/.github/workflows/pr-check-kmp.yml b/.github/workflows/pr-check-kmp.yml index 97db4e044f..1a2bf541b0 100644 --- a/.github/workflows/pr-check-kmp.yml +++ b/.github/workflows/pr-check-kmp.yml @@ -82,7 +82,7 @@ permissions: jobs: pr_checks: name: PR Checks KMP - uses: openMF/mifos-x-actionhub/.github/workflows/pr-check.yaml@v1.0.0 + uses: openMF/mifos-x-actionhub/.github/workflows/pr-check.yaml@v1.0.3 secrets: inherit with: android_package_name: 'cmp-android' # <-- Change Your Android Package Name diff --git a/build-logic/convention/src/main/kotlin/org/mifos/mobile/HierarchyTemplate.kt b/build-logic/convention/src/main/kotlin/org/mifos/mobile/HierarchyTemplate.kt new file mode 100644 index 0000000000..fa059a46df --- /dev/null +++ b/build-logic/convention/src/main/kotlin/org/mifos/mobile/HierarchyTemplate.kt @@ -0,0 +1,179 @@ +/** + * Kotlin Multiplatform project hierarchy template configuration. + * + * This file defines a structured hierarchy for organizing source sets in Kotlin Multiplatform + * projects. It establishes a logical grouping of platform targets that enables efficient code + * sharing across platforms with similar characteristics. + * + * The hierarchy template creates the following logical groupings: + * - `common`: Base shared code for all platforms + * - `nonAndroid`: Code shared between JVM, JS, and native platforms, excluding Android + * - `jsCommon`: Code shared between JavaScript and WebAssembly JavaScript targets + * - `nonJsCommon`: Code shared between JVM and native platforms, excluding JS platforms + * - `jvmCommon`: Code shared between Android and JVM targets + * - `nonJvmCommon`: Code shared between JS and native platforms, excluding JVM platforms + * - `native`: Code shared across all native platforms + * - `apple`: Code shared across Apple platforms (iOS, macOS) + * - `ios`: iOS-specific code + * - `macos`: macOS-specific code + * - `nonNative`: Code shared between JS and JVM platforms + * + * This template applies to both main and test source sets, establishing a consistent + * structure throughout the project. + * + * Note: This implementation uses experimental Kotlin Gradle plugin APIs and may be subject + * to change in future Kotlin releases. + */ +@file:OptIn(ExperimentalKotlinGradlePluginApi::class) + +package org.mifos.mobile + +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import org.jetbrains.kotlin.gradle.plugin.KotlinHierarchyBuilder +import org.jetbrains.kotlin.gradle.plugin.KotlinHierarchyTemplate +import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree + +/** + * Defines the hierarchical structure for source set organization. + * + * This template establishes the relationships between different platform targets, + * creating logical groupings based on platform similarities to facilitate code sharing. + */ +private val hierarchyTemplate = KotlinHierarchyTemplate { + withSourceSetTree( + KotlinSourceSetTree.main, + KotlinSourceSetTree.test, + ) + + common { + withCompilations { true } + + groupNonAndroid() + groupJsCommon() + groupNonJsCommon() + groupJvmCommon() + groupNonJvmCommon() + groupNative() + groupNonNative() + groupJvmJsCommon() + groupMobile() + } +} + +/** + * Creates a group of non-Android platforms (JVM, JS, and native). + */ +private fun KotlinHierarchyBuilder.groupNonAndroid() { + group("nonAndroid") { + withJvm() + groupJsCommon() + groupNative() + } +} + +/** + * Creates a group of JavaScript-related platforms (JS and WebAssembly JS). + */ +private fun KotlinHierarchyBuilder.groupJsCommon() { + group("jsCommon") { + withJs() + withWasmJs() + } +} + +/** + * Creates a group of non-JavaScript platforms (JVM-based and native). + */ +private fun KotlinHierarchyBuilder.groupNonJsCommon() { + group("nonJsCommon") { + groupJvmCommon() + groupNative() + } +} + +/** + * Creates a group of JVM-based platforms (Android and JVM). + */ +private fun KotlinHierarchyBuilder.groupJvmCommon() { + group("jvmCommon") { + withAndroidTarget() + withJvm() + } +} + +/** + * Creates a group of non-JVM platforms (JavaScript and native). + */ +private fun KotlinHierarchyBuilder.groupNonJvmCommon() { + group("nonJvmCommon") { + groupJsCommon() + groupNative() + } +} + +/** + * Creates a group of JVM, JS platforms (JavaScript and JVM). + */ +private fun KotlinHierarchyBuilder.groupJvmJsCommon() { + group("jvmJsCommon") { + groupJsCommon() + withJvm() + } +} + +/** + * Creates a hierarchical group of native platforms with subgroups for Apple platforms. + */ +private fun KotlinHierarchyBuilder.groupNative() { + group("native") { + withNative() + + group("apple") { + withApple() + + group("ios") { + withIos() + } + + group("macos") { + withMacos() + } + } + } +} + +/** + * Creates a group of non-native platforms (JavaScript and JVM-based). + */ +private fun KotlinHierarchyBuilder.groupNonNative() { + group("nonNative") { + groupJsCommon() + groupJvmCommon() + } +} + +private fun KotlinHierarchyBuilder.groupMobile() { + group("mobile") { + withAndroidTarget() + withApple() + } +} + +/** + * Applies the predefined hierarchy template to a Kotlin Multiplatform project. + * + * This extension function should be called within the `kotlin` block of a Multiplatform + * project's build script to establish the source set hierarchy defined in this file. + * + * Example usage: + * ``` + * kotlin { + * applyProjectHierarchyTemplate() + * // Configure targets... + * } + * ``` + */ +fun KotlinMultiplatformExtension.applyProjectHierarchyTemplate() { + applyHierarchyTemplate(hierarchyTemplate) +} \ No newline at end of file diff --git a/build-logic/convention/src/main/kotlin/org/mifos/mobile/KotlinMultiplatform.kt b/build-logic/convention/src/main/kotlin/org/mifos/mobile/KotlinMultiplatform.kt index 50d5a4455c..8337939845 100644 --- a/build-logic/convention/src/main/kotlin/org/mifos/mobile/KotlinMultiplatform.kt +++ b/build-logic/convention/src/main/kotlin/org/mifos/mobile/KotlinMultiplatform.kt @@ -9,7 +9,7 @@ import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension @OptIn(ExperimentalWasmDsl::class, ExperimentalKotlinGradlePluginApi::class) internal fun Project.configureKotlinMultiplatform() { extensions.configure { - applyDefaultHierarchyTemplate() + applyProjectHierarchyTemplate() jvm("desktop") androidTarget() diff --git a/cmp-android/prodRelease-badging.txt b/cmp-android/prodRelease-badging.txt index bd8109b923..0314444d1f 100644 --- a/cmp-android/prodRelease-badging.txt +++ b/cmp-android/prodRelease-badging.txt @@ -1,4 +1,4 @@ -package: name='org.mifospay' versionCode='1' versionName='2024.12.4-beta.0.4' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15' +package: name='org.mifos.mobile' versionCode='1' versionName='2024.12.4-beta.0.4' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15' sdkVersion:'26' targetSdkVersion:'34' uses-permission: name='android.permission.INTERNET' @@ -12,100 +12,100 @@ uses-permission: name='android.permission.WAKE_LOCK' uses-permission: name='com.google.android.finsky.permission.BIND_GET_INSTALL_REFERRER_SERVICE' uses-permission: name='android.permission.ACCESS_ADSERVICES_ATTRIBUTION' uses-permission: name='android.permission.ACCESS_ADSERVICES_AD_ID' -uses-permission: name='org.mifospay.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION' -application-label:'Mifos Pay' -application-label-af:'Mifos Pay' -application-label-am:'Mifos Pay' -application-label-ar:'Mifos Pay' -application-label-as:'Mifos Pay' -application-label-az:'Mifos Pay' -application-label-be:'Mifos Pay' -application-label-bg:'Mifos Pay' -application-label-bn:'Mifos Pay' -application-label-bs:'Mifos Pay' -application-label-ca:'Mifos Pay' -application-label-cs:'Mifos Pay' -application-label-da:'Mifos Pay' -application-label-de:'Mifos Pay' -application-label-el:'Mifos Pay' -application-label-en-AU:'Mifos Pay' -application-label-en-CA:'Mifos Pay' -application-label-en-GB:'Mifos Pay' -application-label-en-IN:'Mifos Pay' -application-label-en-XC:'Mifos Pay' -application-label-es:'Mifos Pay' -application-label-es-US:'Mifos Pay' -application-label-et:'Mifos Pay' -application-label-eu:'Mifos Pay' -application-label-fa:'Mifos Pay' -application-label-fi:'Mifos Pay' -application-label-fr:'Mifos Pay' -application-label-fr-CA:'Mifos Pay' -application-label-gl:'Mifos Pay' -application-label-gu:'Mifos Pay' -application-label-hi:'Mifos Pay' -application-label-hr:'Mifos Pay' -application-label-hu:'Mifos Pay' -application-label-hy:'Mifos Pay' -application-label-in:'Mifos Pay' -application-label-is:'Mifos Pay' -application-label-it:'Mifos Pay' -application-label-iw:'Mifos Pay' -application-label-ja:'Mifos Pay' -application-label-ka:'Mifos Pay' -application-label-kk:'Mifos Pay' -application-label-km:'Mifos Pay' -application-label-kn:'Mifos Pay' -application-label-ko:'Mifos Pay' -application-label-ky:'Mifos Pay' -application-label-lo:'Mifos Pay' -application-label-lt:'Mifos Pay' -application-label-lv:'Mifos Pay' -application-label-mk:'Mifos Pay' -application-label-ml:'Mifos Pay' -application-label-mn:'Mifos Pay' -application-label-mr:'Mifos Pay' -application-label-ms:'Mifos Pay' -application-label-my:'Mifos Pay' -application-label-nb:'Mifos Pay' -application-label-ne:'Mifos Pay' -application-label-nl:'Mifos Pay' -application-label-or:'Mifos Pay' -application-label-pa:'Mifos Pay' -application-label-pl:'Mifos Pay' -application-label-pt:'Mifos Pay' -application-label-pt-BR:'Mifos Pay' -application-label-pt-PT:'Mifos Pay' -application-label-ro:'Mifos Pay' -application-label-ru:'Mifos Pay' -application-label-si:'Mifos Pay' -application-label-sk:'Mifos Pay' -application-label-sl:'Mifos Pay' -application-label-sq:'Mifos Pay' -application-label-sr:'Mifos Pay' -application-label-sr-Latn:'Mifos Pay' -application-label-sv:'Mifos Pay' -application-label-sw:'Mifos Pay' -application-label-ta:'Mifos Pay' -application-label-te:'Mifos Pay' -application-label-th:'Mifos Pay' -application-label-tl:'Mifos Pay' -application-label-tr:'Mifos Pay' -application-label-uk:'Mifos Pay' -application-label-ur:'Mifos Pay' -application-label-uz:'Mifos Pay' -application-label-vi:'Mifos Pay' -application-label-zh-CN:'Mifos Pay' -application-label-zh-HK:'Mifos Pay' -application-label-zh-TW:'Mifos Pay' -application-label-zu:'Mifos Pay' +uses-permission: name='org.mifos.mobile.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION' +application-label:'Mifos Mobile' +application-label-af:'Mifos Mobile' +application-label-am:'Mifos Mobile' +application-label-ar:'Mifos Mobile' +application-label-as:'Mifos Mobile' +application-label-az:'Mifos Mobile' +application-label-be:'Mifos Mobile' +application-label-bg:'Mifos Mobile' +application-label-bn:'Mifos Mobile' +application-label-bs:'Mifos Mobile' +application-label-ca:'Mifos Mobile' +application-label-cs:'Mifos Mobile' +application-label-da:'Mifos Mobile' +application-label-de:'Mifos Mobile' +application-label-el:'Mifos Mobile' +application-label-en-AU:'Mifos Mobile' +application-label-en-CA:'Mifos Mobile' +application-label-en-GB:'Mifos Mobile' +application-label-en-IN:'Mifos Mobile' +application-label-en-XC:'Mifos Mobile' +application-label-es:'Mifos Mobile' +application-label-es-US:'Mifos Mobile' +application-label-et:'Mifos Mobile' +application-label-eu:'Mifos Mobile' +application-label-fa:'Mifos Mobile' +application-label-fi:'Mifos Mobile' +application-label-fr:'Mifos Mobile' +application-label-fr-CA:'Mifos Mobile' +application-label-gl:'Mifos Mobile' +application-label-gu:'Mifos Mobile' +application-label-hi:'Mifos Mobile' +application-label-hr:'Mifos Mobile' +application-label-hu:'Mifos Mobile' +application-label-hy:'Mifos Mobile' +application-label-in:'Mifos Mobile' +application-label-is:'Mifos Mobile' +application-label-it:'Mifos Mobile' +application-label-iw:'Mifos Mobile' +application-label-ja:'Mifos Mobile' +application-label-ka:'Mifos Mobile' +application-label-kk:'Mifos Mobile' +application-label-km:'Mifos Mobile' +application-label-kn:'Mifos Mobile' +application-label-ko:'Mifos Mobile' +application-label-ky:'Mifos Mobile' +application-label-lo:'Mifos Mobile' +application-label-lt:'Mifos Mobile' +application-label-lv:'Mifos Mobile' +application-label-mk:'Mifos Mobile' +application-label-ml:'Mifos Mobile' +application-label-mn:'Mifos Mobile' +application-label-mr:'Mifos Mobile' +application-label-ms:'Mifos Mobile' +application-label-my:'Mifos Mobile' +application-label-nb:'Mifos Mobile' +application-label-ne:'Mifos Mobile' +application-label-nl:'Mifos Mobile' +application-label-or:'Mifos Mobile' +application-label-pa:'Mifos Mobile' +application-label-pl:'Mifos Mobile' +application-label-pt:'Mifos Mobile' +application-label-pt-BR:'Mifos Mobile' +application-label-pt-PT:'Mifos Mobile' +application-label-ro:'Mifos Mobile' +application-label-ru:'Mifos Mobile' +application-label-si:'Mifos Mobile' +application-label-sk:'Mifos Mobile' +application-label-sl:'Mifos Mobile' +application-label-sq:'Mifos Mobile' +application-label-sr:'Mifos Mobile' +application-label-sr-Latn:'Mifos Mobile' +application-label-sv:'Mifos Mobile' +application-label-sw:'Mifos Mobile' +application-label-ta:'Mifos Mobile' +application-label-te:'Mifos Mobile' +application-label-th:'Mifos Mobile' +application-label-tl:'Mifos Mobile' +application-label-tr:'Mifos Mobile' +application-label-uk:'Mifos Mobile' +application-label-ur:'Mifos Mobile' +application-label-uz:'Mifos Mobile' +application-label-vi:'Mifos Mobile' +application-label-zh-CN:'Mifos Mobile' +application-label-zh-HK:'Mifos Mobile' +application-label-zh-TW:'Mifos Mobile' +application-label-zu:'Mifos Mobile' application-icon-160:'res/mipmap-anydpi-v26/ic_launcher.xml' application-icon-240:'res/mipmap-anydpi-v26/ic_launcher.xml' application-icon-320:'res/mipmap-anydpi-v26/ic_launcher.xml' application-icon-480:'res/mipmap-anydpi-v26/ic_launcher.xml' application-icon-640:'res/mipmap-anydpi-v26/ic_launcher.xml' application-icon-65534:'res/mipmap-anydpi-v26/ic_launcher.xml' -application: label='Mifos Pay' icon='res/mipmap-anydpi-v26/ic_launcher.xml' +application: label='Mifos Mobile' icon='res/mipmap-anydpi-v26/ic_launcher.xml' launchable-activity: name='org.mifospay.MainActivity' label='' icon='' property: name='android.adservices.AD_SERVICES_CONFIG' resource='res/xml/ga_ad_services_config.xml' uses-library-not-required:'androidx.window.extensions' diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index 8310f05ff3..8bcd830b72 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -451,12 +451,16 @@ naming: active: true mustBeFirst: true excludes: - - "**/*.jvm.kt" - - "**/*.desktop.kt" - - "**/*.wasmJs.kt" - - "**/*.native.kt" - - "**/*.js.kt" - - "**/*.android.kt" + [ + "**/*.android.*", + "**/*.desktop.*", + "**/*.js.*", + "**/*.native.*", + "**/*.jvm.*", + "**/*.linux.*", + "**/*.macos.*", + "**/*.wasmJs.*", + ] MemberNameEqualsClassName: active: true ignoreOverridden: true @@ -472,6 +476,10 @@ naming: PackageNaming: active: true packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*' + excludes: + [ + "**/generated/**", + ] TopLevelPropertyNaming: active: true constantPattern: "[A-Z][_A-Z0-9]*" diff --git a/core-base/analytics/.gitignore b/core-base/analytics/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/core-base/analytics/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core-base/analytics/README.md b/core-base/analytics/README.md new file mode 100644 index 0000000000..2d37435b14 --- /dev/null +++ b/core-base/analytics/README.md @@ -0,0 +1,343 @@ +# :core-base:analytics module + +## Overview + +The base analytics library provides a comprehensive foundation for tracking user interactions, +performance metrics, and business events across all platforms in a Kotlin Multiplatform project. +This module offers type-safe analytics with extensive validation, testing utilities, and performance +tracking capabilities. + +### Enhanced Analytics Events + +- **Type-Safe Parameters**: Automatic validation of parameter keys (≤40 chars) and values (≤100 + chars) +- **Builder Pattern**: Fluent API for event creation with `withParam()` and `withParams()` +- **25+ Predefined Event Types**: From navigation to authentication, forms to performance tracking +- **Comprehensive Parameter Keys**: 40+ standardized parameter keys for consistent tracking + +### Powerful Analytics Interface + +- **Multiple Convenience Methods**: Simplified logging with `logEvent()` overloads +- **Built-in Common Events**: `logScreenView()`, `logButtonClick()`, `logError()`,`logFeatureUsed()` +- **User Management**: Support for `setUserProperty()` and `setUserId()` +- **Platform Abstraction**: Works seamlessly across Android, iOS, Desktop, and Web + +### Advanced Extension Functions + +- **Event Builders**: Factory methods for creating common events with validation +- **Performance Timing**: + - `startTiming()` and `timeExecution()` for measuring operation durations + - `TimedEvent` class for manual timing control +- **Batch Processing**: `AnalyticsBatch` for efficient multiple event logging +- **Safe Parameter Creation**: Robust validation helpers for dynamic data + +### Jetpack Compose Integration + +- **Declarative Tracking**: `TrackScreenView()` composable for automatic screen analytics +- **Modifier Extensions**: `Modifier.trackClick()` for effortless interaction tracking +- **Lifecycle Tracking**: `TrackComposableLifecycle()` for component enter/exit analytics +- **Helper Functions**: `rememberAnalytics()` for easy composition local access + +### Performance Monitoring + +- **Operation Timing**: Comprehensive timing utilities with automatic slow operation detection +- **Memory Tracking**: Real-time memory usage monitoring with automatic warnings +- **App Lifecycle**: Track app launch times, background/foreground transitions +- **Performance Statistics**: Percentile-based performance analysis (P95, P99) + +### Testing & Validation + +- **Test Analytics Helper**: Complete event capture and verification for unit tests +- **Mock Analytics**: Network delay and failure simulation for robust testing +- **Data Validation**: Comprehensive validation against analytics platform constraints +- **Sanitization**: Automatic data cleaning for invalid parameters + +### Platform Support + +- ✅ **Android**: Full Firebase Analytics integration +- ✅ **iOS**: Firebase Analytics via `nonJsCommonMain` +- ✅ **Desktop**: Development-friendly stub implementation +- ✅ **Web (JS)**: Configurable Firebase/stub implementation +- ✅ **Native**: Firebase Analytics support + +## 📖 Usage Examples + +### Basic Event Logging + +```kotlin +// Simple event +analyticsHelper.logEvent("button_clicked", "button_name" to "save") + +// Using convenience methods +analyticsHelper.logScreenView("UserProfile") +analyticsHelper.logButtonClick("edit_profile", "UserProfile") +analyticsHelper.logError("Network error", "NET_001", "UserProfile") + +// Builder pattern +val event = AnalyticsEvent("form_submitted") + .withParam("form_name", "user_registration") + .withParam("field_count", "8") + .withParam("completion_time", "120s") +analyticsHelper.logEvent(event) +``` + +### Performance Tracking + +```kotlin +// Time a suspend function +val data = analyticsHelper.timePerformance("api_call") { + apiService.fetchUserData() +} + +// Manual timing +val timer = analyticsHelper.startTiming("data_processing") +processData() +timer.complete() + +// Memory monitoring +val memoryTracker = analyticsHelper.memoryTracker() +memoryTracker.logMemoryUsage("after_data_load") +``` + +### Compose Integration + +```kotlin +@Composable +fun UserProfileScreen() { + TrackScreenView("UserProfile") + + val analytics = rememberAnalytics() + + Button( + modifier = Modifier.trackClick("edit_profile", analytics, "UserProfile"), + onClick = { /* edit profile */ } + ) { + Text("Edit Profile") + } +} +``` + +### Batch Processing + +```kotlin +analyticsHelper.batch() + .add("user_registered", "user_id" to "12345") + .add("email_verified", "verification_method" to "link") + .add("profile_completed", "completion_percentage" to "100") + .flush() +``` + +### Testing + +```kotlin +@Test +fun testAnalyticsTracking() { + val testAnalytics = createTestAnalyticsHelper() + + // Use your component with test analytics + userService.registerUser("john@example.com", testAnalytics) + + // Verify analytics were logged + testAnalytics.assertEventLogged( + "user_registered", + mapOf("email_domain" to "example.com") + ) + testAnalytics.assertEventCount("user_registered", 1) + + // Check specific events + assert(testAnalytics.hasEvent("email_verification_sent")) +} +``` + +### Data Validation + +```kotlin +// Automatic validation and sanitization +val validatingAnalytics = analyticsHelper.withValidation( + strictMode = false, // Sanitize invalid data instead of throwing + logValidationErrors = true +) + +// This will be automatically sanitized if invalid +validatingAnalytics.logEvent("user-action-with-invalid-chars", "param" to "value") + +// Manual validation +val event = AnalyticsEvent("my_event", listOf(Param("key", "value"))) +val result = event.validate() +if (!result.isValid) { + println("Validation errors: ${result.errors}") +} +``` + +## 🏗️ Architecture + +### Core Components + +1. **AnalyticsEvent**: Type-safe event representation with builder pattern +2. **AnalyticsHelper**: Platform-agnostic analytics interface +3. **Platform Implementations**: + - `FirebaseAnalyticsHelper` for production + - `StubAnalyticsHelper` for development + - `NoOpAnalyticsHelper` for testing +4. **Extension Functions**: Utility methods for common operations +5. **Validation Layer**: Data quality assurance +6. **Testing Utilities**: Comprehensive test support + +### Design Principles + +- **Type Safety**: Compile-time safety for analytics parameters +- **Platform Agnostic**: Write once, track everywhere +- **Performance Conscious**: Minimal overhead with batch processing +- **Developer Friendly**: Rich testing and debugging tools +- **Extensible**: Easy to add custom tracking methods + +## 🔧 Integration + +### Dependencies + +```kotlin +// In your module's build.gradle.kts +dependencies { + implementation(projects.coreBase.analytics) + + // Platform-specific dependencies are handled automatically +} +``` + +### Dependency Injection (Koin) + +```kotlin +val analyticsModule = module { + // The actual implementation is provided by platform-specific modules + // Android: FirebaseAnalyticsHelper + // Desktop: StubAnalyticsHelper + // etc. +} +``` + +### Compose Setup + +```kotlin +@Composable +fun App() { + val analytics: AnalyticsHelper = koinInject() + + CompositionLocalProvider( + LocalAnalyticsHelper provides analytics + ) { + // Your app content + } +} +``` + +## 📋 Event Types Reference + +### Navigation Events + +- `SCREEN_VIEW`, `SCREEN_TRANSITION` + +### User Interactions + +- `BUTTON_CLICK`, `MENU_ITEM_SELECTED`, `SEARCH_PERFORMED`, `FILTER_APPLIED` + +### Form Events + +- `FORM_STARTED`, `FORM_COMPLETED`, `FORM_ABANDONED`, `FIELD_VALIDATION_ERROR` + +### Content Events + +- `CONTENT_VIEW`, `CONTENT_SHARED`, `CONTENT_LIKED` + +### Error Events + +- `ERROR_OCCURRED`, `API_ERROR`, `NETWORK_ERROR` + +### Performance Events + +- `APP_LAUNCH`, `APP_BACKGROUND`, `APP_FOREGROUND`, `LOADING_TIME` + +### Authentication Events + +- `LOGIN_ATTEMPT`, `LOGIN_SUCCESS`, `LOGIN_FAILURE`, `LOGOUT`, `SIGNUP_ATTEMPT`, `SIGNUP_SUCCESS` + +### Feature Usage + +- `FEATURE_USED`, `TUTORIAL_STARTED`, `TUTORIAL_COMPLETED`, `TUTORIAL_SKIPPED` + +## 🔒 Privacy & Compliance + +- **No PII Logging**: Framework prevents logging of personally identifiable information +- **Data Validation**: Automatic parameter validation prevents sensitive data leakage +- **Configurable**: Easy to disable or mock for privacy-compliant testing +- **Transparent**: All logged data is visible and controllable + +## 🚀 Performance Characteristics + +- **Minimal Overhead**: Event creation is lightweight with lazy validation +- **Batch Processing**: Efficient bulk event logging +- **Memory Conscious**: Automatic memory usage monitoring and warnings +- **Network Optimized**: Platform implementations handle network efficiency + +## 🧪 Testing Features + +- **Complete Event Capture**: Test helpers capture all analytics for verification +- **Assertion Helpers**: Rich assertion methods for common verification patterns +- **Mock Analytics**: Simulate network conditions and failures +- **Debug Output**: Pretty-print analytics events for debugging + +This module provides the foundation for comprehensive analytics tracking while maintaining code +quality, performance, and developer experience across all platforms. + +## 📚 API Documentation + +All classes and methods in this module are comprehensively documented with KDoc. The documentation +includes: + +### Core API Classes + +- **[AnalyticsEvent](src/commonMain/kotlin/template/core/base/analytics/AnalyticsEvent.kt)**: + Type-safe event representation with builder pattern and validation +- **[AnalyticsHelper](src/commonMain/kotlin/template/core/base/analytics/AnalyticsHelper.kt)**: + Platform-agnostic analytics interface with convenience methods +- **[Param](src/commonMain/kotlin/template/core/base/analytics/AnalyticsEvent.kt)**: Validated + parameter class with automatic constraint checking +- **[Types](src/commonMain/kotlin/template/core/base/analytics/AnalyticsEvent.kt)**: Standard event + type constants organized by category +- **[ParamKeys](src/commonMain/kotlin/template/core/base/analytics/AnalyticsEvent.kt)**: Standard + parameter key constants for consistency + +### Extension Functions + +- **[AnalyticsExtensions](src/commonMain/kotlin/template/core/base/analytics/AnalyticsExtensions.kt) + **: Builder functions, timing utilities, and batch processing +- **[PerformanceTracker](src/commonMain/kotlin/template/core/base/analytics/PerformanceTracker.kt) + **: Advanced performance monitoring and timing utilities +- **[UiHelpers](src/commonMain/kotlin/template/core/base/analytics/UiHelpers.kt)**: Jetpack Compose + integration helpers + +### Implementations + +- * + *[FirebaseAnalyticsHelper](src/nonJsCommonMain/kotlin/template/core/base/analytics/FirebaseAnalyticsHelper.kt) + **: Production Firebase Analytics implementation +- **[StubAnalyticsHelper](src/commonMain/kotlin/template/core/base/analytics/StubAnalyticsHelper.kt) + **: Development implementation with console logging +- **[NoOpAnalyticsHelper](src/commonMain/kotlin/template/core/base/analytics/NoOpAnalyticsHelper.kt) + **: No-operation implementation for testing + +### Testing & Validation + +- **[TestingUtils](src/commonMain/kotlin/template/core/base/analytics/TestingUtils.kt)**: + Comprehensive testing utilities and mock implementations +- **[ValidationUtils](src/commonMain/kotlin/template/core/base/analytics/ValidationUtils.kt)**: Data + validation and sanitization utilities + +### Documentation Features + +- ✅ **Detailed Descriptions**: Every class and method has comprehensive documentation +- ✅ **Parameter Documentation**: All parameters documented with @param tags +- ✅ **Usage Examples**: @sample blocks with practical code examples +- ✅ **Cross-References**: @see tags linking related functionality +- ✅ **Platform Notes**: Platform-specific behavior and constraints documented +- ✅ **Error Conditions**: Exception throwing conditions clearly documented +- ✅ **Since Tags**: Version information for API tracking diff --git a/core-base/analytics/build.gradle.kts b/core-base/analytics/build.gradle.kts new file mode 100644 index 0000000000..1ac6c8a30b --- /dev/null +++ b/core-base/analytics/build.gradle.kts @@ -0,0 +1,59 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +plugins { + alias(libs.plugins.mifos.kmp.library) + alias(libs.plugins.jetbrainsCompose) + alias(libs.plugins.compose.compiler) +} + +android { + namespace = "template.core.base.analytics" +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(libs.koin.core) + implementation(compose.runtime) + implementation(compose.ui) + implementation(compose.foundation) + implementation(libs.kermit.logging) + + // For timing and performance tracking + implementation(libs.kotlinx.datetime) + } + + androidMain.dependencies { + api(libs.gitlive.firebase.analytics) + } + + nonJsCommonMain.dependencies { + api(libs.gitlive.firebase.analytics) + } + + nativeMain.dependencies { + api(libs.gitlive.firebase.analytics) + } + + desktopMain.dependencies { + api(libs.gitlive.firebase.analytics) + } + + mobileMain.dependencies { + api(libs.gitlive.firebase.crashlytics) + } + + // Test dependencies for all platforms + commonTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } + } +} diff --git a/core-base/analytics/consumer-rules.pro b/core-base/analytics/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/core-base/analytics/src/androidDemo/kotlin/template/core/base/analytics/di/AnalyticsModule.kt b/core-base/analytics/src/androidDemo/kotlin/template/core/base/analytics/di/AnalyticsModule.kt new file mode 100644 index 0000000000..90f9f515de --- /dev/null +++ b/core-base/analytics/src/androidDemo/kotlin/template/core/base/analytics/di/AnalyticsModule.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.analytics.di + +import org.koin.core.module.Module +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.bind +import org.koin.dsl.module +import template.core.base.analytics.AnalyticsHelper +import template.core.base.analytics.StubAnalyticsHelper + +actual val analyticsModule: Module = module { + singleOf(::StubAnalyticsHelper) bind AnalyticsHelper::class +} diff --git a/core-base/analytics/src/androidMain/AndroidManifest.xml b/core-base/analytics/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000000..37d31716e4 --- /dev/null +++ b/core-base/analytics/src/androidMain/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + \ No newline at end of file diff --git a/core-base/analytics/src/androidProd/kotlin/template.core.base.analytics/di/AnalyticsModule.kt b/core-base/analytics/src/androidProd/kotlin/template.core.base.analytics/di/AnalyticsModule.kt new file mode 100644 index 0000000000..3f398c109b --- /dev/null +++ b/core-base/analytics/src/androidProd/kotlin/template.core.base.analytics/di/AnalyticsModule.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +@file:Suppress("InvalidPackageDeclaration") + +package template.core.base.analytics.di + +import dev.gitlive.firebase.Firebase +import dev.gitlive.firebase.analytics.FirebaseAnalytics +import dev.gitlive.firebase.analytics.analytics +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.bind +import org.koin.dsl.module +import template.core.base.analytics.AnalyticsHelper +import template.core.base.analytics.FirebaseAnalyticsHelper + +actual val analyticsModule = module { + single { Firebase.analytics } + singleOf(::FirebaseAnalyticsHelper) bind AnalyticsHelper::class +} diff --git a/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/AnalyticsEvent.kt b/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/AnalyticsEvent.kt new file mode 100644 index 0000000000..d98030a9e4 --- /dev/null +++ b/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/AnalyticsEvent.kt @@ -0,0 +1,349 @@ +/* + * Copyright 2023 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.analytics + +/** + * Represents an analytics event with type-safe parameter validation. + * + * This data class encapsulates all information needed to log an analytics event, + * including the event type and associated parameters. It provides a builder pattern + * through extension methods for convenient event construction. + * + * @param type The event type identifier. Use predefined constants from [Types] when possible, + * or define custom events that are configured in your analytics backend + * (e.g., Firebase Analytics custom events). Must be non-blank and follow + * analytics platform naming conventions. + * @param extras List of key-value parameters that provide additional context for the event. + * Each parameter is validated according to analytics platform constraints + * (key ≤ 40 chars, value ≤ 100 chars). See [Param] for details. + * + * @see Types for standard event type constants + * @see ParamKeys for standard parameter key constants + * @see Param for parameter validation rules + * + * @sample + * ```kotlin + * // Simple event + * val event = AnalyticsEvent(Types.BUTTON_CLICK) + * + * // Event with parameters using builder pattern + * val event = AnalyticsEvent(Types.SCREEN_VIEW) + * .withParam(ParamKeys.SCREEN_NAME, "UserProfile") + * .withParam(ParamKeys.SOURCE_SCREEN, "Dashboard") + * + * // Event with multiple parameters + * val event = AnalyticsEvent(Types.FORM_COMPLETED) + * .withParams( + * ParamKeys.FORM_NAME to "registration", + * ParamKeys.COMPLETION_TIME to "45s", + * "field_count" to "8" + * ) + * ``` + * + * @since 1.0.0 + */ +data class AnalyticsEvent( + val type: String, + val extras: List = emptyList(), +) { + /** + * Adds a single parameter to this analytics event using the builder pattern. + * + * This method creates a new [AnalyticsEvent] instance with the additional parameter, + * following immutable design principles. The parameter will be validated according + * to analytics platform constraints. + * + * @param key The parameter key identifier. Must be non-blank, ≤ 40 characters, + * and follow valid naming conventions (letters, numbers, underscores). + * Use [ParamKeys] constants when possible. + * @param value The parameter value. Must be ≤ 100 characters. Can be any string + * representing the parameter data. + * + * @return A new [AnalyticsEvent] instance with the added parameter + * @throws IllegalArgumentException if the parameter violates validation constraints + * + * @see ParamKeys for standard parameter key constants + * @see Param for parameter validation details + * + * @sample + * ```kotlin + * val event = AnalyticsEvent(Types.BUTTON_CLICK) + * .withParam(ParamKeys.BUTTON_NAME, "save") + * .withParam(ParamKeys.SCREEN_NAME, "UserProfile") + * ``` + */ + fun withParam(key: String, value: String): AnalyticsEvent { + return copy(extras = extras + Param(key, value)) + } + + /** + * Adds multiple parameters to this analytics event using vararg syntax. + * + * This method provides a convenient way to add multiple parameters at once + * using Kotlin's vararg feature. Each parameter pair will be converted to + * a [Param] instance and validated. + * + * @param params Variable number of parameter pairs (key to value). Each key + * and value must meet the same validation constraints as [withParam]. + * + * @return A new [AnalyticsEvent] instance with all the added parameters + * @throws IllegalArgumentException if any parameter violates validation constraints + * + * @see withParam for single parameter addition + * @see ParamKeys for standard parameter key constants + * + * @sample + * ```kotlin + * val event = AnalyticsEvent(Types.SEARCH_PERFORMED) + * .withParams( + * ParamKeys.SEARCH_TERM to "kotlin", + * ParamKeys.RESULT_COUNT to "42", + * ParamKeys.SCREEN_NAME to "SearchResults" + * ) + * ``` + */ + fun withParams(vararg params: Pair): AnalyticsEvent { + val newParams = params.map { Param(it.first, it.second) } + return copy(extras = extras + newParams) + } + + /** + * Adds multiple parameters to this analytics event from a Map. + * + * This method allows adding parameters from an existing Map, + * which is useful when working with dynamic parameter sets or converting + * from other data structures. + * + * @param params A map containing parameter key-value pairs. Each entry + * will be converted to a [Param] instance and validated. + * + * @return A new [AnalyticsEvent] instance with all the added parameters + * @throws IllegalArgumentException if any parameter violates validation constraints + * + * @see withParam for single parameter addition + * @see withParams for vararg parameter addition + * + * @sample + * ```kotlin + * val dynamicParams = mapOf( + * ParamKeys.USER_TYPE to "premium", + * ParamKeys.APP_VERSION to "2.1.0", + * "custom_metric" to "enabled" + * ) + * val event = AnalyticsEvent(Types.FEATURE_USED) + * .withParams(dynamicParams) + * ``` + */ + fun withParams(params: Map): AnalyticsEvent { + val newParams = params.map { Param(it.key, it.value) } + return copy(extras = extras + newParams) + } +} + +/** + * Standard analytics event type constants for consistent cross-platform event logging. + * + * This object provides predefined event type constants that follow analytics platform + * best practices and naming conventions. Using these constants ensures consistency + * across your application and compatibility with analytics backends like Firebase Analytics. + * + * Event types are organized into logical categories: + * - **Navigation**: Screen views and navigation tracking + * - **User Interactions**: Clicks, selections, and user-initiated actions + * - **Forms**: Form lifecycle and validation events + * - **Content**: Content engagement and interaction + * - **Errors**: Error tracking and debugging + * - **Performance**: App performance and timing metrics + * - **Authentication**: User authentication and session management + * - **Feature Usage**: Feature adoption and usage patterns + * + * @see ParamKeys for corresponding parameter key constants + * @see AnalyticsEvent for usage examples + * + * @since 1.0.0 + */ +object Types { + // Navigation events + const val SCREEN_VIEW = "screen_view" + const val SCREEN_TRANSITION = "screen_transition" + + // User interaction events + const val BUTTON_CLICK = "button_click" + const val MENU_ITEM_SELECTED = "menu_item_selected" + const val SEARCH_PERFORMED = "search_performed" + const val FILTER_APPLIED = "filter_applied" + + // Form events + const val FORM_STARTED = "form_started" + const val FORM_COMPLETED = "form_completed" + const val FORM_ABANDONED = "form_abandoned" + const val FIELD_VALIDATION_ERROR = "field_validation_error" + + // Content events + const val CONTENT_VIEW = "content_view" + const val CONTENT_SHARED = "content_shared" + const val CONTENT_LIKED = "content_liked" + + // Error events + const val ERROR_OCCURRED = "error_occurred" + const val API_ERROR = "api_error" + const val NETWORK_ERROR = "network_error" + + // Performance events + const val APP_LAUNCH = "app_launch" + const val APP_BACKGROUND = "app_background" + const val APP_FOREGROUND = "app_foreground" + const val LOADING_TIME = "loading_time" + + // Authentication events + const val LOGIN_ATTEMPT = "login_attempt" + const val LOGIN_SUCCESS = "login_success" + const val LOGIN_FAILURE = "login_failure" + const val LOGOUT = "logout" + const val SIGNUP_ATTEMPT = "signup_attempt" + const val SIGNUP_SUCCESS = "signup_success" + + // Feature usage + const val FEATURE_USED = "feature_used" + const val TUTORIAL_STARTED = "tutorial_started" + const val TUTORIAL_COMPLETED = "tutorial_completed" + const val TUTORIAL_SKIPPED = "tutorial_skipped" +} + +/** + * Represents a validated analytics parameter with automatic constraint checking. + * + * This data class encapsulates a key-value pair for analytics events with built-in + * validation that enforces analytics platform constraints. The validation occurs + * during object construction to ensure data integrity. + * + * **Validation Rules:** + * - Key must be non-blank + * - Key must be ≤ 40 characters (Firebase Analytics constraint) + * - Value must be ≤ 100 characters (Firebase Analytics constraint) + * - Key should follow naming conventions (letters, numbers, underscores) + * + * @param key The parameter identifier. Use [ParamKeys] constants when possible + * for consistency and to avoid typos. + * @param value The parameter value as a string. All values are stored as strings + * regardless of their original type. + * + * @throws IllegalArgumentException if validation constraints are violated + * + * @see ParamKeys for standard parameter key constants + * @see AnalyticsEvent.withParam for usage in event construction + * @see createParam for safe parameter creation with validation + * + * @sample + * ```kotlin + * // Valid parameter + * val param = Param(ParamKeys.SCREEN_NAME, "UserProfile") + * + * // This would throw IllegalArgumentException (key too long) + * // val invalid = Param("this_key_is_way_too_long_and_exceeds_forty_characters", "value") + * + * // This would throw IllegalArgumentException (value too long) + * // val invalid = Param("key", "very long value..." + "x".repeat(100)) + * ``` + * + * @since 1.0.0 + */ +data class Param(val key: String, val value: String) { + init { + require(key.isNotBlank()) { "Parameter key cannot be blank" } + require(key.length <= 40) { "Parameter key cannot exceed 40 characters" } + require(value.length <= 100) { "Parameter value cannot exceed 100 characters" } + } +} + +/** + * Standard parameter key constants for consistent analytics event parameters. + * + * This object provides predefined parameter key constants that ensure consistency + * across analytics events and prevent typos in parameter naming. These keys follow + * analytics platform best practices and are organized into logical categories for + * easy discovery and usage. + * + * **Parameter Categories:** + * - **Screen & Navigation**: Screen names, navigation context, and flow tracking + * - **User Interaction**: UI element identification and interaction context + * - **Content**: Content identification, categorization, and engagement + * - **Search & Filters**: Search terms, filter states, and result information + * - **Forms**: Form identification, field tracking, and completion metrics + * - **Performance**: Timing, error tracking, and performance metrics + * - **User Attributes**: User identification and characteristic data + * - **Feature Usage**: Feature identification and usage patterns + * - **General**: Common parameters used across multiple event types + * + * **Usage Guidelines:** + * - Always use these constants instead of hardcoded strings + * - Keys are designed to be ≤ 40 characters (analytics platform constraint) + * - Values should be kept ≤ 100 characters when possible + * - Combine with [Types] constants for consistent event structure + * + * @see Types for corresponding event type constants + * @see Param for parameter validation rules + * @see AnalyticsEvent for usage examples + * + * @since 1.0.0 + */ +object ParamKeys { + // Screen and navigation + const val SCREEN_NAME = "screen_name" + const val SOURCE_SCREEN = "source_screen" + const val DESTINATION_SCREEN = "destination_screen" + + // User interaction + const val BUTTON_NAME = "button_name" + const val ELEMENT_ID = "element_id" + const val ELEMENT_TYPE = "element_type" + const val ACTION_TYPE = "action_type" + + // Content + const val CONTENT_TYPE = "content_type" + const val CONTENT_ID = "content_id" + const val CONTENT_NAME = "content_name" + const val CATEGORY = "category" + + // Search and filters + const val SEARCH_TERM = "search_term" + const val FILTER_TYPE = "filter_type" + const val FILTER_VALUE = "filter_value" + const val RESULT_COUNT = "result_count" + + // Forms + const val FORM_NAME = "form_name" + const val FIELD_NAME = "field_name" + const val ERROR_MESSAGE = "error_message" + const val COMPLETION_TIME = "completion_time" + + // Performance + const val LOADING_TIME_MS = "loading_time_ms" + const val ERROR_CODE = "error_code" + const val API_ENDPOINT = "api_endpoint" + const val NETWORK_TYPE = "network_type" + + // User attributes + const val USER_ID = "user_id" + const val USER_TYPE = "user_type" + const val DEVICE_TYPE = "device_type" + const val APP_VERSION = "app_version" + + // Feature usage + const val FEATURE_NAME = "feature_name" + const val USAGE_COUNT = "usage_count" + const val TUTORIAL_STEP = "tutorial_step" + + // Custom + const val VALUE = "value" + const val TIMESTAMP = "timestamp" + const val DURATION = "duration" + const val SUCCESS = "success" +} diff --git a/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/AnalyticsExtensions.kt b/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/AnalyticsExtensions.kt new file mode 100644 index 0000000000..60e7e385af --- /dev/null +++ b/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/AnalyticsExtensions.kt @@ -0,0 +1,215 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.analytics + +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.DurationUnit + +/** + * Extension functions for enhanced analytics functionality + */ + +/** + * Create a screen view event with builder pattern + */ +fun AnalyticsEvent.screenView( + screenName: String, + sourceScreen: String? = null, + additionalParams: Map = emptyMap(), +): AnalyticsEvent { + val params = mutableListOf(Param(ParamKeys.SCREEN_NAME, screenName)) + sourceScreen?.let { params.add(Param(ParamKeys.SOURCE_SCREEN, it)) } + additionalParams.forEach { (key, value) -> params.add(Param(key, value)) } + return AnalyticsEvent(Types.SCREEN_VIEW, params) +} + +/** + * Create a button click event with builder pattern + */ +fun AnalyticsEvent.buttonClick( + buttonName: String, + screenName: String? = null, + elementId: String? = null, +): AnalyticsEvent { + val params = mutableListOf(Param(ParamKeys.BUTTON_NAME, buttonName)) + screenName?.let { params.add(Param(ParamKeys.SCREEN_NAME, it)) } + elementId?.let { params.add(Param(ParamKeys.ELEMENT_ID, it)) } + return AnalyticsEvent(Types.BUTTON_CLICK, params) +} + +/** + * Create an error event with builder pattern + */ +fun AnalyticsEvent.error( + message: String, + errorCode: String? = null, + screen: String? = null, + apiEndpoint: String? = null, +): AnalyticsEvent { + val params = mutableListOf(Param(ParamKeys.ERROR_MESSAGE, message)) + errorCode?.let { params.add(Param(ParamKeys.ERROR_CODE, it)) } + screen?.let { params.add(Param(ParamKeys.SCREEN_NAME, it)) } + apiEndpoint?.let { params.add(Param(ParamKeys.API_ENDPOINT, it)) } + return AnalyticsEvent(Types.ERROR_OCCURRED, params) +} + +/** + * Create a search event with builder pattern + */ +fun AnalyticsEvent.search( + searchTerm: String, + resultCount: Int? = null, + screen: String? = null, +): AnalyticsEvent { + val params = mutableListOf(Param(ParamKeys.SEARCH_TERM, searchTerm)) + resultCount?.let { params.add(Param(ParamKeys.RESULT_COUNT, it.toString())) } + screen?.let { params.add(Param(ParamKeys.SCREEN_NAME, it)) } + return AnalyticsEvent(Types.SEARCH_PERFORMED, params) +} + +/** + * Create a form event with builder pattern + */ +fun AnalyticsEvent.formEvent( + // FORM_STARTED, FORM_COMPLETED, FORM_ABANDONED + eventType: String, + formName: String, + completionTime: Duration? = null, + fieldName: String? = null, +): AnalyticsEvent { + val params = mutableListOf(Param(ParamKeys.FORM_NAME, formName)) + completionTime?.let { params.add(Param(ParamKeys.COMPLETION_TIME, "${it.toDouble(DurationUnit.SECONDS)}s")) } + fieldName?.let { params.add(Param(ParamKeys.FIELD_NAME, it)) } + return AnalyticsEvent(eventType, params) +} + +/** + * Create a loading time event + */ +fun AnalyticsEvent.loadingTime( + screen: String, + loadingTimeMs: Long, + success: Boolean = true, +): AnalyticsEvent { + val params = listOf( + Param(ParamKeys.SCREEN_NAME, screen), + Param(ParamKeys.LOADING_TIME_MS, loadingTimeMs.toString()), + Param(ParamKeys.SUCCESS, success.toString()), + ) + return AnalyticsEvent(Types.LOADING_TIME, params) +} + +/** + * Extension functions for AnalyticsHelper to add timing functionality + */ +class TimedEvent internal constructor( + private val analytics: AnalyticsHelper, + private val eventType: String, + private val baseParams: List, +) { + private val startTime = Clock.System.now().toEpochMilliseconds() + + fun complete(additionalParams: Map = emptyMap()) { + val duration = Clock.System.now().toEpochMilliseconds() - startTime + val params = baseParams + + Param(ParamKeys.DURATION, duration.toString()) + + additionalParams.map { Param(it.key, it.value) } + analytics.logEvent(AnalyticsEvent(eventType, params)) + } +} + +/** + * Start timing an event - call complete() when done + */ +fun AnalyticsHelper.startTiming(eventType: String, vararg params: Pair): TimedEvent { + val baseParams = params.map { Param(it.first, it.second) } + return TimedEvent(this, eventType, baseParams) +} + +/** + * Time a block of code execution + */ +inline fun AnalyticsHelper.timeExecution( + eventType: String, + vararg params: Pair, + block: () -> T, +): T { + val startTime = Clock.System.now().toEpochMilliseconds() + return try { + val result = block() + val duration = Clock.System.now().toEpochMilliseconds() - startTime + logEvent( + eventType, + *params, + ParamKeys.DURATION to duration.toString(), + ParamKeys.SUCCESS to "true", + ) + result + } catch (e: Exception) { + val duration = Clock.System.now().toEpochMilliseconds() - startTime + logEvent( + eventType, + *params, + ParamKeys.DURATION to duration.toString(), + ParamKeys.SUCCESS to "false", + ParamKeys.ERROR_MESSAGE to (e.message ?: "Unknown error"), + ) + throw e + } +} + +/** + * Batch analytics events for better performance + */ +class AnalyticsBatch internal constructor(private val analytics: AnalyticsHelper) { + private val events = mutableListOf() + + fun add(event: AnalyticsEvent): AnalyticsBatch { + events.add(event) + return this + } + + fun add(type: String, vararg params: Pair): AnalyticsBatch { + events.add(AnalyticsEvent(type, params.map { Param(it.first, it.second) })) + return this + } + + fun flush() { + events.forEach { analytics.logEvent(it) } + events.clear() + } +} + +/** + * Create a batch for logging multiple events efficiently + */ +fun AnalyticsHelper.batch(): AnalyticsBatch = AnalyticsBatch(this) + +/** + * Safe parameter creation that handles validation + */ +@Suppress("ReturnCount") +fun createParam(key: String, value: Any?): Param? { + return try { + val stringValue = value?.toString() ?: return null + if (key.isBlank() || stringValue.isBlank()) return null + Param(key.take(40), stringValue.take(100)) + } catch (e: Exception) { + null // Return null for invalid parameters + } +} + +/** + * Create parameters from a map, filtering out invalid ones + */ +fun createParams(params: Map): List { + return params.mapNotNull { (key, value) -> createParam(key, value) } +} diff --git a/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/AnalyticsHelper.kt b/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/AnalyticsHelper.kt new file mode 100644 index 0000000000..00b6c997e7 --- /dev/null +++ b/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/AnalyticsHelper.kt @@ -0,0 +1,357 @@ +/* + * Copyright 2023 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.analytics + +/** + * Platform-agnostic interface for logging analytics events with comprehensive utility methods. + * + * This interface provides the core contract for analytics tracking across all platforms + * in a Kotlin Multiplatform project. It abstracts the underlying analytics implementation + * (Firebase Analytics, custom backends, etc.) and provides convenient methods for common + * analytics operations. + * + * **Key Features:** + * - **Type-Safe Event Logging**: Uses [AnalyticsEvent] for validated event structure + * - **Convenience Methods**: Simplified APIs for common events (screen views, button clicks, errors) + * - **User Management**: Support for user properties and user ID tracking + * - **Platform Abstraction**: Works seamlessly across Android, iOS, Desktop, and Web + * - **Flexible Parameter Handling**: Multiple ways to add event parameters + * + * **Available Implementations:** + * - [FirebaseAnalyticsHelper]: Production implementation using Firebase Analytics + * - [StubAnalyticsHelper]: Development implementation that logs to console + * - [NoOpAnalyticsHelper]: No-operation implementation for testing/previews + * - [TestAnalyticsHelper]: Test implementation that captures events for verification + * + * **Usage Patterns:** + * ```kotlin + * // Dependency injection (Koin) + * val analytics: AnalyticsHelper = koinInject() + * + * // Simple event logging + * analytics.logEvent(Types.BUTTON_CLICK, ParamKeys.BUTTON_NAME to "save") + * + * // Complex event with builder pattern + * val event = AnalyticsEvent(Types.FORM_COMPLETED) + * .withParam(ParamKeys.FORM_NAME, "registration") + * .withParam(ParamKeys.COMPLETION_TIME, "45s") + * analytics.logEvent(event) + * + * // Convenience methods + * analytics.logScreenView("UserProfile", sourceScreen = "Dashboard") + * analytics.logButtonClick("edit_profile", screenName = "UserProfile") + * analytics.logError("Network timeout", "NET_001", "UserProfile") + * ``` + * + * @see AnalyticsEvent for event structure and validation + * @see Types for standard event type constants + * @see ParamKeys for standard parameter key constants + * @see FirebaseAnalyticsHelper for production implementation + * @see StubAnalyticsHelper for development implementation + * @see TestAnalyticsHelper for testing implementation + * + * @since 1.0.0 + */ +interface AnalyticsHelper { + /** + * Logs an analytics event to the underlying analytics platform. + * + * This is the core method that all other logging methods ultimately call. + * The event will be validated and sent to the configured analytics backend + * (e.g., Firebase Analytics, custom analytics service). + * + * @param event The [AnalyticsEvent] to log. Must have a valid event type and + * parameters that meet platform constraints. + * + * @see AnalyticsEvent for event construction and validation + * @see Types for standard event types + * @see ParamKeys for standard parameter keys + * + * @since 1.0.0 + */ + fun logEvent(event: AnalyticsEvent) + + /** + * Logs a simple analytics event with type and optional parameters using vararg syntax. + * + * This convenience method provides a more concise way to log events without + * explicitly creating an [AnalyticsEvent] instance. The parameters will be + * automatically converted to [Param] instances and validated. + * + * @param type The event type identifier. Use constants from [Types] when possible. + * @param params Variable number of parameter pairs (key to value). Each parameter + * must meet the same validation constraints as [Param]. + * + * @throws IllegalArgumentException if the event type or any parameter violates + * validation constraints + * + * @see Types for standard event type constants + * @see ParamKeys for standard parameter key constants + * + * @sample + * ```kotlin + * analytics.logEvent(Types.BUTTON_CLICK, + * ParamKeys.BUTTON_NAME to "save", + * ParamKeys.SCREEN_NAME to "UserProfile" + * ) + * ``` + * + * @since 1.0.0 + */ + fun logEvent(type: String, vararg params: Pair) { + val event = AnalyticsEvent(type, params.map { Param(it.first, it.second) }) + logEvent(event) + } + + /** + * Logs a simple analytics event with type and parameters from a Map. + * + * This convenience method allows logging events with parameters from an existing + * Map, which is useful when working with dynamic parameter sets + * or converting from other data structures. + * + * @param type The event type identifier. Use constants from [Types] when possible. + * @param params A map containing parameter key-value pairs. Each entry will be + * converted to a [Param] instance and validated. + * + * @throws IllegalArgumentException if the event type or any parameter violates + * validation constraints + * + * @see Types for standard event type constants + * @see ParamKeys for standard parameter key constants + * + * @sample + * ```kotlin + * val eventParams = mapOf( + * ParamKeys.SEARCH_TERM to "kotlin", + * ParamKeys.RESULT_COUNT to "42" + * ) + * analytics.logEvent(Types.SEARCH_PERFORMED, eventParams) + * ``` + * + * @since 1.0.0 + */ + fun logEvent(type: String, params: Map) { + val event = AnalyticsEvent(type, params.map { Param(it.key, it.value) }) + logEvent(event) + } + + /** + * Logs a screen view event for navigation tracking. + * + * This convenience method automatically creates a properly formatted screen view + * event, which is essential for understanding user navigation patterns and + * screen engagement metrics. + * + * @param screenName The name/identifier of the screen being viewed. Should be + * descriptive and consistent across the app (e.g., "UserProfile", + * "Settings", "ProductDetails"). + * @param sourceScreen Optional name of the previous screen that led to this view. + * Useful for understanding navigation flows and user journeys. + * + * @see Types.SCREEN_VIEW for the generated event type + * @see ParamKeys.SCREEN_NAME for the screen name parameter + * @see ParamKeys.SOURCE_SCREEN for the source screen parameter + * + * @sample + * ```kotlin + * // Simple screen view + * analytics.logScreenView("UserProfile") + * + * // Screen view with navigation context + * analytics.logScreenView("ProductDetails", sourceScreen = "ProductList") + * ``` + * + * @since 1.0.0 + */ + fun logScreenView(screenName: String, sourceScreen: String? = null) { + val params = mutableListOf(Param(ParamKeys.SCREEN_NAME, screenName)) + sourceScreen?.let { params.add(Param(ParamKeys.SOURCE_SCREEN, it)) } + logEvent(AnalyticsEvent(Types.SCREEN_VIEW, params)) + } + + /** + * Logs a button click event for user interaction tracking. + * + * This convenience method tracks user interactions with buttons and other + * clickable elements, helping understand feature usage and user engagement + * patterns. + * + * @param buttonName The identifier or label of the button clicked. Should be + * descriptive and consistent (e.g., "save", "edit_profile", + * "submit_form"). + * @param screenName Optional name of the screen where the button was clicked. + * Provides context for understanding interaction patterns. + * + * @see Types.BUTTON_CLICK for the generated event type + * @see ParamKeys.BUTTON_NAME for the button name parameter + * @see ParamKeys.SCREEN_NAME for the screen name parameter + * + * @sample + * ```kotlin + * // Simple button click + * analytics.logButtonClick("save") + * + * // Button click with screen context + * analytics.logButtonClick("edit_profile", screenName = "UserProfile") + * ``` + * + * @since 1.0.0 + */ + fun logButtonClick(buttonName: String, screenName: String? = null) { + val params = mutableListOf(Param(ParamKeys.BUTTON_NAME, buttonName)) + screenName?.let { params.add(Param(ParamKeys.SCREEN_NAME, it)) } + logEvent(AnalyticsEvent(Types.BUTTON_CLICK, params)) + } + + /** + * Logs an error event for debugging and monitoring. + * + * This convenience method tracks errors and exceptions that occur in the + * application, providing valuable information for debugging, monitoring + * app stability, and improving user experience. + * + * @param errorMessage A descriptive message about the error. Should be clear + * and actionable for debugging purposes. + * @param errorCode Optional error code or identifier that can help categorize + * and track specific types of errors (e.g., "NET_001", "DB_ERROR"). + * @param screen Optional name of the screen where the error occurred. Helps + * identify problematic areas of the app. + * + * @see Types.ERROR_OCCURRED for the generated event type + * @see ParamKeys.ERROR_MESSAGE for the error message parameter + * @see ParamKeys.ERROR_CODE for the error code parameter + * @see ParamKeys.SCREEN_NAME for the screen name parameter + * + * @sample + * ```kotlin + * // Simple error logging + * analytics.logError("Network connection failed") + * + * // Error with code and context + * analytics.logError( + * errorMessage = "API request timeout", + * errorCode = "NET_001", + * screen = "UserProfile" + * ) + * ``` + * + * @since 1.0.0 + */ + fun logError(errorMessage: String, errorCode: String? = null, screen: String? = null) { + val params = mutableListOf(Param(ParamKeys.ERROR_MESSAGE, errorMessage)) + errorCode?.let { params.add(Param(ParamKeys.ERROR_CODE, it)) } + screen?.let { params.add(Param(ParamKeys.SCREEN_NAME, it)) } + logEvent(AnalyticsEvent(Types.ERROR_OCCURRED, params)) + } + + /** + * Logs a feature usage event for feature adoption tracking. + * + * This convenience method tracks when users interact with specific features + * or functionality, helping understand feature adoption, usage patterns, + * and user engagement with different parts of the application. + * + * @param featureName The identifier of the feature being used. Should be + * descriptive and consistent (e.g., "dark_mode", "export_data", + * "voice_input"). + * @param screen Optional name of the screen where the feature was used. + * Provides context for understanding feature usage patterns. + * + * @see Types.FEATURE_USED for the generated event type + * @see ParamKeys.FEATURE_NAME for the feature name parameter + * @see ParamKeys.SCREEN_NAME for the screen name parameter + * + * @sample + * ```kotlin + * // Simple feature usage + * analytics.logFeatureUsed("dark_mode") + * + * // Feature usage with screen context + * analytics.logFeatureUsed("export_data", screen = "Settings") + * ``` + * + * @since 1.0.0 + */ + fun logFeatureUsed(featureName: String, screen: String? = null) { + val params = mutableListOf(Param(ParamKeys.FEATURE_NAME, featureName)) + screen?.let { params.add(Param(ParamKeys.SCREEN_NAME, it)) } + logEvent(AnalyticsEvent(Types.FEATURE_USED, params)) + } + + /** + * Sets a user property for analytics user profiling and segmentation. + * + * User properties allow you to describe segments of your user base, such as + * language preference, geographic location, or user type. These properties + * are attached to all subsequent events and can be used for analytics + * filtering and audience creation. + * + * **Note:** This is a default implementation that does nothing. Platform-specific + * implementations may override this to provide actual functionality. + * + * @param name The property name identifier. Must be non-blank and ≤ 24 characters + * (Firebase Analytics constraint). Should use consistent naming + * conventions across the app. + * @param value The property value. Must be ≤ 36 characters (Firebase Analytics + * constraint). Should be descriptive and useful for segmentation. + * + * @see setUserId for setting user identification + * + * @sample + * ```kotlin + * // Set user characteristics + * analytics.setUserProperty("user_type", "premium") + * analytics.setUserProperty("preferred_language", "en") + * analytics.setUserProperty("app_theme", "dark") + * ``` + * + * @since 1.0.0 + */ + fun setUserProperty(name: String, value: String) { + // Default implementation does nothing - can be overridden by implementations that support it + } + + /** + * Sets the user ID for analytics user tracking and identification. + * + * The user ID is a unique identifier for a user that persists across sessions + * and devices. It enables you to connect user behavior across multiple sessions + * and understand the user journey more comprehensively. + * + * **Note:** This is a default implementation that does nothing. Platform-specific + * implementations may override this to provide actual functionality. + * + * **Privacy Considerations:** + * - Ensure the user ID does not contain personally identifiable information (PII) + * - Consider using hashed or obfuscated identifiers + * - Follow privacy regulations and platform guidelines + * + * @param userId The unique identifier for the user. Must be non-blank and + * ≤ 256 characters (Firebase Analytics constraint). Should be + * consistent across sessions and not contain PII. + * + * @see setUserProperty for setting user characteristics + * + * @sample + * ```kotlin + * // Set user ID (use hashed or obfuscated IDs for privacy) + * analytics.setUserId("user_${hashedUserId}") + * + * // Clear user ID on logout + * analytics.setUserId("") + * ``` + * + * @since 1.0.0 + */ + fun setUserId(userId: String) { + // Default implementation does nothing - can be overridden by implementations that support it + } +} diff --git a/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/NoOpAnalyticsHelper.kt b/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/NoOpAnalyticsHelper.kt new file mode 100644 index 0000000000..b515ef192e --- /dev/null +++ b/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/NoOpAnalyticsHelper.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2023 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.analytics + +/** + * No-operation implementation of [AnalyticsHelper] that discards all analytics events. + * + * This implementation provides a complete no-op analytics solution that can be used + * in scenarios where analytics tracking should be disabled or is not desired. It's + * particularly useful for: + * + * - **Testing**: Unit tests where analytics calls should not interfere + * - **Previews**: Jetpack Compose previews that need an analytics implementation + * - **Debug Builds**: Development builds where analytics tracking is disabled + * - **Privacy Mode**: Special app modes where analytics is intentionally disabled + * - **Fallback**: Default implementation when no specific analytics provider is configured + * + * All methods in this implementation are safe to call and will not throw exceptions, + * making it a reliable fallback option. + * + * @see AnalyticsHelper for the complete interface contract + * @see StubAnalyticsHelper for a development implementation that logs events + * @see TestAnalyticsHelper for a testing implementation that captures events + * + * @sample + * ```kotlin + * // Use in tests + * val analytics: AnalyticsHelper = NoOpAnalyticsHelper() + * + * // Use as default in CompositionLocal + * val LocalAnalyticsHelper = staticCompositionLocalOf { + * NoOpAnalyticsHelper() + * } + * ``` + * + * @since 1.0.0 + */ +class NoOpAnalyticsHelper : AnalyticsHelper { + /** + * Discards the analytics event without any processing. + * + * @param event The analytics event to discard (ignored) + */ + override fun logEvent(event: AnalyticsEvent) = Unit +} diff --git a/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/PerformanceTracker.kt b/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/PerformanceTracker.kt new file mode 100644 index 0000000000..b93ba5b9b2 --- /dev/null +++ b/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/PerformanceTracker.kt @@ -0,0 +1,283 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.analytics + +import kotlin.time.Clock + +/** Performance tracking utilities for analytics */ + +/** Performance tracker that automatically logs performance metrics */ +class PerformanceTracker( + private val analytics: AnalyticsHelper, + private val enableAutomaticLogging: Boolean = true, + private val slowThresholdMs: Long = 1000L, + private val verySlowThresholdMs: Long = 5000L, +) { + + private val activeTimers = mutableMapOf() + private val performanceMetrics = mutableMapOf>() + + /** Start timing an operation */ + fun startTimer(operationName: String, context: Map = emptyMap()): String { + val timerId = "${operationName}_$currentTime" + activeTimers[timerId] = currentTime + + if (enableAutomaticLogging) { + analytics.logEvent( + "performance_timer_started", + mapOf("operation" to operationName, "timer_id" to timerId) + context, + ) + } + + return timerId + } + + /** Stop timing an operation and log the result */ + fun stopTimer( + timerId: String, + success: Boolean = true, + additionalContext: Map = emptyMap(), + ): Long? { + val startTime = activeTimers.remove(timerId) ?: return null + val duration = currentTime - startTime + + // Extract operation name from timer ID + val operationName = timerId.substringBeforeLast("_") + + // Store metric for analysis + performanceMetrics.getOrPut(operationName) { mutableListOf() }.add(duration) + + if (enableAutomaticLogging) { + val performanceLevel = when { + duration > verySlowThresholdMs -> "very_slow" + duration > slowThresholdMs -> "slow" + else -> "normal" + } + + analytics.logEvent( + "performance_timer_stopped", + mapOf( + "operation" to operationName, + "timer_id" to timerId, + ParamKeys.DURATION to "${duration}ms", + ParamKeys.SUCCESS to success.toString(), + "performance_level" to performanceLevel, + ) + additionalContext, + ) + } + + return duration + } + + /** Time a suspend function execution */ + suspend inline fun timeOperation( + operationName: String, + context: Map = emptyMap(), + crossinline operation: suspend () -> T, + ): T { + val timerId = startTimer(operationName, context) + return try { + val result = operation() + stopTimer(timerId, success = true) + result + } catch (e: Exception) { + stopTimer( + timerId, + success = false, + mapOf(ParamKeys.ERROR_MESSAGE to (e.message ?: "Unknown error")), + ) + throw e + } + } + + /** Time a regular function execution */ + inline fun timeOperationSync( + operationName: String, + context: Map = emptyMap(), + operation: () -> T, + ): T { + val timerId = startTimer(operationName, context) + return try { + val result = operation() + stopTimer(timerId, success = true) + result + } catch (e: Exception) { + stopTimer( + timerId, + success = false, + mapOf(ParamKeys.ERROR_MESSAGE to (e.message ?: "Unknown error")), + ) + throw e + } + } + + /** Get performance statistics for an operation */ + @Suppress("ReturnCount") + fun getPerformanceStats(operationName: String): PerformanceStats? { + val durations = performanceMetrics[operationName] ?: return null + if (durations.isEmpty()) return null + + val sorted = durations.sorted() + return PerformanceStats( + operationName = operationName, + count = durations.size, + averageMs = durations.average(), + medianMs = sorted[sorted.size / 2].toDouble(), + p95Ms = sorted[(sorted.size * 0.95).toInt().coerceAtMost(sorted.size - 1)].toDouble(), + p99Ms = sorted[(sorted.size * 0.99).toInt().coerceAtMost(sorted.size - 1)].toDouble(), + minMs = sorted.first().toDouble(), + maxMs = sorted.last().toDouble(), + ) + } + + /** Log performance summary for an operation */ + fun logPerformanceSummary(operationName: String) { + val stats = getPerformanceStats(operationName) ?: return + + analytics.logEvent( + "performance_summary", + mapOf( + "operation" to operationName, + "count" to stats.count.toString(), + "average_ms" to stats.averageMs.toInt().toString(), + "median_ms" to stats.medianMs.toInt().toString(), + "p95_ms" to stats.p95Ms.toInt().toString(), + "p99_ms" to stats.p99Ms.toInt().toString(), + "min_ms" to stats.minMs.toInt().toString(), + "max_ms" to stats.maxMs.toInt().toString(), + ), + ) + } + + /** Clear performance metrics */ + fun clearMetrics() { + performanceMetrics.clear() + activeTimers.clear() + } + + /** Get all active timers */ + fun getActiveTimers(): Map = activeTimers.toMap() +} + +/** Performance statistics for an operation */ +data class PerformanceStats( + val operationName: String, + val count: Int, + val averageMs: Double, + val medianMs: Double, + val p95Ms: Double, + val p99Ms: Double, + val minMs: Double, + val maxMs: Double, +) + +/** App lifecycle performance tracker */ +class AppLifecycleTracker(private val analytics: AnalyticsHelper) { + + private var appStartTime: Long? = null + private var lastForegroundTime: Long? = null + private var backgroundTime: Long? = null + + /** Mark app launch start */ + fun markAppLaunchStart() { + appStartTime = currentTime + analytics.logEvent( + Types.APP_LAUNCH, + mapOf("launch_start_time" to appStartTime.toString()), + ) + } + + /** Mark app launch complete */ + fun markAppLaunchComplete() { + val startTime = appStartTime ?: return + val launchDuration = currentTime - startTime + + analytics.logEvent( + "app_launch_completed", + mapOf( + "launch_duration_ms" to launchDuration.toString(), + "launch_performance" to when { + launchDuration < 1000 -> "fast" + launchDuration < 3000 -> "normal" + else -> "slow" + }, + ), + ) + } + + /** Mark app going to background */ + fun markAppBackground() { + backgroundTime = currentTime + val foregroundTime = lastForegroundTime + + analytics.logEvent( + Types.APP_BACKGROUND, + if (foregroundTime != null) { + mapOf("foreground_duration_ms" to (backgroundTime!! - foregroundTime).toString()) + } else { + emptyMap() + }, + ) + } + + /** Mark app coming to foreground */ + fun markAppForeground() { + val currentTime = currentTime + lastForegroundTime = currentTime + val bgTime = backgroundTime + + analytics.logEvent( + Types.APP_FOREGROUND, + if (bgTime != null) { + mapOf("background_duration_ms" to (currentTime - bgTime).toString()) + } else { + emptyMap() + }, + ) + } +} + +/** Extension functions for AnalyticsHelper to add performance tracking */ + +/** Create a performance tracker */ +fun AnalyticsHelper.performanceTracker( + enableAutomaticLogging: Boolean = true, + slowThresholdMs: Long = 1000L, + verySlowThresholdMs: Long = 5000L, +): PerformanceTracker = + PerformanceTracker( + analytics = this, + enableAutomaticLogging = enableAutomaticLogging, + slowThresholdMs = slowThresholdMs, + verySlowThresholdMs = verySlowThresholdMs, + ) + +/** Create an app lifecycle tracker */ +fun AnalyticsHelper.lifecycleTracker(): AppLifecycleTracker = AppLifecycleTracker(this) + +private val currentTime = Clock.System.now().toEpochMilliseconds() + +/** Quick performance timing for suspend functions */ +suspend inline fun AnalyticsHelper.timePerformance( + operationName: String, + context: Map = emptyMap(), + crossinline operation: suspend () -> T, +): T { + return performanceTracker().timeOperation(operationName, context, operation) +} + +/** Quick performance timing for regular functions */ +inline fun AnalyticsHelper.timePerformanceSync( + operationName: String, + context: Map = emptyMap(), + operation: () -> T, +): T { + return performanceTracker().timeOperationSync(operationName, context, operation) +} diff --git a/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/StubAnalyticsHelper.kt b/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/StubAnalyticsHelper.kt new file mode 100644 index 0000000000..cb8f26024c --- /dev/null +++ b/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/StubAnalyticsHelper.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2023 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.analytics + +import co.touchlab.kermit.Logger + +private const val TAG = "StubAnalyticsHelper" + +/** + * Development implementation of [AnalyticsHelper] that logs events to console output. + * + * This implementation provides a lightweight analytics solution for development and + * debugging purposes. Instead of sending events to a remote analytics service, it + * logs all events to the console using Kermit logging, making it easy to verify + * that analytics events are being generated correctly during development. + * + * **Use Cases:** + * - **Development Builds**: Debug app builds where you want to see analytics events + * - **Local Testing**: Manual testing where you want to verify event generation + * - **Debugging**: Troubleshooting analytics implementation issues + * - **Offline Development**: Working without network connectivity to analytics services + * + * **Output Format:** + * Events are logged with the full event structure including type and parameters, + * making it easy to verify the correct data is being tracked. + * + * @see AnalyticsHelper for the complete interface contract + * @see NoOpAnalyticsHelper for a no-operation implementation + * @see FirebaseAnalyticsHelper for the production implementation + * @see TestAnalyticsHelper for a testing implementation with capture capabilities + * + * @sample + * ```kotlin + * // Typically used in platform-specific DI modules for debug builds + * val analyticsModule = module { + * single { StubAnalyticsHelper() } + * } + * ``` + * + * @since 1.0.0 + */ +internal class StubAnalyticsHelper : AnalyticsHelper { + /** + * Logs the analytics event to console output using Kermit logger. + * + * The event is logged at ERROR level to ensure visibility in most logging + * configurations. The log includes the complete event structure with type + * and all parameters. + * + * @param event The analytics event to log to console + */ + override fun logEvent(event: AnalyticsEvent) { + Logger.e(TAG, null, "Received analytics event: $event") + } +} diff --git a/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/TestingUtils.kt b/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/TestingUtils.kt new file mode 100644 index 0000000000..4e48ab6a9f --- /dev/null +++ b/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/TestingUtils.kt @@ -0,0 +1,234 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.analytics + +/** Testing utilities for analytics functionality */ + +/** + * Test implementation of AnalyticsHelper that captures events for + * verification + */ +class TestAnalyticsHelper : AnalyticsHelper { + private val _loggedEvents = mutableListOf() + private val _userProperties = mutableMapOf() + private var _userId: String? = null + + /** Get all logged events */ + val loggedEvents: List get() = _loggedEvents.toList() + + /** Get all set user properties */ + val userProperties: Map get() = _userProperties.toMap() + + /** Get the current user ID */ + val userId: String? get() = _userId + + override fun logEvent(event: AnalyticsEvent) { + _loggedEvents.add(event) + } + + override fun setUserProperty(name: String, value: String) { + _userProperties[name] = value + } + + override fun setUserId(userId: String) { + _userId = userId + } + + /** Clear all captured data */ + fun clear() { + _loggedEvents.clear() + _userProperties.clear() + _userId = null + } + + /** Get events by type */ + fun getEventsByType(type: String): List { + return _loggedEvents.filter { it.type == type } + } + + /** Get the last logged event */ + fun getLastEvent(): AnalyticsEvent? = _loggedEvents.lastOrNull() + + /** Get events containing a specific parameter */ + fun getEventsWithParam(key: String, value: String? = null): List { + return _loggedEvents.filter { event -> + event.extras.any { param -> + param.key == key && (value == null || param.value == value) + } + } + } + + /** Verify that an event was logged */ + fun hasEvent(type: String, params: Map = emptyMap()): Boolean { + return _loggedEvents.any { event -> + event.type == type && params.all { (key, value) -> + event.extras.any { it.key == key && it.value == value } + } + } + } + + /** Get count of events by type */ + fun getEventCount(type: String): Int { + return _loggedEvents.count { it.type == type } + } + + /** Get all unique event types logged */ + fun getUniqueEventTypes(): Set { + return _loggedEvents.map { it.type }.toSet() + } + + /** Verify screen view was logged */ + fun hasScreenView(screenName: String): Boolean { + return hasEvent(Types.SCREEN_VIEW, mapOf(ParamKeys.SCREEN_NAME to screenName)) + } + + /** Verify button click was logged */ + fun hasButtonClick(buttonName: String): Boolean { + return hasEvent(Types.BUTTON_CLICK, mapOf(ParamKeys.BUTTON_NAME to buttonName)) + } + + /** Verify error was logged */ + fun hasError(errorMessage: String): Boolean { + return hasEvent(Types.ERROR_OCCURRED, mapOf(ParamKeys.ERROR_MESSAGE to errorMessage)) + } + + /** Get all parameters for a specific event type */ + fun getParametersForEventType(type: String): List> { + return _loggedEvents.filter { it.type == type } + .map { event -> event.extras.associate { it.key to it.value } } + } + + /** Print all logged events (useful for debugging) */ + fun printEvents() { + if (_loggedEvents.isEmpty()) { + println("No analytics events logged") + return + } + + println("Analytics Events Logged:") + _loggedEvents.forEachIndexed { index, event -> + println("${index + 1}. ${event.type}") + event.extras.forEach { param -> + println(" ${param.key}: ${param.value}") + } + } + + if (_userProperties.isNotEmpty()) { + println("\nUser Properties:") + _userProperties.forEach { (key, value) -> + println(" $key: $value") + } + } + + _userId?.let { + println("\nUser ID: $it") + } + } +} + +/** Create a test analytics helper for testing */ +fun createTestAnalyticsHelper(): TestAnalyticsHelper = TestAnalyticsHelper() + +/** Extension for asserting events in tests */ +fun TestAnalyticsHelper.assertEventLogged( + type: String, + params: Map = emptyMap(), + message: String? = null, +) { + val found = hasEvent(type, params) + if (!found) { + val errorMessage = message ?: "Expected event '$type' with params $params was not logged" + val actualEvents = getEventsByType(type) + if (actualEvents.isEmpty()) { + throw AssertionError("$errorMessage. No events of type '$type' were logged.") + } else { + throw AssertionError( + "$errorMessage. Events of type '$type' found: ${ + actualEvents.map { + it.extras.associate { p -> p.key to p.value } + } + }", + ) + } + } +} + +/** Extension for asserting event count */ +fun TestAnalyticsHelper.assertEventCount( + type: String, + expectedCount: Int, + message: String? = null, +) { + val actualCount = getEventCount(type) + if (actualCount != expectedCount) { + val errorMessage = + message ?: "Expected $expectedCount events of type '$type', but found $actualCount" + throw AssertionError(errorMessage) + } +} + +/** Extension for asserting user property was set */ +fun TestAnalyticsHelper.assertUserProperty( + name: String, + expectedValue: String, + message: String? = null, +) { + val actualValue = userProperties[name] + if (actualValue != expectedValue) { + val errorMessage = message + ?: "Expected user property '$name' to be '$expectedValue', but was '$actualValue'" + throw AssertionError(errorMessage) + } +} + +/** Mock analytics helper that simulates network delays and failures */ +class MockAnalyticsHelper( + private val simulateFailures: Boolean = false, + private val failureRate: Float = 0.1f, +) : AnalyticsHelper { + + private val testHelper = TestAnalyticsHelper() + private var eventCount = 0 + + override fun logEvent(event: AnalyticsEvent) { + eventCount++ + + if (simulateFailures && (eventCount * failureRate).toInt() > 0 && eventCount % (1 / failureRate).toInt() == 0) { + // Simulate failure - don't log the event + return + } + +// if (simulateNetworkDelay) { +// // Simulate network delay (in a real implementation, this would be async) +// delay((50..200).random().toLong()) +// } + + testHelper.logEvent(event) + } + + override fun setUserProperty(name: String, value: String) { + testHelper.setUserProperty(name, value) + } + + override fun setUserId(userId: String) { + testHelper.setUserId(userId) + } + + // Delegate test helper methods + val loggedEvents: List get() = testHelper.loggedEvents + val userProperties: Map get() = testHelper.userProperties + val userId: String? get() = testHelper.userId + + fun clear() = testHelper.clear() + fun hasEvent(type: String, params: Map = emptyMap()) = + testHelper.hasEvent(type, params) + + fun getEventCount(type: String) = testHelper.getEventCount(type) +} diff --git a/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/UiHelpers.kt b/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/UiHelpers.kt new file mode 100644 index 0000000000..57bc2845df --- /dev/null +++ b/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/UiHelpers.kt @@ -0,0 +1,106 @@ +/* + * Copyright 2023 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.analytics + +import androidx.compose.foundation.clickable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier + +/** + * Global key used to obtain access to the AnalyticsHelper through a CompositionLocal. + */ +val LocalAnalyticsHelper = staticCompositionLocalOf { + // Provide a default AnalyticsHelper which does nothing. This is so that tests and previews + // do not have to provide one. For real app builds provide a different implementation. + NoOpAnalyticsHelper() +} + +/** + * Composable function to track screen views automatically + */ +@Composable +@Suppress("SpreadOperator") +fun TrackScreenView( + screenName: String, + sourceScreen: String? = null, + additionalParams: Map = emptyMap(), +) { + val analytics = LocalAnalyticsHelper.current + + LaunchedEffect(screenName) { + analytics.logScreenView(screenName, sourceScreen) + // Log additional params if provided + if (additionalParams.isNotEmpty()) { + analytics.logEvent( + Types.SCREEN_VIEW, + mapOf( + ParamKeys.SCREEN_NAME to screenName, + *additionalParams.toList().toTypedArray(), + ).plus(sourceScreen?.let { mapOf(ParamKeys.SOURCE_SCREEN to it) } ?: emptyMap()), + ) + } + } +} + +/** + * Modifier extension for tracking button clicks + */ +fun Modifier.trackClick( + buttonName: String, + analytics: AnalyticsHelper, + screenName: String? = null, + additionalParams: Map = emptyMap(), +): Modifier = this.clickable { + analytics.logButtonClick(buttonName, screenName) + if (additionalParams.isNotEmpty()) { + analytics.logEvent(Types.BUTTON_CLICK, additionalParams) + } +} + +/** + * Remember analytics helper from composition local + */ +@Composable +fun rememberAnalyticsHelper(): AnalyticsHelper = LocalAnalyticsHelper.current + +/** + * Effect for tracking when a composable enters/exits composition + */ +@Composable +fun TrackComposableLifecycle( + name: String, + trackEntry: Boolean = true, + trackExit: Boolean = false, +) { + val analytics = LocalAnalyticsHelper.current + + if (trackEntry) { + LaunchedEffect(name) { + analytics.logEvent( + "composable_entered", + ParamKeys.CONTENT_NAME to name, + ) + } + } + + if (trackExit) { + DisposableEffect(name) { + onDispose { + analytics.logEvent( + "composable_exited", + ParamKeys.CONTENT_NAME to name, + ) + } + } + } +} diff --git a/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/ValidationUtils.kt b/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/ValidationUtils.kt new file mode 100644 index 0000000000..0f71384efe --- /dev/null +++ b/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/ValidationUtils.kt @@ -0,0 +1,403 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.analytics + +/** Data validation utilities for analytics events and parameters */ + +/** Analytics data validator */ +class AnalyticsValidator { + + companion object { + // Analytics platform constraints + const val MAX_EVENT_NAME_LENGTH = 40 + const val MAX_PARAM_KEY_LENGTH = 40 + const val MAX_PARAM_VALUE_LENGTH = 100 + const val MAX_USER_PROPERTY_NAME_LENGTH = 24 + const val MAX_USER_PROPERTY_VALUE_LENGTH = 36 + const val MAX_USER_ID_LENGTH = 256 + const val MAX_PARAMS_PER_EVENT = 25 + + // Validation patterns + private val VALID_EVENT_NAME_PATTERN = Regex("^[a-zA-Z][a-zA-Z0-9_]*$") + private val VALID_PARAM_KEY_PATTERN = Regex("^[a-zA-Z][a-zA-Z0-9_]*$") + private val RESERVED_PREFIXES = setOf("firebase_", "google_", "ga_") + private val RESERVED_EVENT_NAMES = setOf( + "ad_activeview", "ad_click", "ad_exposure", "ad_impression", "ad_query", + "adunit_exposure", "app_clear_data", "app_exception", "app_remove", "app_update", + "error", "first_open", "first_visit", "in_app_purchase", "notification_dismiss", + "notification_foreground", "notification_open", "notification_receive", + "os_update", "screen_view", "session_start", "user_engagement", + ) + } + + /** Validation result */ + sealed class ValidationResult { + object Valid : ValidationResult() + data class Invalid(val errors: List) : ValidationResult() + + val isValid: Boolean get() = this is Valid + val errorMessages: List get() = if (this is Invalid) errors else emptyList() + } + + /** Validate an analytics event */ + fun validateEvent(event: AnalyticsEvent): ValidationResult { + val errors = mutableListOf() + + // Validate event type + errors.addAll(validateEventName(event.type)) + + // Validate parameter count + if (event.extras.size > MAX_PARAMS_PER_EVENT) { + errors.add("Event has ${event.extras.size} parameters, maximum allowed is $MAX_PARAMS_PER_EVENT") + } + + // Validate each parameter + event.extras.forEach { param -> + errors.addAll(validateParameter(param)) + } + + // Check for duplicate parameter keys + val duplicateKeys = event.extras.groupBy { it.key } + .filter { it.value.size > 1 } + .keys + if (duplicateKeys.isNotEmpty()) { + errors.add("Event has duplicate parameter keys: ${duplicateKeys.joinToString(", ")}") + } + + return if (errors.isEmpty()) ValidationResult.Valid else ValidationResult.Invalid(errors) + } + + /** Validate event name */ + fun validateEventName(eventName: String): List { + val errors = mutableListOf() + + if (eventName.isBlank()) { + errors.add("Event name cannot be blank") + return errors + } + + if (eventName.length > MAX_EVENT_NAME_LENGTH) { + errors.add("Event name '$eventName' exceeds maximum length of $MAX_EVENT_NAME_LENGTH characters") + } + + if (!VALID_EVENT_NAME_PATTERN.matches(eventName)) { + errors.add( + "Event name '$eventName' contains invalid characters." + + " Must start with letter and contain only letters, numbers, and underscores", + ) + } + + if (RESERVED_PREFIXES.any { eventName.startsWith(it) }) { + errors.add("Event name '$eventName' uses reserved prefix") + } + + if (RESERVED_EVENT_NAMES.contains(eventName)) { + errors.add("Event name '$eventName' is reserved") + } + + return errors + } + + /** Validate parameter */ + fun validateParameter(param: Param): List { + val errors = mutableListOf() + + // Validate key + if (param.key.isBlank()) { + errors.add("Parameter key cannot be blank") + } else { + if (param.key.length > MAX_PARAM_KEY_LENGTH) { + errors.add( + "Parameter key '${param.key}' " + + "exceeds maximum length of $MAX_PARAM_KEY_LENGTH characters", + ) + } + + if (!VALID_PARAM_KEY_PATTERN.matches(param.key)) { + errors.add( + "Parameter key '${param.key}' contains invalid characters. Must start " + + "with letter and contain only letters, numbers, and underscores", + ) + } + + if (RESERVED_PREFIXES.any { param.key.startsWith(it) }) { + errors.add("Parameter key '${param.key}' uses reserved prefix") + } + } + + // Validate value + if (param.value.length > MAX_PARAM_VALUE_LENGTH) { + errors.add( + "Parameter value for key '${param.key}' exceeds maximum" + + " length of $MAX_PARAM_VALUE_LENGTH characters", + ) + } + + return errors + } + + /** Validate user property */ + fun validateUserProperty(name: String, value: String): List { + val errors = mutableListOf() + + if (name.isBlank()) { + errors.add("User property name cannot be blank") + } else { + if (name.length > MAX_USER_PROPERTY_NAME_LENGTH) { + errors.add( + "User property name '$name' exceeds maximum length " + + "of $MAX_USER_PROPERTY_NAME_LENGTH characters", + ) + } + + if (!VALID_PARAM_KEY_PATTERN.matches(name)) { + errors.add("User property name '$name' contains invalid characters") + } + + if (RESERVED_PREFIXES.any { name.startsWith(it) }) { + errors.add("User property name '$name' uses reserved prefix") + } + } + + if (value.length > MAX_USER_PROPERTY_VALUE_LENGTH) { + errors.add( + "User property value for '$name' exceeds maximum " + + "length of $MAX_USER_PROPERTY_VALUE_LENGTH characters", + ) + } + + return errors + } + + /** Validate user ID */ + fun validateUserId(userId: String): List { + val errors = mutableListOf() + + if (userId.isBlank()) { + errors.add("User ID cannot be blank") + } + + if (userId.length > MAX_USER_ID_LENGTH) { + errors.add("User ID exceeds maximum length of $MAX_USER_ID_LENGTH characters") + } + + return errors + } + + /** Sanitize event name to make it valid */ + fun sanitizeEventName(eventName: String): String { + if (eventName.isBlank()) return "unknown_event" + + // Remove invalid characters and ensure it starts with letter + var sanitized = eventName.replace(Regex("[^a-zA-Z0-9_]"), "_") + .take(MAX_EVENT_NAME_LENGTH) + + // Ensure it starts with a letter + if (!sanitized.first().isLetter()) { + sanitized = "event_$sanitized" + } + + // Avoid reserved names + if (RESERVED_EVENT_NAMES.contains(sanitized) || RESERVED_PREFIXES.any { + sanitized.startsWith( + it, + ) + } + ) { + sanitized = "custom_$sanitized" + } + + return sanitized.take(MAX_EVENT_NAME_LENGTH) + } + + /** Sanitize parameter key to make it valid */ + fun sanitizeParameterKey(key: String): String { + if (key.isBlank()) return "unknown_param" + + var sanitized = key.replace(Regex("[^a-zA-Z0-9_]"), "_") + .take(MAX_PARAM_KEY_LENGTH) + + if (!sanitized.first().isLetter()) { + sanitized = "param_$sanitized" + } + + if (RESERVED_PREFIXES.any { sanitized.startsWith(it) }) { + sanitized = "custom_$sanitized" + } + + return sanitized.take(MAX_PARAM_KEY_LENGTH) + } + + /** Sanitize parameter value to make it valid */ + fun sanitizeParameterValue(value: String): String { + return value.take(MAX_PARAM_VALUE_LENGTH) + } + + /** Create a safe parameter with validation and sanitization */ + fun createSafeParam(key: String, value: String): Param { + val sanitizedKey = sanitizeParameterKey(key) + val sanitizedValue = sanitizeParameterValue(value) + return Param(sanitizedKey, sanitizedValue) + } + + /** Create a safe event with validation and sanitization */ + fun createSafeEvent(type: String, params: List = emptyList()): AnalyticsEvent { + val sanitizedType = sanitizeEventName(type) + val sanitizedParams = params.map { createSafeParam(it.key, it.value) } + .take(MAX_PARAMS_PER_EVENT) + + return AnalyticsEvent(sanitizedType, sanitizedParams) + } +} + +/** + * Validating analytics helper that wraps another helper and validates + * events + */ +class ValidatingAnalyticsHelper( + private val delegate: AnalyticsHelper, + private val validator: AnalyticsValidator = AnalyticsValidator(), + // If true, throws on validation errors; if false, sanitizes + private val strictMode: Boolean = false, + private val logValidationErrors: Boolean = true, +) : AnalyticsHelper { + + override fun logEvent(event: AnalyticsEvent) { + val validationResult = validator.validateEvent(event) + + when { + validationResult.isValid -> { + delegate.logEvent(event) + } + + strictMode -> { + throw IllegalArgumentException( + "Invalid analytics event: ${ + validationResult.errorMessages.joinToString( + ", ", + ) + }", + ) + } + + else -> { + // Sanitize and log + val safeEvent = validator.createSafeEvent(event.type, event.extras) + delegate.logEvent(safeEvent) + + if (logValidationErrors) { + delegate.logEvent( + AnalyticsEvent( + "analytics_validation_error", + listOf( + Param(key = "original_event_type", value = event.type), + Param( + key = "errors", + value = validationResult.errorMessages.joinToString("; "), + ), + ), + ), + ) + } + } + } + } + + override fun setUserProperty(name: String, value: String) { + val errors = validator.validateUserProperty(name, value) + + when { + errors.isEmpty() -> { + delegate.setUserProperty(name, value) + } + + strictMode -> { + throw IllegalArgumentException( + "Invalid user property: ${errors.joinToString(", ")}", + ) + } + + else -> { + val sanitizedName = validator.sanitizeParameterKey(name) + .take(AnalyticsValidator.MAX_USER_PROPERTY_NAME_LENGTH) + val sanitizedValue = value.take(AnalyticsValidator.MAX_USER_PROPERTY_VALUE_LENGTH) + delegate.setUserProperty(sanitizedName, sanitizedValue) + + if (logValidationErrors && errors.isNotEmpty()) { + delegate.logEvent( + AnalyticsEvent( + "user_property_validation_error", + listOf( + Param("property_name", name), + Param("errors", errors.joinToString("; ")), + ), + ), + ) + } + } + } + } + + override fun setUserId(userId: String) { + val errors = validator.validateUserId(userId) + + when { + errors.isEmpty() -> { + delegate.setUserId(userId) + } + + strictMode -> { + throw IllegalArgumentException("Invalid user ID: ${errors.joinToString(", ")}") + } + + else -> { + val sanitizedUserId = userId.take(AnalyticsValidator.MAX_USER_ID_LENGTH) + delegate.setUserId(sanitizedUserId) + + if (logValidationErrors && errors.isNotEmpty()) { + delegate.logEvent( + AnalyticsEvent( + "user_id_validation_error", + listOf( + Param("errors", errors.joinToString("; ")), + ), + ), + ) + } + } + } + } +} + +/** Extension to wrap any analytics helper with validation */ +fun AnalyticsHelper.withValidation( + strictMode: Boolean = false, + logValidationErrors: Boolean = true, + validator: AnalyticsValidator = AnalyticsValidator(), +): AnalyticsHelper = ValidatingAnalyticsHelper(this, validator, strictMode, logValidationErrors) + +/** Extension to validate an event without logging it */ +fun AnalyticsEvent.validate( + validator: AnalyticsValidator = AnalyticsValidator(), +): AnalyticsValidator.ValidationResult { + return validator.validateEvent(this) +} + +/** Extension to check if an event is valid */ +fun AnalyticsEvent.isValid(validator: AnalyticsValidator = AnalyticsValidator()): Boolean { + return validator.validateEvent(this).isValid +} + +/** Extension to sanitize an event */ +fun AnalyticsEvent.sanitize( + validator: AnalyticsValidator = AnalyticsValidator(), +): AnalyticsEvent { + return validator.createSafeEvent(this.type, this.extras) +} diff --git a/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/di/AnalyticsModule.kt b/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/di/AnalyticsModule.kt new file mode 100644 index 0000000000..c424346dd2 --- /dev/null +++ b/core-base/analytics/src/commonMain/kotlin/template/core/base/analytics/di/AnalyticsModule.kt @@ -0,0 +1,14 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.analytics.di + +import org.koin.core.module.Module + +expect val analyticsModule: Module diff --git a/core-base/analytics/src/desktopMain/kotlin/template/core/base/analytics/di/AnalyticsModule.desktop.kt b/core-base/analytics/src/desktopMain/kotlin/template/core/base/analytics/di/AnalyticsModule.desktop.kt new file mode 100644 index 0000000000..84b754df6d --- /dev/null +++ b/core-base/analytics/src/desktopMain/kotlin/template/core/base/analytics/di/AnalyticsModule.desktop.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.analytics.di + +import org.koin.core.module.Module +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.bind +import org.koin.dsl.module +import template.core.base.analytics.AnalyticsHelper +import template.core.base.analytics.StubAnalyticsHelper + +actual val analyticsModule: Module + get() = module { + singleOf(::StubAnalyticsHelper) bind AnalyticsHelper::class + } diff --git a/core-base/analytics/src/jsMain/kotlin/template.core.base.analytics/di/AnalyticsModule.js.kt b/core-base/analytics/src/jsMain/kotlin/template.core.base.analytics/di/AnalyticsModule.js.kt new file mode 100644 index 0000000000..6f9b6f75ea --- /dev/null +++ b/core-base/analytics/src/jsMain/kotlin/template.core.base.analytics/di/AnalyticsModule.js.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +@file:Suppress("InvalidPackageDeclaration") + +package template.core.base.analytics.di + +import org.koin.core.module.Module +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.bind +import org.koin.dsl.module +import template.core.base.analytics.AnalyticsHelper +import template.core.base.analytics.StubAnalyticsHelper + +actual val analyticsModule: Module + get() = module { + + // Enable this when Firebase Project is set up + // single { Firebase.analytics } + // singleOf(::FirebaseAnalyticsHelper) bind AnalyticsHelper::class + + singleOf(::StubAnalyticsHelper) bind AnalyticsHelper::class + } diff --git a/core-base/analytics/src/nativeMain/kotlin/template.core.base.analytics.di/AnalyticsModule.native.kt b/core-base/analytics/src/nativeMain/kotlin/template.core.base.analytics.di/AnalyticsModule.native.kt new file mode 100644 index 0000000000..530bacb712 --- /dev/null +++ b/core-base/analytics/src/nativeMain/kotlin/template.core.base.analytics.di/AnalyticsModule.native.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +@file:Suppress("InvalidPackageDeclaration") + +package template.core.base.analytics.di + +import dev.gitlive.firebase.Firebase +import dev.gitlive.firebase.analytics.FirebaseAnalytics +import dev.gitlive.firebase.analytics.analytics +import org.koin.core.module.Module +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.bind +import org.koin.dsl.module +import template.core.base.analytics.AnalyticsHelper +import template.core.base.analytics.FirebaseAnalyticsHelper + +actual val analyticsModule: Module + get() = module { + single { Firebase.analytics } + singleOf(::FirebaseAnalyticsHelper) bind AnalyticsHelper::class + } diff --git a/core-base/analytics/src/nonJsCommonMain/kotlin/template/core/base/analytics/FirebaseAnalyticsHelper.kt b/core-base/analytics/src/nonJsCommonMain/kotlin/template/core/base/analytics/FirebaseAnalyticsHelper.kt new file mode 100644 index 0000000000..a3a29eae86 --- /dev/null +++ b/core-base/analytics/src/nonJsCommonMain/kotlin/template/core/base/analytics/FirebaseAnalyticsHelper.kt @@ -0,0 +1,117 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.analytics + +import dev.gitlive.firebase.analytics.FirebaseAnalytics +import dev.gitlive.firebase.analytics.logEvent + +/** + * Production implementation of [AnalyticsHelper] that sends events to Firebase Analytics. + * + * This implementation provides a complete analytics solution using Firebase Analytics + * as the backend service. It handles automatic parameter validation, length truncation + * according to Firebase constraints, and provides full user tracking capabilities. + * + * **Features:** + * - **Automatic Truncation**: Parameters are automatically truncated to Firebase limits + * - **User Tracking**: Full support for user properties and user ID tracking + * - **Cross-Platform**: Works on Android, iOS, and other supported Firebase platforms + * - **Real-time Processing**: Events are sent to Firebase for real-time analytics + * - **Integration**: Seamlessly integrates with Firebase Console and other Firebase services + * + * **Firebase Analytics Constraints:** + * - Event names: ≤ 40 characters + * - Parameter keys: ≤ 40 characters + * - Parameter values: ≤ 100 characters + * - User property names: ≤ 24 characters + * - User property values: ≤ 36 characters + * - Maximum parameters per event: 25 + * + * **Setup Requirements:** + * - Firebase project configured with Analytics enabled + * - Platform-specific Firebase SDK configuration + * - Google Services configuration files (google-services.json, GoogleService-Info.plist) + * + * @param firebaseAnalytics The Firebase Analytics instance to use for logging events. + * This should be properly configured for the target platform. + * + * @see AnalyticsHelper for the complete interface contract + * @see StubAnalyticsHelper for development/debugging implementation + * @see NoOpAnalyticsHelper for no-operation implementation + * + * @sample + * ```kotlin + * // Typical DI setup for production builds + * val analyticsModule = module { + * single { + * FirebaseAnalyticsHelper(Firebase.analytics) + * } + * } + * ``` + * + * @since 1.0.0 + */ +internal class FirebaseAnalyticsHelper( + private val firebaseAnalytics: FirebaseAnalytics, +) : AnalyticsHelper { + + /** + * Logs an analytics event to Firebase Analytics with automatic parameter truncation. + * + * This method sends the event to Firebase Analytics, automatically truncating + * parameter keys and values to meet Firebase's length constraints. The event + * will appear in the Firebase Console within a few hours for standard events. + * + * @param event The analytics event to log. Parameters will be automatically + * truncated if they exceed Firebase limits. + */ + override fun logEvent(event: AnalyticsEvent) { + firebaseAnalytics.logEvent(event.type) { + for (extra in event.extras) { + // Truncate parameter keys and values according to firebase maximum length values. + param( + key = extra.key.take(40), + value = extra.value.take(100), + ) + } + } + } + + /** + * Sets a user property in Firebase Analytics with automatic length truncation. + * + * User properties are attributes you define to describe segments of your user base. + * They're useful for creating audiences and can be used as filters in Firebase reports. + * Properties are automatically truncated to Firebase's length limits. + * + * @param name The user property name (≤ 24 characters after truncation) + * @param value The user property value (≤ 36 characters after truncation) + */ + override fun setUserProperty(name: String, value: String) { + firebaseAnalytics.setUserProperty(name.take(24), value.take(36)) + } + + /** + * Sets the user ID in Firebase Analytics for cross-session user tracking. + * + * The user ID enables you to associate events with specific users across + * sessions and devices. This helps create a more complete picture of user + * behavior and enables advanced analytics features. + * + * **Privacy Note**: Ensure the user ID doesn't contain personally identifiable + * information and complies with privacy regulations. + * + * @param userId The unique user identifier. Should not contain PII and must + * be consistent across sessions for the same user. + */ + override fun setUserId(userId: String) { + firebaseAnalytics.setUserId(userId) + } +} diff --git a/core-base/analytics/src/wasmJsMain/kotlin/template/core/base/analytics/di/AnalyticsModule.wasmJs.kt b/core-base/analytics/src/wasmJsMain/kotlin/template/core/base/analytics/di/AnalyticsModule.wasmJs.kt new file mode 100644 index 0000000000..84b754df6d --- /dev/null +++ b/core-base/analytics/src/wasmJsMain/kotlin/template/core/base/analytics/di/AnalyticsModule.wasmJs.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.analytics.di + +import org.koin.core.module.Module +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.bind +import org.koin.dsl.module +import template.core.base.analytics.AnalyticsHelper +import template.core.base.analytics.StubAnalyticsHelper + +actual val analyticsModule: Module + get() = module { + singleOf(::StubAnalyticsHelper) bind AnalyticsHelper::class + } diff --git a/core-base/common/.gitignore b/core-base/common/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/core-base/common/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core-base/common/README.md b/core-base/common/README.md new file mode 100644 index 0000000000..a3fc24ebeb --- /dev/null +++ b/core-base/common/README.md @@ -0,0 +1 @@ +# :core:common module diff --git a/core-base/common/build.gradle.kts b/core-base/common/build.gradle.kts new file mode 100644 index 0000000000..337b750098 --- /dev/null +++ b/core-base/common/build.gradle.kts @@ -0,0 +1,47 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +plugins { + alias(libs.plugins.mifos.kmp.library) + alias(libs.plugins.kotlin.parcelize) +} + +android { + namespace = "template.core.base.common" +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.coroutines.core) + api(libs.kermit.logging) + api(libs.squareup.okio) + api(libs.jb.kotlin.stdlib) + api(libs.kotlinx.datetime) + } + + androidMain.dependencies { + implementation(libs.kotlinx.coroutines.android) + } + commonTest.dependencies { + implementation(libs.kotlinx.coroutines.test) + } + iosMain.dependencies { + api(libs.kermit.simple) + } + desktopMain.dependencies { + implementation(libs.kotlinx.coroutines.swing) + implementation(libs.kotlin.reflect) + } + jsMain.dependencies { + api(libs.jb.kotlin.stdlib.js) + api(libs.jb.kotlin.dom) + } + } +} \ No newline at end of file diff --git a/core-base/common/consumer-rules.pro b/core-base/common/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/core-base/common/src/androidMain/AndroidManifest.xml b/core-base/common/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000000..e60d7f7451 --- /dev/null +++ b/core-base/common/src/androidMain/AndroidManifest.xml @@ -0,0 +1,13 @@ + + + + + \ No newline at end of file diff --git a/core-base/common/src/androidMain/kotlin/template/core/base/common/Parcelize.android.kt b/core-base/common/src/androidMain/kotlin/template/core/base/common/Parcelize.android.kt new file mode 100644 index 0000000000..4f9a1c7ba5 --- /dev/null +++ b/core-base/common/src/androidMain/kotlin/template/core/base/common/Parcelize.android.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.common + +import android.os.Parcel +import android.os.Parcelable +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parceler +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.TypeParceler + +actual typealias Parcelize = Parcelize + +actual typealias Parcelable = Parcelable + +actual typealias IgnoredOnParcel = IgnoredOnParcel + +actual typealias Parceler

= Parceler

+ +actual typealias TypeParceler = TypeParceler + +actual typealias Parcel = Parcel diff --git a/core-base/common/src/androidMain/kotlin/template/core/base/common/di/CommonModule.android.kt b/core-base/common/src/androidMain/kotlin/template/core/base/common/di/CommonModule.android.kt new file mode 100644 index 0000000000..c293e29991 --- /dev/null +++ b/core-base/common/src/androidMain/kotlin/template/core/base/common/di/CommonModule.android.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.common.di + +import org.koin.core.module.Module +import org.koin.dsl.module +import template.core.base.common.manager.DispatcherManager +import template.core.base.common.manager.DispatcherManagerImpl + +actual val dispatcherManagerModule: Module + get() = module { + single { DispatcherManagerImpl() } + } diff --git a/core-base/common/src/androidMain/kotlin/template/core/base/common/manager/DispatchManagerImpl.kt b/core-base/common/src/androidMain/kotlin/template/core/base/common/manager/DispatchManagerImpl.kt new file mode 100644 index 0000000000..73a9344203 --- /dev/null +++ b/core-base/common/src/androidMain/kotlin/template/core/base/common/manager/DispatchManagerImpl.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +@file:Suppress("ktlint:standard:filename", "MatchingDeclarationName") + +package template.core.base.common.manager + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainCoroutineDispatcher +import kotlinx.coroutines.SupervisorJob + +class DispatcherManagerImpl : DispatcherManager { + override val default: CoroutineDispatcher = Dispatchers.IO + + override val main: MainCoroutineDispatcher = Dispatchers.Main + + override val io: CoroutineDispatcher = Dispatchers.Default + + override val unconfined: CoroutineDispatcher = Dispatchers.Unconfined + + override val appScope: CoroutineScope + get() = CoroutineScope(SupervisorJob() + Dispatchers.Default) +} diff --git a/core-base/common/src/commonMain/kotlin/template/core/base/common/DataState.kt b/core-base/common/src/commonMain/kotlin/template/core/base/common/DataState.kt new file mode 100644 index 0000000000..b2d736a854 --- /dev/null +++ b/core-base/common/src/commonMain/kotlin/template/core/base/common/DataState.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.common + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart + +sealed class DataState { + + /** Data that is being wrapped by [DataState]. */ + abstract val data: T? + + /** Loading state that has no data is available. */ + data object Loading : DataState() { + override val data: Nothing? get() = null + } + + /** Loaded state that has data available. */ + data class Success( + override val data: T, + ) : DataState() + + /** Pending state that has data available. */ + data class Pending( + override val data: T, + ) : DataState() + + /** Error state that may have data available. */ + data class Error( + val error: Throwable, + override val data: T? = null, + ) : DataState() + + /** No network state that may have data is available. */ + data class NoNetwork( + override val data: T? = null, + ) : DataState() +} + +fun Flow.asDataStateFlow(): Flow> = + map> { DataState.Success(it) } + .onStart { emit(DataState.Loading) } + .catch { emit(DataState.Error(it, null)) } diff --git a/core-base/common/src/commonMain/kotlin/template/core/base/common/DataStateExtensions.kt b/core-base/common/src/commonMain/kotlin/template/core/base/common/DataStateExtensions.kt new file mode 100644 index 0000000000..407862176e --- /dev/null +++ b/core-base/common/src/commonMain/kotlin/template/core/base/common/DataStateExtensions.kt @@ -0,0 +1,129 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.common + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.transformWhile +import kotlinx.coroutines.flow.update + +inline fun DataState.map( + transform: (T) -> R, +): DataState = when (this) { + is DataState.Success -> DataState.Success(transform(data)) + is DataState.Loading -> DataState.Loading + is DataState.Pending -> DataState.Pending(transform(data)) + is DataState.Error -> DataState.Error(error, data?.let(transform)) + is DataState.NoNetwork -> DataState.NoNetwork(data?.let(transform)) +} + +inline fun DataState.mapNullable( + transform: (T?) -> R, +): DataState = when (this) { + is DataState.Success -> DataState.Success(data = transform(data)) + is DataState.Loading -> DataState.Loading + is DataState.Pending -> DataState.Pending(data = transform(data)) + is DataState.Error -> DataState.Error(error = error, data = transform(data)) + is DataState.NoNetwork -> DataState.NoNetwork(data = transform(data)) +} + +fun Flow>.takeUntilSuccess(): Flow> = transformWhile { + emit(it) + it !is DataState.Success +} + +fun MutableStateFlow>.updateToPendingOrLoading() { + update { dataState -> + dataState.data + ?.let { data -> DataState.Pending(data = data) } + ?: DataState.Loading + } +} + +fun combineDataStates( + dataState1: DataState, + dataState2: DataState, + transform: (t1: T1, t2: T2) -> R, +): DataState { + // Wraps the `transform` lambda to allow null data to be passed in. If either of the passed in + // values are null, the regular transform will not be invoked and null is returned. + val nullableTransform: (T1?, T2?) -> R? = { t1, t2 -> + if (t1 != null && t2 != null) transform(t1, t2) else null + } + return when { + // Error states have highest priority, fail fast. + dataState1 is DataState.Error -> { + DataState.Error( + error = dataState1.error, + data = nullableTransform(dataState1.data, dataState2.data), + ) + } + + dataState2 is DataState.Error -> { + DataState.Error( + error = dataState2.error, + data = nullableTransform(dataState1.data, dataState2.data), + ) + } + + dataState1 is DataState.NoNetwork || dataState2 is DataState.NoNetwork -> { + DataState.NoNetwork(nullableTransform(dataState1.data, dataState2.data)) + } + + // Something is still loading, we will wait for all the data. + dataState1 is DataState.Loading || dataState2 is DataState.Loading -> DataState.Loading + + // Pending state for everything while any one piece of data is updating. + dataState1 is DataState.Pending || dataState2 is DataState.Pending -> { + @Suppress("UNCHECKED_CAST") + DataState.Pending(transform(dataState1.data as T1, dataState2.data as T2)) + } + + // Both states are Success and have data + else -> { + @Suppress("UNCHECKED_CAST") + DataState.Success(transform(dataState1.data as T1, dataState2.data as T2)) + } + } +} + +fun combineDataStates( + dataState1: DataState, + dataState2: DataState, + dataState3: DataState, + transform: (t1: T1, t2: T2, t3: T3) -> R, +): DataState = + dataState1 + .combineDataStatesWith(dataState2) { t1, t2 -> t1 to t2 } + .combineDataStatesWith(dataState3) { t1t2Pair, t3 -> + transform(t1t2Pair.first, t1t2Pair.second, t3) + } + +fun combineDataStates( + dataState1: DataState, + dataState2: DataState, + dataState3: DataState, + dataState4: DataState, + transform: (t1: T1, t2: T2, t3: T3, t4: T4) -> R, +): DataState = + dataState1 + .combineDataStatesWith(dataState2) { t1, t2 -> t1 to t2 } + .combineDataStatesWith(dataState3) { t1t2Pair, t3 -> + Triple(t1t2Pair.first, t1t2Pair.second, t3) + } + .combineDataStatesWith(dataState4) { t1t2t3Triple, t3 -> + transform(t1t2t3Triple.first, t1t2t3Triple.second, t1t2t3Triple.third, t3) + } + +fun DataState.combineDataStatesWith( + dataState2: DataState, + transform: (t1: T1, t2: T2) -> R, +): DataState = + combineDataStates(this, dataState2, transform) diff --git a/core-base/common/src/commonMain/kotlin/template/core/base/common/ImageExtension.kt b/core-base/common/src/commonMain/kotlin/template/core/base/common/ImageExtension.kt new file mode 100644 index 0000000000..39fa764dc7 --- /dev/null +++ b/core-base/common/src/commonMain/kotlin/template/core/base/common/ImageExtension.kt @@ -0,0 +1,102 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.common + +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * Extension function to convert ByteArray to Base64 string + */ +@OptIn(ExperimentalEncodingApi::class) +fun ByteArray.toBase64(): String { + return Base64.encode(this) +} + +/** + * Extension function to convert ByteArray to Base64 string with data URI prefix + * @param mimeType The MIME type of the data (e.g., "image/png", "image/jpeg") + */ +@OptIn(ExperimentalEncodingApi::class) +fun ByteArray.toBase64DataUri(mimeType: String = "application/octet-stream"): String { + return "data:$mimeType;base64,${Base64.encode(this)}" +} + +/** + * Extension function to convert Base64 string to ByteArray + * @throws IllegalArgumentException if the string is not valid Base64 + */ +@OptIn(ExperimentalEncodingApi::class) +fun String.fromBase64(): ByteArray { + return Base64.decode(this) +} + +/** + * Extension function to safely convert Base64 string to ByteArray + * @return ByteArray if successful, null if the string is not valid Base64 + */ +@OptIn(ExperimentalEncodingApi::class) +fun String.fromBase64OrNull(): ByteArray? { + return try { + Base64.decode(this) + } catch (e: IllegalArgumentException) { + null + } +} + +/** + * Extension function to convert Base64 data URI to ByteArray + * Handles data URIs in format: "data:mime/type;base64,actualBase64Data" + * @return ByteArray of the decoded data + * @throws IllegalArgumentException if the data URI format is invalid + */ +@OptIn(ExperimentalEncodingApi::class) +fun String.fromBase64DataUri(): ByteArray { + val dataUriPrefix = "data:" + val base64Prefix = ";base64," + + require(this.startsWith(dataUriPrefix)) { + "Invalid data URI: must start with 'data:'" + } + + val base64Index = this.indexOf(base64Prefix) + require(base64Index != -1) { + "Invalid data URI: missing ';base64,' separator" + } + + val base64Data = this.substring(base64Index + base64Prefix.length) + return Base64.decode(base64Data) +} + +/** + * Extension function to safely convert Base64 data URI to ByteArray + * @return ByteArray if successful, null if the data URI format is invalid + */ +@OptIn(ExperimentalEncodingApi::class) +fun String.fromBase64DataUriOrNull(): ByteArray? { + return try { + fromBase64DataUri() + } catch (e: IllegalArgumentException) { + null + } +} + +/** + * Extension function to extract MIME type from Base64 data URI + * @return MIME type string or null if not a valid data URI + */ +fun String.extractMimeTypeFromDataUri(): String? { + val dataUriPrefix = "data:" + val base64Prefix = ";base64," + + return takeIf { it.startsWith(dataUriPrefix) && it.contains(base64Prefix) } + ?.substringAfter(dataUriPrefix) + ?.substringBefore(base64Prefix) +} diff --git a/core-base/common/src/commonMain/kotlin/template/core/base/common/Parcelize.kt b/core-base/common/src/commonMain/kotlin/template/core/base/common/Parcelize.kt new file mode 100644 index 0000000000..fbd1b2e4a4 --- /dev/null +++ b/core-base/common/src/commonMain/kotlin/template/core/base/common/Parcelize.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.common + +expect annotation class Parcelize() + +expect interface Parcelable + +expect annotation class IgnoredOnParcel() + +expect interface Parceler

{ + fun create(parcel: Parcel): P + + fun P.write(parcel: Parcel, flags: Int) +} + +expect annotation class TypeParceler>() + +expect class Parcel { + fun readByte(): Byte + fun readInt(): Int + + fun readFloat(): Float + fun readDouble(): Double + fun readString(): String? + + fun writeByte(value: Byte) + fun writeInt(value: Int) + + fun writeFloat(value: Float) + + fun writeDouble(value: Double) + fun writeString(value: String?) +} diff --git a/core-base/common/src/commonMain/kotlin/template/core/base/common/di/CommonModule.kt b/core-base/common/src/commonMain/kotlin/template/core/base/common/di/CommonModule.kt new file mode 100644 index 0000000000..e78b98fd44 --- /dev/null +++ b/core-base/common/src/commonMain/kotlin/template/core/base/common/di/CommonModule.kt @@ -0,0 +1,19 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.common.di + +import org.koin.core.module.Module +import org.koin.dsl.module + +val CommonModule = module { + includes(dispatcherManagerModule) +} + +expect val dispatcherManagerModule: Module diff --git a/core-base/common/src/commonMain/kotlin/template/core/base/common/manager/DispatcherManager.kt b/core-base/common/src/commonMain/kotlin/template/core/base/common/manager/DispatcherManager.kt new file mode 100644 index 0000000000..f8bf3102cd --- /dev/null +++ b/core-base/common/src/commonMain/kotlin/template/core/base/common/manager/DispatcherManager.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.common.manager + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.MainCoroutineDispatcher + +interface DispatcherManager { + /** + * The default [CoroutineDispatcher] for the app. + */ + val default: CoroutineDispatcher + + /** + * The [MainCoroutineDispatcher] for the app. + */ + val main: MainCoroutineDispatcher + + /** + * The IO [CoroutineDispatcher] for the app. + */ + val io: CoroutineDispatcher + + /** + * The unconfined [CoroutineDispatcher] for the app. + */ + val unconfined: CoroutineDispatcher + + val appScope: CoroutineScope +} diff --git a/core-base/common/src/nonAndroidMain/kotlin/template/core/base/common/Parcelize.nonAndroid.kt b/core-base/common/src/nonAndroidMain/kotlin/template/core/base/common/Parcelize.nonAndroid.kt new file mode 100644 index 0000000000..54e898c351 --- /dev/null +++ b/core-base/common/src/nonAndroidMain/kotlin/template/core/base/common/Parcelize.nonAndroid.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.common + +actual interface Parcelable +actual annotation class IgnoredOnParcel +actual annotation class Parcelize +actual interface Parceler

{ + actual fun create(parcel: Parcel): P + actual fun P.write(parcel: Parcel, flags: Int) +} + +actual annotation class TypeParceler> + +actual class Parcel { + actual fun readString(): String? = null + actual fun readByte(): Byte = 1 + + actual fun readInt(): Int = 1 + + actual fun readFloat(): Float = 1f + + actual fun readDouble(): Double = 1.0 + + actual fun writeByte(value: Byte) { + } + + actual fun writeInt(value: Int) { + } + + actual fun writeFloat(value: Float) { + } + + actual fun writeDouble(value: Double) { + } + + actual fun writeString(value: String?) { + } +} diff --git a/core-base/common/src/nonAndroidMain/kotlin/template/core/base/common/di/CommonModule.nonAndroid.kt b/core-base/common/src/nonAndroidMain/kotlin/template/core/base/common/di/CommonModule.nonAndroid.kt new file mode 100644 index 0000000000..c293e29991 --- /dev/null +++ b/core-base/common/src/nonAndroidMain/kotlin/template/core/base/common/di/CommonModule.nonAndroid.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.common.di + +import org.koin.core.module.Module +import org.koin.dsl.module +import template.core.base.common.manager.DispatcherManager +import template.core.base.common.manager.DispatcherManagerImpl + +actual val dispatcherManagerModule: Module + get() = module { + single { DispatcherManagerImpl() } + } diff --git a/core-base/common/src/nonAndroidMain/kotlin/template/core/base/common/manager/DispatchManagerImpl.kt b/core-base/common/src/nonAndroidMain/kotlin/template/core/base/common/manager/DispatchManagerImpl.kt new file mode 100644 index 0000000000..c04acce844 --- /dev/null +++ b/core-base/common/src/nonAndroidMain/kotlin/template/core/base/common/manager/DispatchManagerImpl.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +@file:Suppress("ktlint:standard:filename", "MatchingDeclarationName") + +package template.core.base.common.manager + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainCoroutineDispatcher +import kotlinx.coroutines.SupervisorJob + +class DispatcherManagerImpl : DispatcherManager { + override val default: CoroutineDispatcher = Dispatchers.Default + + override val main: MainCoroutineDispatcher = Dispatchers.Main + + override val io: CoroutineDispatcher = Dispatchers.Default + + override val unconfined: CoroutineDispatcher = Dispatchers.Unconfined + + override val appScope: CoroutineScope + get() = CoroutineScope(SupervisorJob() + Dispatchers.Default) +} diff --git a/core-base/database/.gitignore b/core-base/database/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/core-base/database/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core-base/database/README.md b/core-base/database/README.md new file mode 100644 index 0000000000..f17ef11859 --- /dev/null +++ b/core-base/database/README.md @@ -0,0 +1,220 @@ +# Core Base Database Module + +A Kotlin Multiplatform library that provides cross-platform database abstractions using Room +database for Android, Desktop, and Native platforms. + +## Overview + +This module serves as a foundational database layer for the Mifos Initiative applications, enabling +consistent database operations across Android, Desktop (JVM), and Native (iOS/macOS) platforms using +the Room persistence library. + +## Architecture + +The module follows the Kotlin Multiplatform expect/actual pattern to provide platform-specific +implementations while maintaining a common interface: + +### Common Module (`commonMain`) + +- **Room.kt**: Defines expect declarations for Room annotations (`@Dao`, `@Entity`, `@Query`, etc.) +- **TypeConverter.kt**: Defines expect declaration for `@TypeConverter` annotation +- **OnConflictStrategy**: Platform-agnostic constants for database conflict resolution + +### Platform-Specific Modules + +- **Android (`androidMain`)**: Uses androidx.room directly with Android Context +- **Desktop (`desktopMain`)**: Uses androidx.room with file-based database storage +- **Native (`nativeMain`)**: Uses androidx.room with iOS/macOS document directory storage + +## Key Components + +### AppDatabaseFactory + +Platform-specific factory classes that handle database creation and configuration: + +#### Android Implementation + +- Requires Android `Context` for database creation +- Uses `Room.databaseBuilder()` with application context +- Stores databases in standard Android app data directory + +#### Desktop Implementation + +- Creates databases in platform-appropriate directories: + - **Windows**: `%APPDATA%/MifosDatabase` + - **macOS**: `~/Library/Application Support/MifosDatabase` + - **Linux**: `~/.local/share/MifosDatabase` +- Uses inline reified generics for type-safe database instantiation + +#### Native Implementation + +- Stores databases in iOS/macOS document directory +- Uses platform-specific file system APIs +- Leverages Kotlin/Native interop for Foundation framework access + +### Room Annotations + +Cross-platform type aliases for Room annotations that ensure consistent API across all platforms: + +- `@Dao` - Data Access Object annotation +- `@Entity` - Database entity annotation +- `@Query` - SQL query annotation +- `@Insert` - Insert operation annotation +- `@PrimaryKey` - Primary key annotation +- `@ForeignKey` - Foreign key constraint annotation +- `@Index` - Database index annotation +- `@TypeConverter` - Type conversion annotation + +## Usage Examples + +### Basic Setup + +#### Android + +```kotlin +class MyApplication : Application() { + val databaseFactory = AppDatabaseFactory(this) + + val database = databaseFactory + .createDatabase(MyDatabase::class.java, "my_database.db") + .build() +} +``` + +#### Desktop + +```kotlin +class DesktopApp { + val databaseFactory = AppDatabaseFactory() + + val database = databaseFactory + .createDatabase("my_database.db") + .build() +} +``` + +#### Native (iOS/macOS) + +```kotlin +class IOSApp { + val databaseFactory = AppDatabaseFactory() + + val database = databaseFactory + .createDatabase("my_database.db") + .build() +} +``` + +### Defining Entities + +```kotlin +@Entity(tableName = "users") +data class User( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + val name: String, + val email: String +) +``` + +### Creating DAOs + +```kotlin +@Dao +interface UserDao { + @Query("SELECT * FROM users") + suspend fun getAllUsers(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertUser(user: User) + + @Query("DELETE FROM users WHERE id = :userId") + suspend fun deleteUser(userId: Long) +} +``` + +### Database Definition + +```kotlin +@Database( + entities = [User::class], + version = 1, + exportSchema = false +) +abstract class MyDatabase : RoomDatabase() { + abstract fun userDao(): UserDao +} +``` + +## Dependencies + +The module relies on the following key dependencies: + +- **androidx.room.runtime**: Core Room database functionality +- **Kotlin Multiplatform**: Cross-platform code sharing +- **Platform-specific APIs**: Context (Android), File system (Desktop), Foundation (Native) + +## Configuration + +### Gradle Setup + +```kotlin +kotlin { + sourceSets { + androidMain.dependencies { + implementation(libs.androidx.room.runtime) + } + desktopMain.dependencies { + implementation(libs.androidx.room.runtime) + } + nativeMain.dependencies { + implementation(libs.androidx.room.runtime) + } + } +} +``` + +## Platform Considerations + +### Android + +- Requires minimum API level compatible with Room +- Database files stored in internal app storage +- Supports all Room features including migrations and type converters + +### Desktop + +- Cross-platform directory selection ensures proper database placement +- Supports full Room functionality on JVM +- Automatic directory creation for database storage + +### Native (iOS/macOS) + +- Uses iOS/macOS document directory for database storage +- Leverages Kotlin/Native C interop for platform APIs +- Requires iOS/macOS specific Room dependencies + +## Best Practices + +1. **Database Versioning**: Always increment version numbers when changing schema +2. **Migration Strategy**: Implement proper Room migrations for schema changes +3. **Type Converters**: Use `@TypeConverter` for complex data types +4. **Conflict Resolution**: Choose appropriate `OnConflictStrategy` for your use case +5. **Testing**: Test database operations on all target platforms + +## Contributing + +When contributing to this module: + +- Maintain expect/actual pattern consistency +- Test changes across all supported platforms +- Update documentation for any API changes +- Follow Kotlin coding conventions +- Ensure proper license headers on all files + +## License + +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/. + +See https://github.com/openMF/kmp-project-template/blob/main/LICENSE for more details. \ No newline at end of file diff --git a/core-base/database/build.gradle.kts b/core-base/database/build.gradle.kts new file mode 100644 index 0000000000..7bb1218f8c --- /dev/null +++ b/core-base/database/build.gradle.kts @@ -0,0 +1,47 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +import org.jetbrains.compose.compose + +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/kmp-project-template/blob/main/LICENSE + */ +plugins { + alias(libs.plugins.mifos.kmp.library) +} + +android { + namespace = "template.core.base.database" +} + +kotlin { + sourceSets { + androidMain.dependencies { + implementation(libs.androidx.room.runtime) + } + + desktopMain.dependencies { + implementation(libs.androidx.room.runtime) + } + + nativeMain.dependencies { + implementation(libs.androidx.room.runtime) + } + + nonJsCommonMain.dependencies { + implementation(libs.androidx.room.runtime) + } + } +} diff --git a/core-base/database/src/androidMain/kotlin/template/core/base/database/AppDatabaseFactory.kt b/core-base/database/src/androidMain/kotlin/template/core/base/database/AppDatabaseFactory.kt new file mode 100644 index 0000000000..8fc9b4c486 --- /dev/null +++ b/core-base/database/src/androidMain/kotlin/template/core/base/database/AppDatabaseFactory.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.database + +import android.content.Context +import androidx.room.Room +import androidx.room.RoomDatabase + +/** + * Android-specific implementation of the database factory for creating Room database instances. + * + * This factory class provides a standardized approach to database creation on Android platforms, + * ensuring proper context handling and consistent database configuration across the application. + * The factory leverages Android's application context to prevent memory leaks and maintain + * database accessibility throughout the application lifecycle. + * + * Key features: + * - Automatic application context usage to prevent memory leaks + * - Type-safe database creation with compile-time verification + * - Consistent database naming and configuration + * - Integration with Android's storage systems + * + * @param context The Android context used for database creation, typically an Application or Activity context + * + * @see androidx.room.Room + * @see androidx.room.RoomDatabase + */ +class AppDatabaseFactory( + private val context: Context, +) { + + /** + * Creates a Room database builder configured for Android environments. + * + * This method constructs a RoomDatabase.Builder instance that can be further customized + * with additional configuration options such as migrations, type converters, or callback + * handlers before building the final database instance. + * + * The method automatically uses the application context to ensure the database remains + * accessible throughout the application lifecycle while preventing potential memory leaks + * that could occur when using activity or service contexts. + * + * @param T The type of RoomDatabase to create, must extend RoomDatabase + * @param databaseClass The Class object representing the database type to instantiate + * @param databaseName The name of the database file to create or access + * @return A RoomDatabase.Builder instance ready for additional configuration and building + * + * @throws IllegalArgumentException if the database class is invalid or cannot be instantiated + * @throws SQLiteException if there are issues with database creation or access + * + * Example usage: + * ```kotlin + * class MyApplication : Application() { + * private val databaseFactory = AppDatabaseFactory(this) + * + * val userDatabase: UserDatabase by lazy { + * databaseFactory + * .createDatabase(UserDatabase::class.java, "user_database.db") + * .addMigrations(MIGRATION_1_2, MIGRATION_2_3) + * .addTypeConverter(DateConverters()) + * .build() + * } + * } + * ``` + * + * Configuration recommendations: + * - Use descriptive database names that reflect their purpose + * - Consider implementing proper migration strategies for schema changes + * - Add appropriate type converters for complex data types + * - Configure database callbacks for initialization or validation logic + */ + fun createDatabase(databaseClass: Class, databaseName: String): RoomDatabase.Builder { + return Room.databaseBuilder( + context.applicationContext, + databaseClass, + databaseName, + ) + } +} diff --git a/core-base/database/src/commonMain/kotlin/template/core/base/database/Room.kt b/core-base/database/src/commonMain/kotlin/template/core/base/database/Room.kt new file mode 100644 index 0000000000..e935dd5f7a --- /dev/null +++ b/core-base/database/src/commonMain/kotlin/template/core/base/database/Room.kt @@ -0,0 +1,236 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.database + +import kotlin.reflect.KClass + +/** + * Cross-platform annotation for marking interfaces as Data Access Objects (DAOs). + * + * This annotation is used to mark interfaces that contain database access methods. + * The Room persistence library will generate implementations of these interfaces + * at compile time. + * + * Example: + * ```kotlin + * @Dao + * interface UserDao { + * @Query("SELECT * FROM users") + * suspend fun getAllUsers(): List + * } + * ``` + */ +@Suppress("NO_ACTUAL_FOR_EXPECT") +expect annotation class Dao() + +/** + * Cross-platform annotation for defining SQL queries on DAO methods. + * + * This annotation is used to define raw SQL queries that will be executed + * when the annotated method is called. The query can contain parameters + * that correspond to method parameters. + * + * @param value The SQL query string to execute + * + * Example: + * ```kotlin + * @Query("SELECT * FROM users WHERE age > :minAge") + * suspend fun getUsersOlderThan(minAge: Int): List + * ``` + */ +@Suppress("NO_ACTUAL_FOR_EXPECT") +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER) +@Retention(AnnotationRetention.BINARY) +expect annotation class Query( + val value: String, +) + +/** + * Cross-platform annotation for marking DAO methods that insert entities into the database. + * + * This annotation defines how the method should behave when inserting entities. + * It can handle single entities, lists of entities, or arrays of entities. + * + * @param entity The entity class that this method inserts (used for type checking) + * @param onConflict Strategy to use when there's a conflict during insertion + * + * Example: + * ```kotlin + * @Insert(onConflict = OnConflictStrategy.REPLACE) + * suspend fun insertUser(user: User): Long + * + * @Insert(onConflict = OnConflictStrategy.IGNORE) + * suspend fun insertUsers(users: List): List + * ``` + */ +@Suppress("NO_ACTUAL_FOR_EXPECT") +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.BINARY) +expect annotation class Insert( + val entity: KClass<*>, + val onConflict: Int, +) + +/** + * Cross-platform annotation for marking entity fields as primary keys. + * + * This annotation identifies which field(s) serve as the primary key for the entity. + * Primary keys uniquely identify each row in the database table. + * + * @param autoGenerate Whether the database should automatically generate values for this primary key + * + * Example: + * ```kotlin + * @Entity + * data class User( + * @PrimaryKey(autoGenerate = true) + * val id: Long = 0, + * val name: String + * ) + * ``` + */ +@Suppress("NO_ACTUAL_FOR_EXPECT") +@Target(AnnotationTarget.FIELD, AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.BINARY) +expect annotation class PrimaryKey( + val autoGenerate: Boolean, +) + +/** + * Cross-platform annotation for defining foreign key constraints. + * + * This annotation is used within the @Entity annotation to define relationships + * between entities through foreign key constraints. It ensures referential integrity + * between related tables. + * + * Example: + * ```kotlin + * @Entity( + * foreignKeys = [ForeignKey( + * entity = User::class, + * parentColumns = ["id"], + * childColumns = ["userId"], + * onDelete = ForeignKey.CASCADE + * )] + * ) + * data class Post( + * @PrimaryKey val id: Long, + * val userId: Long, + * val content: String + * ) + * ``` + */ +@Suppress("NO_ACTUAL_FOR_EXPECT") +@Target(allowedTargets = []) +@Retention(AnnotationRetention.BINARY) +expect annotation class ForeignKey + +/** + * Cross-platform annotation for defining database indexes. + * + * Indexes improve query performance by creating optimized data structures + * for faster data retrieval. This annotation is used within the @Entity + * annotation to define indexes on one or more columns. + * + * Example: + * ```kotlin + * @Entity( + * indices = [ + * Index(value = ["email"], unique = true), + * Index(value = ["firstName", "lastName"]) + * ] + * ) + * data class User( + * @PrimaryKey val id: Long, + * val email: String, + * val firstName: String, + * val lastName: String + * ) + * ``` + */ +@Suppress("NO_ACTUAL_FOR_EXPECT") +@Target(allowedTargets = []) +@Retention(AnnotationRetention.BINARY) +expect annotation class Index + +/** + * Cross-platform annotation for marking classes as database entities. + * + * This annotation transforms a Kotlin class into a database table. + * Each instance of the class represents a row in the table, and each + * property represents a column. + * + * @param tableName Custom name for the database table (defaults to class name) + * @param indices Array of indexes to create on this table + * @param inheritSuperIndices Whether to inherit indexes from parent classes + * @param primaryKeys Array of column names that form the composite primary key + * @param foreignKeys Array of foreign key constraints for this table + * @param ignoredColumns Array of property names to exclude from the table + * + * Example: + * ```kotlin + * @Entity( + * tableName = "user_profiles", + * indices = [Index(value = ["email"], unique = true)] + * ) + * data class UserProfile( + * @PrimaryKey(autoGenerate = true) + * val id: Long = 0, + * val email: String, + * val displayName: String + * ) + * ``` + */ +@Suppress("NO_ACTUAL_FOR_EXPECT") +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.BINARY) +expect annotation class Entity( + val tableName: String, + val indices: Array, + val inheritSuperIndices: Boolean, + val primaryKeys: Array, + val foreignKeys: Array, + val ignoredColumns: Array, +) + +/** + * Cross-platform constants for handling database conflicts during insert operations. + * + * These constants define the behavior when inserting data that conflicts with + * existing constraints (such as primary key or unique constraints). + * + * Example usage: + * ```kotlin + * @Insert(onConflict = OnConflictStrategy.REPLACE) + * suspend fun insertUser(user: User) + * + * @Insert(onConflict = OnConflictStrategy.IGNORE) + * suspend fun insertUserIfNotExists(user: User) + * ``` + */ +object OnConflictStrategy { + /** No conflict resolution strategy specified (may cause exceptions) */ + const val NONE = 0 + + /** Replace the existing data with the new data when conflicts occur */ + const val REPLACE = 1 + + /** Rollback the transaction when conflicts occur */ + const val ROLLBACK = 2 + + /** Abort the current operation when conflicts occur */ + const val ABORT = 3 + + /** Fail the operation and throw an exception when conflicts occur */ + const val FAIL = 4 + + /** Ignore the new data when conflicts occur (keep existing data) */ + const val IGNORE = 5 +} diff --git a/core-base/database/src/commonMain/kotlin/template/core/base/database/TypeConverter.kt b/core-base/database/src/commonMain/kotlin/template/core/base/database/TypeConverter.kt new file mode 100644 index 0000000000..e708c2a5fc --- /dev/null +++ b/core-base/database/src/commonMain/kotlin/template/core/base/database/TypeConverter.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.database + +/** + * Cross-platform annotation for marking methods as database type converters. + * + * Type converters enable Room to store and retrieve complex data types that are not + * natively supported by SQLite. This annotation marks methods that convert between + * custom types and primitive types that SQLite can understand. + * + * Type converters must be static methods (or methods in an object class) and should + * come in pairs: one method to convert from the custom type to a primitive type, + * and another to convert back from the primitive type to the custom type. + * + * Example usage: + * ```kotlin + * object DateConverters { + * @TypeConverter + * fun fromTimestamp(value: Long?): Date? { + * return value?.let { Date(it) } + * } + * + * @TypeConverter + * fun dateToTimestamp(date: Date?): Long? { + * return date?.time + * } + * } + * + * // Register converters in your database + * @Database(...) + * @TypeConverters(DateConverters::class) + * abstract class MyDatabase : RoomDatabase() { + * // Database implementation + * } + * ``` + * + * Common use cases for type converters include: + * - Converting Date objects to Long timestamps + * - Converting enums to String or Int values + * - Converting complex objects to JSON strings + * - Converting lists or arrays to comma-separated strings + * + * Performance considerations: + * - Type converters are called frequently during database operations + * - Keep conversion logic simple and efficient + * - Consider caching expensive conversions when appropriate + * - Avoid complex object creation in frequently-called converters + */ +@Suppress("NO_ACTUAL_FOR_EXPECT") +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.BINARY) +expect annotation class TypeConverter() diff --git a/core-base/database/src/desktopMain/kotlin/template/core/base/database/AppDatabaseFactory.kt b/core-base/database/src/desktopMain/kotlin/template/core/base/database/AppDatabaseFactory.kt new file mode 100644 index 0000000000..6e5afe2ecb --- /dev/null +++ b/core-base/database/src/desktopMain/kotlin/template/core/base/database/AppDatabaseFactory.kt @@ -0,0 +1,123 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.database + +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.util.findAndInstantiateDatabaseImpl +import java.io.File + +/** + * Desktop-specific implementation of the database factory for creating Room database instances. + * + * This factory class provides cross-platform desktop database creation capabilities, + * automatically selecting appropriate storage locations based on the operating system. + * The implementation ensures databases are stored in platform-conventional directories + * that provide appropriate persistence and user access patterns. + * + * Platform-specific storage locations: + * - Windows: %APPDATA%/MifosDatabase + * - macOS: ~/Library/Application Support/MifosDatabase + * - Linux: ~/.local/share/MifosDatabase + * + * Key features: + * - Automatic platform detection and directory selection + * - Cross-platform file system compatibility + * - Type-safe database creation using inline reified generics + * - Automatic directory creation when required + * - Integration with JVM-based Room implementations + * + * @see androidx.room.Room + * @see androidx.room.RoomDatabase + */ +class AppDatabaseFactory { + + /** + * Creates a Room database builder configured for desktop environments. + * + * This method constructs a RoomDatabase.Builder instance specifically configured + * for desktop applications, with automatic platform-appropriate storage location + * selection. The method leverages inline reified generics to provide type safety + * while maintaining flexibility in database instantiation. + * + * The implementation automatically detects the current operating system and + * selects the conventional application data directory for that platform, + * ensuring databases are stored in locations that align with user expectations + * and system conventions. + * + * @param T The type of RoomDatabase to create, must extend RoomDatabase + * @param databaseName The name of the database file to create or access + * @param factory Optional factory function for database instantiation, defaults to Room's automatic discovery + * @return A RoomDatabase.Builder instance ready for additional configuration and building + * + * @throws SecurityException if the application lacks permission to create directories or files + * @throws IOException if there are file system issues during directory or database creation + * @throws ClassNotFoundException if the database implementation class cannot be located + * + * Directory creation behavior: + * - Automatically creates the MifosDatabase directory if it does not exist + * - Respects existing directory permissions and structure + * - Uses platform-appropriate path separators and naming conventions + * + * Example usage: + * ```kotlin + * class DesktopApplication { + * private val databaseFactory = AppDatabaseFactory() + * + * val transactionDatabase: TransactionDatabase by lazy { + * databaseFactory + * .createDatabase("transactions.db") + * .addMigrations(MIGRATION_1_2) + * .enableMultiInstanceInvalidation() + * .build() + * } + * + * val userDatabase: UserDatabase by lazy { + * databaseFactory + * .createDatabase("users.db") { + * // Custom factory implementation if needed + * UserDatabase_Impl() + * } + * .addTypeConverter(CustomConverters()) + * .build() + * } + * } + * ``` + * + * Platform considerations: + * - Windows installations should ensure %APPDATA% is accessible + * - macOS applications may require appropriate entitlements for file system access + * - Linux environments should verify user home directory permissions + * - Consider backup and synchronization implications of chosen storage locations + */ + inline fun createDatabase( + databaseName: String, + noinline factory: () -> T = { findAndInstantiateDatabaseImpl(T::class.java) }, + ): RoomDatabase.Builder { + val os = System.getProperty("os.name").lowercase() + val userHome = System.getProperty("user.home") + val appDataDir = when { + os.contains("win") -> File(System.getenv("APPDATA"), "MifosDatabase") + os.contains("mac") -> File(userHome, "Library/Application Support/MifosDatabase") + else -> File(userHome, ".local/share/MifosDatabase") + } + + if (!appDataDir.exists()) { + appDataDir.mkdirs() + } + + val dbFile = File(appDataDir, databaseName) + + return Room.databaseBuilder( + name = dbFile.absolutePath, + factory = factory, + ) + } +} diff --git a/core-base/database/src/nativeMain/kotlin/template/core/base/database/AppDatabaseFactory.kt b/core-base/database/src/nativeMain/kotlin/template/core/base/database/AppDatabaseFactory.kt new file mode 100644 index 0000000000..f217e646e6 --- /dev/null +++ b/core-base/database/src/nativeMain/kotlin/template/core/base/database/AppDatabaseFactory.kt @@ -0,0 +1,160 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.database + +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.util.findDatabaseConstructorAndInitDatabaseImpl +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSDocumentDirectory +import platform.Foundation.NSFileManager +import platform.Foundation.NSUserDomainMask + +/** + * Native platform implementation of the database factory for iOS and macOS applications. + * + * This factory class provides database creation capabilities specifically designed for + * iOS and macOS platforms using Kotlin/Native interoperability with Foundation framework APIs. + * The implementation ensures databases are stored in the standard document directory, + * providing appropriate persistence characteristics and compliance with platform guidelines. + * + * Platform integration features: + * - Direct integration with iOS/macOS Foundation framework + * - Standard document directory storage following Apple guidelines + * - Type-safe database creation using inline reified generics + * - Kotlin/Native C interop for optimal platform performance + * - Compliance with iOS app sandbox requirements + * + * Storage characteristics: + * - Databases stored in application's document directory + * - Automatic backup eligibility through iTunes/iCloud (configurable) + * - Persistent across application updates and device restores + * - Accessible through Files app on iOS (when appropriate) + * + * @see androidx.room.Room + * @see androidx.room.RoomDatabase + * @see platform.Foundation.NSFileManager + */ +class AppDatabaseFactory { + + /** + * Creates a Room database builder configured for iOS/macOS native environments. + * + * This method constructs a RoomDatabase.Builder instance specifically optimized + * for native iOS and macOS applications. The implementation leverages Foundation + * framework APIs through Kotlin/Native interop to ensure proper platform integration + * and adherence to Apple's storage guidelines. + * + * The database file is automatically placed in the application's document directory, + * which provides appropriate persistence characteristics and follows Apple's + * recommended storage patterns for user-generated content and application data. + * + * @param T The type of RoomDatabase to create, must extend RoomDatabase + * @param databaseName The name of the database file to create within the document directory + * @param factory Optional factory function for database instantiation, defaults to Room's constructor discovery + * @return A RoomDatabase.Builder instance ready for additional configuration and building + * + * @throws NSException if document directory access fails or is unavailable + * @throws RuntimeException if the database constructor cannot be located or instantiated + * @throws SecurityException if the application lacks required file system permissions + * + * Platform considerations: + * - Database files are eligible for iCloud backup by default + * - Files may be accessible through the Files app depending on configuration + * - Storage location complies with App Store Review Guidelines + * - Automatic cleanup may occur during low storage conditions + * + * Example usage: + * ```kotlin + * class IOSApp { + * private val databaseFactory = AppDatabaseFactory() + * + * val coreDatabase: CoreDatabase by lazy { + * databaseFactory + * .createDatabase("core_data.db") + * .addMigrations(MIGRATION_VERSIONS) + * .setJournalMode(RoomDatabase.JournalMode.WAL) + * .build() + * } + * + * val cacheDatabase: CacheDatabase by lazy { + * databaseFactory + * .createDatabase("cache.db") { + * CacheDatabase_Impl() + * } + * .addCallback(object : RoomDatabase.Callback() { + * override fun onCreate(db: SupportSQLiteDatabase) { + * // Initialize cache tables + * } + * }) + * .build() + * } + * } + * ``` + * + * Configuration recommendations: + * - Consider WAL mode for improved concurrent access performance + * - Implement proper migration strategies for iOS app updates + * - Configure backup exclusion for cache or temporary databases + * - Monitor storage usage in compliance with platform guidelines + */ + inline fun createDatabase( + databaseName: String, + noinline factory: () -> T = { findDatabaseConstructorAndInitDatabaseImpl(T::class) }, + ): RoomDatabase.Builder { + val dbFilePath = documentDirectory() + "/$databaseName" + return Room.databaseBuilder( + name = dbFilePath, + factory = factory, + ) + } + + /** + * Retrieves the path to the application's document directory using Foundation framework APIs. + * + * This method provides access to the standard iOS/macOS document directory through + * Kotlin/Native interop with the Foundation framework. The document directory serves + * as the primary location for storing user-generated content and application data + * that should persist across application launches and system updates. + * + * The implementation uses NSFileManager to locate the document directory within + * the user domain, ensuring proper sandboxing compliance and platform integration. + * This approach guarantees that database files are stored in locations that align + * with Apple's storage guidelines and user expectations. + * + * @return The absolute file system path to the application's document directory + * + * @throws RuntimeException if the document directory cannot be located or accessed + * @throws NSException if Foundation framework calls fail due to system restrictions + * + * Directory characteristics: + * - Persistent across application updates and device restores + * - Included in iTunes and iCloud backups by default + * - Accessible through document provider extensions when configured + * - Subject to iOS storage management and optimization + * + * Implementation notes: + * - Uses NSUserDomainMask to ensure user-specific directory access + * - Leverages NSDocumentDirectory constant for standard directory location + * - Employs Kotlin/Native C interop for optimal performance and integration + * - Handles potential nil responses from Foundation framework appropriately + */ + @OptIn(ExperimentalForeignApi::class) + fun documentDirectory(): String { + val documentDirectory = NSFileManager.defaultManager.URLForDirectory( + directory = NSDocumentDirectory, + inDomain = NSUserDomainMask, + appropriateForURL = null, + create = false, + error = null, + ) + return requireNotNull(documentDirectory?.path) + } +} diff --git a/core-base/database/src/nonJsCommonMain/kotlin/template/core/base/database/Room.nonJsCommon.kt b/core-base/database/src/nonJsCommonMain/kotlin/template/core/base/database/Room.nonJsCommon.kt new file mode 100644 index 0000000000..f4762df10f --- /dev/null +++ b/core-base/database/src/nonJsCommonMain/kotlin/template/core/base/database/Room.nonJsCommon.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.database + +import androidx.room.Dao +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.Insert +import androidx.room.PrimaryKey +import androidx.room.Query + +/** + * Multiplatform typealiases for Room database annotations and interfaces. + * + * This file provides `actual` typealiases for common Room annotations and interfaces, allowing + * shared code to use Room database features in a platform-agnostic way. These typealiases map + * to the corresponding Android Room components, enabling code sharing across platforms in a + * Kotlin Multiplatform project. + * + * @see Room Persistence Library + */ + +/** + * Typealias for the Room `@Dao` annotation/interface. + * Used to mark Data Access Objects in shared code. + */ +actual typealias Dao = Dao + +/** + * Typealias for the Room `@Query` annotation. + * Used to annotate methods in DAOs for SQL queries. + */ +actual typealias Query = Query + +/** + * Typealias for the Room `@Insert` annotation. + * Used to annotate methods in DAOs for insert operations. + */ +actual typealias Insert = Insert + +/** + * Typealias for the Room `@PrimaryKey` annotation. + * Used to mark primary key fields in entities. + */ +actual typealias PrimaryKey = PrimaryKey + +/** + * Typealias for the Room `@ForeignKey` annotation. + * Used to define foreign key relationships in entities. + */ +actual typealias ForeignKey = ForeignKey + +/** + * Typealias for the Room `@Index` annotation. + * Used to define indices on entity fields. + */ +actual typealias Index = Index + +/** + * Typealias for the Room `@Entity` annotation. + * Used to mark classes as database entities. + */ +actual typealias Entity = Entity diff --git a/core-base/database/src/nonJsCommonMain/kotlin/template/core/base/database/TypeConverter.nonJsCommon.kt b/core-base/database/src/nonJsCommonMain/kotlin/template/core/base/database/TypeConverter.nonJsCommon.kt new file mode 100644 index 0000000000..3cc7c40001 --- /dev/null +++ b/core-base/database/src/nonJsCommonMain/kotlin/template/core/base/database/TypeConverter.nonJsCommon.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.database + +import androidx.room.TypeConverter + +/** + * Type alias for Room's [TypeConverter] annotation in non-JS common code. + * + * This type alias maps the Room [TypeConverter] annotation to be used in the non-JS common module. + * The [TypeConverter] annotation is used to define custom type conversions for Room database entities. + * + * @see androidx.room.TypeConverter + */ +actual typealias TypeConverter = TypeConverter diff --git a/core-base/datastore/README.md b/core-base/datastore/README.md new file mode 100644 index 0000000000..822a58c14a --- /dev/null +++ b/core-base/datastore/README.md @@ -0,0 +1,282 @@ +# Core Datastore Module [Deprecated] + +> \[!Important] +> This module serves as a demonstration of Architecture & SOLID Pattern principles with comprehensive unit testing. The +> settings library already provides the same functionality, and under the hood it'd use that library, consider directly +> using that and this will be removed soon from this project. + +A robust, type-safe, and reactive data storage solution for Kotlin Multiplatform projects, built on +top of [Multiplatform Settings](https://github.com/russhwolf/multiplatform-settings). This module +provides a flexible API for managing persistent data with support for caching, validation, and +reactive observation using Kotlin Flows. + +## Features + +- Type-safe data storage for primitive and serializable types +- In-memory caching with LRU implementation for performance +- Data validation for keys and values +- JSON serialization/deserialization using `kotlinx.serialization` +- Reactive data observation using Kotlin Coroutines Flow +- Dedicated support for user preferences management +- Comprehensive exception handling +- Flexible instance creation and dependency management via a Factory + +## Architecture + +The `core-base/datastore` module is designed with a layered and component-based architecture to +ensure modularity, testability, and flexibility. It separates concerns into distinct layers and +components, allowing for customization and easier maintenance. + +**Core Layers:** + +1. **Contracts:** Defines the fundamental interfaces that outline the capabilities of different data + store implementations. This layer provides abstractions for basic storage operations, key-value + storage, caching, and reactive observation. +2. **Stores:** Contains the concrete implementations of the data storage logic. These classes + interact with the underlying `Multiplatform Settings` library and integrate supporting components + like caching, serialization, and validation. +3. **Repositories:** Offers a higher-level, more use-case oriented API built on top of the data + stores. The reactive repository provides convenient methods for common preference management + tasks and reactive observation. + +**Supporting Components:** + +- **Factory:** The `DataStoreFactory` provides a builder pattern for creating and configuring + instances of the reactive data store and repository. It simplifies the process of assembling the + various components with desired settings, dispatchers, cache configurations, validators, and + serialization strategies. +- **Serialization:** Defines strategies for converting complex data objects to and from storable + formats, primarily using `kotlinx.serialization` for JSON. +- **Validation:** Implements rules for validating keys and values before they are stored, ensuring + data integrity. +- **Cache:** Provides an optional in-memory caching layer (`LRUCacheManager`) to improve read + performance for frequently accessed data. +- **Reactive:** Includes components (`ChangeNotifier`, `ValueObserver`) that enable the reactive + capabilities of the data store, allowing observers to react to data changes via Kotlin Flows. +- **Type Handling:** Manages the specific logic required to store and retrieve different primitive + data types using the underlying settings mechanism. +- **Exceptions:** Defines custom exception classes for specific error conditions within the module. + +**Architecture Flow:** + +The typical interaction flow involves the **Repository** layer calling methods on the **Store** +layer. The **Store** layer then utilizes **Serialization**, **Validation**, **Type Handling**, and * +*Cache** components as needed before interacting with the underlying **Settings** implementation. * +*Reactive** components are integrated within the **Store** layer to provide Flow-based observation. +The **Factory** is responsible for assembling and configuring these components. + +**Diagram:** + +```mermaid +graph TD + A[User/Application Code] --> B[DataStoreFactory] + B --> C[ReactivePreferencesRepository] + C --> D[ReactiveDataStore] + D --> E[CachedPreferencesStore] + E --> F[Settings-Multiplatform Settings] + E --> G[CacheManager] + E --> H[SerializationStrategy] + E --> I[PreferencesValidator] + E --> J[TypeHandler] + D --> K[ChangeNotifier] + D --> L[ValueObserver] + + subgraph Layers + C:::repository + D:::store + E:::store + end + + subgraph Supporting Components + B:::factory + G:::cache + H:::serialization + I:::validation + J:::typehandler + K:::reactive + L:::reactive + end + + subgraph Underlying Technology + F:::settings + end + + classDef repository fill: #f9f, stroke: #333, stroke-width: 2; + classDef store fill: #ccf, stroke: #333, stroke-width: 2; + classDef factory fill: #cfc, stroke: #333, stroke-width: 2; + classDef cache fill: #ffc, stroke: #333, stroke-width: 2; + classDef serialization fill: #fcf, stroke: #333, stroke-width: 2; + classDef validation fill: #cff, stroke: #333, stroke-width: 2; + classDef typehandler fill: #cc9, stroke: #333, stroke-width: 2; + classDef reactive fill: #9cf, stroke: #333, stroke-width: 2; + classDef settings fill: #f99, stroke: #333, stroke-width: 2; + +``` + +## Usage + +### Basic Usage + +This section demonstrates how to use the basic `DataStore` for a custom type. + +```kotlin +// Define your data class with serialization annotation +@Serializable +data class UserData(val name: String, val age: Int) + +// Create serializer and validator instances +// Using JsonSerializationStrategy requires a KSerializer for your data type +val serializer = JsonSerializationStrategy(UserData.serializer()) +// Use DefaultDataStoreValidator if no specific validation is needed +val validator = DefaultDataStoreValidator() + +// Obtain DataStore instance from a factory (assuming a DataStoreFactory is provided via DI or elsewhere) +// The factory is responsible for creating instances based on the desired configuration +val dataStore: DataStore = dataStoreFactory.createDataStore(serializer, validator) + +// Store data asynchronously +dataStore.setData(UserData("John", 30)) + +// Get data as a Coroutines Flow. The flow emits the current data and subsequent changes. +dataStore.getData().collect { userData -> + println("User: ${userData.name}, Age: ${userData.age}") +} + +// To get a snapshot of the current data without observing future changes, you might use: +// val currentUserData = dataStore.getData().first() // Requires importing kotlinx.coroutines.flow.first +``` + +### Using Typed DataStore + +`TypedDataStore` is suitable for scenarios where you need to store multiple pieces of data of the +same type, identified by a key. + +```kotlin +// Assuming UserData, serializer, and validator are defined as above + +// Create a typed data store. The second type parameter defines the type of the key. +val typedDataStore: TypedDataStore = + dataStoreFactory.createTypedDataStore( + serializer, + validator + ) + +// Store data with a specific key +typedDataStore.setDataForKey("user1", UserData("John", 30)) +typedDataStore.setDataForKey("user2", UserData("Jane", 25)) + +// Get data by key as a Flow +typedDataStore.getDataForKey("user1").collect { userData -> + // userData will be null if no data is stored for this key + println("User for key user1: ${userData?.name}, Age: ${userData?.age}") +} + +// To get a snapshot for a specific key: +// val user1Snapshot = typedDataStore.getDataForKey("user1").first() +``` + +### Using Cacheable DataStore + +`CacheableDataStore` adds an in-memory caching layer to a `TypedDataStore`, improving read +performance for frequently accessed data. + +```kotlin +// Assuming UserData, serializer, and validator are defined as above + +// Create a CacheManager. LRUCacheManager is a common choice. +// The cache stores data in memory, mapping keys to data objects. +val cacheManager = LRUCacheManager() + +// Create a cacheable data store, combining TypedDataStore functionality with caching. +val cacheableDataStore: CacheableDataStore = + dataStoreFactory.createCacheableDataStore( + serializer, + validator, + cacheManager + ) + +// Store and cache data. Storing data through CacheableDataStore will also update the cache. +cacheableDataStore.setDataForKey("user1", UserData("John", 30)) + +// Get data, potentially served from the cache if available and not invalidated. +cacheableDataStore.getCachedData("user1").collect { userData -> + println("User from cacheable store (key user1): ${userData?.name}, Age: ${userData?.age}") +} + +// Note: The underlying storage is still used to persist data. The cache is an optimization layer. +// Cache invalidation strategies or manual cache management might be needed for complex scenarios. +``` + +### Using User Preferences + +The `UserPreferencesRepository` provides a simple key-value store for basic user settings, typically +backed by Multiplatform Settings. + +```kotlin +// Obtain UserPreferencesRepository instance, usually through Dependency Injection +// Assuming 'get()' is available in your context (e.g., Koin) +val userPreferences: UserPreferencesRepository = get() + +// Store preferences for various primitive types +userPreferences.setString("theme", "dark") +userPreferences.setBoolean("notifications", true) +userPreferences.setInt("fontSize", 14) +// ... other primitive types supported by Multiplatform Settings + +// Get preferences as Flows +userPreferences.getString("theme", defaultValue = "light").collect { theme -> + println("Current theme: $theme") +} + +userPreferences.getBoolean("notifications", defaultValue = false).collect { enabled -> + println("Notifications enabled: $enabled") +} + +// Note: Use appropriate default values when retrieving preferences to handle cases where a preference is not set. +``` + +## Guidelines + +- **Choosing the Right DataStore:** + - Use `DataStore` for storing a single instance of a complex data object (e.g., user profile + data). + - Use `TypedDataStore` for storing multiple instances of a data object, addressable by a unique + key (e.g., cached API responses, individual settings for different features). + - Use `CacheableDataStore` when you need the functionality of `TypedDataStore` and want to add + an in-memory caching layer for performance. + - Use `UserPreferencesRepository` for simple key-value storage of primitive types, suitable for + user settings and flags. + +- **Key Naming:** When using `TypedDataStore` or `UserPreferencesRepository`, use clear and + consistent key names, perhaps following a convention like `featureName_dataType_identifier`. + +- **Serialization:** Always use `@Serializable` annotation from `kotlinx.serialization` for data + classes stored using `DataStore`, `TypedDataStore`, or `CacheableDataStore`. Ensure you have the + necessary serializer instances. + +- **Validation:** Implement `DataStoreValidator` if your data requires specific validation rules + before storage or after retrieval. + +- **Error Handling:** The DataStore operations typically use Kotlin Result or throw exceptions for + failures during serialization, deserialization, or storage. Implement appropriate error handling + in your data flows or suspending functions. + +- **Schema Evolution:** The current module does not include built-in support for schema migrations. + If your data structure changes in future versions of your application, you will need to handle + data migration manually, for example, by checking a version flag in your preferences or + implementing a migration logic during app startup. + +- **Dependency Injection:** It is recommended to obtain instances of `DataStoreFactory` and + `UserPreferencesRepository` through dependency injection (e.g., using Koin) rather than creating + them directly in your application code. This promotes testability and modularity. + +## Testing + +The module includes comprehensive test cases for all components. Run the tests using: + +```bash +./gradlew :core-base:datastore:test +``` + +Ensure you write tests for your specific DataStore implementations and validators when using this +module. \ No newline at end of file diff --git a/core-base/datastore/build.gradle.kts b/core-base/datastore/build.gradle.kts new file mode 100644 index 0000000000..e94180c40b --- /dev/null +++ b/core-base/datastore/build.gradle.kts @@ -0,0 +1,36 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +plugins { + alias(libs.plugins.mifos.kmp.library) + id("kotlinx-serialization") +} + +android { + namespace = "template.core.base.datastore" +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(libs.multiplatform.settings) + implementation(libs.multiplatform.settings.serialization) + implementation(libs.multiplatform.settings.coroutines) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.datetime) + } + + commonTest.dependencies { + implementation(libs.multiplatform.settings.test) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.turbine) + } + } +} diff --git a/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/cache/CacheManager.kt b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/cache/CacheManager.kt new file mode 100644 index 0000000000..286d54ecf2 --- /dev/null +++ b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/cache/CacheManager.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore.cache + +/** + * Interface for managing in-memory caching of key-value pairs in the data store. + * + * Implementations of this interface provide methods for storing, retrieving, removing, + * and clearing cached entries, as well as checking cache size and key existence. + * + * Example usage: + * ```kotlin + * val cache: CacheManager = LruCacheManager(100) + * cache.put("theme", "dark") + * val value = cache.get("theme") + * cache.remove("theme") + * cache.clear() + * val size = cache.size() + * val exists = cache.containsKey("theme") + * ``` + * + * @param K The type of the cache key. + * @param V The type of the cached value. + */ +interface CacheManager { + /** + * Stores a value in the cache associated with the specified key. + * + * @param key The key to associate with the value. + * @param value The value to store. + */ + fun put(key: K, value: V) + + /** + * Retrieves a value from the cache associated with the specified key. + * + * @param key The key to retrieve. + * @return The cached value, or `null` if not present. + */ + fun get(key: K): V? + + /** + * Removes the value associated with the specified key from the cache. + * + * @param key The key to remove. + * @return The removed value, or `null` if not present. + */ + fun remove(key: K): V? + + /** + * Clears all entries from the cache. + */ + fun clear() + + /** + * Returns the number of entries currently stored in the cache. + * + * @return The cache size. + */ + fun size(): Int + + /** + * Checks whether the cache contains the specified key. + * + * @param key The key to check. + * @return `true` if the cache contains the key, `false` otherwise. + */ + fun containsKey(key: K): Boolean +} diff --git a/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/cache/LruCacheManager.kt b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/cache/LruCacheManager.kt new file mode 100644 index 0000000000..05c4f66da7 --- /dev/null +++ b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/cache/LruCacheManager.kt @@ -0,0 +1,132 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore.cache + +import template.core.base.datastore.exceptions.CacheException + +/** + * Least Recently Used (LRU) cache implementation for managing in-memory key-value pairs. + * + * This cache automatically evicts the least recently used entry when the maximum size is exceeded. + * It provides thread-unsafe, fast access for use cases where cache contention is not a concern. + * + * Example usage: + * ```kotlin + * val cache = LruCacheManager(maxSize = 100) + * cache.put("theme", "dark") + * val value = cache.get("theme") + * cache.remove("theme") + * cache.clear() + * val size = cache.size() + * val exists = cache.containsKey("theme") + * ``` + * + * @param K The type of the cache key. + * @param V The type of the cached value. + * @property maxSize The maximum number of entries the cache can hold. + */ +class LruCacheManager( + private val maxSize: Int = 100, +) : CacheManager { + + private val cache = LinkedHashMap(maxSize, 0.75f) + + /** + * Adds or updates a value in the cache for the specified key. + * If the cache exceeds its maximum size, the least recently used entry is evicted. + * + * @param key The key to associate with the value. + * @param value The value to store in the cache. + * @throws CacheException if the operation fails. + */ + override fun put(key: K, value: V) { + try { + cache[key] = value + if (cache.size > maxSize) { + val eldest = cache.keys.first() + cache.remove(eldest) + } + } catch (e: Exception) { + throw CacheException("Failed to put value in cache for key: $key", e) + } + } + + /** + * Retrieves the value associated with the specified key from the cache. + * + * @param key The key to look up. + * @return The value associated with the key, or null if the key is not present. + * @throws CacheException if the operation fails. + */ + override fun get(key: K): V? { + return try { + cache[key] + } catch (e: Exception) { + throw CacheException("Failed to get value from cache for key: $key", e) + } + } + + /** + * Removes the value associated with the specified key from the cache. + * + * @param key The key to remove. + * @return The value that was associated with the key, or null if the key was not present. + * @throws CacheException if the operation fails. + */ + override fun remove(key: K): V? { + return try { + cache.remove(key) + } catch (e: Exception) { + throw CacheException("Failed to remove value from cache for key: $key", e) + } + } + + /** + * Removes all entries from the cache. + * + * @throws CacheException if the operation fails. + */ + override fun clear() { + try { + cache.clear() + } catch (e: Exception) { + throw CacheException("Failed to clear cache", e) + } + } + + /** + * Returns the current number of entries in the cache. + * + * @return The number of key-value pairs currently stored in the cache. + * @throws CacheException if the operation fails. + */ + override fun size(): Int { + return try { + cache.size + } catch (e: Exception) { + throw CacheException("Failed to get cache size", e) + } + } + + /** + * Checks if the cache contains an entry for the specified key. + * + * @param key The key to check. + * @return true if the cache contains an entry for the key, false otherwise. + * @throws CacheException if the operation fails. + */ + override fun containsKey(key: K): Boolean { + return try { + cache.containsKey(key) + } catch (e: Exception) { + throw CacheException("Failed to check if cache contains key: $key", e) + } + } +} diff --git a/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/contracts/CacheableDataStore.kt b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/contracts/CacheableDataStore.kt new file mode 100644 index 0000000000..057987f74b --- /dev/null +++ b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/contracts/CacheableDataStore.kt @@ -0,0 +1,98 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore.contracts + +/** + * Interface for data storage implementations that support caching. + * + * This interface extends the base [DataStore] interface, adding operations for managing + * an in-memory cache associated with the stored data. + * + * Example usage: + * ```kotlin + * val cacheableDataStore: CacheableDataStore = ... + * cacheableDataStore.putValue("settings_cache", "enabled") + * val value = cacheableDataStore.getValue("settings_cache", "disabled") + * cacheableDataStore.invalidateCache("settings_cache") + * ``` + */ +interface CacheableDataStore : DataStore { + /** + * Stores a value associated with the specified key in the data store and updates the cache. + * + * @param key The key to associate with the value. + * @param value The value to store. + * @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs. + */ + suspend fun putValue(key: String, value: T): Result + + /** + * Retrieves a value associated with the specified key, checking the cache first. + * + * @param key The key to retrieve. + * @param default The default value to return if the key does not exist in the data store. + * @return [Result.success] with the value, or [Result.failure] if an error occurs. + */ + suspend fun getValue(key: String, default: T): Result + + /** + * Stores a serializable value using the provided serializer and updates the cache. + * + * @param key The key to associate with the value. + * @param value The value to store. + * @param serializer The serializer for the value type. + * @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs. + */ + suspend fun putSerializableValue( + key: String, + value: T, + serializer: kotlinx.serialization.KSerializer, + ): Result + + /** + * Retrieves a serializable value using the provided serializer, checking the cache first. + * + * @param key The key to retrieve. + * @param default The default value to return if the key does not exist in the data store. + * @param serializer The serializer for the value type. + * @return [Result.success] with the value, or [Result.failure] if an error occurs. + */ + suspend fun getSerializableValue( + key: String, + default: T, + serializer: kotlinx.serialization.KSerializer, + ): Result + + /** + * Invalidates the cache entry for the specified key. + * + * This forces the next retrieval for this key to read from the underlying data store. + * + * @param key The key whose cache entry should be invalidated. + * @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs. + */ + suspend fun invalidateCache(key: String): Result + + /** + * Invalidates all entries in the cache. + * + * This forces the next retrieval for any key to read from the underlying data store. + * + * @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs. + */ + suspend fun invalidateAllCache(): Result + + /** + * Returns the current number of entries in the cache. + * + * @return The size of the cache. + */ + fun getCacheSize(): Int +} diff --git a/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/contracts/DataStore.kt b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/contracts/DataStore.kt new file mode 100644 index 0000000000..11a0674ef8 --- /dev/null +++ b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/contracts/DataStore.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore.contracts + +/** + * Base interface defining fundamental data storage operations. + * + * Implementations provide methods for checking key existence, removing values, + * clearing the store, and retrieving keys and size. + * + * Example usage: + * ```kotlin + * val dataStore: DataStore = ... + * dataStore.hasKey("config") + * dataStore.removeValue("temp_data") + * dataStore.getAllKeys() + * ``` + */ +interface DataStore { + /** + * Checks if the specified key exists in the data store. + * + * @param key The key to check. + * @return [Result.success] with `true` if the key exists, `false` otherwise, + * or [Result.failure] if an error occurs. + */ + suspend fun hasKey(key: String): Result + + /** + * Removes the value associated with the specified key from the data store. + * + * @param key The key to remove. + * @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs. + */ + suspend fun removeValue(key: String): Result + + /** + * Clears all stored data from the data store. + * + * @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs. + */ + suspend fun clearAll(): Result + + /** + * Retrieves a set of all keys currently stored in the data store. + * + * @return [Result.success] with the set of keys, or [Result.failure] if an error occurs. + */ + suspend fun getAllKeys(): Result> + + /** + * Retrieves the total number of key-value pairs stored in the data store. + * + * @return [Result.success] with the count, or [Result.failure] if an error occurs. + */ + suspend fun getSize(): Result +} diff --git a/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/contracts/DataStoreChangeEvent.kt b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/contracts/DataStoreChangeEvent.kt new file mode 100644 index 0000000000..72ea066ca8 --- /dev/null +++ b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/contracts/DataStoreChangeEvent.kt @@ -0,0 +1,96 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore.contracts + +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +/** + * Represents a change event that occurs in the data store. + * + * This sealed class defines different types of events, such as value additions, updates, + * removals, and the clearing of the entire store. + * + * Example usage: + * ```kotlin + * dataStore.observeChanges().collect { event -> + * when (event) { + * is DataStoreChangeEvent.ValueAdded -> println("Value added for key ${event.key}") + * is DataStoreChangeEvent.ValueUpdated -> println("Value for key ${event.key} updated") + * is DataStoreChangeEvent.ValueRemoved -> println("Value for key ${event.key} removed") + * is DataStoreChangeEvent.StoreCleared -> println("Data store cleared") + * } + * } + * ``` + */ +@OptIn(ExperimentalTime::class) +sealed class DataStoreChangeEvent { + /** + * The key associated with the change event. For [StoreCleared], this is typically "*". + */ + abstract val key: String + + /** + * The timestamp (in milliseconds since the epoch) when the event occurred. + */ + abstract val timestamp: Long + + data class ValueAdded constructor( + /** + * Represents the addition of a new value to the data store. + * + * @property key The key of the added value. + * @property value The value that was added. + * @property timestamp The timestamp of the event. + */ + override val key: String, + val value: Any?, + override val timestamp: Long = Clock.System.now().toEpochMilliseconds(), + ) : DataStoreChangeEvent() + + data class ValueUpdated( + /** + * Represents the update of an existing value in the data store. + * + * @property key The key of the updated value. + * @property oldValue The previous value before the update. + * @property newValue The new value after the update. + * @property timestamp The timestamp of the event. + */ + override val key: String, + val oldValue: Any?, + val newValue: Any?, + override val timestamp: Long = Clock.System.now().toEpochMilliseconds(), + ) : DataStoreChangeEvent() + + data class ValueRemoved( + /** + * Represents the removal of a value from the data store. + * + * @property key The key of the removed value. + * @property oldValue The value that was removed. + * @property timestamp The timestamp of the event. + */ + override val key: String, + val oldValue: Any?, + override val timestamp: Long = Clock.System.now().toEpochMilliseconds(), + ) : DataStoreChangeEvent() + + data class StoreCleared( + /** + * Represents the clearing of the entire data store. + * + * @property key The key is typically "*" for store cleared events. + * @property timestamp The timestamp of the event. + */ + override val key: String = "*", + override val timestamp: Long = Clock.System.now().toEpochMilliseconds(), + ) : DataStoreChangeEvent() +} diff --git a/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/contracts/ReactiveDataStore.kt b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/contracts/ReactiveDataStore.kt new file mode 100644 index 0000000000..785ab8f690 --- /dev/null +++ b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/contracts/ReactiveDataStore.kt @@ -0,0 +1,81 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore.contracts + +import kotlinx.coroutines.flow.Flow +import kotlinx.serialization.KSerializer + +/** + * A reactive interface for observing changes in a data store. + * + * This interface extends [CacheableDataStore] and provides methods to observe changes in the data store + * using Kotlin Flow. It enables reactive programming patterns by emitting updates whenever the underlying + * data changes. + * + * Example usage: + * ```kotlin + * // Observe a simple value + * dataStore.observeValue("theme", "light") + * .collect { theme -> println("Theme changed to: $theme") } + * + * // Observe a serializable value + * dataStore.observeSerializableValue("user", defaultUser, User.serializer()) + * .collect { user -> println("User updated: $user") } + * + * // Observe all keys + * dataStore.observeKeys() + * .collect { keys -> println("Available keys: $keys") } + * ``` + */ +interface ReactiveDataStore : CacheableDataStore { + /** + * Observes changes to a value associated with the specified key. + * + * @param key The key to observe. + * @param default The default value to emit if the key does not exist. + * @return A [Flow] that emits the current value and subsequent updates. + */ + fun observeValue(key: String, default: T): Flow + + /** + * Observes changes to a serializable value associated with the specified key. + * + * @param key The key to observe. + * @param default The default value to emit if the key does not exist. + * @param serializer The serializer for the value type. + * @return A [Flow] that emits the current value and subsequent updates. + */ + fun observeSerializableValue( + key: String, + default: T, + serializer: KSerializer, + ): Flow + + /** + * Observes changes to the set of keys in the data store. + * + * @return A [Flow] that emits the current set of keys and subsequent updates. + */ + fun observeKeys(): Flow> + + /** + * Observes changes to the total number of entries in the data store. + * + * @return A [Flow] that emits the current size and subsequent updates. + */ + fun observeSize(): Flow + + /** + * Observes all changes that occur in the data store. + * + * @return A [Flow] that emits [DataStoreChangeEvent] instances for all changes. + */ + fun observeChanges(): Flow +} diff --git a/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/contracts/TypedDataStore.kt b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/contracts/TypedDataStore.kt new file mode 100644 index 0000000000..d4e48e9bf3 --- /dev/null +++ b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/contracts/TypedDataStore.kt @@ -0,0 +1,83 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore.contracts + +import kotlinx.serialization.KSerializer + +/** + * A type-safe interface for managing persistent data storage operations. + * + * This interface extends [DataStore] and provides methods for storing and retrieving typed data + * with proper serialization support. It ensures type safety through generic parameters and + * serialization mechanisms. + * + * Example usage: + * ```kotlin + * // Store a simple value + * dataStore.putValue("theme", "dark") + * + * // Store a serializable object + * dataStore.putSerializableValue("user", user, User.serializer()) + * + * // Retrieve values + * val theme = dataStore.getValue("theme", "light") + * val user = dataStore.getSerializableValue("user", defaultUser, User.serializer()) + * ``` + * + * @param T The generic type parameter representing the data type to be stored + * @param K The generic type parameter representing the key type used for data identification + */ +interface TypedDataStore : DataStore { + /** + * Stores a value of type [T] associated with the specified key. + * + * @param key The unique identifier for the value + * @param value The value to be stored + * @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs + */ + suspend fun putValue(key: String, value: T): Result + + /** + * Retrieves a value of type [T] associated with the specified key. + * + * @param key The unique identifier for the value + * @param default The default value to return if the key does not exist + * @return [Result.success] containing the retrieved value, or [Result.failure] if an error occurs + */ + suspend fun getValue(key: String, default: T): Result + + /** + * Stores a serializable value of type [T] using the provided serializer. + * + * @param key The unique identifier for the value + * @param value The serializable value to be stored + * @param serializer The [KSerializer] for type [T] + * @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs + */ + suspend fun putSerializableValue( + key: String, + value: T, + serializer: KSerializer, + ): Result + + /** + * Retrieves a serializable value of type [T] using the provided serializer. + * + * @param key The unique identifier for the value + * @param default The default value to return if the key does not exist + * @param serializer The [KSerializer] for type [T] + * @return [Result.success] containing the deserialized value, or [Result.failure] if an error occurs + */ + suspend fun getSerializableValue( + key: String, + default: T, + serializer: KSerializer, + ): Result +} diff --git a/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/di/CoreDatastoreModule.kt b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/di/CoreDatastoreModule.kt new file mode 100644 index 0000000000..e396aad76f --- /dev/null +++ b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/di/CoreDatastoreModule.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore.di + +import com.russhwolf.settings.Settings +import kotlinx.coroutines.Dispatchers +import org.koin.dsl.module +import template.core.base.datastore.contracts.ReactiveDataStore +import template.core.base.datastore.factory.DataStoreFactory +import template.core.base.datastore.reactive.PreferenceFlowOperators +import template.core.base.datastore.repository.ReactivePreferencesRepository + +/** + * Koin module for providing core datastore dependencies. + * + * Usage Example: + * ```kotlin + * startKoin { + * modules(CoreDatastoreModule) + * } + * ``` + */ +val CoreDatastoreModule = module { + + // Platform-specific Settings instance + single { Settings() } + + // Main reactive datastore repository (recommended for most use cases) + single { + DataStoreFactory() + .settings(get()) + .dispatcher(Dispatchers.Unconfined) + .cacheSize(200) + .build() + } + + // Direct access to reactive datastore (if needed for specific use cases) + single { + DataStoreFactory() + .settings(get()) + .dispatcher(Dispatchers.Main) + .cacheSize(200) + .buildDataStore() + } + + // Flow operators for advanced reactive operations + single { + PreferenceFlowOperators(get()) + } +} diff --git a/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/exceptions/DataStoreExceptions.kt b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/exceptions/DataStoreExceptions.kt new file mode 100644 index 0000000000..e7865feeb8 --- /dev/null +++ b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/exceptions/DataStoreExceptions.kt @@ -0,0 +1,91 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore.exceptions + +/** + * Base exception for all preference-related errors in the data store. + * + * @param message The error message. + * @param cause The cause of the exception, if any. + * + * Example: + * ```kotlin + * throw PreferencesException("Something went wrong") + * ``` + */ +sealed class PreferencesException(message: String, cause: Throwable? = null) : + Exception(message, cause) + +/** + * Thrown when an invalid key is used in the data store. + * + * @param key The invalid key. + * + * Example: + * ```kotlin + * throw InvalidKeyException("invalid-key") + * ``` + */ +class InvalidKeyException(key: String) : PreferencesException("Invalid key: $key") + +/** + * Thrown when serialization of a value fails. + * + * @param message The error message. + * @param cause The cause of the exception, if any. + * + * Example: + * ```kotlin + * throw SerializationException("Failed to serialize value") + * ``` + */ +class SerializationException(message: String, cause: Throwable? = null) : + PreferencesException("Serialization failed: $message", cause) + +/** + * Thrown when deserialization of a value fails. + * + * @param message The error message. + * @param cause The cause of the exception, if any. + * + * Example: + * ```kotlin + * throw DeserializationException("Failed to deserialize value") + * ``` + */ +class DeserializationException(message: String, cause: Throwable? = null) : + PreferencesException("Deserialization failed: $message", cause) + +/** + * Thrown when an unsupported type is used in the data store. + * + * @param type The unsupported type. + * + * Example: + * ```kotlin + * throw UnsupportedTypeException("CustomType") + * ``` + */ +class UnsupportedTypeException(type: String) : + PreferencesException("Unsupported type: $type") + +/** + * Thrown when a cache operation fails in the data store. + * + * @param message The error message. + * @param cause The cause of the exception, if any. + * + * Example: + * ```kotlin + * throw CacheException("Failed to cache value") + * ``` + */ +class CacheException(message: String, cause: Throwable? = null) : + PreferencesException("Cache operation failed: $message", cause) diff --git a/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/extensions/FlowExtensions.kt b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/extensions/FlowExtensions.kt new file mode 100644 index 0000000000..708a9bf87c --- /dev/null +++ b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/extensions/FlowExtensions.kt @@ -0,0 +1,162 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore.extensions + +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import template.core.base.datastore.contracts.DataStoreChangeEvent + +/** + * Provides extension functions for Kotlin [Flow] related to data store operations. + * + * These extensions offer convenient ways to work with preference flows, + * such as mapping values with default handling, filtering by change type, and logging changes. + */ + +/** + * Maps values emitted by the flow using the [transform] function and catches any errors, + * emitting the [default] value in case of failure. + * + * @param default The default value to emit if the transformation or upstream flow encounters an error. + * @param transform The function to apply to each value emitted by the flow. + * @return A [Flow] emitting the transformed values or the default value on error. + * + * Example usage: + * ```kotlin + * flowOf("1", "2", "abc") // Example flow of strings + * .mapWithDefault(0) { it.toInt() } // Transform to Int, use 0 if parsing fails + * .collect { value -> println(value) } // Prints 1, 2, 0 + * ``` + */ +fun Flow.mapWithDefault(default: R, transform: (T) -> R): Flow { + return this.map { transform(it) } + .catch { emit(default) } +} + +/** + * Filters a flow of [DataStoreChangeEvent] instances to emit only events of a specific type [T]. + * + * This is useful for reacting only to specific changes like value additions or removals. + * + * @param T The specific type of [DataStoreChangeEvent] to filter for. + * @return A [Flow] emitting only [DataStoreChangeEvent] instances of type [T]. + * + * Example usage: + * ```kotlin + * dataStore.observeChanges() + * .filterChangeType() + * .collect { event -> println("New value added: ${event.key}") } + * ``` + */ +inline fun Flow.filterChangeType(): Flow { + return this.map { it as? T }.filter { it != null }.map { it!! } +} + +/** + * Filters a flow of [DataStoreChangeEvent] instances to emit only [ValueAdded] events. + * + * This is a convenience function equivalent to `filterChangeType()`. + * + * @return A [Flow] emitting only [DataStoreChangeEvent.ValueAdded] instances. + * + * Example usage: + * ```kotlin + * dataStore.observeChanges() + * .onlyAdditions() + * .collect { event -> println("Value added: ${event.key}") } + * ``` + */ +fun Flow.onlyAdditions(): Flow { + return filterChangeType() +} + +/** + * Filters a flow of [DataStoreChangeEvent] instances to emit only [ValueUpdated] events. + * + * This is a convenience function equivalent to `filterChangeType()`. + * + * @return A [Flow] emitting only [DataStoreChangeEvent.ValueUpdated] instances. + * + * Example usage: + * ```kotlin + * dataStore.observeChanges() + * .onlyUpdates() + * .collect { event -> println("Value updated: ${event.key}") } + * ``` + */ +fun Flow.onlyUpdates(): Flow { + return filterChangeType() +} + +/** + * Filters a flow of [DataStoreChangeEvent] instances to emit only [ValueRemoved] events. + * + * This is a convenience function equivalent to `filterChangeType()`. + * + * @return A [Flow] emitting only [DataStoreChangeEvent.ValueRemoved] instances. + * + * Example usage: + * ```kotlin + * dataStore.observeChanges() + * .onlyRemovals() + * .collect { event -> println("Value removed: ${event.key}") } + * ``` + */ +fun Flow.onlyRemovals(): Flow { + return filterChangeType() +} + +/** + * Debounces a flow of preference changes to avoid excessive emissions. + * + * This uses [distinctUntilChanged] as a simple debouncing mechanism. For true time-based debouncing, + * consider using a library like `kotlinx-coroutines-core` with its `debounce` operator. + * + * @param timeoutMillis The timeout duration in milliseconds (currently not used for time-based debouncing). + * @return A [Flow] that suppresses consecutive duplicate emissions. + * + * Example usage: + * ```kotlin + * preferencesFlow + * .debouncePreferences(300) // Suppress rapid identical emissions + * .collect { value -> println("Debounced value: $value") } + * ``` + */ +@OptIn(FlowPreview::class) +fun Flow.debouncePreferences(timeoutMillis: Long = 300): Flow { + // Note: This would require kotlinx-coroutines-core with debounce support + // For now, we'll use distinctUntilChanged as a simple approach + return this.debounce(timeoutMillis) +} + +/** + * Logs each value emitted by the flow for debugging purposes. + * + * @param tag A tag to include in the log output. + * @return The original [Flow]. + * + * Example usage: + * ```kotlin + * preferencesFlow + * .logChanges("MyAppPrefs") + * .collect { value -> // Process value } + * ``` + */ +fun Flow.logChanges(tag: String = "DataStore"): Flow { + return this.onEach { value -> + println("[$tag] Value changed: $value") + } +} diff --git a/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/factory/DataStoreFactory.kt b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/factory/DataStoreFactory.kt new file mode 100644 index 0000000000..31727f3410 --- /dev/null +++ b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/factory/DataStoreFactory.kt @@ -0,0 +1,282 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore.factory + +import com.russhwolf.settings.Settings +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import template.core.base.datastore.cache.CacheManager +import template.core.base.datastore.cache.LruCacheManager +import template.core.base.datastore.contracts.ReactiveDataStore +import template.core.base.datastore.handlers.BooleanTypeHandler +import template.core.base.datastore.handlers.DoubleTypeHandler +import template.core.base.datastore.handlers.FloatTypeHandler +import template.core.base.datastore.handlers.IntTypeHandler +import template.core.base.datastore.handlers.LongTypeHandler +import template.core.base.datastore.handlers.StringTypeHandler +import template.core.base.datastore.handlers.TypeHandler +import template.core.base.datastore.reactive.ChangeNotifier +import template.core.base.datastore.reactive.DefaultChangeNotifier +import template.core.base.datastore.reactive.DefaultValueObserver +import template.core.base.datastore.repository.DefaultReactivePreferencesRepository +import template.core.base.datastore.repository.ReactivePreferencesRepository +import template.core.base.datastore.serialization.JsonSerializationStrategy +import template.core.base.datastore.serialization.SerializationStrategy +import template.core.base.datastore.store.ReactiveUserPreferencesDataStore +import template.core.base.datastore.validation.DefaultPreferencesValidator +import template.core.base.datastore.validation.PreferencesValidator + +/** + * Factory for constructing reactive data store repositories and data stores with customizable configuration. + * + * This class uses the builder pattern to allow flexible configuration of settings, dispatcher, + * cache size, validator, serialization strategy, and change notifier. + * + * Example usage: + * ```kotlin + * // Simple usage with defaults + * val repository = DataStoreFactory.create() + * + * // Custom configuration + * val repository = DataStoreFactory() + * .cacheSize(500) + * .dispatcher(Dispatchers.IO) + * .settings(MyCustomSettings()) + * .build() + * ``` + */ +@Deprecated("Use Settings Library instead") +class DataStoreFactory { + private var settings: Settings? = null + private var dispatcher: CoroutineDispatcher = Dispatchers.Default + private var cacheSize: Int = 200 + private var validator: PreferencesValidator? = null + private var serializationStrategy: SerializationStrategy? = null + private var changeNotifier: ChangeNotifier? = null + + /** + * Sets a custom [Settings] implementation for the data store. + * + * If not provided, the default [Settings] implementation will be used. + * + * @param settings The [Settings] instance to use. + * @return This [DataStoreFactory] instance for chaining. + */ + fun settings(settings: Settings) = apply { + this.settings = settings + } + + /** + * Sets the coroutine [dispatcher] for data store operations. + * + * The default is [Dispatchers.Default]. + * + * @param dispatcher The [CoroutineDispatcher] to use. + * @return This [DataStoreFactory] instance for chaining. + */ + fun dispatcher(dispatcher: CoroutineDispatcher) = apply { + this.dispatcher = dispatcher + } + + /** + * Sets the cache size for the LRU cache. + * + * The default is 200 entries. + * + * @param size The maximum number of entries in the cache. + * @return This [DataStoreFactory] instance for chaining. + */ + fun cacheSize(size: Int) = apply { + this.cacheSize = size + } + + /** + * Sets a custom [PreferencesValidator] for validating keys and values. + * + * If not provided, the default validator will be used. + * + * @param validator The [PreferencesValidator] to use. + * @return This [DataStoreFactory] instance for chaining. + */ + fun validator(validator: PreferencesValidator) = apply { + this.validator = validator + } + + /** + * Sets a custom [SerializationStrategy] for serializing and deserializing values. + * + * If not provided, the default [JsonSerializationStrategy] will be used. + * + * @param strategy The [SerializationStrategy] to use. + * @return This [DataStoreFactory] instance for chaining. + */ + fun serializationStrategy(strategy: SerializationStrategy) = apply { + this.serializationStrategy = strategy + } + + /** + * Sets a custom [ChangeNotifier] for broadcasting change events. + * + * If not provided, the default [DefaultChangeNotifier] will be used. + * + * @param notifier The [ChangeNotifier] to use. + * @return This [DataStoreFactory] instance for chaining. + */ + fun changeNotifier(notifier: ChangeNotifier) = apply { + this.changeNotifier = notifier + } + + /** + * Builds and returns a [ReactivePreferencesRepository] with the current configuration. + * + * @return A fully configured [ReactivePreferencesRepository]. + * + * Example usage: + * ```kotlin + * val repository = DataStoreFactory().build() + * ``` + */ + fun build(): ReactivePreferencesRepository { + val finalSettings = settings ?: Settings() + val finalValidator = validator ?: DefaultPreferencesValidator() + val finalSerializationStrategy = serializationStrategy ?: JsonSerializationStrategy() + val finalChangeNotifier = changeNotifier ?: DefaultChangeNotifier() + + val cacheManager: CacheManager = LruCacheManager(maxSize = cacheSize) + val valueObserver = DefaultValueObserver(finalChangeNotifier) + + @Suppress("UNCHECKED_CAST") + val typeHandlers: List> = listOf( + IntTypeHandler(), + StringTypeHandler(), + BooleanTypeHandler(), + LongTypeHandler(), + FloatTypeHandler(), + DoubleTypeHandler(), + ) as List> + + val reactiveDataStore = ReactiveUserPreferencesDataStore( + settings = finalSettings, + dispatcher = dispatcher, + typeHandlers = typeHandlers, + serializationStrategy = finalSerializationStrategy, + validator = finalValidator, + cacheManager = cacheManager, + changeNotifier = finalChangeNotifier, + valueObserver = valueObserver, + ) + + return DefaultReactivePreferencesRepository(reactiveDataStore) + } + + /** + * Builds and returns a [ReactiveDataStore] with the current configuration, without the repository wrapper. + * + * Use this if you need direct access to data store methods. + * + * @return A fully configured [ReactiveDataStore]. + * + * Example usage: + * ```kotlin + * val dataStore = DataStoreFactory().buildDataStore() + * ``` + */ + fun buildDataStore(): ReactiveDataStore { + val finalSettings = settings ?: Settings() + val finalValidator = validator ?: DefaultPreferencesValidator() + val finalSerializationStrategy = serializationStrategy ?: JsonSerializationStrategy() + val finalChangeNotifier = changeNotifier ?: DefaultChangeNotifier() + + val cacheManager: CacheManager = LruCacheManager(maxSize = cacheSize) + val valueObserver = DefaultValueObserver(finalChangeNotifier) + + @Suppress("UNCHECKED_CAST") + val typeHandlers: List> = listOf( + IntTypeHandler(), + StringTypeHandler(), + BooleanTypeHandler(), + LongTypeHandler(), + FloatTypeHandler(), + DoubleTypeHandler(), + ) as List> + + return ReactiveUserPreferencesDataStore( + settings = finalSettings, + dispatcher = dispatcher, + typeHandlers = typeHandlers, + serializationStrategy = finalSerializationStrategy, + validator = finalValidator, + cacheManager = cacheManager, + changeNotifier = finalChangeNotifier, + valueObserver = valueObserver, + ) + } + + companion object { + /** + * Creates a [ReactivePreferencesRepository] with default configuration. + * + * This is the simplest way to obtain a working data store repository. + * + * @return A [ReactivePreferencesRepository] with default settings. + * + * Example usage: + * ```kotlin + * val repository = DataStoreFactory.create() + * ``` + */ + fun create(): ReactivePreferencesRepository { + return DataStoreFactory().build() + } + + /** + * Creates a [ReactivePreferencesRepository] with a custom [Settings] instance. + * + * Useful for providing platform-specific settings. + * + * @param settings The [Settings] instance to use. + * @return A [ReactivePreferencesRepository] with the specified settings. + * + * Example usage: + * ```kotlin + * val repository = DataStoreFactory.create(customSettings) + * ``` + */ + fun create(settings: Settings): ReactivePreferencesRepository { + return DataStoreFactory() + .settings(settings) + .build() + } + + /** + * Creates a [ReactivePreferencesRepository] with a custom [Settings] instance and [CoroutineDispatcher]. + * + * Useful for platform-specific configurations (e.g., Android/iOS). + * + * @param settings The [Settings] instance to use. + * @param dispatcher The [CoroutineDispatcher] to use. + * @return A [ReactivePreferencesRepository] with the specified settings and dispatcher. + * + * Example usage: + * ```kotlin + * val repository = DataStoreFactory.create(customSettings, Dispatchers.IO) + * ``` + */ + fun create( + settings: Settings, + dispatcher: CoroutineDispatcher, + ): ReactivePreferencesRepository { + return DataStoreFactory() + .settings(settings) + .dispatcher(dispatcher) + .build() + } + } +} diff --git a/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/handlers/PrimitiveTypeHandlers.kt b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/handlers/PrimitiveTypeHandlers.kt new file mode 100644 index 0000000000..58886e312e --- /dev/null +++ b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/handlers/PrimitiveTypeHandlers.kt @@ -0,0 +1,162 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore.handlers + +import com.russhwolf.settings.Settings + +/** + * Handler for storing and retrieving [Int] values in the data store. + * + * Example usage: + * ```kotlin + * val handler = IntTypeHandler() + * handler.put(settings, "count", 42) + * val value = handler.get(settings, "count", 0) + * ``` + */ +class IntTypeHandler : TypeHandler { + override suspend fun put(settings: Settings, key: String, value: Int): Result { + println("[IntTypeHandler] put: key=$key, value=$value") + return runCatching { settings.putInt(key, value) } + } + + override suspend fun get(settings: Settings, key: String, default: Int): Result { + val result = runCatching { settings.getInt(key, default) } + println("[IntTypeHandler] get: key=$key, result=$result") + return result + } + + override fun canHandle(value: Any?): Boolean = value is Int +} + +/** + * Handler for storing and retrieving [String] values in the data store. + * + * Example usage: + * ```kotlin + * val handler = StringTypeHandler() + * handler.put(settings, "username", "admin") + * val value = handler.get(settings, "username", "default") + * ``` + */ +class StringTypeHandler : TypeHandler { + override suspend fun put(settings: Settings, key: String, value: String): Result { + println("[StringTypeHandler] put: key=$key, value=$value") + return runCatching { settings.putString(key, value) } + } + + override suspend fun get(settings: Settings, key: String, default: String): Result { + val result = runCatching { settings.getString(key, default) } + println("[StringTypeHandler] get: key=$key, result=$result") + return result + } + + override fun canHandle(value: Any?): Boolean = value is String +} + +/** + * Handler for storing and retrieving [Boolean] values in the data store. + * + * Example usage: + * ```kotlin + * val handler = BooleanTypeHandler() + * handler.put(settings, "enabled", true) + * val value = handler.get(settings, "enabled", false) + * ``` + */ +class BooleanTypeHandler : TypeHandler { + override suspend fun put(settings: Settings, key: String, value: Boolean): Result { + println("[BooleanTypeHandler] put: key=$key, value=$value") + return runCatching { settings.putBoolean(key, value) } + } + + override suspend fun get(settings: Settings, key: String, default: Boolean): Result { + val result = runCatching { settings.getBoolean(key, default) } + println("[BooleanTypeHandler] get: key=$key, result=$result") + return result + } + + override fun canHandle(value: Any?): Boolean = value is Boolean +} + +/** + * Handler for storing and retrieving [Long] values in the data store. + * + * Example usage: + * ```kotlin + * val handler = LongTypeHandler() + * handler.put(settings, "timestamp", 123456789L) + * val value = handler.get(settings, "timestamp", 0L) + * ``` + */ +class LongTypeHandler : TypeHandler { + override suspend fun put(settings: Settings, key: String, value: Long): Result { + println("[LongTypeHandler] put: key=$key, value=$value") + return runCatching { settings.putLong(key, value) } + } + + override suspend fun get(settings: Settings, key: String, default: Long): Result { + val result = runCatching { settings.getLong(key, default) } + println("[LongTypeHandler] get: key=$key, result=$result") + return result + } + + override fun canHandle(value: Any?): Boolean = value is Long +} + +/** + * Handler for storing and retrieving [Float] values in the data store. + * + * Example usage: + * ```kotlin + * val handler = FloatTypeHandler() + * handler.put(settings, "ratio", 0.5f) + * val value = handler.get(settings, "ratio", 0.0f) + * ``` + */ +class FloatTypeHandler : TypeHandler { + override suspend fun put(settings: Settings, key: String, value: Float): Result { + println("[FloatTypeHandler] put: key=$key, value=$value") + return runCatching { settings.putFloat(key, value) } + } + + override suspend fun get(settings: Settings, key: String, default: Float): Result { + val result = runCatching { settings.getFloat(key, default) } + println("[FloatTypeHandler] get: key=$key, result=$result") + return result + } + + override fun canHandle(value: Any?): Boolean = value is Float +} + +/** + * Handler for storing and retrieving [Double] values in the data store. + * + * Example usage: + * ```kotlin + * val handler = DoubleTypeHandler() + * handler.put(settings, "score", 99.9) + * val value = handler.get(settings, "score", 0.0) + * ``` + */ +class DoubleTypeHandler : TypeHandler { + override suspend fun put(settings: Settings, key: String, value: Double): Result { + println("[DoubleTypeHandler] put: key=$key, value=$value") + return runCatching { settings.putDouble(key, value) } + } + + override suspend fun get(settings: Settings, key: String, default: Double): Result { + val result = runCatching { settings.getDouble(key, default) } + println("[DoubleTypeHandler] get: key=$key, result=$result") + return result + } + + override fun canHandle(value: Any?): Boolean = value is Double +} diff --git a/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/handlers/TypeHandler.kt b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/handlers/TypeHandler.kt new file mode 100644 index 0000000000..cf87983d85 --- /dev/null +++ b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/handlers/TypeHandler.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore.handlers + +import com.russhwolf.settings.Settings + +/** + * Interface for handling type conversions and storage operations in the data store. + * + * Implementations of this interface provide methods for storing, retrieving, + * and checking support for specific types in the underlying settings storage. + * + * Example usage: + * ```kotlin + * val handler: TypeHandler = IntTypeHandler() + * handler.put(settings, "count", 42) + * val value = handler.get(settings, "count", 0) + * ``` + * + * @param T The type to be handled by this handler. + */ +interface TypeHandler { + /** + * Stores a value of type [T] in the settings storage. + * + * @param settings The settings storage instance. + * @param key The key to associate with the value. + * @param value The value to store. + * @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs. + */ + suspend fun put(settings: Settings, key: String, value: T): Result + + /** + * Retrieves a value of type [T] from the settings storage. + * + * @param settings The settings storage instance. + * @param key The key to retrieve. + * @param default The default value to return if the key does not exist. + * @return [Result.success] with the value, or [Result.failure] if an error occurs. + */ + suspend fun get(settings: Settings, key: String, default: T): Result + + /** + * Determines whether this handler can process the given value. + * + * @param value The value to check. + * @return `true` if this handler can process the value, `false` otherwise. + */ + fun canHandle(value: Any?): Boolean +} diff --git a/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/reactive/ChangeNotifier.kt b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/reactive/ChangeNotifier.kt new file mode 100644 index 0000000000..20e61575a7 --- /dev/null +++ b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/reactive/ChangeNotifier.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore.reactive + +import kotlinx.coroutines.flow.Flow +import template.core.base.datastore.contracts.DataStoreChangeEvent + +/** + * Interface for notifying and observing changes in the data store. + * + * Implementations of this interface allow for broadcasting change events + * and subscribing to them, enabling reactive updates throughout the application. + * + * Example usage: + * ```kotlin + * val notifier: ChangeNotifier = DefaultChangeNotifier() + * notifier.notifyChange(DataStoreChangeEvent.ValueAdded("key", "value")) + * notifier.observeChanges().collect { event -> println(event) } + * ``` + */ +interface ChangeNotifier { + /** + * Notifies listeners of a change event in the data store. + * + * @param change The event describing the change. + */ + fun notifyChange(change: DataStoreChangeEvent) + + /** + * Observes all change events in the data store. + * + * @return A [Flow] emitting [DataStoreChangeEvent] instances as changes occur. + */ + fun observeChanges(): Flow + + /** + * Observes change events for a specific key in the data store. + * + * @param key The key to observe for changes. + * @return A [Flow] emitting [DataStoreChangeEvent] instances related to the specified key. + */ + fun observeKeyChanges(key: String): Flow + + /** + * Clears all listeners and resources associated with this notifier. + * + * This should be called to release resources when the notifier is no longer needed. + */ + fun clear() +} diff --git a/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/reactive/DefaultChangeNotifier.kt b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/reactive/DefaultChangeNotifier.kt new file mode 100644 index 0000000000..a34282f222 --- /dev/null +++ b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/reactive/DefaultChangeNotifier.kt @@ -0,0 +1,232 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +@file:OptIn(ExperimentalAtomicApi::class) + +package template.core.base.datastore.reactive + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import template.core.base.datastore.contracts.DataStoreChangeEvent +import kotlin.concurrent.atomics.AtomicBoolean +import kotlin.concurrent.atomics.AtomicInt +import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.concurrent.atomics.decrementAndFetch +import kotlin.concurrent.atomics.incrementAndFetch +import kotlin.coroutines.cancellation.CancellationException + +/** + * Production-ready implementation of [ChangeNotifier] using SharedFlow for true event broadcasting. + * + * This implementation ensures all observers receive all events, with configurable buffering and + * overflow strategies. It's thread-safe and follows structured concurrency principles. + * + * Key improvements over Channel-based implementation: + * - Multiple observers receive ALL events (not just one) + * - Non-blocking event emission + * - Configurable replay for late subscribers + * - Better memory management with overflow strategies + * - Proper structured concurrency (no GlobalScope) + * + * Example usage: + * ```kotlin + * val notifier = DefaultChangeNotifier() + * + * // Multiple observers all receive the same events + * val job1 = launch { notifier.observeChanges().collect { println("Observer 1: $it") } } + * val job2 = launch { notifier.observeChanges().collect { println("Observer 2: $it") } } + * + * notifier.notifyChange(DataStoreChangeEvent.ValueAdded("key", "value")) + * // Both observers receive the event + * ``` + * + * @property replay Number of events to replay to new subscribers (default: 0) + * @property extraBufferCapacity Extra buffer capacity beyond replay (default: 64) + * @property onBufferOverflow Strategy when buffer is full (default: DROP_OLDEST) + * @property scope Optional coroutine scope for handling suspended emissions + */ +@Suppress("MaxLineLength") +class DefaultChangeNotifier( + private val replay: Int = 0, + private val extraBufferCapacity: Int = 64, + private val onBufferOverflow: BufferOverflow = BufferOverflow.DROP_OLDEST, + private val scope: CoroutineScope? = null, +) : ChangeNotifier { + + private val changeFlow = MutableSharedFlow( + replay = replay, + extraBufferCapacity = extraBufferCapacity, + onBufferOverflow = onBufferOverflow, + ) + + // Track active observers for debugging/monitoring + private val activeObservers = AtomicInt(0) + + // Track if the notifier has been cleared + private val isCleared = AtomicBoolean(false) + + // Internal scope for SUSPEND operations if no external scope provided + private val internalScope = scope ?: CoroutineScope(SupervisorJob() + Dispatchers.Default) + + /** + * Notifies all active listeners of a change event. + * + * This method is non-blocking and thread-safe. If the buffer is full, + * the behavior depends on the configured [onBufferOverflow] strategy. + * + * For SUSPEND strategy without a provided scope, events will be dropped with a warning. + * + * @param change The event describing the change. + */ + override fun notifyChange(change: DataStoreChangeEvent) { + if (isCleared.load()) { + println("[ChangeNotifier] Attempted to notify after clear: $change") + return + } + + val emitted = changeFlow.tryEmit(change) + + if (!emitted) { + when (onBufferOverflow) { + BufferOverflow.DROP_OLDEST -> { + // Already handled by SharedFlow internally + println("[ChangeNotifier] Buffer full, dropped oldest event for: $change") + } + + BufferOverflow.DROP_LATEST -> { + println("[ChangeNotifier] Buffer full, dropping event: $change") + } + + BufferOverflow.SUSPEND -> { + // Only attempt suspended emission if we have a scope + if (scope != null || !isCleared.load()) { + internalScope.launch { + try { + // 5 second timeout to prevent indefinite blocking + withTimeout(5000) { + changeFlow.emit(change) + } + } catch (e: TimeoutCancellationException) { + println("[ChangeNotifier] Timeout emitting event: $change") + } catch (e: CancellationException) { + // Scope was cancelled, ignore + } catch (e: Exception) { + println("[ChangeNotifier] Failed to emit suspended event: $change, error: $e") + } + } + } else { + println("[ChangeNotifier] Buffer full, cannot emit with SUSPEND strategy without scope: $change") + } + } + } + } + } + + /** + * Suspending version of notifyChange for use within coroutines. + * This will suspend until the event can be emitted. + * + * @param change The event describing the change. + * @throws CancellationException if the coroutine is cancelled + */ + suspend fun notifyChangeSuspend(change: DataStoreChangeEvent) { + if (!isCleared.load()) { + changeFlow.emit(change) + } + } + + /** + * Tries to notify with a result indicating success. + * + * @param change The event describing the change. + * @return true if the event was emitted successfully, false otherwise + */ + fun tryNotifyChange(change: DataStoreChangeEvent): Boolean { + return if (!isCleared.load()) { + changeFlow.tryEmit(change) + } else { + false + } + } + + /** + * Observes all change events emitted to this notifier. + * + * Each collector receives ALL events independently. Late subscribers + * will receive replayed events based on the [replay] configuration. + * + * @return A [Flow] emitting [DataStoreChangeEvent] instances as changes occur. + */ + override fun observeChanges(): Flow { + return changeFlow + .onStart { + val count = activeObservers.incrementAndFetch() + println("[ChangeNotifier] New observer connected. Total: $count") + } + .onCompletion { + val count = activeObservers.decrementAndFetch() + println("[ChangeNotifier] Observer disconnected. Total: $count") + } + } + + /** + * Observes change events for a specific key. + * + * @param key The key to observe for changes. + * @return A [Flow] emitting [DataStoreChangeEvent] instances related to the specified key. + */ + override fun observeKeyChanges(key: String): Flow { + return observeChanges().filter { event -> + event.key == key || event.key == "*" + } + } + + /** + * Clears the notifier and releases resources. + * + * After calling clear, no new events should be emitted. + */ + override fun clear() { + if (isCleared.compareAndSet(false, true)) { + // Cancel internal scope if we created it + if (scope == null) { + internalScope.cancel() + } + println("[ChangeNotifier] Cleared. Active observers: ${activeObservers.load()}") + } + } + + /** + * Get the current number of active observers. + * Useful for debugging and monitoring. + */ + fun loadActiveObserverCount(): Int = activeObservers.load() + + /** + * Check if the notifier has been cleared. + */ + fun isCleared(): Boolean = isCleared.load() + + /** + * Get the current number of buffered events. + * Useful for monitoring buffer usage. + */ + fun getBufferedEventCount(): Int = changeFlow.subscriptionCount.value +} diff --git a/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/reactive/DefaultValueObserver.kt b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/reactive/DefaultValueObserver.kt new file mode 100644 index 0000000000..1ebe1e2553 --- /dev/null +++ b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/reactive/DefaultValueObserver.kt @@ -0,0 +1,79 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore.reactive + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import template.core.base.datastore.contracts.DataStoreChangeEvent + +/** + * Default implementation of [ValueObserver] for observing preference value changes. + * + * This observer uses a [ChangeNotifier] to emit value changes as flows, supporting both initial and distinct emissions. + * + * Example usage: + * ```kotlin + * val observer = DefaultValueObserver(changeNotifier) + * observer.createValueFlow("theme", "light") { getTheme() } + * ``` + * + * @property changeNotifier The notifier used to observe key changes. + */ +class DefaultValueObserver( + private val changeNotifier: ChangeNotifier, +) : ValueObserver { + + /** + * Creates a flow that emits the value for the specified key, starting with an initial emission. + * + * @param key The preference key to observe. + * @param default The default value to emit if retrieval fails. + * @param getter A suspend function to retrieve the value. + * @return A [Flow] emitting the value for the key. + * + * Example usage: + * ```kotlin + * observer.createValueFlow("theme", "light") { getTheme() } + * ``` + */ + override fun createValueFlow( + key: String, + default: T, + getter: suspend () -> Result, + ): Flow { + return changeNotifier.observeKeyChanges(key) + // Trigger initial emission + .onStart { emit(DataStoreChangeEvent.ValueAdded(key, null)) } + .map { getter().getOrElse { default } } + } + + /** + * Creates a flow that emits distinct values for the specified key, suppressing duplicates. + * + * @param key The preference key to observe. + * @param default The default value to emit if retrieval fails. + * @param getter A suspend function to retrieve the value. + * @return A [Flow] emitting only distinct values for the key. + * + * Example usage: + * ```kotlin + * observer.createDistinctValueFlow("theme", "light") { getTheme() } + * ``` + */ + override fun createDistinctValueFlow( + key: String, + default: T, + getter: suspend () -> Result, + ): Flow { + return createValueFlow(key, default, getter).distinctUntilChanged() + } +} diff --git a/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/reactive/PreferenceFlowOperators.kt b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/reactive/PreferenceFlowOperators.kt new file mode 100644 index 0000000000..7cf37825a8 --- /dev/null +++ b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/reactive/PreferenceFlowOperators.kt @@ -0,0 +1,130 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +@file:Suppress("MaxLineLength") + +package template.core.base.datastore.reactive + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import template.core.base.datastore.repository.ReactivePreferencesRepository + +/** + * Provides advanced operations for combining, mapping, and observing user preference flows. + * + * This class enables the combination of multiple preference flows, observation of key changes, + * and transformation of observed values. + * + * Example usage: + * ```kotlin + * val operators = PreferenceFlowOperators(repository) + * val combinedFlow = operators.combinePreferences("key1", 0, "key2", "", transform = { a, b -> "$a-$b" }) + * ``` + * + * @property repository The [ReactivePreferencesRepository] used to observe and manipulate preference flows. + */ +class PreferenceFlowOperators( + private val repository: ReactivePreferencesRepository, +) { + /** + * Combines two preference flows and emits a value produced by the [transform] function. + * + * @param key1 The first preference key. + * @param default1 The default value for the first preference. + * @param key2 The second preference key. + * @param default2 The default value for the second preference. + * @param transform The function to combine the two values. + * @return A [Flow] emitting the combined value. + * + * Example usage: + * ```kotlin + * operators.combinePreferences("theme", "light", "fontSize", 12) { theme, size -> "$theme-$size" } + * ``` + */ + fun combinePreferences( + key1: String, + default1: T1, + key2: String, + default2: T2, + transform: suspend (T1, T2) -> R, + ): Flow = combine( + repository.observePreference(key1, default1), + repository.observePreference(key2, default2), + transform, + ) + + /** + * Combines three preference flows and emits a value produced by the [transform] function. + * + * @param key1 The first preference key. + * @param default1 The default value for the first preference. + * @param key2 The second preference key. + * @param default2 The default value for the second preference. + * @param key3 The third preference key. + * @param default3 The default value for the third preference. + * @param transform The function to combine the three values. + * @return A [Flow] emitting the combined value. + * + * Example usage: + * ```kotlin + * operators.combinePreferences("theme", "light", "fontSize", 12, "lang", "en") { t, s, l -> "$t-$s-$l" } + * ``` + */ + fun combinePreferences( + key1: String, + default1: T1, + key2: String, + default2: T2, + key3: String, + default3: T3, + transform: suspend (T1, T2, T3) -> R, + ): Flow = combine( + repository.observePreference(key1, default1), + repository.observePreference(key2, default2), + repository.observePreference(key3, default3), + transform, + ) + + /** + * Observes changes to any of the specified keys and emits the key that changed. + * + * @param keys The keys to observe for changes. + * @return A [Flow] emitting the key that changed. + * + * Example usage: + * ```kotlin + * operators.observeAnyKeyChange("theme", "fontSize").collect { key -> println("Changed: $key") } + * ``` + */ + fun observeAnyKeyChange(vararg keys: String): Flow = + repository.observePreferenceChanges() + .map { it.key } + .filter { it in keys } + + /** + * Observes a preference and maps its value using the provided [transform] function. + * + * @param key The preference key to observe. + * @param default The default value for the preference. + * @param transform The function to map the preference value. + * @return A [Flow] emitting the mapped value. + * + * Example usage: + * ```kotlin + * operators.observeMappedPreference("theme", "light") { it.uppercase() } + * ``` + */ + fun observeMappedPreference( + key: String, + default: T, + transform: suspend (T) -> R, + ): Flow = repository.observePreference(key, default).map(transform) +} diff --git a/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/reactive/ValueObserver.kt b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/reactive/ValueObserver.kt new file mode 100644 index 0000000000..cea2b976bf --- /dev/null +++ b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/reactive/ValueObserver.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore.reactive + +import kotlinx.coroutines.flow.Flow + +/** + * Interface for observing value changes in the data store as flows. + * + * Implementations of this interface provide mechanisms to create flows + * that emit values for specific keys, supporting both initial and distinct emissions. + * + * Example usage: + * ```kotlin + * val observer: ValueObserver = DefaultValueObserver(changeNotifier) + * observer.createValueFlow("theme", "light") { getTheme() } + * ``` + */ +interface ValueObserver { + /** + * Creates a flow that emits the value for the specified key. + * + * @param key The preference key to observe. + * @param default The default value to emit if retrieval fails. + * @param getter A suspend function to retrieve the value. + * @return A [Flow] emitting the value for the key. + */ + fun createValueFlow( + key: String, + default: T, + getter: suspend () -> Result, + ): Flow + + /** + * Creates a flow that emits only distinct values for the specified key, suppressing duplicates. + * + * @param key The preference key to observe. + * @param default The default value to emit if retrieval fails. + * @param getter A suspend function to retrieve the value. + * @return A [Flow] emitting only distinct values for the key. + */ + fun createDistinctValueFlow( + key: String, + default: T, + getter: suspend () -> Result, + ): Flow +} diff --git a/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/repository/DefaultReactivePreferencesRepository.kt b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/repository/DefaultReactivePreferencesRepository.kt new file mode 100644 index 0000000000..2164c47791 --- /dev/null +++ b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/repository/DefaultReactivePreferencesRepository.kt @@ -0,0 +1,152 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore.repository + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filter +import kotlinx.serialization.KSerializer +import template.core.base.datastore.contracts.DataStoreChangeEvent +import template.core.base.datastore.contracts.ReactiveDataStore + +/** + * Default implementation of [ReactivePreferencesRepository] that delegates operations + * to an underlying [ReactiveDataStore]. + * + * This class provides the reactive repository interface by interacting with + * a reactive data store instance. + * + * Example usage: + * ```kotlin + * // Obtain an instance, e.g., from DataStoreFactory().buildDataStore() + * val dataStore: ReactiveDataStore = ... + * val repository = DefaultReactivePreferencesRepository(dataStore) + * + * // Use repository methods + * repository.savePreference("user_id", "123") + * repository.observePreference("user_id", "").collect { id -> println(id) } + * ``` + * + * @property reactiveDataStore The underlying [ReactiveDataStore] instance. + */ +class DefaultReactivePreferencesRepository( + private val reactiveDataStore: ReactiveDataStore, +) : ReactivePreferencesRepository { + + // Delegate base operations + /** + * {@inheritDoc} + */ + override suspend fun savePreference(key: String, value: T): Result { + println("[Repository] savePreference: key=$key, value=$value") + return reactiveDataStore.putValue(key, value) + } + + /** + * {@inheritDoc} + */ + override suspend fun getPreference(key: String, default: T): Result { + return reactiveDataStore.getValue(key, default) + } + + /** + * {@inheritDoc} + */ + override suspend fun saveSerializablePreference( + key: String, + value: T, + serializer: KSerializer, + ): Result { + return reactiveDataStore.putSerializableValue(key, value, serializer) + } + + /** + * {@inheritDoc} + */ + override suspend fun getSerializablePreference( + key: String, + default: T, + serializer: KSerializer, + ): Result { + return reactiveDataStore.getSerializableValue(key, default, serializer) + } + + /** + * {@inheritDoc} + */ + override suspend fun removePreference(key: String): Result { + println("[Repository] removePreference: key=$key") + return reactiveDataStore.removeValue(key) + } + + /** + * {@inheritDoc} + */ + override suspend fun clearAllPreferences(): Result { + println("[Repository] clearAllPreferences") + return reactiveDataStore.clearAll() + } + + /** + * {@inheritDoc} + */ + override suspend fun hasPreference(key: String): Boolean { + return reactiveDataStore.hasKey(key).getOrDefault(false) + } + + // Reactive operations + /** + * {@inheritDoc} + */ + override fun observePreference(key: String, default: T): Flow { + return reactiveDataStore.observeValue(key, default) + } + + /** + * {@inheritDoc} + */ + override fun observeSerializablePreference( + key: String, + default: T, + serializer: KSerializer, + ): Flow { + return reactiveDataStore.observeSerializableValue(key, default, serializer) + } + + /** + * {@inheritDoc} + */ + override fun observeAllKeys(): Flow> { + println("[Repository] observeAllKeys: flow created") + return reactiveDataStore.observeKeys() + .also { println("[Repository] observeAllKeys: flow returned") } + } + + /** + * {@inheritDoc} + */ + override fun observePreferenceCount(): Flow { + return reactiveDataStore.observeSize() + } + + /** + * {@inheritDoc} + */ + override fun observePreferenceChanges(): Flow { + return reactiveDataStore.observeChanges() + } + + /** + * {@inheritDoc} + */ + override fun observePreferenceChanges(key: String): Flow { + return reactiveDataStore.observeChanges() + .filter { it.key == key || it.key == "*" } + } +} diff --git a/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/repository/PreferencesRepository.kt b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/repository/PreferencesRepository.kt new file mode 100644 index 0000000000..ee03286977 --- /dev/null +++ b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/repository/PreferencesRepository.kt @@ -0,0 +1,99 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore.repository + +import kotlinx.serialization.KSerializer + +/** + * Interface for managing user preferences in a type-safe and coroutine-friendly manner. + * + * Implementations of this interface provide methods for saving, retrieving, + * and removing preferences, supporting both primitive and serializable types. + * + * Example usage: + * ```kotlin + * val repository: PreferencesRepository = ... + * repository.savePreference("theme", "dark") + * val theme = repository.getPreference("theme", "light") + * repository.removePreference("theme") + * repository.clearAllPreferences() + * val exists = repository.hasPreference("theme") + * ``` + */ +interface PreferencesRepository { + /** + * Saves a value associated with the specified key in the preferences. + * + * @param key The key to associate with the value. + * @param value The value to store. + * @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs. + */ + suspend fun savePreference(key: String, value: T): Result + + /** + * Retrieves a value associated with the specified key from the preferences. + * + * @param key The key to retrieve. + * @param default The default value to return if the key does not exist. + * @return [Result.success] with the value, or [Result.failure] if an error occurs. + */ + suspend fun getPreference(key: String, default: T): Result + + /** + * Saves a serializable value using the provided serializer. + * + * @param key The key to associate with the value. + * @param value The value to store. + * @param serializer The serializer for the value type. + * @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs. + */ + suspend fun saveSerializablePreference( + key: String, + value: T, + serializer: KSerializer, + ): Result + + /** + * Retrieves a serializable value using the provided serializer. + * + * @param key The key to retrieve. + * @param default The default value to return if the key does not exist. + * @param serializer The serializer for the value type. + * @return [Result.success] with the value, or [Result.failure] if an error occurs. + */ + suspend fun getSerializablePreference( + key: String, + default: T, + serializer: KSerializer, + ): Result + + /** + * Removes the value associated with the specified key from the preferences. + * + * @param key The key to remove. + * @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs. + */ + suspend fun removePreference(key: String): Result + + /** + * Clears all stored preferences. + * + * @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs. + */ + suspend fun clearAllPreferences(): Result + + /** + * Checks if the specified key exists in the preferences. + * + * @param key The key to check. + * @return `true` if the key exists, `false` otherwise. + */ + suspend fun hasPreference(key: String): Boolean +} diff --git a/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/repository/ReactivePreferencesRepository.kt b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/repository/ReactivePreferencesRepository.kt new file mode 100644 index 0000000000..405dfd5ee7 --- /dev/null +++ b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/repository/ReactivePreferencesRepository.kt @@ -0,0 +1,82 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore.repository + +import kotlinx.coroutines.flow.Flow +import kotlinx.serialization.KSerializer +import template.core.base.datastore.contracts.DataStoreChangeEvent + +/** + * Interface for managing user preferences with reactive (Flow-based) observation capabilities. + * + * This interface extends [PreferencesRepository] and adds methods for observing preference values, + * keys, and change events as Kotlin Flows, enabling reactive programming patterns. + * + * Example usage: + * ```kotlin + * val repository: ReactivePreferencesRepository = ... + * repository.observePreference("theme", "light").collect { value -> println(value) } + * repository.observeAllKeys().collect { keys -> println(keys) } + * repository.observePreferenceChanges().collect { event -> println(event) } + * ``` + */ +interface ReactivePreferencesRepository : PreferencesRepository { + /** + * Observes the value for the specified key as a [Flow], emitting updates as they occur. + * + * @param key The key to observe. + * @param default The default value to emit if the key does not exist. + * @return A [Flow] emitting the value for the key. + */ + fun observePreference(key: String, default: T): Flow + + /** + * Observes a serializable value for the specified key as a [Flow]. + * + * @param key The key to observe. + * @param default The default value to emit if the key does not exist. + * @param serializer The serializer for the value type. + * @return A [Flow] emitting the value for the key. + */ + fun observeSerializablePreference( + key: String, + default: T, + serializer: KSerializer, + ): Flow + + /** + * Observes all keys in the preferences as a [Flow], emitting updates as they occur. + * + * @return A [Flow] emitting the set of all keys. + */ + fun observeAllKeys(): Flow> + + /** + * Observes the number of preferences as a [Flow], emitting updates as they occur. + * + * @return A [Flow] emitting the number of key-value pairs in the preferences. + */ + fun observePreferenceCount(): Flow + + /** + * Observes all change events in the preferences as a [Flow]. + * + * @return A [Flow] emitting [DataStoreChangeEvent] instances as changes occur. + */ + fun observePreferenceChanges(): Flow + + /** + * Observes change events for a specific key as a [Flow]. + * + * @param key The key to observe for changes. + * @return A [Flow] emitting [DataStoreChangeEvent] instances related to the specified key. + */ + fun observePreferenceChanges(key: String): Flow +} diff --git a/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/serialization/JsonSerializationStrategy.kt b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/serialization/JsonSerializationStrategy.kt new file mode 100644 index 0000000000..71cdd48cf8 --- /dev/null +++ b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/serialization/JsonSerializationStrategy.kt @@ -0,0 +1,109 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore.serialization + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.Json +import template.core.base.datastore.exceptions.DeserializationException +import template.core.base.datastore.exceptions.SerializationException + +/** + * Implementation of [SerializationStrategy] that uses kotlinx.serialization's [Json] + * for serializing and deserializing data. + * + * This strategy provides a convenient way to store and retrieve complex data objects in the + * data store by converting them to and from JSON strings. + * + * Example usage: + * ```kotlin + * // Define a serializable data class + * @Serializable + * data class Settings(val theme: String, val fontSize: Int) + * + * // Create a JsonSerializationStrategy instance + * val serializationStrategy = JsonSerializationStrategy() + * val settingsSerializer = Settings.serializer() + * + * // Serialize an object + * val settings = Settings("dark", 14) + * val serializedData = serializationStrategy.serialize(settings, settingsSerializer) + * println("Serialized data: $serializedData") + * // Example output: Result.success("{"theme":"dark","fontSize":14}") + * + * // Deserialize a string + * val dataString = "{"theme":"light","fontSize":12}" + * val deserializedSettings = serializationStrategy.deserialize(dataString, settingsSerializer) + * println("Deserialized settings: $deserializedSettings") + * // Example output: Result.success(Settings(theme=light, fontSize=12)) + * ``` + * + * @property json The [Json] instance used for serialization and deserialization. + * Configurable with default lenient settings. + */ +class JsonSerializationStrategy( + private val json: Json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + isLenient = true + }, +) : SerializationStrategy { + + /** + * Serializes the given [value] of type [T] to a JSON [String] using the provided [serializer] + * and the configured [Json] instance. + * + * @param value The object to serialize. + * @param serializer The [KSerializer] for type [T]. + * @return A [Result.success] containing the JSON string, or [Result.failure] + * if serialization fails (e.g., due to serialization errors). + */ + override suspend fun serialize(value: T, serializer: KSerializer): Result { + return try { + val result = json.encodeToString(serializer, value) + Result.success(result) + } catch (e: Exception) { + Result.failure( + SerializationException( + "Failed to serialize value of type ${value?.let { it::class.simpleName }}", + e, + ), + ) + } + } + + /** + * Deserializes the given JSON [data] string back into an object of type [T] using + * the provided [serializer] and the configured [Json] instance. + * + * @param data The JSON string data to deserialize. + * @param serializer The [KSerializer] for type [T]. + * @return A [Result.success] containing the deserialized object, or [Result.failure] if + * deserialization fails (e.g., due to invalid JSON format or deserialization errors). + */ + override suspend fun deserialize(data: String, serializer: KSerializer): Result { + return try { + if (data.isBlank()) { + return Result.failure( + DeserializationException("Cannot deserialize blank string"), + ) + } + + val result = json.decodeFromString(serializer, data) + Result.success(result) + } catch (e: Exception) { + Result.failure( + DeserializationException( + "Failed to deserialize data: ${data.take(100)}${if (data.length > 100) "..." else ""}", + e, + ), + ) + } + } +} diff --git a/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/serialization/SerializationStrategy.kt b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/serialization/SerializationStrategy.kt new file mode 100644 index 0000000000..2aa4dc13fe --- /dev/null +++ b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/serialization/SerializationStrategy.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore.serialization + +import kotlinx.serialization.KSerializer + +/** + * Strategy for handling serialization operations. + * Follows Single Responsibility Principle. + */ +interface SerializationStrategy { + suspend fun serialize(value: T, serializer: KSerializer): Result + suspend fun deserialize(data: String, serializer: KSerializer): Result +} diff --git a/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/store/BasicPreferencesStore.kt b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/store/BasicPreferencesStore.kt new file mode 100644 index 0000000000..2acdae3872 --- /dev/null +++ b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/store/BasicPreferencesStore.kt @@ -0,0 +1,208 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore.store + +import com.russhwolf.settings.ExperimentalSettingsApi +import com.russhwolf.settings.Settings +import com.russhwolf.settings.serialization.decodeValue +import com.russhwolf.settings.serialization.encodeValue +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer + +/** + * Basic implementation of a user preferences data store using a settings storage mechanism. + * + * This class is deprecated. Use [ReactiveUserPreferencesDataStore] + * via [template.core.base.datastore.factory.DataStoreFactory] for advanced features such + * as reactive flows, caching, and validation. + * + * Example migration: + * ```kotlin + * // Old way + * val dataStore = BasicPreferencesStore(settings, dispatcher) + * + * // New way + * val repository = DataStoreFactory.create(settings, dispatcher) + * ``` + * + * @property settings The underlying settings storage implementation. + * @property dispatcher The coroutine dispatcher for executing operations. + */ +@Deprecated( + message = "Use ReactiveUserPreferencesRepository through DataStoreFactory instead", + replaceWith = ReplaceWith( + "DataStoreFactory.create(settings, dispatcher)", + "template.core.base.datastore.factory.DataStoreFactory", + ), + level = DeprecationLevel.WARNING, +) +class BasicPreferencesStore( + private val settings: Settings, + private val dispatcher: CoroutineDispatcher, +) { + + /** + * Stores a value associated with the specified key in the data store. + * Supports primitive types directly and custom types via a provided [KSerializer]. + * + * @param key The key to associate with the value. + * @param value The value to store. + * @param serializer The serializer for the value type, if needed. + * @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs. + * + * Example usage: + * ```kotlin + * dataStore.putValue("theme", "dark") + * ``` + */ + @OptIn(ExperimentalSerializationApi::class, ExperimentalSettingsApi::class) + suspend fun putValue( + key: String, + value: T, + serializer: KSerializer? = null, + ): Result = withContext(dispatcher) { + runCatching { + when (value) { + is Int -> settings.putInt(key, value) + is Long -> settings.putLong(key, value) + is Float -> settings.putFloat(key, value) + is Double -> settings.putDouble(key, value) + is String -> settings.putString(key, value) + is Boolean -> settings.putBoolean(key, value) + else -> { + require(serializer != null) { + "Unsupported type or no serializer provided for ${value?.let { it::class } ?: "null"}" + } + settings.encodeValue( + serializer = serializer, + value = value, + key = key, + ) + } + } + } + } + + /** + * Retrieves a value associated with the specified key from the data store. + * + * @param key The key to retrieve. + * @param default The default value to return if the key does not exist. + * @param serializer The serializer for the value type, if needed. + * @return [Result.success] with the value, or [Result.failure] if an error occurs. + * + * Example usage: + * ```kotlin + * dataStore.getValue("theme", "light") + * ``` + */ + @Suppress("UNCHECKED_CAST") + @OptIn(ExperimentalSerializationApi::class, ExperimentalSettingsApi::class) + suspend fun getValue( + key: String, + default: T, + serializer: KSerializer? = null, + ): Result = withContext(dispatcher) { + runCatching { + when (default) { + is Int -> settings.getInt(key, default) as T + is Long -> settings.getLong(key, default) as T + is Float -> settings.getFloat(key, default) as T + is Double -> settings.getDouble(key, default) as T + is String -> settings.getString(key, default) as T + is Boolean -> settings.getBoolean(key, default) as T + else -> { + require(serializer != null) { + "Unsupported type or no serializer provided for ${default?.let { it::class } ?: "null"}" + } + settings.decodeValue( + serializer = serializer, + key = key, + defaultValue = default, + ) + } + } + } + } + + /** + * Checks if the specified key exists in the data store. + * + * @param key The key to check. + * @return [Result.success] with true if the key exists, or [Result.failure] if an error occurs. + * + * Example usage: + * ```kotlin + * dataStore.hasKey("theme") + * ``` + */ + suspend fun hasKey(key: String): Result = withContext(dispatcher) { + runCatching { settings.hasKey(key) } + } + + /** + * Removes the value associated with the specified key from the data store. + * + * @param key The key to remove. + * @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs. + * + * Example usage: + * ```kotlin + * dataStore.removeValue("theme") + * ``` + */ + suspend fun removeValue(key: String): Result = withContext(dispatcher) { + runCatching { settings.remove(key) } + } + + /** + * Clears all stored preferences in the data store. + * + * @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs. + * + * Example usage: + * ```kotlin + * dataStore.clearAll() + * ``` + */ + suspend fun clearAll(): Result = withContext(dispatcher) { + runCatching { settings.clear() } + } + + /** + * Retrieves all keys currently stored in the data store. + * + * @return [Result.success] with the set of keys, or [Result.failure] if an error occurs. + * + * Example usage: + * ```kotlin + * dataStore.getAllKeys() + * ``` + */ + suspend fun getAllKeys(): Result> = withContext(dispatcher) { + runCatching { settings.keys } + } + + /** + * Returns the total number of key-value pairs stored in the data store. + * + * @return [Result.success] with the count, or [Result.failure] if an error occurs. + * + * Example usage: + * ```kotlin + * dataStore.getSize() + * ``` + */ + suspend fun getSize(): Result = withContext(dispatcher) { + runCatching { settings.size } + } +} diff --git a/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/store/CachedPreferencesStore.kt b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/store/CachedPreferencesStore.kt new file mode 100644 index 0000000000..6e720a3d42 --- /dev/null +++ b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/store/CachedPreferencesStore.kt @@ -0,0 +1,371 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore.store + +import com.russhwolf.settings.Settings +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import kotlinx.serialization.KSerializer +import template.core.base.datastore.cache.CacheManager +import template.core.base.datastore.contracts.CacheableDataStore +import template.core.base.datastore.exceptions.CacheException +import template.core.base.datastore.exceptions.UnsupportedTypeException +import template.core.base.datastore.handlers.TypeHandler +import template.core.base.datastore.serialization.SerializationStrategy +import template.core.base.datastore.validation.PreferencesValidator + +/** + * Implementation of a cache-enabled user preferences data store. + * + * This class provides coroutine-based, type-safe, and observable access to user preferences, + * supporting both primitive and serializable types, with in-memory caching for improved performance. + * + * Example usage: + * ```kotlin + * val store = CachedPreferencesStore( + * settings = Settings(), + * dispatcher = Dispatchers.IO, + * typeHandlers = listOf(IntTypeHandler(), StringTypeHandler()), + * serializationStrategy = JsonSerializationStrategy(), + * validator = DefaultPreferencesValidator(), + * cacheManager = LruCacheManager(200) + * ) + * ``` + * + * @property settings The underlying settings storage implementation. + * @property dispatcher The coroutine dispatcher for executing operations. + * @property typeHandlers The list of type handlers for supported types. + * @property serializationStrategy The strategy for serializing and deserializing values. + * @property validator The validator for keys and values. + * @property cacheManager The cache manager for in-memory caching. + */ +@Suppress("MaxLineLength") +class CachedPreferencesStore( + private val settings: Settings, + private val dispatcher: CoroutineDispatcher, + private val typeHandlers: List>, + private val serializationStrategy: SerializationStrategy, + private val validator: PreferencesValidator, + private val cacheManager: CacheManager, +) : CacheableDataStore { + + /** + * Stores a value associated with the specified key in the data store. + * + * @param key The key to associate with the value. + * @param value The value to store. + * @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs. + * + * Example usage: + * ```kotlin + * store.putValue("theme", "dark") + * ``` + */ + override suspend fun putValue(key: String, value: T): Result = + withContext(dispatcher) { + runCatching { + // Validate inputs + validator.validateKey(key).getOrThrow() + validator.validateValue(value).getOrThrow() + + // Find and use type handler + val handler = findTypeHandler(value) ?: throw UnsupportedTypeException( + "No handler found for type: ${value?.let { it::class.simpleName } ?: "null"}", + ) + + @Suppress("UNCHECKED_CAST") + val typedHandler = handler as TypeHandler + val result = typedHandler.put(settings, key, value) + + if (result.isSuccess) { + cacheValue(key, value as Any) + } + + result.getOrThrow() + } + } + + /** + * Retrieves a value associated with the specified key from the data store. + * + * @param key The key to retrieve. + * @param default The default value to return if the key does not exist. + * @return [Result.success] with the value, or [Result.failure] if an error occurs. + * + * Example usage: + * ```kotlin + * store.getValue("theme", "light") + * ``` + */ + override suspend fun getValue(key: String, default: T): Result = + withContext(dispatcher) { + runCatching { + validator.validateKey(key).getOrThrow() + + // Check cache first + getCachedValue(key)?.let { return@runCatching it } + + // Find and use type handler + val handler = findTypeHandler(default) + ?: throw UnsupportedTypeException( + "No handler found for type: ${default?.let { it::class.simpleName } ?: "null"}", + ) + + @Suppress("UNCHECKED_CAST") + val typedHandler = handler as TypeHandler + val result = typedHandler.get(settings, key, default) + + if (result.isSuccess) { + cacheValue(key, result.getOrThrow() as Any) + } + + result.getOrThrow() + } + } + + /** + * Stores a serializable value using the provided serializer. + * + * @param key The key to associate with the value. + * @param value The value to store. + * @param serializer The serializer for the value type. + * @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs. + * + * Example usage: + * ```kotlin + * store.putSerializableValue("user", user, User.serializer()) + * ``` + */ + override suspend fun putSerializableValue( + key: String, + value: T, + serializer: KSerializer, + ): Result = withContext(dispatcher) { + runCatching { + validator.validateKey(key).getOrThrow() + validator.validateValue(value).getOrThrow() + + val serializedResult = serializationStrategy.serialize(value, serializer) + serializedResult.fold( + onSuccess = { serializedData -> + val result = runCatching { settings.putString(key, serializedData) } + if (result.isSuccess) { + cacheValue(key, value as Any) + } + result.getOrThrow() + }, + onFailure = { throw it }, + ) + } + } + + /** + * Retrieves a serializable value using the provided serializer. + * + * @param key The key to retrieve. + * @param default The default value to return if the key does not exist. + * @param serializer The serializer for the value type. + * @return [Result.success] with the value, or [Result.failure] if an error occurs. + * + * Example usage: + * ```kotlin + * store.getSerializableValue("user", defaultUser, User.serializer()) + * ``` + */ + override suspend fun getSerializableValue( + key: String, + default: T, + serializer: KSerializer, + ): Result = withContext(dispatcher) { + runCatching { + validator.validateKey(key).getOrThrow() + + // Check cache first + getCachedValue(key)?.let { return@runCatching it } + + if (!settings.hasKey(key)) { + cacheValue(key, default as Any) + return@runCatching default + } + + val serializedData = runCatching { settings.getString(key, "") } + .getOrElse { throw it } + + if (serializedData.isEmpty()) { + return@runCatching default + } + + serializationStrategy.deserialize(serializedData, serializer).fold( + onSuccess = { value -> + cacheValue(key, value as Any) + value + }, + onFailure = { + // Return default on deserialization failure but don't cache it + default + }, + ) + } + } + + /** + * Checks if the specified key exists in the data store or cache. + * + * @param key The key to check. + * @return [Result.success] with true if the key exists, or [Result.failure] if an error occurs. + * + * Example usage: + * ```kotlin + * store.hasKey("theme") + * ``` + */ + override suspend fun hasKey(key: String): Result = withContext(dispatcher) { + runCatching { + validator.validateKey(key).getOrThrow() + settings.hasKey(key) || cacheManager.containsKey(key) + } + } + + /** + * Removes the value associated with the specified key from the data store and cache. + * + * @param key The key to remove. + * @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs. + * + * Example usage: + * ```kotlin + * store.removeValue("theme") + * ``` + */ + override suspend fun removeValue(key: String): Result = withContext(dispatcher) { + runCatching { + validator.validateKey(key).getOrThrow() + settings.remove(key) + cacheManager.remove(key) + Unit + } + } + + /** + * Clears all stored preferences in the data store and cache. + * + * @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs. + * + * Example usage: + * ```kotlin + * store.clearAll() + * ``` + */ + override suspend fun clearAll(): Result = withContext(dispatcher) { + runCatching { + settings.clear() + cacheManager.clear() + } + } + + /** + * Retrieves all keys currently stored in the data store. + * + * @return [Result.success] with the set of keys, or [Result.failure] if an error occurs. + * + * Example usage: + * ```kotlin + * store.getAllKeys() + * ``` + */ + override suspend fun getAllKeys(): Result> = withContext(dispatcher) { + runCatching { settings.keys } + } + + /** + * Retrieves the total number of key-value pairs stored in the data store. + * + * @return [Result.success] with the count, or [Result.failure] if an error occurs. + * + * Example usage: + * ```kotlin + * store.getSize() + * ``` + */ + override suspend fun getSize(): Result = withContext(dispatcher) { + runCatching { settings.size } + } + + /** + * Invalidates the cache for the specified key. + * + * @param key The key whose cache should be invalidated. + * @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs. + * + * Example usage: + * ```kotlin + * store.invalidateCache("theme") + * ``` + */ + override suspend fun invalidateCache(key: String): Result = withContext(dispatcher) { + runCatching { + validator.validateKey(key).getOrThrow() + cacheManager.remove(key) + Unit + } + } + + /** + * Invalidates all cache entries in the data store. + * + * @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs. + * + * Example usage: + * ```kotlin + * store.invalidateAllCache() + * ``` + */ + override suspend fun invalidateAllCache(): Result = withContext(dispatcher) { + runCatching { + cacheManager.clear() + } + } + + /** + * Returns the current size of the cache. + * + * @return The number of entries in the cache. + * + * Example usage: + * ```kotlin + * val cacheSize = store.getCacheSize() + * ``` + */ + override fun getCacheSize(): Int = cacheManager.size() + + // Private helper methods to reduce duplication + private fun findTypeHandler(value: Any?): TypeHandler? { + return typeHandlers.find { it.canHandle(value) } + } + + @Suppress("UNCHECKED_CAST") + private fun getCachedValue(key: String): T? { + return try { + cacheManager.get(key) as? T + } catch (_: Exception) { + // Log cache retrieval error but don't fail the operation + null + } + } + + private fun cacheValue(key: String, value: Any) { + try { + cacheManager.put(key, value) + } catch (e: Exception) { + // Cache operation failed - log but don't fail the main operation + throw CacheException("Failed to cache value for key: $key", e) + } + } +} diff --git a/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/store/ReactiveUserPreferencesDataStore.kt b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/store/ReactiveUserPreferencesDataStore.kt new file mode 100644 index 0000000000..e24c81ad6f --- /dev/null +++ b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/store/ReactiveUserPreferencesDataStore.kt @@ -0,0 +1,414 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore.store + +import com.russhwolf.settings.Settings +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.withContext +import kotlinx.serialization.KSerializer +import template.core.base.datastore.cache.CacheManager +import template.core.base.datastore.contracts.DataStoreChangeEvent +import template.core.base.datastore.contracts.ReactiveDataStore +import template.core.base.datastore.handlers.TypeHandler +import template.core.base.datastore.reactive.ChangeNotifier +import template.core.base.datastore.reactive.ValueObserver +import template.core.base.datastore.serialization.SerializationStrategy +import template.core.base.datastore.validation.PreferencesValidator + +/** + * Reactive implementation of a user preferences data store with support for caching, + * validation, and change notifications. + * + * This class provides coroutine-based, type-safe, and observable access to user preferences, + * supporting both primitive and serializable types. + * + * Example usage: + * ```kotlin + * val dataStore = ReactiveUserPreferencesDataStore( + * settings = Settings(), + * dispatcher = Dispatchers.IO, + * typeHandlers = listOf(IntTypeHandler(), StringTypeHandler()), + * serializationStrategy = JsonSerializationStrategy(), + * validator = DefaultPreferencesValidator(), + * cacheManager = LruCacheManager(200), + * changeNotifier = DefaultChangeNotifier(), + * valueObserver = DefaultValueObserver(DefaultChangeNotifier()) + * ) + * ``` + * + * @property settings The underlying settings storage implementation. + * @property dispatcher The coroutine dispatcher for executing operations. + * @property typeHandlers The list of type handlers for supported types. + * @property serializationStrategy The strategy for serializing and deserializing values. + * @property validator The validator for keys and values. + * @property cacheManager The cache manager for in-memory caching. + * @property changeNotifier The notifier for broadcasting change events. + * @property valueObserver The observer for value changes. + */ +class ReactiveUserPreferencesDataStore( + private val settings: Settings, + private val dispatcher: CoroutineDispatcher, + private val typeHandlers: List>, + private val serializationStrategy: SerializationStrategy, + private val validator: PreferencesValidator, + private val cacheManager: CacheManager, + private val changeNotifier: ChangeNotifier, + private val valueObserver: ValueObserver, +) : ReactiveDataStore { + + // Delegate to the base enhanced implementation + private val enhancedDataStore = CachedPreferencesStore( + settings, + dispatcher, + typeHandlers, + serializationStrategy, + validator, + cacheManager, + ) + + /** + * Stores a value associated with the specified key in the data store. + * + * @param key The key to associate with the value. + * @param value The value to store. + * @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs. + * + * Example usage: + * ```kotlin + * dataStore.putValue("theme", "dark") + * ``` + */ + override suspend fun putValue(key: String, value: T): Result { + return withContext(dispatcher) { + val oldValue = if (hasKey(key).getOrDefault(false)) { + enhancedDataStore.getValue(key, value).getOrNull() + } else { + null + } + + val result = enhancedDataStore.putValue(key, value) + + if (result.isSuccess) { + val change = if (oldValue != null) { + DataStoreChangeEvent.ValueUpdated(key, oldValue, value) + } else { + DataStoreChangeEvent.ValueAdded(key, value) + } + changeNotifier.notifyChange(change) + } + + result + } + } + + /** + * Retrieves a value associated with the specified key from the data store. + * + * @param key The key to retrieve. + * @param default The default value to return if the key does not exist. + * @return [Result.success] with the value, or [Result.failure] if an error occurs. + * + * Example usage: + * ```kotlin + * dataStore.getValue("theme", "light") + * ``` + */ + override suspend fun getValue(key: String, default: T): Result { + return enhancedDataStore.getValue(key, default) + } + + /** + * Stores a serializable value using the provided serializer. + * + * @param key The key to associate with the value. + * @param value The value to store. + * @param serializer The serializer for the value type. + * @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs. + * + * Example usage: + * ```kotlin + * dataStore.putSerializableValue("user", user, User.serializer()) + * ``` + */ + override suspend fun putSerializableValue( + key: String, + value: T, + serializer: KSerializer, + ): Result { + return withContext(dispatcher) { + val oldValue = if (hasKey(key).getOrDefault(false)) { + enhancedDataStore.getSerializableValue(key, value, serializer).getOrNull() + } else { + null + } + + val result = enhancedDataStore.putSerializableValue(key, value, serializer) + + if (result.isSuccess) { + val change = if (oldValue != null) { + DataStoreChangeEvent.ValueUpdated(key, oldValue, value) + } else { + DataStoreChangeEvent.ValueAdded(key, value) + } + changeNotifier.notifyChange(change) + } + + result + } + } + + /** + * Retrieves a serializable value using the provided serializer. + * + * @param key The key to retrieve. + * @param default The default value to return if the key does not exist. + * @param serializer The serializer for the value type. + * @return [Result.success] with the value, or [Result.failure] if an error occurs. + * + * Example usage: + * ```kotlin + * dataStore.getSerializableValue("user", defaultUser, User.serializer()) + * ``` + */ + override suspend fun getSerializableValue( + key: String, + default: T, + serializer: KSerializer, + ): Result { + return enhancedDataStore.getSerializableValue(key, default, serializer) + } + + /** + * Removes the value associated with the specified key from the data store. + * + * @param key The key to remove. + * @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs. + * + * Example usage: + * ```kotlin + * dataStore.removeValue("theme") + * ``` + */ + override suspend fun removeValue(key: String): Result { + return withContext(dispatcher) { + val oldValue = if (hasKey(key).getOrDefault(false)) { + runCatching { settings.getString(key, "") }.getOrNull() + } else { + null + } + + val result = enhancedDataStore.removeValue(key) + + if (result.isSuccess) { + changeNotifier.notifyChange( + DataStoreChangeEvent.ValueRemoved(key, oldValue), + ) + } + + result + } + } + + /** + * Clears all stored preferences in the data store. + * + * @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs. + * + * Example usage: + * ```kotlin + * dataStore.clearAll() + * ``` + */ + override suspend fun clearAll(): Result { + return withContext(dispatcher) { + val result = enhancedDataStore.clearAll() + + if (result.isSuccess) { + changeNotifier.notifyChange(DataStoreChangeEvent.StoreCleared()) + } + + result + } + } + + /** + * Observes the value for the specified key as a flow, emitting updates as they occur. + * + * @param key The key to observe. + * @param default The default value to emit if the key does not exist. + * @return A [Flow] emitting the value for the key. + * + * Example usage: + * ```kotlin + * dataStore.observeValue("theme", "light").collect { value -> println(value) } + * ``` + */ + override fun observeValue(key: String, default: T): Flow { + return valueObserver.createDistinctValueFlow(key, default) { + enhancedDataStore.getValue(key, default) + } + } + + /** + * Observes a serializable value for the specified key as a flow. + * + * @param key The key to observe. + * @param default The default value to emit if the key does not exist. + * @param serializer The serializer for the value type. + * @return A [Flow] emitting the value for the key. + * + * Example usage: + * ```kotlin + * dataStore + * .observeSerializableValue("user", defaultUser, User.serializer()) + * .collect { user -> println(user) } + * ``` + */ + override fun observeSerializableValue( + key: String, + default: T, + serializer: KSerializer, + ): Flow { + return valueObserver.createDistinctValueFlow(key, default) { + enhancedDataStore.getSerializableValue(key, default, serializer) + } + } + + /** + * Observes all keys in the data store as a flow, emitting updates as they occur. + * + * @return A [Flow] emitting the set of all keys. + * + * Example usage: + * ```kotlin + * dataStore.observeKeys().collect { keys -> println(keys) } + * ``` + */ + override fun observeKeys(): Flow> { + return changeNotifier.observeChanges() + .onStart { emit(DataStoreChangeEvent.ValueAdded("", null)) } // Trigger initial emission + .map { settings.keys } + } + + /** + * Observes the size of the data store as a flow, emitting updates as they occur. + * + * @return A [Flow] emitting the number of key-value pairs in the data store. + * + * Example usage: + * ```kotlin + * dataStore.observeSize().collect { size -> println(size) } + * ``` + */ + override fun observeSize(): Flow { + return changeNotifier.observeChanges() + .onStart { emit(DataStoreChangeEvent.ValueAdded("", null)) } // Trigger initial emission + .map { getSize().getOrDefault(0) } + .distinctUntilChanged() + } + + /** + * Observes all change events in the data store as a flow. + * + * @return A [Flow] emitting [DataStoreChangeEvent] instances as changes occur. + * + * Example usage: + * ```kotlin + * dataStore.observeChanges().collect { event -> println(event) } + * ``` + */ + override fun observeChanges(): Flow { + return changeNotifier.observeChanges() + } + + /** + * Checks if the specified key exists in the data store. + * + * @param key The key to check. + * @return [Result.success] with true if the key exists, or [Result.failure] if an error occurs. + * + * Example usage: + * ```kotlin + * dataStore.hasKey("theme") + * ``` + */ + override suspend fun hasKey(key: String): Result = enhancedDataStore.hasKey(key) + + /** + * Retrieves all keys currently stored in the data store. + * + * @return [Result.success] with the set of keys, or [Result.failure] if an error occurs. + * + * Example usage: + * ```kotlin + * dataStore.getAllKeys() + * ``` + */ + override suspend fun getAllKeys(): Result> = enhancedDataStore.getAllKeys() + + /** + * Retrieves the total number of key-value pairs stored in the data store. + * + * @return [Result.success] with the count, or [Result.failure] if an error occurs. + * + * Example usage: + * ```kotlin + * dataStore.getSize() + * ``` + */ + override suspend fun getSize(): Result = enhancedDataStore.getSize() + + /** + * Invalidates the cache entry for the specified key. + * + * This forces the next retrieval for this key to read from the underlying data store. + * + * @param key The key whose cache entry should be invalidated. + * @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs. + * + * Example usage: + * ```kotlin + * dataStore.invalidateCache("user_preferences") + * ``` + */ + override suspend fun invalidateCache(key: String): Result = + enhancedDataStore.invalidateCache(key) + + /** + * Invalidates all entries in the cache. + * + * This forces the next retrieval for any key to read from the underlying data store. + * + * @return [Result.success] if the operation succeeds, or [Result.failure] if an error occurs. + * + * Example usage: + * ```kotlin + * dataStore.invalidateAllCache() + * ``` + */ + override suspend fun invalidateAllCache(): Result = enhancedDataStore.invalidateAllCache() + + /** + * Returns the current number of entries in the cache. + * + * @return The size of the cache. + * + * Example usage: + * ```kotlin + * val cacheSize = dataStore.getCacheSize() + * println("Cache contains $cacheSize entries") + * ``` + */ + override fun getCacheSize(): Int = enhancedDataStore.getCacheSize() +} diff --git a/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/validation/DefaultPreferencesValidator.kt b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/validation/DefaultPreferencesValidator.kt new file mode 100644 index 0000000000..e61b461af1 --- /dev/null +++ b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/validation/DefaultPreferencesValidator.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore.validation + +import template.core.base.datastore.exceptions.InvalidKeyException + +/** + * Default implementation of [PreferencesValidator] for validating keys and values in the data store. + * + * This implementation enforces constraints such as non-blank keys, maximum key length, and value size limits. + * + * Example usage: + * ```kotlin + * val validator = DefaultPreferencesValidator() + * validator.validateKey("theme") + * validator.validateValue("dark") + * ``` + */ +class DefaultPreferencesValidator : PreferencesValidator { + + /** + * {@inheritDoc} + */ + override fun validateKey(key: String): Result { + return when { + key.isBlank() -> Result.failure( + InvalidKeyException("Key cannot be blank"), + ) + + key.length > 255 -> Result.failure( + InvalidKeyException("Key length cannot exceed 255 characters: '$key'"), + ) + + key.contains('\u0000') -> Result.failure( + InvalidKeyException("Key cannot contain null characters: '$key'"), + ) + + else -> Result.success(Unit) + } + } + + /** + * {@inheritDoc} + */ + override fun validateValue(value: T): Result { + return when (value) { + null -> Result.failure( + IllegalArgumentException("Value cannot be null"), + ) + + is String -> { + if (value.length > 10000) { + Result.failure( + IllegalArgumentException("String value too large: ${value.length} characters"), + ) + } else { + Result.success(Unit) + } + } + + else -> Result.success(Unit) + } + } +} diff --git a/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/validation/PreferencesValidator.kt b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/validation/PreferencesValidator.kt new file mode 100644 index 0000000000..187cef73e7 --- /dev/null +++ b/core-base/datastore/src/commonMain/kotlin/template/core/base/datastore/validation/PreferencesValidator.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore.validation + +/** + * Interface for validating keys and values used in the data store. + * + * Implementations of this interface provide validation logic to ensure that keys and values meet + * required constraints before being stored or retrieved. + * + * Example usage: + * ```kotlin + * val validator: PreferencesValidator = DefaultPreferencesValidator() + * validator.validateKey("theme") + * validator.validateValue("dark") + * ``` + */ +interface PreferencesValidator { + /** + * Validates the provided key for use in the data store. + * + * @param key The key to validate. + * @return [Result.success] if the key is valid, or [Result.failure] with an exception if invalid. + */ + fun validateKey(key: String): Result + + /** + * Validates the provided value for use in the data store. + * + * @param value The value to validate. + * @return [Result.success] if the value is valid, or [Result.failure] with an exception if invalid. + */ + fun validateValue(value: T): Result +} diff --git a/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/DataStoreComprehensiveFeatureTest.kt b/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/DataStoreComprehensiveFeatureTest.kt new file mode 100644 index 0000000000..cb83c58a29 --- /dev/null +++ b/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/DataStoreComprehensiveFeatureTest.kt @@ -0,0 +1,872 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore + +import app.cash.turbine.test +import com.russhwolf.settings.MapSettings +import com.russhwolf.settings.Settings +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Clock.System +import kotlinx.serialization.Serializable +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.core.qualifier.named +import org.koin.dsl.module +import org.koin.test.KoinTest +import org.koin.test.inject +import template.core.base.datastore.cache.LruCacheManager +import template.core.base.datastore.contracts.DataStoreChangeEvent +import template.core.base.datastore.di.CoreDatastoreModule +import template.core.base.datastore.extensions.onlyAdditions +import template.core.base.datastore.extensions.onlyRemovals +import template.core.base.datastore.extensions.onlyUpdates +import template.core.base.datastore.factory.DataStoreFactory +import template.core.base.datastore.handlers.IntTypeHandler +import template.core.base.datastore.reactive.PreferenceFlowOperators +import template.core.base.datastore.repository.ReactivePreferencesRepository +import template.core.base.datastore.serialization.JsonSerializationStrategy +import template.core.base.datastore.validation.DefaultPreferencesValidator +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.measureTime + +/** + * Comprehensive feature test for the Core DataStore Module. + * Tests all functionality including basic operations, reactive features, + * caching, serialization, validation, and performance characteristics. + */ +@ExperimentalCoroutinesApi +class DataStoreComprehensiveFeatureTest : KoinTest { + + private val testDispatcher = StandardTestDispatcher() + private val repository: ReactivePreferencesRepository by inject() + private val operators: PreferenceFlowOperators by inject() + + // Test data models + @Serializable + data class UserProfile( + val id: Long = 0, + val name: String = "", + val email: String = "", + val age: Int = 0, + val isActive: Boolean = true, + val preferences: UserPreferences = UserPreferences(), + ) + + @Serializable + data class UserPreferences( + val theme: String = "light", + val language: String = "en", + val notifications: Boolean = true, + val fontSize: Float = 14.0f, + val autoSave: Boolean = true, + ) + + @Serializable + data class AppConfig( + val version: String, + val features: List, + val settings: Map, + ) + + @BeforeTest + fun setup() { + startKoin { + modules( + CoreDatastoreModule, + module { + single { MapSettings() } + single( + qualifier = named("IO"), + ) { testDispatcher } + }, + ) + } + } + + @AfterTest + fun tearDown() { + stopKoin() + } + + /** + * Test 1: Basic DataStore Operations + * Tests fundamental CRUD operations for all primitive types + */ + @Test + fun test01_BasicDataStoreOperations() = runTest(testDispatcher) { + println("=== Testing Basic DataStore Operations ===") + + // Test all primitive types + val primitiveTests = mapOf( + "int_key" to 42, + "long_key" to 123456789L, + "float_key" to 3.14f, + "double_key" to 2.71828, + "string_key" to "Hello DataStore!", + "boolean_key" to true, + ) + + primitiveTests.forEach { (key, value) -> + println("Testing $key with value: $value") + + // Save preference + val saveResult = repository.savePreference(key, value) + assertTrue(saveResult.isSuccess, "Failed to save $key") + + // Retrieve preference + val retrieveResult = repository.getPreference(key, getDefaultForType(value)) + assertTrue(retrieveResult.isSuccess, "Failed to retrieve $key") + assertEquals(value, retrieveResult.getOrThrow(), "Value mismatch for $key") + + // Check if key exists + assertTrue(repository.hasPreference(key), "Key $key should exist") + } + + // Test key retrieval + val allKeys = repository.observeAllKeys().first() + assertTrue(allKeys.containsAll(primitiveTests.keys), "Not all keys found") + + println("✅ Basic operations test passed") + } + + /** + * Test 2: Serializable Object Storage + * Tests complex object serialization and deserialization + */ + @Test + fun test02_SerializableObjectStorage() = runTest(testDispatcher) { + println("=== Testing Serializable Object Storage ===") + + // Test UserProfile storage + val userProfile = UserProfile( + id = 12345, + name = "John Doe", + email = "john.doe@example.com", + age = 30, + isActive = true, + preferences = UserPreferences( + theme = "dark", + language = "es", + notifications = false, + fontSize = 16.0f, + autoSave = true, + ), + ) + + // Save complex object + val saveResult = repository.saveSerializablePreference( + "user_profile", + userProfile, + UserProfile.serializer(), + ) + assertTrue(saveResult.isSuccess, "Failed to save UserProfile") + + // Retrieve complex object + val retrieveResult = repository.getSerializablePreference( + "user_profile", + UserProfile(), + UserProfile.serializer(), + ) + assertTrue(retrieveResult.isSuccess, "Failed to retrieve UserProfile") + assertEquals(userProfile, retrieveResult.getOrThrow(), "UserProfile mismatch") + + // Test AppConfig with collections + val appConfig = AppConfig( + version = "1.2.3", + features = listOf("feature1", "feature2", "feature3"), + settings = mapOf( + "timeout" to "30", + "retries" to "3", + "debug" to "false", + ), + ) + + val configSaveResult = repository.saveSerializablePreference( + "app_config", + appConfig, + AppConfig.serializer(), + ) + assertTrue(configSaveResult.isSuccess, "Failed to save AppConfig") + + val configRetrieveResult = repository.getSerializablePreference( + "app_config", + AppConfig("", emptyList(), emptyMap()), + AppConfig.serializer(), + ) + assertTrue(configRetrieveResult.isSuccess, "Failed to retrieve AppConfig") + assertEquals(appConfig, configRetrieveResult.getOrThrow(), "AppConfig mismatch") + + println("✅ Serializable object storage test passed") + } + + /** + * Test 3: Reactive Functionality + * Tests reactive flows, change notifications, and observers + */ + @Test + fun test03_ReactiveFunctionality() = runTest(testDispatcher) { + println("=== Testing Reactive Functionality ===") + + // Test preference observation + repository.observePreference("reactive_key", "default").test { + // Initial value + assertEquals("default", awaitItem()) + + // Update value + repository.savePreference("reactive_key", "updated") + assertEquals("updated", awaitItem()) + + // Update again + repository.savePreference("reactive_key", "final") + assertEquals("final", awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + + // Test change notifications + repository.observePreferenceChanges().test { + // Add a new preference + repository.savePreference("change_test", "value1") + val addChange = awaitItem() + assertTrue(addChange is DataStoreChangeEvent.ValueAdded) + assertEquals("change_test", addChange.key) + + // Update the preference + repository.savePreference("change_test", "value2") + val updateChange = awaitItem() + assertTrue(updateChange is DataStoreChangeEvent.ValueUpdated) + assertEquals("change_test", updateChange.key) + assertEquals("value1", updateChange.oldValue) + assertEquals("value2", updateChange.newValue) + + // Remove the preference + repository.removePreference("change_test") + val removeChange = awaitItem() + assertTrue(removeChange is DataStoreChangeEvent.ValueRemoved) + assertEquals("change_test", removeChange.key) + + cancelAndIgnoreRemainingEvents() + } + + // Test serializable object observation + val defaultProfile = UserProfile() + repository.observeSerializablePreference( + "profile_reactive", + defaultProfile, + UserProfile.serializer(), + ).test { + assertEquals(defaultProfile, awaitItem()) + + val newProfile = UserProfile(id = 999, name = "Jane") + repository.saveSerializablePreference( + "profile_reactive", + newProfile, + UserProfile.serializer(), + ) + assertEquals(newProfile, awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + + println("✅ Reactive functionality test passed") + } + + /** + * Test 4: Preference Flow Operators + * Tests combining, mapping, and advanced flow operations + */ + @Test + fun test04_PreferenceFlowOperators() = runTest(testDispatcher) { + println("=== Testing Preference Flow Operators ===") + + // Test combining two preferences + operators.combinePreferences( + "username", + "", + "is_premium", + false, + ) { username, isPremium -> + "User: $username, Premium: $isPremium" + }.test { + assertEquals("User: , Premium: false", awaitItem()) + + repository.savePreference("username", "alice") + assertEquals("User: alice, Premium: false", awaitItem()) + + repository.savePreference("is_premium", true) + assertEquals("User: alice, Premium: true", awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + + // Test combining three preferences + operators.combinePreferences( + "theme", + "light", + "language", + "en", + "notifications", + true, + ) { theme, lang, notifs -> + Triple(theme, lang, notifs) + }.test { + assertEquals(Triple("light", "en", true), awaitItem()) + + repository.savePreference("theme", "dark") + assertEquals(Triple("dark", "en", true), awaitItem()) + + repository.savePreference("language", "es") + assertEquals(Triple("dark", "es", true), awaitItem()) + + repository.savePreference("notifications", false) + assertEquals(Triple("dark", "es", false), awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + + // Test mapped preference observation + operators.observeMappedPreference("counter", 0) { count -> + "Count: $count" + }.test { + assertEquals("Count: 0", awaitItem()) + + repository.savePreference("counter", 5) + assertEquals("Count: 5", awaitItem()) + + repository.savePreference("counter", 10) + assertEquals("Count: 10", awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + + // Test key change observation + operators.observeAnyKeyChange("monitored1", "monitored2").test { + repository.savePreference("monitored1", "value1") + assertEquals("monitored1", awaitItem()) + + repository.savePreference("unmonitored", "value") + // Should not emit + + repository.savePreference("monitored2", "value2") + assertEquals("monitored2", awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + + println("✅ Preference flow operators test passed") + } + + /** + * Test 5: Caching Functionality + * Tests LRU cache behavior and cache management + */ + @Test + fun test05_CachingFunctionality() = runTest(testDispatcher) { + println("=== Testing Caching Functionality ===") + + // Create a small cache to test eviction + val cache = LruCacheManager(maxSize = 3) + + // Test basic cache operations + cache.put("key1", "value1") + cache.put("key2", "value2") + cache.put("key3", "value3") + + assertEquals(3, cache.size()) + assertEquals("value1", cache.get("key1")) + assertEquals("value2", cache.get("key2")) + assertEquals("value3", cache.get("key3")) + + // Test LRU eviction + cache.put("key4", "value4") // Should evict key1 (least recently used) + assertEquals(3, cache.size()) + assertNull(cache.get("key1"), "key1 should have been evicted") + assertEquals("value4", cache.get("key4")) + + // Test cache removal + cache.remove("key2") + assertEquals(2, cache.size()) + assertNull(cache.get("key2")) + + // Test cache clear + cache.clear() + assertEquals(0, cache.size()) + + // Test with datastore factory + val settings = MapSettings() + val cacheableDataStore = DataStoreFactory() + .settings(settings) + .cacheSize(5) + .dispatcher(testDispatcher) + .buildDataStore() + + // Test cache hit/miss behavior + val putResult = cacheableDataStore.putValue("cached_key", "cached_value") + assertTrue(putResult.isSuccess) + + val getResult = cacheableDataStore.getValue("cached_key", "default") + assertTrue(getResult.isSuccess) + assertEquals("cached_value", getResult.getOrThrow()) + + // Verify cache contains the value + assertTrue(cacheableDataStore.getCacheSize() > 0) + + // Test cache invalidation + val invalidateResult = cacheableDataStore.invalidateCache("cached_key") + assertTrue(invalidateResult.isSuccess) + + println("✅ Caching functionality test passed") + } + + /** + * Test 6: Validation and Error Handling + * Tests input validation and error scenarios + */ + @Test + fun test06_ValidationAndErrorHandling() = runTest(testDispatcher) { + println("=== Testing Validation and Error Handling ===") + + val validator = DefaultPreferencesValidator() + + // Test key validation + assertTrue(validator.validateKey("valid_key").isSuccess) + assertTrue(validator.validateKey("").isFailure) + assertTrue(validator.validateKey(" ".repeat(256)).isFailure) + + // Test value validation + assertTrue(validator.validateValue("valid_value").isSuccess) + assertTrue(validator.validateValue(123).isSuccess) + assertTrue(validator.validateValue(null).isFailure) + + val longString = "x".repeat(20000) + assertTrue(validator.validateValue(longString).isFailure) + + // Test serialization error handling + val strategy = JsonSerializationStrategy() + + @Serializable + data class TestData(val value: String) + + val validData = TestData("test") + val serializeResult = strategy.serialize(validData, TestData.serializer()) + assertTrue(serializeResult.isSuccess) + + val deserializeResult = strategy.deserialize( + serializeResult.getOrThrow(), + TestData.serializer(), + ) + assertTrue(deserializeResult.isSuccess) + assertEquals(validData, deserializeResult.getOrThrow()) + + // Test deserializing invalid JSON + val invalidDeserializeResult = strategy.deserialize( + "invalid json", + TestData.serializer(), + ) + assertTrue(invalidDeserializeResult.isFailure) + + // Test type handler error scenarios + val intHandler = IntTypeHandler() + assertTrue(intHandler.canHandle(42)) + assertFalse(intHandler.canHandle("not an int")) + assertFalse(intHandler.canHandle(null)) + + println("✅ Validation and error handling test passed") + } + + /** + * Test 7: Flow Extensions + * Tests custom flow extension functions + */ + @Test + fun test07_FlowExtensions() = runTest(testDispatcher) { + println("=== Testing Flow Extensions ===") + + // Test change filtering extensions + repository.observePreferenceChanges().onlyAdditions().test { + repository.savePreference("add_test", "value1") + val addition = awaitItem() + assertTrue(addition is DataStoreChangeEvent.ValueAdded) + assertEquals("add_test", addition.key) + + // Update should not appear in additions + repository.savePreference("add_test", "value2") + + repository.savePreference("add_test2", "value2") + val addition2 = awaitItem() + assertTrue(addition2 is DataStoreChangeEvent.ValueAdded) + assertEquals("add_test2", addition2.key) + + cancelAndIgnoreRemainingEvents() + } + + repository.observePreferenceChanges().onlyUpdates().test { + // First save (addition) should not appear + repository.savePreference("update_test", "initial") + + // Second save (update) should appear + repository.savePreference("update_test", "updated") + val update = awaitItem() + assertTrue(update is DataStoreChangeEvent.ValueUpdated) + assertEquals("update_test", update.key) + assertEquals("initial", update.oldValue) + assertEquals("updated", update.newValue) + + cancelAndIgnoreRemainingEvents() + } + + repository.observePreferenceChanges().onlyRemovals().test { + repository.savePreference("remove_test", "value") + + repository.removePreference("remove_test") + val removal = awaitItem() + assertTrue(removal is DataStoreChangeEvent.ValueRemoved) + assertEquals("remove_test", removal.key) + + cancelAndIgnoreRemainingEvents() + } + + println("✅ Flow extensions test passed") + } + + /** + * Test 8: Performance and Stress Testing + * Tests performance characteristics under load + */ + @Test + fun test08_PerformanceAndStressTesting() = runTest(testDispatcher) { + println("=== Testing Performance and Stress ===") + + val operationCount = 100 + + // Test rapid sequential operations + val sequentialDuration = measureTime { + repeat(operationCount) { i -> + repository.savePreference("perf_key_$i", "value_$i") + } + advanceUntilIdle() + } + + // Verify all values were saved + repeat(operationCount) { i -> + val result = repository.getPreference("perf_key_$i", "") + assertEquals("value_$i", result.getOrThrow()) + } + + println("Sequential operations ($operationCount): ${sequentialDuration.inWholeMilliseconds}ms") + + // Test rapid updates to same key + repository.observePreference("rapid_update", 0).test { + assertEquals(0, awaitItem()) // Initial value + + val updateDuration = measureTime { + repeat(50) { i -> + repository.savePreference("rapid_update", i + 1) + advanceUntilIdle() + } + } + + // Should receive all updates + repeat(50) { i -> + assertEquals(i + 1, awaitItem()) + } + + println("Rapid updates (50): ${updateDuration.inWholeMilliseconds}ms") + + cancelAndIgnoreRemainingEvents() + } + + // Test large object serialization + val largeConfig = AppConfig( + version = "1.0.0", + features = (1..100).map { "feature_$it" }, + settings = (1..50).associate { "setting_$it" to "value_$it" }, + ) + + val serializationDuration = measureTime { + repeat(10) { + repository.saveSerializablePreference( + "large_config_$it", + largeConfig, + AppConfig.serializer(), + ) + } + advanceUntilIdle() + } + + println("Large object serialization (10): ${serializationDuration.inWholeMilliseconds}ms") + + // Verify large objects were saved correctly + repeat(10) { + val result = repository.getSerializablePreference( + "large_config_$it", + AppConfig("", emptyList(), emptyMap()), + AppConfig.serializer(), + ) + assertEquals(largeConfig, result.getOrThrow()) + } + + println("✅ Performance and stress test passed") + } + + /** + * Test 9: Complete Integration Scenario + * Tests realistic app usage patterns + */ + @Test + fun test09_CompleteIntegrationScenario() = runTest(testDispatcher) { + println("=== Testing Complete Integration Scenario ===") + + // Simulate complete app onboarding and usage + + // 1. Initial app setup + val appConfig = AppConfig( + version = "2.1.0", + features = listOf("dark_mode", "notifications", "analytics"), + settings = mapOf( + "api_timeout" to "30000", + "cache_size" to "100", + "log_level" to "info", + ), + ) + + repository.saveSerializablePreference("app_config", appConfig, AppConfig.serializer()) + + // 2. User profile creation + val userProfile = UserProfile( + id = 12345, + name = "Integration Test User", + email = "test@example.com", + age = 25, + preferences = UserPreferences( + theme = "auto", + language = "en", + notifications = true, + fontSize = 15.0f, + ), + ) + + repository.saveSerializablePreference("user_profile", userProfile, UserProfile.serializer()) + + // 3. Session preferences + repository.savePreference("session_id", "sess_abc123") + repository.savePreference("login_timestamp", System.now().toEpochMilliseconds()) + repository.savePreference("device_id", "device_xyz789") + + // 4. Feature flags and settings + val featureFlags = mapOf( + "new_ui" to true, + "beta_features" to false, + "experimental_api" to true, + ) + + featureFlags.forEach { (flag, enabled) -> + repository.savePreference("feature_$flag", enabled) + } + + // 5. Observe combined user state + operators.combinePreferences( + "user_profile", + UserProfile(), + "session_id", + "", + ) { profile, sessionId -> + "User: ${profile.name} (${profile.email}), Session: $sessionId" + }.test { + val expectedState = + "User: ${userProfile.name} (${userProfile.email}), Session: sess_abc123" + assertEquals(expectedState, awaitItem()) + + // Update user profile + val updatedProfile = userProfile.copy(name = "Updated User") + repository.saveSerializablePreference( + "user_profile", + updatedProfile, + UserProfile.serializer(), + ) + + val expectedUpdatedState = + "User: Updated User (${userProfile.email}), Session: sess_abc123" + assertEquals(expectedUpdatedState, awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + + // 6. Verify all data persistence + val retrievedConfig = repository.getSerializablePreference( + "app_config", + AppConfig("", emptyList(), emptyMap()), + AppConfig.serializer(), + ).getOrThrow() + assertEquals(appConfig, retrievedConfig) + + val retrievedProfile = repository.getSerializablePreference( + "user_profile", + UserProfile(), + UserProfile.serializer(), + ).getOrThrow() + assertEquals("Updated User", retrievedProfile.name) + + // 7. Test bulk operations + val bulkClearDuration = measureTime { + repository.clearAllPreferences() + advanceUntilIdle() + } + + println("Bulk clear operation: ${bulkClearDuration.inWholeMilliseconds}ms") + + // Verify everything was cleared + val keysAfterClear = repository.observeAllKeys().first() + assertTrue(keysAfterClear.isEmpty(), "All keys should be cleared") + + println("✅ Complete integration scenario test passed") + } + + /** + * Test 10: Edge Cases and Boundary Conditions + * Tests unusual scenarios and edge cases + */ + @Test + fun test10_EdgeCasesAndBoundaryConditions() = runTest(testDispatcher) { + println("=== Testing Edge Cases and Boundary Conditions ===") + + // Test empty string handling + repository.savePreference("empty_string", "") + assertEquals("", repository.getPreference("empty_string", "default").getOrThrow()) + + // Test special characters in keys and values + val specialKey = "key_with_special_chars_!@#$%^&*()" + val specialValue = "Value with émojis 🚀💻 and spëcial chars: <>?/|\\`~" + repository.savePreference(specialKey, specialValue) + assertEquals(specialValue, repository.getPreference(specialKey, "").getOrThrow()) + + // Test very long strings (within limits) + val longValue = "x".repeat(9999) // Just under the 10000 limit + repository.savePreference("long_value", longValue) + assertEquals(longValue, repository.getPreference("long_value", "").getOrThrow()) + + // Test numeric edge cases + repository.savePreference("max_int", Int.MAX_VALUE) + repository.savePreference("min_int", Int.MIN_VALUE) + repository.savePreference("max_long", Long.MAX_VALUE) + repository.savePreference("min_long", Long.MIN_VALUE) + repository.savePreference("max_float", Float.MAX_VALUE) + repository.savePreference("min_float", Float.MIN_VALUE) + repository.savePreference("max_double", Double.MAX_VALUE) + repository.savePreference("min_double", Double.MIN_VALUE) + + assertEquals(Int.MAX_VALUE, repository.getPreference("max_int", 0).getOrThrow()) + assertEquals(Int.MIN_VALUE, repository.getPreference("min_int", 0).getOrThrow()) + assertEquals(Long.MAX_VALUE, repository.getPreference("max_long", 0L).getOrThrow()) + assertEquals(Long.MIN_VALUE, repository.getPreference("min_long", 0L).getOrThrow()) + assertEquals(Float.MAX_VALUE, repository.getPreference("max_float", 0f).getOrThrow()) + assertEquals(Float.MIN_VALUE, repository.getPreference("min_float", 0f).getOrThrow()) + assertEquals(Double.MAX_VALUE, repository.getPreference("max_double", 0.0).getOrThrow()) + assertEquals(Double.MIN_VALUE, repository.getPreference("min_double", 0.0).getOrThrow()) + + // Test rapid key creation and deletion + repeat(20) { i -> + repository.savePreference("temp_key_$i", "temp_value_$i") + } + + repeat(20) { i -> + assertTrue(repository.hasPreference("temp_key_$i")) + repository.removePreference("temp_key_$i") + assertFalse(repository.hasPreference("temp_key_$i")) + } + + // Test observing non-existent keys + repository.observePreference("non_existent", "default_value").test { + assertEquals("default_value", awaitItem()) + + // Create the key + repository.savePreference("non_existent", "now_exists") + assertEquals("now_exists", awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + + // Test multiple observers on same key + val observers = List(5) { + repository.observePreference("shared_key", "initial") + } + + observers.forEach { flow -> + flow.test { + assertEquals("initial", awaitItem()) + expectNoEvents() // Should not have additional events yet + cancelAndIgnoreRemainingEvents() + } + } + + println("✅ Edge cases and boundary conditions test passed") + } + + // Helper function to get default values for different types + @Suppress("UNCHECKED_CAST") + private fun getDefaultForType(value: T): T = when (value) { + is Int -> 0 as T + is Long -> 0L as T + is Float -> 0f as T + is Double -> 0.0 as T + is String -> "" as T + is Boolean -> false as T + else -> throw IllegalArgumentException("Unsupported type: ${value?.let { it::class }}") + } + + /** + * Runs all tests in sequence and provides a comprehensive report + */ + @Test + fun runAllFeatureTests() = runTest(testDispatcher) { + println("🚀 Starting Comprehensive DataStore Feature Test Suite") + println("=" * 60) + + val totalDuration = measureTime { + try { + test01_BasicDataStoreOperations() + test02_SerializableObjectStorage() + test03_ReactiveFunctionality() + test04_PreferenceFlowOperators() + test05_CachingFunctionality() + test06_ValidationAndErrorHandling() + test07_FlowExtensions() + test08_PerformanceAndStressTesting() + test09_CompleteIntegrationScenario() + test10_EdgeCasesAndBoundaryConditions() + } catch (e: Exception) { + println("❌ Test suite failed with error: ${e.message}") + throw e + } + } + + println("=" * 60) + println("🎉 ALL FEATURE TESTS PASSED!") + println("⏱️ Total execution time: ${totalDuration.inWholeMilliseconds}ms") + println("✅ DataStore module is fully functional and ready for production") + println("=" * 60) + } +} + +/** + * Extension function for string repetition (Kotlin doesn't have this built-in) + */ +private operator fun String.times(count: Int): String = repeat(count) diff --git a/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/EnhancedUserPreferencesDataStoreTest.kt b/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/EnhancedUserPreferencesDataStoreTest.kt new file mode 100644 index 0000000000..a6700c1dbb --- /dev/null +++ b/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/EnhancedUserPreferencesDataStoreTest.kt @@ -0,0 +1,133 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore + +import com.russhwolf.settings.MapSettings +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.Serializable +import template.core.base.datastore.cache.LruCacheManager +import template.core.base.datastore.handlers.BooleanTypeHandler +import template.core.base.datastore.handlers.IntTypeHandler +import template.core.base.datastore.handlers.StringTypeHandler +import template.core.base.datastore.handlers.TypeHandler +import template.core.base.datastore.serialization.JsonSerializationStrategy +import template.core.base.datastore.store.CachedPreferencesStore +import template.core.base.datastore.validation.DefaultPreferencesValidator +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@ExperimentalCoroutinesApi +class EnhancedUserPreferencesDataStoreTest { + private val testDispatcher = StandardTestDispatcher() + private val settings = MapSettings() + private val cacheManager = LruCacheManager(maxSize = 2) + + @Suppress("UNCHECKED_CAST") + private val dataStore = CachedPreferencesStore( + settings = settings, + dispatcher = testDispatcher, + typeHandlers = listOf( + IntTypeHandler(), + StringTypeHandler(), + BooleanTypeHandler(), + ) as List>, + serializationStrategy = JsonSerializationStrategy(), + validator = DefaultPreferencesValidator(), + cacheManager = cacheManager, + ) + + @Serializable + data class Custom(val id: Int, val name: String) + + @Test + fun putAndGet_PrimitiveTypes() = runTest(testDispatcher) { + assertTrue(dataStore.putValue("int", 1).isSuccess) + assertEquals(1, dataStore.getValue("int", 0).getOrThrow()) + assertTrue(dataStore.putValue("bool", true).isSuccess) + assertEquals(true, dataStore.getValue("bool", false).getOrThrow()) + } + + @Test + fun putAndGet_SerializableType() = runTest(testDispatcher) { + val custom = Custom(1, "abc") + assertTrue(dataStore.putSerializableValue("custom", custom, Custom.serializer()).isSuccess) + assertEquals(custom, dataStore.getSerializableValue("custom", Custom(0, ""), Custom.serializer()).getOrThrow()) + } + + @Test + fun getValue_ReturnsDefaultIfMissingOrCorrupt() = runTest(testDispatcher) { + assertEquals(99, dataStore.getValue("missing", 99).getOrThrow()) + } + + @Test + fun hasKey_WorksWithCache() = runTest(testDispatcher) { + assertTrue(dataStore.hasKey("nope").getOrThrow() == false) + assertTrue(dataStore.putValue("exists", 1).isSuccess) + assertTrue(dataStore.hasKey("exists").getOrThrow()) + } + + @Test + fun removeValue_RemovesFromCacheAndSettings() = runTest(testDispatcher) { + assertTrue(dataStore.putValue("toremove", 5).isSuccess) + assertTrue(dataStore.removeValue("toremove").isSuccess) + assertEquals(0, dataStore.getValue("toremove", 0).getOrThrow()) + } + + @Test + fun clearAll_RemovesEverything() = runTest(testDispatcher) { + assertTrue(dataStore.putValue("a", 1).isSuccess) + assertTrue(dataStore.putValue("b", 2).isSuccess) + assertTrue(dataStore.clearAll().isSuccess) + assertEquals(0, dataStore.getValue("a", 0).getOrThrow()) + assertEquals(0, dataStore.getValue("b", 0).getOrThrow()) + } + + @Test + fun getAllKeysAndSize() = runTest(testDispatcher) { + assertTrue(dataStore.putValue("k1", 1).isSuccess) + assertTrue(dataStore.putValue("k2", 2).isSuccess) + assertEquals(setOf("k1", "k2"), dataStore.getAllKeys().getOrThrow()) + assertEquals(2, dataStore.getSize().getOrThrow()) + } + + @Test + fun cacheEviction_WorksAsExpected() = runTest(testDispatcher) { + assertTrue(dataStore.putValue("k1", 1).isSuccess) + assertTrue(dataStore.putValue("k2", 2).isSuccess) + assertTrue(dataStore.putValue("k3", 3).isSuccess) // Should evict k1 if maxSize=2 + assertEquals(2, dataStore.getCacheSize()) + assertTrue(!cacheManager.containsKey("k1")) + } + + @Test + fun invalidateCache_RemovesSpecificKey() = runTest(testDispatcher) { + assertTrue(dataStore.putValue("k1", 1).isSuccess) + assertTrue(dataStore.invalidateCache("k1").isSuccess) + assertTrue(!cacheManager.containsKey("k1")) + } + + @Test + fun invalidateAllCache_RemovesAll() = runTest(testDispatcher) { + assertTrue(dataStore.putValue("k1", 1).isSuccess) + assertTrue(dataStore.putValue("k2", 2).isSuccess) + assertTrue(dataStore.invalidateAllCache().isSuccess) + assertEquals(0, dataStore.getCacheSize()) + } + + @Test + fun putValue_FailsForUnsupportedType() = runTest(testDispatcher) { + class Unsupported + val result = dataStore.putValue("bad", Unsupported()) + assertTrue(result.isFailure) + } +} diff --git a/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/ReactiveUserPreferencesDataStoreTest.kt b/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/ReactiveUserPreferencesDataStoreTest.kt new file mode 100644 index 0000000000..1f134dd8c4 --- /dev/null +++ b/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/ReactiveUserPreferencesDataStoreTest.kt @@ -0,0 +1,220 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore + +import app.cash.turbine.test +import com.russhwolf.settings.MapSettings +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.Serializable +import template.core.base.datastore.cache.LruCacheManager +import template.core.base.datastore.contracts.DataStoreChangeEvent +import template.core.base.datastore.handlers.BooleanTypeHandler +import template.core.base.datastore.handlers.DoubleTypeHandler +import template.core.base.datastore.handlers.FloatTypeHandler +import template.core.base.datastore.handlers.IntTypeHandler +import template.core.base.datastore.handlers.LongTypeHandler +import template.core.base.datastore.handlers.StringTypeHandler +import template.core.base.datastore.handlers.TypeHandler +import template.core.base.datastore.reactive.DefaultChangeNotifier +import template.core.base.datastore.reactive.DefaultValueObserver +import template.core.base.datastore.serialization.JsonSerializationStrategy +import template.core.base.datastore.store.ReactiveUserPreferencesDataStore +import template.core.base.datastore.validation.DefaultPreferencesValidator +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@ExperimentalCoroutinesApi +class ReactiveUserPreferencesDataStoreTest { + + private val testDispatcher = StandardTestDispatcher() + private val settings = MapSettings() + private val changeNotifier = DefaultChangeNotifier() + + private val reactiveDataStore = ReactiveUserPreferencesDataStore( + settings = settings, + dispatcher = testDispatcher, + typeHandlers = listOf( + IntTypeHandler(), + StringTypeHandler(), + BooleanTypeHandler(), + LongTypeHandler(), + FloatTypeHandler(), + DoubleTypeHandler(), + ) as List>, + serializationStrategy = JsonSerializationStrategy(), + validator = DefaultPreferencesValidator(), + cacheManager = LruCacheManager(maxSize = 10), + changeNotifier = changeNotifier, + valueObserver = DefaultValueObserver(changeNotifier), + ) + + @Serializable + data class TestUser( + val id: Long, + val name: String, + val age: Int, + ) + + @Test + fun observeValue_EmitsInitialValueAndUpdates() = runTest(testDispatcher) { + // Initially store a value + assertTrue(reactiveDataStore.putValue("test_key", "initial").isSuccess) + + reactiveDataStore.observeValue("test_key", "default").test { + // Should emit initial value + assertEquals("initial", awaitItem()) + + // Update the value + assertTrue(reactiveDataStore.putValue("test_key", "updated").isSuccess) + assertEquals("updated", awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun observeValue_EmitsDefaultWhenKeyNotExists() = runTest(testDispatcher) { + reactiveDataStore.observeValue("non_existent", "default").test { + assertEquals("default", awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun observeSerializableValue_WorksWithCustomObjects() = runTest(testDispatcher) { + val defaultUser = TestUser(0, "", 0) + val testUser = TestUser(1, "John", 25) + + reactiveDataStore.observeSerializableValue( + "user", + defaultUser, + TestUser.serializer(), + ).test { + // Should emit default initially + assertEquals(defaultUser, awaitItem()) + + // Update with new user + assertTrue( + reactiveDataStore.putSerializableValue( + "user", + testUser, + TestUser.serializer(), + ).isSuccess, + ) + assertEquals(testUser, awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun observeChanges_EmitsCorrectChangeTypes() = runTest(testDispatcher) { + reactiveDataStore.observeChanges().test { + // Add a value + assertTrue(reactiveDataStore.putValue("key1", "value1").isSuccess) + val addChange = awaitItem() + assertTrue(addChange is DataStoreChangeEvent.ValueAdded) + assertEquals("key1", addChange.key) + assertEquals("value1", addChange.value) + + // Update the value + assertTrue(reactiveDataStore.putValue("key1", "value2").isSuccess) + val updateChange = awaitItem() + assertTrue(updateChange is DataStoreChangeEvent.ValueUpdated) + assertEquals("key1", updateChange.key) + assertEquals("value1", updateChange.oldValue) + assertEquals("value2", updateChange.newValue) + + // Remove the value + assertTrue(reactiveDataStore.removeValue("key1").isSuccess) + val removeChange = awaitItem() + assertTrue(removeChange is DataStoreChangeEvent.ValueRemoved) + assertEquals("key1", removeChange.key) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun observeKeys_EmitsUpdatedKeySets() = runTest(context = testDispatcher) { + reactiveDataStore.observeKeys().test { + // Initial empty set (from onStart emission) + assertEquals(emptySet(), awaitItem()) + advanceUntilIdle() + + // Add first key + assertTrue(reactiveDataStore.putValue("key1", "value1").isSuccess) + delay(300) // Allow time for initial emission + + assertEquals(setOf("key1"), awaitItem()) + + // Add second key + assertTrue(reactiveDataStore.putValue("key2", "value2").isSuccess) + assertEquals(setOf("key1", "key2"), awaitItem()) + + // Remove first key + assertTrue(reactiveDataStore.removeValue("key1").isSuccess) + assertEquals(setOf("key2"), awaitItem()) + + // Remove second key + assertTrue(reactiveDataStore.removeValue("key2").isSuccess) + assertEquals(emptySet(), awaitItem()) + } + } + + @Test + fun observeSize_EmitsCorrectCounts() = runTest(testDispatcher) { + reactiveDataStore.observeSize().test { + // Initial size should be 0 (from onStart emission) + assertEquals(0, awaitItem()) + + // Add items + assertTrue(reactiveDataStore.putValue("key1", "value1").isSuccess) + assertEquals(1, awaitItem()) + + assertTrue(reactiveDataStore.putValue("key2", "value2").isSuccess) + assertEquals(2, awaitItem()) + + // Clear all + assertTrue(reactiveDataStore.clearAll().isSuccess) + assertEquals(0, awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun observeValue_DistinctUntilChanged() = runTest(testDispatcher) { + reactiveDataStore.observeValue("key", "default").test { + // Initial emission + assertEquals("default", awaitItem()) + + // Set same value - should not emit + assertTrue(reactiveDataStore.putValue("key", "default").isSuccess) + + // Set different value - should emit + assertTrue(reactiveDataStore.putValue("key", "new_value").isSuccess) + assertEquals("new_value", awaitItem()) + + // Set same value again - should not emit + assertTrue(reactiveDataStore.putValue("key", "new_value").isSuccess) + + // Verify no more emissions + expectNoEvents() + + cancelAndIgnoreRemainingEvents() + } + } +} diff --git a/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/UserPreferencesDataStoreTest.kt b/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/UserPreferencesDataStoreTest.kt new file mode 100644 index 0000000000..2f21f1acfa --- /dev/null +++ b/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/UserPreferencesDataStoreTest.kt @@ -0,0 +1,113 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore + +import com.russhwolf.settings.MapSettings +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.Serializable +import template.core.base.datastore.store.BasicPreferencesStore +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@ExperimentalCoroutinesApi +class UserPreferencesDataStoreTest { + private val testDispatcher = StandardTestDispatcher() + private val settings = MapSettings() + private val dataStore = BasicPreferencesStore(settings, testDispatcher) + + @Serializable + data class CustomData(val id: Int, val name: String) + + @Test + fun putAndGet_PrimitiveTypes() = runTest(testDispatcher) { + assertTrue(dataStore.putValue("int", 42).isSuccess) + assertEquals(42, dataStore.getValue("int", 0).getOrThrow()) + + assertTrue(dataStore.putValue("long", 123L).isSuccess) + assertEquals(123L, dataStore.getValue("long", 0L).getOrThrow()) + + assertTrue(dataStore.putValue("float", 3.14f).isSuccess) + assertEquals(3.14f, dataStore.getValue("float", 0f).getOrThrow()) + + assertTrue(dataStore.putValue("double", 2.71).isSuccess) + assertEquals(2.71, dataStore.getValue("double", 0.0).getOrThrow()) + + assertTrue(dataStore.putValue("string", "hello").isSuccess) + assertEquals("hello", dataStore.getValue("string", "").getOrThrow()) + + assertTrue(dataStore.putValue("boolean", true).isSuccess) + assertEquals(true, dataStore.getValue("boolean", false).getOrThrow()) + } + + @Test + fun putAndGet_CustomType_WithSerializer() = runTest(testDispatcher) { + val custom = CustomData(1, "test") + assertTrue(dataStore.putValue("custom", custom, CustomData.serializer()).isSuccess) + assertEquals(custom, dataStore.getValue("custom", CustomData(0, ""), CustomData.serializer()).getOrThrow()) + } + + @Test + fun putValue_ThrowsWithoutSerializer() = runTest(testDispatcher) { + val custom = CustomData(2, "fail") + val result = dataStore.putValue("fail", custom) + assertTrue(result.isFailure) + } + + @Test + fun getValue_ThrowsWithoutSerializer() = runTest(testDispatcher) { + val result = dataStore.getValue("fail", CustomData(0, "")) + assertTrue(result.isFailure) + } + + @Test + fun getValue_ReturnsDefaultIfKeyMissing() = runTest(testDispatcher) { + assertEquals(99, dataStore.getValue("missing", 99).getOrThrow()) + } + + @Test + fun hasKey_WorksCorrectly() = runTest(testDispatcher) { + assertTrue(dataStore.hasKey("nope").getOrThrow() == false) + assertTrue(dataStore.putValue("exists", 1).isSuccess) + assertTrue(dataStore.hasKey("exists").getOrThrow()) + } + + @Test + fun removeValue_RemovesKey() = runTest(testDispatcher) { + assertTrue(dataStore.putValue("toremove", 5).isSuccess) + assertTrue(dataStore.removeValue("toremove").isSuccess) + assertEquals(0, dataStore.getValue("toremove", 0).getOrThrow()) + } + + @Test + fun clearAll_RemovesAllKeys() = runTest(testDispatcher) { + assertTrue(dataStore.putValue("a", 1).isSuccess) + assertTrue(dataStore.putValue("b", 2).isSuccess) + assertTrue(dataStore.clearAll().isSuccess) + assertEquals(0, dataStore.getValue("a", 0).getOrThrow()) + assertEquals(0, dataStore.getValue("b", 0).getOrThrow()) + } + + @Test + fun getAllKeys_ReturnsAllKeys() = runTest(testDispatcher) { + assertTrue(dataStore.putValue("k1", 1).isSuccess) + assertTrue(dataStore.putValue("k2", 2).isSuccess) + assertEquals(setOf("k1", "k2"), dataStore.getAllKeys().getOrThrow()) + } + + @Test + fun getSize_ReturnsCorrectCount() = runTest(testDispatcher) { + assertTrue(dataStore.putValue("k1", 1).isSuccess) + assertTrue(dataStore.putValue("k2", 2).isSuccess) + assertEquals(2, dataStore.getSize().getOrThrow()) + } +} diff --git a/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/cache/LRUCacheManagerTest.kt b/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/cache/LRUCacheManagerTest.kt new file mode 100644 index 0000000000..46bed34d45 --- /dev/null +++ b/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/cache/LRUCacheManagerTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore.cache + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class LRUCacheManagerTest { + @Test + fun putAndGet_WorksCorrectly() { + val cache = LruCacheManager(maxSize = 2) + cache.put("a", 1) + assertEquals(1, cache.get("a")) + } + + @Test + fun remove_RemovesKey() { + val cache = LruCacheManager(maxSize = 2) + cache.put("a", 1) + cache.remove("a") + assertNull(cache.get("a")) + } + + @Test + fun clear_RemovesAll() { + val cache = LruCacheManager(maxSize = 2) + cache.put("a", 1) + cache.put("b", 2) + cache.clear() + assertEquals(0, cache.size()) + } + + @Test + fun eviction_EvictsLeastRecentlyUsed() { + val cache = LruCacheManager(maxSize = 2) + cache.put("a", 1) + cache.put("b", 2) + cache.put("c", 3) // Should evict "a" + assertNull(cache.get("a")) + assertEquals(2, cache.size()) + } + + @Test + fun containsKey_Works() { + val cache = LruCacheManager(maxSize = 2) + cache.put("a", 1) + assertTrue(cache.containsKey("a")) + cache.remove("a") + assertTrue(!cache.containsKey("a")) + } +} diff --git a/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/extension/FlowExtensionsTest.kt b/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/extension/FlowExtensionsTest.kt new file mode 100644 index 0000000000..53ac2c23df --- /dev/null +++ b/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/extension/FlowExtensionsTest.kt @@ -0,0 +1,99 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore.extension + +import app.cash.turbine.test +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import template.core.base.datastore.contracts.DataStoreChangeEvent +import template.core.base.datastore.extensions.mapWithDefault +import template.core.base.datastore.extensions.onlyAdditions +import template.core.base.datastore.extensions.onlyRemovals +import template.core.base.datastore.extensions.onlyUpdates +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class FlowExtensionsTest { + + @Test + fun mapWithDefault_HandlesErrors() = runTest { + val flow = flowOf(1, 2, 3) + .mapWithDefault("default") { + if (it == 2) throw RuntimeException("Error") + "value_$it" + } + + flow.test { + assertEquals("value_1", awaitItem()) + assertEquals("default", awaitItem()) // Error case + awaitComplete() + } + } + + @Test + fun filterChangeType_FiltersCorrectTypes() = runTest { + val changes = flowOf( + DataStoreChangeEvent.ValueAdded("key1", "value1"), + DataStoreChangeEvent.ValueUpdated("key2", "old", "new"), + DataStoreChangeEvent.ValueAdded("key3", "value3"), + DataStoreChangeEvent.ValueRemoved("key4", "value4"), + ) + + changes.onlyAdditions().test { + val first = awaitItem() + assertTrue(true) + assertEquals("key1", first.key) + + val second = awaitItem() + assertTrue(true) + assertEquals("key3", second.key) + + awaitComplete() + } + } + + @Test + fun onlyUpdates_FiltersUpdateChanges() = runTest { + val changes = flowOf( + DataStoreChangeEvent.ValueAdded("key1", "value1"), + DataStoreChangeEvent.ValueUpdated("key2", "old", "new"), + DataStoreChangeEvent.ValueRemoved("key3", "value3"), + ) + + changes.onlyUpdates().test { + val update = awaitItem() + assertTrue(true) + assertEquals("key2", update.key) + assertEquals("old", update.oldValue) + assertEquals("new", update.newValue) + + awaitComplete() + } + } + + @Test + fun onlyRemovals_FiltersRemovalChanges() = runTest { + val changes = flowOf( + DataStoreChangeEvent.ValueAdded("key1", "value1"), + DataStoreChangeEvent.ValueRemoved("key2", "value2"), + DataStoreChangeEvent.ValueUpdated("key3", "old", "new"), + ) + + changes.onlyRemovals().test { + val removal = awaitItem() + assertTrue(true) + assertEquals("key2", removal.key) + assertEquals("value2", removal.oldValue) + + awaitComplete() + } + } +} diff --git a/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/integration/ReactiveIntegrationTest.kt b/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/integration/ReactiveIntegrationTest.kt new file mode 100644 index 0000000000..99cc973a58 --- /dev/null +++ b/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/integration/ReactiveIntegrationTest.kt @@ -0,0 +1,155 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore.integration + +import app.cash.turbine.test +import com.russhwolf.settings.MapSettings +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.Serializable +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.core.qualifier.named +import org.koin.dsl.module +import org.koin.test.KoinTest +import org.koin.test.inject +import template.core.base.datastore.di.CoreDatastoreModule +import template.core.base.datastore.reactive.PreferenceFlowOperators +import template.core.base.datastore.repository.ReactivePreferencesRepository +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@ExperimentalCoroutinesApi +class ReactiveIntegrationTest : KoinTest { + + private val repository: ReactivePreferencesRepository by inject() + private val operators: PreferenceFlowOperators by inject() + private val testDispatcher = StandardTestDispatcher() + + @Serializable + data class UserProfile( + val name: String, + val email: String, + val theme: String, + ) + + @BeforeTest + fun setup() { + startKoin { + modules( + CoreDatastoreModule, + module { + // Override dispatcher for testing + single(named("IO")) { testDispatcher } + single { PreferenceFlowOperators(get()) } + single { MapSettings() } + }, + ) + } + } + + @AfterTest + fun tearDown() { + stopKoin() + } + + @Test + fun endToEndReactiveTest_ComplexWorkflow() = runTest(testDispatcher) { + val defaultProfile = UserProfile("", "", "light") + + // Start observing user profile + repository.observeSerializablePreference( + "user_profile", + defaultProfile, + UserProfile.serializer(), + ).test { + // Initial default profile + assertEquals(defaultProfile, awaitItem()) + + // Save initial profile + val initialProfile = UserProfile("John", "john@example.com", "light") + repository.saveSerializablePreference( + "user_profile", + initialProfile, + UserProfile.serializer(), + ) + assertEquals(initialProfile, awaitItem()) + + // Update theme + val darkProfile = initialProfile.copy(theme = "dark") + repository.saveSerializablePreference( + "user_profile", + darkProfile, + UserProfile.serializer(), + ) + assertEquals(darkProfile, awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun combinedPreferencesWorkflow() = runTest(testDispatcher) { + // Observe combined user state + operators.combinePreferences( + "username", + "", + "is_premium", + false, + "login_count", + 0, + ) { username, isPremium, loginCount -> + "User: $username, Premium: $isPremium, Logins: $loginCount" + }.test { + // Initial state + assertEquals("User: , Premium: false, Logins: 0", awaitItem()) + + // Set username + repository.savePreference("username", "alice") + assertEquals("User: alice, Premium: false, Logins: 0", awaitItem()) + + // Upgrade to premium + repository.savePreference("is_premium", true) + assertEquals("User: alice, Premium: true, Logins: 0", awaitItem()) + + // Increment login count + repository.savePreference("login_count", 1) + assertEquals("User: alice, Premium: true, Logins: 1", awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun saveAndRetrieveAllPrimitiveTypes() = runTest(testDispatcher) { + // Int + repository.savePreference("intKey", 42) + assertEquals(42, repository.getPreference("intKey", 0).getOrThrow()) + // String + repository.savePreference("stringKey", "hello") + assertEquals("hello", repository.getPreference("stringKey", "").getOrThrow()) + // Boolean + repository.savePreference("boolKey", true) + assertEquals(true, repository.getPreference("boolKey", false).getOrThrow()) + // Long + repository.savePreference("longKey", 123456789L) + assertEquals(123456789L, repository.getPreference("longKey", 0L).getOrThrow()) + // Float + repository.savePreference("floatKey", 3.14f) + assertEquals(3.14f, repository.getPreference("floatKey", 0f).getOrThrow()) + // Double + repository.savePreference("doubleKey", 2.718) + assertEquals(2.718, repository.getPreference("doubleKey", 0.0).getOrThrow()) + } +} diff --git a/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/notification/DefaultChangeNotifierTest.kt b/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/notification/DefaultChangeNotifierTest.kt new file mode 100644 index 0000000000..5c4181b279 --- /dev/null +++ b/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/notification/DefaultChangeNotifierTest.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore.notification + +import app.cash.turbine.test +import kotlinx.coroutines.test.runTest +import template.core.base.datastore.contracts.DataStoreChangeEvent +import template.core.base.datastore.reactive.DefaultChangeNotifier +import kotlin.test.Test +import kotlin.test.assertEquals + +class DefaultChangeNotifierTest { + + private val changeNotifier = DefaultChangeNotifier() + + @Test + fun observeChanges_EmitsNotifiedChanges() = runTest { + changeNotifier.observeChanges().test { + val change = DataStoreChangeEvent.ValueAdded("key", "value") + changeNotifier.notifyChange(change) + + assertEquals(change, awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun observeKeyChanges_FiltersCorrectKey() = runTest { + changeNotifier.observeKeyChanges("target_key").test { + // Send change for different key - should not emit + changeNotifier.notifyChange(DataStoreChangeEvent.ValueAdded("other_key", "value")) + + // Send change for target key - should emit + val targetChange = DataStoreChangeEvent.ValueAdded("target_key", "value") + changeNotifier.notifyChange(targetChange) + assertEquals(targetChange, awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun observeKeyChanges_EmitsGlobalChanges() = runTest { + changeNotifier.observeKeyChanges("specific_key").test { + // Global change (clear all) should be emitted regardless of key + val globalChange = DataStoreChangeEvent.StoreCleared() + changeNotifier.notifyChange(globalChange) + assertEquals(globalChange, awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + } +} diff --git a/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/operators/PreferenceFlowOperatorsTest.kt b/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/operators/PreferenceFlowOperatorsTest.kt new file mode 100644 index 0000000000..918b91bd6f --- /dev/null +++ b/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/operators/PreferenceFlowOperatorsTest.kt @@ -0,0 +1,203 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore.operators + +import app.cash.turbine.test +import com.russhwolf.settings.MapSettings +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import template.core.base.datastore.cache.LruCacheManager +import template.core.base.datastore.handlers.BooleanTypeHandler +import template.core.base.datastore.handlers.IntTypeHandler +import template.core.base.datastore.handlers.StringTypeHandler +import template.core.base.datastore.handlers.TypeHandler +import template.core.base.datastore.reactive.DefaultChangeNotifier +import template.core.base.datastore.reactive.DefaultValueObserver +import template.core.base.datastore.reactive.PreferenceFlowOperators +import template.core.base.datastore.repository.DefaultReactivePreferencesRepository +import template.core.base.datastore.serialization.JsonSerializationStrategy +import template.core.base.datastore.store.ReactiveUserPreferencesDataStore +import template.core.base.datastore.validation.DefaultPreferencesValidator +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Test suite for [template.core.base.datastore.reactive.PreferenceFlowOperators]. + * Verifies combining, mapping, and observing preference flows, including edge cases. + */ +@ExperimentalCoroutinesApi +class PreferenceFlowOperatorsTest { + + private val testDispatcher = StandardTestDispatcher() + private val changeNotifier = DefaultChangeNotifier() + + private val reactiveDataStore = ReactiveUserPreferencesDataStore( + settings = MapSettings(), + dispatcher = testDispatcher, + typeHandlers = listOf( + IntTypeHandler(), + StringTypeHandler(), + BooleanTypeHandler(), + ) as List>, + serializationStrategy = JsonSerializationStrategy(), + validator = DefaultPreferencesValidator(), + cacheManager = LruCacheManager(), + changeNotifier = changeNotifier, + valueObserver = DefaultValueObserver(changeNotifier), + ) + + private val repository = DefaultReactivePreferencesRepository(reactiveDataStore) + private val operators = PreferenceFlowOperators(repository) + + /** + * Tests combining two preference flows and verifies correct emission order. + */ + @Test + fun combinePreferences_TwoValues_CombinesCorrectly() = runTest(testDispatcher) { + operators.combinePreferences( + "key1", + "default1", + "key2", + "default2", + ) { value1, value2 -> + "$value1-$value2" + }.test { + // Initial combined value + assertEquals("default1-default2", awaitItem()) + + // Update first preference + repository.savePreference("key1", "new1") + assertEquals("new1-default2", awaitItem()) + + // Update second preference + repository.savePreference("key2", "new2") + assertEquals("new1-new2", awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + } + + /** + * Tests combining three preference flows and verifies correct emission order. + */ + @Test + fun combinePreferences_ThreeValues_CombinesCorrectly() = runTest(testDispatcher) { + operators.combinePreferences( + "theme", + "light", + "language", + "en", + "notifications", + true, + ) { theme, language, notifications -> + Triple(theme, language, notifications) + }.test { + advanceUntilIdle() + delay(10) + + // Initial combined value + assertEquals(Triple("light", "en", true), awaitItem()) + + // Give time for initial emission + kotlinx.coroutines.delay(10) + + // Update theme + repository.savePreference("theme", "dark") + advanceUntilIdle() + assertEquals(Triple("dark", "en", true), awaitItem()) + + // Update notifications + repository.savePreference("notifications", false) + delay(10) + advanceUntilIdle() + + assertEquals(Triple("dark", "en", false), awaitItem()) + } + } + + /** + * Tests observing changes to any of the specified keys. + */ + @Test + fun observeAnyKeyChange_EmitsOnSpecifiedKeys() = runTest(testDispatcher) { + operators.observeAnyKeyChange("key1", "key2").test { + // Change to key1 - should emit + repository.savePreference("key1", "value1") + assertEquals("key1", awaitItem()) + + // Change to key3 - should not emit (not in watched keys) + repository.savePreference("key3", "value3") + + // Change to key2 - should emit + repository.savePreference("key2", "value2") + assertEquals("key2", awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + } + + /** + * Tests mapping a preference value using a transform function. + */ + @Test + fun observeMappedPreference_TransformsValues() = runTest(testDispatcher) { + operators.observeMappedPreference("count", 0) { count -> + "Count is: $count" + }.test { + // Initial mapped value + assertEquals("Count is: 0", awaitItem()) + + // Update preference + repository.savePreference("count", 5) + assertEquals("Count is: 5", awaitItem()) + + // Update again + repository.savePreference("count", 10) + assertEquals("Count is: 10", awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + } + + /** + * Tests rapid updates to preferences and ensures all changes are emitted. + */ + @Test + fun rapidUpdates_AreHandledCorrectly() = runTest(testDispatcher) { + operators.observeMappedPreference("rapid", 0) { it }.test { + for (i in 1..5) { + repository.savePreference("rapid", i) + assertEquals(i, awaitItem()) + } + cancelAndIgnoreRemainingEvents() + } + } + + /** + * Tests combining preferences with default/null values. + */ + @Test + fun combinePreferences_DefaultValues() = runTest(testDispatcher) { + operators.combinePreferences( + "missing1", + "", + "missing2", + "", + ) { v1, v2 -> + v1 to v2 + }.test { + assertEquals("" to "", awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } +} diff --git a/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/performance/ReactivePerformanceTest.kt b/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/performance/ReactivePerformanceTest.kt new file mode 100644 index 0000000000..03c56c4aa2 --- /dev/null +++ b/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/performance/ReactivePerformanceTest.kt @@ -0,0 +1,121 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore.performance + +import app.cash.turbine.test +import app.cash.turbine.turbineScope +import com.russhwolf.settings.MapSettings +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import template.core.base.datastore.cache.LruCacheManager +import template.core.base.datastore.handlers.BooleanTypeHandler +import template.core.base.datastore.handlers.IntTypeHandler +import template.core.base.datastore.handlers.StringTypeHandler +import template.core.base.datastore.handlers.TypeHandler +import template.core.base.datastore.reactive.DefaultChangeNotifier +import template.core.base.datastore.reactive.DefaultValueObserver +import template.core.base.datastore.serialization.JsonSerializationStrategy +import template.core.base.datastore.store.ReactiveUserPreferencesDataStore +import template.core.base.datastore.validation.DefaultPreferencesValidator +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.measureTime + +@ExperimentalCoroutinesApi +class ReactivePerformanceTest { + + private val testDispatcher = StandardTestDispatcher() + private val changeNotifier = DefaultChangeNotifier() + + @Suppress("UNCHECKED_CAST") + private val reactiveDataStore = ReactiveUserPreferencesDataStore( + settings = MapSettings(), + dispatcher = testDispatcher, + typeHandlers = listOf( + IntTypeHandler(), + StringTypeHandler(), + BooleanTypeHandler(), + ) as List>, + serializationStrategy = JsonSerializationStrategy(), + validator = DefaultPreferencesValidator(), + cacheManager = LruCacheManager(maxSize = 1000), + changeNotifier = changeNotifier, + valueObserver = DefaultValueObserver(changeNotifier), + ) + + @Test + fun rapidUpdates_HandledEfficiently() = runTest(testDispatcher) { + val updateCount = 100 + + reactiveDataStore.observeValue("counter", 0).test { + // Initial value + assertEquals(0, awaitItem()) + + // Give time for initial emission + delay(10) + + val duration = measureTime { + repeat(updateCount) { i -> + reactiveDataStore.putValue("counter", i + 1) + advanceUntilIdle() + } + yield() + advanceUntilIdle() + } + + // Should receive all updates in order + val received = mutableListOf() + repeat(updateCount) { + received.add(awaitItem()) + } + assertEquals((1..updateCount).toList(), received) + + // Verify performance is reasonable (this is a rough check) + assertTrue( + duration.inWholeMilliseconds < 5000, + "Updates took too long: \\${duration.inWholeMilliseconds}ms", + ) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun multipleObservers_ShareNotifications() = runTest(testDispatcher) { + turbineScope { + val observer1 = reactiveDataStore + .observeValue("shared_key", "default").testIn(backgroundScope) + + val observer2 = + reactiveDataStore.observeValue("shared_key", "default").testIn(backgroundScope) + + advanceUntilIdle() + + // Both should get initial value + assertEquals("default", observer1.awaitItem()) + assertEquals("default", observer2.awaitItem()) + + // Give time for initial emission + delay(10) + + // Update the value + reactiveDataStore.putValue("shared_key", "updated") + + // Both should get updated value + assertEquals("updated", observer1.awaitItem()) + assertEquals("updated", observer2.awaitItem()) + } + } +} diff --git a/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/repository/DefaultReactiveUserPreferencesRepositoryTest.kt b/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/repository/DefaultReactiveUserPreferencesRepositoryTest.kt new file mode 100644 index 0000000000..527029cf8c --- /dev/null +++ b/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/repository/DefaultReactiveUserPreferencesRepositoryTest.kt @@ -0,0 +1,169 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore.repository + +import app.cash.turbine.test +import com.russhwolf.settings.MapSettings +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.Serializable +import template.core.base.datastore.cache.LruCacheManager +import template.core.base.datastore.contracts.DataStoreChangeEvent +import template.core.base.datastore.handlers.BooleanTypeHandler +import template.core.base.datastore.handlers.IntTypeHandler +import template.core.base.datastore.handlers.StringTypeHandler +import template.core.base.datastore.handlers.TypeHandler +import template.core.base.datastore.reactive.DefaultChangeNotifier +import template.core.base.datastore.reactive.DefaultValueObserver +import template.core.base.datastore.serialization.JsonSerializationStrategy +import template.core.base.datastore.store.ReactiveUserPreferencesDataStore +import template.core.base.datastore.validation.DefaultPreferencesValidator +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@ExperimentalCoroutinesApi +class DefaultReactiveUserPreferencesRepositoryTest { + + private val testDispatcher = StandardTestDispatcher() + private val changeNotifier = DefaultChangeNotifier() + + private val reactiveDataStore = ReactiveUserPreferencesDataStore( + settings = MapSettings(), + dispatcher = testDispatcher, + typeHandlers = listOf( + IntTypeHandler(), + StringTypeHandler(), + BooleanTypeHandler(), + ) as List>, + serializationStrategy = JsonSerializationStrategy(), + validator = DefaultPreferencesValidator(), + cacheManager = LruCacheManager(), + changeNotifier = changeNotifier, + valueObserver = DefaultValueObserver(changeNotifier), + ) + + private val repository = DefaultReactivePreferencesRepository(reactiveDataStore) + + @Serializable + data class AppSettings( + val theme: String, + val language: String, + val notifications: Boolean, + ) + + @Test + fun observePreference_ReactsToChanges() = runTest(testDispatcher) { + repository.observePreference("theme", "light").test { + // Initial value + assertEquals("light", awaitItem()) + + // Save new preference + repository.savePreference("theme", "dark") + assertEquals("dark", awaitItem()) + + // Save another value + repository.savePreference("theme", "auto") + assertEquals("auto", awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun observeSerializablePreference_ReactsToComplexObjects() = runTest(testDispatcher) { + val defaultSettings = AppSettings("light", "en", true) + + repository.observeSerializablePreference( + "app_settings", + defaultSettings, + AppSettings.serializer(), + ).test { + // Initial default + assertEquals(defaultSettings, awaitItem()) + + // Update settings + val newSettings = AppSettings("dark", "es", false) + repository.saveSerializablePreference( + "app_settings", + newSettings, + AppSettings.serializer(), + ) + assertEquals(newSettings, awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun observePreferenceChanges_FiltersCorrectly() = runTest(testDispatcher) { + repository.observePreferenceChanges("specific_key").test { + // Save to different key - should not emit + repository.savePreference("other_key", "value") + + // Save to specific key - should emit + repository.savePreference("specific_key", "value") + val change = awaitItem() + assertTrue(change is DataStoreChangeEvent.ValueAdded) + assertEquals("specific_key", change.key) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun observeAllKeys_ReactsToKeyChanges() = runTest(testDispatcher) { + repository.observeAllKeys().test { + // Initial empty set (from onStart emission) + assertEquals(emptySet(), awaitItem()) + advanceUntilIdle() + delay(100) // Ensure we wait for any initial emissions + + // Add preferences + repository.savePreference("key1", "value1") + assertEquals(setOf("key1"), awaitItem()) + + repository.savePreference("key2", "value2") + assertEquals(setOf("key1", "key2"), awaitItem()) + + // Remove preference + repository.removePreference("key1") + assertEquals(setOf("key2"), awaitItem()) + + // Clear all + repository.clearAllPreferences() + assertEquals(emptySet(), awaitItem()) + } + } + + @Test + fun observePreferenceCount_ReactsToSizeChanges() = runTest(testDispatcher) { + repository.observePreferenceCount().test { + // Initial count (from onStart emission) + assertEquals(0, awaitItem()) + + // Add preferences + repository.savePreference("key1", "value1") + assertEquals(1, awaitItem()) + + repository.savePreference("key2", "value2") + assertEquals(2, awaitItem()) + + // Clear all + repository.clearAllPreferences() + assertEquals(0, awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + } +} diff --git a/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/serialization/JsonSerializationStrategyTest.kt b/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/serialization/JsonSerializationStrategyTest.kt new file mode 100644 index 0000000000..38e62b1873 --- /dev/null +++ b/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/serialization/JsonSerializationStrategyTest.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore.serialization + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class JsonSerializationStrategyTest { + private val strategy = JsonSerializationStrategy(Json { encodeDefaults = true }) + + @Serializable + data class Data(val id: Int, val name: String) + + @Test + fun serializeAndDeserialize_Success() = kotlinx.coroutines.test.runTest { + val data = Data(1, "abc") + val serialized = strategy.serialize(data, Data.serializer()) + assertTrue(serialized.isSuccess) + val deserialized = strategy.deserialize(serialized.getOrThrow(), Data.serializer()) + assertTrue(deserialized.isSuccess) + assertEquals(data, deserialized.getOrThrow()) + } + + @Test + fun deserialize_FailureOnCorruptData() = kotlinx.coroutines.test.runTest { + val result = strategy.deserialize("not a json", Data.serializer()) + assertTrue(result.isFailure) + } +} diff --git a/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/validation/DefaultPreferencesValidatorTest.kt b/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/validation/DefaultPreferencesValidatorTest.kt new file mode 100644 index 0000000000..3813befd80 --- /dev/null +++ b/core-base/datastore/src/commonTest/kotlin/template/core/base/datastore/validation/DefaultPreferencesValidatorTest.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.datastore.validation + +import kotlin.test.Test +import kotlin.test.assertTrue + +class DefaultPreferencesValidatorTest { + private val validator = DefaultPreferencesValidator() + + @Test + fun validateKey_AcceptsValidKey() { + val result = validator.validateKey("validKey") + assertTrue(result.isSuccess) + } + + @Test + fun validateKey_RejectsEmptyKey() { + val result = validator.validateKey("") + assertTrue(result.isFailure) + } + + @Test + fun validateValue_AcceptsNonNull() { + val result = validator.validateValue(123) + assertTrue(result.isSuccess) + } + + @Test + fun validateValue_RejectsNull() { + val result = validator.validateValue(null) + assertTrue(result.isFailure) + } +} diff --git a/core-base/designsystem/.gitignore b/core-base/designsystem/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/core-base/designsystem/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core-base/designsystem/README.md b/core-base/designsystem/README.md new file mode 100644 index 0000000000..7128eef922 --- /dev/null +++ b/core-base/designsystem/README.md @@ -0,0 +1,341 @@ +# KPT Design System + +A comprehensive, Kotlin Multiplatform design system built on top of Material3, providing reusable UI components, theming +capabilities, and layout primitives for building consistent user interfaces across platforms. + +## 🌟 Overview + +The KPT Design System offers a robust foundation for building modern applications with: + +- **Consistent theming** across all platforms +- **Responsive layouts** that adapt to different screen sizes +- **Material3 integration** with custom design tokens +- **Component composition** with flexible configuration +- **Type-safe APIs** with Kotlin DSL builders + +## 🎯 Key Features + +- **🎨 Comprehensive Theming**: Complete design token system with color, typography, spacing, shapes, and elevation +- **📱 Responsive Design**: Adaptive layouts that work across phones, tablets, and desktop +- **🔧 Material3 Integration**: Seamless integration with Material3 components +- **⚡ Type Safety**: Kotlin DSL builders with compile-time safety +- **🎭 Consistent Animations**: Material Motion compliant animation specifications +- **🧩 Composable Architecture**: Flexible component composition with configuration objects +- **🌗 Dark Mode Support**: Built-in support for light and dark themes +- **♿ Accessibility**: Semantic properties and content descriptions throughout +- **🧪 Testing Support**: Test tags and testing utilities included + +## 📦 Module Structure + +``` +designsystem/ +├── component/ # UI Components +│ ├── KptTopAppBar.kt +│ ├── KptAnimationSpecs.kt +│ └── ... +├── core/ # Core abstractions +│ ├── KptComponent.kt +│ ├── ComponentStateHolder.kt +│ └── ... +├── layout/ # Layout components +│ ├── KptResponsiveLayout.kt +│ ├── KptGrid.kt +│ └── ... +├── theme/ # Theme implementation +│ └── KptColorSchemeImpl.kt +├── KptTheme.kt # Main theme composable +├── KptMaterialTheme.kt # Material3 integration +└── KptThemeExtensions.kt # Utility extensions +``` + +## 🏗️ Architecture + +```mermaid +graph TB + subgraph "KPT Design System Architecture" + Core[Core Interfaces & Abstractions] + Theme[Theme System] + Components[UI Components] + Layout[Layout System] + Extensions[Material3 Extensions] + Core --> Theme + Core --> Components + Core --> Layout + Theme --> Components + Theme --> Extensions + Components --> Layout + Extensions --> Components + end + + subgraph "Theme System" + Colors[KptColorScheme] + Typography[KptTypography] + Shapes[KptShapes] + Spacing[KptSpacing] + Elevation[KptElevation] + Provider[KptThemeProvider] + Provider --> Colors + Provider --> Typography + Provider --> Shapes + Provider --> Spacing + Provider --> Elevation + end + + subgraph "Component System" + BaseComponent[KptComponent] + TopAppBar[KptTopAppBar] + Animation[Animation Components] + Loading[Loading States] + BaseComponent --> Scaffold + BaseComponent --> TopAppBar + BaseComponent --> Animation + BaseComponent --> Loading + end +``` + +## 🎨 Theme System + +The KPT Design System provides a comprehensive theming solution that extends Material3 design tokens: + +### Color Scheme + +```kotlin +val customTheme = kptTheme { + colors { + primary = Color(0xFF6750A4) + onPrimary = Color.White + background = Color(0xFFFFFBFE) + // ... other colors + } +} +``` + +### Typography Scale + +```kotlin +kptTheme { + typography { + titleLarge = TextStyle( + fontSize = 22.sp, + fontWeight = FontWeight.Bold + ) + // ... other text styles + } +} +``` + +### Spacing System + +```kotlin +// Predefined spacing scale +KptTheme.spacing.xs // 4.dp +KptTheme.spacing.sm // 8.dp +KptTheme.spacing.md // 16.dp +KptTheme.spacing.lg // 24.dp +KptTheme.spacing.xl // 32.dp +KptTheme.spacing.xxl // 64.dp +``` + +## 🔧 Setup & Integration + +### Basic Setup + +```kotlin +@Composable +fun App() { + KptMaterialTheme { + // Your app content + NavHost(navController, startDestination = "home") { + composable("home") { HomeScreen() } + // ... other destinations + } + } +} +``` + +### Custom Theme Setup + +```kotlin +@Composable +fun App() { + val customTheme = kptTheme { + colors { + primary = Color(0xFF1976D2) + onPrimary = Color.White + } + typography { + titleLarge = titleLarge.copy(fontSize = 24.sp) + } + spacing { + md = 20.dp + } + } + + KptMaterialTheme(theme = customTheme) { + // App content with custom theme + } +} +``` + +### Dark Theme Support + +```kotlin +@Composable +fun App() { + val lightTheme = kptTheme { /* light theme config */ } + val darkTheme = kptTheme { /* dark theme config */ } + + KptMaterialTheme( + lightTheme = lightTheme, + darkThemeProvider = darkTheme + ) { + // Automatically switches based on system preference + } +} +``` + +## 🧩 Components(Demo) + +### KptTopAppBar + +Flexible top app bar with multiple variants: + +```kotlin +// Simple top app bar +KptTopAppBar(title = "Title") + +// With navigation and actions +KptTopAppBar( + title = "Title", + onNavigationIconClick = { navController.navigateUp() }, + actionIcon = Icons.Default.Search, + onActionClick = { openSearch() } +) + +// Using configuration builder +KptTopAppBar( + kptTopAppBar { + title = "Settings" + variant = TopAppBarVariant.Large + navigationIcon = Icons.AutoMirrored.Filled.ArrowBack + onNavigationClick = { navController.navigateUp() } + + action(Icons.Default.Search, "Search") { openSearch() } + action(Icons.Default.MoreVert, "More") { openMenu() } + } +) +``` + +## 📱 Responsive Layout System + +The design system includes responsive layout components that adapt to different screen sizes: + +```mermaid +graph LR + subgraph "Screen Size Breakpoints" + Compact["Compact < 600dp"] + Medium["Medium 600-840dp"] + Expanded["Expanded ≥ 840dp"] + end + + subgraph "Layout Components" + ResponsiveLayout[KptResponsiveLayout] + Grid[KptGrid] + FlowRow[KptFlowRow] + SplitPane[KptSplitPane] + SidebarLayout[KptSidebarLayout] + end + + Compact --> ResponsiveLayout + Medium --> ResponsiveLayout + Expanded --> ResponsiveLayout + ResponsiveLayout --> Grid + ResponsiveLayout --> FlowRow + ResponsiveLayout --> SplitPane + ResponsiveLayout --> SidebarLayout +``` + +### Usage Example + +```kotlin +KptResponsiveLayout( + compact = { + // Single column layout for phones + LazyColumn { /* items */ } + }, + medium = { + // Two column layout for tablets + Row { + LazyColumn(modifier = Modifier.weight(1f)) { /* left */ } + LazyColumn(modifier = Modifier.weight(1f)) { /* right */ } + } + }, + expanded = { + // Three column layout for desktop + KptSidebarLayout { + sidebar { NavigationRail() } + content { + Row { + LazyColumn(modifier = Modifier.weight(2f)) { /* main */ } + LazyColumn(modifier = Modifier.weight(1f)) { /* aside */ } + } + } + } + } +) +``` + +## 🎭 Animation System + +Consistent animation specifications following Material Motion guidelines: + +```kotlin +object KptAnimationSpecs { + val fast = tween(150, FastOutSlowInEasing) + val medium = tween(300, FastOutSlowInEasing) + val slow = tween(500, FastOutSlowInEasing) + + // Material Motion easing curves + val emphasizedEasing = CubicBezierEasing(0.2f, 0.0f, 0.0f, 1.0f) + val standardEasing = CubicBezierEasing(0.2f, 0.0f, 0.0f, 1.0f) +} +``` + +## 🧪 Component State Management + +The design system provides utilities for managing component state: + +```kotlin +@Composable +fun MyComponent() { + val state = rememberComponentState(initialValue = false) + + Button( + onClick = { state.update(!state.value) } + ) { + Text(if (state.value) "Enabled" else "Disabled") + } +} +``` + +## 🤝 Contributing + +1. Follow the existing code style and patterns +2. Add comprehensive KDoc documentation to new components +3. Include usage examples in component documentation +4. Test components across different screen sizes +5. Ensure accessibility compliance with semantic properties + +## 📄 License + +``` +Copyright 2025 Mifos Initiative + +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/. +``` + +--- + +**Built with ❤️ for the Kotlin Multiplatform community** \ No newline at end of file diff --git a/core-base/designsystem/build.gradle.kts b/core-base/designsystem/build.gradle.kts new file mode 100644 index 0000000000..e4ec924097 --- /dev/null +++ b/core-base/designsystem/build.gradle.kts @@ -0,0 +1,49 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +plugins { + alias(libs.plugins.mifos.kmp.library) + alias(libs.plugins.jetbrainsCompose) + alias(libs.plugins.compose.compiler) +} + +android { + namespace = "template.core.base.designsystem" +} + +kotlin { + sourceSets{ + androidMain.dependencies { + implementation(libs.androidx.compose.ui.tooling) + } + commonMain.dependencies { + implementation(compose.ui) + implementation(compose.material3) + implementation(compose.foundation) + implementation(compose.components.resources) + implementation(compose.components.uiToolingPreview) + implementation(compose.materialIconsExtended) + + api(compose.material3AdaptiveNavigationSuite) + api(libs.jetbrains.compose.material3.adaptive) + api(libs.jetbrains.compose.material3.adaptive.layout) + api(libs.jetbrains.compose.material3.adaptive.navigation) + + implementation(libs.jb.lifecycleViewmodel) + implementation(libs.window.size) + implementation(libs.ui.backhandler) + } + } +} + +compose.resources { + publicResClass = true + generateResClass = always + packageOfResClass = "template.core.base.designsystem.generated.resources" +} \ No newline at end of file diff --git a/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/KptMaterialTheme.kt b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/KptMaterialTheme.kt new file mode 100644 index 0000000000..16493bd566 --- /dev/null +++ b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/KptMaterialTheme.kt @@ -0,0 +1,184 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.designsystem + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import template.core.base.designsystem.core.KptThemeProvider +import template.core.base.designsystem.theme.KptTheme +import template.core.base.designsystem.theme.KptThemeProviderImpl +import template.core.base.designsystem.theme.LocalKptColors +import template.core.base.designsystem.theme.LocalKptElevation +import template.core.base.designsystem.theme.LocalKptShapes +import template.core.base.designsystem.theme.LocalKptSpacing +import template.core.base.designsystem.theme.LocalKptTypography +import template.core.base.designsystem.theme.kptTheme + +/** + * KptMaterialTheme provides Material3 integration for KptTheme. + * This composable applies KptTheme values to MaterialTheme automatically, + * making all Material3 components use KptTheme design tokens. + * + * @param theme KptThemeProvider instance containing design tokens + * @param content The composable content that will have access to both KptTheme and MaterialTheme + * + * @sample KptMaterialThemeUsageExample + */ +@Composable +fun KptMaterialTheme( + theme: KptThemeProvider = KptThemeProviderImpl(), + content: @Composable () -> Unit, +) { + // Convert KptTheme values to Material3 equivalents + val materialColorScheme = theme.colors.toMaterial3ColorScheme() + val materialTypography = theme.typography.toMaterial3Typography() + val materialShapes = theme.shapes.toMaterial3Shapes() + + // Provide both KptTheme composition locals and MaterialTheme + CompositionLocalProvider( + LocalKptColors provides theme.colors, + LocalKptTypography provides theme.typography, + LocalKptShapes provides theme.shapes, + LocalKptSpacing provides theme.spacing, + LocalKptElevation provides theme.elevation, + ) { + MaterialTheme( + colorScheme = materialColorScheme, + typography = materialTypography, + shapes = materialShapes, + content = content, + ) + } +} + +/** + * KptMaterialTheme with dark theme support. + * Provides automatic light/dark theme switching with Material3 integration. + * + * @param darkTheme Whether to use dark theme. Defaults to system preference. + * @param lightTheme KptThemeProvider for light theme + * @param darkTheme KptThemeProvider for dark theme + * @param content The composable content that will have access to both KptTheme and MaterialTheme + * + * @sample KptMaterialThemeWithDarkModeExample + */ +@Composable +fun KptMaterialTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + lightTheme: KptThemeProvider = KptThemeProviderImpl(), + darkThemeProvider: KptThemeProvider = KptThemeProviderImpl(), + content: @Composable () -> Unit, +) { + val selectedTheme = if (darkTheme) darkThemeProvider else lightTheme + KptMaterialTheme( + theme = selectedTheme, + content = content, + ) +} + +/** + * DSL builder for creating KptMaterialTheme with custom configuration + */ +@Composable +fun KptMaterialTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + themeBuilder: @Composable (Boolean) -> KptThemeProvider, + content: @Composable () -> Unit, +) { + val theme = themeBuilder(darkTheme) + KptMaterialTheme( + theme = theme, + content = content, + ) +} + +// region Usage Examples (for documentation) + +/** + * Example of basic KptMaterialTheme usage + */ +@Composable +private fun KptMaterialThemeUsageExample() { + KptMaterialTheme { + // All Material3 components will use KptTheme values + MaterialTheme.colorScheme.primary // = KptTheme.colorScheme.primary + MaterialTheme.typography.titleLarge // = KptTheme.typography.titleLarge + MaterialTheme.shapes.medium // = KptTheme.shapes.medium + + // KptTheme values are also available directly + KptTheme.spacing.md + KptTheme.elevation.level2 + } +} + +/** + * Example of KptMaterialTheme with dark mode support + */ +@Composable +private fun KptMaterialThemeWithDarkModeExample() { + val lightTheme = kptTheme { + colors { + primary = Color.Blue + } + } + + val darkTheme = kptTheme { + colors { + primary = Color.Cyan + } + } + + KptMaterialTheme( + lightTheme = lightTheme, + darkThemeProvider = darkTheme, + ) { + // Theme automatically switches based on system preference + // Material3 components inherit the appropriate theme + } +} + +/** + * Example of KptMaterialTheme with DSL builder + */ +@Composable +private fun KptMaterialThemeBuilderExample() { + KptMaterialTheme( + themeBuilder = { isDark -> + kptTheme { + colors { + if (isDark) { + primary = Color.Cyan + background = Color.Black + } else { + primary = Color.Blue + background = Color.White + } + } + typography { + titleLarge = titleLarge.copy(fontSize = 24.sp) + } + shapes { + medium = Shapes().medium.copy(all = CornerSize(16.dp)) + } + } + }, + ) { + // Dynamic theme based on dark mode + } +} + +// endregion diff --git a/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/KptTheme.kt b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/KptTheme.kt new file mode 100644 index 0000000000..9ea79808df --- /dev/null +++ b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/KptTheme.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.designsystem + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import template.core.base.designsystem.core.KptThemeProvider +import template.core.base.designsystem.theme.KptThemeProviderImpl +import template.core.base.designsystem.theme.LocalKptColors +import template.core.base.designsystem.theme.LocalKptElevation +import template.core.base.designsystem.theme.LocalKptShapes +import template.core.base.designsystem.theme.LocalKptSpacing +import template.core.base.designsystem.theme.LocalKptTypography + +/** + * KptTheme provides the core theming composable that makes all KPT design tokens + * available to child components through Composition Locals. + * + * This composable sets up the theme hierarchy by providing: + * - [LocalKptColors] for color design tokens + * - [LocalKptTypography] for typography design tokens + * - [LocalKptShapes] for shape design tokens + * - [LocalKptSpacing] for spacing design tokens + * - [LocalKptElevation] for elevation design tokens + * + * Usage: + * ``` + * KptTheme { + * // All child components can now access: + * // KptTheme.colorScheme + * // KptTheme.typography + * // KptTheme.shapes + * // KptTheme.spacing + * // KptTheme.elevation + * MyScreen() + * } + * ``` + * + * For Material3 integration, consider using [KptMaterialTheme] instead, + * which provides both KPT design tokens and Material3 theme compatibility. + * + * @param theme The theme provider containing all design tokens. Defaults to [KptThemeProviderImpl] + * @param content The composable content that will have access to the theme + * + * @see KptMaterialTheme + * @see KptThemeProvider + */ +@Composable +fun KptTheme( + theme: KptThemeProvider = KptThemeProviderImpl(), + content: @Composable () -> Unit, +) { + CompositionLocalProvider( + LocalKptColors provides theme.colors, + LocalKptTypography provides theme.typography, + LocalKptShapes provides theme.shapes, + LocalKptSpacing provides theme.spacing, + LocalKptElevation provides theme.elevation, + ) { + content() + } +} diff --git a/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/KptThemeExtensions.kt b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/KptThemeExtensions.kt new file mode 100644 index 0000000000..5990b26065 --- /dev/null +++ b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/KptThemeExtensions.kt @@ -0,0 +1,577 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.designsystem + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CardElevation +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.Typography +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.text.style.LineHeightStyle.Trim +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.sp +import template.core.base.designsystem.core.KptColorScheme +import template.core.base.designsystem.core.KptElevation +import template.core.base.designsystem.core.KptShapes +import template.core.base.designsystem.core.KptSpacing +import template.core.base.designsystem.core.KptTypography +import template.core.base.designsystem.theme.KptColorSchemeImpl +import template.core.base.designsystem.theme.KptShapesImpl +import template.core.base.designsystem.theme.KptTheme +import template.core.base.designsystem.theme.KptTypographyImpl + +/** + * Creates [PaddingValues] using KPT spacing tokens with horizontal and vertical values. + * + * This extension function provides a convenient way to create consistent padding + * using the design system's spacing scale. + * + * Example usage: + * ``` + * Box( + * modifier = Modifier.padding( + * KptTheme.spacing.paddingValues( + * horizontal = KptTheme.spacing.lg, + * vertical = KptTheme.spacing.md + * ) + * ) + * ) + * ``` + * + * @param horizontal Horizontal padding (start and end). Defaults to [md] + * @param vertical Vertical padding (top and bottom). Defaults to [md] + * @return [PaddingValues] configured with the specified spacing + * + * @see KptSpacing + */ +@Composable +fun KptSpacing.paddingValues( + horizontal: Dp = md, + vertical: Dp = md, +): PaddingValues = PaddingValues(horizontal = horizontal, vertical = vertical) + +/** + * Creates [PaddingValues] using KPT spacing tokens with individual edge values. + * + * This extension function provides fine-grained control over padding for each edge + * while maintaining consistency with the design system's spacing scale. + * + * Example usage: + * ``` + * Card( + * modifier = Modifier.padding( + * KptTheme.spacing.paddingValues( + * start = KptTheme.spacing.lg, + * top = KptTheme.spacing.md, + * end = KptTheme.spacing.lg, + * bottom = KptTheme.spacing.xl + * ) + * ) + * ) + * ``` + * + * @param start Padding for the start edge (left in LTR, right in RTL). Defaults to [md] + * @param top Padding for the top edge. Defaults to [md] + * @param end Padding for the end edge (right in LTR, left in RTL). Defaults to [md] + * @param bottom Padding for the bottom edge. Defaults to [md] + * @return [PaddingValues] configured with the specified spacing for each edge + * + * @see KptSpacing + */ +@Composable +fun KptSpacing.paddingValues( + start: Dp = md, + top: Dp = md, + end: Dp = md, + bottom: Dp = md, +): PaddingValues = PaddingValues(start = start, top = top, end = end, bottom = bottom) + +@Composable +fun KptTypography.toMaterial3Typography(fontFamily: FontFamily? = FontFamily.Default): Typography { + return Typography( + displayLarge = this.displayLarge.copy(fontFamily = fontFamily), + displayMedium = this.displayMedium.copy(fontFamily = fontFamily), + displaySmall = this.displaySmall.copy(fontFamily = fontFamily), + headlineLarge = this.headlineLarge.copy(fontFamily = fontFamily), + headlineMedium = this.headlineMedium.copy(fontFamily = fontFamily), + headlineSmall = this.headlineSmall.copy( + fontFamily = fontFamily, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Bottom, + trim = Trim.None, + ), + ), + titleLarge = this.titleLarge.copy( + fontFamily = fontFamily, + fontWeight = FontWeight.Bold, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Bottom, + trim = Trim.LastLineBottom, + ), + ), + titleMedium = this.titleMedium.copy( + fontFamily = fontFamily, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + ), + titleSmall = this.titleSmall.copy(fontFamily = fontFamily), + bodyLarge = this.bodyLarge.copy( + fontFamily = fontFamily, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = Trim.None, + ), + ), + bodyMedium = this.bodyMedium.copy(fontFamily = fontFamily), + bodySmall = this.bodySmall.copy(fontFamily = fontFamily), + labelLarge = this.labelLarge.copy( + fontFamily = fontFamily, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = Trim.LastLineBottom, + ), + ), + labelMedium = this.labelMedium.copy( + fontFamily = fontFamily, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = Trim.LastLineBottom, + ), + ), + labelSmall = this.labelSmall.copy( + fontFamily = fontFamily, + fontSize = 10.sp, + lineHeight = 14.sp, + letterSpacing = 0.sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = Trim.LastLineBottom, + ), + ), + ) +} + +fun Typography.toKptTypography(fontFamily: FontFamily? = FontFamily.Default): KptTypography = KptTypographyImpl( + displayLarge = this.displayLarge.copy(fontFamily = fontFamily), + displayMedium = this.displayMedium.copy(fontFamily = fontFamily), + displaySmall = this.displaySmall.copy(fontFamily = fontFamily), + headlineLarge = this.headlineLarge.copy(fontFamily = fontFamily), + headlineMedium = this.headlineMedium.copy(fontFamily = fontFamily), + headlineSmall = this.headlineSmall.copy( + fontFamily = fontFamily, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Bottom, + trim = Trim.None, + ), + ), + titleLarge = this.titleLarge.copy( + fontFamily = fontFamily, + fontWeight = FontWeight.Bold, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Bottom, + trim = Trim.LastLineBottom, + ), + ), + titleMedium = this.titleMedium.copy( + fontFamily = fontFamily, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + ), + titleSmall = this.titleSmall.copy(fontFamily = fontFamily), + bodyLarge = this.bodyLarge.copy( + fontFamily = fontFamily, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = Trim.None, + ), + ), + bodyMedium = this.bodyMedium.copy(fontFamily = fontFamily), + bodySmall = this.bodySmall.copy(fontFamily = fontFamily), + labelLarge = this.labelLarge.copy( + fontFamily = fontFamily, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = Trim.LastLineBottom, + ), + ), + labelMedium = this.labelMedium.copy( + fontFamily = fontFamily, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = Trim.LastLineBottom, + ), + ), + labelSmall = this.labelSmall.copy( + fontFamily = fontFamily, + fontSize = 10.sp, + lineHeight = 14.sp, + letterSpacing = 0.sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = Trim.LastLineBottom, + ), + ), +) + +/** + * Extension function to convert KptTypography to Material3 Typography + * This ensures that all Material3 components automatically use KptTheme typography + */ +fun KptTypography.toMaterial3Typography(): Typography { + return Typography( + displayLarge = this.displayLarge, + displayMedium = this.displayMedium, + displaySmall = this.displaySmall, + headlineLarge = this.headlineLarge, + headlineMedium = this.headlineMedium, + headlineSmall = this.headlineSmall, + titleLarge = this.titleLarge, + titleMedium = this.titleMedium, + titleSmall = this.titleSmall, + bodyLarge = this.bodyLarge, + bodyMedium = this.bodyMedium, + bodySmall = this.bodySmall, + labelLarge = this.labelLarge, + labelMedium = this.labelMedium, + labelSmall = this.labelSmall, + ) +} + +fun Typography.toKptTypography(): KptTypography = KptTypographyImpl( + displayLarge = this.displayLarge, + displayMedium = this.displayMedium, + displaySmall = this.displaySmall, + headlineLarge = this.headlineLarge, + headlineMedium = this.headlineMedium, + headlineSmall = this.headlineSmall, + titleLarge = this.titleLarge, + titleMedium = this.titleMedium, + titleSmall = this.titleSmall, + bodyLarge = this.bodyLarge, + bodyMedium = this.bodyMedium, + bodySmall = this.bodySmall, + labelLarge = this.labelLarge, + labelMedium = this.labelMedium, + labelSmall = this.labelSmall, +) + +/** + * Extension function to convert KptColorScheme to Material3 ColorScheme + * This ensures that all Material3 components automatically use KptTheme colors + */ +@Composable +fun KptColorScheme.toMaterial3ColorScheme(): ColorScheme { + return ColorScheme( + primary = this.primary, + onPrimary = this.onPrimary, + primaryContainer = this.primaryContainer, + onPrimaryContainer = this.onPrimaryContainer, + inversePrimary = this.inversePrimary, + secondary = this.secondary, + onSecondary = this.onSecondary, + secondaryContainer = this.secondaryContainer, + onSecondaryContainer = this.onSecondaryContainer, + tertiary = this.tertiary, + onTertiary = this.onTertiary, + tertiaryContainer = this.tertiaryContainer, + onTertiaryContainer = this.onTertiaryContainer, + background = this.background, + onBackground = this.onBackground, + surface = this.surface, + onSurface = this.onSurface, + surfaceVariant = this.surfaceVariant, + onSurfaceVariant = this.onSurfaceVariant, + surfaceTint = this.primary, + inverseSurface = this.inverseSurface, + inverseOnSurface = this.inverseOnSurface, + error = this.error, + onError = this.onError, + errorContainer = this.errorContainer, + onErrorContainer = this.onErrorContainer, + outline = this.outline, + outlineVariant = this.outlineVariant, + scrim = this.scrim, + surfaceBright = this.surfaceBright, + surfaceDim = this.surfaceDim, + surfaceContainer = this.surfaceContainer, + surfaceContainerHigh = this.surfaceContainerHigh, + surfaceContainerHighest = this.surfaceContainerHighest, + surfaceContainerLow = this.surfaceContainerLow, + surfaceContainerLowest = this.surfaceContainerLowest, + ) +} + +fun ColorScheme.toKptColorScheme(): KptColorScheme = KptColorSchemeImpl( + primary = this.primary, + onPrimary = this.onPrimary, + primaryContainer = this.primaryContainer, + onPrimaryContainer = this.onPrimaryContainer, + inversePrimary = this.inversePrimary, + secondary = this.secondary, + onSecondary = this.onSecondary, + secondaryContainer = this.secondaryContainer, + onSecondaryContainer = this.onSecondaryContainer, + tertiary = this.tertiary, + onTertiary = this.onTertiary, + tertiaryContainer = this.tertiaryContainer, + onTertiaryContainer = this.onTertiaryContainer, + background = this.background, + onBackground = this.onBackground, + surface = this.surface, + onSurface = this.onSurface, + surfaceVariant = this.surfaceVariant, + onSurfaceVariant = this.onSurfaceVariant, + surfaceTint = this.surfaceTint, + inverseSurface = this.inverseSurface, + inverseOnSurface = this.inverseOnSurface, + error = this.error, + onError = this.onError, + errorContainer = this.errorContainer, + onErrorContainer = this.onErrorContainer, + outline = this.outline, + outlineVariant = this.outlineVariant, + scrim = this.scrim, + surfaceBright = this.surfaceBright, + surfaceDim = this.surfaceDim, + surfaceContainer = this.surfaceContainer, + surfaceContainerHigh = this.surfaceContainerHigh, + surfaceContainerHighest = this.surfaceContainerHighest, + surfaceContainerLow = this.surfaceContainerLow, + surfaceContainerLowest = this.surfaceContainerLowest, +) + +/** + * Extension function to convert KptShapes to Material3 Shapes + * This ensures that all Material3 components automatically use KptTheme shapes + */ +fun KptShapes.toMaterial3Shapes(): Shapes { + return Shapes( + extraSmall = this.extraSmall, + small = this.small, + medium = this.medium, + large = this.large, + extraLarge = this.extraLarge, + ) +} + +fun Shapes.toKptShapes(): KptShapes = KptShapesImpl( + extraSmall = this.extraSmall, + small = this.small, + medium = this.medium, + large = this.large, + extraLarge = this.extraLarge, +) + +/** + * Get CardDefaults.cardElevation using KptTheme elevation + */ +@Composable +fun KptElevation.cardElevation( + defaultElevation: Dp = level1, + pressedElevation: Dp = level2, + focusedElevation: Dp = level2, + hoveredElevation: Dp = level2, + draggedElevation: Dp = level4, + disabledElevation: Dp = level0, +): CardElevation = CardDefaults.cardElevation( + defaultElevation = defaultElevation, + pressedElevation = pressedElevation, + focusedElevation = focusedElevation, + hoveredElevation = hoveredElevation, + draggedElevation = draggedElevation, + disabledElevation = disabledElevation, +) + +/** + * Predefined spacing combinations for common UI patterns. + * + * This object provides convenient access to commonly used padding configurations + * that follow design system best practices. Use these instead of hardcoded values + * to maintain consistency across the application. + * + * Example usage: + * ``` + * // Apply standard screen padding + * Column( + * modifier = Modifier.padding(KptSpacingDefaults.screenPadding()) + * ) { + * // Screen content + * } + * + * // Apply card content padding + * Card { + * Column( + * modifier = Modifier.padding(KptSpacingDefaults.cardPadding()) + * ) { + * // Card content + * } + * } + * ``` + */ +object KptSpacingDefaults { + /** + * Standard padding for screen-level content. + * Horizontal: lg (24dp), Vertical: md (16dp) + * Best for: Main screen content, page layouts + */ + @Composable + fun screenPadding() = KptTheme.spacing.paddingValues( + horizontal = KptTheme.spacing.lg, + vertical = KptTheme.spacing.md, + ) + + /** + * Standard padding for card content. + * Horizontal: md (16dp), Vertical: sm (8dp) + * Best for: Content inside cards, list items + */ + @Composable + fun cardPadding() = KptTheme.spacing.paddingValues( + horizontal = KptTheme.spacing.md, + vertical = KptTheme.spacing.sm, + ) + + /** + * Standard padding for button content. + * Horizontal: lg (24dp), Vertical: sm (8dp) + * Best for: Button internal padding, touch targets + */ + @Composable + fun buttonPadding() = KptTheme.spacing.paddingValues( + horizontal = KptTheme.spacing.lg, + vertical = KptTheme.spacing.sm, + ) +} + +/** + * Predefined elevation configurations for common UI patterns. + * + * This object provides semantically meaningful elevation presets that follow + * Material Design elevation guidelines. Use these to maintain consistent + * visual hierarchy throughout the application. + * + * Example usage: + * ``` + * // Standard card elevation + * Card(elevation = KptElevationDefaults.card()) { + * // Card content + * } + * + * // Prominent card for important content + * Card(elevation = KptElevationDefaults.raisedCard()) { + * // Important content + * } + * ``` + */ +object KptElevationDefaults { + /** + * Standard card elevation for normal content. + * Default: level1 (1dp), Pressed: level2 (3dp) + * Best for: Regular cards, list items, content containers + */ + @Composable + fun card() = KptTheme.elevation.cardElevation( + defaultElevation = KptTheme.elevation.level1, + pressedElevation = KptTheme.elevation.level2, + ) + + /** + * Elevated card for prominent content. + * Default: level3 (6dp), Pressed: level4 (8dp) + * Best for: Featured content, important cards, floating panels + */ + @Composable + fun raisedCard() = KptTheme.elevation.cardElevation( + defaultElevation = KptTheme.elevation.level3, + pressedElevation = KptTheme.elevation.level4, + ) + + /** + * High elevation for modal content. + * Default: level5 (12dp) + * Best for: Dialogs, modal bottom sheets, overlays + */ + @Composable + fun dialogCard() = KptTheme.elevation.cardElevation( + defaultElevation = KptTheme.elevation.level5, + ) +} + +/** + * Provides convenient access to container color combinations. + * + * This extension property groups related container colors and their corresponding + * content colors for easy access. Container colors are typically used for + * backgrounds, surfaces, and filled components. + * + * Example usage: + * ``` + * val colors = KptTheme.colorScheme.containerColors + * + * Card( + * colors = CardDefaults.cardColors( + * containerColor = colors.primary, + * contentColor = colors.onPrimary + * ) + * ) { + * // Card content + * } + * ``` + * + * @see ContainerColors + * @see KptColorScheme + */ +@get:Composable +val KptColorScheme.containerColors: ContainerColors + get() = ContainerColors( + primary = primaryContainer, + onPrimary = onPrimaryContainer, + secondary = secondaryContainer, + onSecondary = onSecondaryContainer, + tertiary = tertiaryContainer, + onTertiary = onTertiaryContainer, + error = errorContainer, + onError = onErrorContainer, + ) + +/** + * A collection of container colors and their corresponding content colors. + * + * This data class groups semantically related color pairs to make it easier + * to apply consistent color schemes to components that need both background + * and foreground colors. + * + * @param primary Primary container background color + * @param onPrimary Color for content on primary container backgrounds + * @param secondary Secondary container background color + * @param onSecondary Color for content on secondary container backgrounds + * @param tertiary Tertiary container background color + * @param onTertiary Color for content on tertiary container backgrounds + * @param error Error container background color + * @param onError Color for content on error container backgrounds + * + * @see KptColorScheme.containerColors + */ +data class ContainerColors( + val primary: Color, + val onPrimary: Color, + val secondary: Color, + val onSecondary: Color, + val tertiary: Color, + val onTertiary: Color, + val error: Color, + val onError: Color, +) diff --git a/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/component/BounceAnimation.kt b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/component/BounceAnimation.kt new file mode 100644 index 0000000000..f9104b9a04 --- /dev/null +++ b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/component/BounceAnimation.kt @@ -0,0 +1,174 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.designsystem.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import org.jetbrains.compose.ui.tooling.preview.Preview +import template.core.base.designsystem.KptTheme + +/** + * A composable that briefly enlarges the content to create a bounce effect when triggered. + * + * @param targetValue The peak scale value during bounce. + * @param durationMillis The duration of the bounce animation in milliseconds. + * @param content A composable lambda that receives the animated scale value. + * + * @sample BounceAnimationPreview + */ +@Composable +fun BounceAnimation( + targetValue: Float = 1.1f, + durationMillis: Int = 100, + content: @Composable (scale: Float) -> Unit, +) { + var triggered by remember { mutableStateOf(false) } + val scale by animateFloatAsState( + targetValue = if (triggered) targetValue else 1f, + animationSpec = tween(durationMillis, easing = FastOutSlowInEasing), + finishedListener = { triggered = false }, + label = "bounce_animation", + ) + + LaunchedEffect(Unit) { + triggered = true + } + + content(scale) +} + +/** + * A composable that reveals or hides content with a combination of fade and scale animations. + * + * @param visible Controls whether the content is shown or hidden. + * @param modifier Modifier applied to the container. + * @param animationSpec The animation spec used for fade and scale effects. + * @param content The composable content to animate. + * + * @sample RevealAnimationPreview + */ +@Composable +fun RevealAnimation( + visible: Boolean, + modifier: Modifier = Modifier, + animationSpec: FiniteAnimationSpec = KptAnimationSpecs.medium, + content: @Composable () -> Unit, +) { + AnimatedVisibility( + visible = visible, + modifier = modifier, + enter = fadeIn(animationSpec) + scaleIn(animationSpec, initialScale = 0.8f), + exit = fadeOut(animationSpec) + scaleOut(animationSpec, targetScale = 0.8f), + ) { + content() + } +} + +/** + * Animates a list of items with a staggered vertical slide-in and fade-in effect. + * + * @param items The list of items to animate. + * @param modifier Modifier applied to the wrapping Column. + * @param staggerDelayMillis Delay in milliseconds between each item's animation. + * @param content A composable that renders each item with index. + * + * @sample StaggeredAnimationPreview + */ +@Composable +fun StaggeredAnimation( + items: List, + modifier: Modifier = Modifier, + staggerDelayMillis: Long = 100, + content: @Composable (item: T, index: Int) -> Unit, +) { + Column(modifier = modifier) { + items.forEachIndexed { index, item -> + var visible by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + delay(index * staggerDelayMillis) + visible = true + } + + AnimatedVisibility( + visible = visible, + enter = slideInVertically( + initialOffsetY = { it }, + animationSpec = tween(300, easing = KptAnimationSpecs.emphasizedEasing), + ) + fadeIn(animationSpec = tween(300)), + ) { + content(item, index) + } + } + } +} + +@Preview +@Composable +private fun BounceAnimationPreview() { + KptTheme { + BounceAnimation { + Text( + text = "Bouncy!", + modifier = Modifier.padding(16.dp), + ) + } + } +} + +@Preview +@Composable +private fun RevealAnimationPreview() { + KptTheme { + var show by remember { mutableStateOf(true) } + RevealAnimation(visible = show) { + Text( + text = "Revealed!", + modifier = Modifier.padding(16.dp), + ) + } + } +} + +@Preview +@Composable +private fun StaggeredAnimationPreview() { + KptTheme { + StaggeredAnimation(items = listOf("One", "Two", "Three")) { item, _ -> + Text( + text = item, + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + ) + } + } +} diff --git a/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/component/KptAnimationSpecs.kt b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/component/KptAnimationSpecs.kt new file mode 100644 index 0000000000..eb57a3b51d --- /dev/null +++ b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/component/KptAnimationSpecs.kt @@ -0,0 +1,204 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.designsystem.component + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.IntOffset + +/** + * Centralized animation specifications following Material Motion design guidelines. + * + * This object provides consistent animation timing and easing curves throughout the KPT design system. + * All animations should use these predefined specifications to ensure visual consistency. + * + * The specifications are organized into: + * - **Duration-based animations**: [fast], [medium], [slow] with tween interpolation + * - **Spring-based animations**: [fastSpring], [mediumSpring], [slowSpring] with physics simulation + * - **Material Motion easing**: Standard easing curves from Material Design guidelines + * + * Example usage: + * ``` + * // For simple property animations + * val animatedAlpha by animateFloatAsState( + * targetValue = if (visible) 1f else 0f, + * animationSpec = KptAnimationSpecs.medium + * ) + * + * // For spring-based animations + * val animatedScale by animateFloatAsState( + * targetValue = if (pressed) 0.95f else 1f, + * animationSpec = KptAnimationSpecs.fastSpring + * ) + * ``` + * + * @see androidx.compose.animation.core.AnimationSpec + */ +object KptAnimationSpecs { + /** + * Fast tween animation (150ms) for quick transitions like state changes. + * Best used for: button states, small UI element appearances/disappearances. + */ + val fast = tween(durationMillis = 150, easing = FastOutSlowInEasing) + + /** + * Medium tween animation (300ms) for standard UI transitions. + * Best used for: screen transitions, modal appearances, content changes. + */ + val medium = tween(durationMillis = 300, easing = FastOutSlowInEasing) + + /** + * Slow tween animation (500ms) for complex or large-scale transitions. + * Best used for: page transitions, complex layout changes, dramatic effects. + */ + val slow = tween(durationMillis = 500, easing = FastOutSlowInEasing) + + /** + * Fast spring animation with medium bounce for responsive interactions. + * Best used for: button presses, interactive feedback, quick selections. + */ + val fastSpring = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessHigh, + ) + + /** + * Medium spring animation with low bounce for smooth transitions. + * Best used for: drawer openings, sheet expansions, smooth scrolling effects. + */ + val mediumSpring = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessMedium, + ) + + /** + * Slow spring animation with no bounce for stable, smooth animations. + * Best used for: large content movements, settling animations, smooth stops. + */ + val slowSpring = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessLow, + ) + + // Material Motion easing curves following Material Design 3 specifications + + /** + * Emphasized easing for important transitions that should draw attention. + * Creates a slow start with a quick finish. + */ + val emphasizedEasing = CubicBezierEasing(0.2f, 0.0f, 0.0f, 1.0f) + + /** + * Emphasized accelerate easing for elements leaving the screen. + * Quick start that maintains momentum. + */ + val emphasizedAccelerate = CubicBezierEasing(0.3f, 0.0f, 0.8f, 0.15f) + + /** + * Emphasized decelerate easing for elements entering the screen. + * Maintains momentum then slows to a smooth stop. + */ + val emphasizedDecelerate = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1.0f) + + /** + * Standard easing for general purpose animations. + * Provides a balanced, natural feeling motion. + */ + val standardEasing = CubicBezierEasing(0.2f, 0.0f, 0.0f, 1.0f) +} + +/** + * Slide in animation from the start edge of the screen (left in LTR, right in RTL). + * + * This extension function provides a convenient way to create slide-in animations + * that respect the current layout direction. + * + * @param animationSpec The animation specification to use. Defaults to 300ms with emphasized easing. + * @return An [EnterTransition] that slides content in from the start edge + * + * @see slideInFromEnd + * @see AnimatedVisibilityScope + */ +@Composable +fun AnimatedVisibilityScope.slideInFromStart( + animationSpec: FiniteAnimationSpec = tween( + 300, + easing = KptAnimationSpecs.emphasizedEasing, + ), +): EnterTransition = slideInHorizontally(animationSpec) { -it } + +/** + * Slide in animation from the end edge of the screen (right in LTR, left in RTL). + * + * This extension function provides a convenient way to create slide-in animations + * that respect the current layout direction. + * + * @param animationSpec The animation specification to use. Defaults to 300ms with emphasized easing. + * @return An [EnterTransition] that slides content in from the end edge + * + * @see slideInFromStart + * @see AnimatedVisibilityScope + */ +@Composable +fun AnimatedVisibilityScope.slideInFromEnd( + animationSpec: FiniteAnimationSpec = tween( + 300, + easing = KptAnimationSpecs.emphasizedEasing, + ), +): EnterTransition = slideInHorizontally(animationSpec) { it } + +/** + * Slide in animation from the top edge of the screen. + * + * Creates a smooth vertical slide-in effect commonly used for notifications, + * drop-down menus, or top-anchored content. + * + * @param animationSpec The animation specification to use. Defaults to 300ms with emphasized easing. + * @return An [EnterTransition] that slides content in from the top + * + * @see slideInFromBottom + * @see AnimatedVisibilityScope + */ +@Composable +fun AnimatedVisibilityScope.slideInFromTop( + animationSpec: FiniteAnimationSpec = tween( + 300, + easing = KptAnimationSpecs.emphasizedEasing, + ), +): EnterTransition = slideInVertically(animationSpec) { -it } + +/** + * Slide in animation from the bottom edge of the screen. + * + * Creates a smooth vertical slide-in effect commonly used for bottom sheets, + * action panels, or bottom-anchored content. + * + * @param animationSpec The animation specification to use. Defaults to 300ms with emphasized easing. + * @return An [EnterTransition] that slides content in from the bottom + * + * @see slideInFromTop + * @see AnimatedVisibilityScope + */ +@Composable +fun AnimatedVisibilityScope.slideInFromBottom( + animationSpec: FiniteAnimationSpec = tween( + 300, + easing = KptAnimationSpecs.emphasizedEasing, + ), +): EnterTransition = slideInVertically(animationSpec) { it } diff --git a/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/component/KptShimmerLoadingBox.kt b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/component/KptShimmerLoadingBox.kt new file mode 100644 index 0000000000..5c993ed58f --- /dev/null +++ b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/component/KptShimmerLoadingBox.kt @@ -0,0 +1,103 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.designsystem.component + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +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.graphics.Shape +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import template.core.base.designsystem.theme.KptTheme + +@Composable +fun KptShimmerLoadingBox( + modifier: Modifier = Modifier, + shape: Shape = KptTheme.shapes.small, + shimmerColor: Color = KptTheme.colorScheme.surfaceVariant, + highlightColor: Color = KptTheme.colorScheme.surface, +) { + val infiniteTransition = rememberInfiniteTransition(label = "shimmer") + val shimmerTranslateAnim by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 1000f, + animationSpec = infiniteRepeatable( + animation = tween(1000, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "shimmer_translate", + ) + + Box( + modifier = modifier + .background( + brush = androidx.compose.ui.graphics.Brush.horizontalGradient( + colors = listOf( + shimmerColor, + highlightColor, + shimmerColor, + ), + startX = shimmerTranslateAnim - 200f, + endX = shimmerTranslateAnim, + ), + shape = shape, + ) + .testTag("KptShimmerLoadingBox"), + ) +} + +@Composable +fun KptShimmerListItem( + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + KptShimmerLoadingBox( + modifier = Modifier + .size(40.dp), + shape = CircleShape, + ) + Spacer(modifier = Modifier.width(16.dp)) + Column { + KptShimmerLoadingBox( + modifier = Modifier + .height(16.dp) + .fillMaxWidth(0.7f), + ) + Spacer(modifier = Modifier.height(8.dp)) + KptShimmerLoadingBox( + modifier = Modifier + .height(12.dp) + .fillMaxWidth(0.5f), + ) + } + } +} diff --git a/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/component/KptSnackbarHost.kt b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/component/KptSnackbarHost.kt new file mode 100644 index 0000000000..aeb451220c --- /dev/null +++ b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/component/KptSnackbarHost.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.designsystem.component + +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarData +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag + +@Composable +fun KptSnackbarHost( + hostState: SnackbarHostState, + modifier: Modifier = Modifier, + snackbar: @Composable (SnackbarData) -> Unit = { Snackbar(it) }, +) { + SnackbarHost( + hostState = hostState, + modifier = modifier.testTag("KptSnackbarHost"), + snackbar = snackbar, + ) +} + +suspend fun SnackbarHostState.showKptSnackbar(config: SnackbarConfiguration): SnackbarResult { + return showSnackbar( + message = config.message, + actionLabel = config.actionLabel, + duration = config.duration, + withDismissAction = config.withDismissAction, + ) +} + +@Immutable +data class SnackbarConfiguration( + val message: String, + val actionLabel: String? = null, + val duration: SnackbarDuration = SnackbarDuration.Short, + val withDismissAction: Boolean = false, + val onActionClick: (() -> Unit)? = null, +) diff --git a/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/component/KptTopAppBar.kt b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/component/KptTopAppBar.kt new file mode 100644 index 0000000000..f69ed446ab --- /dev/null +++ b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/component/KptTopAppBar.kt @@ -0,0 +1,394 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +@file:OptIn(ExperimentalMaterial3Api::class) + +package template.core.base.designsystem.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LargeTopAppBar +import androidx.compose.material3.MediumTopAppBar +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import template.core.base.designsystem.core.KptTopAppBarConfiguration +import template.core.base.designsystem.core.TopAppBarAction +import template.core.base.designsystem.core.TopAppBarVariant +import template.core.base.designsystem.theme.KptTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun KptTopAppBar(configuration: KptTopAppBarConfiguration) { + val finalModifier = configuration.modifier + .testTag(configuration.testTag ?: "KptTopAppBar") + .let { mod -> + if (configuration.contentDescription != null) { + mod.semantics { contentDescription = configuration.contentDescription } + } else { + mod + } + } + + val titleContent: @Composable () -> Unit = { + Column { + Text( + text = configuration.title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + configuration.subtitle?.let { subtitle -> + Text( + text = subtitle, + style = KptTheme.typography.bodySmall, + color = KptTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + + val navigationIconContent: @Composable () -> Unit = { + configuration.navigationIcon?.let { icon -> + IconButton( + onClick = configuration.onNavigationIonClick ?: {}, + enabled = configuration.onNavigationIonClick != null, + ) { + Icon( + imageVector = icon, + contentDescription = "Navigation", + ) + } + } + } + + val actionsContent: @Composable RowScope.() -> Unit = { + configuration.actions.forEach { action -> + IconButton( + onClick = action.onClick, + enabled = action.enabled, + ) { + Icon( + imageVector = action.icon, + contentDescription = action.contentDescription, + ) + } + } + } + + when (configuration.variant) { + TopAppBarVariant.Small -> TopAppBar( + title = titleContent, + modifier = finalModifier, + navigationIcon = navigationIconContent, + actions = actionsContent, + windowInsets = configuration.windowInsets ?: TopAppBarDefaults.windowInsets, + colors = configuration.colors ?: TopAppBarDefaults.topAppBarColors(), + scrollBehavior = configuration.scrollBehavior, + ) + + TopAppBarVariant.CenterAligned -> CenterAlignedTopAppBar( + title = titleContent, + modifier = finalModifier, + navigationIcon = navigationIconContent, + actions = actionsContent, + windowInsets = configuration.windowInsets ?: TopAppBarDefaults.windowInsets, + colors = configuration.colors ?: TopAppBarDefaults.centerAlignedTopAppBarColors(), + scrollBehavior = configuration.scrollBehavior, + ) + + TopAppBarVariant.Medium -> MediumTopAppBar( + title = titleContent, + modifier = finalModifier, + navigationIcon = navigationIconContent, + actions = actionsContent, + windowInsets = configuration.windowInsets ?: TopAppBarDefaults.windowInsets, + colors = configuration.colors ?: TopAppBarDefaults.mediumTopAppBarColors(), + scrollBehavior = configuration.scrollBehavior, + ) + + TopAppBarVariant.Large -> LargeTopAppBar( + title = titleContent, + modifier = finalModifier, + navigationIcon = navigationIconContent, + actions = actionsContent, + windowInsets = configuration.windowInsets ?: TopAppBarDefaults.windowInsets, + colors = configuration.colors ?: TopAppBarDefaults.largeTopAppBarColors(), + scrollBehavior = configuration.scrollBehavior, + ) + } +} + +@Composable +fun KptTopAppBar( + title: String, + modifier: Modifier = Modifier, + variant: TopAppBarVariant = TopAppBarVariant.Small, +) { + KptTopAppBar( + KptTopAppBarConfiguration( + title = title, + modifier = modifier, + variant = variant, + ), + ) +} + +@Composable +fun KptTopAppBar( + title: String, + onNavigationIconClick: () -> Unit, + modifier: Modifier = Modifier, + navigationIcon: ImageVector = Icons.AutoMirrored.Filled.ArrowBack, + variant: TopAppBarVariant = TopAppBarVariant.Small, + actions: List = emptyList(), +) { + KptTopAppBar( + KptTopAppBarConfiguration( + title = title, + modifier = modifier, + variant = variant, + navigationIcon = navigationIcon, + onNavigationIonClick = onNavigationIconClick, + actions = actions, + ), + ) +} + +@Composable +fun KptTopAppBar( + title: String, + showNavigationIcon: Boolean, + onNavigationIconClick: () -> Unit, + modifier: Modifier = Modifier, + navigationIcon: ImageVector = Icons.AutoMirrored.Filled.ArrowBack, + variant: TopAppBarVariant = TopAppBarVariant.Small, + actions: List = emptyList(), +) { + KptTopAppBar( + KptTopAppBarConfiguration( + title = title, + modifier = modifier, + variant = variant, + navigationIcon = if (showNavigationIcon) navigationIcon else null, + onNavigationIonClick = onNavigationIconClick, + actions = actions, + ), + ) +} + +@Composable +fun KptTopAppBar( + title: String, + subtitle: String, + modifier: Modifier = Modifier, + onNavigationIconClick: (() -> Unit)? = null, + navigationIcon: ImageVector? = if (onNavigationIconClick != null) Icons.AutoMirrored.Filled.ArrowBack else null, + variant: TopAppBarVariant = TopAppBarVariant.Small, +) { + KptTopAppBar( + KptTopAppBarConfiguration( + title = title, + subtitle = subtitle, + modifier = modifier, + variant = variant, + navigationIcon = navigationIcon, + onNavigationIonClick = onNavigationIconClick, + ), + ) +} + +@Composable +fun KptTopAppBar( + title: String, + actionIcon: ImageVector, + onActionClick: () -> Unit, + modifier: Modifier = Modifier, + actionContentDescription: String = "Action", + onNavigationIconClick: (() -> Unit)? = null, + navigationIcon: ImageVector? = if (onNavigationIconClick != null) Icons.AutoMirrored.Filled.ArrowBack else null, + variant: TopAppBarVariant = TopAppBarVariant.Small, +) { + KptTopAppBar( + KptTopAppBarConfiguration( + title = title, + modifier = modifier, + variant = variant, + navigationIcon = navigationIcon, + onNavigationIonClick = onNavigationIconClick, + actions = listOf( + TopAppBarAction(actionIcon, actionContentDescription, onActionClick), + ), + ), + ) +} + +@Composable +fun KptSearchAppBar( + searchQuery: String, + onSearchQueryChange: (String) -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, + placeholder: String = "Search...", + onSearchClick: (() -> Unit)? = null, +) { + TopAppBar( + title = { + OutlinedTextField( + value = searchQuery, + onValueChange = onSearchQueryChange, + placeholder = { Text(placeholder) }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = "Search") }, + trailingIcon = if (searchQuery.isNotEmpty()) { + { + IconButton(onClick = { onSearchQueryChange("") }) { + Icon(Icons.Default.Clear, contentDescription = "Clear") + } + } + } else { + null + }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + actions = { + onSearchClick?.let { onClick -> + IconButton(onClick = onClick) { + Icon(Icons.Default.Search, contentDescription = "Search") + } + } + }, + modifier = modifier.testTag("KptSearchAppBar"), + ) +} + +@Composable +fun KptProfileAppBar( + title: String, + onProfileClick: () -> Unit, + modifier: Modifier = Modifier, + subtitle: String? = null, + onNavigationIconClick: (() -> Unit)? = null, +) { + KptTopAppBar( + KptTopAppBarConfiguration( + title = title, + subtitle = subtitle, + modifier = modifier, + navigationIcon = if (onNavigationIconClick != null) Icons.AutoMirrored.Filled.ArrowBack else null, + onNavigationIonClick = onNavigationIconClick, + actions = listOf( + TopAppBarAction( + icon = Icons.Default.AccountCircle, + contentDescription = "Profile", + onClick = onProfileClick, + ), + ), + ), + ) +} + +@Composable +fun KptSettingsAppBar( + title: String = "Settings", + onNavigationIconClick: () -> Unit, + modifier: Modifier = Modifier, + onSearchClick: (() -> Unit)? = null, + onMoreClick: (() -> Unit)? = null, +) { + val actions = mutableListOf() + + onSearchClick?.let { + actions.add(TopAppBarAction(Icons.Default.Search, "Search", it)) + } + + onMoreClick?.let { + actions.add(TopAppBarAction(Icons.Default.MoreVert, "More options", it)) + } + + KptTopAppBar( + KptTopAppBarConfiguration( + title = title, + modifier = modifier, + navigationIcon = Icons.AutoMirrored.Filled.ArrowBack, + onNavigationIonClick = onNavigationIconClick, + actions = actions, + ), + ) +} + +@Composable +fun KptSmallTopAppBar( + title: String, + onNavigationIconClick: (() -> Unit)? = null, + modifier: Modifier = Modifier, +) { + onNavigationIconClick?.let { + KptTopAppBar( + title = title, + onNavigationIconClick = it, + modifier = modifier, + variant = TopAppBarVariant.Small, + ) + } ?: KptTopAppBar(title, modifier, TopAppBarVariant.Small) +} + +@Composable +fun KptCenterAlignedTopAppBar( + title: String, + onNavigationIconClick: (() -> Unit)? = null, + modifier: Modifier = Modifier, +) = onNavigationIconClick?.let { + KptTopAppBar( + title = title, + onNavigationIconClick = it, + modifier = modifier, + variant = TopAppBarVariant.CenterAligned, + ) +} ?: KptTopAppBar(title, modifier, TopAppBarVariant.CenterAligned) + +@Composable +fun KptMediumTopAppBar( + title: String, + onNavigationIconClick: (() -> Unit)? = null, + modifier: Modifier = Modifier, +) = KptTopAppBar(title, modifier, TopAppBarVariant.Medium) + +@Composable +fun KptLargeTopAppBar( + title: String, + onNavigationIconClick: (() -> Unit)? = null, + modifier: Modifier = Modifier, +) = KptTopAppBar(title, modifier, TopAppBarVariant.Large) diff --git a/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/component/SlideTransition.kt b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/component/SlideTransition.kt new file mode 100644 index 0000000000..8c5632beb4 --- /dev/null +++ b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/component/SlideTransition.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.designsystem.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.slideOutVertically +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.IntOffset + +@Composable +fun KptSlideTransition( + visible: Boolean, + direction: SlideDirection = SlideDirection.Left, + modifier: Modifier = Modifier, + animationSpec: FiniteAnimationSpec = tween( + 300, + easing = KptAnimationSpecs.emphasizedEasing, + ), + content: @Composable AnimatedVisibilityScope.() -> Unit, +) { + AnimatedVisibility( + visible = visible, + modifier = modifier, + enter = when (direction) { + SlideDirection.Left -> slideInHorizontally(animationSpec) { -it } + SlideDirection.Right -> slideInHorizontally(animationSpec) { it } + SlideDirection.Up -> slideInVertically(animationSpec) { -it } + SlideDirection.Down -> slideInVertically(animationSpec) { it } + }, + exit = when (direction) { + SlideDirection.Left -> slideOutHorizontally(animationSpec) { -it } + SlideDirection.Right -> slideOutHorizontally(animationSpec) { it } + SlideDirection.Up -> slideOutVertically(animationSpec) { -it } + SlideDirection.Down -> slideOutVertically(animationSpec) { it } + }, + content = content, + ) +} + +enum class SlideDirection { Left, Right, Up, Down } diff --git a/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/core/ComponentStateHolder.kt b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/core/ComponentStateHolder.kt new file mode 100644 index 0000000000..e973855677 --- /dev/null +++ b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/core/ComponentStateHolder.kt @@ -0,0 +1,113 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.designsystem.core + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue + +/** + * A concrete implementation of [ComponentState] that holds and manages component state. + * + * This class provides a thread-safe way to hold and update state values within components, + * with automatic recomposition when the state changes. The state is observable by Compose + * and will trigger recomposition of any composables that read the [value]. + * + * The state holder is marked as [Stable], meaning Compose can make assumptions about + * when it changes and optimize recomposition accordingly. + * + * Example usage: + * ``` + * class MyComponentState(initialExpanded: Boolean) { + * private val _expanded = ComponentStateHolder(initialExpanded) + * val expanded: ComponentState = _expanded + * + * fun toggleExpanded() { + * _expanded.update(!_expanded.value) + * } + * } + * ``` + * + * @param T The type of value this state holder manages + * @param initialValue The initial value for this state + * + * @see ComponentState + * @see rememberComponentState + */ +@Stable +class ComponentStateHolder(initialValue: T) : ComponentState { + /** + * The current value of the state. Reading this property in a composable + * will cause that composable to recompose when the value changes. + * + * The setter is private to ensure state changes go through [update], + * which provides a clear API for state mutations. + */ + override var value by mutableStateOf(initialValue) + private set + + /** + * Updates the state with a new value. + * + * This will trigger recomposition of any composables that read [value]. + * The update is performed immediately and synchronously. + * + * @param newValue The new value to set + */ + override fun update(newValue: T) { + value = newValue + } +} + +/** + * Remembers a [ComponentState] instance across recompositions. + * + * This composable function creates a [ComponentStateHolder] that survives recomposition, + * ensuring state is preserved when the composable is recomposed but not when the + * composable is completely removed from the composition. + * + * The state will be recreated if the composition is completely rebuilt or if + * the key used in the remember call changes. + * + * Example usage: + * ``` + * @Composable + * fun ExpandableCard() { + * val expandedState = rememberComponentState(initialValue = false) + * + * Card( + * modifier = Modifier.clickable { + * expandedState.update(!expandedState.value) + * } + * ) { + * if (expandedState.value) { + * DetailContent() + * } else { + * SummaryContent() + * } + * } + * } + * ``` + * + * @param T The type of value the state will hold + * @param initialValue The initial value for the state + * @return A [ComponentState] instance that persists across recompositions + * + * @see ComponentState + * @see ComponentStateHolder + * @see androidx.compose.runtime.remember + */ +@Composable +fun rememberComponentState(initialValue: T): ComponentState { + return remember { ComponentStateHolder(initialValue) } +} diff --git a/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/core/KptComponent.kt b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/core/KptComponent.kt new file mode 100644 index 0000000000..baa9e2dc65 --- /dev/null +++ b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/core/KptComponent.kt @@ -0,0 +1,204 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.designsystem.core + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.shape.CornerBasedShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import kotlin.reflect.KClass + +interface KptComponent { + val testTag: String? + val contentDescription: String? + val modifier: Modifier +} + +interface Clickable { + val onClick: () -> Unit + val enabled: Boolean + val interactionSource: MutableInteractionSource? +} + +interface Styleable { + val colors: ComponentColors? + val shape: Shape? + val elevation: ComponentElevation? +} + +interface Themeable { + val theme: ComponentTheme? +} + +interface ComponentColors + +interface ComponentElevation + +interface ComponentTheme + +interface ThemeStrategy { + fun applyTheme(component: KptComponent): ComponentTheme +} + +interface ComponentFactory { + fun create(configuration: ComponentConfiguration): T +} + +interface ComponentConfiguration { + fun build(): KptComponent +} + +@Stable +interface ComponentState { + val value: T + fun update(newValue: T) +} + +sealed interface ComponentVariant { + val name: String + val isEnabled: Boolean get() = true +} + +interface ComponentComposer { + @Composable + fun compose(components: List): Unit +} + +interface Animatable { + val animationDuration: Long + val animationEasing: androidx.compose.animation.core.Easing? +} + +interface AccessibilityProvider { + val semantics: androidx.compose.ui.semantics.SemanticsPropertyReceiver.() -> Unit + val contentDescription: String? + val role: androidx.compose.ui.semantics.Role? +} + +interface KptThemeProvider { + val colors: KptColorScheme + val typography: KptTypography + val shapes: KptShapes + val spacing: KptSpacing + val elevation: KptElevation +} + +@Stable +interface KptColorScheme { + val primary: Color + val onPrimary: Color + val primaryContainer: Color + val onPrimaryContainer: Color + val inversePrimary: Color + val secondary: Color + val onSecondary: Color + val secondaryContainer: Color + val onSecondaryContainer: Color + val tertiary: Color + val onTertiary: Color + val tertiaryContainer: Color + val onTertiaryContainer: Color + val background: Color + val onBackground: Color + val surface: Color + val onSurface: Color + val surfaceVariant: Color + val onSurfaceVariant: Color + val surfaceTint: Color + val inverseSurface: Color + val inverseOnSurface: Color + val error: Color + val onError: Color + val errorContainer: Color + val onErrorContainer: Color + val outline: Color + val outlineVariant: Color + val scrim: Color + val surfaceBright: Color + val surfaceDim: Color + val surfaceContainer: Color + val surfaceContainerHigh: Color + val surfaceContainerHighest: Color + val surfaceContainerLow: Color + val surfaceContainerLowest: Color +} + +@Stable +interface KptTypography { + val displayLarge: androidx.compose.ui.text.TextStyle + val displayMedium: androidx.compose.ui.text.TextStyle + val displaySmall: androidx.compose.ui.text.TextStyle + val headlineLarge: androidx.compose.ui.text.TextStyle + val headlineMedium: androidx.compose.ui.text.TextStyle + val headlineSmall: androidx.compose.ui.text.TextStyle + val titleLarge: androidx.compose.ui.text.TextStyle + val titleMedium: androidx.compose.ui.text.TextStyle + val titleSmall: androidx.compose.ui.text.TextStyle + val bodyLarge: androidx.compose.ui.text.TextStyle + val bodyMedium: androidx.compose.ui.text.TextStyle + val bodySmall: androidx.compose.ui.text.TextStyle + val labelLarge: androidx.compose.ui.text.TextStyle + val labelMedium: androidx.compose.ui.text.TextStyle + val labelSmall: androidx.compose.ui.text.TextStyle +} + +@Stable +interface KptShapes { + val extraSmall: CornerBasedShape + val small: CornerBasedShape + val medium: CornerBasedShape + val large: CornerBasedShape + val extraLarge: CornerBasedShape +} + +@Stable +interface KptSpacing { + val xs: Dp + val sm: Dp + val md: Dp + val lg: Dp + val xl: Dp + val xxl: Dp +} + +@Stable +interface KptElevation { + val level0: Dp + val level1: Dp + val level2: Dp + val level3: Dp + val level4: Dp + val level5: Dp +} + +interface ComponentRenderer { + @Composable + fun render(component: T) +} + +interface ComponentRegistry { + fun register(type: KClass, renderer: ComponentRenderer) + fun getRenderer(type: KClass): ComponentRenderer? +} + +@DslMarker +annotation class ComponentDsl + +@ComponentDsl +interface ComponentConfigurationScope { + var testTag: String? + var contentDescription: String? + var enabled: Boolean + var modifier: Modifier +} diff --git a/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/core/KptTopAppBarConfiguration.kt b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/core/KptTopAppBarConfiguration.kt new file mode 100644 index 0000000000..c0781973ef --- /dev/null +++ b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/core/KptTopAppBarConfiguration.kt @@ -0,0 +1,291 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.designsystem.core + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TopAppBarColors +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector + +/** + * Defines the visual variants available for the KPT top app bar. + * + * Each variant corresponds to a different Material3 top app bar style with + * different visual characteristics and use cases. + * + * @see KptTopAppBarConfiguration + */ +sealed interface TopAppBarVariant : ComponentVariant { + override val name: String + + /** + * Standard compact top app bar suitable for most screens. + * Height: 64dp + * Best for: Regular screens with standard content + */ + data object Small : TopAppBarVariant { + override val name: String = "small" + } + + /** + * Center-aligned top app bar with title centered horizontally. + * Height: 64dp + * Best for: Modal screens, settings, or when centering is preferred + */ + data object CenterAligned : TopAppBarVariant { + override val name: String = "center_aligned" + } + + /** + * Medium height top app bar that provides more visual prominence. + * Height: 112dp + * Best for: Secondary screens, categories, or when more emphasis is needed + */ + data object Medium : TopAppBarVariant { + override val name: String = "medium" + } + + /** + * Large height top app bar for maximum visual impact. + * Height: 152dp + * Best for: Main screens, landing pages, or hero sections + */ + data object Large : TopAppBarVariant { + override val name: String = "large" + } +} + +/** + * Configuration class that defines all properties for a KPT top app bar. + * + * This immutable data class encapsulates all the customization options for the top app bar, + * providing a clean API for complex configurations. It supports all Material3 top app bar + * features while adding KPT-specific enhancements. + * + * Example usage: + * ``` + * val config = KptTopAppBarConfiguration( + * title = "My Screen", + * variant = TopAppBarVariant.Large, + * navigationIcon = Icons.AutoMirrored.Filled.ArrowBack, + * onNavigationIonClick = { navController.navigateUp() }, + * actions = listOf( + * TopAppBarAction( + * icon = Icons.Default.Search, + * contentDescription = "Search", + * onClick = { openSearch() } + * ) + * ), + * subtitle = "Optional subtitle" + * ) + * ``` + * + * @param title The primary title text displayed in the app bar + * @param modifier Modifier to be applied to the app bar + * @param variant The visual variant of the app bar (Small, CenterAligned, Medium, Large) + * @param navigationIcon Optional icon for navigation (typically back arrow) + * @param onNavigationIonClick Optional click handler for the navigation icon + * @param actions List of action buttons to display on the right side + * @param subtitle Optional secondary text displayed below the title + * @param colors Optional custom colors for the app bar + * @param scrollBehavior Optional scroll behavior for collapsing/expanding + * @param windowInsets Optional window insets for proper spacing + * @param testTag Optional test tag for UI testing + * @param contentDescription Optional content description for accessibility + * + * @see TopAppBarVariant + * @see TopAppBarAction + */ +@OptIn(ExperimentalMaterial3Api::class) +@Immutable +data class KptTopAppBarConfiguration( + val title: String, + val modifier: Modifier = Modifier, + val variant: TopAppBarVariant = TopAppBarVariant.Small, + val navigationIcon: ImageVector? = null, + val onNavigationIonClick: (() -> Unit)? = null, + val actions: List = emptyList(), + val subtitle: String? = null, + val colors: TopAppBarColors? = null, + val scrollBehavior: TopAppBarScrollBehavior? = null, + val windowInsets: WindowInsets? = null, + val testTag: String? = null, + val contentDescription: String? = null, +) + +/** + * Represents an action button in the top app bar. + * + * Action buttons are displayed on the right side of the top app bar and provide + * quick access to common functions like search, menu, or other contextual actions. + * + * @param icon The vector icon to display for this action + * @param contentDescription Accessibility description for screen readers + * @param onClick Callback invoked when the action is clicked + * @param enabled Whether the action button is enabled and clickable + * + * @see KptTopAppBarConfiguration + */ +data class TopAppBarAction( + val icon: ImageVector, + val contentDescription: String, + val onClick: () -> Unit, + val enabled: Boolean = true, +) + +@DslMarker +annotation class TopAppBarDsl + +/** + * DSL builder class for creating [KptTopAppBarConfiguration] instances. + * + * This builder provides a fluent API for configuring top app bars with a clean, + * readable syntax. All properties have sensible defaults and can be customized + * as needed. + * + * Example usage: + * ``` + * val config = kptTopAppBar { + * title = "Settings" + * variant = TopAppBarVariant.Large + * navigationIcon = Icons.AutoMirrored.Filled.ArrowBack + * onNavigationClick = { navController.navigateUp() } + * + * action(Icons.Default.Search, "Search") { openSearch() } + * action(Icons.Default.MoreVert, "More options") { showMenu() } + * + * subtitle = "Customize your experience" + * testTag = "SettingsTopAppBar" + * } + * ``` + * + * @see kptTopAppBar + * @see KptTopAppBarConfiguration + */ +@TopAppBarDsl +class KptTopAppBarBuilder { + /** The primary title text for the app bar */ + var title: String = "" + + /** Modifier to apply to the app bar */ + var modifier: Modifier = Modifier + + /** Visual variant of the app bar */ + var variant: TopAppBarVariant = TopAppBarVariant.Small + + /** Optional navigation icon (typically back arrow) */ + var navigationIcon: ImageVector? = null + + /** Click handler for the navigation icon */ + var onNavigationClick: (() -> Unit)? = null + + /** Optional secondary text below the title */ + var subtitle: String? = null + + /** Custom colors for the app bar */ + @OptIn(ExperimentalMaterial3Api::class) + var colors: TopAppBarColors? = null + + /** Scroll behavior for collapsing/expanding */ + @OptIn(ExperimentalMaterial3Api::class) + var scrollBehavior: TopAppBarScrollBehavior? = null + + /** Window insets for proper spacing */ + @OptIn(ExperimentalMaterial3Api::class) + var windowInsets: WindowInsets? = null + + /** Test tag for UI testing */ + var testTag: String? = null + + /** Content description for accessibility */ + var contentDescription: String? = null + + private val actionsList = mutableListOf() + + /** + * Adds an action button to the app bar. + * + * Action buttons are displayed on the right side of the app bar in the order + * they are added. Keep the number of actions minimal for better UX. + * + * @param icon The vector icon to display + * @param contentDescription Accessibility description + * @param enabled Whether the action is enabled + * @param onClick Callback when the action is clicked + */ + fun action( + icon: ImageVector, + contentDescription: String, + enabled: Boolean = true, + onClick: () -> Unit, + ) { + actionsList.add(TopAppBarAction(icon, contentDescription, onClick, enabled)) + } + + /** + * Builds the final [KptTopAppBarConfiguration] with all specified properties. + * + * @return A configured [KptTopAppBarConfiguration] instance + */ + @OptIn(ExperimentalMaterial3Api::class) + fun build(): KptTopAppBarConfiguration = KptTopAppBarConfiguration( + title = title, + modifier = modifier, + variant = variant, + navigationIcon = navigationIcon, + onNavigationIonClick = onNavigationClick, + actions = actionsList.toList(), + subtitle = subtitle, + colors = colors, + scrollBehavior = scrollBehavior, + windowInsets = windowInsets, + testTag = testTag, + contentDescription = contentDescription, + ) +} + +/** + * DSL function for creating a [KptTopAppBarConfiguration] using a builder pattern. + * + * This function provides a convenient way to configure top app bars with a clean, + * type-safe DSL syntax. All configuration is done within the builder block. + * + * Example usage: + * ``` + * val topAppBarConfig = kptTopAppBar { + * title = "My Screen" + * variant = TopAppBarVariant.Medium + * navigationIcon = Icons.AutoMirrored.Filled.ArrowBack + * onNavigationClick = { navController.navigateUp() } + * + * action(Icons.Default.Search, "Search") { + * // Handle search + * } + * + * action(Icons.Default.Favorite, "Add to favorites") { + * // Handle favorite + * } + * } + * + * KptTopAppBar(topAppBarConfig) + * ``` + * + * @param block Configuration block for building the top app bar + * @return A fully configured [KptTopAppBarConfiguration] + * + * @see KptTopAppBarBuilder + * @see KptTopAppBarConfiguration + */ +fun kptTopAppBar(block: KptTopAppBarBuilder.() -> Unit): KptTopAppBarConfiguration { + return KptTopAppBarBuilder().apply(block).build() +} diff --git a/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/AdaptiveListDetailPaneScaffold.kt b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/AdaptiveListDetailPaneScaffold.kt new file mode 100644 index 0000000000..a8df05c03e --- /dev/null +++ b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/AdaptiveListDetailPaneScaffold.kt @@ -0,0 +1,115 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.designsystem.layout + +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold +import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole +import androidx.compose.material3.adaptive.layout.PaneExpansionState +import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldPaneScope +import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldScope +import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator +import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import kotlinx.coroutines.launch + +/** + * A layout scaffold for adaptive list-detail navigation using Material 3's [ListDetailPaneScaffold]. + * + * This composable allows you to build responsive UIs with two primary panes: a main (list) pane and + * a detail pane, with an optional third pane. It handles the internal navigation between panes using + * a [ThreePaneScaffoldNavigator], which allows toggling between list-only, detail-only, or side-by-side layouts + * depending on screen size and device posture. + * + * The provided [mainPaneContent] and [detailPaneContent] are composable lambdas that define the UI for each pane. + * You can invoke the passed lambda (`navigateToDetail` or `navigateBack`) to trigger navigation. + * + * @param mainPaneContent The main list pane content. Use the provided `navigateToDetail: () -> Unit` callback + * to programmatically transition to the detail pane. + * + * @param detailPaneContent The detail pane content. Use the provided `navigateBack: () -> Unit` callback + * to programmatically navigate back to the list pane (if supported by screen layout). + * + * ## Example: + * ``` + * AdaptiveListDetailPaneScaffold( + * mainPaneContent = { navigateToDetail -> + * LazyColumn { + * items(itemsList) { item -> + * ListItem( + * headlineText = { Text(item.title) }, + * modifier = Modifier.clickable { navigateToDetail() } + * ) + * } + * } + * }, + * detailPaneContent = { navigateBack -> + * Column { + * Text("Detail view") + * Button(onClick = navigateBack) { Text("Back") } + * } + * } + * ) + * ``` + * + * @param modifier Modifier applied to the root [ListDetailPaneScaffold]. + * @param navigator The [ThreePaneScaffoldNavigator] to control pane transitions. + * Defaults to a [rememberListDetailPaneScaffoldNavigator] instance. + * @param extraPaneContent Optional content for a third pane, shown when screen size allows. + * @param paneExpansionDragHandle Optional drag handle composable for resizing panes interactively. + * @param paneExpansionState Optional override for the scaffold's expansion state. + * @param testTag Optional testTag for the root ListDetailPaneScaffold. + * + * @see androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold + * @see androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator + */ + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +fun AdaptiveListDetailPaneScaffold( + mainPaneContent: @Composable ThreePaneScaffoldPaneScope.(navigateToDetail: () -> Unit) -> Unit, + detailPaneContent: @Composable ThreePaneScaffoldPaneScope.(navigateBack: () -> Unit) -> Unit, + modifier: Modifier = Modifier, + navigator: ThreePaneScaffoldNavigator = rememberListDetailPaneScaffoldNavigator(), + extraPaneContent: @Composable ThreePaneScaffoldPaneScope.() -> Unit = {}, + paneExpansionDragHandle: @Composable (ThreePaneScaffoldScope.(PaneExpansionState) -> Unit)? = null, + paneExpansionState: PaneExpansionState? = null, + testTag: String? = null, +) { + val scope = rememberCoroutineScope() + + ListDetailPaneScaffold( + directive = navigator.scaffoldDirective, + value = navigator.scaffoldValue, + listPane = { + mainPaneContent { + scope.launch { + navigator.navigateTo(ListDetailPaneScaffoldRole.Detail) + } + } + }, + detailPane = { + detailPaneContent { + scope.launch { + if (navigator.canNavigateBack()) { + navigator.navigateBack() + } + } + } + }, + extraPane = extraPaneContent, + modifier = modifier.then(Modifier.testTag(testTag ?: "KptAdaptiveListDetailPaneScaffold")), + paneExpansionDragHandle = paneExpansionDragHandle, + paneExpansionState = paneExpansionState, + ) +} diff --git a/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/AdaptiveNavigableListDetailScaffold.kt b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/AdaptiveNavigableListDetailScaffold.kt new file mode 100644 index 0000000000..f416f712bd --- /dev/null +++ b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/AdaptiveNavigableListDetailScaffold.kt @@ -0,0 +1,353 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.designsystem.layout + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.selection.selectable +import androidx.compose.material3.Card +import androidx.compose.material3.CardColors +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CardElevation +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.layout.AnimatedPane +import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold +import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole +import androidx.compose.material3.adaptive.layout.PaneAdaptedValue +import androidx.compose.material3.adaptive.layout.PaneExpansionState +import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldPaneScope +import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldScope +import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.backhandler.BackHandler +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch + +/** + * A generic, adaptive list-detail layout scaffold for responsive UIs in CMP applications. + * + * This scaffold supports navigation between a list and a detail view in an adaptive layout, + * adjusting its layout behavior based on screen size (e.g., displaying panes side-by-side or stacked). + * + * The list and detail panes accept composables that support animated shared transitions and visibility scopes. + * Interaction and pane transitions (e.g., selecting an item, going back) are handled internally, + * enabling consumers to focus only on content composition. + * + * ## Example usage: + * ```kotlin + * AdaptiveNavigableListDetailPaneScaffold( + * items = myItems, + * listPaneItem = { item, isListAndDetailVisible, isListVisible, sharedTransitionScope, visibilityScope -> + * Text(text = item.title) + * }, + * detailPaneContent = { item, isListAndDetailVisible, isDetailVisible, sharedTransitionScope, visibilityScope -> + * Text(text = item.details) + * } + * ) + * ``` + * + * @param items The list of items to render in the list pane. + * @param listPaneItem The composable content for each list item inside a card. + * Selection and navigation logic are handled internally. + * @param detailPaneContent The composable content for the selected item in the detail pane. + * @param modifier Modifier applied to the root of the scaffold layout. *(Optional)* + * @param extraPaneContent Optional content for a third pane (e.g., settings, metadata). *(Optional)* + * @param paneExpansionDragHandle Optional UI element for resizing panes interactively. *(Optional)* + * @param paneExpansionState Optional state controller for pane expansion. Defaults to internal handling. *(Optional)* + * @param cardShape Optional shape to override the default card shape for list items. *(Optional)* + * @param cardElevation Optional elevation to override the default card elevation for list items. *(Optional)* + * @param cardColors Optional colors to override default card colors for list items. *(Optional)* + * @param cardBorder Optional border to override default list item card border behavior. *(Optional)* + * @param testTag Optional testTag for the root of the scaffold layout. + * + * @see ListDetailPaneScaffold for platform-level behavior and layout management. + * @see SelectionVisibilityState for selection handling behavior. + */ + +@OptIn( + ExperimentalMaterial3AdaptiveApi::class, + ExperimentalComposeUiApi::class, + ExperimentalSharedTransitionApi::class, +) +@Composable +fun > AdaptiveNavigableListDetailPaneScaffold( + items: List, + listPaneItem: @Composable ( + // The item to display in the list pane + item: T, + isListAndDetailVisible: Boolean, + isListVisible: Boolean, + sharedTransitionScope: SharedTransitionScope, + animatedVisibilityScope: AnimatedVisibilityScope, + ) -> Unit, + detailPaneContent: @Composable ( + // The selected item to display in the detail pane + item: T, + isListAndDetailVisible: Boolean, + isDetailVisible: Boolean, + sharedTransitionScope: SharedTransitionScope, + animatedVisibilityScope: AnimatedVisibilityScope, + ) -> Unit, + modifier: Modifier = Modifier, + extraPaneContent: @Composable (ThreePaneScaffoldPaneScope.() -> Unit)? = null, + paneExpansionDragHandle: @Composable (ThreePaneScaffoldScope.(PaneExpansionState) -> Unit)? = null, + paneExpansionState: PaneExpansionState? = null, + cardShape: Shape? = null, + cardElevation: CardElevation? = null, + cardColors: CardColors? = null, + cardBorder: BorderStroke? = null, + testTag: String? = null, +) { + var selectedItemIndex: Int? by rememberSaveable { mutableStateOf(null) } + val navigator = rememberListDetailPaneScaffoldNavigator() + val scope = rememberCoroutineScope() + val isListAndDetailVisible = + navigator.scaffoldValue[ListDetailPaneScaffoldRole.Detail] == PaneAdaptedValue.Expanded && + navigator.scaffoldValue[ListDetailPaneScaffoldRole.List] == PaneAdaptedValue.Expanded + + BackHandler(enabled = navigator.canNavigateBack()) { + scope.launch { + navigator.navigateBack() + } + } + + SharedTransitionLayout { + AnimatedContent(targetState = isListAndDetailVisible, label = "AdaptiveListDetailLayout") { + ListDetailPaneScaffold( + directive = navigator.scaffoldDirective, + value = navigator.scaffoldValue, + listPane = { + val currentSelectedItemIndex = selectedItemIndex + val isDetailVisible = + navigator.scaffoldValue[ListDetailPaneScaffoldRole.Detail] == PaneAdaptedValue.Expanded + AnimatedPane { + ListContent( + items = items, + selectionState = if (isDetailVisible && currentSelectedItemIndex != null) { + SelectionVisibilityState.ShowSelection(currentSelectedItemIndex) + } else { + SelectionVisibilityState.NoSelection + }, + onIndexClick = { index -> + selectedItemIndex = index + scope.launch { + navigator.navigateTo(ListDetailPaneScaffoldRole.Detail) + } + }, + isListAndDetailVisible = isListAndDetailVisible, + isListVisible = !isDetailVisible, + animatedVisibilityScope = this@AnimatedPane, + sharedTransitionScope = this@SharedTransitionLayout, + listPaneItem = listPaneItem, + cardShape = cardShape, + cardElevation = cardElevation, + cardColors = cardColors, + cardBorder = cardBorder, + testTag = testTag, + ) + } + }, + detailPane = { + val selectedItem = selectedItemIndex?.let(items::get) ?: items[0] + val isDetailVisible = + navigator.scaffoldValue[ListDetailPaneScaffoldRole.Detail] == PaneAdaptedValue.Expanded + AnimatedPane { + detailPaneContent( + selectedItem, + isListAndDetailVisible, + isDetailVisible, + this@SharedTransitionLayout, + this@AnimatedPane, + ) + } + }, + extraPane = extraPaneContent, + modifier = modifier.then(Modifier.testTag(testTag ?: "KptAdaptiveListDetailScaffold")), + paneExpansionDragHandle = paneExpansionDragHandle, + paneExpansionState = paneExpansionState, + ) + } + } +} + +/** + * A reusable list pane layout for adaptive list-detail scaffolds with support for selection and transitions. + * + * This composable is designed to display a vertically scrolling list of items with built-in support + * for selection states and animated transitions. Each item is displayed within a [Card], and the content + * of the card is provided by the [listPaneItem] composable lambda. + * + * Handles interaction logic (clickable/selectable behavior), transition animation scopes, + * and visual differentiation for selected items. + * + * @param items The list of items to render in the list pane. + * @param selectionState Controls whether an item is selected and how it should be visually represented. + * @param onIndexClick Callback invoked when an item is clicked. Passes the selected index. + * @param isListAndDetailVisible True if both list and detail panes are shown side-by-side. + * @param isListVisible True if the list pane is currently visible (not hidden in compact layouts). + * @param sharedTransitionScope Scope for shared element transitions between list and detail. + * @param animatedVisibilityScope Scope for managing animated enter/exit transitions. + * @param modifier Modifier applied to the outer [LazyColumn]. + * @param listPaneItem Composable content lambda for each item, receiving animation and layout context. + * @param cardShape Optional shape override for the item card. + * @param cardElevation Optional elevation override for the item card. + * @param cardColors Optional colors override for the item card. + * @param cardBorder Optional border override for the item card. + * @param testTag Optional testTag for the root of the list content. + * + * @see SelectionVisibilityState for controlling selection behavior. + */ + +@Suppress("CyclomaticComplexMethod") +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +private fun > ListContent( + items: List, + selectionState: SelectionVisibilityState, + onIndexClick: (index: Int) -> Unit, + isListAndDetailVisible: Boolean, + isListVisible: Boolean, + sharedTransitionScope: SharedTransitionScope, + animatedVisibilityScope: AnimatedVisibilityScope, + listPaneItem: @Composable ( + T, + Boolean, + Boolean, + SharedTransitionScope, + AnimatedVisibilityScope, + ) -> Unit, + modifier: Modifier = Modifier, + cardShape: Shape? = null, + cardElevation: CardElevation? = null, + cardColors: CardColors? = null, + cardBorder: BorderStroke? = null, + testTag: String? = null, +) { + LazyColumn( + contentPadding = PaddingValues(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier.then(Modifier.testTag(testTag ?: "KptAdaptiveListDetailList")), + ) { + itemsIndexed( + items = items, + key = { _, item -> item.id!! }, + ) { index, item -> + + val interactionModifier = when (selectionState) { + SelectionVisibilityState.NoSelection -> { + Modifier.clickable( + onClick = { onIndexClick(index) }, + ) + } + + is SelectionVisibilityState.ShowSelection -> { + Modifier.selectable( + selected = index == selectionState.selectedItemIndex, + onClick = { onIndexClick(index) }, + ) + } + } + + val containerColor = when (selectionState) { + SelectionVisibilityState.NoSelection -> MaterialTheme.colorScheme.surface + is SelectionVisibilityState.ShowSelection -> + if (index == selectionState.selectedItemIndex) { + MaterialTheme.colorScheme.surfaceVariant + } else { + MaterialTheme.colorScheme.surface + } + } + + val borderStroke = when (selectionState) { + SelectionVisibilityState.NoSelection -> cardBorder ?: BorderStroke( + 1.dp, + MaterialTheme.colorScheme.outline, + ) + + is SelectionVisibilityState.ShowSelection -> + if (index == selectionState.selectedItemIndex) { + null + } else { + cardBorder ?: BorderStroke( + 1.dp, + MaterialTheme.colorScheme.outline, + ) + } + } + + Card( + colors = cardColors?.copy(containerColor = containerColor) + ?: CardDefaults.cardColors(containerColor = containerColor), + border = borderStroke, + elevation = cardElevation ?: CardDefaults.cardElevation(), + shape = cardShape ?: CardDefaults.shape, + modifier = Modifier + .then(interactionModifier) + .fillMaxWidth() + .testTag("KptAdaptiveListDetailItem_$index"), + ) { + listPaneItem( + item, + isListAndDetailVisible, + isListVisible, + sharedTransitionScope, + animatedVisibilityScope, + ) + } + } + } +} + +/** + * Describes the current selection state for the list pane within an adaptive layout. + * + * Used to determine how list items should behave (clickable vs. selectable) and how they are styled. + */ +sealed interface SelectionVisibilityState { + + /** + * No selection should be shown, and each item should be clickable. + */ + data object NoSelection : SelectionVisibilityState + + /** + * Selection state should be shown, and each item should be selectable. + */ + data class ShowSelection( + /** + * The index of the word that is selected. + */ + val selectedItemIndex: Int, + ) : SelectionVisibilityState +} + +interface PaneScaffoldItem { + val id: T +} diff --git a/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/AdaptiveNavigableSupportingPaneScaffold.kt b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/AdaptiveNavigableSupportingPaneScaffold.kt new file mode 100644 index 0000000000..200a4087e1 --- /dev/null +++ b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/AdaptiveNavigableSupportingPaneScaffold.kt @@ -0,0 +1,103 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.designsystem.layout + +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.layout.PaneExpansionState +import androidx.compose.material3.adaptive.layout.SupportingPaneScaffold +import androidx.compose.material3.adaptive.layout.SupportingPaneScaffoldRole +import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldPaneScope +import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldScope +import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator +import androidx.compose.material3.adaptive.navigation.rememberSupportingPaneScaffoldNavigator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.backhandler.BackHandler +import androidx.compose.ui.platform.testTag +import kotlinx.coroutines.launch + +/** + * A composable layout for adaptive UIs that implements a navigable two-pane structure + * using Material 3's [SupportingPaneScaffold]. It handles navigation between the main and supporting panes, + * optionally including a third extra pane for additional content. + * + * This layout automatically adapts to screen size and orientation, making it suitable for + * responsive UIs across phones, tablets, and foldables. It also handles back navigation + * internally via [BackHandler] integration. + * + * @param mainPaneContent The main pane content, typically representing the primary screen or list. + * This lambda receives a navigation callback that should be invoked to trigger the transition to the supporting pane. + * + * @param supportingPaneContent The content of the supporting pane, shown after navigation. + * This lambda receives a back navigation callback for returning to the main pane. + * + * @param modifier The [Modifier] applied to the scaffold layout. *(Default: [Modifier])* + * + * @param scaffoldNavigator Optional external [ThreePaneScaffoldNavigator] to control pane navigation. + * If not provided, an internal one is created using [rememberSupportingPaneScaffoldNavigator]. + * + * @param extraPaneContent Optional content for the third pane (e.g., info panel or context pane). *(Default: empty)* + * + * @param paneExpansionDragHandle Optional composable used for displaying a draggable divider + * between panes for manual expansion. *(Default: null)* + * + * @param paneExpansionState Optional [PaneExpansionState] to control and observe pane expansion behavior. + * If not provided, a state will be remembered internally based on scaffold layout state. + * + * @param testTag Optional testTag for the root SupportingPaneScaffold. If not provided, a default tag is used. + */ + +@OptIn(ExperimentalMaterial3AdaptiveApi::class, ExperimentalComposeUiApi::class) +@Composable +fun AdaptiveNavigableSupportingPaneScaffold( + mainPaneContent: @Composable ThreePaneScaffoldPaneScope.(navigateToSupporting: () -> Unit) -> Unit, + supportingPaneContent: @Composable ThreePaneScaffoldPaneScope.(navigateBack: () -> Unit) -> Unit, + modifier: Modifier = Modifier, + scaffoldNavigator: ThreePaneScaffoldNavigator = rememberSupportingPaneScaffoldNavigator(), + extraPaneContent: @Composable ThreePaneScaffoldPaneScope.() -> Unit = {}, + paneExpansionDragHandle: @Composable (ThreePaneScaffoldScope.(PaneExpansionState) -> Unit)? = null, + paneExpansionState: PaneExpansionState? = null, + testTag: String? = null, +) { + val scope = rememberCoroutineScope() + + BackHandler(enabled = scaffoldNavigator.canNavigateBack()) { + scope.launch { + scaffoldNavigator.navigateBack() + } + } + + SupportingPaneScaffold( + directive = scaffoldNavigator.scaffoldDirective, + value = scaffoldNavigator.scaffoldValue, + mainPane = { + mainPaneContent { + scope.launch { + scaffoldNavigator.navigateTo(SupportingPaneScaffoldRole.Supporting) + } + } + }, + supportingPane = { + supportingPaneContent { + scope.launch { + if (scaffoldNavigator.canNavigateBack()) { + scaffoldNavigator.navigateBack() + } + } + } + }, + extraPane = extraPaneContent, + modifier = modifier.then(Modifier.testTag(testTag ?: "KptAdaptiveNavigableSupportingPaneScaffold")), + paneExpansionDragHandle = paneExpansionDragHandle, + paneExpansionState = paneExpansionState, + ) +} diff --git a/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/AdaptiveNavigationSuiteScaffold.kt b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/AdaptiveNavigationSuiteScaffold.kt new file mode 100644 index 0000000000..80a548ce83 --- /dev/null +++ b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/AdaptiveNavigationSuiteScaffold.kt @@ -0,0 +1,87 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.designsystem.layout + +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteColors +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldDefaults +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScope +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag + +/** + * A responsive scaffold that adapts the navigation UI (drawer, rail, or bottom bar) + * based on the current window size and device posture. + * + * This composable wraps [NavigationSuiteScaffold] and automatically determines the most suitable + * navigation layout using [WindowSizeClass] and [WindowAdaptiveInfo]. It is ideal for + * creating adaptive applications that behave consistently across phones, tablets, and foldables. + * + * @param navigationSuiteItems A lambda used to define navigation destinations via [NavigationSuiteScope]. + * + * @param modifier Modifier to be applied to the scaffold. *(Default: [Modifier])* + * + * @param layoutType Optional override for the navigation layout type. + * If not provided, the layout is inferred automatically. *(Default: null)* + * + * @param navigationSuiteColors The color configuration for navigation components, + * such as rail or drawer. *(Default: [NavigationSuiteDefaults.colors()])* + * + * @param containerColor The background color of the scaffold container. + *(Default: [NavigationSuiteScaffoldDefaults.containerColor])* + * + * @param contentColor The color applied to content within the scaffold. + *(Default: [NavigationSuiteScaffoldDefaults.contentColor])* + * + * @param testTag Optional testTag for the root NavigationSuiteScaffold. + * + * @param content The main content of the screen displayed beside or below the navigation UI. + */ + +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) +@Composable +fun AdaptiveNavigationSuiteScaffold( + navigationSuiteItems: NavigationSuiteScope.() -> Unit, + modifier: Modifier = Modifier, + layoutType: NavigationSuiteType? = null, + navigationSuiteColors: NavigationSuiteColors = NavigationSuiteDefaults.colors(), + containerColor: Color = NavigationSuiteScaffoldDefaults.containerColor, + contentColor: Color = NavigationSuiteScaffoldDefaults.contentColor, + testTag: String? = null, + content: @Composable () -> Unit, +) { + val adaptiveInfo = currentWindowAdaptiveInfo() + val windowSizeClass = calculateWindowSizeClass() + + val customNavSuiteType = + layoutType ?: if (windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded) { + NavigationSuiteType.NavigationDrawer + } else { + NavigationSuiteScaffoldDefaults.calculateFromAdaptiveInfo(adaptiveInfo) + } + + NavigationSuiteScaffold( + modifier = modifier.then(Modifier.testTag(testTag ?: "KptAdaptiveNavigationSuiteScaffold")), + layoutType = customNavSuiteType, + navigationSuiteColors = navigationSuiteColors, + containerColor = containerColor, + contentColor = contentColor, + navigationSuiteItems = navigationSuiteItems, + content = content, + ) +} diff --git a/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/KptFlowColumn.kt b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/KptFlowColumn.kt new file mode 100644 index 0000000000..4f607da44f --- /dev/null +++ b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/KptFlowColumn.kt @@ -0,0 +1,104 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.designsystem.layout + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.util.fastForEach +import kotlin.math.max + +@Composable +fun KptFlowColumn( + modifier: Modifier = Modifier, + verticalArrangement: Arrangement.Vertical = Arrangement.Top, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + maxItemsInEachColumn: Int = Int.MAX_VALUE, + content: @Composable () -> Unit, +) { + Layout( + modifier = modifier.testTag("KptFlowColumn"), + content = content, + ) { measurables, constraints -> + val sequences = mutableListOf>() + val crossAxisSizes = mutableListOf() + val crossAxisPositions = mutableListOf() + + var mainAxisSpace = 0 + var crossAxisSpace = 0 + + val currentSequence = mutableListOf() + var currentMainAxisSize = 0 + var currentCrossAxisSize = 0 + + val childConstraints = Constraints(maxHeight = constraints.maxHeight) + + measurables.fastForEach { measurable -> + val placeable = measurable.measure(childConstraints) + + if (currentSequence.isNotEmpty() && + ( + currentMainAxisSize + placeable.height > constraints.maxHeight || + currentSequence.size >= maxItemsInEachColumn + ) + ) { + sequences += currentSequence.toList() + crossAxisSizes += currentCrossAxisSize + mainAxisSpace = max(mainAxisSpace, currentMainAxisSize) + crossAxisSpace += currentCrossAxisSize + + currentSequence.clear() + currentMainAxisSize = placeable.height + currentCrossAxisSize = placeable.width + } else { + currentMainAxisSize += placeable.height + currentCrossAxisSize = max(currentCrossAxisSize, placeable.width) + } + + currentSequence += placeable + } + + if (currentSequence.isNotEmpty()) { + sequences += currentSequence + crossAxisSizes += currentCrossAxisSize + mainAxisSpace = max(mainAxisSpace, currentMainAxisSize) + crossAxisSpace += currentCrossAxisSize + } + + val mainAxisLayoutSize = max(mainAxisSpace, constraints.minHeight) + val crossAxisLayoutSize = max(crossAxisSpace, constraints.minWidth) + + var crossAxisPosition = 0 + crossAxisSizes.fastForEach { size -> + crossAxisPositions += crossAxisPosition + crossAxisPosition += size + } + + layout(crossAxisLayoutSize, mainAxisLayoutSize) { + sequences.forEachIndexed { sequenceIndex, placeables -> + val childCrossAxisPosition = crossAxisPositions[sequenceIndex] + var childMainAxisPosition = 0 + + placeables.fastForEach { placeable -> + placeable.place( + x = childCrossAxisPosition, + y = childMainAxisPosition, + ) + childMainAxisPosition += placeable.height + } + } + } + } +} diff --git a/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/KptFlowRow.kt b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/KptFlowRow.kt new file mode 100644 index 0000000000..5ebd108ac4 --- /dev/null +++ b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/KptFlowRow.kt @@ -0,0 +1,104 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.designsystem.layout + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.util.fastForEach +import kotlin.math.max + +@Composable +fun KptFlowRow( + modifier: Modifier = Modifier, + horizontalArrangement: Arrangement.Horizontal = Arrangement.Start, + verticalAlignment: Alignment.Vertical = Alignment.Top, + maxItemsInEachRow: Int = Int.MAX_VALUE, + content: @Composable () -> Unit, +) { + Layout( + modifier = modifier.testTag("KptFlowRow"), + content = content, + ) { measurables, constraints -> + val sequences = mutableListOf>() + val crossAxisSizes = mutableListOf() + val crossAxisPositions = mutableListOf() + + var mainAxisSpace = 0 + var crossAxisSpace = 0 + + val currentSequence = mutableListOf() + var currentMainAxisSize = 0 + var currentCrossAxisSize = 0 + + val childConstraints = Constraints(maxWidth = constraints.maxWidth) + + measurables.fastForEach { measurable -> + val placeable = measurable.measure(childConstraints) + + if (currentSequence.isNotEmpty() && + ( + currentMainAxisSize + placeable.width > constraints.maxWidth || + currentSequence.size >= maxItemsInEachRow + ) + ) { + sequences += currentSequence.toList() + crossAxisSizes += currentCrossAxisSize + mainAxisSpace = max(mainAxisSpace, currentMainAxisSize) + crossAxisSpace += currentCrossAxisSize + + currentSequence.clear() + currentMainAxisSize = placeable.width + currentCrossAxisSize = placeable.height + } else { + currentMainAxisSize += placeable.width + currentCrossAxisSize = max(currentCrossAxisSize, placeable.height) + } + + currentSequence += placeable + } + + if (currentSequence.isNotEmpty()) { + sequences += currentSequence + crossAxisSizes += currentCrossAxisSize + mainAxisSpace = max(mainAxisSpace, currentMainAxisSize) + crossAxisSpace += currentCrossAxisSize + } + + val mainAxisLayoutSize = max(mainAxisSpace, constraints.minWidth) + val crossAxisLayoutSize = max(crossAxisSpace, constraints.minHeight) + + var crossAxisPosition = 0 + crossAxisPositions.fastForEach { size -> + crossAxisPositions += crossAxisPosition + crossAxisPosition += size + } + + layout(mainAxisLayoutSize, crossAxisLayoutSize) { + sequences.forEachIndexed { sequenceIndex, placeables -> + val childCrossAxisPosition = crossAxisPositions[sequenceIndex] + var childMainAxisPosition = 0 + + placeables.fastForEach { placeable -> + placeable.place( + x = childMainAxisPosition, + y = childCrossAxisPosition, + ) + childMainAxisPosition += placeable.width + } + } + } + } +} diff --git a/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/KptGrid.kt b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/KptGrid.kt new file mode 100644 index 0000000000..e563b27d39 --- /dev/null +++ b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/KptGrid.kt @@ -0,0 +1,184 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.designsystem.layout + +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.ParentDataModifier +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import androidx.compose.ui.util.fastMap +import androidx.compose.ui.util.fastMaxBy +import template.core.base.designsystem.theme.KptTheme +import kotlin.math.min + +@Composable +fun KptGrid( + modifier: Modifier = Modifier, + configuration: GridConfiguration = GridConfiguration( + spacing = KptTheme.spacing.md, + horizontalPadding = KptTheme.spacing.md, + ), + content: @Composable GridScope.() -> Unit, +) { + val density = LocalDensity.current + val columns = configuration.getColumnsForCurrentScreen() + + Layout( + modifier = modifier + .padding(horizontal = configuration.horizontalPadding) + .testTag("KptGrid"), + content = { + GridScopeImpl(columns, configuration.spacing).content() + }, + ) { measurables, constraints -> + val spacing = with(density) { configuration.spacing.roundToPx() } + val horizontalPadding = with(density) { configuration.horizontalPadding.roundToPx() } + + val availableWidth = constraints.maxWidth - horizontalPadding * 2 + val columnWidth = (availableWidth - spacing * (columns - 1)) / columns + + val placeables = measurables.fastMap { measurable -> + val gridItem = measurable.parentData as? GridItemData ?: GridItemData() + val itemColumns = min(gridItem.span, columns) + val itemWidth = columnWidth * itemColumns + spacing * (itemColumns - 1) + + measurable.measure( + constraints.copy( + minWidth = itemWidth, + maxWidth = itemWidth, + ), + ) + } + + val rows = mutableListOf>() + var currentRow = mutableListOf() + var currentRowColumns = 0 + + placeables.fastForEach { placeable -> + val gridItem = placeable.parentData as? GridItemData ?: GridItemData() + val itemColumns = min(gridItem.span, columns) + + if (currentRowColumns + itemColumns > columns) { + if (currentRow.isNotEmpty()) { + rows.add(currentRow) + currentRow = mutableListOf() + currentRowColumns = 0 + } + } + + currentRow.add(placeable) + currentRowColumns += itemColumns + } + + if (currentRow.isNotEmpty()) { + rows.add(currentRow) + } + + val rowHeights = rows.fastMap { row -> + row.fastMaxBy { it.height }?.height ?: 0 + } + + val totalHeight = rowHeights.sum() + spacing * (rows.size - 1).coerceAtLeast(0) + + layout(constraints.maxWidth, totalHeight) { + var yPosition = 0 + + rows.forEachIndexed { rowIndex, row -> + var xPosition = 0 + + row.fastForEach { placeable -> + placeable.place(xPosition, yPosition) + val gridItem = placeable.parentData as? GridItemData ?: GridItemData() + val itemColumns = min(gridItem.span, columns) + xPosition += columnWidth * itemColumns + spacing * itemColumns + } + + yPosition += rowHeights[rowIndex] + spacing + } + } + } +} + +interface GridScope { + fun Modifier.gridItem(span: Int = 1): Modifier +} + +private class GridScopeImpl( + private val columns: Int, + private val spacing: Dp, +) : GridScope { + override fun Modifier.gridItem(span: Int): Modifier { + return this.then( + GridItemModifier( + span = span.coerceIn(1, columns), + ), + ) + } +} + +private data class GridItemData( + val span: Int = 1, +) : ParentDataModifier { + override fun Density.modifyParentData(parentData: Any?): Any = this@GridItemData +} + +private data class GridItemModifier( + val span: Int, +) : ParentDataModifier { + override fun Density.modifyParentData(parentData: Any?): Any { + return GridItemData(span) + } +} + +@Immutable +data class GridConfiguration( + val spacing: Dp, + val horizontalPadding: Dp, + val columns: Int = 12, + val breakpoints: BreakpointConfiguration = BreakpointConfiguration(), +) { + @Composable + fun getColumnsForCurrentScreen(): Int { + val window = LocalWindowInfo.current + val screenWidth = window.containerSize.width.dp + + return when { + screenWidth >= breakpoints.xl -> breakpoints.xlColumns + screenWidth >= breakpoints.lg -> breakpoints.lgColumns + screenWidth >= breakpoints.md -> breakpoints.mdColumns + screenWidth >= breakpoints.sm -> breakpoints.smColumns + else -> breakpoints.xsColumns + } + } +} + +@Immutable +data class BreakpointConfiguration( + val xs: Dp = 0.dp, + val sm: Dp = 600.dp, + val md: Dp = 840.dp, + val lg: Dp = 1200.dp, + val xl: Dp = 1600.dp, + val xsColumns: Int = 4, + val smColumns: Int = 8, + val mdColumns: Int = 12, + val lgColumns: Int = 12, + val xlColumns: Int = 12, +) diff --git a/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/KptMasonryGrid.kt b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/KptMasonryGrid.kt new file mode 100644 index 0000000000..e3ddfc6ad8 --- /dev/null +++ b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/KptMasonryGrid.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.designsystem.layout + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.util.fastMap +import template.core.base.designsystem.theme.KptTheme + +@Composable +fun KptMasonryGrid( + columns: Int, + modifier: Modifier = Modifier, + spacing: Dp = KptTheme.spacing.sm, + content: @Composable () -> Unit, +) { + Layout( + modifier = modifier.testTag("KptMasonryGrid"), + content = content, + ) { measurables, constraints -> + val columnWidth = (constraints.maxWidth - spacing.roundToPx() * (columns - 1)) / columns + val itemConstraints = constraints.copy( + minWidth = columnWidth, + maxWidth = columnWidth, + ) + + val placeables = measurables.fastMap { it.measure(itemConstraints) } + val columnHeights = IntArray(columns) { 0 } + + val itemPlacements = placeables.fastMap { placeable -> + val shortestColumnIndex = columnHeights.withIndex().minBy { it.value }.index + val x = shortestColumnIndex * (columnWidth + spacing.roundToPx()) + val y = columnHeights[shortestColumnIndex] + + columnHeights[shortestColumnIndex] += placeable.height + spacing.roundToPx() + + Pair(x, y) + } + + val totalHeight = columnHeights.maxOrNull() ?: 0 + + layout(constraints.maxWidth, totalHeight) { + placeables.forEachIndexed { index, placeable -> + val (x, y) = itemPlacements[index] + placeable.place(x, y) + } + } + } +} diff --git a/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/KptResponsiveLayout.kt b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/KptResponsiveLayout.kt new file mode 100644 index 0000000000..4e9dc8848d --- /dev/null +++ b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/KptResponsiveLayout.kt @@ -0,0 +1,154 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.designsystem.layout + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * A responsive layout composable that adapts content based on screen size breakpoints. + * + * This component automatically selects the appropriate layout based on the current screen width: + * - **Compact**: < 600dp (phones in portrait, small tablets) + * - **Medium**: 600-840dp (tablets, phones in landscape) + * - **Expanded**: ≥ 840dp (large tablets, desktop) + * + * The layout will fall back to the compact layout if medium/expanded layouts are not provided + * for the current screen size. + * + * Example usage: + * ``` + * KptResponsiveLayout( + * compact = { + * // Single column layout for phones + * LazyColumn { + * items(data) { item -> ItemCard(item) } + * } + * }, + * medium = { + * // Two column grid for tablets + * LazyVerticalGrid(columns = GridCells.Fixed(2)) { + * items(data) { item -> ItemCard(item) } + * } + * }, + * expanded = { + * // Three column layout with sidebar for desktop + * Row { + * Sidebar(modifier = Modifier.width(240.dp)) + * LazyVerticalGrid( + * columns = GridCells.Fixed(3), + * modifier = Modifier.weight(1f) + * ) { + * items(data) { item -> ItemCard(item) } + * } + * } + * } + * ) + * ``` + * + * @param compact The layout to display on compact screens (< 600dp). Always required. + * @param medium The layout to display on medium screens (600-840dp). Optional, falls back to compact. + * @param expanded The layout to display on expanded screens (≥ 840dp). Optional, falls back to medium or compact. + * + * @see rememberResponsiveLayoutInfo + * @see ResponsiveLayoutInfo + */ +@Composable +fun KptResponsiveLayout( + compact: @Composable () -> Unit, + medium: (@Composable () -> Unit)? = null, + expanded: (@Composable () -> Unit)? = null, +) { + val layoutInfo = rememberResponsiveLayoutInfo() + + when { + layoutInfo.isExpanded && expanded != null -> expanded() + layoutInfo.isMedium && medium != null -> medium() + else -> compact() + } +} + +/** + * Contains information about the current screen size and responsive breakpoints. + * + * This class provides both the raw screen dimensions and convenience boolean flags + * for determining which breakpoint the current screen size falls into. + * + * @property screenWidthDp The current screen width in density-independent pixels + * @property screenHeightDp The current screen height in density-independent pixels + * @property isCompact True if screen width < 600dp + * @property isMedium True if screen width is between 600-840dp + * @property isExpanded True if screen width ≥ 840dp + * + * @see rememberResponsiveLayoutInfo + */ +@Stable +class ResponsiveLayoutInfo( + val screenWidthDp: Dp, + val screenHeightDp: Dp, + val isCompact: Boolean, + val isMedium: Boolean, + val isExpanded: Boolean, +) + +/** + * Remembers and provides responsive layout information based on the current window size. + * + * This composable function observes the current window configuration and returns + * a [ResponsiveLayoutInfo] object that contains both raw dimensions and computed + * breakpoint flags. + * + * The breakpoints follow Material Design guidelines: + * - Compact: < 600dp width + * - Medium: 600-840dp width + * - Expanded: ≥ 840dp width + * + * The returned object is automatically recomposed when the window size changes, + * allowing responsive layouts to adapt in real-time. + * + * Example usage: + * ``` + * @Composable + * fun MyScreen() { + * val layoutInfo = rememberResponsiveLayoutInfo() + * + * when { + * layoutInfo.isCompact -> CompactLayout() + * layoutInfo.isMedium -> MediumLayout() + * layoutInfo.isExpanded -> ExpandedLayout() + * } + * } + * ``` + * + * @return A [ResponsiveLayoutInfo] object containing current screen size information + * + * @see ResponsiveLayoutInfo + * @see KptResponsiveLayout + */ +@Composable +fun rememberResponsiveLayoutInfo(): ResponsiveLayoutInfo { + val configuration = LocalWindowInfo.current + val width = configuration.containerSize.width + val height = configuration.containerSize.height.dp + + return remember(configuration) { + ResponsiveLayoutInfo( + screenWidthDp = width.dp, + screenHeightDp = height, + isCompact = width < 600, + isMedium = width >= 600 && width < 840, + isExpanded = width >= 840, + ) + } +} diff --git a/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/KptSidebarLayout.kt b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/KptSidebarLayout.kt new file mode 100644 index 0000000000..e71824c5c0 --- /dev/null +++ b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/KptSidebarLayout.kt @@ -0,0 +1,107 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.designsystem.layout + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Surface +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import template.core.base.designsystem.theme.KptTheme + +@Composable +fun KptSidebarLayout( + sidebarContent: @Composable () -> Unit, + modifier: Modifier = Modifier, + configuration: SidebarConfiguration = SidebarConfiguration(), + sidebarVisible: Boolean = true, + onSidebarVisibilityChange: (Boolean) -> Unit = {}, + content: @Composable () -> Unit, +) { + Row( + modifier = modifier + .fillMaxSize() + .testTag("KptSidebarLayout"), + ) { + if (configuration.position == SidebarPosition.Start && sidebarVisible) { + Surface( + modifier = Modifier.width(configuration.width), + color = configuration.backgroundColor ?: KptTheme.colorScheme.surface, + content = sidebarContent, + ) + + if (!configuration.overlay) { + VerticalDivider( + color = configuration.dividerColor ?: KptTheme.colorScheme.outline, + ) + } + } + + Box( + modifier = Modifier.weight(1f), + ) { + content() + + if (configuration.overlay && sidebarVisible) { + Surface( + modifier = Modifier + .width(configuration.width) + .fillMaxHeight() + .align( + if (configuration.position == SidebarPosition.Start) { + Alignment.CenterStart + } else { + Alignment.CenterEnd + }, + ), + color = configuration.backgroundColor ?: KptTheme.colorScheme.surface, + shadowElevation = 8.dp, + content = sidebarContent, + ) + } + } + + if (configuration.position == SidebarPosition.End && sidebarVisible) { + if (!configuration.overlay) { + VerticalDivider( + color = configuration.dividerColor ?: KptTheme.colorScheme.outline, + ) + } + + Surface( + modifier = Modifier.width(configuration.width), + color = configuration.backgroundColor ?: KptTheme.colorScheme.surface, + content = sidebarContent, + ) + } + } +} + +@Immutable +data class SidebarConfiguration( + val width: Dp = 300.dp, + val position: SidebarPosition = SidebarPosition.Start, + val collapsible: Boolean = true, + val overlay: Boolean = false, + val backgroundColor: Color? = null, + val dividerColor: Color? = null, +) + +enum class SidebarPosition { Start, End } diff --git a/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/KptSplitPane.kt b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/KptSplitPane.kt new file mode 100644 index 0000000000..8b3b355e1b --- /dev/null +++ b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/KptSplitPane.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.designsystem.layout + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import template.core.base.designsystem.theme.KptTheme + +@Composable +fun KptSplitPane( + leftContent: @Composable () -> Unit, + rightContent: @Composable () -> Unit, + modifier: Modifier = Modifier, + initialSplitRatio: Float = 0.5f, + minLeftWidth: Dp = 200.dp, + minRightWidth: Dp = 200.dp, + resizable: Boolean = true, + dividerColor: Color = KptTheme.colorScheme.outline, + dividerWidth: Dp = 1.dp, +) { + var splitRatio by remember { mutableFloatStateOf(initialSplitRatio) } + + Row( + modifier = modifier + .fillMaxSize() + .testTag("KptSplitPane"), + ) { + Box( + modifier = Modifier.weight(splitRatio), + ) { + leftContent() + } + + VerticalDivider( + thickness = dividerWidth, + color = dividerColor, + ) + + Box( + modifier = Modifier.weight(1f - splitRatio), + ) { + rightContent() + } + } +} diff --git a/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/KptStack.kt b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/KptStack.kt new file mode 100644 index 0000000000..1d961518bc --- /dev/null +++ b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/layout/KptStack.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.designsystem.layout + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag + +@Composable +fun KptStack( + modifier: Modifier = Modifier, + alignment: Alignment = Alignment.TopStart, + content: @Composable BoxScope.() -> Unit, +) { + Box( + modifier = modifier.testTag("KptStack"), + contentAlignment = alignment, + content = content, + ) +} diff --git a/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/theme/KptColorSchemeImpl.kt b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/theme/KptColorSchemeImpl.kt new file mode 100644 index 0000000000..89f0f7fcbe --- /dev/null +++ b/core-base/designsystem/src/commonMain/kotlin/template/core/base/designsystem/theme/KptColorSchemeImpl.kt @@ -0,0 +1,412 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.designsystem.theme + +import androidx.compose.foundation.shape.CornerBasedShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import template.core.base.designsystem.core.ComponentDsl +import template.core.base.designsystem.core.KptColorScheme +import template.core.base.designsystem.core.KptElevation +import template.core.base.designsystem.core.KptShapes +import template.core.base.designsystem.core.KptSpacing +import template.core.base.designsystem.core.KptThemeProvider +import template.core.base.designsystem.core.KptTypography + +@Immutable +data class KptColorSchemeImpl( + override val primary: Color = Color(0xFF6750A4), + override val onPrimary: Color = Color(0xFFFFFFFF), + override val primaryContainer: Color = Color(0xFFEADDFF), + override val onPrimaryContainer: Color = Color(0xFF21005D), + override val secondary: Color = Color(0xFF625B71), + override val onSecondary: Color = Color(0xFFFFFFFF), + override val secondaryContainer: Color = Color(0xFFE8DEF8), + override val onSecondaryContainer: Color = Color(0xFF1D192B), + override val tertiary: Color = Color(0xFF7D5260), + override val onTertiary: Color = Color(0xFFFFFFFF), + override val tertiaryContainer: Color = Color(0xFFFFD8E4), + override val onTertiaryContainer: Color = Color(0xFF31111D), + override val error: Color = Color(0xFFBA1A1A), + override val onError: Color = Color(0xFFFFFFFF), + override val errorContainer: Color = Color(0xFFFFDAD6), + override val onErrorContainer: Color = Color(0xFF410002), + override val background: Color = Color(0xFFFFFBFE), + override val onBackground: Color = Color(0xFF1C1B1F), + override val surface: Color = Color(0xFFFFFBFE), + override val onSurface: Color = Color(0xFF1C1B1F), + override val surfaceVariant: Color = Color(0xFFE7E0EC), + override val onSurfaceVariant: Color = Color(0xFF49454F), + override val outline: Color = Color(0xFF79747E), + override val outlineVariant: Color = Color(0xFFCAC4D0), + override val scrim: Color = Color(0xFF000000), + override val inverseSurface: Color = Color(0xFF313033), + override val inverseOnSurface: Color = Color(0xFFF4EFF4), + override val inversePrimary: Color = Color(0xFFD0BCFF), + override val surfaceDim: Color = Color(0xFFDAD6DC), + override val surfaceBright: Color = Color(0xFFFFFBFE), + override val surfaceContainerLowest: Color = Color(0xFFFFFFFF), + override val surfaceContainerLow: Color = Color(0xFFF3EFF4), + override val surfaceContainer: Color = Color(0xFFE7E0EC), + override val surfaceContainerHigh: Color = Color(0xFFDAD6DC), + override val surfaceContainerHighest: Color = Color(0xFFCFC8D0), + override val surfaceTint: Color = Color(0xFF6750A4), +) : KptColorScheme + +@Immutable +data class KptTypographyImpl( + override val displayLarge: TextStyle = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 57.sp, + lineHeight = 64.sp, + letterSpacing = (-0.25).sp, + ), + override val displayMedium: TextStyle = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 45.sp, + lineHeight = 52.sp, + letterSpacing = 0.sp, + ), + override val displaySmall: TextStyle = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 36.sp, + lineHeight = 44.sp, + letterSpacing = 0.sp, + ), + override val headlineLarge: TextStyle = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 32.sp, + lineHeight = 40.sp, + letterSpacing = 0.sp, + ), + override val headlineMedium: TextStyle = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 28.sp, + lineHeight = 36.sp, + letterSpacing = 0.sp, + ), + override val headlineSmall: TextStyle = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 24.sp, + lineHeight = 32.sp, + letterSpacing = 0.sp, + ), + override val titleLarge: TextStyle = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp, + ), + override val titleMedium: TextStyle = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp, + ), + override val titleSmall: TextStyle = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + override val bodyLarge: TextStyle = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ), + override val bodyMedium: TextStyle = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp, + ), + override val bodySmall: TextStyle = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp, + ), + override val labelLarge: TextStyle = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + override val labelMedium: TextStyle = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), + override val labelSmall: TextStyle = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), +) : KptTypography + +@Immutable +data class KptShapesImpl( + override val extraSmall: CornerBasedShape = RoundedCornerShape(4.dp), + override val small: CornerBasedShape = RoundedCornerShape(8.dp), + override val medium: CornerBasedShape = RoundedCornerShape(12.dp), + override val large: CornerBasedShape = RoundedCornerShape(16.dp), + override val extraLarge: CornerBasedShape = RoundedCornerShape(28.dp), +) : KptShapes + +@Immutable +data class KptSpacingImpl( + override val xs: Dp = 4.dp, + override val sm: Dp = 8.dp, + override val md: Dp = 16.dp, + override val lg: Dp = 24.dp, + override val xl: Dp = 32.dp, + override val xxl: Dp = 64.dp, +) : KptSpacing + +@Immutable +data class KptElevationImpl( + override val level0: Dp = 0.dp, + override val level1: Dp = 1.dp, + override val level2: Dp = 3.dp, + override val level3: Dp = 6.dp, + override val level4: Dp = 8.dp, + override val level5: Dp = 12.dp, +) : KptElevation + +@Immutable +data class KptThemeProviderImpl( + override val colors: KptColorScheme = KptColorSchemeImpl(), + override val typography: KptTypography = KptTypographyImpl(), + override val shapes: KptShapes = KptShapesImpl(), + override val spacing: KptSpacing = KptSpacingImpl(), + override val elevation: KptElevation = KptElevationImpl(), +) : KptThemeProvider + +val LocalKptColors = staticCompositionLocalOf { KptColorSchemeImpl() } +val LocalKptTypography = staticCompositionLocalOf { KptTypographyImpl() } +val LocalKptShapes = staticCompositionLocalOf { KptShapesImpl() } +val LocalKptSpacing = staticCompositionLocalOf { KptSpacingImpl() } +val LocalKptElevation = staticCompositionLocalOf { KptElevationImpl() } + +@ComponentDsl +class KptThemeBuilder { + private var colors: KptColorScheme = KptColorSchemeImpl() + private var typography: KptTypography = KptTypographyImpl() + private var shapes: KptShapes = KptShapesImpl() + private var spacing: KptSpacing = KptSpacingImpl() + private var elevation: KptElevation = KptElevationImpl() + + fun colors(block: KptColorSchemeBuilder.() -> Unit) { + colors = KptColorSchemeBuilder().apply(block).build() + } + + fun typography(block: KptTypographyBuilder.() -> Unit) { + typography = KptTypographyBuilder().apply(block).build() + } + + fun shapes(block: KptShapesBuilder.() -> Unit) { + shapes = KptShapesBuilder().apply(block).build() + } + + fun spacing(block: KptSpacingBuilder.() -> Unit) { + spacing = KptSpacingBuilder().apply(block).build() + } + + fun elevation(block: KptElevationBuilder.() -> Unit) { + elevation = KptElevationBuilder().apply(block).build() + } + + fun build(): KptThemeProvider = KptThemeProviderImpl( + colors = colors, + typography = typography, + shapes = shapes, + spacing = spacing, + elevation = elevation, + ) +} + +@ComponentDsl +class KptColorSchemeBuilder { + var primary: Color = Color(0xFF6750A4) + var onPrimary: Color = Color(0xFFFFFFFF) + var primaryContainer: Color = Color(0xFFEADDFF) + var onPrimaryContainer: Color = Color(0xFF21005D) + var secondary: Color = Color(0xFF625B71) + var onSecondary: Color = Color(0xFFFFFFFF) + var secondaryContainer: Color = Color(0xFFE8DEF8) + var onSecondaryContainer: Color = Color(0xFF1D192B) + var tertiary: Color = Color(0xFF7D5260) + var onTertiary: Color = Color(0xFFFFFFFF) + var tertiaryContainer: Color = Color(0xFFFFD8E4) + var onTertiaryContainer: Color = Color(0xFF31111D) + var error: Color = Color(0xFFBA1A1A) + var onError: Color = Color(0xFFFFFFFF) + var errorContainer: Color = Color(0xFFFFDAD6) + var onErrorContainer: Color = Color(0xFF410002) + var background: Color = Color(0xFFFFFBFE) + var onBackground: Color = Color(0xFF1C1B1F) + var surface: Color = Color(0xFFFFFBFE) + var onSurface: Color = Color(0xFF1C1B1F) + var surfaceVariant: Color = Color(0xFFE7E0EC) + var onSurfaceVariant: Color = Color(0xFF49454F) + var outline: Color = Color(0xFF79747E) + var outlineVariant: Color = Color(0xFFCAC4D0) + + fun build(): KptColorScheme = KptColorSchemeImpl( + primary = primary, + onPrimary = onPrimary, + primaryContainer = primaryContainer, + onPrimaryContainer = onPrimaryContainer, + secondary = secondary, + onSecondary = onSecondary, + secondaryContainer = secondaryContainer, + onSecondaryContainer = onSecondaryContainer, + tertiary = tertiary, + onTertiary = onTertiary, + tertiaryContainer = tertiaryContainer, + onTertiaryContainer = onTertiaryContainer, + error = error, + onError = onError, + errorContainer = errorContainer, + onErrorContainer = onErrorContainer, + background = background, + onBackground = onBackground, + surface = surface, + onSurface = onSurface, + surfaceVariant = surfaceVariant, + onSurfaceVariant = onSurfaceVariant, + outline = outline, + outlineVariant = outlineVariant, + ) +} + +@ComponentDsl +class KptTypographyBuilder { + var displayLarge: TextStyle = TextStyle(fontWeight = FontWeight.Normal, fontSize = 57.sp) + var displayMedium: TextStyle = TextStyle(fontWeight = FontWeight.Normal, fontSize = 45.sp) + var displaySmall: TextStyle = TextStyle(fontWeight = FontWeight.Normal, fontSize = 36.sp) + var headlineLarge: TextStyle = TextStyle(fontWeight = FontWeight.Normal, fontSize = 32.sp) + var headlineMedium: TextStyle = TextStyle(fontWeight = FontWeight.Normal, fontSize = 28.sp) + var headlineSmall: TextStyle = TextStyle(fontWeight = FontWeight.Normal, fontSize = 24.sp) + var titleLarge: TextStyle = TextStyle(fontWeight = FontWeight.Normal, fontSize = 22.sp) + var titleMedium: TextStyle = TextStyle(fontWeight = FontWeight.Medium, fontSize = 16.sp) + var titleSmall: TextStyle = TextStyle(fontWeight = FontWeight.Medium, fontSize = 14.sp) + var bodyLarge: TextStyle = TextStyle(fontWeight = FontWeight.Normal, fontSize = 16.sp) + var bodyMedium: TextStyle = TextStyle(fontWeight = FontWeight.Normal, fontSize = 14.sp) + var bodySmall: TextStyle = TextStyle(fontWeight = FontWeight.Normal, fontSize = 12.sp) + var labelLarge: TextStyle = TextStyle(fontWeight = FontWeight.Medium, fontSize = 14.sp) + var labelMedium: TextStyle = TextStyle(fontWeight = FontWeight.Medium, fontSize = 12.sp) + var labelSmall: TextStyle = TextStyle(fontWeight = FontWeight.Medium, fontSize = 11.sp) + + fun build(): KptTypography = KptTypographyImpl( + displayLarge = displayLarge, + displayMedium = displayMedium, + displaySmall = displaySmall, + headlineLarge = headlineLarge, + headlineMedium = headlineMedium, + headlineSmall = headlineSmall, + titleLarge = titleLarge, + titleMedium = titleMedium, + titleSmall = titleSmall, + bodyLarge = bodyLarge, + bodyMedium = bodyMedium, + bodySmall = bodySmall, + labelLarge = labelLarge, + labelMedium = labelMedium, + labelSmall = labelSmall, + ) +} + +@ComponentDsl +class KptShapesBuilder { + var extraSmall: CornerBasedShape = RoundedCornerShape(4.dp) + var small: CornerBasedShape = RoundedCornerShape(8.dp) + var medium: CornerBasedShape = RoundedCornerShape(12.dp) + var large: CornerBasedShape = RoundedCornerShape(16.dp) + var extraLarge: CornerBasedShape = RoundedCornerShape(28.dp) + + fun build(): KptShapes = KptShapesImpl( + extraSmall = extraSmall, + small = small, + medium = medium, + large = large, + extraLarge = extraLarge, + ) +} + +@ComponentDsl +class KptSpacingBuilder { + var xs: Dp = 4.dp + var sm: Dp = 8.dp + var md: Dp = 16.dp + var lg: Dp = 24.dp + var xl: Dp = 32.dp + var xxl: Dp = 64.dp + + fun build(): KptSpacing = KptSpacingImpl( + xs = xs, + sm = sm, + md = md, + lg = lg, + xl = xl, + xxl = xxl, + ) +} + +@ComponentDsl +class KptElevationBuilder { + var level0: Dp = 0.dp + var level1: Dp = 1.dp + var level2: Dp = 3.dp + var level3: Dp = 6.dp + var level4: Dp = 8.dp + var level5: Dp = 12.dp + + fun build(): KptElevation = KptElevationImpl( + level0 = level0, + level1 = level1, + level2 = level2, + level3 = level3, + level4 = level4, + level5 = level5, + ) +} + +object KptTheme { + val colorScheme: KptColorScheme + @Composable get() = LocalKptColors.current + + val typography: KptTypography + @Composable get() = LocalKptTypography.current + + val shapes: KptShapes + @Composable get() = LocalKptShapes.current + + val spacing: KptSpacing + @Composable get() = LocalKptSpacing.current + + val elevation: KptElevation + @Composable get() = LocalKptElevation.current +} + +fun kptTheme(block: KptThemeBuilder.() -> Unit): KptThemeProvider { + return KptThemeBuilder().apply(block).build() +} diff --git a/core-base/network/.gitignore b/core-base/network/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/core-base/network/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core-base/network/README.md b/core-base/network/README.md new file mode 100644 index 0000000000..bb1c5344de --- /dev/null +++ b/core-base/network/README.md @@ -0,0 +1,149 @@ +# Ktorfit Networking Module Setup +This module provides a convenient way to configure and use Ktorfit with a consistent HTTP client setup and response handling using Result wrappers for both suspend and Flow return types. + +## Getting Started +To use this module, you need to create a Ktorfit instance and provide it with a configured HttpClient and converter factories. + +### Ktorfit Setup Example +```yaml +val ktorfit = Ktorfit.Builder() + .httpClient(httpClient(setupDefaultHttpClient(baseUrl = "https://your.api.com"))) + .converterFactories( + ResultSuspendConverterFactory(), + ResultFlowConverterFactory() + ) + .build() +``` +## HttpClient Configuration +When configuring the HttpClient, you have two options: + +### Option 1: Provide a custom config +You can define your own plugins and setup manually: +```yaml +val config: HttpClientConfig<*>.() -> Unit = { + install(Auth) { /* ... */ } + install(ContentNegotiation) { /* ... */ } + install(HttpTimeout) { /* ... */ } + // other plugins... +} +``` +val client = httpClient(config) + +### Option 2: Use our default config +We provide `setupDefaultHttpClient()` which simplifies configuration. Only `baseUrl` is required. All other parameters are optional. +```yaml +val client = httpClient( + setupDefaultHttpClient( + baseUrl = "https://your.api.com" + ) +) +``` + +| Parameter | Type | Description | +|---------------------------|-------------------------------|------------------------------------------------------------------------------------------| +| baseUrl | `String` | Required. The base URL for requests | +| authRequiredUrl | `List ` | Domains that require auth headers | +| defaultHeaders | `Map` | Headers to include in every request | +| requestTimeout | `Long` | Millis before timing out a request. Default: 60_000 | +| socketTimeout | `Long` | Millis before timing out a socket. Default: 60_000 | +| httpLogger | `Logger` | Ktor logger. Default: Logger.DEFAULT | +| httpLogLevel | `LogLevel` | Logging level. Default: LogLevel.ALL | +| loggableHosts | `List` | A list of hostnames. Only requests made to these hosts will be logged. | +| sensitiveHeaders | `List` | Headers to redact in logs. Default: Authorization | +| jsonConfig | `Json` | Customize JSON parsing. Default: lenient, ignoresUnknownKeys, prettyPrint, explicitNulls | +| basicCredentialsProvider | `() -> BasicAuthCredentials` | Supplies basic auth(username, password) credentials | +| digestCredentialsProvider | `() -> DigestAuthCredentials` | Supplies digest auth credentials | +| bearerTokensProvider | `() -> BearerTokens` | Supplies bearer tokens | +| bearerRefreshProvider | `() -> BearerTokens` | Refreshes bearer tokens if needed | + +## Converter Factories +To handle wrapping network responses into a Result model, use converters. + +### Option 1: Use your own converters +You can write your own as shown in [Ktorfit Docs – Custom Converters](https://foso.github.io/Ktorfit/converters/responseconverter/) + +### Option 2: Use built-in converters from this module +We provide: + +**ResultSuspendConverterFactory** +Wraps suspend functions into: +```yaml +interface ApiService { + @GET("users") + suspend fun getUsers(): Result, RemoteError> +} +``` +**ResultFlowConverterFactory** +Wraps Flow-returning functions: +```yaml +interface ApiService { + @GET("items") + fun getItems(): Flow, RemoteError>> +} +``` +## Result & Error Wrapping +The following sealed class and enum are used: +```yaml +sealed interface Result { + data class Success(val data: D) : Result + data class Error(val error: E) : Result +} +``` +```yaml +enum class RemoteError { + BAD_REQUEST, + NOT_FOUND, + UNAUTHORIZED, + REQUEST_TIMEOUT, + TOO_MANY_REQUESTS, + SERVER, + SERIALIZATION, + UNKNOWN +} +``` + +## Usage Example +Define an API interface +```yaml +interface UserApi { + @GET("users") + suspend fun getUsers(): Result, RemoteError> + + @GET("groups") + fun getGroups(): Flow, RemoteError>> +} +``` +Create an instance +```yaml +val ktorfit = Ktorfit.Builder() + .httpClient(httpClient(setupDefaultHttpClient(baseUrl = "https://your.api.com"))) + .converterFactories(ResultSuspendConverterFactory(), ResultFlowConverterFactory()) + .build() + +val userApi = ktorfit.createUserApi() +``` +You can now call: +```yaml +val usersResult = userApi.getUsers() // suspend function + +userApi.getGroups().collect { result -> + when (result) { + is Result.Success -> { /* handle data */ } + is Result.Error -> { /* handle error */ } + } +} +``` + +## Summary +This module aims to make Ktorfit easy, safe, and robust by providing: + +- Built-in Result wrappers. + +- Cleanly configured HttpClient. + +- Support for both suspend and Flow return types. + +Use the default config if you want to skip boilerplate, or customize to suit your needs. + +## Demo video +https://github.com/user-attachments/assets/23b70168-a0a3-42fe-8b06-23b0ae34fc44 \ No newline at end of file diff --git a/core-base/network/build.gradle.kts b/core-base/network/build.gradle.kts new file mode 100644 index 0000000000..6235227985 --- /dev/null +++ b/core-base/network/build.gradle.kts @@ -0,0 +1,52 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +plugins { + alias(libs.plugins.mifos.kmp.library) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "org.mifos.corebase.network" +} + +kotlin { + sourceSets { + commonMain.dependencies { + api(libs.ktor.client.core) + api(libs.ktor.client.logging) + api(libs.ktor.client.content.negotiation) + api(libs.ktor.serialization.kotlinx.json) + api(libs.ktor.client.auth) + api(libs.ktorfit.lib) + api(libs.kermit.logging) + } + + androidMain.dependencies { + api(libs.ktor.client.okhttp) + api(libs.koin.android) + } + + nativeMain.dependencies { + api(libs.ktor.client.darwin) + } + + desktopMain.dependencies { + api(libs.ktor.client.okhttp) + } + + jsMain.dependencies { + api(libs.ktor.client.js) + } + + wasmJsMain.dependencies { + api(libs.ktor.client.js) + } + } +} \ No newline at end of file diff --git a/core-base/network/src/androidMain/kotlin/org/mifos/corebase/network/KtorHttpClient.android.kt b/core-base/network/src/androidMain/kotlin/org/mifos/corebase/network/KtorHttpClient.android.kt new file mode 100644 index 0000000000..7461470769 --- /dev/null +++ b/core-base/network/src/androidMain/kotlin/org/mifos/corebase/network/KtorHttpClient.android.kt @@ -0,0 +1,18 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package org.mifos.corebase.network + +import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig +import io.ktor.client.engine.okhttp.OkHttp + +actual fun httpClient(config: HttpClientConfig<*>.() -> Unit) = HttpClient(OkHttp) { + config(this) +} diff --git a/core-base/network/src/commonMain/kotlin/org/mifos/corebase/network/KtorHttpClient.kt b/core-base/network/src/commonMain/kotlin/org/mifos/corebase/network/KtorHttpClient.kt new file mode 100644 index 0000000000..48fb867ad7 --- /dev/null +++ b/core-base/network/src/commonMain/kotlin/org/mifos/corebase/network/KtorHttpClient.kt @@ -0,0 +1,162 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package org.mifos.corebase.network + +import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.auth.Auth +import io.ktor.client.plugins.auth.providers.BasicAuthCredentials +import io.ktor.client.plugins.auth.providers.BearerTokens +import io.ktor.client.plugins.auth.providers.DigestAuthCredentials +import io.ktor.client.plugins.auth.providers.basic +import io.ktor.client.plugins.auth.providers.bearer +import io.ktor.client.plugins.auth.providers.digest +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.plugins.logging.DEFAULT +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logger +import io.ktor.client.plugins.logging.Logging +import io.ktor.http.HttpHeaders +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json +import co.touchlab.kermit.Logger.Companion as KermitLogger + +expect fun httpClient(config: HttpClientConfig<*>.() -> Unit): HttpClient + +/** + * Provides a default [HttpClientConfig] setup for use with a Ktor-based HTTP client. + * + * This function simplifies client configuration by handling common concerns such as: + * - Authentication (Bearer, Basic, Digest) + * - Default headers + * - Timeouts + * - Logging + * - JSON serialization + * + * It can be passed directly into a Ktor client builder via the `config` lambda: + * ```kotlin + * val client = httpClient(setupDefaultHttpClient(baseUrl = "https://api.example.com")) + * ``` + * + * @param baseUrl The base URL to be applied to all requests unless explicitly overridden. + * @param authRequiredUrl A list of hostnames that require authentication. + * @param defaultHeaders Headers that are applied to every request. + * @param requestTimeout Timeout in milliseconds for entire request lifecycle. + * @param socketTimeout Timeout in milliseconds for socket-level communication. + * @param httpLogger A logger used for HTTP logging (defaults to `Logger.DEFAULT`). + * @param httpLogLevel Level of HTTP logging (e.g. `LogLevel.ALL`). + * @param loggableHosts A list of hostnames for which HTTP logging is enabled. + * @param sensitiveHeaders List of headers to be hidden in logs (defaults to Authorization). + * @param jsonConfig Custom [Json] configuration used by `ContentNegotiation`. + * @param basicCredentialsProvider Provider for Basic authentication credentials. + * @param digestCredentialsProvider Provider for Digest authentication credentials. + * @param bearerTokensProvider Provider for Bearer token authentication. + * @param bearerRefreshProvider Optional refresh logic for Bearer tokens (only used if Bearer auth is configured). + * + * @return A configuration lambda to be passed into the Ktor [HttpClient]. + */ +fun setupDefaultHttpClient( + baseUrl: String, + authRequiredUrl: List = emptyList(), + defaultHeaders: Map = emptyMap(), + requestTimeout: Long = 60_000L, + socketTimeout: Long = 60_000L, + httpLogger: Logger = Logger.DEFAULT, + httpLogLevel: LogLevel = LogLevel.ALL, + loggableHosts: List = emptyList(), + sensitiveHeaders: List = listOf(HttpHeaders.Authorization), + jsonConfig: Json = Json { + prettyPrint = true + isLenient = true + ignoreUnknownKeys = true + explicitNulls = false + }, + basicCredentialsProvider: (() -> BasicAuthCredentials)? = null, + digestCredentialsProvider: (() -> DigestAuthCredentials)? = null, + bearerTokensProvider: (() -> BearerTokens)? = null, + bearerRefreshProvider: (() -> BearerTokens)? = null, +): HttpClientConfig<*>.() -> Unit = { + when { + bearerTokensProvider != null -> { + install(Auth) { + bearer { + loadTokens { bearerTokensProvider() } + if (bearerRefreshProvider != null) { + refreshTokens { + bearerRefreshProvider() + } + } + sendWithoutRequest { request -> + request.url.host in authRequiredUrl + } + } + } + } + + basicCredentialsProvider != null -> { + install(Auth) { + basic { + credentials { + basicCredentialsProvider() + } + sendWithoutRequest { request -> + request.url.host in authRequiredUrl + } + } + } + } + + digestCredentialsProvider != null -> { + install(Auth) { + digest { + credentials { + digestCredentialsProvider() + } + } + } + } + } + + defaultRequest { + url(baseUrl) + defaultHeaders.forEach { (key, value) -> + headers.append(key, value) + } + } + + install(HttpTimeout) { + requestTimeoutMillis = requestTimeout + socketTimeoutMillis = socketTimeout + } + + install(Logging) { + logger = httpLogger + level = httpLogLevel + filter { request -> + loggableHosts.any { host -> + request.url.host.contains(host) + } + } + sanitizeHeader { header -> + header in sensitiveHeaders + } + logger = object : Logger { + override fun log(message: String) { + KermitLogger.d(tag = "KtorClient", messageString = message) + } + } + } + + install(ContentNegotiation) { + json(jsonConfig) + } +} diff --git a/core-base/network/src/commonMain/kotlin/org/mifos/corebase/network/NetworkError.kt b/core-base/network/src/commonMain/kotlin/org/mifos/corebase/network/NetworkError.kt new file mode 100644 index 0000000000..cb56a78bb2 --- /dev/null +++ b/core-base/network/src/commonMain/kotlin/org/mifos/corebase/network/NetworkError.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package org.mifos.corebase.network + +/** + * Represents standardized error types for remote or network operations. + * + * This enum is typically used with the [NetworkResult.Error] variant to describe what kind of failure occurred. + */ +enum class NetworkError { + + /** + * The request was malformed or missing required parameters (HTTP 400). + */ + BAD_REQUEST, + + /** + * The requested resource could not be found (HTTP 404). + */ + NOT_FOUND, + + /** + * Authentication failed due to invalid or missing credentials (HTTP 401). + */ + UNAUTHORIZED, + + /** + * The request timed out, usually due to a slow or unresponsive network (HTTP 408 or socket timeout). + */ + REQUEST_TIMEOUT, + + /** + * The client has sent too many requests in a given amount of time (HTTP 429). + */ + TOO_MANY_REQUESTS, + + /** + * A server-side error occurred (HTTP 5xx). + */ + SERVER, + + /** + * The response could not be deserialized, likely due to mismatched or invalid data formats. + */ + SERIALIZATION, + + /** + * An unknown or unexpected error occurred, used as a fallback when the specific cause is not identifiable. + */ + UNKNOWN, +} diff --git a/core-base/network/src/commonMain/kotlin/org/mifos/corebase/network/NetworkResult.kt b/core-base/network/src/commonMain/kotlin/org/mifos/corebase/network/NetworkResult.kt new file mode 100644 index 0000000000..d5784c8fc8 --- /dev/null +++ b/core-base/network/src/commonMain/kotlin/org/mifos/corebase/network/NetworkResult.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package org.mifos.corebase.network + +/** + * Represents the result of a network or remote operation, encapsulating either a success or an error. + * + * This is a sealed interface with two implementations: + * - [Success] indicates the operation completed successfully and contains the resulting data. + * - [Error] represents a failure and contains a [NetworkError] describing the error condition. + * + * @param D The type of data returned on success. + * @param E The type of error returned on failure, constrained to [NetworkError]. + */ +sealed interface NetworkResult { + + /** + * Represents a successful result. + * + * @param D The type of the successful response data. + * @property data The actual result of the operation. + */ + data class Success(val data: D) : NetworkResult + + /** + * Represents a failed result due to a [NetworkError]. + * + * @param E The specific type of [NetworkError] encountered. + * @property error Details about the error that occurred. + */ + data class Error(val error: E) : NetworkResult +} diff --git a/core-base/network/src/commonMain/kotlin/org/mifos/corebase/network/factory/ResultSuspendConverterFactory.kt b/core-base/network/src/commonMain/kotlin/org/mifos/corebase/network/factory/ResultSuspendConverterFactory.kt new file mode 100644 index 0000000000..56213f9765 --- /dev/null +++ b/core-base/network/src/commonMain/kotlin/org/mifos/corebase/network/factory/ResultSuspendConverterFactory.kt @@ -0,0 +1,118 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package org.mifos.corebase.network.factory + +import de.jensklingenberg.ktorfit.Ktorfit +import de.jensklingenberg.ktorfit.converter.Converter +import de.jensklingenberg.ktorfit.converter.KtorfitResult +import de.jensklingenberg.ktorfit.converter.TypeData +import io.ktor.client.call.NoTransformationFoundException +import io.ktor.client.call.body +import io.ktor.client.statement.HttpResponse +import kotlinx.serialization.SerializationException +import org.mifos.corebase.network.NetworkError +import org.mifos.corebase.network.NetworkResult + +/** + * A custom [Converter.Factory] for Ktorfit that provides a suspend + * response converter which wraps successful or error HTTP responses into a + * sealed [NetworkResult] type. + * + * This is useful for abstracting error handling logic across your network + * layer while providing strong typing for both success and failure + * outcomes. + * + * This converter handles: + * - HTTP 2xx responses by deserializing the response body into the + * expected type. + * - Known HTTP error codes like 400, 401, 404, etc., by mapping them to + * [NetworkError] types. + * - Deserialization issues via [SerializationException]. + * - Unknown failures via [KtorfitResult.Failure]. + * + * Example usage: + * ```kotlin + * interface ApiService { + * @GET("users") + * suspend fun getUsers(): Result, RemoteError> + * } + * ``` + */ +@Suppress("NestedBlockDepth") +class ResultSuspendConverterFactory : Converter.Factory { + + /** + * Creates a [Converter.SuspendResponseConverter] that wraps an HTTP + * response into a [NetworkResult] type. + * + * @param typeData Metadata about the expected response type. + * @param ktorfit The [Ktorfit] instance requesting this converter. + * @return A [Converter.SuspendResponseConverter] if the return type is + * `Result`, or `null` otherwise. + */ + override fun suspendResponseConverter( + typeData: TypeData, + ktorfit: Ktorfit, + ): Converter.SuspendResponseConverter? { + if (typeData.typeInfo.type == NetworkResult::class) { + val successType = typeData.typeArgs.first().typeInfo + return object : + Converter.SuspendResponseConverter> { + + /** + * Converts a [KtorfitResult] into a [NetworkResult], handling success and + * various failure scenarios. + * + * @param result The response wrapped in [KtorfitResult]. + * @return A [NetworkResult.Success] if the response is successful, or a + * [NetworkResult.Error] if an error occurred. + */ + override suspend fun convert(result: KtorfitResult): NetworkResult { + return when (result) { + is KtorfitResult.Failure -> { + println("Failure: " + result.throwable.message) + NetworkResult.Error(NetworkError.UNKNOWN) + } + + is KtorfitResult.Success -> { + val status = result.response.status.value + + when (status) { + in 200..209 -> { + try { + val data = result.response.body(successType) as Any + NetworkResult.Success(data) + } catch (e: NoTransformationFoundException) { + NetworkResult.Error(NetworkError.SERIALIZATION) + } catch (e: SerializationException) { + println("Serialization error: ${e.message}") + NetworkResult.Error(NetworkError.SERIALIZATION) + } + } + + 400 -> NetworkResult.Error(NetworkError.BAD_REQUEST) + 401 -> NetworkResult.Error(NetworkError.UNAUTHORIZED) + 404 -> NetworkResult.Error(NetworkError.NOT_FOUND) + 408 -> NetworkResult.Error(NetworkError.REQUEST_TIMEOUT) + 429 -> NetworkResult.Error(NetworkError.TOO_MANY_REQUESTS) + in 500..599 -> NetworkResult.Error(NetworkError.SERVER) + else -> { + println("Status code $status") + NetworkResult.Error(NetworkError.UNKNOWN) + } + } + } + } + } + } + } + return null + } +} diff --git a/core-base/network/src/desktopMain/kotlin/org/mifos/corebase/network/KtorHttpClient.desktop.kt b/core-base/network/src/desktopMain/kotlin/org/mifos/corebase/network/KtorHttpClient.desktop.kt new file mode 100644 index 0000000000..7461470769 --- /dev/null +++ b/core-base/network/src/desktopMain/kotlin/org/mifos/corebase/network/KtorHttpClient.desktop.kt @@ -0,0 +1,18 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package org.mifos.corebase.network + +import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig +import io.ktor.client.engine.okhttp.OkHttp + +actual fun httpClient(config: HttpClientConfig<*>.() -> Unit) = HttpClient(OkHttp) { + config(this) +} diff --git a/core-base/network/src/jsMain/kotlin/org/mifos/corebase/network/KtorHttpClient.js.kt b/core-base/network/src/jsMain/kotlin/org/mifos/corebase/network/KtorHttpClient.js.kt new file mode 100644 index 0000000000..db6da4bc83 --- /dev/null +++ b/core-base/network/src/jsMain/kotlin/org/mifos/corebase/network/KtorHttpClient.js.kt @@ -0,0 +1,18 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package org.mifos.corebase.network + +import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig +import io.ktor.client.engine.js.Js + +actual fun httpClient(config: HttpClientConfig<*>.() -> Unit) = HttpClient(Js) { + config(this) +} diff --git a/core-base/network/src/nativeMain/kotlin/org/mifos/corebase/network/KtorHttpClient.native.kt b/core-base/network/src/nativeMain/kotlin/org/mifos/corebase/network/KtorHttpClient.native.kt new file mode 100644 index 0000000000..2a7c834e80 --- /dev/null +++ b/core-base/network/src/nativeMain/kotlin/org/mifos/corebase/network/KtorHttpClient.native.kt @@ -0,0 +1,18 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package org.mifos.corebase.network + +import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig +import io.ktor.client.engine.darwin.Darwin + +actual fun httpClient(config: HttpClientConfig<*>.() -> Unit) = HttpClient(Darwin) { + config(this) +} diff --git a/core-base/network/src/wasmJsMain/kotlin/org/mifos/corebase/network/KtorHttpClient.wasmJs.kt b/core-base/network/src/wasmJsMain/kotlin/org/mifos/corebase/network/KtorHttpClient.wasmJs.kt new file mode 100644 index 0000000000..db6da4bc83 --- /dev/null +++ b/core-base/network/src/wasmJsMain/kotlin/org/mifos/corebase/network/KtorHttpClient.wasmJs.kt @@ -0,0 +1,18 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package org.mifos.corebase.network + +import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig +import io.ktor.client.engine.js.Js + +actual fun httpClient(config: HttpClientConfig<*>.() -> Unit) = HttpClient(Js) { + config(this) +} diff --git a/core-base/platform/.gitignore b/core-base/platform/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/core-base/platform/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core-base/platform/README.md b/core-base/platform/README.md new file mode 100644 index 0000000000..f20533b524 --- /dev/null +++ b/core-base/platform/README.md @@ -0,0 +1,723 @@ +# Platform Module Documentation + +## Overview + +The `platform` module provides a comprehensive abstraction layer for platform-specific +implementations across multiple targets (Android, Desktop, JS, Native, WasmJs) in a Kotlin +Multiplatform project. It follows the expect/actual pattern to create uniform APIs that work +seamlessly across platforms while maintaining native functionality. + +### Core Purpose + +- Isolate platform-specific code to improve maintainability +- Provide consistent APIs across platforms +- Enable platform-specific optimizations without changing client code +- Support Kotlin Multiplatform Project (KMP) architecture + +## Architecture Design + +### Composition Pattern + +The module uses Jetpack Compose's `CompositionLocal` pattern to provide platform-specific +implementations throughout the application without explicit dependency injection. This creates a +hierarchy of providers that can be accessed from any composable function. + +``` +App +└── LocalManagerProvider + ├── LocalAppReviewManager + ├── LocalIntentManager + └── LocalAppUpdateManager +``` + +### Platform Implementation Strategy + +For each platform, three levels of abstraction are implemented: + +1. **Common Interfaces**: Defined in `commonMain` using `expect` declarations +2. **Platform Interfaces**: Platform-specific abstractions in respective source sets +3. **Concrete Implementations**: Platform-specific implementations of the interfaces + +``` +commonMain +├── Interfaces (expect) +├── Models +└── Utilities + +androidMain +├── Concrete implementations +└── Android-specific utilities + +desktopMain/jsMain/nativeMain/wasmJsMain +└── Placeholder implementations +``` + +## Common Interfaces and Types + +### AppContext + +```kotlin +// Platform-agnostic representation of context +expect abstract class AppContext + +// Access to current context +expect val LocalContext: ProvidableCompositionLocal + +// Access to current activity +expect val AppContext.activity: Any +``` + +The `AppContext` provides a platform-agnostic way to access contextual information needed for +platform operations: +> Use `LocalContext.current` to get AppContext Aka `android.content.Context` +- `AppContext`: Represents the platform's context (e.g., `android.content.Context`) +- `LocalContext`: Provides access to the current `AppContext` through Compose's `CompositionLocal` +- `AppContext.activity`: Provides access to the current activity (e.g., `android + +### Manager Providers + +```kotlin +@Composable +expect fun LocalManagerProvider( + context: AppContext, + content: @Composable () -> Unit, +) +``` + +This composable function sets up the platform-specific managers and provides them through +`CompositionLocalProvider`. It's designed to wrap your app's content and make all managers available +to child composables. + +### IntentManager + +```kotlin +interface IntentManager { + // Launch a platform-specific intent + fun startActivity(intent: Any) + + // Open a URI in an appropriate app + fun launchUri(uri: String) + + // Share text with platform sharing mechanism + fun shareText(text: String) + + // Share a file with appropriate MIME type + fun shareFile(fileUri: String, mimeType: MimeType) + + // Extract shared data from incoming intents + fun getShareDataFromIntent(intent: Any): ShareData? + + // Create an intent for document creation + fun createDocumentIntent(fileName: String): Any + + // Launch application settings + fun startApplicationDetailsSettingsActivity() + + // Open default email application + fun startDefaultEmailApplication() + + // Data wrapper for incoming shared content + sealed class ShareData { + data class TextSend(val subject: String?, val text: String) : ShareData() + // Extensible for future share types (images, files, etc.) + } +} +``` + +The `IntentManager` provides platform-agnostic operations for working with platform-specific intents +and sharing mechanisms. It handles: + +- Activity and URI launching +- Content sharing +- Settings navigation +- Document creation +- Handling incoming shared content + +### AppReviewManager + +```kotlin +interface AppReviewManager { + // Trigger platform's native review prompt + fun promptForReview() + + // Launch custom review implementation + fun promptForCustomReview() +} +``` + +The `AppReviewManager` abstracts in-app review functionality: + +- On Android: Uses Google Play In-App Review API +- On other platforms: Provides placeholder implementations for future extension + +### AppUpdateManager + +```kotlin +interface AppUpdateManager { + // Check for available updates + fun checkForAppUpdate() + + // Resume interrupted update processes + fun checkForResumeUpdateState() +} +``` + +The `AppUpdateManager` handles update checking and flow management: + +- On Android: Implements Google Play In-App Update API +- On other platforms: Provides placeholder implementations + +### MimeType + +```kotlin +enum class MimeType(val value: String, vararg val extensions: String) { + // Images + IMAGE_JPEG("image/jpeg", "jpg", "jpeg"), + IMAGE_PNG("image/png", "png"), + // ... many more types + + // Default for unknown types + UNKNOWN("application/octet-stream"), + + companion object { + // Maps file extensions to MimeType + private val extensionToMimeType = mutableMapOf() + + init { + // Populate the map during initialization + entries.forEach { mimeType -> + mimeType.extensions.forEach { extension -> + extensionToMimeType[extension] = mimeType + } + } + } + + // Get MimeType from file extension + fun fromExtension(extension: String): MimeType + + // Get MimeType from filename + fun fromFileName(fileName: String): MimeType + } +} +``` + +The `MimeType` enum provides a comprehensive catalog of MIME types with: + +- String representation for platform APIs +- Associated file extensions +- Helper methods for determining types from filenames or extensions +- Organized categories (images, videos, audio, documents, archives) + +## Android Implementation + +### AppContext (Android) + +```kotlin +actual typealias AppContext = android.content.Context + +actual val LocalContext: ProvidableCompositionLocal + get() = androidx.compose.ui.platform.LocalContext + +actual val AppContext.activity: Any + @Composable + get() = requireNotNull(LocalActivity.current) +``` + +The Android implementation: + +- Maps `AppContext` directly to Android's `Context` +- Uses Compose UI's `LocalContext` for provider +- Returns the current activity from `LocalActivity` + +### IntentManagerImpl (Android) + +```kotlin +class IntentManagerImpl(private val context: Context) : IntentManager { + // Implementation details +} +``` + +Key implementation features: + +1. **URI Handling**: + - Handles `androidapp://` scheme for app store links + - Normalizes schemes for web URLs + - Handles platform-specific intents + +2. **Activity Starting**: + - Uses Android's `startActivity` to launch intents + - Catches `ActivityNotFoundException` to prevent crashes + +3. **Sharing**: + - Creates `ACTION_SEND` intents for text sharing + - Handles file sharing with appropriate MIME types + - Adds promotional text to file shares + +4. **Intent Processing**: + - Extracts text content from incoming share intents + - Creates document intents with appropriate MIME types + +5. **Settings Navigation**: + - Opens application details settings + - Launches default email application + +6. **Play Store Interaction**: + - Constructs Play Store URIs for app installations + - Falls back to Play Store when direct app launch fails + +### AppReviewManagerImpl (Android) + +```kotlin +class AppReviewManagerImpl(private val activity: Activity) : AppReviewManager { + override fun promptForReview() { + val manager = ReviewManagerFactory.create(activity) + val request = manager.requestReviewFlow() + request.addOnCompleteListener { task -> + if (task.isSuccessful) { + val reviewInfo = task.result + manager.launchReviewFlow(activity, reviewInfo) + } else { + Log.e("Failed to launch review flow.", task.exception?.message.toString()) + } + } + + if (BuildConfig.DEBUG) { + Log.d("ReviewManager", "Prompting for review") + } + } + + override fun promptForCustomReview() { + // TODO:: Implement custom review flow + } +} +``` + +Implementation details: + +- Uses Google Play Core library's `ReviewManagerFactory` +- Handles asynchronous flow with callbacks +- Logs failures for debugging +- Includes debug logging for development builds +- Placeholder for custom review implementation + +### AppUpdateManagerImpl (Android) + +```kotlin +class AppUpdateManagerImpl(private val activity: Activity) : AppUpdateManager { + private val manager = AppUpdateManagerFactory.create(activity) + private val updateOptions = AppUpdateOptions + .newBuilder(AppUpdateType.IMMEDIATE) + .setAllowAssetPackDeletion(false) + .build() + + // Implementation details +} +``` + +Key features: + +- Uses Google Play Core library's `AppUpdateManagerFactory` +- Configures for immediate update type +- Skips update checks in debug builds +- Checks for and handles interrupted update flows +- Uses success/failure listeners for async operations +- Implements request code handling for update flow + +### LocalManagerProviders (Android) + +```kotlin +@Composable +actual fun LocalManagerProvider( + context: AppContext, + content: @Composable () -> Unit, +) { + val activity = context.activity as Activity + CompositionLocalProvider( + LocalAppReviewManager provides AppReviewManagerImpl(activity), + LocalIntentManager provides IntentManagerImpl(activity), + LocalAppUpdateManager provides AppUpdateManagerImpl(activity), + ) { + content() + } +} +``` + +This implementation: + +- Extracts the Android `Activity` from the context +- Creates concrete Android implementations of each manager +- Provides them through `CompositionLocalProvider` + +## Non-Android Platform Implementations + +For Desktop, JS, Native, and WasmJs platforms, the implementations follow similar patterns: + +### Context Implementation + +```kotlin +actual abstract class AppContext private constructor() { + companion object { + val INSTANCE = object : AppContext() {} + } +} + +actual val LocalContext: ProvidableCompositionLocal + get() = staticCompositionLocalOf { AppContext.INSTANCE } + +actual val AppContext.activity: Any + @Composable + get() = AppContext.INSTANCE +``` + +The non-Android implementations: + +- Use a singleton pattern via companion object +- Provide the same instance for both context and activity + +### Manager Implementations + +```kotlin +class IntentManagerImpl : IntentManager { + override fun startActivity(intent: Any) { + // TODO("Not yet implemented") + } + + // Other methods with TODO placeholders +} + +class AppReviewManagerImpl : AppReviewManager { + override fun promptForReview() { + // Empty implementation + } + + override fun promptForCustomReview() { + // TODO:: Implement custom review flow + } +} + +class AppUpdateManagerImpl : AppUpdateManager { + override fun checkForAppUpdate() { + // Empty implementation + } + + override fun checkForResumeUpdateState() { + // Empty implementation + } +} +``` + +These implementations: + +- Provide empty or placeholder implementations +- Use TODO comments to mark future implementation points +- Return default values for required return types + +## Advanced Usage Examples + +### Using IntentManager for Deep Linking + +```kotlin +@Composable +fun DeepLinkHandler(uri: String?) { + val intentManager = LocalIntentManager.current + + LaunchedEffect(uri) { + uri?.let { + intentManager.launchUri(it) + } + } +} +``` + +### Implementing Custom Review Flow + +```kotlin +class MyCustomReviewManager( + private val appReviewManager: AppReviewManager = LocalAppReviewManager.current +) { + fun showReviewAfterSuccessfulOperation(operationCount: Int) { + // Track usage and show review at appropriate times + if (operationCount > 5 && shouldShowReview()) { + appReviewManager.promptForReview() + markReviewShown() + } + } + + private fun shouldShowReview(): Boolean { + // Your custom logic + return true + } + + private fun markReviewShown() { + // Your tracking logic + } +} +``` + +### Handling Incoming Shared Content + +```kotlin +@Composable +fun ShareReceiver(intent: Any) { + val intentManager = LocalIntentManager.current + val shareData = intentManager.getShareDataFromIntent(intent) + + when (shareData) { + is IntentManager.ShareData.TextSend -> { + // Handle received text + Text("Received: ${shareData.text}") + } + else -> { + // Handle other types or null + Text("No sharable content found") + } + } +} +``` + +### Managing App Updates + +```kotlin +@Composable +fun UpdateCheckScreen() { + val updateManager = LocalAppUpdateManager.current + val networkAvailable = rememberNetworkState() + + LaunchedEffect(networkAvailable) { + if (networkAvailable) { + updateManager.checkForAppUpdate() + } + } + + // UI content +} +``` + +## Integration Patterns + +### Basic Setup in App Root + +```kotlin +@Composable +fun App() { + val context = LocalContext.current + + LocalManagerProvider(context) { + AppNavigation() + } +} +``` + +### With Navigation Component + +```kotlin +@Composable +fun AppWithNavigation() { + val context = LocalContext.current + val navController = rememberNavController() + + LocalManagerProvider(context) { + NavHost(navController, startDestination = "home") { + composable("home") { HomeScreen() } + composable("settings") { SettingsScreen() } + // Other destinations + } + } +} +``` + +### In Activities with Manual Initialization + +```kotlin +class MainActivity : ComponentActivity() { + private lateinit var appUpdateManager: AppUpdateManager + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + appUpdateManager = AppUpdateManagerImpl(this) + + setContent { + val context = LocalContext.current + + LocalManagerProvider(context) { + // App content + } + } + } + + override fun onResume() { + super.onResume() + appUpdateManager.checkForResumeUpdateState() + } +} +``` + +## Best Practices + +### 1. Manager Access + +- Use `LocalX.current` within composables +- Inject managers via parameters for testability +- Don't store managers in view models unless necessary + +```kotlin +// Good practice +@Composable +fun MyScreen( + intentManager: IntentManager = LocalIntentManager.current +) { + // Use intentManager +} + +// For testing +@Test +fun testMyScreen() { + val mockIntentManager = MockIntentManager() + composeTestRule.setContent { + MyScreen(intentManager = mockIntentManager) + } +} +``` + +### 2. Platform-Specific Code + +- Keep platform-specific code within manager implementations +- Use conditional compilation for minor platform differences +- Create separate high-level abstractions for major platform differences + +### 3. Error Handling + +- Handle platform-specific exceptions within manager implementations +- Provide consistent error reporting across platforms +- Log detailed errors in debug builds + +### 4. Testing + +- Create test fakes or mocks of manager interfaces +- Test platform-specific implementations separately +- Use dependency injection for testability + +## Extending the Platform Module + +### Adding New Manager Types + +1. Define the interface in `commonMain`: + ```kotlin + interface MyNewManager { + fun doSomething() + } + ``` + +2. Create the `CompositionLocal` provider: + ```kotlin + val LocalMyNewManager: ProvidableCompositionLocal = compositionLocalOf { + error("CompositionLocal MyNewManager not present") + } + ``` + +3. Implement for each platform: + ```kotlin + // Android + class MyNewManagerImpl(private val context: Context) : MyNewManager { + override fun doSomething() { + // Android implementation + } + } + + // Other platforms + class MyNewManagerImpl : MyNewManager { + override fun doSomething() { + // Implementation or placeholder + } + } + ``` + +4. Update `LocalManagerProvider` for each platform: + ```kotlin + @Composable + actual fun LocalManagerProvider( + context: AppContext, + content: @Composable () -> Unit, + ) { + // Existing providers + CompositionLocalProvider( + // Existing providers + LocalMyNewManager provides MyNewManagerImpl(context), + ) { + content() + } + } + ``` + +### Adding New Platform Targets + +1. Create the appropriate source set in `build.gradle.kts` +2. Implement the required `actual` declarations +3. Create platform-specific manager implementations + +## Troubleshooting + +### Common Issues + +1. **CompositionLocal errors**: + ``` + java.lang.IllegalStateException: CompositionLocal LocalIntentManager not present + ``` + + **Solution**: Ensure your composable is called within the scope of a `LocalManagerProvider`. + +2. **Context casting errors**: + ``` + java.lang.ClassCastException: android.content.Context cannot be cast to android.app.Activity + ``` + + **Solution**: Ensure you're using an Activity context when required. + +3. **Permissions issues**: + ``` + java.lang.SecurityException: Permission Denial: starting Intent + ``` + + **Solution**: Verify required permissions are declared in AndroidManifest.xml. + +### Platform-Specific Issues + +**Android**: + +- In-App Review not showing: Google limits frequency of review prompts +- Update flow interruptions: Handle onActivityResult and resume the flow + +**Desktop/Web/Native**: + +- Placeholder implementations: Replace TODOs with actual implementations + +## Design Philosophy + +The platform module follows several key design principles: + +1. **Separation of Concerns**: Isolates platform-specific code +2. **Interface Segregation**: Each manager has a focused responsibility +3. **Dependency Inversion**: High-level modules depend on abstractions +4. **Composition Over Inheritance**: Uses composition for flexibility + +This approach allows for: + +- Platform-specific optimizations +- Easy extensibility +- Testability +- Code reuse across platforms + +## Relation to Project Architecture + +In the overall architecture: + +1. UI components depend on platform managers via CompositionLocal +2. Managers abstract platform-specific functionality +3. The common module provides cross-platform interfaces +4. Each platform module provides concrete implementations + +This creates a clean dependency flow: + +``` +UI → Managers (Interface) → Platform Implementation +``` \ No newline at end of file diff --git a/core-base/platform/build.gradle.kts b/core-base/platform/build.gradle.kts new file mode 100644 index 0000000000..7b32e403e9 --- /dev/null +++ b/core-base/platform/build.gradle.kts @@ -0,0 +1,61 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +import org.gradle.kotlin.dsl.implementation + +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/kmp-project-template/blob/main/LICENSE + */ +plugins { + alias(libs.plugins.mifos.kmp.library) + alias(libs.plugins.jetbrainsCompose) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.kotlin.parcelize) +} + +android { + namespace = "template.core.base.platform" + + buildFeatures { + buildConfig = true + } +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(compose.ui) + implementation(compose.runtime) + implementation(libs.calf.permissions) + } + + androidMain.dependencies { + implementation(libs.androidx.activity.ktx) + implementation(libs.androidx.activity.compose) + + implementation(libs.androidx.metrics) + implementation(libs.androidx.browser) + implementation(libs.androidx.compose.runtime) + + implementation(compose.material3) + + implementation(libs.review) + implementation(libs.review.ktx) + + implementation(libs.app.update.ktx) + implementation(libs.app.update) + } + } +} \ No newline at end of file diff --git a/core-base/platform/consumer-rules.pro b/core-base/platform/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/core-base/platform/src/androidMain/kotlin/template/core/base/platform/LocalManagerProviders.android.kt b/core-base/platform/src/androidMain/kotlin/template/core/base/platform/LocalManagerProviders.android.kt new file mode 100644 index 0000000000..f634c52ea1 --- /dev/null +++ b/core-base/platform/src/androidMain/kotlin/template/core/base/platform/LocalManagerProviders.android.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.platform + +import android.app.Activity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import template.core.base.platform.context.AppContext +import template.core.base.platform.context.activity +import template.core.base.platform.intent.IntentManagerImpl +import template.core.base.platform.review.AppReviewManagerImpl +import template.core.base.platform.update.AppUpdateManagerImpl + +/** + * Android-specific implementation of the LocalManagerProvider composable function. + * + * This implementation initializes the platform-specific manager instances required + * by the application and provides them to the composition hierarchy through + * CompositionLocal providers. The function handles the creation and provision of: + * + * - AppReviewManager: For managing in-app review requests through Google Play + * - IntentManager: For handling Android-specific intent operations + * - AppUpdateManager: For managing application updates through Google Play + * + * The function retrieves the current Activity from the provided AppContext and uses + * it to initialize each manager implementation. All managers are then made available + * to child composables through their respective CompositionLocal providers. + * + * @param context The Android Context used to initialize the managers + * @param content The composable content where the managers will be available + */ +@Composable +actual fun LocalManagerProvider( + context: AppContext, + content: @Composable () -> Unit, +) { + val activity = context.activity as Activity + CompositionLocalProvider( + LocalAppReviewManager provides AppReviewManagerImpl(activity), + LocalIntentManager provides IntentManagerImpl(activity), + LocalAppUpdateManager provides AppUpdateManagerImpl(activity), + ) { + content() + } +} diff --git a/core-base/platform/src/androidMain/kotlin/template/core/base/platform/context/AppContext.android.kt b/core-base/platform/src/androidMain/kotlin/template/core/base/platform/context/AppContext.android.kt new file mode 100644 index 0000000000..410abd97f0 --- /dev/null +++ b/core-base/platform/src/androidMain/kotlin/template/core/base/platform/context/AppContext.android.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.platform.context + +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidableCompositionLocal + +/** + * Android-specific implementation of AppContext. + * + * For Android platforms, the application context is represented by Android's native + * Context class, which provides access to application-specific resources, system services, + * and other platform functionality. + */ +actual typealias AppContext = android.content.Context + +/** + * Android-specific implementation of LocalContext. + * + * This delegates to the standard LocalContext provided by the Jetpack Compose UI library, + * which makes the current Android Context available throughout the composition hierarchy. + * + * @return A CompositionLocal containing the current Android Context + */ +actual val LocalContext: ProvidableCompositionLocal + get() = androidx.compose.ui.platform.LocalContext + +/** + * Android-specific implementation of the activity property. + * + * Retrieves the current Activity instance from the LocalActivity CompositionLocal. + * This implementation requires that a valid Activity is present in the composition hierarchy; + * otherwise, it will throw an IllegalStateException. + * + * @throws IllegalStateException if no Activity is available in the current composition + * @return The current Activity instance + */ +actual val AppContext.activity: Any + @Composable + get() = requireNotNull(LocalActivity.current) diff --git a/core-base/platform/src/androidMain/kotlin/template/core/base/platform/garbage/GarbageCollectionManager.android.kt b/core-base/platform/src/androidMain/kotlin/template/core/base/platform/garbage/GarbageCollectionManager.android.kt new file mode 100644 index 0000000000..ac13801fc2 --- /dev/null +++ b/core-base/platform/src/androidMain/kotlin/template/core/base/platform/garbage/GarbageCollectionManager.android.kt @@ -0,0 +1,14 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.platform.garbage + +@Suppress("ExplicitGarbageCollectionCall") +actual val garbageCollector: () -> Unit + get() = { Runtime.getRuntime().gc() } diff --git a/core-base/platform/src/androidMain/kotlin/template/core/base/platform/intent/IntentManagerImpl.kt b/core-base/platform/src/androidMain/kotlin/template/core/base/platform/intent/IntentManagerImpl.kt new file mode 100644 index 0000000000..221df75ddb --- /dev/null +++ b/core-base/platform/src/androidMain/kotlin/template/core/base/platform/intent/IntentManagerImpl.kt @@ -0,0 +1,297 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.platform.intent + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.content.IntentSender +import android.graphics.Bitmap +import android.net.Uri +import android.os.Build +import android.provider.Settings +import android.util.Log +import android.webkit.MimeTypeMap +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.core.content.FileProvider +import androidx.core.net.toUri +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import template.core.base.platform.model.MimeType +import template.core.base.platform.utils.isBuildVersionBelow +import java.io.File +import java.io.FileOutputStream +import java.io.IOException + +/** + * Android-specific implementation of the IntentManager interface. + * + * This class provides concrete implementations of intent operations using Android's Intent system. + * It handles activities, URI navigation, content sharing, file operations, and system settings + * navigation using the standard Android Intent mechanisms. + * + * @property context The Android Context used to start activities and access system services + */ +class IntentManagerImpl( + private val context: Context, +) : IntentManager { + /** + * Starts an Android activity with the provided intent. + * + * This method attempts to start an activity with the provided Intent object. If the activity + * cannot be found (ActivityNotFoundException), the exception is caught and ignored to prevent + * application crashes. + * + * @param intent The Android Intent object to be processed, cast from Any + */ + override fun startActivity(intent: Any) { + try { + Log.d("IntentManagerImpl", "Starting activity: $intent") + context.startActivity(intent as Intent) + } catch (_: ActivityNotFoundException) { + // no-op + } + } + + /** + * Opens the specified URI in an appropriate application. + * + * This method supports both standard URIs (http, https, etc.) and custom "androidapp://" + * URIs for launching applications from the Play Store. For standard URIs without a scheme, + * HTTPS is automatically applied as the default scheme. + * + * The method handles: + * - App store URIs (androidapp://) to open Play Store or launch the app directly (on Android 13+) + * - Standard web and deep link URIs using ACTION_VIEW intent + * + * @param uri The URI string to be opened + */ + override fun launchUri(uri: String) { + Log.d("IntentManagerImpl", "Launching URI: $uri") + val androidUri = uri.toUri() + if (androidUri.scheme.equals(other = "androidapp", ignoreCase = true)) { + val packageName = androidUri.toString().removePrefix(prefix = "androidapp://") + if (isBuildVersionBelow(Build.VERSION_CODES.TIRAMISU)) { + startActivity(createPlayStoreIntent(packageName)) + } else { + try { + context + .packageManager + .getLaunchIntentSenderForPackage(packageName) + .sendIntent(context, Activity.RESULT_OK, null, null, null) + } catch (_: IntentSender.SendIntentException) { + startActivity(createPlayStoreIntent(packageName)) + } + } + } else { + val newUri = if (androidUri.scheme == null) { + androidUri.buildUpon().scheme("https").build() + } else { + androidUri.normalizeScheme() + } + startActivity(Intent(Intent.ACTION_VIEW, newUri)) + } + } + + /** + * Shares text content with other applications via Android's share sheet. + * + * This method creates an ACTION_SEND intent with the provided text and displays + * the Android system share chooser to allow the user to select a target application. + * + * @param text The text content to be shared + */ + override fun shareText(text: String) { + val sendIntent: Intent = Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_TEXT, text) + type = "text/plain" + } + startActivity(Intent.createChooser(sendIntent, null)) + } + + /** + * Shares a file with other applications via Android's share sheet. + * + * This method creates an ACTION_SEND intent with the provided file URI and displays + * the Android system share chooser. It automatically adds promotional text about the app + * to the shared content. + * + * @param fileUri The URI string pointing to the file to be shared + * @param mimeType The MIME type of the file to help receiving applications handle it properly + */ + override fun shareFile(fileUri: String, mimeType: MimeType) { + val shareIntent = Intent(Intent.ACTION_SEND).apply { + // Add file URI directly + putExtra(Intent.EXTRA_STREAM, fileUri.toUri()) + + // Add promotional text + putExtra( + Intent.EXTRA_TEXT, + "*Downloaded using our awesome app!* \n\n" + + "*Download now from the Play Store!* \n" + + "https://play.google.com/store/apps/details?id=${context.packageName}", + ) + + // Grant read permission to receiving app + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + type = mimeType.value + } + + // Create chooser to let user pick sharing app + val chooserIntent = Intent.createChooser( + shareIntent, + "Share your media", + ) + + startActivity(chooserIntent) + } + + /** + * Shares a file with other applications along with custom text content. + * + * This method creates an ACTION_SEND intent with the provided file URI and custom text, + * then displays the Android system share chooser. + * + * @param fileUri The URI string pointing to the file to be shared + * @param mimeType The MIME type of the file to help receiving applications handle it properly + * @param extraText Additional text to include with the shared file + */ + override fun shareFile(fileUri: String, mimeType: MimeType, extraText: String) { + val shareIntent = Intent(Intent.ACTION_SEND).apply { + // Add file URI directly + putExtra(Intent.EXTRA_STREAM, fileUri.toUri()) + + // Add promotional text + putExtra(Intent.EXTRA_TEXT, extraText) + + // Grant read permission to receiving app + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + type = mimeType.value + } + + // Create chooser to let user pick sharing app + val chooserIntent = Intent.createChooser( + shareIntent, + "Share your media", + ) + + startActivity(chooserIntent) + } + + /** + * Shares an ImageBitmap with other applications. + * + * This method converts the provided ImageBitmap to an Android Bitmap, saves it to a + * temporary file in the cache directory, and shares it using an ACTION_SEND intent. + * FileProvider is used to generate a content:// URI for the saved image. + * + * @param title The title to be used in the share dialog + * @param image The ImageBitmap to be shared + */ + override suspend fun shareImage(title: String, image: ImageBitmap) { + val uri = saveImage(image.asAndroidBitmap(), context) + + val sendIntent: Intent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_STREAM, uri) + setDataAndType(uri, "image/png") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + val shareIntent = Intent.createChooser(sendIntent, title) + startActivity(shareIntent) + } + + /** + * Saves a Bitmap to a temporary file in the application's cache directory. + * + * This private helper method handles the IO operations to save an image to the + * cache directory and generate a content:// URI using FileProvider. + * + * @param image The Android Bitmap to save + * @param context The Android Context used to access the cache directory + * @return A content:// URI for the saved image or null if an error occurred + */ + private suspend fun saveImage(image: Bitmap, context: Context): Uri? { + return withContext(Dispatchers.IO) { + try { + val imagesFolder = File(context.cacheDir, "images") + imagesFolder.mkdirs() + val file = File(imagesFolder, "shared_image.png") + + val stream = FileOutputStream(file) + image.compress(Bitmap.CompressFormat.PNG, 100, stream) + stream.flush() + stream.close() + + FileProvider.getUriForFile(context, "${context.packageName}.provider", file) + } catch (e: IOException) { + Log.d("saving bitmap", "saving bitmap error ${e.message}") + null + } + } + } + + /** + * Creates an intent for document creation using Android's document provider system. + * + * This method generates an ACTION_CREATE_DOCUMENT intent that will prompt the user + * to choose a location and filename for a new document. The MIME type is automatically + * detected from the file extension when possible. + * + * @param fileName The suggested name for the document to be created + * @return An Android Intent configured for document creation + */ + override fun createDocumentIntent(fileName: String): Any { + return Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + // Attempt to get the MIME type from the file extension + val extension = MimeTypeMap.getFileExtensionFromUrl(fileName) + type = extension?.let { + MimeTypeMap.getSingleton().getMimeTypeFromExtension(it) + } ?: "*/*" + + addCategory(Intent.CATEGORY_OPENABLE) + putExtra(Intent.EXTRA_TITLE, fileName) + } + } + + /** + * Opens the application's details page in the system settings. + * + * This method creates and launches an intent to navigate to the current application's + * details page in the system settings, where users can manage permissions, notifications, + * and other app-specific settings. + */ + override fun startApplicationDetailsSettingsActivity() { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + intent.data = ("package:" + context.packageName).toUri() + startActivity(intent = intent) + } + + /** + * Creates an intent to open the Google Play Store for a specific application. + * + * This private helper method generates an ACTION_VIEW intent targeting the + * Google Play Store with the specified package name. + * + * @param packageName The package name of the application to view in Play Store + * @return An Android Intent configured to open the Play Store + */ + private fun createPlayStoreIntent(packageName: String): Intent { + val playStoreUri = "https://play.google.com/store/apps/details" + .toUri() + .buildUpon() + .appendQueryParameter("id", packageName) + .build() + return Intent(Intent.ACTION_VIEW, playStoreUri) + } +} diff --git a/core-base/platform/src/androidMain/kotlin/template/core/base/platform/review/AppReviewManagerImpl.kt b/core-base/platform/src/androidMain/kotlin/template/core/base/platform/review/AppReviewManagerImpl.kt new file mode 100644 index 0000000000..765fba1b41 --- /dev/null +++ b/core-base/platform/src/androidMain/kotlin/template/core/base/platform/review/AppReviewManagerImpl.kt @@ -0,0 +1,79 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.platform.review + +import android.app.Activity +import android.util.Log +import com.google.android.play.core.review.ReviewManagerFactory +import template.core.base.platform.BuildConfig + +/** + * Default implementation of the AppReviewManager interface for Android platforms. + * + * This class leverages the Google Play In-App Review API to request user reviews + * in accordance with platform guidelines. The implementation handles the complete + * review flow process, including requesting review information and launching the + * review dialog when appropriate. + * + * Note that the actual display of the review dialog is controlled by Google Play + * Store policies, which may limit the frequency of review prompts to prevent user + * fatigue. As such, calling the prompt methods does not guarantee that the review + * dialog will be displayed. + * + * @property activity The Android Activity context required to initiate the review flow + */ +class AppReviewManagerImpl( + private val activity: Activity, +) : AppReviewManager { + /** + * Prompts the user to review the application using the standard Google Play + * In-App Review flow. + * + * This implementation follows a two-step process: + * 1. Request review flow information from the Review Manager + * 2. Launch the review flow with the obtained information + * + * The method handles potential failures in the review flow process and logs + * errors appropriately. In debug builds, additional logging is provided to + * facilitate testing and development. + */ + override fun promptForReview() { + val manager = ReviewManagerFactory.create(activity) + val request = manager.requestReviewFlow() + request.addOnCompleteListener { task -> + if (task.isSuccessful) { + val reviewInfo = task.result + manager.launchReviewFlow(activity, reviewInfo) + } else { + Log.e("Failed to launch review flow.", task.exception?.message.toString()) + } + } + + if (BuildConfig.DEBUG) { + Log.d("ReviewManager", "Prompting for review") + } + } + + /** + * Provides infrastructure for a custom application-defined review experience. + * + * This method is intended for scenarios where the standard Google Play review flow + * is insufficient for application requirements. Custom implementations might include: + * - Multi-stage feedback collection + * - Conditional review flows based on user satisfaction + * - Alternative review destinations + * + * Note: This method currently contains a placeholder implementation and requires + * further development to implement the custom review logic. + */ + override fun promptForCustomReview() { + // TODO:: Implement custom review flow + } +} diff --git a/core-base/platform/src/androidMain/kotlin/template/core/base/platform/update/AppUpdateManagerImpl.kt b/core-base/platform/src/androidMain/kotlin/template/core/base/platform/update/AppUpdateManagerImpl.kt new file mode 100644 index 0000000000..eb35664dde --- /dev/null +++ b/core-base/platform/src/androidMain/kotlin/template/core/base/platform/update/AppUpdateManagerImpl.kt @@ -0,0 +1,137 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.platform.update + +import android.app.Activity +import android.util.Log +import com.google.android.play.core.appupdate.AppUpdateManagerFactory +import com.google.android.play.core.appupdate.AppUpdateOptions +import com.google.android.play.core.install.model.AppUpdateType +import com.google.android.play.core.install.model.UpdateAvailability +import com.google.android.play.core.ktx.isImmediateUpdateAllowed +import template.core.base.platform.BuildConfig + +/** + * Android-specific implementation of the AppUpdateManager interface that integrates + * with Google Play's In-App Update API. + * + * This class handles checking for application updates, initiating the update process, + * and resuming updates that were previously in progress. It is configured to use + * immediate updates, which interrupt the user experience and require the user to + * update before continuing to use the application. + * + * The implementation includes special handling for debug builds to prevent unwanted + * update prompts during development and testing. + * + * @property activity The Android Activity context required to initiate the update flow + */ +private const val UPDATE_MANAGER_REQUEST_CODE: Int = 9900 + +class AppUpdateManagerImpl( + private val activity: Activity, +) : AppUpdateManager { + /** + * The Google Play update manager instance that handles the update process. + */ + private val manager = AppUpdateManagerFactory.create(activity) + + /** + * Configuration options for the update process. + * + * This implementation uses immediate updates (AppUpdateType.IMMEDIATE), which + * interrupt the user experience and require the update to be completed before + * the user can continue using the application. Asset pack deletion is disabled + * to preserve any downloaded content. + */ + private val updateOptions = AppUpdateOptions + .newBuilder(AppUpdateType.IMMEDIATE) + .setAllowAssetPackDeletion(false) + .build() + + /** + * Checks for available application updates and initiates the update flow if + * an update is available and allowed. + * + * This method queries the Google Play Store for update information and, if an + * update is available and meets the configured criteria (immediate update type), + * launches the update flow. The update process is initiated using the activity's + * startUpdateFlowForResult method, which will handle the user interface for the + * update process. + * + * In debug builds, update checks are skipped to prevent interrupting the + * development process with update prompts. + */ + override fun checkForAppUpdate() { + if (!BuildConfig.DEBUG) { + manager + .appUpdateInfo + .addOnSuccessListener { info -> + val isUpdateAvailable = + info.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE + + val isUpdateAllowed = when (updateOptions.appUpdateType()) { + AppUpdateType.IMMEDIATE -> info.isImmediateUpdateAllowed + else -> false + } + + if (isUpdateAvailable && isUpdateAllowed) { + manager.startUpdateFlowForResult( + /* p0 = */ + info, + /* p1 = */ + activity, + /* p2 = */ + updateOptions, + /* p3 = */ + UPDATE_MANAGER_REQUEST_CODE, + ) + } + }.addOnFailureListener { + Log.d("Unable to update app!", "UpdateManager", it) + } + } else { + Log.d("UpdateManager", "Skipping update check in debug mode") + } + } + + /** + * Checks for and resumes any update process that was previously initiated but + * not completed. + * + * This method queries the update status and detects if there is an update in + * the DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS state, which indicates that an + * update was started but not completed. If such a state is detected, the update + * flow is restarted to allow the user to complete the update process. + * + * This method is typically called during application startup to ensure that + * interrupted update processes are properly resumed. + */ + override fun checkForResumeUpdateState() { + manager + .appUpdateInfo + .addOnSuccessListener { appUpdateInfo -> + if (appUpdateInfo.updateAvailability() + == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS + ) { + // If an in-app update is already running, resume the update. + manager.startUpdateFlowForResult( + /* p0 = */ + appUpdateInfo, + /* p1 = */ + activity, + /* p2 = */ + updateOptions, + /* p3 = */ + UPDATE_MANAGER_REQUEST_CODE, + ) + } + } + } +} diff --git a/core-base/platform/src/androidMain/kotlin/template/core/base/platform/utils/AndroidBuildUtils.kt b/core-base/platform/src/androidMain/kotlin/template/core/base/platform/utils/AndroidBuildUtils.kt new file mode 100644 index 0000000000..a73fbe7241 --- /dev/null +++ b/core-base/platform/src/androidMain/kotlin/template/core/base/platform/utils/AndroidBuildUtils.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.platform.utils + +import android.os.Build + +/** + * Determines if the current device's Android SDK version is below a specified version. + * + * This utility function compares the current device's Android SDK version with the + * provided version parameter. It is useful for implementing version-specific behavior + * in Android applications. + * + * @param version The Android SDK version to compare against (e.g., Build.VERSION_CODES.TIRAMISU) + * @return true if the current device's SDK version is below the specified version, + * false otherwise + */ +fun isBuildVersionBelow(version: Int): Boolean = version > Build.VERSION.SDK_INT diff --git a/core-base/platform/src/commonMain/kotlin/template/core/base/platform/LocalManagerProviders.kt b/core-base/platform/src/commonMain/kotlin/template/core/base/platform/LocalManagerProviders.kt new file mode 100644 index 0000000000..7ada5dec2f --- /dev/null +++ b/core-base/platform/src/commonMain/kotlin/template/core/base/platform/LocalManagerProviders.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.platform + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.compositionLocalOf +import template.core.base.platform.context.AppContext +import template.core.base.platform.intent.IntentManager +import template.core.base.platform.review.AppReviewManager +import template.core.base.platform.update.AppUpdateManager + +/** + * A composable function that provides platform-specific managers to the composition tree. + * + * This function initializes and provides various platform-specific managers + * (AppReviewManager, IntentManager, AppUpdateManager) to the composition through + * CompositionLocal providers. It acts as a central point for injecting + * platform-specific functionality into the Compose UI hierarchy. + * + * As an expect function, platform-specific implementations will be provided in + * each target platform's source set, allowing for platform-specific initialization + * while maintaining a consistent API across platforms. + * + * @param context The platform-specific AppContext to initialize the managers + * @param content The composable content where the managers will be available + */ +@Composable +expect fun LocalManagerProvider( + context: AppContext, + content: @Composable () -> Unit, +) + +/** + * Provides access to the app review manager throughout the app. + */ +val LocalAppReviewManager: ProvidableCompositionLocal = compositionLocalOf { + error("CompositionLocal AppReviewManager not present") +} + +/** + * Provides access to the intent manager throughout the app. + */ +val LocalIntentManager: ProvidableCompositionLocal = compositionLocalOf { + error("CompositionLocal LocalIntentManager not present") +} + +/** + * Provides access to the circumstance manager throughout the app. + */ +val LocalAppUpdateManager: ProvidableCompositionLocal = compositionLocalOf { + error("CompositionLocal LocalAppUpdateManager not present") +} diff --git a/core-base/platform/src/commonMain/kotlin/template/core/base/platform/context/AppContext.kt b/core-base/platform/src/commonMain/kotlin/template/core/base/platform/context/AppContext.kt new file mode 100644 index 0000000000..7fbfcc2123 --- /dev/null +++ b/core-base/platform/src/commonMain/kotlin/template/core/base/platform/context/AppContext.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.platform.context + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidableCompositionLocal + +/** + * Represents an abstract context for the application that provides platform-specific + * functionality. This class must be implemented in each platform-specific source set. + */ +expect abstract class AppContext + +/** + * A composition local that provides the current [AppContext] to the composition tree. + * This allows composable functions to access the platform-specific context without + * explicit parameters. + */ +expect val LocalContext: ProvidableCompositionLocal + +/** + * The platform-specific activity or view controller associated with the current context. + * + * This property is accessible only from within a Composable function. + * The return type is [Any] to support different platform-specific types + * (Activity on Android, UIViewController on iOS, etc.). + */ +@get:Composable +expect val AppContext.activity: Any diff --git a/core-base/platform/src/commonMain/kotlin/template/core/base/platform/di/PlatformModule.kt b/core-base/platform/src/commonMain/kotlin/template/core/base/platform/di/PlatformModule.kt new file mode 100644 index 0000000000..4dc1ba1956 --- /dev/null +++ b/core-base/platform/src/commonMain/kotlin/template/core/base/platform/di/PlatformModule.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.platform.di + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import org.koin.dsl.module +import template.core.base.platform.garbage.GarbageCollectionManager +import template.core.base.platform.garbage.GarbageCollectionManagerImpl + +val platformModule = module { + single { Dispatchers.Unconfined } + single { GarbageCollectionManagerImpl(get()) } +} diff --git a/core-base/platform/src/commonMain/kotlin/template/core/base/platform/garbage/GarbageCollectionManager.kt b/core-base/platform/src/commonMain/kotlin/template/core/base/platform/garbage/GarbageCollectionManager.kt new file mode 100644 index 0000000000..6808ff5fe9 --- /dev/null +++ b/core-base/platform/src/commonMain/kotlin/template/core/base/platform/garbage/GarbageCollectionManager.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.platform.garbage + +interface GarbageCollectionManager { + /** + * Calls the garbage collector on the [Runtime] in an effort to clear the unused resources in + * the heap. + */ + fun tryCollect() +} + +expect val garbageCollector: () -> Unit diff --git a/core-base/platform/src/commonMain/kotlin/template/core/base/platform/garbage/GarbageCollectionManagerImpl.kt b/core-base/platform/src/commonMain/kotlin/template/core/base/platform/garbage/GarbageCollectionManagerImpl.kt new file mode 100644 index 0000000000..325d82bc08 --- /dev/null +++ b/core-base/platform/src/commonMain/kotlin/template/core/base/platform/garbage/GarbageCollectionManagerImpl.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.platform.garbage + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Suppress("UnusedPrivateProperty") +class GarbageCollectionManagerImpl( + private val dispatcher: CoroutineDispatcher, + private val collector: () -> Unit = garbageCollector, +) : GarbageCollectionManager { + private val unconfinedScope = CoroutineScope(dispatcher) + private var collectionJob: Job = Job().apply { complete() } + + override fun tryCollect() { + collectionJob.cancel() + collectionJob = unconfinedScope.launch { + delay(timeMillis = GARBAGE_COLLECTION_INITIAL_DELAY_MS) + repeat(times = GARBAGE_COLLECTION_ATTEMPTS) { + delay(timeMillis = GARBAGE_COLLECTION_BASE_BACKOFF_MS * it) + garbageCollector() + } + } + } +} + +private const val GARBAGE_COLLECTION_ATTEMPTS: Int = 10 + +/** + * The base delay, in milliseconds, between a garbage collection attempt. The duration will be + * multiplied by the number of attempts made thus far. + */ +private const val GARBAGE_COLLECTION_BASE_BACKOFF_MS: Long = 100L + +/** + * The initial delay, in milliseconds, before the first garbage collection attempt. + */ +private const val GARBAGE_COLLECTION_INITIAL_DELAY_MS: Long = 100L diff --git a/core-base/platform/src/commonMain/kotlin/template/core/base/platform/intent/IntentManager.kt b/core-base/platform/src/commonMain/kotlin/template/core/base/platform/intent/IntentManager.kt new file mode 100644 index 0000000000..a7db4c69d7 --- /dev/null +++ b/core-base/platform/src/commonMain/kotlin/template/core/base/platform/intent/IntentManager.kt @@ -0,0 +1,81 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.platform.intent + +import androidx.compose.ui.graphics.ImageBitmap +import template.core.base.platform.model.MimeType + +/** + * Manages platform-specific intent operations and content sharing functionality. + * This interface abstracts platform differences for various system interactions + * such as launching activities, sharing content, and handling system intents. + */ +interface IntentManager { + /** + * Starts a platform-specific activity with the provided intent. + * + * @param intent The platform-specific intent object to be processed + */ + fun startActivity(intent: Any) + + /** + * Opens the specified URI in an appropriate application. + * Typically used for opening websites, deep links, or specific application URIs. + * + * @param uri The URI string to be opened + */ + fun launchUri(uri: String) + + /** + * Shares text content with other applications via the platform's share mechanism. + * + * @param text The text content to be shared + */ + fun shareText(text: String) + + /** + * Shares a file with other applications. + * + * @param fileUri The URI string pointing to the file to be shared + * @param mimeType The MIME type of the file to help receiving applications handle it properly + */ + fun shareFile(fileUri: String, mimeType: MimeType) + + /** + * Shares a file with other applications, including additional text content. + * + * @param fileUri The URI string pointing to the file to be shared + * @param mimeType The MIME type of the file to help receiving applications handle it properly + * @param extraText Additional text to include with the shared file + */ + fun shareFile(fileUri: String, mimeType: MimeType, extraText: String) + + /** + * Shares an image with other applications. + * + * @param title The title to use when sharing the image + * @param image The ImageBitmap to be shared + */ + suspend fun shareImage(title: String, image: ImageBitmap) + + /** + * Creates a platform-specific intent for document creation. + * + * @param fileName The suggested name for the document to be created + * @return A platform-specific intent object for document creation + */ + fun createDocumentIntent(fileName: String): Any + + /** + * Opens the application details settings screen for the current application. + * Typically used to direct users to app permissions, notifications, or other system settings. + */ + fun startApplicationDetailsSettingsActivity() +} diff --git a/core-base/platform/src/commonMain/kotlin/template/core/base/platform/model/MimeType.kt b/core-base/platform/src/commonMain/kotlin/template/core/base/platform/model/MimeType.kt new file mode 100644 index 0000000000..25970acde8 --- /dev/null +++ b/core-base/platform/src/commonMain/kotlin/template/core/base/platform/model/MimeType.kt @@ -0,0 +1,239 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.platform.model + +/** + * Represents standardized MIME (Multipurpose Internet Mail Extensions) types for various file formats. + * + * This enum provides a structured representation of common MIME types organized by categories + * (images, videos, audio, documents, archives) with their corresponding string values and file extensions. + * It offers utility methods to determine the appropriate MIME type from file extensions or filenames. + * + * @property value The standard MIME type string representation (e.g., "image/jpeg") + * @property extensions The file extensions associated with this MIME type (e.g., "jpg", "jpeg") + */ +enum class MimeType(val value: String, vararg val extensions: String) { + /** + * JPEG image format - "image/jpeg" + * Extensions: jpg, jpeg + */ + IMAGE_JPEG("image/jpeg", "jpg", "jpeg"), + + /** + * PNG image format - "image/png" + * Extension: png + */ + IMAGE_PNG("image/png", "png"), + + /** + * GIF image format - "image/gif" + * Extension: gif + */ + IMAGE_GIF("image/gif", "gif"), + + /** + * WebP image format - "image/webp" + * Extension: webp + */ + IMAGE_WEBP("image/webp", "webp"), + + /** + * BMP image format - "image/bmp" + * Extension: bmp + */ + IMAGE_BMP("image/bmp", "bmp"), + + /** + * SVG image format - "image/svg+xml" + * Extension: svg + */ + IMAGE_SVG("image/svg+xml", "svg"), + + /** + * MP4 video format - "video/mp4" + * Extension: mp4 + */ + VIDEO_MP4("video/mp4", "mp4"), + + /** + * WebM video format - "video/webm" + * Extension: webm + */ + VIDEO_WEBM("video/webm", "webm"), + + /** + * Matroska video format - "video/x-matroska" + * Extension: mkv + */ + VIDEO_MKV("video/x-matroska", "mkv"), + + /** + * AVI video format - "video/x-msvideo" + * Extension: avi + */ + VIDEO_AVI("video/x-msvideo", "avi"), + + /** + * QuickTime video format - "video/quicktime" + * Extension: mov + */ + VIDEO_MOV("video/quicktime", "mov"), + + /** + * MP3 audio format - "audio/mpeg" + * Extension: mp3 + */ + AUDIO_MPEG("audio/mpeg", "mp3"), + + /** + * WAV audio format - "audio/wav" + * Extension: wav + */ + AUDIO_WAV("audio/wav", "wav"), + + /** + * OGG audio format - "audio/ogg" + * Extension: ogg + */ + AUDIO_OGG("audio/ogg", "ogg"), + + /** + * M4A audio format - "audio/mp4" + * Extension: m4a + */ + AUDIO_M4A("audio/mp4", "m4a"), + + /** + * FLAC audio format - "audio/flac" + * Extension: flac + */ + AUDIO_FLAC("audio/flac", "flac"), + + /** + * PDF document format - "application/pdf" + * Extension: pdf + */ + APPLICATION_PDF("application/pdf", "pdf"), + + /** + * Microsoft Word document format - "application/msword" + * Extension: doc + */ + APPLICATION_DOC("application/msword", "doc"), + + /** + * Microsoft Word Open XML document format + * Extension: docx + */ + APPLICATION_DOCX( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "docx", + ), + + /** + * Microsoft Excel spreadsheet format - "application/vnd.ms-excel" + * Extension: xls + */ + APPLICATION_XLS("application/vnd.ms-excel", "xls"), + + /** + * Microsoft Excel Open XML spreadsheet format + * Extension: xlsx + */ + APPLICATION_XLSX("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "xlsx"), + + /** + * Microsoft PowerPoint presentation format - "application/vnd.ms-powerpoint" + * Extension: ppt + */ + APPLICATION_PPT("application/vnd.ms-powerpoint", "ppt"), + + /** + * Microsoft PowerPoint Open XML presentation format + * Extension: pptx + */ + APPLICATION_PPTX( + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "pptx", + ), + + /** + * Plain text format - "text/plain" + * Extension: txt + */ + TEXT_PLAIN("text/plain", "txt"), + + /** + * ZIP archive format - "application/zip" + * Extension: zip + */ + APPLICATION_ZIP("application/zip", "zip"), + + /** + * RAR archive format - "application/x-rar-compressed" + * Extension: rar + */ + APPLICATION_RAR("application/x-rar-compressed", "rar"), + + /** + * 7-Zip archive format - "application/x-7z-compressed" + * Extension: 7z + */ + APPLICATION_7Z("application/x-7z-compressed", "7z"), + + /** + * Default MIME type for unknown file formats - "application/octet-stream" + * Used when the specific MIME type cannot be determined. + */ + UNKNOWN("application/octet-stream"), + ; + + /** + * Contains utility methods for working with MIME types, including + * lookups by file extension and filename. + */ + companion object { + // Map to store file extensions and their corresponding MimeType + private val extensionToMimeType = mutableMapOf() + + init { + // Populate the map with extensions and their corresponding MimeType + entries.forEach { mimeType -> + mimeType.extensions.forEach { extension -> + extensionToMimeType[extension] = mimeType + } + } + } + + /** + * Returns the MimeType corresponding to the given file extension. + * If the extension is not found, returns UNKNOWN. + * + * @param extension The file extension to look up (without the leading dot) + * @return The corresponding MimeType or UNKNOWN if not found + */ + fun fromExtension(extension: String): MimeType = extensionToMimeType[extension.lowercase()] ?: UNKNOWN + + /** + * Returns the MimeType corresponding to the given file name. + * If the file name has no extension or the extension is not found, returns UNKNOWN. + * + * @param fileName The complete filename including its extension + * @return The corresponding MimeType or UNKNOWN if the extension is not recognized + */ + fun fromFileName(fileName: String): MimeType { + val extension = fileName.substringAfterLast( + delimiter = '.', + missingDelimiterValue = "", + ).lowercase() + return fromExtension(extension) + } + } +} diff --git a/core-base/platform/src/commonMain/kotlin/template/core/base/platform/review/AppReviewManager.kt b/core-base/platform/src/commonMain/kotlin/template/core/base/platform/review/AppReviewManager.kt new file mode 100644 index 0000000000..b3c4155d1c --- /dev/null +++ b/core-base/platform/src/commonMain/kotlin/template/core/base/platform/review/AppReviewManager.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.platform.review + +/** + * Manages application review requests across platforms. + * + * This interface abstracts the platform-specific implementations for requesting + * user reviews of the application. It provides methods to prompt users for app + * reviews using either standard system-provided flows or custom-designed review + * experiences. + * + * Platform-specific implementations of this interface typically integrate with: + * - Google Play In-App Review API on Android + * - StoreKit Review Controller on iOS + * - Other platform-specific review mechanisms + * + * Review requests should be triggered at appropriate moments in the user journey + * when the user has completed a meaningful interaction with the application and + * is likely to have a positive experience to report. + */ +interface AppReviewManager { + + /** + * Prompts the user to review the app using the platform's standard review flow. + * + * This method triggers the native system review prompt, which is typically + * managed by the platform to control frequency and prevent review fatigue. + * The actual display of the review prompt may be deferred or throttled by + * the platform based on internal policies. + */ + fun promptForReview() + + /** + * Prompts the user to review the app using a custom application-defined review flow. + * + * This method initiates a custom review experience designed within the application, + * which may include custom UI elements, multi-step processes, or conditional + * logic before directing users to the appropriate store page for leaving a review. + * + * Custom review flows provide more control over the user experience but require + * careful implementation to comply with platform guidelines and avoid potential + * rejection during app review processes. + */ + fun promptForCustomReview() +} diff --git a/core-base/platform/src/commonMain/kotlin/template/core/base/platform/update/AppUpdateManager.kt b/core-base/platform/src/commonMain/kotlin/template/core/base/platform/update/AppUpdateManager.kt new file mode 100644 index 0000000000..287d8d97c9 --- /dev/null +++ b/core-base/platform/src/commonMain/kotlin/template/core/base/platform/update/AppUpdateManager.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.platform.update + +/** + * Manages application update detection and processing. + * + * This interface abstracts platform-specific implementations for checking + * and managing application updates. It provides methods to initiate update checks + * and verify the state of previously initiated update processes. + * + * Platform-specific implementations of this interface typically integrate with: + * - Google Play In-App Updates API on Android + * - AppStoreKit on iOS + * - Other platform-specific update mechanisms + * + * The update management process is essential for ensuring users have access to + * the latest features, security patches, and performance improvements. + */ +interface AppUpdateManager { + + /** + * Initiates a check for available application updates. + * + * This method communicates with the relevant app distribution platform to + * determine if a newer version of the application is available for download. + * The implementation may handle the entire update flow, including presenting + * update dialogs to the user and facilitating the download and installation + * process, depending on platform capabilities. + */ + fun checkForAppUpdate() + + /** + * Verifies if there is an update process that was previously initiated but not completed. + * + * This method is typically called during application startup to determine if + * an update process needs to be resumed. Update processes may be interrupted + * by application termination, system restart, or other events, and this method + * allows the application to recover and continue the update process. + */ + fun checkForResumeUpdateState() +} diff --git a/core-base/platform/src/nonAndroidMain/kotlin/template/core/base/platform/LocalManagerProviders.native.kt b/core-base/platform/src/nonAndroidMain/kotlin/template/core/base/platform/LocalManagerProviders.native.kt new file mode 100644 index 0000000000..e2b4cd54f3 --- /dev/null +++ b/core-base/platform/src/nonAndroidMain/kotlin/template/core/base/platform/LocalManagerProviders.native.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.platform + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import template.core.base.platform.context.AppContext +import template.core.base.platform.intent.IntentManagerImpl +import template.core.base.platform.review.AppReviewManagerImpl +import template.core.base.platform.update.AppUpdateManagerImpl + +@Composable +actual fun LocalManagerProvider( + context: AppContext, + content: @Composable () -> Unit, +) { + CompositionLocalProvider( + LocalAppReviewManager provides AppReviewManagerImpl(), + LocalIntentManager provides IntentManagerImpl(), + LocalAppUpdateManager provides AppUpdateManagerImpl(), + ) { + content() + } +} diff --git a/core-base/platform/src/nonAndroidMain/kotlin/template/core/base/platform/context/AppContext.native.kt b/core-base/platform/src/nonAndroidMain/kotlin/template/core/base/platform/context/AppContext.native.kt new file mode 100644 index 0000000000..9da7ff9a3d --- /dev/null +++ b/core-base/platform/src/nonAndroidMain/kotlin/template/core/base/platform/context/AppContext.native.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.platform.context + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.staticCompositionLocalOf + +actual abstract class AppContext private constructor() { + companion object { + val INSTANCE = object : AppContext() {} + } +} + +actual val LocalContext: ProvidableCompositionLocal + get() = staticCompositionLocalOf { AppContext.INSTANCE } + +actual val AppContext.activity: Any + @Composable + get() = AppContext.INSTANCE diff --git a/core-base/platform/src/nonAndroidMain/kotlin/template/core/base/platform/garbage/GarbageCollectionManager.nonAndroid.kt b/core-base/platform/src/nonAndroidMain/kotlin/template/core/base/platform/garbage/GarbageCollectionManager.nonAndroid.kt new file mode 100644 index 0000000000..5250cc98b2 --- /dev/null +++ b/core-base/platform/src/nonAndroidMain/kotlin/template/core/base/platform/garbage/GarbageCollectionManager.nonAndroid.kt @@ -0,0 +1,13 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.platform.garbage + +actual val garbageCollector: () -> Unit + get() = {} diff --git a/core-base/platform/src/nonAndroidMain/kotlin/template/core/base/platform/intent/IntentManagerImpl.kt b/core-base/platform/src/nonAndroidMain/kotlin/template/core/base/platform/intent/IntentManagerImpl.kt new file mode 100644 index 0000000000..c9f9c0210c --- /dev/null +++ b/core-base/platform/src/nonAndroidMain/kotlin/template/core/base/platform/intent/IntentManagerImpl.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.platform.intent + +import androidx.compose.ui.graphics.ImageBitmap +import template.core.base.platform.model.MimeType + +class IntentManagerImpl : IntentManager { + override fun startActivity(intent: Any) { + // TODO("Not yet implemented") + } + + override fun launchUri(uri: String) { + // TODO("Not yet implemented") + } + + override fun shareText(text: String) { + // TODO("Not yet implemented") + } + + override fun shareFile(fileUri: String, mimeType: MimeType) { + // TODO("Not yet implemented") + } + + override fun shareFile(fileUri: String, mimeType: MimeType, extraText: String) { + TODO("Not yet implemented") + } + + override suspend fun shareImage(title: String, image: ImageBitmap) { + TODO("Not yet implemented") + } + + override fun createDocumentIntent(fileName: String): Any { + // TODO("Not yet implemented") + return Any() + } + + override fun startApplicationDetailsSettingsActivity() { + // TODO("Not yet implemented") + } +} diff --git a/core-base/platform/src/nonAndroidMain/kotlin/template/core/base/platform/review/AppReviewManagerImpl.kt b/core-base/platform/src/nonAndroidMain/kotlin/template/core/base/platform/review/AppReviewManagerImpl.kt new file mode 100644 index 0000000000..d4c1d7be59 --- /dev/null +++ b/core-base/platform/src/nonAndroidMain/kotlin/template/core/base/platform/review/AppReviewManagerImpl.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.platform.review + +/** + * Default implementation of [AppReviewManager]. + */ +class AppReviewManagerImpl : AppReviewManager { + override fun promptForReview() { + } + + override fun promptForCustomReview() { + // TODO:: Implement custom review flow + } +} diff --git a/core-base/platform/src/nonAndroidMain/kotlin/template/core/base/platform/update/AppUpdateManagerImpl.kt b/core-base/platform/src/nonAndroidMain/kotlin/template/core/base/platform/update/AppUpdateManagerImpl.kt new file mode 100644 index 0000000000..c22f52ac4f --- /dev/null +++ b/core-base/platform/src/nonAndroidMain/kotlin/template/core/base/platform/update/AppUpdateManagerImpl.kt @@ -0,0 +1,18 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.platform.update + +class AppUpdateManagerImpl : AppUpdateManager { + override fun checkForAppUpdate() { + } + + override fun checkForResumeUpdateState() { + } +} diff --git a/core-base/ui/.gitignore b/core-base/ui/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/core-base/ui/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core-base/ui/README.md b/core-base/ui/README.md new file mode 100644 index 0000000000..eec63d0235 --- /dev/null +++ b/core-base/ui/README.md @@ -0,0 +1,854 @@ +# Documentation: core-base/ui Module + +The `core-base/ui` module serves as the foundation for building consistent, cross-platform user +interfaces in Kotlin Multiplatform projects. This documentation explores the inner workings, +implementation details, and best practices for each component. + +## Architectural Foundation + +This module implements the unidirectional data flow pattern within the MVVM architecture, which +creates a predictable and testable application structure: + +1. **State flows down**: UI receives immutable state snapshots +2. **Actions flow up**: User interactions are sent as discrete actions +3. **Events are one-shot**: Navigation and notifications occur once, not continuously + +This pattern helps prevent common UI bugs like inconsistent state, navigation loops, and race +conditions by enforcing a strict cycle of state updates. + +## 1. BaseViewModel Implementation (`BaseViewModel.kt`) + +### Core Mechanics + +The `BaseViewModel` serves as the cornerstone for UI state management, using Kotlin coroutines and +channels to manage the application's data flow: + +```kotlin +abstract class BaseViewModel(initialState: S) : ViewModel() { + protected val mutableStateFlow: MutableStateFlow = MutableStateFlow(initialState) + private val eventChannel: Channel = Channel(capacity = Channel.UNLIMITED) + private val internalActionChannel: Channel = Channel(capacity = Channel.UNLIMITED) + + // Public immutable interfaces + val stateFlow: StateFlow = mutableStateFlow.asStateFlow() + val eventFlow: Flow = eventChannel.receiveAsFlow() + val actionChannel: SendChannel = internalActionChannel + + // Initialize action processing + init { + viewModelScope.launch { + internalActionChannel + .consumeAsFlow() + .collect { action -> handleAction(action) } + } + } + + protected abstract fun handleAction(action: A) +} +``` + +The `Channel.UNLIMITED` capacity ensures that actions and events won't be dropped if they're emitted +faster than they can be processed, which is crucial for maintaining UI integrity. + +### Advanced Usage Patterns + +Beyond the basic implementation, effective `BaseViewModel` usage includes: + +**1. State Splitting** + +For complex screens, consider splitting state into logical subgroups: + +```kotlin +data class ProfileState( + val userData: UserDataState = UserDataState(), + val settings: SettingsState = SettingsState(), + val interaction: InteractionState = InteractionState() +) + +data class UserDataState( + val isLoading: Boolean = false, + val user: User? = null, + val error: String? = null +) + +data class SettingsState( + val notifications: Boolean = true, + val darkMode: Boolean = false +) + +data class InteractionState( + val selectedTab: Tab = Tab.PROFILE, + val isEditMode: Boolean = false +) +``` + +This approach makes it easier to update only relevant portions of state and prevents unnecessary +recompositions. + +**2. Action Chaining** + +For complex operations that require multiple state updates: + +```kotlin +override fun handleAction(action: ProfileAction) { + when (action) { + is ProfileAction.UpdateProfile -> { + mutableStateFlow.value = state.copy( + userData = state.userData.copy(isLoading = true) + ) + + viewModelScope.launch { + try { + val updatedUser = userRepository.updateProfile(action.updates) + sendAction(ProfileAction.ProfileUpdateSuccess(updatedUser)) + } catch (e: Exception) { + sendAction(ProfileAction.ProfileUpdateFailure(e.message ?: "Unknown error")) + } + } + } + + is ProfileAction.ProfileUpdateSuccess -> { + mutableStateFlow.value = state.copy( + userData = state.userData.copy( + isLoading = false, + user = action.user, + error = null + ) + ) + sendEvent(ProfileEvent.ShowSuccessMessage("Profile updated successfully")) + } + + is ProfileAction.ProfileUpdateFailure -> { + mutableStateFlow.value = state.copy( + userData = state.userData.copy( + isLoading = false, + error = action.message + ) + ) + sendEvent(ProfileEvent.ShowErrorMessage(action.message)) + } + } +} +``` + +**3. Shared Actions** + +For actions that need to be processed by multiple ViewModels, define them in a shared location and +have each ViewModel handle the subset it cares about: + +```kotlin +sealed class AppAction { + object LogOut : AppAction() + data class NetworkStatusChanged(val isConnected: Boolean) : AppAction() + data class ThemeChanged(val isDarkMode: Boolean) : AppAction() +} + +// Then in ViewModels, handle relevant actions: +override fun handleAction(action: AppAction) { + when (action) { + is AppAction.ThemeChanged -> { + // Only handle theme changes in this ViewModel + mutableStateFlow.value = state.copy(isDarkMode = action.isDarkMode) + } + else -> { + // Ignore other AppActions + } + } +} +``` + +## 2. Events System (`BackgroundEvent.kt`, `EventsEffect.kt`) + +### Understanding the Event Flow + +The events system has several critical components working together: + +1. `eventChannel`: A backing `Channel` that buffers events +2. `eventFlow`: Public `Flow` for consuming events once +3. `EventsEffect`: A composable that consumes events with lifecycle awareness +4. `BackgroundEvent`: A marker interface for events that bypass lifecycle checks + +### Implementation Details + +The `EventsEffect` composable uses a `LaunchedEffect` to safely collect events within the +composition lifecycle: + +```kotlin +@Composable +fun EventsEffect( + viewModel: BaseViewModel<*, E, *>, + lifecycleOwner: Lifecycle = LocalLifecycleOwner.current.lifecycle, + handler: suspend (E) -> Unit, +) { + LaunchedEffect(key1 = Unit) { + viewModel.eventFlow + .filter { + it is BackgroundEvent || + lifecycleOwner.currentState.isAtLeast(Lifecycle.State.RESUMED) + } + .onEach { handler.invoke(it) } + .launchIn(this) + } +} +``` + +The `filter` operator is crucial here—it ensures that events are only processed when: + +- The event implements the `BackgroundEvent` interface, OR +- The screen is currently visible (in the `RESUMED` state) + +This prevents navigation events from triggering multiple times during configuration changes or when +returning to a screen from the background. + +### Types of Events + +Events typically fall into four categories: + +1. **Navigation Events**: Direct the user to a new screen + ```kotlin + data class NavigateTo(val route: String, val popUpTo: String? = null) : UiEvent + ``` + +2. **Message Events**: Show transient UI like toasts or snackbars + ```kotlin + data class ShowMessage(val message: String, val type: MessageType) : UiEvent + ``` + +3. **Dialog Events**: Display modal UI elements + ```kotlin + data class ShowDialog(val title: String, val message: String) : UiEvent + ``` + +4. **System Events**: Interact with system components like camera or permissions + ```kotlin + object RequestCameraPermission : UiEvent, BackgroundEvent + ``` + +## 3. Lifecycle Observer (`LifecycleEventEffect.kt`) + +The `LivecycleEventEffect` composable provides a clean way to observe and respond to Android +lifecycle events within compositions. It uses the `DisposableEffect` API to ensure proper cleanup. + +### Implementation Analysis + +The implementation uses a clever combination of `rememberUpdatedState` and `DisposableEffect`: + +```kotlin +@Composable +fun LivecycleEventEffect( + onEvent: (owner: LifecycleOwner, event: Lifecycle.Event) -> Unit, +) { + val eventHandler = rememberUpdatedState(onEvent) + val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current) + + DisposableEffect(lifecycleOwner.value) { + val lifecycle = lifecycleOwner.value.lifecycle + val observer = LifecycleEventObserver { owner, event -> + eventHandler.value(owner, event) + } + + lifecycle.addObserver(observer) + onDispose { + lifecycle.removeObserver(observer) + } + } +} +``` + +The `rememberUpdatedState` calls are essential: they ensure that if `onEvent` or the`lifecycleOwner` +changes during composition, the observer always uses the most current versions without needing to +resubscribe. + +### Advanced Lifecycle Handling + +When working with complex screens that may have their own internal composition lifecycles: + +```kotlin +@Composable +fun ComplexScreenWithTabs(viewModel: ComplexViewModel) { + var currentTab by remember { mutableStateOf(Tab.HOME) } + + // Main screen lifecycle + LivecycleEventEffect { owner, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> { + viewModel.trySendAction(ComplexAction.ScreenResumed) + Analytics.logScreenView("ComplexScreen") + } + Lifecycle.Event.ON_PAUSE -> { + viewModel.trySendAction(ComplexAction.ScreenPaused) + } + Lifecycle.Event.ON_DESTROY -> { + viewModel.trySendAction(ComplexAction.SaveState) + } + else -> { /* Ignore other events */ + } + } + } + + // Tab-specific behavior + when (currentTab) { + Tab.HOME -> HomeTab( + onEnter = { /* Tab-specific enter logic */ }, + onExit = { /* Tab-specific exit logic */ } + ) + Tab.PROFILE -> ProfileTab( + onEnter = { /* Tab-specific enter logic */ }, + onExit = { /* Tab-specific exit logic */ } + ) + } +} +``` + +This pattern allows separation of screen-level lifecycle events from tab-specific behavior. + +## 4. Navigation Extensions (`NavGraphBuilderExtensions.kt`, `Transition.kt`) + +The navigation system provides a rich set of transition patterns that create a cohesive, +motion-driven navigation experience. + +### Transition Animation Details + +Each transition type is carefully timed and coordinated: + +**Slide Transitions (450ms)**: + +- Slide content from bottom to top (enter) or top to bottom (exit) +- Used for modal dialogs and bottom sheets + +**Push Transitions (350ms)**: + +- Horizontal sliding with synchronized fading +- Content slides in from right/out to left for forward navigation +- Content slides in from left/out to right when going back +- Includes a subtle overlap timing to create a natural feeling of depth + +**Stay Transitions**: + +- No visible movement to maintain context +- Maintains visibility for the duration of other concurrent transitions +- Uses fade transitions with minimal alpha changes (from 1.0 to 0.99) to keep Compose from + optimizing away the animation + +### Intelligent Transition Handling + +The most sophisticated aspect is the handling of nested navigation: + +```kotlin +val AnimatedContentTransitionScope.isSameGraphNavigation: Boolean + get() = initialState.destination.parent == targetState.destination.parent +``` + +This property checks if we're navigating between destinations within the same parent graph, and +transitions will only apply within the same graph, allowing for hierarchical navigation patterns. + +```kotlin +val fadeIn: EnterTransitionProvider = { + RootTransitionProviders.Enter + .fadeIn(this) + .takeIf { isSameGraphNavigation } +} +``` + +By returning `null` when navigating between different graphs, this allows parent navigators to +define transitions for cross-graph navigation while child navigators handle transitions within their +scope. + +## 5. Image Loading (`ImageLoaderExt.kt`) + +The image loading system abstracts Coil's capabilities across platforms while providing sensible +defaults and optimization. + +### Memory Management + +The system intelligently manages memory based on platform constraints: + +```kotlin +internal fun rememberDefaultImageLoader(context: PlatformContext): ImageLoader { + return remember(context) { + ImageLoader.Builder(context) + .memoryCache { + MemoryCache.Builder() + .maxSizePercent(context, 0.25) // Use 25% of available memory + .build() + } + .logger(DebugLogger()) + .build() + } +} +``` + +The `maxSizePercent` call is crucial—it adapts the cache size to the device's available memory, +ensuring efficient resource usage across a wide range of devices. + +### Request Optimization + +The image request builder includes memory cache optimization: + +```kotlin +@Composable +fun rememberImageRequest( + context: PlatformContext, + wallpaper: String, +): ImageRequest { + return remember(wallpaper) { + ImageRequest.Builder(context) + .data(wallpaper) + .memoryCacheKey(wallpaper) + .placeholderMemoryCacheKey(wallpaper) + .build() + } +} +``` + +The use of `memoryCacheKey` and `placeholderMemoryCacheKey` ensures that images with the same URL +share the same cache entry, reducing memory usage and improving load times. + +### Common Use Patterns + +For profile pictures and avatars: + +```kotlin +@Composable +fun CircularProfileImage(url: String, size: Dp = 48.dp) { + val context = LocalPlatformContext.current + val imageLoader = rememberImageLoader(context) + + AsyncImage( + model = rememberImageRequest(context, url), + contentDescription = "Profile picture", + imageLoader = imageLoader, + modifier = Modifier + .size(size) + .clip(CircleShape) + .border(1.dp, MaterialTheme.colorScheme.outline, CircleShape), + contentScale = ContentScale.Crop, + placeholder = painterResource(R.drawable.placeholder_profile), + error = painterResource(R.drawable.error_profile) + ) +} +``` + +For background images: + +```kotlin +@Composable +fun BackgroundImage(url: String, overlay: Color = Color.Black.copy(alpha = 0.3f)) { + val context = LocalPlatformContext.current + val imageLoader = rememberImageLoader(context) + + Box { + AsyncImage( + model = rememberImageRequest(context, url), + contentDescription = null, + imageLoader = imageLoader, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.FillBounds + ) + + // Overlay for better text visibility + Box( + modifier = Modifier + .fillMaxSize() + .background(overlay) + ) + } +} +``` + +## 6. Performance Monitoring (`JankStatsExtensions.kt`) + +Performance monitoring is crucial for delivering smooth UIs. The JankStats integration helps +identify and address UI performance issues. + +### Jank Detection Mechanism + +"Jank" refers to frames that take longer than 16.67ms (for 60fps) to render, causing visible +stuttering. The Android-specific implementation uses the Metrics API: + +```kotlin +@Composable +actual fun TrackScrollJank(scrollableState: ScrollableState, stateName: String) { + TrackJank(scrollableState) { metricsHolder -> + snapshotFlow { scrollableState.isScrollInProgress }.collect { isScrollInProgress -> + metricsHolder.state?.apply { + if (isScrollInProgress) { + putState(stateName, "Scrolling=true") + } else { + removeState(stateName) + } + } + } + } +} +``` + +When scrolling starts, the system marks the current frames with the provided state name. This allows +the performance tools to attribute jank to specific UI interactions. + +### Performance Optimization Strategies + +To minimize jank in scrolling lists: + +1. **Minimize composition cost**: Use `key` for list items to prevent unnecessary recomposition +2. **Avoid nested scrolling**: Nested scrollable containers can compound performance issues +3. **Lazy loading**: Only load visible items and maintain a reasonable buffer +4. **Pre-compute complex layouts**: Calculate layout parameters ahead of time +5. **Bitmap caching**: For complex images, pre-compute and cache bitmaps +6. **Avoid allocation in scroll**: Don't create new objects during scrolling + +Example of a performance-optimized list: + +```kotlin +@Composable +fun OptimizedList(items: List) { + val listState = rememberLazyListState() + + // Track scrolling performance + TrackScrollJank(listState, "main_list") + + // Pre-compute expensive stuff + val coloredItems = remember(items) { + items.map { it.copy(color = calculateComplexColor(it)) } + } + + LazyColumn(state = listState) { + items( + items = coloredItems, + key = { it.id } // Stable key for efficient updates + ) { item -> + // Cached layout calculation + val layoutInfo = remember(item.id) { + calculateLayout(item) + } + + ListItemRow( + item = item, + layoutInfo = layoutInfo, + modifier = Modifier.animateItemPlacement() + ) + } + } +} +``` + +## 7. Cross-Platform Sharing (`ShareUtils.kt`) + +The `ShareUtils` object provides a unified API for sharing content, with platform-specific +implementations handling the technical details. + +### Platform Implementation Details + +**Android Implementation**: + +- Uses Android's `Intent` system with `ACTION_SEND` +- For images, first saves to cache directory, then creates a `FileProvider` URI +- Requires a valid Activity context from `activityProvider` + +**iOS (Native) Implementation**: + +- Uses `UIActivityViewController` for sharing +- Requires the root view controller from `UIApplication.sharedApplication()` + +**Desktop/JS/WASM Implementations**: + +- Use `FileKit` to save content to disk since direct sharing is less standardized +- For images, converts `ImageBitmap` to pixel data before saving + +### Security Considerations + +The Android implementation includes important security features: + +```kotlin +private suspend fun saveImage(image: Bitmap, context: Context): Uri? { + return withContext(Dispatchers.IO) { + try { + val imagesFolder = File(context.cacheDir, "images") + // ... save image ... + + // Use FileProvider for secure content sharing + FileProvider.getUriForFile(context, "${context.packageName}.provider", file) + } catch (e: IOException) { + Log.d("saving bitmap", "saving bitmap error ${e.message}") + null + } + } +} +``` + +Using `FileProvider` instead of direct file URIs is essential for API 24+ compatibility and +security. The provider creates content URIs that grant temporary access to the files being shared, +without exposing file system paths. + +## 8. String Extensions (`StringExt.kt`) + +The `capitalizeEachWord` extension demonstrates how simple utility functions can improve code +clarity and consistency. + +```kotlin +val String.capitalizeEachWord: String + get() = this.split(" ").joinToString(" ") { word -> + word.takeIf { it.isNotEmpty() } + ?.let { it.first().uppercase() + it.substring(1).lowercase() } + ?: "" + } +``` + +This implementation handles edge cases like: + +- Empty strings +- Words containing only a single character +- Strings with multiple consecutive spaces + +For multi-lingual applications, consider extending this with locale-aware capitalization: + +```kotlin +fun String.capitalizeEachWordWithLocale(locale: Locale): String { + return this.split(" ").joinToString(" ") { word -> + word.takeIf { it.isNotEmpty() } + ?.let { it.replaceFirstChar { char -> char.titlecase(locale) } } + ?: "" + } +} +``` + +## 9. Reporting Drawn State (`ReportDrawnExt.kt`) + +The `ReportDrawnWhen` composable is an important performance optimization that tells the system when +content is considered meaningfully drawn. + +### Platform-Specific Implementations + +On Android, the implementation delegates to the Android Compose implementation: + +```kotlin +@Composable +actual fun ReportDrawnWhen(block: () -> Boolean) { + androidx.activity.compose.ReportDrawnWhen { block() } +} +``` + +On other platforms, the implementation is a no-op, preserving the API surface without requiring +platform-specific functionality: + +```kotlin +@Composable +actual fun ReportDrawnWhen(block: () -> Boolean) { + // No-op implementation +} +``` + +### Performance Impact + +This composable has a significant performance impact on initial screen rendering. Android uses this +signal to: + +1. Mark the activity as drawn for launcher animations +2. Complete "warm start" timing measurements +3. Report performance metrics to developer tools + +A common pattern is to report drawn status once critical content is visible, even if background +loading continues: + +```kotlin +@Composable +fun NewsScreen(viewModel: NewsViewModel) { + val state by viewModel.stateFlow.collectAsState() + + Column { + TopBar() + + when (val currentState = state) { + is Loading -> LoadingIndicator() + is Success -> { + NewsList(currentState.headlines) + + // Asynchronously load recommended stories + LaunchedEffect(Unit) { + viewModel.trySendAction(LoadRecommendations) + } + } + is Error -> ErrorView(currentState.message) + } + } + + // Report as drawn once headlines are loaded, even if recommendations are still loading + ReportDrawnWhen { + state is Success + } +} +``` + +## 10. Shared Element Transitions (`SharedElementExt.kt`) + +The shared element transition system enables smooth visual continuity between screens using Material +3's shared element transitions. + +### Implementation Details + +The system uses composition locals to provide access to animation scopes: + +```kotlin +val LocalAnimatedVisibilityScope = compositionLocalOf { null } + +@OptIn(ExperimentalSharedTransitionApi::class) +val LocalSharedTransitionScope = compositionLocalOf { null } +``` + +These locals enable any composable in the hierarchy to participate in transitions regardless of +their depth in the UI tree. + +### Advanced Transition Patterns + +Beyond basic image transitions, shared elements can be used for: + +1. **Expanding Cards**: A card expands into a full screen detail view + ```kotlin + Card( + modifier = Modifier + .sharedElement( + scope = sharedTransitionScope, + state = rememberSharedContentState(key = "card-${item.id}") + ) + ) { + // Card content + } + + // In detail screen: + Surface( + modifier = Modifier + .fillMaxSize() + .sharedElement( + scope = sharedTransitionScope, + state = rememberSharedContentState(key = "card-${item.id}") + ) + ) { + // Detail content + } + ``` + +2. **Text Transitions**: Text elements that move and resize + ```kotlin + Text( + text = item.title, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.sharedElement( + scope = sharedTransitionScope, + state = rememberSharedContentState(key = "title-${item.id}") + ) + ) + + // In detail screen: + Text( + text = item.title, + style = MaterialTheme.typography.headlineLarge, + modifier = Modifier.sharedElement( + scope = sharedTransitionScope, + state = rememberSharedContentState(key = "title-${item.id}") + ) + ) + ``` + +3. **Color Transitions**: Smoothly changing colors between screens + ```kotlin + Box( + modifier = Modifier + .background(item.color) + .sharedElement( + scope = sharedTransitionScope, + state = rememberSharedContentState(key = "color-${item.id}") + ) + ) + ``` + +## Comprehensive Testing Strategy + +A robust testing strategy ensures the module's reliability across platforms. + +### Unit Testing ViewModels + +Test ViewModels by verifying state changes, action handling, and event emission: + +```kotlin +@Test +fun `when profile loaded successfully, state updated and success event emitted`() = runTest { + // Given + val repository = FakeUserRepository() + val viewModel = ProfileViewModel(repository) + val events = mutableListOf() + val job = launch { viewModel.eventFlow.collect { events.add(it) } } + + // When + viewModel.trySendAction(ProfileAction.LoadProfile("user123")) + + // Then + assertEquals(false, viewModel.stateFlow.value.userData.isLoading) + assertNotNull(viewModel.stateFlow.value.userData.user) + assertEquals("user123", viewModel.stateFlow.value.userData.user?.id) + assertEquals(1, events.size) + assertTrue(events[0] is ProfileEvent.ProfileLoaded) + + job.cancel() + } +``` + +### Testing Composables + +Use the Compose testing library to verify UI behavior: + +```kotlin +@Test +fun profileScreen_showsUserData_whenProvided() { + // Given + val user = User("123", "Jane Doe", "jane@example.com") + val state = ProfileState(userData = UserDataState(user = user)) + + // When + composeTestRule.setContent { + MaterialTheme { + ProfileScreen(state = state, onAction = {}) + } + } + + // Then + composeTestRule.onNodeWithText("Jane Doe").assertIsDisplayed() + composeTestRule.onNodeWithText("jane@example.com").assertIsDisplayed() +} +``` + +### Integration Testing + +Test component interactions using fake implementations: + +```kotlin +@Test +fun navigationEvents_triggerCorrectNavigation() { + // Given + val navController = TestNavHostController(ApplicationProvider.getApplicationContext()) + + composeTestRule.setContent { + NavigationTestHost(navController = navController) { + val viewModel = ProfileViewModel(FakeUserRepository()) + ProfileScreen(viewModel = viewModel) + + // Set up event observation + EventsEffect(viewModel) { event -> + when (event) { + is ProfileEvent.NavigateToSettings -> { + navController.navigate("settings") + } + } + } + } + } + + // When - click settings button + composeTestRule.onNodeWithContentDescription("Settings").performClick() + + // Then - verify navigation occurred + assertEquals("settings", navController.currentDestination?.route) +} +``` + +By combining these testing approaches, you can ensure the core-base/ui module functions correctly +across all supported platforms and integration points. \ No newline at end of file diff --git a/core-base/ui/build.gradle.kts b/core-base/ui/build.gradle.kts new file mode 100644 index 0000000000..cfa8b0aefa --- /dev/null +++ b/core-base/ui/build.gradle.kts @@ -0,0 +1,82 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi + +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/kmp-project-template/blob/main/LICENSE + */ +plugins { + alias(libs.plugins.mifos.kmp.library) + alias(libs.plugins.jetbrainsCompose) + alias(libs.plugins.compose.compiler) +} + +android { + namespace = "template.core.base.ui" +} + +kotlin { + sourceSets { + androidMain.dependencies { + api(libs.androidx.metrics) + implementation(libs.androidx.browser) + implementation(libs.androidx.compose.runtime) + } + + commonMain.dependencies { + implementation(compose.ui) + implementation(compose.material3) + implementation(compose.foundation) + implementation(compose.material) + implementation(compose.components.resources) + implementation(compose.materialIconsExtended) + implementation(compose.components.uiToolingPreview) + + implementation(libs.jb.composeViewmodel) + implementation(libs.jb.lifecycle.compose) + implementation(libs.jb.lifecycleViewmodel) + implementation(libs.jb.composeNavigation) + implementation(libs.jb.lifecycleViewmodelSavedState) + + implementation(libs.coil.kt) + implementation(libs.coil.kt.compose) + + implementation(libs.filekit.core) + implementation(libs.filekit.compose) + implementation(libs.filekit.coil) + } + androidInstrumentedTest.dependencies { + implementation(libs.bundles.androidx.compose.ui.test) + } + + desktopMain.dependencies { + implementation(compose.desktop.common) + implementation(compose.desktop.currentOs) + } + + jvmJsCommonMain.dependencies { + implementation(libs.filekit.core) + implementation(libs.filekit.compose) + implementation(libs.filekit.coil) + } + } +} + +compose.resources { + publicResClass = true + generateResClass = always + packageOfResClass = "template.core.base.ui.generated.resources" +} \ No newline at end of file diff --git a/core-base/ui/consumer-rules.pro b/core-base/ui/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/core-base/ui/src/androidMain/kotlin/template/core/base/ui/JankStatsExtensions.kt b/core-base/ui/src/androidMain/kotlin/template/core/base/ui/JankStatsExtensions.kt new file mode 100644 index 0000000000..9da46f17b5 --- /dev/null +++ b/core-base/ui/src/androidMain/kotlin/template/core/base/ui/JankStatsExtensions.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.ui + +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.DisposableEffectResult +import androidx.compose.runtime.DisposableEffectScope +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.platform.LocalView +import androidx.metrics.performance.PerformanceMetricsState +import androidx.metrics.performance.PerformanceMetricsState.Holder +import kotlinx.coroutines.CoroutineScope + +/** + * Retrieves [PerformanceMetricsState.Holder] from current [LocalView] and + * remembers it until the View changes. + * @see PerformanceMetricsState.getHolderForHierarchy + */ +@Composable +fun rememberMetricsStateHolder(): Holder { + val localView = LocalView.current + + return remember(localView) { + PerformanceMetricsState.getHolderForHierarchy(localView) + } +} + +/** + * Convenience function to work with [PerformanceMetricsState] state. The side effect is + * re-launched if any of the [keys] value is not equal to the previous composition. + * @see TrackDisposableJank if you need to work with DisposableEffect to cleanup added state. + */ +@Composable +fun TrackJank( + vararg keys: Any, + reportMetric: suspend CoroutineScope.(state: Holder) -> Unit, +) { + val metrics = rememberMetricsStateHolder() + LaunchedEffect(metrics, *keys) { + reportMetric(metrics) + } +} + +/** + * Convenience function to work with [PerformanceMetricsState] state that needs to be cleaned up. + * The side effect is re-launched if any of the [keys] value is not equal to the previous composition. + */ +@Composable +fun TrackDisposableJank( + vararg keys: Any, + reportMetric: DisposableEffectScope.(state: Holder) -> DisposableEffectResult, +) { + val metrics = rememberMetricsStateHolder() + DisposableEffect(metrics, *keys) { + reportMetric(this, metrics) + } +} + +/** + * Track jank while scrolling anything that's scrollable. + */ +@Composable +actual fun TrackScrollJank(scrollableState: ScrollableState, stateName: String) { + TrackJank(scrollableState) { metricsHolder -> + snapshotFlow { scrollableState.isScrollInProgress }.collect { isScrollInProgress -> + metricsHolder.state?.apply { + if (isScrollInProgress) { + putState(stateName, "Scrolling=true") + } else { + removeState(stateName) + } + } + } + } +} diff --git a/core-base/ui/src/androidMain/kotlin/template/core/base/ui/ReportDrawnExt.android.kt b/core-base/ui/src/androidMain/kotlin/template/core/base/ui/ReportDrawnExt.android.kt new file mode 100644 index 0000000000..45578737ce --- /dev/null +++ b/core-base/ui/src/androidMain/kotlin/template/core/base/ui/ReportDrawnExt.android.kt @@ -0,0 +1,17 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.ui + +import androidx.compose.runtime.Composable + +@Composable +actual fun ReportDrawnWhen(block: () -> Boolean) { + androidx.activity.compose.ReportDrawnWhen { block() } +} diff --git a/core-base/ui/src/androidMain/kotlin/template/core/base/ui/ShareUtils.android.kt b/core-base/ui/src/androidMain/kotlin/template/core/base/ui/ShareUtils.android.kt new file mode 100644 index 0000000000..a792cbe280 --- /dev/null +++ b/core-base/ui/src/androidMain/kotlin/template/core/base/ui/ShareUtils.android.kt @@ -0,0 +1,105 @@ +/* + * Copyright 2024 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.ui + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.net.Uri +import android.util.Log +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.core.content.FileProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jetbrains.compose.resources.ExperimentalResourceApi +import org.jetbrains.compose.resources.decodeToImageBitmap +import java.io.File +import java.io.FileOutputStream +import java.io.IOException + +actual object ShareUtils { + + private var activityProvider: () -> Activity = { + throw IllegalArgumentException( + "You need to implement the 'activityProvider' to provide the required Activity. " + + "Just make sure to set a valid activity using " + + "the 'setActivityProvider()' method.", + ) + } + + fun setActivityProvider(provider: () -> Activity) { + activityProvider = provider + } + + actual suspend fun shareText(text: String) { + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, text) + } + val intentChooser = Intent.createChooser(intent, null) + activityProvider.invoke().startActivity(intentChooser) + } + + actual suspend fun shareImage(title: String, image: ImageBitmap) { + val context = activityProvider.invoke().application.baseContext + + val uri = saveImage(image.asAndroidBitmap(), context) + + val sendIntent: Intent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_STREAM, uri) + setDataAndType(uri, "image/png") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + val shareIntent = Intent.createChooser(sendIntent, title) + activityProvider.invoke().startActivity(shareIntent) + } + + @OptIn(ExperimentalResourceApi::class) + actual suspend fun shareImage(title: String, byte: ByteArray) { + val context = activityProvider.invoke().application.baseContext + val imageBitmap = byte.decodeToImageBitmap() + + val uri = saveImage(imageBitmap.asAndroidBitmap(), context) + + val sendIntent: Intent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_STREAM, uri) + setDataAndType(uri, "image/png") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + val shareIntent = Intent.createChooser(sendIntent, title) + activityProvider.invoke().startActivity(shareIntent) + } + + private suspend fun saveImage(image: Bitmap, context: Context): Uri? { + return withContext(Dispatchers.IO) { + try { + val imagesFolder = File(context.cacheDir, "images") + imagesFolder.mkdirs() + val file = File(imagesFolder, "shared_image.png") + + val stream = FileOutputStream(file) + image.compress(Bitmap.CompressFormat.PNG, 100, stream) + stream.flush() + stream.close() + + FileProvider.getUriForFile(context, "${context.packageName}.provider", file) + } catch (e: IOException) { + Log.d("saving bitmap", "saving bitmap error ${e.message}") + null + } + } + } +} diff --git a/core-base/ui/src/commonMain/kotlin/template/core/base/ui/BackgroundEvent.kt b/core-base/ui/src/commonMain/kotlin/template/core/base/ui/BackgroundEvent.kt new file mode 100644 index 0000000000..914d1a3c2f --- /dev/null +++ b/core-base/ui/src/commonMain/kotlin/template/core/base/ui/BackgroundEvent.kt @@ -0,0 +1,17 @@ +/* + * Copyright 2024 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.ui + +/** + * Almost all the events in the app involve navigation or toasts. To prevent accidentally + * navigating to the same view twice, by default, events are ignored if the view is not currently + * resumed. To avoid that restriction, specific events can implement [BackgroundEvent]. + */ +interface BackgroundEvent diff --git a/core-base/ui/src/commonMain/kotlin/template/core/base/ui/BaseViewModel.kt b/core-base/ui/src/commonMain/kotlin/template/core/base/ui/BaseViewModel.kt new file mode 100644 index 0000000000..3a72463d00 --- /dev/null +++ b/core-base/ui/src/commonMain/kotlin/template/core/base/ui/BaseViewModel.kt @@ -0,0 +1,100 @@ +/* + * Copyright 2024 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch + +/** + * A base [ViewModel] that helps enforce the unidirectional data flow pattern and associated + * responsibilities of a typical ViewModel: + * + * - Maintaining and emitting a current state (of type [S]) with the given `initialState`. + * - Emitting one-shot events as needed (of type [E]). These should be rare and are typically + * reserved for things such as non-state based navigation. + * - Receiving actions (of type [A]) that may induce changes in the current state, trigger an + * event emission, or both. + */ +abstract class BaseViewModel( + initialState: S, +) : ViewModel() { + protected val mutableStateFlow: MutableStateFlow = MutableStateFlow(initialState) + private val eventChannel: Channel = Channel(capacity = Channel.UNLIMITED) + private val internalActionChannel: Channel = Channel(capacity = Channel.UNLIMITED) + + /** + * A helper that returns the current state of the view model. + */ + protected val state: S get() = mutableStateFlow.value + + /** + * A [StateFlow] representing state updates. + */ + val stateFlow: StateFlow = mutableStateFlow.asStateFlow() + + /** + * A [Flow] of one-shot events. These may be received and consumed by only a single consumer. + * Any additional consumers will receive no events. + */ + val eventFlow: Flow = eventChannel.receiveAsFlow() + + /** + * A [SendChannel] for sending actions to the ViewModel for processing. + */ + val actionChannel: SendChannel = internalActionChannel + + init { + viewModelScope.launch { + internalActionChannel + .consumeAsFlow() + .collect { action -> + handleAction(action) + } + } + } + + /** + * Handles the given [action] in a synchronous manner. + * + * Any changes to internal state that first require asynchronous work should post a follow-up + * action that may be used to then update the state synchronously. + */ + protected abstract fun handleAction(action: A): Unit + + /** + * Convenience method for sending an action to the [actionChannel]. + */ + fun trySendAction(action: A) { + actionChannel.trySend(action) + } + + /** + * Helper method for sending an internal action. + */ + protected suspend fun sendAction(action: A) { + actionChannel.send(action) + } + + /** + * Helper method for sending an event. + */ + protected fun sendEvent(event: E) { + viewModelScope.launch { eventChannel.send(event) } + } +} diff --git a/core-base/ui/src/commonMain/kotlin/template/core/base/ui/EventsEffect.kt b/core-base/ui/src/commonMain/kotlin/template/core/base/ui/EventsEffect.kt new file mode 100644 index 0000000000..46457b3bfe --- /dev/null +++ b/core-base/ui/src/commonMain/kotlin/template/core/base/ui/EventsEffect.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2024 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +/** + * Convenience method for observing event flow from [BaseViewModel]. + * + * By default, events will only be consumed when the associated screen is + * resumed, to avoid bugs like duplicate navigation calls. To override + * this behavior, a given event type can implement [BackgroundEvent]. + */ +@Composable +fun EventsEffect( + viewModel: BaseViewModel<*, E, *>, + lifecycleOwner: Lifecycle = LocalLifecycleOwner.current.lifecycle, + handler: suspend (E) -> Unit, +) { + LaunchedEffect(key1 = Unit) { + viewModel.eventFlow + .filter { + it is BackgroundEvent || + lifecycleOwner.currentState.isAtLeast(Lifecycle.State.RESUMED) + } + .onEach { handler.invoke(it) } + .launchIn(this) + } +} + +/** + * Convenience method for observing event flow from [BaseViewModel]. + * + * By default, events will only be consumed when the associated screen is + * resumed, to avoid bugs like duplicate navigation calls. To override + * this behavior, a given event type can implement [BackgroundEvent]. + */ +@Composable +fun EventsEffect( + eventFlow: Flow, + lifecycleOwner: Lifecycle = LocalLifecycleOwner.current.lifecycle, + handler: suspend (E) -> Unit, +) { + LaunchedEffect(key1 = Unit) { + eventFlow + .filter { + it is BackgroundEvent || + lifecycleOwner.currentState.isAtLeast(Lifecycle.State.RESUMED) + } + .onEach { handler.invoke(it) } + .launchIn(this) + } +} diff --git a/core-base/ui/src/commonMain/kotlin/template/core/base/ui/ImageLoaderExt.kt b/core-base/ui/src/commonMain/kotlin/template/core/base/ui/ImageLoaderExt.kt new file mode 100644 index 0000000000..4151e6d81e --- /dev/null +++ b/core-base/ui/src/commonMain/kotlin/template/core/base/ui/ImageLoaderExt.kt @@ -0,0 +1,96 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember +import coil3.ImageLoader +import coil3.PlatformContext +import coil3.memory.MemoryCache +import coil3.request.ImageRequest +import coil3.util.DebugLogger +import io.github.vinceglb.filekit.coil.addPlatformFileSupport + +/** + * CompositionLocal that provides access to an [ImageLoader] within the composition hierarchy. + * Default value is null, requiring an explicit provider or fallback to default implementation. + */ +internal val LocalAppImageLoader = compositionLocalOf { null } + +/** + * Returns an [ImageLoader] from the current composition or creates a default one if none is available. + * + * @param context Platform context required for image loading + * @return An [ImageLoader] instance that can be used for image loading operations + */ +@Composable +fun rememberImageLoader(context: PlatformContext): ImageLoader { + return LocalAppImageLoader.current ?: rememberDefaultImageLoader(context) +} + +/** + * Creates and remembers a default [ImageLoader] with memory cache and debug logging. + * + * @param context Platform context required for image loading and memory calculations + * @return A default configured [ImageLoader] instance + */ +@Composable +internal fun rememberDefaultImageLoader(context: PlatformContext): ImageLoader { + return remember(context) { + ImageLoader.Builder(context) + .memoryCache { + MemoryCache.Builder() + .maxSizePercent(context, 0.25) + .build() + } + .logger(DebugLogger()) + .components { + addPlatformFileSupport() + } + .build() + } +} + +/** + * Creates and remembers an [ImageRequest] for loading the specified wallpaper. + * + * @param context Platform context required for the image request + * @param wallpaper String identifier for the wallpaper to be loaded + * @return An [ImageRequest] configured for the specified wallpaper + */ +@Composable +fun rememberImageRequest( + context: PlatformContext, + wallpaper: String, +): ImageRequest { + return remember(wallpaper) { + ImageRequest.Builder(context) + .data(wallpaper) + .memoryCacheKey(wallpaper) + .placeholderMemoryCacheKey(wallpaper) + .build() + } +} + +/** + * Provides the specified [ImageLoader] to all composables within the [content] lambda + * via [CompositionLocalProvider]. + * + * @param imageLoader The [ImageLoader] to provide downstream + * @param content The composable content that will have access to the provided [ImageLoader] + */ +@Composable +fun LocalImageLoaderProvider(imageLoader: ImageLoader, content: @Composable () -> Unit) { + CompositionLocalProvider(LocalAppImageLoader provides imageLoader) { + content() + } +} diff --git a/core-base/ui/src/commonMain/kotlin/template/core/base/ui/JankStatsExtension.kt b/core-base/ui/src/commonMain/kotlin/template/core/base/ui/JankStatsExtension.kt new file mode 100644 index 0000000000..72b712f1be --- /dev/null +++ b/core-base/ui/src/commonMain/kotlin/template/core/base/ui/JankStatsExtension.kt @@ -0,0 +1,16 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.ui + +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.runtime.Composable + +@Composable +expect fun TrackScrollJank(scrollableState: ScrollableState, stateName: String) diff --git a/core-base/ui/src/commonMain/kotlin/template/core/base/ui/LifecycleEventEffect.kt b/core-base/ui/src/commonMain/kotlin/template/core/base/ui/LifecycleEventEffect.kt new file mode 100644 index 0000000000..e59267fdee --- /dev/null +++ b/core-base/ui/src/commonMain/kotlin/template/core/base/ui/LifecycleEventEffect.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2024 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.rememberUpdatedState +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner + +/** + * Creates a side effect to observe lifecycle events. + */ +@Composable +fun LivecycleEventEffect( + onEvent: (owner: LifecycleOwner, event: Lifecycle.Event) -> Unit, +) { + val eventHandler = rememberUpdatedState(onEvent) + val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current) + + DisposableEffect(lifecycleOwner.value) { + val lifecycle = lifecycleOwner.value.lifecycle + val observer = LifecycleEventObserver { owner, event -> + eventHandler.value(owner, event) + } + + lifecycle.addObserver(observer) + onDispose { + lifecycle.removeObserver(observer) + } + } +} diff --git a/core-base/ui/src/commonMain/kotlin/template/core/base/ui/NavGraphBuilderExtensions.kt b/core-base/ui/src/commonMain/kotlin/template/core/base/ui/NavGraphBuilderExtensions.kt new file mode 100644 index 0000000000..609eb0dcb1 --- /dev/null +++ b/core-base/ui/src/commonMain/kotlin/template/core/base/ui/NavGraphBuilderExtensions.kt @@ -0,0 +1,104 @@ +/* + * Copyright 2024 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.ui + +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.runtime.Composable +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavDeepLink +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import kotlin.jvm.JvmSuppressWildcards +import kotlin.reflect.KType + +/** + * A wrapper around [NavGraphBuilder.composable] that supplies slide up/down transitions. + */ +inline fun NavGraphBuilder.composableWithSlideTransitions( + typeMap: Map> = emptyMap(), + deepLinks: List = emptyList(), + noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, +) { + this.composable( + typeMap = typeMap, + deepLinks = deepLinks, + enterTransition = TransitionProviders.Enter.slideUp, + exitTransition = TransitionProviders.Exit.stay, + popEnterTransition = TransitionProviders.Enter.stay, + popExitTransition = TransitionProviders.Exit.slideDown, + sizeTransform = null, + content = content, + ) +} + +/** + * A wrapper around [NavGraphBuilder.composable] that supplies "stay" transitions. + */ +inline fun NavGraphBuilder.composableWithStayTransitions( + typeMap: Map> = emptyMap(), + deepLinks: List = emptyList(), + noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, +) { + this.composable( + typeMap = typeMap, + deepLinks = deepLinks, + enterTransition = TransitionProviders.Enter.stay, + exitTransition = TransitionProviders.Exit.stay, + popEnterTransition = TransitionProviders.Enter.stay, + popExitTransition = TransitionProviders.Exit.stay, + sizeTransform = null, + content = content, + ) +} + +/** + * A wrapper around [NavGraphBuilder.composable] that supplies push transitions. + * + * This is suitable for screens deeper within a hierarchy that uses push transitions; the root + * screen of such a hierarchy should use [composableWithRootPushTransitions]. + */ +inline fun NavGraphBuilder.composableWithPushTransitions( + typeMap: Map> = emptyMap(), + deepLinks: List = emptyList(), + noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, +) { + this.composable( + typeMap = typeMap, + deepLinks = deepLinks, + enterTransition = TransitionProviders.Enter.pushLeft, + exitTransition = TransitionProviders.Exit.stay, + popEnterTransition = TransitionProviders.Enter.stay, + popExitTransition = TransitionProviders.Exit.pushRight, + sizeTransform = null, + content = content, + ) +} + +/** + * A wrapper around [NavGraphBuilder.composable] that supplies push transitions to the root screen + * in a nested graph that uses push transitions. + */ +inline fun NavGraphBuilder.composableWithRootPushTransitions( + typeMap: Map> = emptyMap(), + deepLinks: List = emptyList(), + noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, +) { + this.composable( + typeMap = typeMap, + deepLinks = deepLinks, + enterTransition = TransitionProviders.Enter.stay, + exitTransition = TransitionProviders.Exit.pushLeft, + popEnterTransition = TransitionProviders.Enter.pushRight, + popExitTransition = TransitionProviders.Exit.fadeOut, + sizeTransform = null, + content = content, + ) +} diff --git a/core-base/ui/src/commonMain/kotlin/template/core/base/ui/ReportDrawnExt.kt b/core-base/ui/src/commonMain/kotlin/template/core/base/ui/ReportDrawnExt.kt new file mode 100644 index 0000000000..d83a37ae91 --- /dev/null +++ b/core-base/ui/src/commonMain/kotlin/template/core/base/ui/ReportDrawnExt.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.ui + +import androidx.compose.runtime.Composable + +/** + * Reports to the composition system that content is considered drawn when the specified condition is true. + * Platform-specific implementation that affects rendering optimizations. + * + * @param block Lambda that returns true when content should be considered drawn + */ +@Composable +expect fun ReportDrawnWhen(block: () -> Boolean) diff --git a/core-base/ui/src/commonMain/kotlin/template/core/base/ui/ShareUtils.kt b/core-base/ui/src/commonMain/kotlin/template/core/base/ui/ShareUtils.kt new file mode 100644 index 0000000000..fe22188fb5 --- /dev/null +++ b/core-base/ui/src/commonMain/kotlin/template/core/base/ui/ShareUtils.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2024 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.ui + +import androidx.compose.ui.graphics.ImageBitmap + +/** + * Platform-specific utility for sharing content with other applications. + * This expect class requires platform-specific implementations. + */ +expect object ShareUtils { + + /** + * Shares text content with other applications. + * + * @param text The text content to be shared + */ + suspend fun shareText(text: String) + + /** + * Shares an image with other applications. + * + * @param title The title to use when sharing the image + * @param image The ImageBitmap to be shared + */ + suspend fun shareImage(title: String, image: ImageBitmap) + + /** + * Shares an image with other applications using raw byte data. + * + * @param title The title to use when sharing the image + * @param byte The raw image data as ByteArray + */ + suspend fun shareImage(title: String, byte: ByteArray) +} diff --git a/core-base/ui/src/commonMain/kotlin/template/core/base/ui/SharedElementExt.kt b/core-base/ui/src/commonMain/kotlin/template/core/base/ui/SharedElementExt.kt new file mode 100644 index 0000000000..440124c9b9 --- /dev/null +++ b/core-base/ui/src/commonMain/kotlin/template/core/base/ui/SharedElementExt.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +@file:OptIn(ExperimentalSharedTransitionApi::class) + +package template.core.base.ui + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.runtime.compositionLocalOf + +/** + * CompositionLocal that provides access to an [AnimatedVisibilityScope] within the composition. + * Default value is null, requiring an explicit provider upstream in the composition. + */ +val LocalAnimatedVisibilityScope = compositionLocalOf { null } + +/** + * CompositionLocal that provides access to a [SharedTransitionScope] within the composition. + * Used for creating shared element transitions between composables. + * Default value is null, requiring an explicit provider upstream in the composition. + * + * Note: Uses experimental API [ExperimentalSharedTransitionApi]. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +val LocalSharedTransitionScope = compositionLocalOf { null } diff --git a/core-base/ui/src/commonMain/kotlin/template/core/base/ui/StringExt.kt b/core-base/ui/src/commonMain/kotlin/template/core/base/ui/StringExt.kt new file mode 100644 index 0000000000..6b7455463f --- /dev/null +++ b/core-base/ui/src/commonMain/kotlin/template/core/base/ui/StringExt.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.ui + +/** + * Extension property that returns a string with the first letter of each word capitalized. + * For example, "hello world" becomes "Hello World". + */ +val String.capitalizeEachWord: String + get() = this.split(" ").joinToString(" ") { word -> + word.takeIf { it.isNotEmpty() } + ?.let { it.first().uppercase() + it.substring(1).lowercase() } + ?: "" + } diff --git a/core-base/ui/src/commonMain/kotlin/template/core/base/ui/Transition.kt b/core-base/ui/src/commonMain/kotlin/template/core/base/ui/Transition.kt new file mode 100644 index 0000000000..63de01f913 --- /dev/null +++ b/core-base/ui/src/commonMain/kotlin/template/core/base/ui/Transition.kt @@ -0,0 +1,394 @@ +/* + * Copyright 2024 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.ui + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.navigation.NavBackStackEntry +import kotlin.jvm.JvmSuppressWildcards + +/** + * Function type for providing nullable enter transitions in navigation. + * Used with [AnimatedContentTransitionScope] to define how a destination enters the screen. + */ +typealias EnterTransitionProvider = + (@JvmSuppressWildcards AnimatedContentTransitionScope.() -> EnterTransition?) + +/** + * Function type for providing nullable exit transitions in navigation. + * Used with [AnimatedContentTransitionScope] to define how a destination exits the screen. + */ +typealias ExitTransitionProvider = + (@JvmSuppressWildcards AnimatedContentTransitionScope.() -> ExitTransition?) + +/** + * Function type for providing non-null enter transitions in navigation. + * Used with [AnimatedContentTransitionScope] to define how a destination enters the screen. + */ +typealias NonNullEnterTransitionProvider = + (@JvmSuppressWildcards AnimatedContentTransitionScope.() -> EnterTransition) + +/** + * Function type for providing non-null exit transitions in navigation. + * Used with [AnimatedContentTransitionScope] to define how a destination exits the screen. + */ +typealias NonNullExitTransitionProvider = + (@JvmSuppressWildcards AnimatedContentTransitionScope.() -> ExitTransition) + +/** + * The default transition time (in milliseconds) for all fade transitions in the + * [TransitionProviders]. + */ +const val DEFAULT_FADE_TRANSITION_TIME_MS: Int = 300 + +/** + * The default transition time (in milliseconds) for all slide transitions in the + * [TransitionProviders]. + */ +const val DEFAULT_SLIDE_TRANSITION_TIME_MS: Int = 450 + +/** + * The default transition time (in milliseconds) for all slide transitions in the + * [TransitionProviders]. + */ +const val DEFAULT_PUSH_TRANSITION_TIME_MS: Int = 350 + +/** + * The default transition time (in milliseconds) for all "stay"/no-op transitions in the + * [TransitionProviders]. + * + * This should be at least as large as any other transition that might also be happening during a + * navigation. + */ +val DEFAULT_STAY_TRANSITION_TIME_MS: Int = + maxOf( + DEFAULT_FADE_TRANSITION_TIME_MS, + DEFAULT_SLIDE_TRANSITION_TIME_MS, + DEFAULT_PUSH_TRANSITION_TIME_MS, + ) + +/** + * Checks if the parent of the destination before and after the navigation is the same. This is + * useful to ignore certain enter/exit transitions when navigating between distinct, nested flows. + */ +val AnimatedContentTransitionScope.isSameGraphNavigation: Boolean + get() = initialState.destination.parent == targetState.destination.parent + +/** + * Contains standard "transition providers" that may be used to specify the [EnterTransition] and + * [ExitTransition] used when building a typical composable destination. These may return `null` + * values in order to allow transitions between nested navigation graphs to be specified by + * components higher up in the graph. + */ +object TransitionProviders { + /** + * The standard set of "enter" transition providers. + */ + object Enter { + /** + * Fades the new screen in. + * + * Note that this represents a `null` transition when navigating between different nested + * navigation graphs. + */ + val fadeIn: EnterTransitionProvider = { + RootTransitionProviders.Enter + .fadeIn(this) + .takeIf { isSameGraphNavigation } + } + + /** + * Slides the new screen in from the left of the screen. + */ + val pushLeft: EnterTransitionProvider = { + RootTransitionProviders + .Enter + .pushLeft(this) + .takeIf { isSameGraphNavigation } + } + + /** + * Slides the new screen in from the right of the screen. + */ + val pushRight: EnterTransitionProvider = { + RootTransitionProviders + .Enter + .pushRight(this) + .takeIf { isSameGraphNavigation } + } + + /** + * Slides the new screen in from the bottom of the screen. + * + * Note that this represents a `null` transition when navigating between different nested + * navigation graphs. + */ + val slideUp: EnterTransitionProvider = { + RootTransitionProviders + .Enter + .slideUp(this) + .takeIf { isSameGraphNavigation } + } + + /** + * A "no-op" transition: this changes nothing about the screen but "lasts" as long as + * other standard transitions in order to leave the screen in place such that it does not + * immediately appear while the other screen transitions away. + * + * Note that this represents a `null` transition when navigating between different nested + * navigation graphs. + */ + val stay: EnterTransitionProvider = { + RootTransitionProviders + .Enter + .stay(this) + .takeIf { isSameGraphNavigation } + } + } + + /** + * The standard set of "exit" transition providers. + */ + object Exit { + /** + * Fades the current screen out. + * + * Note that this represents a `null` transition when navigating between different nested + * navigation graphs. + */ + val fadeOut: ExitTransitionProvider = { + RootTransitionProviders + .Exit + .fadeOut(this) + .takeIf { isSameGraphNavigation } + } + + /** + * Slides the current screen out to the left of the screen. + */ + val pushLeft: ExitTransitionProvider = { + RootTransitionProviders + .Exit + .pushLeft(this) + .takeIf { isSameGraphNavigation } + } + + /** + * Slides the current screen out to the right of the screen. + */ + val pushRight: ExitTransitionProvider = { + RootTransitionProviders + .Exit + .pushRight(this) + .takeIf { isSameGraphNavigation } + } + + /** + * Slides the current screen down to the bottom of the screen. + * + * Note that this represents a `null` transition when navigating between different nested + * navigation graphs. + */ + val slideDown: ExitTransitionProvider = { + RootTransitionProviders + .Exit + .slideDown(this) + .takeIf { isSameGraphNavigation } + } + + /** + * A "no-op" transition: this changes nothing about the screen but "lasts" as long as + * other standard transitions in order to leave the screen in place such that it does not + * immediately disappear while the other screen transitions into place. + * + * Note that this represents a `null` transition when navigating between different nested + * navigation graphs. + */ + val stay: ExitTransitionProvider = { + RootTransitionProviders + .Exit + .stay(this) + .takeIf { isSameGraphNavigation } + } + } +} + +/** + * Contains standard "transition providers" that may be used to specify the [EnterTransition] and + * [ExitTransition] used when building a root [NavHost], which requires a non-null value. + */ +object RootTransitionProviders { + /** + * The standard set of "enter" transition providers. + */ + object Enter { + /** + * Fades the new screen in. + */ + val fadeIn: NonNullEnterTransitionProvider = { + fadeIn(tween(DEFAULT_FADE_TRANSITION_TIME_MS)) + } + + /** + * There is no transition for the entering screen. + */ + val none: NonNullEnterTransitionProvider = { + EnterTransition.None + } + + /** + * Slides the new screen in from the left of the screen. + */ + val pushLeft: NonNullEnterTransitionProvider = { + val totalTransitionDurationMs = DEFAULT_PUSH_TRANSITION_TIME_MS + slideInHorizontally( + animationSpec = tween(durationMillis = totalTransitionDurationMs), + initialOffsetX = { fullWidth -> fullWidth / 2 }, + ) + fadeIn( + animationSpec = tween( + durationMillis = totalTransitionDurationMs / 2, + delayMillis = totalTransitionDurationMs / 2, + ), + ) + } + + /** + * Slides the new screen in from the right of the screen. + */ + val pushRight: NonNullEnterTransitionProvider = { + val totalTransitionDurationMs = DEFAULT_PUSH_TRANSITION_TIME_MS + slideInHorizontally( + animationSpec = tween(durationMillis = totalTransitionDurationMs), + initialOffsetX = { fullWidth -> -fullWidth / 2 }, + ) + fadeIn( + animationSpec = tween( + durationMillis = totalTransitionDurationMs / 2, + delayMillis = totalTransitionDurationMs / 2, + ), + ) + } + + /** + * Slides the new screen in from the bottom of the screen. + */ + val slideUp: NonNullEnterTransitionProvider = { + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Up, + animationSpec = tween(DEFAULT_SLIDE_TRANSITION_TIME_MS), + ) + } + + /** + * A "no-op" transition: this changes nothing about the screen but "lasts" as long as + * other standard transitions in order to leave the screen in place such that it does not + * immediately appear while the other screen transitions away. + */ + val stay: NonNullEnterTransitionProvider = { + fadeIn( + animationSpec = tween(DEFAULT_STAY_TRANSITION_TIME_MS), + initialAlpha = 1f, + ) + } + } + + /** + * The standard set of "exit" transition providers. + */ + object Exit { + /** + * Fades the current screen out. + */ + val fadeOut: NonNullExitTransitionProvider = { + fadeOut(tween(DEFAULT_FADE_TRANSITION_TIME_MS)) + } + + /** + * There is no transition for the exiting screen. + * + * Unlike the [stay] transition, this will immediately remove the outgoing screen even if + * there is an ongoing enter transition happening for the new screen. + */ + val none: NonNullExitTransitionProvider = { + ExitTransition.None + } + + /** + * Slides the current screen out to the left of the screen. + */ + @Suppress("MagicNumber") + val pushLeft: NonNullExitTransitionProvider = { + val totalTransitionDurationMs = DEFAULT_PUSH_TRANSITION_TIME_MS + val delayMs = totalTransitionDurationMs / 7 + val slideWithoutDelayMs = totalTransitionDurationMs - delayMs + slideOutHorizontally( + animationSpec = tween( + durationMillis = slideWithoutDelayMs, + delayMillis = delayMs, + ), + targetOffsetX = { fullWidth -> -fullWidth / 2 }, + ) + fadeOut( + animationSpec = tween( + durationMillis = totalTransitionDurationMs / 2, + delayMillis = delayMs, + ), + ) + } + + /** + * Slides the current screen out to the right of the screen. + */ + @Suppress("MagicNumber") + val pushRight: NonNullExitTransitionProvider = { + val totalTransitionDurationMs = DEFAULT_PUSH_TRANSITION_TIME_MS + val delayMs = totalTransitionDurationMs / 7 + val slideWithoutDelayMs = totalTransitionDurationMs - delayMs + slideOutHorizontally( + animationSpec = tween( + durationMillis = slideWithoutDelayMs, + delayMillis = delayMs, + ), + targetOffsetX = { fullWidth -> fullWidth / 2 }, + ) + fadeOut( + animationSpec = tween( + durationMillis = totalTransitionDurationMs / 2, + delayMillis = delayMs, + ), + ) + } + + /** + * Slides the current screen down to the bottom of the screen. + */ + val slideDown: NonNullExitTransitionProvider = { + slideOutOfContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Down, + animationSpec = tween(DEFAULT_SLIDE_TRANSITION_TIME_MS), + ) + } + + /** + * A "no-op" transition: this changes nothing about the screen but "lasts" as long as + * other standard transitions in order to leave the screen in place such that it does not + * immediately disappear while the other screen transitions into place. + */ + val stay: NonNullExitTransitionProvider = { + fadeOut( + animationSpec = tween(DEFAULT_STAY_TRANSITION_TIME_MS), + targetAlpha = 0.99f, + ) + } + } +} diff --git a/core-base/ui/src/desktopMain/java/template/core/base/ui/ShareUtils.desktop.kt b/core-base/ui/src/desktopMain/java/template/core/base/ui/ShareUtils.desktop.kt new file mode 100644 index 0000000000..c7233d7d1e --- /dev/null +++ b/core-base/ui/src/desktopMain/java/template/core/base/ui/ShareUtils.desktop.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.ui + +import androidx.compose.ui.graphics.asSkiaBitmap +import io.github.vinceglb.filekit.FileKit +import io.github.vinceglb.filekit.saveImageToGallery +import kotlinx.coroutines.DelicateCoroutinesApi + +actual object ShareUtils { + @OptIn(DelicateCoroutinesApi::class) + actual suspend fun shareText(text: String) { + FileKit.saveImageToGallery( + bytes = text.encodeToByteArray(), + filename = "shared_text.txt", + ) + } + + actual suspend fun shareImage( + title: String, + image: androidx.compose.ui.graphics.ImageBitmap, + ) { + image.asSkiaBitmap().readPixels()?.let { + FileKit.saveImageToGallery( + bytes = it, + filename = "$title.png", + ) + } + } + + actual suspend fun shareImage(title: String, byte: ByteArray) { + FileKit.saveImageToGallery( + bytes = byte, + filename = "$title.png", + ) + } +} diff --git a/core-base/ui/src/jsCommonMain/kotlin/template/core/base/ui/ShareUtils.kt b/core-base/ui/src/jsCommonMain/kotlin/template/core/base/ui/ShareUtils.kt new file mode 100644 index 0000000000..06d39f4235 --- /dev/null +++ b/core-base/ui/src/jsCommonMain/kotlin/template/core/base/ui/ShareUtils.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.ui + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asSkiaBitmap +import io.github.vinceglb.filekit.FileKit +import io.github.vinceglb.filekit.download +import kotlinx.coroutines.DelicateCoroutinesApi + +@OptIn(DelicateCoroutinesApi::class) +actual object ShareUtils { + actual suspend fun shareText(text: String) { + FileKit.download( + bytes = text.encodeToByteArray(), + fileName = "shared_text.txt", + ) + } + + actual suspend fun shareImage( + title: String, + image: ImageBitmap, + ) { + image.asSkiaBitmap().readPixels()?.let { + FileKit.download( + bytes = it, + fileName = "$title.png", + ) + } + } + + actual suspend fun shareImage(title: String, byte: ByteArray) { + FileKit.download( + bytes = byte, + fileName = "$title.png", + ) + } +} diff --git a/core-base/ui/src/nativeMain/kotlin/template/core/base/ui/ShareUtils.native.kt b/core-base/ui/src/nativeMain/kotlin/template/core/base/ui/ShareUtils.native.kt new file mode 100644 index 0000000000..585756f22d --- /dev/null +++ b/core-base/ui/src/nativeMain/kotlin/template/core/base/ui/ShareUtils.native.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.ui + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asSkiaBitmap +import io.github.vinceglb.filekit.FileKit +import io.github.vinceglb.filekit.saveImageToGallery +import platform.UIKit.UIActivityViewController +import platform.UIKit.UIApplication + +actual object ShareUtils { + actual suspend fun shareText(text: String) { + val currentViewController = UIApplication.sharedApplication().keyWindow?.rootViewController + val activityViewController = UIActivityViewController(listOf(text), null) + currentViewController?.presentViewController( + viewControllerToPresent = activityViewController, + animated = true, + completion = null, + ) + } + + actual suspend fun shareImage(title: String, image: ImageBitmap) { + image.asSkiaBitmap().readPixels()?.let { + FileKit.saveImageToGallery( + bytes = it, + filename = "$title.png", + ) + } + } + + actual suspend fun shareImage(title: String, byte: ByteArray) { + FileKit.saveImageToGallery( + bytes = byte, + filename = "$title.png", + ) + } +} diff --git a/core-base/ui/src/nonAndroidMain/kotlin/template/core/base/ui/JankStatsExtension.jvmJs.kt b/core-base/ui/src/nonAndroidMain/kotlin/template/core/base/ui/JankStatsExtension.jvmJs.kt new file mode 100644 index 0000000000..79fac842ac --- /dev/null +++ b/core-base/ui/src/nonAndroidMain/kotlin/template/core/base/ui/JankStatsExtension.jvmJs.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.ui + +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.runtime.Composable + +@Composable +actual fun TrackScrollJank( + scrollableState: ScrollableState, + stateName: String, +) { +} diff --git a/core-base/ui/src/nonAndroidMain/kotlin/template/core/base/ui/ReportDrawnExt.jvmJs.kt b/core-base/ui/src/nonAndroidMain/kotlin/template/core/base/ui/ReportDrawnExt.jvmJs.kt new file mode 100644 index 0000000000..ef719f5894 --- /dev/null +++ b/core-base/ui/src/nonAndroidMain/kotlin/template/core/base/ui/ReportDrawnExt.jvmJs.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2025 Mifos Initiative + * + * 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/. + * + * See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md + */ +package template.core.base.ui + +import androidx.compose.runtime.Composable + +/** + * Reports to the composition system that content is considered drawn when the specified condition is true. + * Platform-specific implementation that affects rendering optimizations. + * + * @param block Lambda that returns true when content should be considered drawn + */ +@Composable +actual fun ReportDrawnWhen(block: () -> Boolean) { +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d94b65518b..265ff51168 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,7 @@ androidxProfileinstaller = "1.4.1" androidxTestRules = "1.6.1" androidxTracing = "1.2.0" appcompatVersion = "1.7.0" +androidxBrowser = "1.8.0" mlkit="17.3.0" guavaVersion = "33.3.1-android" cameraxVersion = "1.4.1" @@ -86,6 +87,7 @@ fileKit = "0.8.7" fileKitDialog = "0.10.0-beta04" wire = "5.0.0" zoomImage = "1.3.0" +uiBackhandler = "1.8.2" # Jetbrains CMP windowsSizeClass = "0.5.0" @@ -93,6 +95,13 @@ composeLifecycle = "2.8.3" composeNavigation = "2.8.0-alpha10" jbCoreBundle = "1.0.1" jbSavedState = "1.2.2" +gitLive = "2.1.0" +material3adaptive = "1.1.2" + +calfPermissions = "0.8.0" +appUpdate = "2.1.0" +review = "2.0.2" +integrity = "1.4.0" # Desktop Version packageName = "MifosWallet" @@ -118,6 +127,7 @@ android-tools-common = { group = "com.android.tools", name = "common", version.r androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" } androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityVersion" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompatVersion" } +androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "androidxBrowser" } androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "cameraxVersion" } androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "cameraxVersion" } androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "cameraxVersion" } @@ -188,6 +198,7 @@ jb-kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", ver jb-kotlin-stdlib-js = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib-js", version.ref = "kotlin" } jb-kotlin-dom = { group = "org.jetbrains.kotlin", name = "kotlin-dom-api-compat", version.ref = "kotlin" } jb-composeRuntime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose-plugin" } +jb-lifecycle-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "composeLifecycle" } jb-composeViewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "composeLifecycle" } jb-lifecycleViewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel", version.ref = "composeLifecycle" } jb-lifecycleViewmodelSavedState = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "composeLifecycle" } @@ -196,6 +207,11 @@ jb-savedstate = { module = "org.jetbrains.androidx.savedstate:savedstate", versi jb-composeNavigation = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "composeNavigation" } jb-navigation = { module = "org.jetbrains.androidx.navigation:navigation-common", version.ref = "composeNavigation" } +jetbrains-compose-material3-adaptive = { group = "org.jetbrains.compose.material3.adaptive", name = "adaptive", version.ref = "material3adaptive" } +jetbrains-compose-material3-adaptive-layout = { group = "org.jetbrains.compose.material3.adaptive", name = "adaptive-layout", version.ref = "material3adaptive" } +jetbrains-compose-material3-adaptive-navigation = { group = "org.jetbrains.compose.material3.adaptive", name = "adaptive-navigation", version.ref = "material3adaptive" } + + koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" } koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" } koin-androidx-navigation = { group = "io.insert-koin", name = "koin-androidx-navigation", version.ref = "koin" } @@ -233,6 +249,7 @@ ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-conte ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktorVersion" } ktor-client-darwin = { group = "io.ktor", name = "ktor-client-darwin", version.ref = "ktorVersion" } ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktorVersion" } +ktor-client-js = { group = "io.ktor", name = "ktor-client-js", version.ref = "ktorVersion" } ktor-client-json = { group = "io.ktor", name = "ktor-client-json", version.ref = "ktorVersion" } ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktorVersion" } ktor-client-serialization = { group = "io.ktor", name = "ktor-client-serialization", version.ref = "ktorVersion" } @@ -248,11 +265,13 @@ coil-network-ktor = { group = "io.coil-kt.coil3", name = "coil-network-ktor3", v coil-svg = { group = "io.coil-kt.coil3", name = "coil-svg", version.ref = "coil" } zoom-image = { module="io.github.panpf.zoomimage:zoomimage-compose-coil3", version.ref = "zoomImage" } +calf-permissions = { module = "com.mohamedrejeb.calf:calf-permissions", version.ref = "calfPermissions" } compose-gradlePlugin = { group = "org.jetbrains.kotlin", name = "compose-compiler-gradle-plugin", version.ref = "kotlin" } squareup-okio = { group = "com.squareup.okio", name = "okio", version.ref = "okioVersion" } back-handler = { group = "com.arkivanov.essenty", name = "back-handler", version.ref = "backHandlerVersion" } filekit-core = { group = "io.github.vinceglb", name = "filekit-core", version.ref = "fileKit" } filekit-compose = { group = "io.github.vinceglb", name = "filekit-compose", version.ref = "fileKit" } +filekit-coil = { module = "io.github.vinceglb:filekit-coil", version.ref = "fileKit" } filekit-dialog-compose = { group = "io.github.vinceglb", name = "filekit-dialogs-compose", version.ref = "fileKitDialog" } qrose = { group = "io.github.alexzhirkevich", name="qrose", version.ref = "qroseVersion" } lottie-compose = { group = "com.airbnb.android", name = "lottie-compose", version.ref = "lottie" } @@ -268,14 +287,27 @@ kermit-simple = { group = "co.touchlab", name = "kermit-simple", version.ref = " multiplatform-settings = { group = "com.russhwolf", name = "multiplatform-settings-no-arg", version.ref = "multiplatformSettings" } multiplatform-settings-coroutines = { group = "com.russhwolf", name = "multiplatform-settings-coroutines", version.ref = "multiplatformSettings" } multiplatform-settings-serialization = { group = "com.russhwolf", name = "multiplatform-settings-serialization", version.ref = "multiplatformSettings" } +multiplatform-settings-test = { group = "com.russhwolf", name = "multiplatform-settings-test", version.ref = "multiplatformSettings" } moko-permission-compose = { group = "dev.icerock.moko", name = "permissions-compose", version.ref = "mokoPermission" } +app-update = { module = "com.google.android.play:app-update", version.ref = "appUpdate" } +app-update-ktx = { module = "com.google.android.play:app-update-ktx", version.ref = "appUpdate" } +integrity = { module = "com.google.android.play:integrity", version.ref = "integrity" } +review = { module = "com.google.android.play:review", version.ref = "review" } +review-ktx = { module = "com.google.android.play:review-ktx", version.ref = "review" } + +ui-backhandler = { module = "org.jetbrains.compose.ui:ui-backhandler", version.ref = "uiBackhandler" } window-size = { group = "dev.chrisbanes.material3", name = "material3-window-size-class-multiplatform", version.ref = "windowsSizeClass" } fluentui-system-icons = { module = "io.github.niyajali:fluentui-system-icons", version.ref = "fluentui-icons" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } +gitlive-firebase-analytics = { module = "dev.gitlive:firebase-analytics", version.ref = "gitLive" } +gitlive-firebase-crashlytics = { module = "dev.gitlive:firebase-crashlytics", version.ref = "gitLive" } +gitlive-firebase-performance = { module = "dev.gitlive:firebase-performance", version.ref = "gitLive" } + + [bundles] androidx-compose-ui-test = [ "androidx-compose-ui-test", diff --git a/keystore-manager.sh b/keystore-manager.sh new file mode 100644 index 0000000000..ac3c3f3542 --- /dev/null +++ b/keystore-manager.sh @@ -0,0 +1,1147 @@ +#!/bin/bash + +# Android Keystore Generator and GitHub Secrets Management Script +# This script generates Android keystores and manages GitHub secrets + +set -e # Exit on any error + +# Colors for better readability +GREEN='\033[0;32m' +BLUE='\033[0;34m' +RED='\033[0;31m' +YELLOW='\033[0;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' # No Color + +# Default environment file path +ENV_FILE="secrets.env" + +# Default values +COMMAND="generate" +REPO="" +ENV="" +SECRET_NAME="" + +# Keys that should not be sent to GitHub +EXCLUDED_GITHUB_KEYS=( + "COMPANY_NAME" + "DEPARTMENT" + "ORGANIZATION" + "CITY" + "STATE" + "COUNTRY" + "VALIDITY" + "KEYALG" + "KEYSIZE" + "OVERWRITE" + "ORIGINAL_KEYSTORE_NAME" + "UPLOAD_KEYSTORE_NAME" + "CN" + "OU" + "O" + "L" + "ST" + "C" +) + +# Function to strip quotes from values +strip_quotes() { + local value="$1" + # Remove surrounding double quotes if present + value="${value#\"}" + value="${value%\"}" + # Remove surrounding single quotes if present + value="${value#\'}" + value="${value%\'}" + echo "$value" +} + +# Load variables from secrets.env if it exists (simple variables only) +load_env_vars() { + local env_file="$1" + local show_message="$2" + + if [ -f "$env_file" ]; then + if [ "$show_message" = "true" ]; then + echo -e "${BLUE}Loading configuration from $env_file${NC}" + fi + + # Only load simple variables (KEY=VALUE format), ignore multiline blocks + local in_multiline=false + local multiline_end="" + + while IFS= read -r line; do + # Skip comments and blank lines + if [ "$in_multiline" = false ] && [[ -z "$line" || "$line" == \#* ]]; then + continue + fi + + # Check if we're entering a multiline block + if [ "$in_multiline" = false ] && [[ "$line" == *"<<"* ]]; then + multiline_end=$(echo "$line" | sed 's/.*<<\(.*\)/\1/') + in_multiline=true + continue + fi + + # Check if we're exiting a multiline block + if [ "$in_multiline" = true ] && [[ "$line" == "$multiline_end" ]]; then + in_multiline=false + continue + fi + + # Skip lines inside multiline blocks + if [ "$in_multiline" = true ]; then + continue + fi + + # Process regular KEY=VALUE pairs + if [[ "$line" == *"="* ]]; then + # Extract the variable name + local key=$(echo "$line" | cut -d '=' -f1 | xargs) + # Extract the value (anything after the first =) + local value=$(echo "$line" | cut -d '=' -f2-) + # Export the variable + export "$key"="$value" + fi + done < "$env_file" + fi +} + +# Function to display help +show_help() { + echo -e "${BLUE}Android Keystore Generator and GitHub Secrets Management Script${NC}" + echo "" + echo "Usage:" + echo " ./keystore-manager.sh [COMMAND] [OPTIONS]" + echo "" + echo "Commands:" + echo " generate - Generate Android keystores and update secrets.env (default)" + echo " view - View all secrets in the secrets.env file as a formatted table" + echo " add - Add secrets to a GitHub repository from secrets.env" + echo " list - List all secrets in a GitHub repository" + echo " delete - Delete a secret from a GitHub repository" + echo " delete-all - Delete all secrets from a GitHub repository that are in secrets.env" + echo " Use --include-excluded flag to also delete excluded secrets" + echo " help - Show this help message" + echo "" + echo "Options:" + echo " --repo=username/repo - GitHub repository name" + echo " --env=environment - GitHub environment name" + echo " --name=SECRET_NAME - Secret name (for delete command)" + echo "" + echo "Examples:" + echo " ./keystore-manager.sh generate" + echo " ./keystore-manager.sh view" + echo " ./keystore-manager.sh add --repo=username/repo" + echo " ./keystore-manager.sh list --repo=username/repo" + echo " ./keystore-manager.sh delete --repo=username/repo --name=SECRET_NAME" + echo " ./keystore-manager.sh delete-all --repo=username/repo [--env=environment]" + echo " ./keystore-manager.sh delete-all --repo=username/repo [--env=environment] --include-excluded" + +} + +# Function to view secrets from secrets.env in a table +view_secrets() { + if [ ! -f "$ENV_FILE" ]; then + echo -e "${RED}Error: $ENV_FILE file not found.${NC}" + exit 1 + fi + + echo -e "${BLUE}Loading configuration from $ENV_FILE${NC}" + echo -e "${BLUE}Viewing secrets from $ENV_FILE${NC}" + echo "" + + # Calculate column widths + KEY_WIDTH=30 + VALUE_WIDTH=50 + TOTAL_WIDTH=$((KEY_WIDTH + VALUE_WIDTH + 5)) # 5 for borders and spacing + + # Function to print horizontal border + print_border() { + local char=$1 + local width=$2 + printf "${CYAN}%*s${NC}\n" "$width" | tr " " "$char" + } + + # Print table header + print_border "═" $TOTAL_WIDTH + printf "${CYAN}║${BOLD} %-${KEY_WIDTH}s ${CYAN}║${BOLD} %-${VALUE_WIDTH}s ${CYAN}║${NC}\n" "SECRET KEY" "VALUE" + print_border "═" $TOTAL_WIDTH + + # Process the file line by line with support for multiline values + local multiline_mode=false + local multiline_end="" + + while IFS= read -r line || [ -n "$line" ]; do + # Skip empty lines and comments when not in multiline mode + if [ "$multiline_mode" = false ] && [[ -z "$line" || "$line" == \#* ]]; then + continue + fi + + # Check if we're exiting a multiline block + if [ "$multiline_mode" = true ] && [[ "$line" == "$multiline_end" ]]; then + multiline_mode=false + continue + fi + + # Skip content lines inside multiline blocks + if [ "$multiline_mode" = true ]; then + continue + fi + + # Check if this is the start of a multiline value + if [[ "$line" == *"<<"* ]]; then + # Extract the key (part before <<) + local key=$(echo "$line" | cut -d '<' -f1 | xargs) + # Extract the delimiter (part after <<) + multiline_end=$(echo "$line" | sed 's/.*<<\(.*\)/\1/') + multiline_mode=true + + # Print the multiline value immediately + printf "${CYAN}║${NC} ${YELLOW}%-${KEY_WIDTH}s${NC} ${CYAN}║${NC} ${GREEN}%-${VALUE_WIDTH}s${NC} ${CYAN}║${NC}\n" "$key" "[MULTILINE VALUE]" + elif [[ "$line" == *"="* ]]; then + # This is a regular key=value line + local key=$(echo "$line" | cut -d '=' -f1 | xargs) + local value=$(echo "$line" | cut -d '=' -f2-) + + # Strip quotes for display + value=$(strip_quotes "$value") + + # Truncate value if too long + local display_value="" + if [ ${#value} -gt $VALUE_WIDTH ]; then + display_value="${value:0:$((VALUE_WIDTH-5))}..." + else + display_value="$value" + fi + + # Print the regular key-value pair + printf "${CYAN}║${NC} ${YELLOW}%-${KEY_WIDTH}s${NC} ${CYAN}║${NC} ${GREEN}%-${VALUE_WIDTH}s${NC} ${CYAN}║${NC}\n" "$key" "$display_value" + fi + done < "$ENV_FILE" + + # Print table footer + print_border "═" $TOTAL_WIDTH + + # Help message for multiline values + echo -e "${BLUE}Note: For multiline values, the content is displayed as [MULTILINE VALUE]${NC}" +} + +# Function to check if keytool is available +check_keytool() { + if ! command -v keytool &> /dev/null; then + echo -e "${RED}Error: keytool command not found.${NC}" + echo -e "Please ensure you have Java Development Kit (JDK) installed and that keytool is in your PATH." + exit 1 + fi +} + +# Function to check if gh CLI is available +check_gh_cli() { + if ! command -v gh &> /dev/null; then + echo -e "${RED}GitHub CLI (gh) is not installed. Please install it first:${NC}" + echo -e "https://cli.github.com/manual/installation" + exit 1 + fi + + # Check if user is authenticated + if ! gh auth status &> /dev/null; then + echo -e "${RED}You are not logged in to GitHub CLI. Please run:${NC}" + echo -e "${BLUE}gh auth login${NC}" + exit 1 + fi +} + +# Function to create keystores directory +create_keystores_dir() { + if [ ! -d "keystores" ]; then + echo -e "${BLUE}Creating 'keystores' directory...${NC}" + mkdir -p keystores + if [ $? -ne 0 ]; then + echo -e "${RED}Error: Failed to create 'keystores' directory.${NC}" + exit 1 + fi + fi +} + +# Function to encode file to base64 +encode_base64() { + local file_path=$1 + if [ -f "$file_path" ]; then + if [[ "$OSTYPE" == "darwin"* ]]; then + # macOS + base64 "$file_path" + else + # Linux + base64 -w 0 "$file_path" + fi + else + echo -e "${RED}Error: File not found: $file_path${NC}" + return 1 + fi +} + +# Function to create/update secrets.env file +update_secrets_env() { + local original_keystore=$1 + local upload_keystore=$2 + local original_b64=$(encode_base64 "keystores/$original_keystore") + local upload_b64=$(encode_base64 "keystores/$upload_keystore") + + # Check if secrets.env exists + if [ -f "$ENV_FILE" ]; then + echo -e "${BLUE}Updating existing secrets.env file${NC}" + + # Create a temporary file + local temp_file="secrets.env.tmp" + + # Process the file line by line + local in_original_block=false + local in_upload_block=false + local original_found=false + local upload_found=false + + while IFS= read -r line || [ -n "$line" ]; do + # Check if we're in the ORIGINAL_KEYSTORE_FILE block + if [[ "$line" == "ORIGINAL_KEYSTORE_FILE<> "$temp_file" + echo "$original_b64" >> "$temp_file" + continue + fi + + # Check if we're in the UPLOAD_KEYSTORE_FILE block + if [[ "$line" == "UPLOAD_KEYSTORE_FILE<> "$temp_file" + echo "$upload_b64" >> "$temp_file" + continue + fi + + # Check if we're exiting a block + if [ "$in_original_block" = true ] && [[ "$line" == "EOF" ]]; then + in_original_block=false + echo "$line" >> "$temp_file" + continue + fi + + if [ "$in_upload_block" = true ] && [[ "$line" == "EOF" ]]; then + in_upload_block=false + echo "$line" >> "$temp_file" + continue + fi + + # Skip lines inside blocks as we've already written the new content + if [ "$in_original_block" = true ] || [ "$in_upload_block" = true ]; then + continue + fi + + # Write all other lines as is + echo "$line" >> "$temp_file" + done < "$ENV_FILE" + + # Add blocks that weren't found + if [ "$original_found" = false ]; then + echo "" >> "$temp_file" + echo "ORIGINAL_KEYSTORE_FILE<> "$temp_file" + echo "$original_b64" >> "$temp_file" + echo "EOF" >> "$temp_file" + fi + + if [ "$upload_found" = false ]; then + echo "" >> "$temp_file" + echo "UPLOAD_KEYSTORE_FILE<> "$temp_file" + echo "$upload_b64" >> "$temp_file" + echo "EOF" >> "$temp_file" + fi + + # Replace the original file + mv "$temp_file" "$ENV_FILE" + else + echo -e "${BLUE}Creating new secrets.env file${NC}" + + # Create a new secrets.env file + cat > "$ENV_FILE" < "$config_file" << EOL +module FastlaneConfig + module AndroidConfig + STORE_CONFIG = { + default_store_file: "$keystore_name", + default_store_password: "$keystore_password", + default_key_alias: "$key_alias", + default_key_password: "$key_password" + } + + FIREBASE_CONFIG = { + firebase_prod_app_id: "1:728433984912738:android:3902eb32kjaska3363b0938f1a1dbb", + firebase_demo_app_id: "1:72843493212738:android:8392hjks3298ak9032skja", + firebase_service_creds_file: "secrets/firebaseAppDistributionServiceCredentialsFile.json", + firebase_groups: "mifos-mobile-apps" + } + + BUILD_PATHS = { + prod_apk_path: "cmp-android/build/outputs/apk/prod/release/cmp-android-prod-release.apk", + demo_apk_path: "cmp-android/build/outputs/apk/demo/release/cmp-android-demo-release.apk", + prod_aab_path: "cmp-android/build/outputs/bundle/prodRelease/cmp-android-prod-release.aab" + } + end +end +EOL + fi + + echo -e "${GREEN}Fastlane configuration updated successfully${NC}" +} + +# Function to update cmp-android/build.gradle.kts with keystore information +update_gradle_config() { + local keystore_name=$1 + local keystore_password=$2 + local key_alias=$3 + local key_password=$4 + + # Path to the Gradle build file + local gradle_file="cmp-android/build.gradle.kts" + + echo -e "${BLUE}Updating Gradle build file with keystore information...${NC}" + + # Check if the file exists + if [ -f "$gradle_file" ]; then + echo -e "${BLUE}Updating existing $gradle_file${NC}" + + # Create a backup of the original file + cp "$gradle_file" "$gradle_file.bak" + + # Use sed to update the signing configuration + sed -i \ + -e "s|storeFile = file(System.getenv(\"KEYSTORE_PATH\") ?: \".*\")|storeFile = file(System.getenv(\"KEYSTORE_PATH\") ?: \"../keystores/$keystore_name\")|" \ + -e "s|storePassword = System.getenv(\"KEYSTORE_PASSWORD\") ?: \".*\"|storePassword = System.getenv(\"KEYSTORE_PASSWORD\") ?: \"$keystore_password\"|" \ + -e "s|keyAlias = System.getenv(\"KEYSTORE_ALIAS\") ?: \".*\"|keyAlias = System.getenv(\"KEYSTORE_ALIAS\") ?: \"$key_alias\"|" \ + -e "s|keyPassword = System.getenv(\"KEYSTORE_ALIAS_PASSWORD\") ?: \".*\"|keyPassword = System.getenv(\"KEYSTORE_ALIAS_PASSWORD\") ?: \"$key_password\"|" \ + "$gradle_file" + + # Remove the backup file + rm -f "$gradle_file.bak" + echo -e "${GREEN}Gradle build file updated successfully${NC}" + else + echo -e "${YELLOW}Gradle file not found: $gradle_file${NC}" + echo -e "${YELLOW}Skipping Gradle build file update${NC}" + fi +} + +# Function to generate keystore +generate_keystore() { + local env=$1 + local keystore_name=$2 + local key_alias=$3 + local keystore_password=$4 + local key_password=$5 + + # Use common values for other parameters + local validity=${VALIDITY:-25} + local keyalg=${KEYALG:-"RSA"} + local keysize=${KEYSIZE:-2048} + local dname=${DNAME} + local overwrite=${OVERWRITE:-false} + + # Path to save the keystore + local keystore_path="keystores/$keystore_name" + + echo -e "${BLUE}==================================================================${NC}" + echo -e "${BLUE}Generating $env keystore${NC}" + echo -e "${BLUE}==================================================================${NC}" + + echo -e "Generating keystore with the following parameters:" + echo -e "- Environment: $env" + echo -e "- Keystore Name: $keystore_path" + echo -e "- Key Alias: $key_alias" + echo -e "- Validity: $validity years" + echo -e "- Key Algorithm: $keyalg" + echo -e "- Key Size: $keysize" + + # Check if the keystore file already exists + if [ -f "$keystore_path" ]; then + if [ "$overwrite" = "true" ]; then + echo -e "${BLUE}Overwriting existing keystore file '$keystore_path'.${NC}" + else + echo -e "${BLUE}Keystore file '$keystore_path' already exists and OVERWRITE is not set to 'true'.${NC}" + echo -e "${BLUE}Using existing keystore.${NC}" + return 0 + fi + fi + + # Generate the keystore + if [ -n "$dname" ]; then + # If DNAME is provided, use it directly + echo -e "- Distinguished Name: $dname" + keytool -genkey -v \ + -keystore "$keystore_path" \ + -alias "$key_alias" \ + -keyalg "$keyalg" \ + -keysize "$keysize" \ + -validity $((validity*365)) \ + -storepass "$keystore_password" \ + -keypass "$key_password" \ + -dname "$dname" + else + # If individual DN components are provided, construct the DN using the more descriptive names + DN_PARTS=() + + # Map more descriptive environment variables to their DN counterparts + if [ -n "$COMPANY_NAME" ]; then + local clean_value=$(strip_quotes "$COMPANY_NAME") + echo -e "- Company Name (CN): $clean_value" + DN_PARTS+=("CN=$clean_value") + fi + if [ -n "$DEPARTMENT" ]; then + local clean_value=$(strip_quotes "$DEPARTMENT") + echo -e "- Department (OU): $clean_value" + DN_PARTS+=("OU=$clean_value") + fi + if [ -n "$ORGANIZATION" ]; then + local clean_value=$(strip_quotes "$ORGANIZATION") + echo -e "- Organization (O): $clean_value" + DN_PARTS+=("O=$clean_value") + fi + if [ -n "$CITY" ]; then + local clean_value=$(strip_quotes "$CITY") + echo -e "- City (L): $clean_value" + DN_PARTS+=("L=$clean_value") + fi + if [ -n "$STATE" ]; then + local clean_value=$(strip_quotes "$STATE") + echo -e "- State (ST): $clean_value" + DN_PARTS+=("ST=$clean_value") + fi + if [ -n "$COUNTRY" ]; then + local clean_value=$(strip_quotes "$COUNTRY") + echo -e "- Country (C): $clean_value" + DN_PARTS+=("C=$clean_value") + fi + + # For backward compatibility, also check the traditional DN variable names + if [ -z "$COMPANY_NAME" ] && [ -n "$CN" ]; then + local clean_value=$(strip_quotes "$CN") + echo -e "- Company Name (CN): $clean_value" + DN_PARTS+=("CN=$clean_value") + fi + if [ -z "$DEPARTMENT" ] && [ -n "$OU" ]; then + local clean_value=$(strip_quotes "$OU") + echo -e "- Department (OU): $clean_value" + DN_PARTS+=("OU=$clean_value") + fi + if [ -z "$ORGANIZATION" ] && [ -n "$O" ]; then + local clean_value=$(strip_quotes "$O") + echo -e "- Organization (O): $clean_value" + DN_PARTS+=("O=$clean_value") + fi + if [ -z "$CITY" ] && [ -n "$L" ]; then + local clean_value=$(strip_quotes "$L") + echo -e "- City (L): $clean_value" + DN_PARTS+=("L=$clean_value") + fi + if [ -z "$STATE" ] && [ -n "$ST" ]; then + local clean_value=$(strip_quotes "$ST") + echo -e "- State (ST): $clean_value" + DN_PARTS+=("ST=$clean_value") + fi + if [ -z "$COUNTRY" ] && [ -n "$C" ]; then + local clean_value=$(strip_quotes "$C") + echo -e "- Country (C): $clean_value" + DN_PARTS+=("C=$clean_value") + fi + + if [ ${#DN_PARTS[@]} -gt 0 ]; then + # Join the DN parts with commas + DN=$(IFS=,; echo "${DN_PARTS[*]}") + + keytool -genkey -v \ + -keystore "$keystore_path" \ + -alias "$key_alias" \ + -keyalg "$keyalg" \ + -keysize "$keysize" \ + -validity $((validity*365)) \ + -storepass "$keystore_password" \ + -keypass "$key_password" \ + -dname "$DN" + else + # If no DN components are provided, use interactive mode for DN + echo -e "${BLUE}No Distinguished Name components found in environment file for $env. Using interactive mode for certificate information.${NC}" + + keytool -genkey -v \ + -keystore "$keystore_path" \ + -alias "$key_alias" \ + -keyalg "$keyalg" \ + -keysize "$keysize" \ + -validity $((validity*365)) \ + -storepass "$keystore_password" \ + -keypass "$key_password" + fi + fi + + # Check if keystore was successfully created + if [ $? -eq 0 ] && [ -f "$keystore_path" ]; then + echo "" + echo -e "${GREEN}===== $env Keystore created successfully! =====${NC}" + echo -e "Keystore location: $(realpath "$keystore_path")" + echo -e "Keystore alias: $key_alias" + echo "" + return 0 + else + echo "" + echo -e "${RED}Error: Failed to create $env keystore. Please check the error messages above.${NC}" + return 1 + fi +} + +# Function to generate both keystores +generate_keystores() { + check_keytool + create_keystores_dir + + # Names for local keystore files (these won't be uploaded to GitHub) + ORIGINAL_KEYSTORE_NAME=${ORIGINAL_KEYSTORE_NAME:-"original.keystore"} + UPLOAD_KEYSTORE_NAME=${UPLOAD_KEYSTORE_NAME:-"upload.keystore"} + + # Map GitHub secret names to local keystore variables + ORIGINAL_KEYSTORE_FILE_PASSWORD=${ORIGINAL_KEYSTORE_FILE_PASSWORD:-"Keystore_password"} + ORIGINAL_KEYSTORE_ALIAS=${ORIGINAL_KEYSTORE_ALIAS:-"Keystore_Alias"} + ORIGINAL_KEYSTORE_ALIAS_PASSWORD=${ORIGINAL_KEYSTORE_ALIAS_PASSWORD:-"Alias_password"} + + UPLOAD_KEYSTORE_FILE_PASSWORD=${UPLOAD_KEYSTORE_FILE_PASSWORD:-"Keystore_password"} + UPLOAD_KEYSTORE_ALIAS=${UPLOAD_KEYSTORE_ALIAS:-"Keystore_Alias"} + UPLOAD_KEYSTORE_ALIAS_PASSWORD=${UPLOAD_KEYSTORE_ALIAS_PASSWORD:-"Alias_password"} + + # Generate ORIGINAL keystore + generate_keystore "ORIGINAL" "$ORIGINAL_KEYSTORE_NAME" "$ORIGINAL_KEYSTORE_ALIAS" "$ORIGINAL_KEYSTORE_FILE_PASSWORD" "$ORIGINAL_KEYSTORE_ALIAS_PASSWORD" + ORIGINAL_RESULT=$? + + # Generate UPLOAD keystore + generate_keystore "UPLOAD" "$UPLOAD_KEYSTORE_NAME" "$UPLOAD_KEYSTORE_ALIAS" "$UPLOAD_KEYSTORE_FILE_PASSWORD" "$UPLOAD_KEYSTORE_ALIAS_PASSWORD" + UPLOAD_RESULT=$? + + # Update secrets.env with base64 encoded keystores + if [ $ORIGINAL_RESULT -eq 0 ] && [ $UPLOAD_RESULT -eq 0 ]; then + update_secrets_env "$ORIGINAL_KEYSTORE_NAME" "$UPLOAD_KEYSTORE_NAME" + + # Update fastlane-config/android_config.rb with UPLOAD keystore information + update_fastlane_config "$UPLOAD_KEYSTORE_NAME" "$UPLOAD_KEYSTORE_FILE_PASSWORD" "$UPLOAD_KEYSTORE_ALIAS" "$UPLOAD_KEYSTORE_ALIAS_PASSWORD" + + # Update cmp-android/build.gradle.kts with UPLOAD keystore information + update_gradle_config "$UPLOAD_KEYSTORE_NAME" "$UPLOAD_KEYSTORE_FILE_PASSWORD" "$UPLOAD_KEYSTORE_ALIAS" "$UPLOAD_KEYSTORE_ALIAS_PASSWORD" + fi + + # Summary + echo "" + echo -e "${BLUE}==================================================================${NC}" + echo -e "${BLUE} SUMMARY ${NC}" + echo -e "${BLUE}==================================================================${NC}" + + if [ $ORIGINAL_RESULT -eq 0 ]; then + echo -e "${GREEN}ORIGINAL keystore: SUCCESS - $(realpath "keystores/$ORIGINAL_KEYSTORE_NAME")${NC}" + else + echo -e "${RED}ORIGINAL keystore: FAILED${NC}" + fi + + if [ $UPLOAD_RESULT -eq 0 ]; then + echo -e "${GREEN}UPLOAD keystore: SUCCESS - $(realpath "keystores/$UPLOAD_KEYSTORE_NAME")${NC}" + else + echo -e "${RED}UPLOAD keystore: FAILED${NC}" + fi + + echo "" + echo -e "${BLUE}IMPORTANT: Keep these keystore files and their passwords in a safe place.${NC}" + echo -e "${BLUE}If you lose them, you will not be able to update your application on the Play Store.${NC}" + + if [ $ORIGINAL_RESULT -eq 0 ] && [ $UPLOAD_RESULT -eq 0 ]; then + echo -e "${GREEN}secrets.env has been updated with base64 encoded keystores${NC}" + echo -e "${GREEN}fastlane-config/android_config.rb has been updated with UPLOAD keystore information${NC}" + return 0 + else + return 1 + fi +} + +# Function to check if key should be excluded from GitHub +should_exclude_key() { + local key=$1 + for excluded_key in "${EXCLUDED_GITHUB_KEYS[@]}"; do + if [ "$key" = "$excluded_key" ]; then + return 0 # True, should exclude + fi + done + return 1 # False, should not exclude +} + +# Function to add secrets from secrets.env to GitHub +add_secrets_to_github() { + local repo=$1 + local env=$2 + + check_gh_cli + + echo -e "${BLUE}Adding secrets to ${repo} from secrets.env${NC}" + if [ -n "$env" ]; then + echo -e "${BLUE}Environment: ${env}${NC}" + fi + + # Check if secrets.env exists + if [ ! -f "$ENV_FILE" ]; then + echo -e "${RED}Error: secrets.env file not found. Please run the 'generate' command first.${NC}" + exit 1 + fi + + # Process the secrets.env file + process_secrets_file "$repo" "$env" + + echo -e "${GREEN}All secrets have been added to GitHub successfully!${NC}" +} + +# Function to process secrets from file +process_secrets_file() { + local repo=$1 + local env=$2 + + echo -e "${BLUE}Processing secrets from $ENV_FILE${NC}" + + # Process the file line by line with support for multiline values + local current_key="" + local current_value="" + local multiline_mode=false + local multiline_end="" + + while IFS= read -r line || [ -n "$line" ]; do + # Skip empty lines and comments when not in multiline mode + if [ "$multiline_mode" = false ] && [[ -z "$line" || "$line" == \#* ]]; then + continue + fi + + # Check if we're in multiline mode + if [ "$multiline_mode" = true ]; then + # Check if this line is the end marker for multiline + if [[ "$line" == "$multiline_end" ]]; then + multiline_mode=false + + # Add secret only if it's not in the excluded list + if ! should_exclude_key "$current_key"; then + echo -e "${BLUE}Adding multiline secret: $current_key${NC}" + + if [ -n "$env" ]; then + echo -n "$current_value" | gh secret set "$current_key" --repo="$repo" --env="$env" + else + echo -n "$current_value" | gh secret set "$current_key" --repo="$repo" + fi + else + echo -e "${YELLOW}Skipping excluded key: $current_key (not sent to GitHub)${NC}" + fi + + current_key="" + current_value="" + else + # Append this line to the multiline value + if [ -n "$current_value" ]; then + current_value+=$'\n' + fi + current_value+="$line" + fi + else + # Check if this is the start of a multiline value using pattern matching + if echo "$line" | grep -q "<<"; then + # Extract the key (part before <<) + current_key=$(echo "$line" | cut -d '<' -f1 | xargs) + # Extract the delimiter (part after <<) + multiline_end=$(echo "$line" | sed 's/.*<<\(.*\)/\1/') + multiline_mode=true + current_value="" + elif echo "$line" | grep -q "="; then + # This is a regular key=value line + key=$(echo "$line" | cut -d '=' -f1 | xargs) + value=$(echo "$line" | cut -d '=' -f2-) + + # Strip quotes for the actual value + value=$(strip_quotes "$value") + + # Add secret only if it's not in the excluded list + if ! should_exclude_key "$key"; then + echo -e "${BLUE}Adding secret: $key${NC}" + + if [ -n "$env" ]; then + echo -n "$value" | gh secret set "$key" --repo="$repo" --env="$env" + else + echo -n "$value" | gh secret set "$key" --repo="$repo" + fi + else + echo -e "${YELLOW}Skipping excluded key: $key (not sent to GitHub)${NC}" + fi + fi + fi + done < "$ENV_FILE" + + # Check if we're still in multiline mode at the end of the file + if [ "$multiline_mode" = true ]; then + echo -e "${RED}Error: Unterminated multiline secret. Missing closing delimiter: $multiline_end${NC}" + return 1 + fi + + return 0 +} + +# Function to list secrets +list_secrets() { + local repo=$1 + local env=$2 + + check_gh_cli + + echo -e "${BLUE}Listing secrets for ${repo}${NC}" + + if [ -n "$env" ]; then + echo -e "${BLUE}Environment: ${env}${NC}" + gh secret list --repo="$repo" --env="$env" + else + gh secret list --repo="$repo" + fi +} + +# Function to delete a secret +delete_secret() { + local repo=$1 + local name=$2 + local env=$3 + + check_gh_cli + + echo -e "${BLUE}Deleting secret ${name} from ${repo}${NC}" + + if [ -n "$env" ]; then + echo -e "${BLUE}Environment: ${env}${NC}" + gh secret delete "$name" --repo="$repo" --env="$env" + else + gh secret delete "$name" --repo="$repo" + fi + + echo -e "${GREEN}Secret deleted successfully!${NC}" +} + +# Function to delete all secrets in env file from GitHub repository +delete_all_repo_secrets() { + local repo=$1 + local env=$2 + local include_excluded=${3:-false} # Default to false if not provided + + check_gh_cli + + echo -e "${BLUE}Deleting all secrets from ${repo} that are in $ENV_FILE${NC}" + if [ -n "$env" ]; then + echo -e "${BLUE}Environment: ${env}${NC}" + fi + + if [ "$include_excluded" = "true" ]; then + echo -e "${YELLOW}Warning: Including excluded secrets in deletion${NC}" + fi + + # Check if secrets.env exists + if [ ! -f "$ENV_FILE" ]; then + echo -e "${RED}Error: $ENV_FILE file not found.${NC}" + exit 1 + fi + + # First, get a list of all secrets in the repo + echo -e "${BLUE}Fetching current secrets from GitHub...${NC}" + + local temp_secrets_list=$(mktemp) + if [ -n "$env" ]; then + gh secret list --repo="$repo" --env="$env" > "$temp_secrets_list" + else + gh secret list --repo="$repo" > "$temp_secrets_list" + fi + + # Variables to track progress + local deleted_count=0 + local skipped_count=0 + local excluded_count=0 + local deleted_secrets=() + local skipped_secrets=() + local excluded_secrets=() + + # Process the file line by line to find secrets + echo -e "${BLUE}Processing secrets from $ENV_FILE...${NC}" + local multiline_mode=false + local multiline_end="" + + while IFS= read -r line || [ -n "$line" ]; do + # Skip empty lines and comments when not in multiline mode + if [ "$multiline_mode" = false ] && [[ -z "$line" || "$line" == \#* ]]; then + continue + fi + + # Check if we're exiting a multiline block + if [ "$multiline_mode" = true ] && [[ "$line" == "$multiline_end" ]]; then + multiline_mode=false + continue + fi + + # Skip content lines inside multiline blocks + if [ "$multiline_mode" = true ]; then + continue + fi + + # Extract key from regular lines or multiline start + local key="" + if [[ "$line" == *"<<"* ]]; then + # Extract the key (part before <<) + key=$(echo "$line" | cut -d '<' -f1 | xargs) + # Extract the delimiter (part after <<) + multiline_end=$(echo "$line" | sed 's/.*<<\(.*\)/\1/') + multiline_mode=true + elif [[ "$line" == *"="* ]]; then + # This is a regular key=value line + key=$(echo "$line" | cut -d '=' -f1 | xargs) + else + continue + fi + + # Skip empty keys + if [ -z "$key" ]; then + continue + fi + + # Check if key should be excluded + local is_excluded=false + if should_exclude_key "$key"; then + is_excluded=true + if [ "$include_excluded" != "true" ]; then + echo -e "${YELLOW}Skipping excluded key: $key${NC}" + excluded_count=$((excluded_count + 1)) + excluded_secrets+=("$key") + continue + else + echo -e "${YELLOW}Including excluded key (due to flag): $key${NC}" + fi + fi + + # Check if the key exists in the repo + if grep -q "$key" "$temp_secrets_list"; then + if [ "$is_excluded" = true ]; then + echo -e "${YELLOW}Deleting excluded secret: $key${NC}" + else + echo -e "${BLUE}Deleting secret: $key${NC}" + fi + + if [ -n "$env" ]; then + gh secret delete "$key" --repo="$repo" --env="$env" + else + gh secret delete "$key" --repo="$repo" + fi + + if [ $? -eq 0 ]; then + if [ "$is_excluded" = true ]; then + excluded_count=$((excluded_count + 1)) + excluded_secrets+=("$key (deleted)") + else + deleted_count=$((deleted_count + 1)) + deleted_secrets+=("$key") + fi + else + echo -e "${RED}Failed to delete secret: $key${NC}" + skipped_count=$((skipped_count + 1)) + skipped_secrets+=("$key (error)") + fi + else + echo -e "${YELLOW}Secret not found in repo: $key${NC}" + skipped_count=$((skipped_count + 1)) + skipped_secrets+=("$key (not found)") + fi + done < "$ENV_FILE" + + # Clean up + rm -f "$temp_secrets_list" + + # Summary + echo "" + echo -e "${BLUE}==================================================================${NC}" + echo -e "${BLUE} SUMMARY ${NC}" + echo -e "${BLUE}==================================================================${NC}" + echo -e "${GREEN}Deleted $deleted_count secrets${NC}" + echo -e "${YELLOW}Skipped $skipped_count secrets (not found in repo or errors)${NC}" + echo -e "${YELLOW}Excluded $excluded_count secrets${NC}" + + if [ ${#deleted_secrets[@]} -gt 0 ]; then + echo "" + echo -e "${GREEN}Deleted secrets:${NC}" + for secret in "${deleted_secrets[@]}"; do + echo -e " - $secret" + done + fi + + if [ ${#excluded_secrets[@]} -gt 0 ]; then + echo "" + echo -e "${YELLOW}Excluded secrets:${NC}" + for secret in "${excluded_secrets[@]}"; do + echo -e " - $secret" + done + fi + + echo "" + echo -e "${GREEN}Secret deletion process completed${NC}" +} + +INCLUDE_EXCLUDED="false" # Default value + +# Parse command line arguments +if [ "$1" != "" ]; then + COMMAND=$1 + shift +fi + +for i in "$@"; do + case $i in + --repo=*) + REPO="${i#*=}" + shift + ;; + --env=*) + ENV="${i#*=}" + shift + ;; + --include-excluded) + INCLUDE_EXCLUDED="true" + shift + ;; + --name=*) + SECRET_NAME="${i#*=}" + shift + ;; + *) + # Unknown option + ;; + esac +done + +# Load variables safely from secrets.env if it exists +# Only show the loading message for the view command +show_message="false" +if [ "$COMMAND" = "view" ]; then + show_message="true" +fi + +if [ -f "$ENV_FILE" ]; then + load_env_vars "$ENV_FILE" "$show_message" +fi + +# Execute the appropriate command +case $COMMAND in + generate) + generate_keystores + ;; + view) + view_secrets + ;; + add) + if [ -z "$REPO" ]; then + echo -e "${RED}Error: Repository is required.${NC}" + echo -e "Usage: ./keystore-manager.sh add --repo=username/repo [--env=environment]" + exit 1 + fi + add_secrets_to_github "$REPO" "$ENV" + ;; + list) + if [ -z "$REPO" ]; then + echo -e "${RED}Error: Repository is required.${NC}" + echo -e "Usage: ./keystore-manager.sh list --repo=username/repo [--env=environment]" + exit 1 + fi + list_secrets "$REPO" "$ENV" + ;; + delete) + if [ -z "$REPO" ] || [ -z "$SECRET_NAME" ]; then + echo -e "${RED}Error: Repository and secret name are required.${NC}" + echo -e "Usage: ./keystore-manager.sh delete --repo=username/repo --name=SECRET_NAME [--env=environment]" + exit 1 + fi + delete_secret "$REPO" "$SECRET_NAME" "$ENV" + ;; + delete-all) + if [ -z "$REPO" ]; then + echo -e "${RED}Error: Repository is required.${NC}" + echo -e "Usage: ./keystore-manager.sh delete-all --repo=username/repo [--env=environment] [--include-excluded]" + exit 1 + fi + delete_all_repo_secrets "$REPO" "$ENV" "$INCLUDE_EXCLUDED" + ;; + help) + show_help + ;; + *) + echo -e "${RED}Unknown command: $COMMAND${NC}" + show_help + exit 1 + ;; +esac \ No newline at end of file diff --git a/secrets.env b/secrets.env new file mode 100644 index 0000000000..4928b16cd3 --- /dev/null +++ b/secrets.env @@ -0,0 +1,118 @@ +# GitHub Secrets Environment File +# Format: KEY=VALUE (use quotes for values with spaces) +# Use <> "$LOG_FILE" + echo -e "$1" +} + +# Error handling function +handle_error() { + log_message "${RED}${CROSS} Error: $1${NC}" + exit 1 +} + +# Print error message +print_error() { + log_message "${RED}${CROSS} Error: $1${NC}" +} + +# Simple progress indicator function +show_progress() { + if [ "$DRY_RUN" = false ]; then + echo -ne "${BLUE}[ ]${NC}\r" + echo -ne "${BLUE}[===== ]${NC}\r" + sleep 0.1 + echo -ne "${BLUE}[========== ]${NC}\r" + sleep 0.1 + echo -ne "${BLUE}[=============== ]${NC}\r" + sleep 0.1 + echo -ne "${BLUE}[====================]${NC}" + echo + fi +} + +# Fancy banner +print_banner() { + echo -e "${BLUE}╔════════════════════════════════════════════╗${NC}" + echo -e "${BLUE}║${BOLD} Project Directory Sync Tool ${NC}${BLUE}║${NC}" + echo -e "${BLUE}╚════════════════════════════════════════════╝${NC}" + echo +} + +# Print step with color and symbol +print_step() { + log_message "${GREEN}${CHECKMARK} $1${NC}" +} + +# Print warning with color +print_warning() { + log_message "${YELLOW}⚠ $1${NC}" +} + +# Function to generate unique branch name +get_sync_branch_name() { + local date_suffix=$(date +%Y%m%d-%H%M%S) + echo "sync/upstream-${date_suffix}" +} + +# Print directories and files to be synced +print_items() { + echo -e "${BLUE}Items to sync:${NC}" + echo -e "${BOLD}Directories:${NC}" + for dir in "${SYNC_DIRS[@]}"; do + echo -e " ${BOLD}→${NC} $dir" + done + + echo -e "\n${BOLD}Files:${NC}" + for file in "${SYNC_FILES[@]}"; do + echo -e " ${BOLD}→${NC} $file" + done + echo +} + +# Function to check if a path is excluded +is_excluded() { + local check_dir=$1 + local full_path=$2 + local check_type=$3 # 'file' or 'dir' + + # Remove ./ from the beginning of the path if it exists + full_path="${full_path#./}" + + # Check for root-level exclusions + if [ -n "${EXCLUSIONS["root"]}" ] && [[ "$check_type" == "file" ]]; then + local IFS=' ' + read -ra ROOT_EXCLUDE_ITEMS <<< "${EXCLUSIONS["root"]}" + + for item in "${ROOT_EXCLUDE_ITEMS[@]}"; do + local IFS=':' + read -ra PARTS <<< "$item" + local exclude_path="${PARTS[0]}" + local exclude_type="${PARTS[1]}" + + if [ "$exclude_type" = "$check_type" ] && [ "$full_path" = "$exclude_path" ]; then + return 0 # Path is excluded + fi + done + fi + + # Check directory-specific exclusions + for dir in "${!EXCLUSIONS[@]}"; do + # Skip the root key as we've already checked it + if [ "$dir" = "root" ]; then + continue + fi + + # Check if the path starts with the directory we're looking at + if [[ "$full_path" == "$dir"* ]]; then + local IFS=' ' + read -ra EXCLUDE_ITEMS <<< "${EXCLUSIONS[$dir]}" + + for item in "${EXCLUDE_ITEMS[@]}"; do + local IFS=':' + read -ra PARTS <<< "$item" + local exclude_path="$dir/${PARTS[0]}" + local exclude_type="${PARTS[1]}" + + # Remove any duplicate slashes + exclude_path=$(echo "$exclude_path" | sed 's#/\+#/#g') + full_path=$(echo "$full_path" | sed 's#/\+#/#g') + + if [ "$exclude_type" = "$check_type" ] && [ "$full_path" = "$exclude_path" ]; then + return 0 # Path is excluded + fi + done + fi + done + return 1 # Path is not excluded +} + +cleanup_temp_dirs() { + print_step "Cleaning up temporary directories..." + find . -type d -name "temp_*" -exec rm -rf {} + + show_progress +} + +# Function to preserve excluded paths +preserve_excluded_paths() { + local dir=$1 + local destination=$2 + + if [ -n "${EXCLUSIONS[$dir]}" ]; then + local IFS=' ' + read -ra EXCLUDE_ITEMS <<< "${EXCLUSIONS[$dir]}" + + for item in "${EXCLUDE_ITEMS[@]}"; do + local IFS=':' + read -ra PARTS <<< "$item" + local exclude_path="${PARTS[0]}" + local exclude_type="${PARTS[1]}" + local full_source_path="$dir/$exclude_path" + local full_dest_path="$destination/$exclude_path" + + if [ -e "$full_source_path" ]; then + print_step "Preserving excluded ${exclude_type}: ${BOLD}$exclude_path${NC}" + mkdir -p "$(dirname "$full_dest_path")" + cp -r "$full_source_path" "$(dirname "$full_dest_path")" + fi + done + fi +} + +# Function to sync directory with exclusions +sync_directory() { + local dir=$1 + local temp_branch=$2 + + if [ -d "$dir" ]; then + print_step "Syncing ${BOLD}$dir${NC}..." + + if [ "$DRY_RUN" = false ]; then + # Create temporary directory for original content + mkdir -p "temp_$dir" + + # Store original directory for excluded items + if [ -d "$dir" ]; then + # First handle directory exclusions + if [ -n "${EXCLUSIONS[$dir]}" ]; then + local IFS=' ' + read -ra EXCLUDE_ITEMS <<< "${EXCLUSIONS[$dir]}" + + for item in "${EXCLUDE_ITEMS[@]}"; do + local IFS=':' + read -ra PARTS <<< "$item" + local exclude_path="$dir/${PARTS[0]}" + local exclude_type="${PARTS[1]}" + + if [ "$exclude_type" = "dir" ] && [ -e "$exclude_path" ]; then + print_step "Preserving excluded directory: ${BOLD}${PARTS[0]}${NC}" + mkdir -p "$(dirname "temp_$exclude_path")" + cp -r "$exclude_path" "$(dirname "temp_$exclude_path")" + elif [ "$exclude_type" = "file" ] && [ -f "$exclude_path" ]; then + print_step "Preserving excluded file: ${BOLD}${PARTS[0]}${NC}" + mkdir -p "$(dirname "temp_$exclude_path")" + cp "$exclude_path" "temp_$exclude_path" + fi + done + fi + fi + + # Checkout from upstream + git checkout "$temp_branch" -- "$dir" || { + print_error "Failed to sync $dir" + rm -rf "temp_$dir" + return 1 + } + + # Restore excluded files and directories + if [ -n "${EXCLUSIONS[$dir]}" ]; then + local IFS=' ' + read -ra EXCLUDE_ITEMS <<< "${EXCLUSIONS[$dir]}" + + for item in "${EXCLUDE_ITEMS[@]}"; do + local IFS=':' + read -ra PARTS <<< "$item" + local exclude_path="$dir/${PARTS[0]}" + local exclude_type="${PARTS[1]}" + local temp_path="temp_$exclude_path" + + if [ -e "$temp_path" ]; then + print_step "Restoring excluded ${exclude_type}: ${BOLD}${PARTS[0]}${NC}" + mkdir -p "$(dirname "$exclude_path")" + if [ "$exclude_type" = "dir" ]; then + rm -rf "$exclude_path" + cp -r "$temp_path" "$(dirname "$exclude_path")" + else + cp "$temp_path" "$exclude_path" + fi + fi + done + fi + fi + else + print_warning "Directory ${BOLD}$dir${NC} not found. Creating it..." + if [ "$DRY_RUN" = false ]; then + mkdir -p "$dir" + git checkout "$temp_branch" -- "$dir" || { + handle_error "Failed to sync $dir" + cleanup_temp_dirs + } + fi + fi + show_progress +} + +# Function to sync individual file with exclusions +sync_file() { + local file=$1 + local temp_branch=$2 + + # Check if file should be excluded (root-level or directory-specific) + if is_excluded "$(dirname "$file")" "$file" "file"; then + print_step "Skipping excluded file: ${BOLD}$file${NC}" + return + fi + + print_step "Syncing ${BOLD}$file${NC}..." + if [ "$DRY_RUN" = false ]; then + if [ -f "$file" ]; then + # Create directory for excluded files if it doesn't exist + mkdir -p "temp_files" + # Store original file if it exists + cp "$file" "temp_files/$(basename "$file")" + fi + + if ! git checkout "$temp_branch" -- "$file"; then + if [ -f "temp_files/$(basename "$file")" ]; then + # Restore original file if checkout fails + cp "temp_files/$(basename "$file")" "$file" + fi + print_error "Failed to sync $file" + return 1 + fi + fi + show_progress +} + +# Function to get default branch name +get_default_branch() { + local default_branch + default_branch=$(git remote show origin | grep 'HEAD branch' | cut -d' ' -f5) + echo "$default_branch" +} + +# Function to preserve root-level excluded files +preserve_root_files() { + if [ -n "${EXCLUSIONS["root"]}" ] && [ "$DRY_RUN" = false ]; then + print_step "Preserving root-level excluded files..." + mkdir -p "temp_root" + + local IFS=' ' + read -ra ROOT_EXCLUDE_ITEMS <<< "${EXCLUSIONS["root"]}" + + for item in "${ROOT_EXCLUDE_ITEMS[@]}"; do + local IFS=':' + read -ra PARTS <<< "$item" + local exclude_path="${PARTS[0]}" + local exclude_type="${PARTS[1]}" + + if [ "$exclude_type" = "file" ] && [ -f "$exclude_path" ]; then + print_step "Preserving root file: ${BOLD}$exclude_path${NC}" + cp "$exclude_path" "temp_root/" + fi + done + fi +} + +# Function to restore root-level excluded files +restore_root_files() { + if [ -n "${EXCLUSIONS["root"]}" ] && [ "$DRY_RUN" = false ]; then + print_step "Restoring root-level excluded files..." + + local IFS=' ' + read -ra ROOT_EXCLUDE_ITEMS <<< "${EXCLUSIONS["root"]}" + + for item in "${ROOT_EXCLUDE_ITEMS[@]}"; do + local IFS=':' + read -ra PARTS <<< "$item" + local exclude_path="${PARTS[0]}" + local exclude_type="${PARTS[1]}" + + if [ "$exclude_type" = "file" ] && [ -f "temp_root/$(basename "$exclude_path")" ]; then + print_step "Restoring root file: ${BOLD}$exclude_path${NC}" + cp "temp_root/$(basename "$exclude_path")" "$exclude_path" + fi + done + + rm -rf "temp_root" + fi +} + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + case $1 in + -h|--help) + show_help + exit 0 + ;; + --dry-run) + DRY_RUN=true + shift + ;; + -f|--force) + FORCE=true + shift + ;; + *) + handle_error "Unknown option: $1. Use --help for usage information." + ;; + esac +done + +# Check git configuration +if ! git config user.name > /dev/null || ! git config user.email > /dev/null; then + handle_error "Git user.name or user.email not configured" +fi + +# Main script +clear +print_banner +print_items + +# Print configured exclusions +echo -e "${BLUE}Configured Exclusions:${NC}" +OLD_IFS="$IFS" # Save original IFS +for dir in "${!EXCLUSIONS[@]}"; do + echo -e " ${BOLD}${dir}${NC}:" + IFS=' ' + read -ra EXCLUDE_ITEMS <<< "${EXCLUSIONS[$dir]}" + for item in "${EXCLUDE_ITEMS[@]}"; do + IFS=':' + read -ra PARTS <<< "$item" + if [ "$dir" = "root" ]; then + echo -e " → ${PARTS[0]} (${PARTS[1]}) [root level]" + else + echo -e " → ${PARTS[0]} (${PARTS[1]})" + fi + done +done +IFS="$OLD_IFS" # Restore original IFS +echo + +# Check if upstream remote exists +if ! git remote | grep -q '^upstream$'; then + print_warning "Upstream remote not found." + if [ "$DRY_RUN" = false ]; then + echo -e "${YELLOW}Default upstream URL:${NC} ${BOLD}$DEFAULT_UPSTREAM_URL${NC}" + if [ "$FORCE" = false ]; then + echo -e "${YELLOW}Press Enter to use default URL or input a different one:${NC}" + read -r custom_url + else + custom_url="" + fi + + upstream_url=${custom_url:-$DEFAULT_UPSTREAM_URL} + print_step "Adding upstream remote: ${BOLD}$upstream_url${NC}" + git remote add upstream "$upstream_url" || handle_error "Failed to add upstream remote" + show_progress + fi +fi + +# Fetch from upstream +print_step "Fetching from upstream..." +if ! git fetch upstream; then + handle_error "Failed to fetch from upstream" +fi +show_progress + +# Get default branch if dev doesn't exist +DEFAULT_BRANCH=$(get_default_branch) +BASE_BRANCH="dev" +if ! git rev-parse --verify "origin/dev" >/dev/null 2>&1; then + print_warning "dev branch not found, using default branch: ${BOLD}$DEFAULT_BRANCH${NC}" + BASE_BRANCH="$DEFAULT_BRANCH" +fi + +# Create sync branch +SYNC_BRANCH=$(get_sync_branch_name) +print_step "Creating sync branch: ${BOLD}$SYNC_BRANCH${NC}" + +if [ "$DRY_RUN" = false ]; then + # Create sync branch from base branch + if ! git checkout -b "$SYNC_BRANCH" "$BASE_BRANCH"; then + handle_error "Failed to create sync branch" + fi + show_progress + + # Create temporary branch for upstream changes + TEMP_BRANCH="temp-${SYNC_BRANCH}" + print_step "Creating temporary branch: ${BOLD}$TEMP_BRANCH${NC}" + if ! git checkout -b "$TEMP_BRANCH" "upstream/$BASE_BRANCH"; then + handle_error "Failed to create temporary branch" + fi + show_progress + + # Switch back to sync branch + print_step "Switching back to sync branch..." + if ! git checkout "$SYNC_BRANCH"; then + handle_error "Failed to switch to sync branch" + fi + show_progress + + # Preserve root-level excluded files + preserve_root_files +fi + +# Sync directories +echo -e "\n${BLUE}${BOLD}Syncing directories...${NC}" +echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n" +for dir in "${SYNC_DIRS[@]}"; do + sync_directory "$dir" "$TEMP_BRANCH" +done + +# Sync files +echo -e "\n${BLUE}${BOLD}Syncing files...${NC}" +echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n" +for file in "${SYNC_FILES[@]}"; do + sync_file "$file" "$TEMP_BRANCH" +done + +if [ "$DRY_RUN" = false ]; then + # Restore root-level excluded files + restore_root_files + + cleanup_temp_dirs + rm -rf temp_files + + # Cleanup temporary branch + print_step "Cleaning up temporary branch..." + git branch -D "$TEMP_BRANCH" || handle_error "Failed to delete temporary branch" + show_progress + + # Check for changes + if ! git diff --quiet --exit-code --cached; then + print_step "Committing changes..." + git add "${SYNC_DIRS[@]}" "${SYNC_FILES[@]}" + git commit -m "sync: Update directories and files from upstream + +This PR syncs the following items with upstream: +- Directories: ${SYNC_DIRS[*]} +- Files: ${SYNC_FILES[*]}" || handle_error "Failed to commit changes" + show_progress + + if [ "$FORCE" = false ]; then + echo -e "\n${YELLOW}${BOLD}Would you like to push the sync branch? (y/n)${NC}" + read -r response + if [[ "$response" =~ ^[Yy]$ ]]; then + print_step "Pushing sync branch..." + git push -u origin "$SYNC_BRANCH" || handle_error "Failed to push sync branch" + show_progress + echo -e "\n${GREEN}${BOLD}✨ Sync branch pushed successfully! ✨${NC}" + echo -e "${YELLOW}Please create a pull request from branch ${BOLD}$SYNC_BRANCH${NC}${YELLOW} to ${BOLD}$BASE_BRANCH${NC}${YELLOW} in your repository.${NC}\n" + else + echo -e "\n${YELLOW}Changes committed but not pushed. You can push later with:${NC}" + echo -e "${BOLD}git push -u origin $SYNC_BRANCH${NC}" + echo -e "${YELLOW}Then create a pull request from ${BOLD}$SYNC_BRANCH${NC}${YELLOW} to ${BOLD}$BASE_BRANCH${NC}\n" + fi + else + print_step "Pushing sync branch..." + git push -u origin "$SYNC_BRANCH" || handle_error "Failed to push sync branch" + show_progress + echo -e "\n${GREEN}${BOLD}✨ Sync branch pushed successfully! ✨${NC}" + echo -e "${YELLOW}Please create a pull request from branch ${BOLD}$SYNC_BRANCH${NC}${YELLOW} to ${BOLD}$BASE_BRANCH${NC}${YELLOW} in your repository.${NC}\n" + fi + else + print_warning "No changes to commit" + git checkout "$BASE_BRANCH" + git branch -D "$SYNC_BRANCH" + fi +else + echo -e "\n${YELLOW}${BOLD}Dry run completed. No changes were made.${NC}\n" +fi \ No newline at end of file From 41a337e250416141c46124caf54b296ec01d7276 Mon Sep 17 00:00:00 2001 From: Nagarjuna Date: Tue, 2 Sep 2025 13:52:06 +0530 Subject: [PATCH 2/3] chore: added dependency --- gradle/libs.versions.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 265ff51168..d1d8c13dfa 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -321,6 +321,7 @@ android-application = { id = "com.android.application", version.ref = "androidGr android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" } android-test = { id = "com.android.test", version.ref = "androidGradlePlugin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlinCocoapods = { id = "org.jetbrains.kotlin.native.cocoapods", version.ref = "kotlin" } # Room Plugins room = { id = "androidx.room", version.ref = "room" } From 6ff01a3f05ff8fb30d7a2a0da875e6a009fe42a4 Mon Sep 17 00:00:00 2001 From: Nagarjuna Date: Wed, 3 Sep 2025 09:16:24 +0530 Subject: [PATCH 3/3] refactor: action tag --- .github/workflows/pr-check-kmp.yml | 2 +- gradle/libs.versions.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-check-kmp.yml b/.github/workflows/pr-check-kmp.yml index 1a2bf541b0..97db4e044f 100644 --- a/.github/workflows/pr-check-kmp.yml +++ b/.github/workflows/pr-check-kmp.yml @@ -82,7 +82,7 @@ permissions: jobs: pr_checks: name: PR Checks KMP - uses: openMF/mifos-x-actionhub/.github/workflows/pr-check.yaml@v1.0.3 + uses: openMF/mifos-x-actionhub/.github/workflows/pr-check.yaml@v1.0.0 secrets: inherit with: android_package_name: 'cmp-android' # <-- Change Your Android Package Name diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d1d8c13dfa..a6f6b3d289 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -104,7 +104,7 @@ review = "2.0.2" integrity = "1.4.0" # Desktop Version -packageName = "MifosWallet" +packageName = "Mifos Mobile" packageNamespace = "org.mifos.mobile" packageVersion = "1.0.0" roomCommonVersion = "2.6.1"