├── compose ├── .gitignore ├── src │ └── commonMain │ │ ├── composeResources │ │ └── drawable │ │ │ └── ic_action_arrow_back_ios.png │ │ └── kotlin │ │ └── aung │ │ └── thiha │ │ └── compose │ │ ├── navigation │ │ └── Destination.kt │ │ ├── coroutines │ │ └── FlowExt.kt │ │ ├── LoadingOverlay.kt │ │ └── AppTopAppBar.kt └── build.gradle.kts ├── operation ├── .gitignore ├── build.gradle.kts └── src │ └── commonMain │ └── kotlin │ └── aung │ └── thiha │ └── operation │ ├── Operation.kt │ ├── DefaultErrorMapper.kt │ ├── Outcome.kt │ └── SuspendOperation.kt ├── ui_design.png ├── storage ├── src │ ├── androidMain │ │ ├── AndroidManifest.xml │ │ └── kotlin │ │ │ └── aung │ │ │ └── thiha │ │ │ └── storage │ │ │ ├── CreatePrefDataStore.android.kt │ │ │ └── di │ │ │ └── StorageModule.android.kt │ ├── commonMain │ │ └── kotlin │ │ │ └── aung │ │ │ └── thiha │ │ │ └── storage │ │ │ ├── di │ │ │ └── StorageModule.kt │ │ │ ├── CreatePrefDataStore.kt │ │ │ └── CreateKeyValueStorage.kt │ ├── jvmMain │ │ └── kotlin │ │ │ └── aung │ │ │ └── thiha │ │ │ └── storage │ │ │ └── di │ │ │ └── StorageModule.jvm.kt │ └── iosMain │ │ └── kotlin │ │ └── aung │ │ └── thiha │ │ └── storage │ │ ├── di │ │ └── StorageModule.ios.kt │ │ └── CreatePrefDataStore.ios.kt └── build.gradle.kts ├── iosApp ├── Configuration │ └── Config.xcconfig ├── iosApp │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── Icon-1024.png │ │ │ └── Contents.json │ │ └── AccentColor.colorset │ │ │ └── Contents.json │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── ContentView.swift │ ├── iOSApp.swift │ └── Info.plist └── iosApp.xcodeproj │ └── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ ├── IDEWorkspaceChecks.plist │ └── swiftpm │ └── Package.resolved ├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties └── libs.versions.toml ├── composeApp ├── src │ ├── androidMain │ │ ├── res │ │ │ ├── values │ │ │ │ ├── strings.xml │ │ │ │ └── ic_launcher_background.xml │ │ │ ├── mipmap-hdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ ├── ic_launcher_round.webp │ │ │ │ └── ic_launcher_foreground.webp │ │ │ ├── mipmap-mdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ ├── ic_launcher_round.webp │ │ │ │ └── ic_launcher_foreground.webp │ │ │ ├── mipmap-xhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ ├── ic_launcher_round.webp │ │ │ │ └── ic_launcher_foreground.webp │ │ │ ├── mipmap-xxhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ ├── ic_launcher_round.webp │ │ │ │ └── ic_launcher_foreground.webp │ │ │ ├── mipmap-xxxhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ ├── ic_launcher_round.webp │ │ │ │ └── ic_launcher_foreground.webp │ │ │ └── mipmap-anydpi-v26 │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ ├── ic_launcher-playstore.png │ │ ├── kotlin │ │ │ └── aung │ │ │ │ └── thiha │ │ │ │ └── photo │ │ │ │ └── album │ │ │ │ ├── di │ │ │ │ └── PlatformModule.android.kt │ │ │ │ ├── Platform.android.kt │ │ │ │ ├── MainApplication.kt │ │ │ │ ├── MainActivity.kt │ │ │ │ └── provider │ │ │ │ └── ContextProvider.kt │ │ └── AndroidManifest.xml │ ├── commonMain │ │ ├── composeResources │ │ │ ├── drawable │ │ │ │ ├── ic_waffles.webp │ │ │ │ └── ic_action_signout.png │ │ │ └── values │ │ │ │ └── strings.xml │ │ └── kotlin │ │ │ └── aung │ │ │ └── thiha │ │ │ └── photo │ │ │ └── album │ │ │ ├── di │ │ │ ├── PlatformModule.kt │ │ │ ├── NavigationModule.kt │ │ │ ├── NetworkModule.kt │ │ │ ├── SplashModule.kt │ │ │ └── AppModule.kt │ │ │ ├── Platform.kt │ │ │ ├── koin │ │ │ └── KoinInit.kt │ │ │ ├── splash │ │ │ ├── SplashNavGraph.kt │ │ │ ├── SplashNavigator.kt │ │ │ ├── SplashViewModel.kt │ │ │ └── SplashScreen.kt │ │ │ ├── adapter │ │ │ ├── authentication │ │ │ │ ├── createNavigateToSplash.kt │ │ │ │ ├── di │ │ │ │ │ └── AuthenticationAppModule.kt │ │ │ │ └── DefaultAuthenticationNavigator.kt │ │ │ └── photos │ │ │ │ └── di │ │ │ │ └── PhotosAppModule.kt │ │ │ ├── navigation │ │ │ ├── NavigationOptions.kt │ │ │ ├── NavOptionsBuilderExt.kt │ │ │ └── NavigationDispatcher.kt │ │ │ ├── network │ │ │ └── HttpClientFactory.kt │ │ │ └── App.kt │ ├── jvmTest │ │ └── kotlin │ │ │ └── aung │ │ │ └── thiha │ │ │ ├── photo │ │ │ └── album │ │ │ │ ├── di │ │ │ │ ├── KoinModuleOverrides.kt │ │ │ │ ├── SessionStorageModuleOverride.kt │ │ │ │ ├── AuthenticationDataModuleOverride.kt │ │ │ │ └── core │ │ │ │ │ ├── ModuleExt.kt │ │ │ │ │ └── KoinTestExtension.kt │ │ │ │ ├── navigation │ │ │ │ ├── DestinationWithOptions.kt │ │ │ │ ├── DefaultNavigationDispatcherTest.kt │ │ │ │ └── SpyNavigationHandler.kt │ │ │ │ ├── authentication │ │ │ │ ├── data │ │ │ │ │ └── remote │ │ │ │ │ │ └── service │ │ │ │ │ │ └── FakeAuthenticationDataSource.kt │ │ │ │ └── domain │ │ │ │ │ └── usecase │ │ │ │ │ └── IsSignedInTest.kt │ │ │ │ └── splash │ │ │ │ └── SplashViewModelTest.kt │ │ │ ├── session │ │ │ └── domain │ │ │ │ └── FakeSessionStorage.kt │ │ │ └── coroutines │ │ │ └── TestDispatcherExtension.kt │ ├── iosMain │ │ └── kotlin │ │ │ └── aung │ │ │ └── thiha │ │ │ └── photo │ │ │ └── album │ │ │ ├── MainViewController.kt │ │ │ ├── di │ │ │ └── PlatformModule.ios.kt │ │ │ └── Platform.ios.kt │ └── jvmMain │ │ └── kotlin │ │ └── aung │ │ └── thiha │ │ └── photo │ │ └── album │ │ ├── di │ │ └── PlatformModule.jvm.kt │ │ └── Platform.jvm.kt └── build.gradle.kts ├── photos ├── domain │ ├── src │ │ └── commonMain │ │ │ └── kotlin │ │ │ └── aung │ │ │ └── thiha │ │ │ └── photo │ │ │ └── album │ │ │ └── photos │ │ │ └── domain │ │ │ ├── model │ │ │ └── Photo.kt │ │ │ ├── PhotosRepository.kt │ │ │ └── usecase │ │ │ └── Signout.kt │ └── build.gradle.kts ├── presentation │ ├── src │ │ └── commonMain │ │ │ └── kotlin │ │ │ └── aung │ │ │ └── thiha │ │ │ └── photo │ │ │ └── album │ │ │ └── photos │ │ │ └── presentation │ │ │ ├── overview │ │ │ ├── PhotoListScreenListener.kt │ │ │ ├── PhotoListState.kt │ │ │ ├── PhotoListViewModel.kt │ │ │ └── PhotoListScreen.kt │ │ │ ├── navigation │ │ │ └── PhotosNavGraph.kt │ │ │ └── di │ │ │ └── PhotosPresentationModule.kt │ └── build.gradle.kts └── data │ ├── src │ └── commonMain │ │ └── kotlin │ │ └── aung │ │ └── thiha │ │ └── photo │ │ └── album │ │ └── photos │ │ └── data │ │ ├── remote │ │ ├── service │ │ │ ├── PhotosDataSource.kt │ │ │ └── PhotosService.kt │ │ └── response │ │ │ └── PhotoResponse.kt │ │ ├── di │ │ └── PhotosDataModule.kt │ │ └── PhotosRepositoryImpl.kt │ └── build.gradle.kts ├── session ├── domain │ ├── src │ │ └── commonMain │ │ │ └── kotlin │ │ │ └── aung │ │ │ └── thiha │ │ │ └── session │ │ │ └── domain │ │ │ ├── model │ │ │ └── Session.kt │ │ │ └── SessionStorage.kt │ └── build.gradle.kts └── data │ ├── src │ └── commonMain │ │ └── kotlin │ │ └── aung │ │ └── thiha │ │ └── session │ │ └── data │ │ ├── di │ │ └── SessionStorageModule.kt │ │ └── SessionStorageImpl.kt │ └── build.gradle.kts ├── authentication ├── domain │ ├── src │ │ └── commonMain │ │ │ └── kotlin │ │ │ └── aung │ │ │ └── thiha │ │ │ └── photo │ │ │ └── album │ │ │ └── authentication │ │ │ └── domain │ │ │ ├── model │ │ │ ├── SigninInput.kt │ │ │ └── SignupInput.kt │ │ │ ├── usecase │ │ │ ├── IsEmailValid.kt │ │ │ ├── Signout.kt │ │ │ └── IsSignedIn.kt │ │ │ ├── AuthenticationRepository.kt │ │ │ └── di │ │ │ └── AuthenticationDomainModule.kt │ └── build.gradle.kts ├── presentation │ ├── src │ │ └── commonMain │ │ │ ├── composeResources │ │ │ └── values │ │ │ │ └── strings.xml │ │ │ └── kotlin │ │ │ └── aung │ │ │ └── thiha │ │ │ └── photo │ │ │ └── album │ │ │ └── authentication │ │ │ └── presentation │ │ │ └── signup │ │ │ ├── signin │ │ │ ├── SigninScreenListener.kt │ │ │ ├── SigninViewModel.kt │ │ │ └── SigninScreen.kt │ │ │ ├── signup │ │ │ ├── SignupScreenListener.kt │ │ │ ├── SignupViewModel.kt │ │ │ └── SignupScreen.kt │ │ │ ├── navigation │ │ │ ├── AuthenticationNavigator.kt │ │ │ └── AuthenticationNavGraph.kt │ │ │ └── di │ │ │ └── AuthenticationPresentationModule.kt │ └── build.gradle.kts └── data │ ├── src │ └── commonMain │ │ └── kotlin │ │ └── aung │ │ └── thiha │ │ └── photo │ │ └── album │ │ └── authentication │ │ └── data │ │ ├── remote │ │ ├── response │ │ │ ├── TokenCheckResponse.kt │ │ │ └── AuthenticationResponse.kt │ │ ├── request │ │ │ ├── RefreshTokenRequest.kt │ │ │ └── AuthenticationRequest.kt │ │ └── service │ │ │ ├── AuthenticationDataSource.kt │ │ │ └── AuthenticationService.kt │ │ ├── di │ │ └── AuthDataModule.kt │ │ ├── AuthenticationRepositoryImpl.kt │ │ └── plugin │ │ └── AuthPlugin.kt │ └── build.gradle.kts ├── gradle.properties ├── coroutines ├── build.gradle.kts └── src │ └── commonMain │ └── kotlin │ └── aung │ └── thiha │ └── coroutines │ └── AppDispatchers.kt ├── .gitignore ├── settings.gradle.kts ├── gradlew.bat ├── README.md ├── TESTING.md ├── gradlew └── LICENSE /compose/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /operation/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /ui_design.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AungThiha/KMPTemplate/HEAD/ui_design.png -------------------------------------------------------------------------------- /storage/src/androidMain/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /iosApp/Configuration/Config.xcconfig: -------------------------------------------------------------------------------- 1 | TEAM_ID= 2 | BUNDLE_ID=aung.thiha.photo.album.PhotoAlbum 3 | APP_NAME=PhotoAlbum -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AungThiha/KMPTemplate/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | PhotoAlbum 3 | -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } -------------------------------------------------------------------------------- /composeApp/src/androidMain/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AungThiha/KMPTemplate/HEAD/composeApp/src/androidMain/ic_launcher-playstore.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AungThiha/KMPTemplate/HEAD/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AungThiha/KMPTemplate/HEAD/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AungThiha/KMPTemplate/HEAD/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AungThiha/KMPTemplate/HEAD/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AungThiha/KMPTemplate/HEAD/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Icon-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AungThiha/KMPTemplate/HEAD/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Icon-1024.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AungThiha/KMPTemplate/HEAD/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AungThiha/KMPTemplate/HEAD/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /storage/src/commonMain/kotlin/aung/thiha/storage/di/StorageModule.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.storage.di 2 | 3 | import org.koin.core.module.Module 4 | 5 | expect val storageModule: Module 6 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AungThiha/KMPTemplate/HEAD/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AungThiha/KMPTemplate/HEAD/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AungThiha/KMPTemplate/HEAD/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /composeApp/src/commonMain/composeResources/drawable/ic_waffles.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AungThiha/KMPTemplate/HEAD/composeApp/src/commonMain/composeResources/drawable/ic_waffles.webp -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AungThiha/KMPTemplate/HEAD/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AungThiha/KMPTemplate/HEAD/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFFFFF 4 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AungThiha/KMPTemplate/HEAD/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AungThiha/KMPTemplate/HEAD/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AungThiha/KMPTemplate/HEAD/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /composeApp/src/commonMain/composeResources/drawable/ic_action_signout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AungThiha/KMPTemplate/HEAD/composeApp/src/commonMain/composeResources/drawable/ic_action_signout.png -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/aung/thiha/photo/album/di/PlatformModule.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.di 2 | 3 | import org.koin.core.module.Module 4 | 5 | expect val platformModule : Module 6 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/aung/thiha/photo/album/Platform.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album 2 | 3 | interface Platform { 4 | val name: String 5 | } 6 | 7 | expect fun getPlatform(): Platform 8 | -------------------------------------------------------------------------------- /compose/src/commonMain/composeResources/drawable/ic_action_arrow_back_ios.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AungThiha/KMPTemplate/HEAD/compose/src/commonMain/composeResources/drawable/ic_action_arrow_back_ios.png -------------------------------------------------------------------------------- /photos/domain/src/commonMain/kotlin/aung/thiha/photo/album/photos/domain/model/Photo.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.photos.domain.model 2 | 3 | data class Photo( 4 | val id: Long, 5 | val url: String 6 | ) -------------------------------------------------------------------------------- /composeApp/src/jvmTest/kotlin/aung/thiha/photo/album/di/KoinModuleOverrides.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.di 2 | 3 | val overrides = listOf( 4 | sessionStorageModule, 5 | authenticationDataModuleOverride 6 | ) -------------------------------------------------------------------------------- /iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } -------------------------------------------------------------------------------- /composeApp/src/iosMain/kotlin/aung/thiha/photo/album/MainViewController.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album 2 | 3 | import androidx.compose.ui.window.ComposeUIViewController 4 | 5 | fun MainViewController() = ComposeUIViewController { App() } -------------------------------------------------------------------------------- /composeApp/src/iosMain/kotlin/aung/thiha/photo/album/di/PlatformModule.ios.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.di 2 | 3 | import org.koin.core.module.Module 4 | import org.koin.dsl.module 5 | 6 | actual val platformModule: Module = module { } -------------------------------------------------------------------------------- /session/domain/src/commonMain/kotlin/aung/thiha/session/domain/model/Session.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.session.domain.model 2 | 3 | data class Session( 4 | val accessToken: String, 5 | val refreshToken: String, 6 | val userId: String, 7 | ) -------------------------------------------------------------------------------- /composeApp/src/jvmMain/kotlin/aung/thiha/photo/album/di/PlatformModule.jvm.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.di 2 | 3 | import org.koin.core.module.Module 4 | import org.koin.dsl.module 5 | 6 | actual val platformModule: Module = module { 7 | 8 | } -------------------------------------------------------------------------------- /authentication/domain/src/commonMain/kotlin/aung/thiha/photo/album/authentication/domain/model/SigninInput.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.authentication.domain.model 2 | 3 | data class SigninInput( 4 | val email: String, 5 | val password: String 6 | ) -------------------------------------------------------------------------------- /authentication/domain/src/commonMain/kotlin/aung/thiha/photo/album/authentication/domain/model/SignupInput.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.authentication.domain.model 2 | 3 | data class SignupInput( 4 | val email: String, 5 | val password: String 6 | ) -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/aung/thiha/photo/album/di/PlatformModule.android.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.di 2 | 3 | import org.koin.core.module.Module 4 | import org.koin.dsl.module 5 | 6 | actual val platformModule: Module = module { 7 | 8 | } 9 | -------------------------------------------------------------------------------- /photos/presentation/src/commonMain/kotlin/aung/thiha/photo/album/photos/presentation/overview/PhotoListScreenListener.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.photos.presentation.overview 2 | 3 | interface PhotoListScreenListener { 4 | fun onRetryClick() 5 | fun onSignoutClick() 6 | } -------------------------------------------------------------------------------- /authentication/domain/src/commonMain/kotlin/aung/thiha/photo/album/authentication/domain/usecase/IsEmailValid.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.authentication.domain.usecase 2 | 3 | 4 | fun isEmailValid(email: String): Boolean { 5 | return email.matches("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\$".toRegex()) 6 | } -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /composeApp/src/jvmMain/kotlin/aung/thiha/photo/album/Platform.jvm.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album 2 | 3 | import java.net.InetAddress 4 | 5 | class JVMPlatform: Platform { 6 | override val name: String = InetAddress.getLocalHost().getHostName() 7 | } 8 | 9 | actual fun getPlatform(): Platform = JVMPlatform() 10 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official 2 | 3 | #Gradle 4 | org.gradle.jvmargs=-Xmx3072M -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx3072M" 5 | 6 | #Android 7 | android.nonTransitiveRClass=true 8 | android.useAndroidX=true 9 | 10 | #Kotlin Multiplatform 11 | kotlin.mpp.enableCInteropCommonization=true -------------------------------------------------------------------------------- /session/domain/src/commonMain/kotlin/aung/thiha/session/domain/SessionStorage.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.session.domain 2 | 3 | import aung.thiha.session.domain.model.Session 4 | 5 | interface SessionStorage { 6 | suspend fun getAuthenticationSession() : Session? 7 | suspend fun setAuthenticationSession(session: Session?) 8 | } -------------------------------------------------------------------------------- /photos/data/src/commonMain/kotlin/aung/thiha/photo/album/photos/data/remote/service/PhotosDataSource.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.photos.data.remote.service 2 | 3 | import aung.thiha.photo.album.photos.data.remote.response.PhotoResponse 4 | 5 | interface PhotosDataSource { 6 | suspend fun photos(): List 7 | } -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Icon-1024.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | } 9 | ], 10 | "info" : { 11 | "author" : "xcode", 12 | "version" : 1 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /compose/src/commonMain/kotlin/aung/thiha/compose/navigation/Destination.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.compose.navigation 2 | 3 | /** 4 | * Right now, this doesn't do anything but if we want to provide extra info from a route in the future, 5 | * we can add functions here and override them in the concrete implementations 6 | * */ 7 | interface Destination -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /photos/domain/src/commonMain/kotlin/aung/thiha/photo/album/photos/domain/PhotosRepository.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.photos.domain 2 | 3 | import aung.thiha.operation.SuspendOperation 4 | import aung.thiha.photo.album.photos.domain.model.Photo 5 | 6 | interface PhotosRepository { 7 | val photos: SuspendOperation> 8 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/composeResources/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Random 4 | Invalid Email 5 | Failed 6 | Passwords do not match 7 | 8 | -------------------------------------------------------------------------------- /composeApp/src/iosMain/kotlin/aung/thiha/photo/album/Platform.ios.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album 2 | 3 | import platform.UIKit.UIDevice 4 | 5 | class IOSPlatform: Platform { 6 | override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion 7 | } 8 | 9 | actual fun getPlatform(): Platform = IOSPlatform() 10 | -------------------------------------------------------------------------------- /authentication/presentation/src/commonMain/composeResources/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Invalid Email 4 | Failed 5 | Passwords do not match 6 | 7 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/aung/thiha/photo/album/koin/KoinInit.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.koin 2 | 3 | import aung.thiha.photo.album.di.appModule 4 | import org.koin.core.context.startKoin 5 | 6 | /** 7 | * WARNING: This is used in [iOSApp.swift] 8 | * Don't delete this 9 | * */ 10 | fun initKoin(){ 11 | startKoin { 12 | modules(appModule) 13 | } 14 | } -------------------------------------------------------------------------------- /authentication/presentation/src/commonMain/kotlin/aung/thiha/photo/album/authentication/presentation/signup/signin/SigninScreenListener.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.authentication.presentation.signup.signin 2 | 3 | interface SigninScreenListener { 4 | fun onEmailChange(email: String) 5 | fun onPasswordChange(password: String) 6 | fun onSigninClick() 7 | fun onSignupClick() 8 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/aung/thiha/photo/album/di/NavigationModule.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.di 2 | 3 | import aung.thiha.photo.album.navigation.DefaultNavigationDispatcher 4 | import aung.thiha.photo.album.navigation.NavigationDispatcher 5 | import org.koin.dsl.module 6 | 7 | val navigationModule = module { 8 | single { DefaultNavigationDispatcher() } 9 | } -------------------------------------------------------------------------------- /authentication/data/src/commonMain/kotlin/aung/thiha/photo/album/authentication/data/remote/response/TokenCheckResponse.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.authentication.data.remote.response 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class TokenCheckResponse( 8 | @SerialName("message") 9 | val message: String 10 | ) -------------------------------------------------------------------------------- /session/domain/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlinMultiplatform) 3 | } 4 | 5 | kotlin { 6 | jvmToolchain(libs.versions.jvm.toolchain.get().toInt()) 7 | jvm() 8 | iosX64() 9 | iosArm64() 10 | iosSimulatorArm64() 11 | 12 | sourceSets { 13 | commonMain { 14 | dependencies { 15 | } 16 | } 17 | } 18 | } 19 | 20 | -------------------------------------------------------------------------------- /authentication/data/src/commonMain/kotlin/aung/thiha/photo/album/authentication/data/remote/request/RefreshTokenRequest.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.authentication.data.remote.request 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class RefreshTokenRequest( 8 | @SerialName("refreshToken") 9 | val refreshToken: String, 10 | ) -------------------------------------------------------------------------------- /photos/data/src/commonMain/kotlin/aung/thiha/photo/album/photos/data/remote/response/PhotoResponse.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.photos.data.remote.response 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class PhotoResponse( 8 | @SerialName("id") 9 | val id: Long, 10 | @SerialName("url") 11 | val url: String 12 | ) 13 | -------------------------------------------------------------------------------- /photos/presentation/src/commonMain/kotlin/aung/thiha/photo/album/photos/presentation/overview/PhotoListState.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.photos.presentation.overview 2 | 3 | import aung.thiha.photo.album.photos.domain.model.Photo 4 | 5 | sealed class PhotoListState { 6 | data class Content(val photos: List) : PhotoListState() 7 | data object Loading : PhotoListState() 8 | data object LoadingFailed : PhotoListState() 9 | } -------------------------------------------------------------------------------- /authentication/presentation/src/commonMain/kotlin/aung/thiha/photo/album/authentication/presentation/signup/signup/SignupScreenListener.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.authentication.presentation.signup.signup 2 | 3 | interface SignupScreenListener { 4 | fun onUpClick() 5 | fun onEmailChange(email: String) 6 | fun onPasswordChange(password: String) 7 | fun onConfirmPasswordChange(confirmPassword: String) 8 | fun onSignupClick() 9 | } -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/aung/thiha/photo/album/Platform.android.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album 2 | 3 | import android.app.Activity 4 | import android.content.Intent 5 | import android.os.Build 6 | import aung.thiha.photo.album.provider.ContextHolder 7 | 8 | class AndroidPlatform : Platform { 9 | override val name: String = "Android ${Build.VERSION.SDK_INT}" 10 | } 11 | 12 | actual fun getPlatform(): Platform = AndroidPlatform() 13 | -------------------------------------------------------------------------------- /operation/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlinMultiplatform) 3 | } 4 | 5 | kotlin { 6 | jvmToolchain(libs.versions.jvm.toolchain.get().toInt()) 7 | jvm() 8 | iosX64() 9 | iosArm64() 10 | iosSimulatorArm64() 11 | 12 | sourceSets { 13 | commonMain { 14 | dependencies { 15 | implementation(libs.ktor.client.core) 16 | } 17 | } 18 | } 19 | } 20 | 21 | -------------------------------------------------------------------------------- /operation/src/commonMain/kotlin/aung/thiha/operation/Operation.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.operation 2 | 3 | fun interface Operation : (I) -> Outcome 4 | 5 | fun operation( 6 | mapError: (Exception) -> Outcome = DefaultErrorMapper(), 7 | block: (I) -> O 8 | ): Operation = Operation { input -> 9 | try { 10 | Outcome.Success(block(input)) 11 | } catch (e: Exception) { 12 | mapError(e) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /photos/domain/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlinMultiplatform) 3 | } 4 | 5 | kotlin { 6 | jvmToolchain(libs.versions.jvm.toolchain.get().toInt()) 7 | jvm() 8 | iosX64() 9 | iosArm64() 10 | iosSimulatorArm64() 11 | 12 | sourceSets { 13 | commonMain { 14 | dependencies { 15 | implementation(projects.operation) 16 | } 17 | } 18 | } 19 | } 20 | 21 | -------------------------------------------------------------------------------- /storage/src/androidMain/kotlin/aung/thiha/storage/CreatePrefDataStore.android.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.storage 2 | 3 | import android.content.Context 4 | import androidx.datastore.core.DataStore 5 | import androidx.datastore.preferences.core.Preferences 6 | 7 | fun createPrefDataStore(context: Context, prefFileName: String): DataStore = createPrefDataStore( 8 | producePath = { context.filesDir.resolve(prefFileName).absolutePath } 9 | ) 10 | -------------------------------------------------------------------------------- /coroutines/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlinMultiplatform) 3 | } 4 | 5 | kotlin { 6 | jvmToolchain(libs.versions.jvm.toolchain.get().toInt()) 7 | jvm() 8 | iosX64() 9 | iosArm64() 10 | iosSimulatorArm64() 11 | 12 | sourceSets { 13 | commonMain { 14 | dependencies { 15 | implementation(libs.kotlinx.coroutines.core) 16 | } 17 | } 18 | } 19 | } 20 | 21 | -------------------------------------------------------------------------------- /storage/src/jvmMain/kotlin/aung/thiha/storage/di/StorageModule.jvm.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.storage.di 2 | 3 | import org.koin.core.module.Module 4 | import org.koin.dsl.module 5 | 6 | /** 7 | * junit5 works only in JVM 8 | * jvmTest folder cannot be created using jvm target setup in Gradle 9 | * but then, with jvm target setup, we need to provide `actual` for JVM 10 | * This is a trade-off 11 | * */ 12 | actual val storageModule: Module = module { 13 | 14 | } -------------------------------------------------------------------------------- /composeApp/src/jvmTest/kotlin/aung/thiha/photo/album/di/SessionStorageModuleOverride.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.di 2 | 3 | import aung.thiha.photo.album.di.core.fake 4 | import aung.thiha.session.domain.FakeSessionStorage 5 | import aung.thiha.session.domain.SessionStorage 6 | import org.koin.dsl.module 7 | 8 | val sessionStorageModule = module { 9 | fake { 10 | FakeSessionStorage() 11 | } 12 | } 13 | 14 | -------------------------------------------------------------------------------- /composeApp/src/jvmTest/kotlin/aung/thiha/session/domain/FakeSessionStorage.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.session.domain 2 | 3 | import aung.thiha.session.domain.model.Session 4 | 5 | class FakeSessionStorage: SessionStorage { 6 | var session: Session? = null 7 | override suspend fun getAuthenticationSession(): Session? = session 8 | 9 | override suspend fun setAuthenticationSession(session: Session?) { 10 | this.session = session 11 | } 12 | } 13 | 14 | -------------------------------------------------------------------------------- /authentication/presentation/src/commonMain/kotlin/aung/thiha/photo/album/authentication/presentation/signup/navigation/AuthenticationNavigator.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.authentication.presentation.signup.navigation 2 | 3 | import kotlinx.coroutines.Deferred 4 | import kotlinx.coroutines.Job 5 | 6 | interface AuthenticationNavigator { 7 | fun navigateUpFromSignup() : Deferred 8 | fun navigateToSignup() : Job 9 | fun navigateToPhotoList() : Job 10 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .kotlin 3 | .gradle 4 | **/build/ 5 | xcuserdata 6 | !src/**/build/ 7 | local.properties 8 | .idea 9 | .DS_Store 10 | captures 11 | .externalNativeBuild 12 | .cxx 13 | *.xcodeproj/* 14 | !*.xcodeproj/project.pbxproj 15 | !*.xcodeproj/xcshareddata/ 16 | !*.xcodeproj/project.xcworkspace/ 17 | !*.xcworkspace/contents.xcworkspacedata 18 | **/xcshareddata/WorkspaceSettings.xcsettings 19 | composeApp/release/ 20 | 21 | google-services.json 22 | GoogleService-Info.plist -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/aung/thiha/photo/album/di/NetworkModule.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.di 2 | 3 | import aung.thiha.photo.album.network.HttpClientFactory 4 | import io.ktor.client.* 5 | import org.koin.dsl.module 6 | 7 | val networkModule = module { 8 | factory { 9 | HttpClientFactory( 10 | authPlugin = get() 11 | ) 12 | } 13 | factory { 14 | get().createHttpClient() 15 | } 16 | } -------------------------------------------------------------------------------- /storage/src/iosMain/kotlin/aung/thiha/storage/di/StorageModule.ios.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.storage.di 2 | 3 | import aung.thiha.storage.CreateKeyValueStorage 4 | import aung.thiha.storage.createPrefDataStore 5 | import org.koin.core.module.Module 6 | import org.koin.dsl.module 7 | 8 | actual val storageModule: Module 9 | get() = module { 10 | single { 11 | CreateKeyValueStorage { storageScope -> createPrefDataStore(storageScope) } 12 | } 13 | } -------------------------------------------------------------------------------- /photos/domain/src/commonMain/kotlin/aung/thiha/photo/album/photos/domain/usecase/Signout.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.photos.domain.usecase 2 | 3 | import aung.thiha.operation.SuspendOperation 4 | 5 | /** 6 | * Actual signout logic is inside authentication but 7 | * feature modules shouldn't cross-reference each other to maintain modular integrity 8 | * So, the adapter for this is written in [PhotosAppModule.kt] in ComposeApp module 9 | * */ 10 | fun interface Signout : SuspendOperation -------------------------------------------------------------------------------- /operation/src/commonMain/kotlin/aung/thiha/operation/DefaultErrorMapper.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.operation 2 | 3 | import io.ktor.utils.io.errors.IOException 4 | 5 | class DefaultErrorMapper : (Exception) -> Outcome { 6 | override fun invoke(exception: Exception): Outcome { 7 | return when (exception) { 8 | is IOException -> Outcome.Failure(FailureType.NETWORK, exception) 9 | else -> Outcome.Failure(FailureType.GENERAL, exception) 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/aung/thiha/photo/album/splash/SplashNavGraph.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.splash 2 | 3 | import androidx.navigation.NavGraphBuilder 4 | import androidx.navigation.compose.composable 5 | import aung.thiha.compose.navigation.Destination 6 | 7 | import kotlinx.serialization.Serializable 8 | 9 | @Serializable 10 | data object SplashRoute : Destination 11 | 12 | fun NavGraphBuilder.splash() { 13 | composable { 14 | SplashContainer() 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /storage/src/androidMain/kotlin/aung/thiha/storage/di/StorageModule.android.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.storage.di 2 | 3 | import aung.thiha.storage.CreateKeyValueStorage 4 | import aung.thiha.storage.createPrefDataStore 5 | import org.koin.core.module.Module 6 | import org.koin.dsl.module 7 | 8 | actual val storageModule: Module 9 | get() = module { 10 | single { 11 | CreateKeyValueStorage { storageScope -> createPrefDataStore(context = get(), storageScope) } 12 | } 13 | } -------------------------------------------------------------------------------- /storage/src/commonMain/kotlin/aung/thiha/storage/CreatePrefDataStore.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.storage 2 | 3 | import androidx.datastore.core.DataStore 4 | import androidx.datastore.preferences.core.PreferenceDataStoreFactory 5 | import androidx.datastore.preferences.core.Preferences 6 | import okio.Path.Companion.toPath 7 | 8 | fun createPrefDataStore(producePath: () -> String): DataStore = 9 | PreferenceDataStoreFactory.createWithPath( 10 | produceFile = { producePath().toPath() } 11 | ) 12 | -------------------------------------------------------------------------------- /authentication/data/src/commonMain/kotlin/aung/thiha/photo/album/authentication/data/remote/response/AuthenticationResponse.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.authentication.data.remote.response 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class AuthenticationResponse( 8 | @SerialName("userId") 9 | val userId: String, 10 | @SerialName("accessToken") 11 | val accessToken: String, 12 | @SerialName("refreshToken") 13 | val refreshToken: String, 14 | ) 15 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/aung/thiha/photo/album/adapter/authentication/createNavigateToSplash.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.adapter.authentication 2 | 3 | import aung.thiha.photo.album.authentication.domain.usecase.NavigateToSplash 4 | import aung.thiha.photo.album.navigation.NavigationDispatcher 5 | import aung.thiha.photo.album.splash.SplashRoute 6 | 7 | fun createNavigateToSplash(navigationDispatcher: NavigationDispatcher) = NavigateToSplash { 8 | navigationDispatcher.navigate(destination = SplashRoute, clearBackStack = true) 9 | } 10 | -------------------------------------------------------------------------------- /authentication/domain/src/commonMain/kotlin/aung/thiha/photo/album/authentication/domain/AuthenticationRepository.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.authentication.domain 2 | 3 | import aung.thiha.photo.album.authentication.domain.model.SigninInput 4 | import aung.thiha.photo.album.authentication.domain.model.SignupInput 5 | import aung.thiha.operation.SuspendOperation 6 | 7 | interface AuthenticationRepository { 8 | val signin: SuspendOperation 9 | val signup: SuspendOperation 10 | val isTokenValid: SuspendOperation 11 | } -------------------------------------------------------------------------------- /composeApp/src/jvmTest/kotlin/aung/thiha/photo/album/di/AuthenticationDataModuleOverride.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.di 2 | 3 | import aung.thiha.photo.album.authentication.data.remote.service.AuthenticationDataSource 4 | import aung.thiha.photo.album.authentication.data.remote.service.FakeAuthenticationDataSource 5 | import aung.thiha.photo.album.di.core.fake 6 | import org.koin.dsl.module 7 | 8 | val authenticationDataModuleOverride = module { 9 | fake { 10 | FakeAuthenticationDataSource() 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /operation/src/commonMain/kotlin/aung/thiha/operation/Outcome.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.operation 2 | 3 | sealed interface Outcome { 4 | data class Failure(val type: FailureType, val e: Exception) : Outcome 5 | data class Success(val data: D): Outcome 6 | } 7 | 8 | enum class FailureType { 9 | NETWORK, GENERAL; 10 | } 11 | 12 | inline fun Outcome.getOrNull(): T? { 13 | return (this as? Outcome.Success)?.data 14 | } 15 | 16 | inline fun Outcome.rethrowIfFailure() { 17 | (this as? Outcome.Failure)?.e?.let { throw it } 18 | } 19 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/aung/thiha/photo/album/adapter/photos/di/PhotosAppModule.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.adapter.photos.di 2 | 3 | import aung.thiha.photo.album.photos.domain.usecase.Signout as PhotosSignout 4 | import aung.thiha.photo.album.authentication.domain.usecase.Signout as AuthenticationSignout 5 | import org.koin.dsl.module 6 | 7 | val PhotosAppModule = module { 8 | factory { 9 | val delegate = get() 10 | PhotosSignout { input -> 11 | delegate.invoke(input) 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /iosApp/iosApp/ContentView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import SwiftUI 3 | import ComposeApp 4 | 5 | struct ComposeView: UIViewControllerRepresentable { 6 | func makeUIViewController(context: Context) -> UIViewController { 7 | return MainViewControllerKt.MainViewController() 8 | } 9 | 10 | func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} 11 | } 12 | 13 | struct ContentView: View { 14 | var body: some View { 15 | ComposeView() 16 | .ignoresSafeArea(.keyboard) // Compose has own keyboard handler 17 | } 18 | } 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /storage/src/commonMain/kotlin/aung/thiha/storage/CreateKeyValueStorage.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.storage 2 | 3 | import androidx.datastore.core.DataStore 4 | import androidx.datastore.preferences.core.Preferences 5 | 6 | fun interface CreateKeyValueStorage { 7 | /** 8 | * Creates a key-value storage for a given scope. 9 | * 10 | * @param storageScope A string used to group related key-value pairs. 11 | * For example, pairs related to session storage are grouped under the "session" storage scope. 12 | */ 13 | operator fun invoke(storageScope: String): DataStore 14 | } 15 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/aung/thiha/photo/album/MainApplication.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album 2 | 3 | import android.app.Application 4 | import aung.thiha.photo.album.di.appModule 5 | import org.koin.android.ext.koin.androidContext 6 | import org.koin.core.context.startKoin 7 | 8 | class MainApplication : Application() { 9 | 10 | override fun onCreate() { 11 | super.onCreate() 12 | 13 | startKoin { 14 | // Reference Android context 15 | androidContext(this@MainApplication) 16 | // Load modules 17 | modules(appModule) 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /photos/presentation/src/commonMain/kotlin/aung/thiha/photo/album/photos/presentation/navigation/PhotosNavGraph.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.photos.presentation.navigation 2 | 3 | import androidx.navigation.NavGraphBuilder 4 | import androidx.navigation.compose.composable 5 | import aung.thiha.compose.navigation.Destination 6 | import aung.thiha.photo.album.photos.presentation.overview.PhotoListContainer 7 | import kotlinx.serialization.Serializable 8 | 9 | @Serializable 10 | data object PhotoListRoute : Destination 11 | 12 | fun NavGraphBuilder.photos() { 13 | composable { 14 | PhotoListContainer() 15 | } 16 | } -------------------------------------------------------------------------------- /iosApp/iosApp/iOSApp.swift: -------------------------------------------------------------------------------- 1 | import ComposeApp 2 | import SwiftUI 3 | import Firebase 4 | 5 | class AppDelegate: NSObject, UIApplicationDelegate { 6 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { 7 | FirebaseApp.configure() 8 | KoinInitKt.doInitKoin() 9 | return true 10 | } 11 | } 12 | 13 | @main 14 | struct iOSApp: App { 15 | 16 | @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate 17 | 18 | var body: some Scene { 19 | WindowGroup { 20 | ContentView() 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/aung/thiha/photo/album/di/SplashModule.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.di 2 | 3 | import aung.thiha.photo.album.splash.DefaultSplashNavigator 4 | import aung.thiha.photo.album.splash.SplashNavigator 5 | import aung.thiha.photo.album.splash.SplashViewModel 6 | import org.koin.core.module.dsl.viewModel 7 | import org.koin.dsl.module 8 | 9 | val splashModule = module { 10 | factory { 11 | DefaultSplashNavigator(navigationDispatcher = get()) 12 | } 13 | viewModel { 14 | SplashViewModel( 15 | isSignedIn = get(), 16 | navigator = get() 17 | ) 18 | } 19 | } -------------------------------------------------------------------------------- /photos/presentation/src/commonMain/kotlin/aung/thiha/photo/album/photos/presentation/di/PhotosPresentationModule.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.photos.presentation.di 2 | 3 | import aung.thiha.photo.album.photos.domain.PhotosRepository 4 | import aung.thiha.photo.album.photos.domain.usecase.Signout 5 | import aung.thiha.photo.album.photos.presentation.overview.PhotoListViewModel 6 | import org.koin.core.module.dsl.viewModel 7 | import org.koin.dsl.module 8 | 9 | val photosPresentationModule = module { 10 | viewModel { 11 | PhotoListViewModel( 12 | _signout = get(), 13 | photos = get().photos 14 | ) 15 | } 16 | } -------------------------------------------------------------------------------- /authentication/domain/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlinMultiplatform) 3 | } 4 | 5 | kotlin { 6 | jvmToolchain(libs.versions.jvm.toolchain.get().toInt()) 7 | jvm() 8 | iosX64() 9 | iosArm64() 10 | iosSimulatorArm64() 11 | 12 | sourceSets { 13 | commonMain { 14 | dependencies { 15 | implementation(projects.coroutines) 16 | implementation(projects.session.domain) 17 | implementation(projects.operation) 18 | implementation(libs.koin.core) 19 | implementation(libs.kotlinx.coroutines.core) 20 | } 21 | } 22 | } 23 | } 24 | 25 | -------------------------------------------------------------------------------- /authentication/domain/src/commonMain/kotlin/aung/thiha/photo/album/authentication/domain/di/AuthenticationDomainModule.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.authentication.domain.di 2 | 3 | import aung.thiha.photo.album.authentication.domain.usecase.IsSignedIn 4 | import aung.thiha.photo.album.authentication.domain.usecase.Signout 5 | import org.koin.dsl.module 6 | 7 | val authenticationDomainModule = module { 8 | factory { 9 | IsSignedIn( 10 | authenticationRepository = get(), 11 | sessionStorage = get(), 12 | ) 13 | } 14 | factory { 15 | Signout( 16 | sessionStorage = get(), 17 | navigateToSplash = get(), 18 | ) 19 | } 20 | } -------------------------------------------------------------------------------- /photos/data/src/commonMain/kotlin/aung/thiha/photo/album/photos/data/remote/service/PhotosService.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.photos.data.remote.service 2 | 3 | import aung.thiha.photo.album.photos.data.remote.response.PhotoResponse 4 | import io.ktor.client.HttpClient 5 | import io.ktor.client.call.body 6 | import io.ktor.client.request.get 7 | import io.ktor.http.ContentType 8 | import io.ktor.http.contentType 9 | 10 | class PhotosService( 11 | private val httpClient: HttpClient 12 | ) : PhotosDataSource { 13 | override suspend fun photos(): List { 14 | return httpClient.get("photos/") { 15 | contentType(ContentType.Application.Json) 16 | }.body() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /photos/data/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlinMultiplatform) 3 | alias(libs.plugins.kotlinxSerialization) 4 | } 5 | 6 | kotlin { 7 | jvmToolchain(libs.versions.jvm.toolchain.get().toInt()) 8 | jvm() 9 | iosX64() 10 | iosArm64() 11 | iosSimulatorArm64() 12 | 13 | sourceSets { 14 | commonMain { 15 | dependencies { 16 | implementation(projects.photos.domain) 17 | 18 | implementation(projects.operation) 19 | 20 | implementation(libs.koin.core) 21 | 22 | implementation(libs.ktor.client.core) 23 | implementation(libs.ktor.serialization.kotlinx.json) 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /authentication/data/src/commonMain/kotlin/aung/thiha/photo/album/authentication/data/remote/service/AuthenticationDataSource.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.authentication.data.remote.service 2 | 3 | import aung.thiha.photo.album.authentication.data.remote.request.AuthenticationRequest 4 | import aung.thiha.photo.album.authentication.data.remote.response.AuthenticationResponse 5 | import aung.thiha.photo.album.authentication.data.remote.response.TokenCheckResponse 6 | 7 | interface AuthenticationDataSource { 8 | suspend fun signin(authenticationRequest: AuthenticationRequest): AuthenticationResponse 9 | suspend fun signup(authenticationRequest: AuthenticationRequest): AuthenticationResponse 10 | suspend fun isTokenValid(): TokenCheckResponse 11 | } -------------------------------------------------------------------------------- /photos/data/src/commonMain/kotlin/aung/thiha/photo/album/photos/data/di/PhotosDataModule.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.photos.data.di 2 | 3 | import aung.thiha.photo.album.photos.data.PhotosRepositoryImpl 4 | import aung.thiha.photo.album.photos.data.remote.service.PhotosDataSource 5 | import aung.thiha.photo.album.photos.data.remote.service.PhotosService 6 | import aung.thiha.photo.album.photos.domain.PhotosRepository 7 | import org.koin.dsl.module 8 | 9 | val photosDataModule = module { 10 | single { 11 | PhotosService( 12 | httpClient = get() 13 | ) 14 | } 15 | factory { 16 | PhotosRepositoryImpl( 17 | photosDataSource = get(), 18 | ) 19 | } 20 | } -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/aung/thiha/photo/album/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album 2 | 3 | import android.os.Bundle 4 | import android.os.Looper 5 | import androidx.activity.ComponentActivity 6 | import androidx.activity.compose.setContent 7 | import androidx.activity.enableEdgeToEdge 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.tooling.preview.Preview 10 | 11 | class MainActivity : ComponentActivity() { 12 | override fun onCreate(savedInstanceState: Bundle?) { 13 | enableEdgeToEdge() 14 | super.onCreate(savedInstanceState) 15 | 16 | setContent { 17 | App() 18 | } 19 | } 20 | } 21 | 22 | @Preview 23 | @Composable 24 | fun AppAndroidPreview() { 25 | App() 26 | } -------------------------------------------------------------------------------- /photos/data/src/commonMain/kotlin/aung/thiha/photo/album/photos/data/PhotosRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.photos.data 2 | 3 | import aung.thiha.operation.SuspendOperation 4 | import aung.thiha.operation.suspendOperation 5 | import aung.thiha.photo.album.photos.data.remote.service.PhotosDataSource 6 | import aung.thiha.photo.album.photos.domain.PhotosRepository 7 | import aung.thiha.photo.album.photos.domain.model.Photo 8 | 9 | class PhotosRepositoryImpl( 10 | private val photosDataSource: PhotosDataSource 11 | ) : PhotosRepository { 12 | override val photos: SuspendOperation> = suspendOperation { 13 | val result = photosDataSource.photos() 14 | result.map { 15 | Photo(id = it.id, url = it.url) 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /authentication/domain/src/commonMain/kotlin/aung/thiha/photo/album/authentication/domain/usecase/Signout.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.authentication.domain.usecase 2 | 3 | import aung.thiha.operation.Outcome 4 | import aung.thiha.operation.SuspendOperation 5 | import aung.thiha.session.domain.SessionStorage 6 | import kotlinx.coroutines.Job 7 | 8 | fun interface NavigateToSplash: () -> Job 9 | 10 | class Signout( 11 | private val sessionStorage: SessionStorage, 12 | private val navigateToSplash: NavigateToSplash 13 | ) : SuspendOperation { 14 | override suspend fun invoke(input: Unit): Outcome { 15 | sessionStorage.setAuthenticationSession(null) 16 | navigateToSplash.invoke().join() 17 | return Outcome.Success(Unit) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/aung/thiha/photo/album/adapter/authentication/di/AuthenticationAppModule.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.adapter.authentication.di 2 | 3 | import aung.thiha.photo.album.adapter.authentication.DefaultAuthenticationNavigator 4 | import aung.thiha.photo.album.adapter.authentication.createNavigateToSplash 5 | import aung.thiha.photo.album.authentication.domain.usecase.NavigateToSplash 6 | import aung.thiha.photo.album.authentication.presentation.signup.navigation.AuthenticationNavigator 7 | import org.koin.dsl.module 8 | 9 | val authenticationAppModule = module { 10 | factory { DefaultAuthenticationNavigator(navigationDispatcher = get()) } 11 | factory { 12 | createNavigateToSplash(navigationDispatcher = get()) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /compose/src/commonMain/kotlin/aung/thiha/compose/coroutines/FlowExt.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.compose.coroutines 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.LaunchedEffect 5 | import androidx.lifecycle.Lifecycle 6 | import androidx.lifecycle.compose.LocalLifecycleOwner 7 | import androidx.lifecycle.flowWithLifecycle 8 | import kotlinx.coroutines.flow.Flow 9 | import kotlinx.coroutines.flow.FlowCollector 10 | 11 | @Composable 12 | fun Flow.observeWithLifecycle( 13 | lifecycle: Lifecycle = LocalLifecycleOwner.current.lifecycle, 14 | minActiveState: Lifecycle.State = Lifecycle.State.STARTED, 15 | collector: FlowCollector 16 | ) { 17 | LaunchedEffect(this) { 18 | flowWithLifecycle(lifecycle, minActiveState) 19 | .collect(collector) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /composeApp/src/jvmTest/kotlin/aung/thiha/photo/album/di/core/ModuleExt.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.di.core 2 | 3 | import org.koin.core.module.Module 4 | import org.koin.core.qualifier.Qualifier 5 | import org.koin.dsl.binds 6 | 7 | inline fun Module.fake(qualifier: Qualifier? = null, noinline factory: () -> C) { 8 | single(qualifier) { ThreadScoped(factory).instance } binds arrayOf(I::class, C::class) 9 | } 10 | 11 | class ThreadScoped(private val factory: () -> T) { 12 | private val threadLocal = ThreadLocal() 13 | 14 | val instance: T 15 | get() = threadLocal.get() ?: factory().also { threadLocal.set(it) } 16 | 17 | fun set(value: T) { 18 | threadLocal.set(value) 19 | } 20 | 21 | fun clear() { 22 | threadLocal.remove() 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/aung/thiha/photo/album/splash/SplashNavigator.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.splash 2 | 3 | import aung.thiha.photo.album.authentication.presentation.signup.navigation.SigninRoute 4 | import aung.thiha.photo.album.navigation.NavigationDispatcher 5 | import aung.thiha.photo.album.photos.presentation.navigation.PhotoListRoute 6 | import kotlinx.coroutines.Job 7 | 8 | interface SplashNavigator { 9 | fun toPhotoList(): Job 10 | fun toSignin(): Job 11 | } 12 | 13 | class DefaultSplashNavigator( 14 | private val navigationDispatcher: NavigationDispatcher 15 | ) : SplashNavigator { 16 | override fun toPhotoList() = navigationDispatcher.navigate(destination = PhotoListRoute, clearBackStack = true) 17 | 18 | override fun toSignin() = navigationDispatcher.navigate(destination = SigninRoute, clearBackStack = true) 19 | } 20 | -------------------------------------------------------------------------------- /authentication/data/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlinMultiplatform) 3 | alias(libs.plugins.kotlinxSerialization) 4 | } 5 | 6 | kotlin { 7 | jvmToolchain(libs.versions.jvm.toolchain.get().toInt()) 8 | jvm() 9 | iosX64() 10 | iosArm64() 11 | iosSimulatorArm64() 12 | 13 | sourceSets { 14 | commonMain { 15 | dependencies { 16 | implementation(projects.session.domain) 17 | implementation(projects.authentication.domain) 18 | 19 | implementation(projects.operation) 20 | 21 | implementation(libs.koin.core) 22 | 23 | implementation(libs.ktor.client.auth) 24 | implementation(libs.ktor.client.core) 25 | implementation(libs.ktor.serialization.kotlinx.json) 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /coroutines/src/commonMain/kotlin/aung/thiha/coroutines/AppDispatchers.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.coroutines 2 | 3 | import aung.thiha.coroutines.TestDispatcherHolder.testDefault 4 | import aung.thiha.coroutines.TestDispatcherHolder.testIo 5 | import aung.thiha.coroutines.TestDispatcherHolder.testMain 6 | import kotlinx.coroutines.CoroutineDispatcher 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.IO 9 | 10 | object AppDispatchers { 11 | val main: CoroutineDispatcher get() = testMain ?: Dispatchers.Main 12 | val io: CoroutineDispatcher get() = testIo ?: Dispatchers.IO 13 | val default: CoroutineDispatcher get() = testDefault ?: Dispatchers.Default 14 | } 15 | 16 | object TestDispatcherHolder { 17 | var testMain: CoroutineDispatcher? = null 18 | var testIo: CoroutineDispatcher? = null 19 | var testDefault: CoroutineDispatcher? = null 20 | } 21 | -------------------------------------------------------------------------------- /compose/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlinMultiplatform) 3 | alias(libs.plugins.jetbrainsCompose) 4 | alias(libs.plugins.compose.compiler) 5 | } 6 | 7 | kotlin { 8 | jvmToolchain(libs.versions.jvm.toolchain.get().toInt()) 9 | jvm() 10 | iosX64() 11 | iosArm64() 12 | iosSimulatorArm64() 13 | 14 | sourceSets { 15 | commonMain { 16 | dependencies { 17 | implementation(compose.runtime) 18 | implementation(compose.foundation) 19 | implementation(compose.material3) 20 | implementation(compose.ui) 21 | implementation(compose.components.resources) 22 | implementation(compose.components.uiToolingPreview) 23 | implementation(libs.androidx.lifecycle.runtime.compose) 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /operation/src/commonMain/kotlin/aung/thiha/operation/SuspendOperation.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.operation 2 | 3 | fun interface SuspendOperation : suspend (I) -> Outcome 4 | 5 | fun suspendOperation( 6 | mapError: (Exception) -> Outcome = DefaultErrorMapper(), 7 | block: suspend (I) -> O 8 | ): SuspendOperation = SuspendOperation { input -> 9 | try { 10 | Outcome.Success(block(input)) 11 | } catch (e: Exception) { 12 | mapError(e) 13 | } 14 | } 15 | 16 | suspend inline operator fun SuspendOperation.invoke(): Outcome { 17 | return invoke(Unit) 18 | } 19 | 20 | suspend inline fun SuspendOperation.getOrNull(): O? { 21 | return invoke(Unit).getOrNull() 22 | } 23 | 24 | suspend inline fun SuspendOperation.getOrNull(input: I): O? { 25 | return invoke(input).getOrNull() 26 | } 27 | -------------------------------------------------------------------------------- /authentication/domain/src/commonMain/kotlin/aung/thiha/photo/album/authentication/domain/usecase/IsSignedIn.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.authentication.domain.usecase 2 | 3 | import aung.thiha.operation.SuspendOperation 4 | import aung.thiha.operation.rethrowIfFailure 5 | import aung.thiha.operation.suspendOperation 6 | import aung.thiha.photo.album.authentication.domain.AuthenticationRepository 7 | import aung.thiha.session.domain.SessionStorage 8 | 9 | class IsSignedIn( 10 | private val authenticationRepository: AuthenticationRepository, 11 | private val sessionStorage: SessionStorage 12 | ) : SuspendOperation by suspendOperation( 13 | block = { 14 | if (sessionStorage.getAuthenticationSession() == null) { 15 | throw Exception("no token saved") 16 | } 17 | authenticationRepository.isTokenValid.invoke(Unit).rethrowIfFailure() 18 | } 19 | ) 20 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/aung/thiha/photo/album/splash/SplashViewModel.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.splash 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import aung.thiha.photo.album.authentication.domain.usecase.IsSignedIn 6 | import aung.thiha.operation.Outcome 7 | import kotlinx.coroutines.launch 8 | 9 | class SplashViewModel( 10 | private val isSignedIn: IsSignedIn, 11 | private val navigator: SplashNavigator, 12 | ) : ViewModel() { 13 | 14 | init { 15 | viewModelScope.launch { 16 | when (val result = isSignedIn.invoke(Unit)) { 17 | is Outcome.Failure<*> -> { 18 | navigator.toSignin() 19 | } 20 | is Outcome.Success<*> -> { 21 | navigator.toPhotoList() 22 | } 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /authentication/presentation/src/commonMain/kotlin/aung/thiha/photo/album/authentication/presentation/signup/navigation/AuthenticationNavGraph.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.authentication.presentation.signup.navigation 2 | 3 | import androidx.navigation.NavGraphBuilder 4 | import androidx.navigation.compose.composable 5 | import aung.thiha.compose.navigation.Destination 6 | import aung.thiha.photo.album.authentication.presentation.signup.signin.SinginContainer 7 | import aung.thiha.photo.album.authentication.presentation.signup.signup.SignupContainer 8 | import kotlinx.serialization.Serializable 9 | 10 | @Serializable 11 | data object SignupRoute : Destination 12 | 13 | @Serializable 14 | data object SigninRoute : Destination 15 | 16 | fun NavGraphBuilder.authentication() { 17 | composable { 18 | SignupContainer() 19 | } 20 | composable { 21 | SinginContainer() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/aung/thiha/photo/album/navigation/NavigationOptions.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.navigation 2 | 3 | import aung.thiha.compose.navigation.Destination 4 | 5 | /** 6 | * The navigation abstraction layer is designed after to Jetpack Navigation so engineers can pick it up quickly. 7 | * Then, why abstract at all? Mainly to make it easy to swap in another library if needed. 8 | * When needed to swap in another library, might require a bit of adapter logic 9 | * but the change will all be isolated inside the abstraction. 10 | * */ 11 | sealed class BackStackOptions { 12 | data object Clear : BackStackOptions() 13 | data class PopUpTo( 14 | val popUpToDestination: Destination, 15 | val inclusive: Boolean = false 16 | ) : BackStackOptions() 17 | } 18 | 19 | data class NavigationOptions( 20 | val launchSingleTop: Boolean = false, 21 | val backStackOptions: BackStackOptions? = null 22 | ) 23 | -------------------------------------------------------------------------------- /session/data/src/commonMain/kotlin/aung/thiha/session/data/di/SessionStorageModule.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.session.data.di 2 | 3 | import androidx.datastore.core.DataStore 4 | import androidx.datastore.preferences.core.Preferences 5 | import aung.thiha.session.data.SessionStorageImpl 6 | import aung.thiha.session.domain.SessionStorage 7 | import aung.thiha.storage.CreateKeyValueStorage 8 | import org.koin.core.qualifier.named 9 | import org.koin.dsl.module 10 | 11 | private const val PREF_AUTHENTICATION = "auth.preferences_pb" 12 | private val NAMED_AUTHENTICATION_PREFERENCE = named(PREF_AUTHENTICATION) 13 | 14 | val sessionStorageModule = module { 15 | single>(NAMED_AUTHENTICATION_PREFERENCE) { 16 | get().invoke(PREF_AUTHENTICATION) 17 | } 18 | factory { 19 | SessionStorageImpl( 20 | dataStore = get(NAMED_AUTHENTICATION_PREFERENCE) 21 | ) 22 | } 23 | } -------------------------------------------------------------------------------- /authentication/presentation/src/commonMain/kotlin/aung/thiha/photo/album/authentication/presentation/signup/di/AuthenticationPresentationModule.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.authentication.presentation.signup.di 2 | 3 | import aung.thiha.photo.album.authentication.domain.AuthenticationRepository 4 | import aung.thiha.photo.album.authentication.presentation.signup.signin.SigninViewModel 5 | import aung.thiha.photo.album.authentication.presentation.signup.signup.SignupViewModel 6 | import org.koin.core.module.dsl.viewModel 7 | import org.koin.dsl.module 8 | 9 | val authenticationPresentationModule = module { 10 | viewModel { 11 | SigninViewModel( 12 | sigin = get().signin, 13 | navigator = get(), 14 | ) 15 | } 16 | viewModel { 17 | SignupViewModel( 18 | sigup = get().signup, 19 | navigator = get(), 20 | ) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /storage/src/iosMain/kotlin/aung/thiha/storage/CreatePrefDataStore.ios.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.storage 2 | 3 | import androidx.datastore.core.DataStore 4 | import androidx.datastore.preferences.core.Preferences 5 | import kotlinx.cinterop.ExperimentalForeignApi 6 | import platform.Foundation.NSDocumentDirectory 7 | import platform.Foundation.NSFileManager 8 | import platform.Foundation.NSURL 9 | import platform.Foundation.NSUserDomainMask 10 | 11 | @OptIn(ExperimentalForeignApi::class) 12 | fun createPrefDataStore(prefFileName: String): DataStore = createPrefDataStore( 13 | producePath = { 14 | val documentDirectory: NSURL? = NSFileManager.defaultManager.URLForDirectory( 15 | directory = NSDocumentDirectory, 16 | inDomain = NSUserDomainMask, 17 | appropriateForURL = null, 18 | create = false, 19 | error = null, 20 | ) 21 | requireNotNull(documentDirectory).path + "/$prefFileName" 22 | } 23 | ) -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/aung/thiha/photo/album/adapter/authentication/DefaultAuthenticationNavigator.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.adapter.authentication 2 | 3 | import aung.thiha.photo.album.authentication.presentation.signup.navigation.AuthenticationNavigator 4 | import aung.thiha.photo.album.authentication.presentation.signup.navigation.SignupRoute 5 | import aung.thiha.photo.album.navigation.NavigationDispatcher 6 | import aung.thiha.photo.album.photos.presentation.navigation.PhotoListRoute 7 | 8 | class DefaultAuthenticationNavigator( 9 | private val navigationDispatcher: NavigationDispatcher 10 | ) : AuthenticationNavigator { 11 | override fun navigateUpFromSignup() = navigationDispatcher.navigateUp() 12 | 13 | override fun navigateToSignup() = navigationDispatcher.navigate( 14 | destination = SignupRoute 15 | ) 16 | 17 | override fun navigateToPhotoList() = navigationDispatcher.navigate(destination = PhotoListRoute, clearBackStack = true) 18 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/aung/thiha/photo/album/navigation/NavOptionsBuilderExt.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.navigation 2 | 3 | import androidx.navigation.NavOptionsBuilder 4 | import co.touchlab.kermit.Logger 5 | 6 | fun NavOptionsBuilder.applyNavigationOptions(navigationOptions: NavigationOptions) { 7 | Logger.withTag("navOptions").d("launchSingleTop: $launchSingleTop") 8 | launchSingleTop = navigationOptions.launchSingleTop 9 | 10 | navigationOptions.backStackOptions?.let { 11 | when (it) { 12 | BackStackOptions.Clear -> { 13 | Logger.withTag("navOptions").d("clearBackStack") 14 | popUpTo(0) 15 | } 16 | 17 | is BackStackOptions.PopUpTo -> { 18 | Logger.withTag("navOptions").d("withPopupOptions") 19 | popUpTo(it.popUpToDestination) { 20 | inclusive = it.inclusive 21 | } 22 | } 23 | 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /storage/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.android.build.api.dsl.androidLibrary 2 | 3 | plugins { 4 | alias(libs.plugins.kotlinMultiplatform) 5 | alias(libs.plugins.androidKotlinMultiplatformLibrary) 6 | } 7 | 8 | kotlin { 9 | 10 | jvm() 11 | androidLibrary { 12 | namespace = "aung.thiha.storage" 13 | compileSdk = libs.versions.android.compileSdk.get().toInt() 14 | minSdk = libs.versions.android.minSdk.get().toInt() 15 | 16 | packaging { 17 | resources { 18 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 19 | } 20 | } 21 | } 22 | 23 | iosX64() 24 | iosArm64() 25 | iosSimulatorArm64() 26 | 27 | sourceSets { 28 | 29 | androidMain.dependencies { 30 | } 31 | iosMain.dependencies { 32 | } 33 | commonMain.dependencies { 34 | implementation(libs.androidx.datastore) 35 | implementation(libs.koin.core) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /compose/src/commonMain/kotlin/aung/thiha/compose/LoadingOverlay.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.compose 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.material3.CircularProgressIndicator 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Alignment 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.graphics.Color 12 | 13 | @Composable 14 | fun LoadingOverlay() { 15 | Box( 16 | modifier = Modifier 17 | .fillMaxSize() 18 | .background(Color.Black.copy(alpha = 0.5f)) // Semi-transparent background 19 | .clickable(enabled = false) {} // Disables clicks on the overlay 20 | ) { 21 | CircularProgressIndicator( 22 | modifier = Modifier 23 | .align(Alignment.Center) 24 | ) 25 | } 26 | } -------------------------------------------------------------------------------- /authentication/data/src/commonMain/kotlin/aung/thiha/photo/album/authentication/data/remote/request/AuthenticationRequest.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.authentication.data.remote.request 2 | 3 | import aung.thiha.photo.album.authentication.domain.model.SigninInput 4 | import aung.thiha.photo.album.authentication.domain.model.SignupInput 5 | import kotlinx.serialization.SerialName 6 | import kotlinx.serialization.Serializable 7 | 8 | @Serializable 9 | data class AuthenticationRequest( 10 | @SerialName("email") 11 | val email: String, 12 | @SerialName("password") 13 | val password: String, 14 | ) { 15 | companion object { 16 | fun fromSigninInput(signinInput: SigninInput) = AuthenticationRequest( 17 | email = signinInput.email, 18 | password = signinInput.password, 19 | ) 20 | fun fromSignupInput(signinInput: SignupInput) = AuthenticationRequest( 21 | email = signinInput.email, 22 | password = signinInput.password, 23 | ) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /composeApp/src/jvmTest/kotlin/aung/thiha/photo/album/navigation/DestinationWithOptions.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.navigation 2 | 3 | import aung.thiha.compose.navigation.Destination 4 | 5 | data class DestinationWithOptions( 6 | val destination: Destination, 7 | val launchSingleTop: Boolean = false, 8 | val backStackOptions: BackStackOptions? = null, 9 | ) 10 | 11 | val Destination.withLaunchSingleTop: DestinationWithOptions 12 | get() = DestinationWithOptions( 13 | destination = this, 14 | launchSingleTop = true 15 | ) 16 | 17 | 18 | val Destination.withClearBackStack: DestinationWithOptions 19 | get() = DestinationWithOptions( 20 | destination = this, 21 | backStackOptions = BackStackOptions.Clear 22 | ) 23 | 24 | fun Destination.withPopUpTo(popUpTo: Destination, isInclusive: Boolean = false): DestinationWithOptions = 25 | DestinationWithOptions( 26 | destination = this, 27 | backStackOptions = BackStackOptions.PopUpTo(popUpTo, isInclusive) 28 | ) 29 | -------------------------------------------------------------------------------- /session/data/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.android.build.api.dsl.androidLibrary 2 | 3 | plugins { 4 | alias(libs.plugins.kotlinMultiplatform) 5 | alias(libs.plugins.androidKotlinMultiplatformLibrary) 6 | } 7 | 8 | kotlin { 9 | 10 | jvm() 11 | 12 | androidLibrary { 13 | namespace = "aung.thiha.session" 14 | compileSdk = libs.versions.android.compileSdk.get().toInt() 15 | minSdk = libs.versions.android.minSdk.get().toInt() 16 | 17 | packaging { 18 | resources { 19 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 20 | } 21 | } 22 | } 23 | 24 | iosX64() 25 | iosArm64() 26 | iosSimulatorArm64() 27 | 28 | sourceSets { 29 | 30 | androidMain.dependencies { 31 | } 32 | iosMain.dependencies { 33 | } 34 | commonMain.dependencies { 35 | implementation(projects.storage) 36 | implementation(projects.session.domain) 37 | 38 | implementation(libs.androidx.datastore) 39 | implementation(libs.koin.core) 40 | implementation(libs.kermit) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/aung/thiha/photo/album/provider/ContextProvider.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.provider 2 | 3 | import android.content.ContentProvider 4 | import android.content.ContentValues 5 | import android.content.Context 6 | import android.database.Cursor 7 | import android.net.Uri 8 | 9 | object ContextHolder { 10 | lateinit var context: Context 11 | internal set 12 | } 13 | 14 | class ContextProvider : ContentProvider() { 15 | override fun onCreate(): Boolean { 16 | context?.let { 17 | ContextHolder.context = it 18 | } 19 | return false 20 | } 21 | override fun query( 22 | uri: Uri, 23 | projection: Array?, 24 | selection: String?, 25 | selectionArgs: Array?, 26 | sortOrder: String? 27 | ): Cursor? = null 28 | override fun getType(uri: Uri): String? = null 29 | override fun insert(uri: Uri, values: ContentValues?): Uri? = null 30 | override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0 31 | override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int = 0 32 | } -------------------------------------------------------------------------------- /composeApp/src/jvmTest/kotlin/aung/thiha/photo/album/di/core/KoinTestExtension.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.di.core 2 | import aung.thiha.photo.album.di.appModule 3 | import aung.thiha.photo.album.di.overrides 4 | import org.junit.jupiter.api.extension.AfterEachCallback 5 | import org.junit.jupiter.api.extension.BeforeEachCallback 6 | import org.junit.jupiter.api.extension.ExtensionContext 7 | import org.koin.core.context.GlobalContext.startKoin 8 | import org.koin.core.context.GlobalContext.stopKoin 9 | import org.koin.core.module.Module 10 | 11 | /** 12 | * @param extraOverrides - if the override is applicable only to a particular test class, 13 | * use this param in that particular test class. This leaves the overrides unchanged for other test classes. 14 | * */ 15 | class KoinTestExtension( 16 | private val extraOverrides: List = emptyList() 17 | ) : BeforeEachCallback, AfterEachCallback { 18 | override fun beforeEach(context: ExtensionContext) { 19 | startKoin { 20 | modules( 21 | appModule + overrides + extraOverrides 22 | ) 23 | } 24 | } 25 | 26 | override fun afterEach(context: ExtensionContext) { 27 | stopKoin() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/aung/thiha/photo/album/di/AppModule.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.di 2 | 3 | import aung.thiha.photo.album.authentication.data.di.authenticationDataModule 4 | import aung.thiha.photo.album.adapter.authentication.di.authenticationAppModule 5 | import aung.thiha.photo.album.authentication.domain.di.authenticationDomainModule 6 | import aung.thiha.photo.album.authentication.presentation.signup.di.authenticationPresentationModule 7 | import aung.thiha.photo.album.photos.data.di.photosDataModule 8 | import aung.thiha.photo.album.adapter.photos.di.PhotosAppModule 9 | import aung.thiha.photo.album.photos.presentation.di.photosPresentationModule 10 | import aung.thiha.session.data.di.sessionStorageModule 11 | import aung.thiha.storage.di.storageModule 12 | import org.koin.core.module.Module 13 | 14 | val appModule : List = listOf( 15 | 16 | platformModule, 17 | 18 | storageModule, 19 | 20 | sessionStorageModule, 21 | 22 | networkModule, 23 | 24 | splashModule, 25 | 26 | authenticationAppModule, 27 | authenticationDataModule, 28 | authenticationDomainModule, 29 | authenticationPresentationModule, 30 | 31 | PhotosAppModule, 32 | photosDataModule, 33 | photosPresentationModule, 34 | 35 | navigationModule, 36 | ) -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "PhotoAlbum" 2 | enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") 3 | 4 | pluginManagement { 5 | repositories { 6 | google { 7 | mavenContent { 8 | includeGroupAndSubgroups("androidx") 9 | includeGroupAndSubgroups("com.android") 10 | includeGroupAndSubgroups("com.google") 11 | } 12 | } 13 | mavenCentral() 14 | gradlePluginPortal() 15 | } 16 | } 17 | 18 | dependencyResolutionManagement { 19 | repositories { 20 | google { 21 | mavenContent { 22 | includeGroupAndSubgroups("androidx") 23 | includeGroupAndSubgroups("com.android") 24 | includeGroupAndSubgroups("com.google") 25 | } 26 | } 27 | mavenCentral() 28 | } 29 | } 30 | 31 | include( 32 | ":authentication:data", 33 | ":authentication:domain", 34 | ":authentication:presentation", 35 | ) 36 | include( 37 | ":photos:data", 38 | ":photos:domain", 39 | ":photos:presentation", 40 | ) 41 | include(":coroutines") 42 | include(":composeApp") 43 | include(":compose") 44 | include(":operation") 45 | include(":storage") 46 | include( 47 | ":session:data", 48 | ":session:domain", 49 | ) 50 | -------------------------------------------------------------------------------- /compose/src/commonMain/kotlin/aung/thiha/compose/AppTopAppBar.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.compose 2 | 3 | import androidx.compose.material3.TopAppBar 4 | import androidx.compose.foundation.layout.RowScope 5 | import androidx.compose.material3.ExperimentalMaterial3Api 6 | import androidx.compose.material3.Icon 7 | import androidx.compose.material3.IconButton 8 | import androidx.compose.material3.TopAppBarDefaults 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.graphics.Color 11 | import org.jetbrains.compose.resources.painterResource 12 | import photoalbum.compose.generated.resources.Res 13 | import photoalbum.compose.generated.resources.ic_action_arrow_back_ios 14 | 15 | @OptIn(ExperimentalMaterial3Api::class) 16 | @Composable 17 | fun AlbumTopAppBar( 18 | actions: @Composable RowScope.() -> Unit = {}, 19 | onUpButtonClick: () -> Unit, 20 | ) { 21 | TopAppBar( 22 | title = { }, 23 | navigationIcon = { 24 | IconButton(onClick = onUpButtonClick) { 25 | Icon( 26 | // TODO update to material 3 back button and delete ic_action_arrow_back_ios 27 | painter = painterResource(Res.drawable.ic_action_arrow_back_ios), 28 | contentDescription = "Up Button" 29 | ) 30 | } 31 | }, 32 | actions = actions 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /authentication/data/src/commonMain/kotlin/aung/thiha/photo/album/authentication/data/di/AuthDataModule.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.authentication.data.di 2 | 3 | import aung.thiha.photo.album.authentication.data.AuthenticationRepositoryImpl 4 | import aung.thiha.photo.album.authentication.data.plugin.AuthPlugin 5 | import aung.thiha.photo.album.authentication.data.remote.service.AuthenticationDataSource 6 | import aung.thiha.photo.album.authentication.data.remote.service.AuthenticationService 7 | import aung.thiha.photo.album.authentication.domain.AuthenticationRepository 8 | import aung.thiha.photo.album.authentication.domain.usecase.Signout 9 | import aung.thiha.session.domain.SessionStorage 10 | import org.koin.dsl.module 11 | 12 | val authenticationDataModule = module { 13 | factory { 14 | AuthPlugin( 15 | sessionStorage = get(), 16 | authenticationServiceProvider = lazy(LazyThreadSafetyMode.NONE) { 17 | get() 18 | }, 19 | signoutProvider = lazy(LazyThreadSafetyMode.NONE) { 20 | get() 21 | } 22 | ) 23 | } 24 | factory { 25 | AuthenticationRepositoryImpl( 26 | sessionStorage = get(), 27 | authenticationDataSourceProvider = lazy(LazyThreadSafetyMode.NONE) { get() }, 28 | ) 29 | } 30 | single { 31 | AuthenticationService( 32 | httpClient = get() 33 | ) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/aung/thiha/photo/album/splash/SplashScreen.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.splash 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Spacer 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.foundation.layout.height 8 | import androidx.compose.foundation.layout.imePadding 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.material3.LinearProgressIndicator 11 | import androidx.compose.material3.Text 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.ui.Alignment 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.unit.dp 16 | import androidx.compose.ui.unit.sp 17 | import org.koin.compose.viewmodel.koinViewModel 18 | 19 | @Composable 20 | fun SplashContainer() { 21 | val viewModel = koinViewModel() 22 | 23 | SplashScreen() 24 | } 25 | 26 | @Composable 27 | fun SplashScreen() { 28 | 29 | Column( 30 | modifier = Modifier 31 | .fillMaxSize() 32 | .imePadding(), 33 | horizontalAlignment = Alignment.CenterHorizontally, 34 | verticalArrangement = Arrangement.Center 35 | ) { 36 | Text( 37 | text = "Photo Album", 38 | fontSize = 24.sp, 39 | modifier = Modifier.padding(bottom = 32.dp) 40 | ) 41 | Spacer(modifier = Modifier.height(8.dp)) 42 | LinearProgressIndicator() 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 15 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /photos/presentation/src/commonMain/kotlin/aung/thiha/photo/album/photos/presentation/overview/PhotoListViewModel.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.photos.presentation.overview 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import aung.thiha.operation.Outcome 6 | import aung.thiha.operation.SuspendOperation 7 | import aung.thiha.photo.album.photos.domain.model.Photo 8 | import kotlinx.coroutines.flow.MutableStateFlow 9 | import kotlinx.coroutines.flow.StateFlow 10 | import kotlinx.coroutines.launch 11 | 12 | class PhotoListViewModel( 13 | private val _signout: SuspendOperation, 14 | private val photos: SuspendOperation> 15 | ) : ViewModel(), PhotoListScreenListener { 16 | 17 | private val _photoListState = MutableStateFlow(PhotoListState.Loading) 18 | val photoListState: StateFlow = _photoListState 19 | 20 | init { 21 | load() 22 | } 23 | 24 | override fun onRetryClick() { 25 | load() 26 | } 27 | 28 | override fun onSignoutClick() { 29 | viewModelScope.launch { 30 | _signout(Unit) 31 | } 32 | } 33 | 34 | private fun load() { 35 | viewModelScope.launch { 36 | _photoListState.value = PhotoListState.Loading 37 | when (val result = photos.invoke(Unit)) { 38 | is Outcome.Failure<*> -> { 39 | _photoListState.value = PhotoListState.LoadingFailed 40 | } 41 | is Outcome.Success> -> { 42 | _photoListState.value = PhotoListState.Content(photos = result.data) 43 | } 44 | } 45 | } 46 | } 47 | 48 | } -------------------------------------------------------------------------------- /iosApp/iosApp/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | CADisableMinimumFrameDurationOnPhone 24 | 25 | UIApplicationSceneManifest 26 | 27 | UIApplicationSupportsMultipleScenes 28 | 29 | 30 | UILaunchScreen 31 | 32 | UIRequiredDeviceCapabilities 33 | 34 | armv7 35 | 36 | UISupportedInterfaceOrientations 37 | 38 | UIInterfaceOrientationPortrait 39 | UIInterfaceOrientationLandscapeLeft 40 | UIInterfaceOrientationLandscapeRight 41 | 42 | UISupportedInterfaceOrientations~ipad 43 | 44 | UIInterfaceOrientationPortrait 45 | UIInterfaceOrientationPortraitUpsideDown 46 | UIInterfaceOrientationLandscapeLeft 47 | UIInterfaceOrientationLandscapeRight 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /composeApp/src/jvmTest/kotlin/aung/thiha/photo/album/authentication/data/remote/service/FakeAuthenticationDataSource.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.authentication.data.remote.service 2 | 3 | import aung.thiha.photo.album.authentication.data.remote.request.AuthenticationRequest 4 | import aung.thiha.photo.album.authentication.data.remote.response.AuthenticationResponse 5 | import aung.thiha.photo.album.authentication.data.remote.response.TokenCheckResponse 6 | 7 | class FakeAuthenticationDataSource : AuthenticationDataSource { 8 | 9 | val signInRequests = mutableListOf() 10 | val signUpRequests = mutableListOf() 11 | var tokenCheckCallCount = 0 12 | 13 | val signInResponses = ArrayDeque() 14 | val signUpResponses = ArrayDeque() 15 | val tokenCheckResponses = ArrayDeque() 16 | 17 | override suspend fun signin(authenticationRequest: AuthenticationRequest): AuthenticationResponse { 18 | signInRequests += authenticationRequest 19 | return signInResponses.removeFirstOrNull() 20 | ?: error("No signin response enqueued — possible unexpected extra call") 21 | } 22 | 23 | override suspend fun signup(authenticationRequest: AuthenticationRequest): AuthenticationResponse { 24 | signUpRequests += authenticationRequest 25 | return signUpResponses.removeFirstOrNull() 26 | ?: error("No signup response enqueued — possible unexpected extra call") 27 | } 28 | 29 | override suspend fun isTokenValid(): TokenCheckResponse { 30 | tokenCheckCallCount++ 31 | return tokenCheckResponses.removeFirstOrNull() 32 | ?: error("No token check response enqueued — possible unexpected extra call") 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /authentication/data/src/commonMain/kotlin/aung/thiha/photo/album/authentication/data/remote/service/AuthenticationService.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.authentication.data.remote.service 2 | 3 | import aung.thiha.photo.album.authentication.data.remote.request.AuthenticationRequest 4 | import aung.thiha.photo.album.authentication.data.remote.response.AuthenticationResponse 5 | import aung.thiha.photo.album.authentication.data.remote.response.TokenCheckResponse 6 | import aung.thiha.photo.album.authentication.data.remote.request.RefreshTokenRequest 7 | import io.ktor.client.* 8 | import io.ktor.client.call.* 9 | import io.ktor.client.plugins.auth.providers.RefreshTokensParams 10 | import io.ktor.client.request.* 11 | import io.ktor.http.* 12 | 13 | class AuthenticationService( 14 | private val httpClient: HttpClient 15 | ) : AuthenticationDataSource { 16 | 17 | override suspend fun signin( 18 | authenticationRequest: AuthenticationRequest 19 | ): AuthenticationResponse { 20 | return httpClient.post("api/auth/signin") { 21 | contentType(ContentType.Application.Json) 22 | setBody(authenticationRequest) 23 | }.body() 24 | } 25 | 26 | override suspend fun signup( 27 | authenticationRequest: AuthenticationRequest 28 | ): AuthenticationResponse { 29 | return httpClient.post("api/auth/signup") { 30 | contentType(ContentType.Application.Json) 31 | setBody(authenticationRequest) 32 | }.body() 33 | } 34 | 35 | override suspend fun isTokenValid(): TokenCheckResponse { 36 | val httpResponse = httpClient.get("api/auth/is_token_valid") { 37 | contentType(ContentType.Application.Json) 38 | } 39 | if (httpResponse.status != HttpStatusCode.OK) { 40 | throw Exception() 41 | } 42 | return httpResponse.body() 43 | } 44 | 45 | suspend fun refreshTokens(refreshTokensParams: RefreshTokensParams, refreshToken: String) = refreshTokensParams.run { 46 | client.post("api/auth/refreshtoken") { 47 | contentType(ContentType.Application.Json) 48 | setBody(RefreshTokenRequest(refreshToken)) 49 | markAsRefreshTokenRequest() 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /authentication/presentation/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.android.build.api.dsl.androidLibrary 2 | 3 | plugins { 4 | alias(libs.plugins.androidKotlinMultiplatformLibrary) 5 | alias(libs.plugins.kotlinMultiplatform) 6 | alias(libs.plugins.jetbrainsCompose) 7 | alias(libs.plugins.kotlinxSerialization) 8 | alias(libs.plugins.compose.compiler) 9 | } 10 | 11 | kotlin { 12 | jvmToolchain(libs.versions.jvm.toolchain.get().toInt()) 13 | 14 | jvm() 15 | 16 | androidLibrary { 17 | namespace = "aung.thiha.photo.album.authentication.presentation" 18 | compileSdk = libs.versions.android.compileSdk.get().toInt() 19 | minSdk = libs.versions.android.minSdk.get().toInt() 20 | androidResources.enable = true 21 | 22 | packaging { 23 | resources { 24 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 25 | } 26 | } 27 | } 28 | 29 | iosX64() 30 | iosArm64() 31 | iosSimulatorArm64() 32 | 33 | sourceSets { 34 | commonMain { 35 | dependencies { 36 | implementation(projects.authentication.domain) 37 | implementation(projects.operation) 38 | 39 | implementation(projects.coroutines) 40 | 41 | implementation(libs.aungthiha.snackbarStateflowHandle) 42 | 43 | implementation(libs.koin.core) 44 | implementation(libs.koin.compose) 45 | implementation(libs.koin.composeViewModel) 46 | 47 | implementation(libs.androidx.lifecycle.viewmodel) 48 | implementation(libs.androidx.lifecycle.runtime.compose) 49 | implementation(libs.androidx.navigation) 50 | 51 | implementation(libs.ktor.serialization.kotlinx.json) 52 | 53 | implementation(projects.compose) 54 | 55 | implementation(compose.runtime) 56 | implementation(compose.foundation) 57 | implementation(compose.material3) 58 | implementation(compose.ui) 59 | implementation(compose.components.resources) 60 | implementation(compose.components.uiToolingPreview) 61 | } 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /authentication/data/src/commonMain/kotlin/aung/thiha/photo/album/authentication/data/AuthenticationRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.authentication.data 2 | 3 | import aung.thiha.operation.SuspendOperation 4 | import aung.thiha.operation.suspendOperation 5 | import aung.thiha.photo.album.authentication.data.remote.request.AuthenticationRequest 6 | import aung.thiha.photo.album.authentication.data.remote.service.AuthenticationDataSource 7 | import aung.thiha.photo.album.authentication.domain.AuthenticationRepository 8 | import aung.thiha.photo.album.authentication.domain.model.SigninInput 9 | import aung.thiha.photo.album.authentication.domain.model.SignupInput 10 | import aung.thiha.session.domain.SessionStorage 11 | import aung.thiha.session.domain.model.Session 12 | 13 | class AuthenticationRepositoryImpl( 14 | private val sessionStorage: SessionStorage, 15 | private val authenticationDataSourceProvider: Lazy, 16 | ) : AuthenticationRepository { 17 | 18 | private val authenticationDataSource 19 | get() = authenticationDataSourceProvider.value 20 | 21 | override val signin: SuspendOperation = suspendOperation { 22 | val result = authenticationDataSource.signin(AuthenticationRequest.fromSigninInput(it)) 23 | with(result) { 24 | sessionStorage.setAuthenticationSession( 25 | Session( 26 | accessToken = accessToken, 27 | refreshToken = refreshToken, 28 | userId = userId, 29 | ) 30 | ) 31 | } 32 | } 33 | 34 | override val signup: SuspendOperation = suspendOperation { 35 | val result = authenticationDataSource.signup(AuthenticationRequest.fromSignupInput(it)) 36 | with(result) { 37 | sessionStorage.setAuthenticationSession( 38 | Session( 39 | accessToken = accessToken, 40 | refreshToken = refreshToken, 41 | userId = userId, 42 | ) 43 | ) 44 | } 45 | } 46 | 47 | override val isTokenValid: SuspendOperation = suspendOperation { 48 | authenticationDataSource.isTokenValid() 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /photos/presentation/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.android.build.api.dsl.androidLibrary 2 | 3 | plugins { 4 | alias(libs.plugins.androidKotlinMultiplatformLibrary) 5 | alias(libs.plugins.kotlinMultiplatform) 6 | alias(libs.plugins.jetbrainsCompose) 7 | alias(libs.plugins.kotlinxSerialization) 8 | alias(libs.plugins.compose.compiler) 9 | } 10 | 11 | kotlin { 12 | jvmToolchain(libs.versions.jvm.toolchain.get().toInt()) 13 | 14 | jvm() 15 | 16 | androidLibrary { 17 | namespace = "aung.thiha.photo.album.photos.presentation" 18 | compileSdk = libs.versions.android.compileSdk.get().toInt() 19 | minSdk = libs.versions.android.minSdk.get().toInt() 20 | 21 | packaging { 22 | resources { 23 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 24 | } 25 | } 26 | } 27 | 28 | iosX64() 29 | iosArm64() 30 | iosSimulatorArm64() 31 | 32 | sourceSets { 33 | commonMain { 34 | dependencies { 35 | implementation(projects.photos.domain) 36 | implementation(projects.operation) 37 | 38 | implementation(projects.coroutines) 39 | 40 | implementation(libs.compose.shimmer) 41 | 42 | implementation(libs.coil.compose) 43 | implementation(libs.coil.network) 44 | 45 | implementation(libs.koin.core) 46 | implementation(libs.koin.compose) 47 | implementation(libs.koin.composeViewModel) 48 | 49 | implementation(libs.androidx.lifecycle.viewmodel) 50 | implementation(libs.androidx.lifecycle.runtime.compose) 51 | implementation(libs.androidx.navigation) 52 | 53 | implementation(libs.ktor.serialization.kotlinx.json) 54 | 55 | implementation(projects.compose) 56 | 57 | implementation(compose.runtime) 58 | implementation(compose.foundation) 59 | implementation(compose.material3) 60 | implementation(compose.ui) 61 | implementation(compose.components.resources) 62 | implementation(compose.components.uiToolingPreview) 63 | } 64 | } 65 | } 66 | } 67 | 68 | -------------------------------------------------------------------------------- /composeApp/src/jvmTest/kotlin/aung/thiha/photo/album/splash/SplashViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.splash 2 | 3 | import aung.thiha.coroutines.TestDispatcherExtension 4 | import aung.thiha.photo.album.authentication.data.remote.response.TokenCheckResponse 5 | import aung.thiha.photo.album.authentication.data.remote.service.FakeAuthenticationDataSource 6 | import aung.thiha.photo.album.authentication.presentation.signup.navigation.SigninRoute 7 | import aung.thiha.photo.album.di.core.KoinTestExtension 8 | import aung.thiha.photo.album.navigation.runNavTest 9 | import aung.thiha.photo.album.navigation.shouldNavigateTo 10 | import aung.thiha.photo.album.navigation.withClearBackStack 11 | import aung.thiha.photo.album.photos.presentation.navigation.PhotoListRoute 12 | import aung.thiha.session.domain.FakeSessionStorage 13 | import aung.thiha.session.domain.model.Session 14 | import org.junit.jupiter.api.Test 15 | import org.junit.jupiter.api.extension.ExtendWith 16 | import org.koin.core.component.get 17 | import org.koin.test.KoinTest 18 | import org.koin.test.inject 19 | 20 | @ExtendWith(TestDispatcherExtension::class) 21 | @ExtendWith(KoinTestExtension::class) 22 | class SplashViewModelTest : KoinTest { 23 | 24 | private val sessionStorage: FakeSessionStorage by inject() 25 | private val authDataSource: FakeAuthenticationDataSource by inject() 26 | 27 | @Test 28 | fun `navigates to photo list if signed in`() = runNavTest { spyNav -> 29 | sessionStorage.session = Session("access", "refresh", "user") 30 | authDataSource.tokenCheckResponses += TokenCheckResponse(message = "all good") 31 | 32 | get() 33 | 34 | spyNav shouldNavigateTo PhotoListRoute.withClearBackStack 35 | } 36 | 37 | @Test 38 | fun `navigates to signin if not signed in`() = runNavTest { spyNav -> 39 | sessionStorage.session = null 40 | 41 | get() 42 | 43 | spyNav shouldNavigateTo SigninRoute.withClearBackStack 44 | } 45 | 46 | @Test 47 | fun `navigates to signin if server says token invalid`() = runNavTest { spyNav -> 48 | sessionStorage.session = Session("access", "refresh", "user") 49 | 50 | get() 51 | 52 | spyNav shouldNavigateTo SigninRoute.withClearBackStack 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/aung/thiha/photo/album/network/HttpClientFactory.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.network 2 | 3 | import aung.thiha.photo.album.authentication.data.plugin.AuthPlugin 4 | import io.ktor.client.HttpClient 5 | import io.ktor.client.plugins.auth.Auth 6 | import io.ktor.client.plugins.contentnegotiation.ContentNegotiation 7 | import io.ktor.client.plugins.defaultRequest 8 | import io.ktor.client.plugins.logging.DEFAULT 9 | import io.ktor.client.plugins.logging.LogLevel 10 | import io.ktor.client.plugins.logging.Logger 11 | import io.ktor.client.plugins.logging.Logging 12 | import io.ktor.http.URLProtocol 13 | import io.ktor.serialization.kotlinx.json.json 14 | import kotlinx.serialization.json.Json 15 | 16 | private const val HOST = "quiet-citadel-44720-935ed12c52b6.herokuapp.com" // https://github.com/AungThiha/PhotoAlbumServer 17 | 18 | class HttpClientFactory( 19 | private val authPlugin: AuthPlugin 20 | ) { 21 | fun createHttpClient(): HttpClient = HttpClient { 22 | defaultRequest { 23 | host = HOST 24 | url { 25 | protocol = URLProtocol.HTTPS 26 | } 27 | // to connect local server. your app needs to be on the same local network as the server 28 | // host = "192.168.1.34:8080" // IP:PORT of your local server 29 | // url { 30 | // protocol = URLProtocol.HTTP 31 | // } 32 | } 33 | install(ContentNegotiation) { 34 | json(Json { 35 | ignoreUnknownKeys = true 36 | useAlternativeNames = false 37 | }) 38 | } 39 | install(Logging) { 40 | logger = Logger.DEFAULT 41 | level = LogLevel.ALL 42 | logger = object : Logger { 43 | override fun log(message: String) { 44 | println(message) 45 | } 46 | } 47 | } 48 | install(Auth) { 49 | /** 50 | * The reason AuthPlugin is in authentication module instead of directly implementing the plugin is to enable multiple squad ownership 51 | * one squad can own the composeApp module and another can own the authentication module 52 | * This allows two different squads to work in parallel 53 | * */ 54 | authPlugin.plug(this) 55 | } 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /composeApp/src/jvmTest/kotlin/aung/thiha/photo/album/authentication/domain/usecase/IsSignedInTest.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.authentication.domain.usecase 2 | 3 | import aung.thiha.operation.Outcome 4 | import aung.thiha.operation.invoke 5 | import aung.thiha.photo.album.authentication.data.remote.response.TokenCheckResponse 6 | import aung.thiha.photo.album.authentication.data.remote.service.FakeAuthenticationDataSource 7 | import aung.thiha.photo.album.di.core.KoinTestExtension 8 | import aung.thiha.session.domain.FakeSessionStorage 9 | import aung.thiha.session.domain.model.Session 10 | import kotlinx.coroutines.test.runTest 11 | import org.junit.jupiter.api.Assertions.assertEquals 12 | import org.junit.jupiter.api.Assertions.assertTrue 13 | import org.junit.jupiter.api.Test 14 | import org.junit.jupiter.api.extension.ExtendWith 15 | import org.koin.test.KoinTest 16 | import org.koin.test.inject 17 | 18 | @ExtendWith(KoinTestExtension::class) 19 | class IsSignedInTest : KoinTest { 20 | 21 | private val isSignedIn: IsSignedIn by inject() 22 | private val sessionStorage: FakeSessionStorage by inject() 23 | private val authDataSource: FakeAuthenticationDataSource by inject() 24 | 25 | @Test 26 | fun `returns failure when session is null`() = runTest { 27 | sessionStorage.session = null 28 | 29 | val result = isSignedIn() 30 | 31 | assertTrue(result is Outcome.Failure) 32 | assertEquals("no token saved", (result as Outcome.Failure).e?.message) 33 | } 34 | 35 | @Test 36 | fun `returns success when session exists and token is valid`() = runTest { 37 | sessionStorage.session = Session("access", "refresh", "user") 38 | authDataSource.tokenCheckResponses += TokenCheckResponse(message = "all good") 39 | 40 | val result = isSignedIn() 41 | 42 | assertTrue(result is Outcome.Success) 43 | } 44 | 45 | @Test 46 | fun `returns failure when token check throws`() = runTest { 47 | sessionStorage.session = Session("access", "refresh", "user") 48 | authDataSource.tokenCheckResponses.clear() 49 | // Don't enqueue anything → simulates error 50 | 51 | val result = isSignedIn() 52 | 53 | assertTrue(result is Outcome.Failure) 54 | assertEquals("No token check response enqueued — possible unexpected extra call", 55 | (result as Outcome.Failure).e.message 56 | ) 57 | } 58 | } 59 | 60 | -------------------------------------------------------------------------------- /session/data/src/commonMain/kotlin/aung/thiha/session/data/SessionStorageImpl.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.session.data 2 | 3 | import androidx.datastore.core.DataStore 4 | import androidx.datastore.core.IOException 5 | import androidx.datastore.preferences.core.Preferences 6 | import androidx.datastore.preferences.core.edit 7 | import androidx.datastore.preferences.core.emptyPreferences 8 | import androidx.datastore.preferences.core.stringPreferencesKey 9 | import aung.thiha.session.domain.SessionStorage 10 | import aung.thiha.session.domain.model.Session 11 | import co.touchlab.kermit.Logger 12 | import kotlinx.coroutines.flow.catch 13 | import kotlinx.coroutines.flow.first 14 | 15 | class SessionStorageImpl( 16 | private val dataStore: DataStore, 17 | ) : SessionStorage { 18 | private object PreferencesKeys { 19 | val ACCESS_TOKEN = stringPreferencesKey("ACCESS_TOKEN") 20 | val USER_ID = stringPreferencesKey("USER_ID") 21 | val REFRESH_TOKEN = stringPreferencesKey("REFRESH_TOKEN") 22 | } 23 | 24 | override suspend fun getAuthenticationSession() : Session? { 25 | val preferences = dataStore.data 26 | .catch { exception -> 27 | if (exception is IOException) { 28 | emit(emptyPreferences()) 29 | } 30 | } 31 | .first() 32 | 33 | val accessToken = preferences[PreferencesKeys.ACCESS_TOKEN] 34 | val refreshToken = preferences[PreferencesKeys.REFRESH_TOKEN] 35 | val userId = preferences[PreferencesKeys.USER_ID] 36 | return if (accessToken != null && refreshToken != null && userId != null) { 37 | Session( 38 | accessToken = accessToken, 39 | refreshToken = refreshToken, 40 | userId = userId, 41 | ) 42 | } else { 43 | null 44 | } 45 | } 46 | 47 | override suspend fun setAuthenticationSession(session: Session?) { 48 | try { 49 | dataStore.edit { mutablePreferences -> 50 | session?.let { session -> 51 | mutablePreferences[PreferencesKeys.ACCESS_TOKEN] = session.accessToken 52 | mutablePreferences[PreferencesKeys.REFRESH_TOKEN] = session.refreshToken 53 | mutablePreferences[PreferencesKeys.USER_ID] = session.userId 54 | } ?: run { 55 | mutablePreferences.remove(PreferencesKeys.ACCESS_TOKEN) 56 | mutablePreferences.remove(PreferencesKeys.REFRESH_TOKEN) 57 | mutablePreferences.remove(PreferencesKeys.USER_ID) 58 | } 59 | } 60 | 61 | } catch (e: Exception) { 62 | e.printStackTrace() 63 | Logger.e(e.message ?: "no error message") 64 | } 65 | } 66 | } -------------------------------------------------------------------------------- /composeApp/src/jvmTest/kotlin/aung/thiha/photo/album/navigation/DefaultNavigationDispatcherTest.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.navigation 2 | 3 | import aung.thiha.photo.album.authentication.presentation.signup.navigation.SigninRoute 4 | import aung.thiha.photo.album.photos.presentation.navigation.PhotoListRoute 5 | import dev.mokkery.answering.returns 6 | import dev.mokkery.every 7 | import dev.mokkery.matcher.any 8 | import dev.mokkery.mock 9 | import dev.mokkery.verify 10 | import kotlinx.coroutines.Job 11 | import org.junit.jupiter.api.Test 12 | 13 | class DefaultNavigationDispatcherTest { 14 | 15 | private val navigationDispatcher = DefaultNavigationDispatcher() 16 | private val handler = mock() 17 | 18 | @Test 19 | fun `navigate should call handler with launchSingleTop = true`() { 20 | navigationDispatcher.setHandler(handler) 21 | every { handler.onNavigation(any(), any()) } returns Job() 22 | 23 | navigationDispatcher.navigate(PhotoListRoute, launchSingleTop = true) 24 | 25 | verify { 26 | handler.onNavigation( 27 | PhotoListRoute, 28 | NavigationOptions( 29 | launchSingleTop = true 30 | ) 31 | ) 32 | } 33 | } 34 | 35 | @Test 36 | fun `navigate should call handler with popUpToRoute = SigninRoute`() { 37 | navigationDispatcher.setHandler(handler) 38 | every { handler.onNavigation(any(), any()) } returns Job() 39 | 40 | navigationDispatcher.navigate(PhotoListRoute, popUpTo = SigninRoute) 41 | 42 | verify { 43 | handler.onNavigation( 44 | PhotoListRoute, 45 | NavigationOptions( 46 | backStackOptions = BackStackOptions.PopUpTo(SigninRoute) 47 | ) 48 | ) 49 | } 50 | } 51 | 52 | @Test 53 | fun `navigate should call handler with inclusive = true`() { 54 | navigationDispatcher.setHandler(handler) 55 | every { handler.onNavigation(any(), any()) } returns Job() 56 | 57 | navigationDispatcher.navigate(PhotoListRoute, popUpTo = SigninRoute, isInclusive = true) 58 | 59 | verify { 60 | handler.onNavigation( 61 | PhotoListRoute, 62 | NavigationOptions( 63 | backStackOptions = BackStackOptions.PopUpTo(SigninRoute, true) 64 | ) 65 | ) 66 | } 67 | } 68 | 69 | @Test 70 | fun `navigate should call handler with null popup destination`() { 71 | navigationDispatcher.setHandler(handler) 72 | every { handler.onNavigation(any(), any()) } returns Job() 73 | 74 | navigationDispatcher.navigate(PhotoListRoute, clearBackStack = true) 75 | 76 | verify { 77 | handler.onNavigation( 78 | PhotoListRoute, 79 | NavigationOptions( 80 | backStackOptions = BackStackOptions.Clear 81 | ) 82 | ) 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/aung/thiha/photo/album/navigation/NavigationDispatcher.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.navigation 2 | 3 | import aung.thiha.compose.navigation.Destination 4 | import kotlinx.coroutines.Deferred 5 | import kotlinx.coroutines.Job 6 | 7 | /** 8 | * The navigation abstraction layer is designed after Jetpack Navigation so engineers can pick it up quickly. 9 | * Then, why abstract at all? Mainly to make it easy to swap in another library if needed. 10 | * When needed to swap in another library, might require a bit of adapter logic 11 | * but the change will all be isolated inside the abstraction. 12 | * */ 13 | interface NavigationHandler { 14 | fun onNavigateUp(): Deferred 15 | fun onNavigation(destination: Destination, navigationOptions: NavigationOptions): Job 16 | } 17 | 18 | interface NavigationDispatcher { 19 | fun setHandler(handler: NavigationHandler) 20 | 21 | fun navigate( 22 | destination: Destination, 23 | launchSingleTop: Boolean = false, 24 | clearBackStack: Boolean = false 25 | ): Job 26 | 27 | fun navigate( 28 | destination: Destination, 29 | popUpTo: Destination, 30 | isInclusive: Boolean = false 31 | ): Job 32 | 33 | fun navigate( 34 | destination: Destination, 35 | launchSingleTop: Boolean, 36 | popUpTo: Destination, 37 | isInclusive: Boolean = false 38 | ): Job 39 | 40 | 41 | fun navigateUp(): Deferred 42 | } 43 | 44 | class DefaultNavigationDispatcher : NavigationDispatcher { 45 | 46 | private lateinit var handler: NavigationHandler 47 | 48 | override fun setHandler(handler: NavigationHandler) { 49 | this.handler = handler 50 | } 51 | 52 | override fun navigate( 53 | destination: Destination, 54 | launchSingleTop: Boolean, 55 | clearBackStack: Boolean, 56 | ): Job = handler.onNavigation( 57 | destination = destination, 58 | NavigationOptions( 59 | launchSingleTop = launchSingleTop, 60 | backStackOptions = if (clearBackStack) BackStackOptions.Clear else null 61 | ) 62 | ) 63 | 64 | override fun navigate( 65 | destination: Destination, 66 | popUpTo: Destination, 67 | isInclusive: Boolean 68 | ): Job = handler.onNavigation( 69 | destination = destination, 70 | NavigationOptions( 71 | launchSingleTop = false, 72 | backStackOptions = BackStackOptions.PopUpTo(popUpTo, isInclusive) 73 | ) 74 | ) 75 | 76 | override fun navigate( 77 | destination: Destination, 78 | launchSingleTop: Boolean, 79 | popUpTo: Destination, 80 | isInclusive: Boolean 81 | ): Job = handler.onNavigation( 82 | destination = destination, 83 | NavigationOptions( 84 | launchSingleTop = launchSingleTop, 85 | backStackOptions = BackStackOptions.PopUpTo(popUpTo, isInclusive) 86 | ) 87 | ) 88 | 89 | override fun navigateUp(): Deferred = handler.onNavigateUp() 90 | } 91 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 48 | echo. 49 | echo Please set the JAVA_HOME variable in your environment to match the 50 | echo location of your Java installation. 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 62 | echo. 63 | echo Please set the JAVA_HOME variable in your environment to match the 64 | echo location of your Java installation. 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /composeApp/src/jvmTest/kotlin/aung/thiha/photo/album/navigation/SpyNavigationHandler.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.navigation 2 | 3 | import aung.thiha.compose.navigation.Destination 4 | import kotlinx.coroutines.CompletableDeferred 5 | import kotlinx.coroutines.Job 6 | import kotlinx.coroutines.launch 7 | import kotlinx.coroutines.test.TestResult 8 | import kotlinx.coroutines.test.TestScope 9 | import kotlinx.coroutines.test.runTest 10 | import org.koin.core.component.get 11 | import org.koin.test.KoinTest 12 | import kotlin.coroutines.CoroutineContext 13 | import kotlin.coroutines.EmptyCoroutineContext 14 | import kotlin.test.assertEquals 15 | import kotlin.test.assertTrue 16 | import kotlin.time.Duration 17 | import kotlin.time.Duration.Companion.seconds 18 | 19 | class SpyNavigationHandler( 20 | private val coroutineScope: TestScope 21 | ) : NavigationHandler { 22 | 23 | val navigateCalls = mutableListOf>() 24 | var navigateUpCallCount = 0 25 | 26 | val navigateJobs = mutableListOf() 27 | 28 | override fun onNavigateUp(): CompletableDeferred { 29 | navigateUpCallCount++ 30 | return CompletableDeferred(true) 31 | } 32 | 33 | override fun onNavigation(destination: Destination, navigationOptions: NavigationOptions): Job { 34 | navigateCalls += destination to navigationOptions 35 | return coroutineScope.launch { 36 | 37 | }.also { 38 | navigateJobs += it 39 | } 40 | } 41 | } 42 | 43 | infix fun SpyNavigationHandler.shouldNavigateTo(destination: Destination) { 44 | assertTrue( 45 | navigateCalls.any { (d, _) -> d == destination }, 46 | "Expected navigation to $destination but got: ${navigateCalls.map { it.first }}" 47 | ) 48 | } 49 | 50 | infix fun SpyNavigationHandler.shouldNavigateTo(expected: DestinationWithOptions) { 51 | val match = navigateCalls.any { (actualDest, actualOpts) -> 52 | actualDest == expected.destination && 53 | actualOpts.launchSingleTop == expected.launchSingleTop && 54 | actualOpts.backStackOptions == expected.backStackOptions 55 | } 56 | 57 | assert(match) { 58 | "Expected navigation to ${expected.destination} with $expected, but got:\n" + 59 | navigateCalls.joinToString("\n") { (dest, opt) -> "$dest with $opt" } 60 | } 61 | } 62 | 63 | fun SpyNavigationHandler.shouldNavigateUp(times: Int = 1) { 64 | assertEquals(times, navigateUpCallCount, "Expected navigateUp() to be called $times times") 65 | } 66 | 67 | fun KoinTest.runNavTest( 68 | context: CoroutineContext = EmptyCoroutineContext, 69 | timeout: Duration = 60.seconds, 70 | testBody: suspend TestScope.(SpyNavigationHandler) -> Unit 71 | ): TestResult { 72 | val testScope = TestScope(context) 73 | val spyNavigationHandler = SpyNavigationHandler(testScope) 74 | get().setHandler(spyNavigationHandler) 75 | return testScope.runTest(timeout) { 76 | testBody(spyNavigationHandler) 77 | } 78 | } 79 | 80 | fun KoinTest.runNavTest( 81 | testScope: TestScope, 82 | timeout: Duration = 60.seconds, 83 | testBody: suspend TestScope.(SpyNavigationHandler) -> Unit 84 | ): TestResult { 85 | val spyNavigationHandler = SpyNavigationHandler(testScope) 86 | get().setHandler(spyNavigationHandler) 87 | return testScope.runTest(timeout) { 88 | testBody(spyNavigationHandler) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # KMP/CMP Template 2 | 3 | A template to jumpstart KMP/CMP projects with modularization, clean architecture, MVVM and UDF. 4 | 5 | This project uses: 6 | - Ktor for networking 7 | - Kotlin Coroutines for asynchronous tasks 8 | - Jetpack Navigation for screen transitions 9 | - Jetpack DataStore for local persistence 10 | - Firebase for app distributions 11 | - [JUnit5](https://junit.org/) and [Mokkery](https://mokkery.dev/) for testing 12 | 13 | ## UI Design 14 | This is not a full-fledged photo album. It’s just a simple photo viewer to help you jumpstart setting up your own project. 15 | 16 | ![UI Design](ui_design.png) 17 | 18 | ## Get the Precompiled App 19 | - [iOS](https://aungthiha.github.io/iOSAppAccessAutomation/pages/firebase-setup.html) - Automatically compiled and distributed after email registration (usually within 20 minutes) 20 | - Android - Please, manually compile from the source (for now) 21 | 22 | ## Set up the Project 23 | 24 | > 💡 If you prefer to try this project **without Firebase**, switch to the `remove-firebase` branch. Eliminating the need for this separate branch is on the roadmap. 25 | 26 | 1. Follow [the official guide](https://www.jetbrains.com/help/kotlin-multiplatform-dev/quickstart.html#set-up-the-environment) to set up the KMP development environment. 27 | 2. Clone the repo. 28 | 3. (Only for `main` branch) Setup Firebase for Android by following [the official guide](https://firebase.google.com/docs/android/setup). Place `google-services.json` in the `composeApp` directory. 29 | 4. (Only for `main` branch) Setup Firebase for iOS by following [the official guide](https://firebase.google.com/docs/ios/setup). The initialization code is already added—just place `GoogleService-Info.plist` into the `iosApp/iosApp/` directory. 30 | 5. Open the project in Android Studio. 31 | 6. If you get a `NoToolchainAvailableException`, install JDK 24. If you don't get this error, you can skip this step. 32 | 7. Swift packages should be automatically resolved by Android Studio. If not, please update your Android Studio. 33 | 8. There's a dropdown menu beside the Run button in Android Studio. That's where you choose the target platform. 34 | 9. Enjoy! 35 | 36 | ## Testing 37 | Refer to [the dedicated page](TESTING.md) for testing. 38 | 39 | ## Roadmap 40 | - Set up spacer sizes in terms of XXS, XS, S, M, L, XL and so on instead of hardcoded DPs to easily unify size 41 | - Figure out a way to resolve Firebase dependencies in CI/CD for iOS to eliminate the need for a separate branch. For more details on why this is necessary, please refer to [this Linkedin post](https://www.linkedin.com/feed/update/urn:li:activity:7342558671152828416/) 42 | - Write an Android Studio plugin to help developers easily generate fakes(test doubles) to use with integration tests 43 | - Write instrumentation tests to ensure things work as expected on real devices 44 | - Use paging3 in PhotoListScreen to support pagination 45 | - Figure out which snapshot test framework would be best suited for the project 46 | - Set up snapshot tests 47 | - Implement [Talaiot](https://github.com/cdsap/Talaiot) to analyze Gradle tasks 48 | - Implement AES-GCM encryption for session storage to enhance security 49 | - Implement remote Gradle cache for the iOS CI/CD pipeline 50 | - Use [Spotless](https://github.com/diffplug/spotless) to sort the keys in version catalog to make searching keys easier 51 | 52 | ## CI/CD 53 | - [iOS](https://github.com/AungThiha/iOSAppAccessAutomation) 54 | - Android (Coming Soon) 55 | 56 | ## Contributing 57 | PRs and feedback welcome! 58 | 59 | ## License 60 | Apache 2.0 61 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/aung/thiha/photo/album/App.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album 2 | 3 | import androidx.compose.foundation.layout.fillMaxSize 4 | import androidx.compose.material3.MaterialTheme 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Modifier 7 | import androidx.lifecycle.compose.LocalLifecycleOwner 8 | import androidx.lifecycle.lifecycleScope 9 | import androidx.navigation.NavHostController 10 | import androidx.navigation.compose.NavHost 11 | import androidx.navigation.compose.rememberNavController 12 | import aung.thiha.compose.navigation.Destination 13 | import aung.thiha.coroutines.AppDispatchers 14 | import aung.thiha.photo.album.authentication.presentation.signup.navigation.authentication 15 | import aung.thiha.photo.album.navigation.DefaultNavigationDispatcher 16 | import aung.thiha.photo.album.navigation.NavigationDispatcher 17 | import aung.thiha.photo.album.navigation.NavigationHandler 18 | import aung.thiha.photo.album.navigation.NavigationOptions 19 | import aung.thiha.photo.album.navigation.applyNavigationOptions 20 | import aung.thiha.photo.album.photos.presentation.navigation.photos 21 | import aung.thiha.photo.album.splash.SplashRoute 22 | import aung.thiha.photo.album.splash.splash 23 | import kotlinx.coroutines.Deferred 24 | import kotlinx.coroutines.async 25 | import kotlinx.coroutines.launch 26 | import org.jetbrains.compose.ui.tooling.preview.Preview 27 | import org.koin.compose.getKoin 28 | import org.koin.compose.koinInject 29 | import org.koin.compose.viewmodel.koinViewModel 30 | 31 | @Composable 32 | @Preview 33 | fun App() { 34 | MaterialTheme { 35 | val navController: NavHostController = rememberNavController() 36 | val lifecycleOwner = LocalLifecycleOwner.current 37 | val lifecycleScope = lifecycleOwner.lifecycleScope 38 | 39 | koinInject().setHandler(object : NavigationHandler { 40 | /** 41 | * [onNavigateUp] can be called from any thread but 42 | * [NavHostController.navigateUp] needs to be called from main thread 43 | * That's why it uses [lifecycleScope] to ensure the function is main-safe 44 | * This separates the concern from the caller, 45 | * meaning the caller can use it without worrying about main-safety 46 | * */ 47 | override fun onNavigateUp(): Deferred = lifecycleScope.async(AppDispatchers.main) { 48 | navController.navigateUp() 49 | } 50 | 51 | /** 52 | * [onNavigation] can be called from any thread but 53 | * [NavHostController.navigate] needs to be called from main thread 54 | * That's why it uses [lifecycleScope] to ensure the function is main-safe 55 | * This separates the concern from the caller, 56 | * meaning the caller can use it without worrying about main-safety 57 | * */ 58 | override fun onNavigation( 59 | destination: Destination, 60 | navigationOptions: NavigationOptions 61 | ) = lifecycleScope.launch(AppDispatchers.main) { 62 | navController.navigate(destination) { 63 | applyNavigationOptions(navigationOptions) 64 | } 65 | } 66 | }) 67 | NavHost( 68 | navController = navController, 69 | startDestination = SplashRoute, 70 | modifier = Modifier 71 | .fillMaxSize() 72 | ) { 73 | splash() 74 | authentication() 75 | photos() 76 | } 77 | } 78 | } -------------------------------------------------------------------------------- /authentication/presentation/src/commonMain/kotlin/aung/thiha/photo/album/authentication/presentation/signup/signin/SigninViewModel.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.authentication.presentation.signup.signin 2 | 3 | import androidx.lifecycle.SavedStateHandle 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.viewModelScope 6 | import aung.thiha.operation.Outcome 7 | import aung.thiha.operation.SuspendOperation 8 | import aung.thiha.photo.album.authentication.domain.model.SigninInput 9 | import aung.thiha.photo.album.authentication.presentation.signup.navigation.AuthenticationNavigator 10 | import aung.thiha.photo.album.authentication.domain.usecase.isEmailValid 11 | import io.github.aungthiha.snackbar.SnackbarStateFlowHandle 12 | import io.github.aungthiha.snackbar.SnackbarStateFlowOwner 13 | import kotlinx.coroutines.flow.MutableStateFlow 14 | import kotlinx.coroutines.flow.StateFlow 15 | import kotlinx.coroutines.launch 16 | import photoalbum.authentication.presentation.generated.resources.Res 17 | import photoalbum.authentication.presentation.generated.resources.authentication_failed 18 | import photoalbum.authentication.presentation.generated.resources.authentication_invalid_email 19 | 20 | private const val EMAIL = "EMAIL" 21 | private const val PASSWORD = "PASSWORD" 22 | 23 | class SigninViewModel( 24 | private val sigin: SuspendOperation, 25 | private val navigator: AuthenticationNavigator, 26 | private val savedStateHandle: SavedStateHandle = SavedStateHandle(), 27 | private val snackbarStateFlowHandle: SnackbarStateFlowHandle = SnackbarStateFlowHandle() 28 | ) : ViewModel(), SigninScreenListener, SnackbarStateFlowOwner by snackbarStateFlowHandle { 29 | 30 | // TODO when user moves to the second input, if the email is invalid, show error. Hide error when user comes back to the input field 31 | val email = savedStateHandle.getStateFlow(key = EMAIL, initialValue = "") 32 | // TODO when user moves to password input field, show minimum password length 33 | val password = savedStateHandle.getStateFlow(key = PASSWORD, initialValue = "") 34 | 35 | private val mutableOverlayLoading = MutableStateFlow(false) 36 | val overlayLoading: StateFlow = mutableOverlayLoading 37 | 38 | override fun onEmailChange(email: String) { 39 | savedStateHandle[EMAIL] = email 40 | } 41 | 42 | override fun onPasswordChange(password: String) { 43 | savedStateHandle[PASSWORD] = password 44 | } 45 | 46 | override fun onSignupClick() { 47 | navigator.navigateToSignup() 48 | } 49 | 50 | override fun onSigninClick() { 51 | // TODO prevent continuous click 52 | 53 | if (isEmailValid(email.value).not()) { 54 | snackbarStateFlowHandle.showSnackBar(Res.string.authentication_invalid_email) 55 | return 56 | } 57 | 58 | viewModelScope.launch { 59 | showOverlayLoading() 60 | val result = sigin(SigninInput(email = email.value, password = password.value)) 61 | when (result) { 62 | is Outcome.Failure -> { 63 | snackbarStateFlowHandle.showSnackBar(Res.string.authentication_failed) 64 | hideOverlayLoading() 65 | } 66 | 67 | is Outcome.Success -> { 68 | navigator.navigateToPhotoList() 69 | } 70 | } 71 | } 72 | } 73 | 74 | private fun showOverlayLoading() { 75 | mutableOverlayLoading.value = true 76 | } 77 | 78 | private fun hideOverlayLoading() { 79 | mutableOverlayLoading.value = false 80 | } 81 | 82 | } -------------------------------------------------------------------------------- /authentication/data/src/commonMain/kotlin/aung/thiha/photo/album/authentication/data/plugin/AuthPlugin.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.authentication.data.plugin 2 | 3 | import aung.thiha.operation.SuspendOperation 4 | import aung.thiha.operation.invoke 5 | import aung.thiha.photo.album.authentication.data.remote.response.AuthenticationResponse 6 | import aung.thiha.photo.album.authentication.data.remote.service.AuthenticationService 7 | import aung.thiha.session.domain.SessionStorage 8 | import aung.thiha.session.domain.model.Session 9 | import io.ktor.client.call.body 10 | import io.ktor.client.plugins.auth.Auth 11 | import io.ktor.client.plugins.auth.providers.BearerAuthConfig 12 | import io.ktor.client.plugins.auth.providers.BearerTokens 13 | import io.ktor.client.plugins.auth.providers.RefreshTokensParams 14 | import io.ktor.client.plugins.auth.providers.bearer 15 | import io.ktor.http.HttpStatusCode 16 | 17 | /** 18 | * The reason AuthPlugin is here instead of HttpClientFactory directly implementing this is to enable multiple squad ownership 19 | * one squad can own the composeApp module and another can own the authentication module 20 | * This allows two different squads to work in parallel 21 | * */ 22 | class AuthPlugin( 23 | private val sessionStorage: SessionStorage, 24 | /** 25 | * Even though refreshToken API is written in AuthenticationService, it's not in AuthenticationDataSource 26 | * That's why this class directly uses AuthenticationService 27 | * 28 | * The reason AuthenticationDataSource doesn't have refreshToken API is that it requires RefreshTokensParams, a third party dependency 29 | * 30 | * An interface shouldn't depend on third-party dependencies so that they can be easily mocked 31 | * Plus, if we need to switch to a different library, we can do it without affecting the downstream classes 32 | * That's possible only if the interface doesn't have any third-party dependencies 33 | * */ 34 | private val authenticationServiceProvider: Lazy, 35 | private val signoutProvider: Lazy>, 36 | ) { 37 | 38 | private val signout: SuspendOperation 39 | get() = signoutProvider.value 40 | 41 | private val authenticationService: AuthenticationService 42 | get() = authenticationServiceProvider.value 43 | 44 | fun plug(auth: Auth) { 45 | auth.bearer { 46 | loadTokensPlugin() 47 | refreshTokensPlugin() 48 | } 49 | } 50 | 51 | private fun BearerAuthConfig.loadTokensPlugin() { 52 | loadTokens { 53 | sessionStorage.getAuthenticationSession()?.let { session: Session -> 54 | BearerTokens(session.accessToken, session.refreshToken) 55 | } 56 | } 57 | } 58 | 59 | private fun BearerAuthConfig.refreshTokensPlugin() { 60 | refreshTokens { 61 | val currentSession = sessionStorage.getAuthenticationSession() 62 | if (currentSession != null) { 63 | refreshTokens(currentSession) 64 | } else { 65 | null 66 | } 67 | } 68 | } 69 | 70 | private suspend fun RefreshTokensParams.refreshTokens(currentSession: Session): BearerTokens? { 71 | val httpResponse = authenticationService.refreshTokens(this, currentSession.refreshToken) 72 | 73 | return try { 74 | 75 | if (httpResponse.status != HttpStatusCode.OK) { 76 | throw Exception() 77 | } 78 | 79 | with(httpResponse.body()) { 80 | storeAuthenticationSession() 81 | BearerTokens(accessToken, refreshToken) 82 | } 83 | } catch (e: Exception) { 84 | signout.invoke() 85 | null 86 | } 87 | } 88 | 89 | private suspend fun AuthenticationResponse.storeAuthenticationSession() { 90 | sessionStorage.setAuthenticationSession( 91 | Session( 92 | accessToken, 93 | refreshToken, 94 | userId 95 | ) 96 | ) 97 | } 98 | } -------------------------------------------------------------------------------- /authentication/presentation/src/commonMain/kotlin/aung/thiha/photo/album/authentication/presentation/signup/signup/SignupViewModel.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.authentication.presentation.signup.signup 2 | 3 | import androidx.lifecycle.SavedStateHandle 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.viewModelScope 6 | import aung.thiha.operation.Outcome 7 | import aung.thiha.operation.SuspendOperation 8 | import aung.thiha.photo.album.authentication.domain.model.SignupInput 9 | import aung.thiha.photo.album.authentication.presentation.signup.navigation.AuthenticationNavigator 10 | import aung.thiha.photo.album.authentication.domain.usecase.isEmailValid 11 | import io.github.aungthiha.snackbar.SnackbarStateFlowHandle 12 | import io.github.aungthiha.snackbar.SnackbarStateFlowOwner 13 | import kotlinx.coroutines.flow.MutableStateFlow 14 | import kotlinx.coroutines.flow.StateFlow 15 | import kotlinx.coroutines.launch 16 | import photoalbum.authentication.presentation.generated.resources.Res 17 | import photoalbum.authentication.presentation.generated.resources.authentication_failed 18 | import photoalbum.authentication.presentation.generated.resources.authentication_invalid_email 19 | import photoalbum.authentication.presentation.generated.resources.authentication_passwords_do_not_match 20 | 21 | private const val EMAIL = "EMAIL" 22 | private const val PASSWORD = "PASSWORD" 23 | private const val CONFIRM_PASSWORD = "CONFIRM_PASSWORD" 24 | 25 | class SignupViewModel( 26 | private val sigup: SuspendOperation, 27 | private val navigator: AuthenticationNavigator, 28 | private val savedStateHandle: SavedStateHandle = SavedStateHandle(), 29 | private val snackbarStateFlowHandle: SnackbarStateFlowHandle = SnackbarStateFlowHandle() 30 | ) : ViewModel(), SignupScreenListener, SnackbarStateFlowOwner by snackbarStateFlowHandle { 31 | 32 | // TODO when user moves to the second input, if the email is invalid, show error. Hide error when user comes back to the input field 33 | val email = savedStateHandle.getStateFlow(key = EMAIL, initialValue = "") 34 | val password = savedStateHandle.getStateFlow(key = PASSWORD, initialValue = "") 35 | // TODO when user stops typing for a while and if the password and confirm password don't match, show an error. Once they start typing, remove the error 36 | val confirmPassword = savedStateHandle.getStateFlow(key = CONFIRM_PASSWORD, initialValue = "") 37 | 38 | private val mutableOverlayLoading = MutableStateFlow(false) 39 | val overlayLoading: StateFlow = mutableOverlayLoading 40 | 41 | override fun onUpClick() { 42 | navigator.navigateUpFromSignup() 43 | } 44 | 45 | override fun onEmailChange(email: String) { 46 | savedStateHandle[EMAIL] = email 47 | } 48 | 49 | override fun onPasswordChange(password: String) { 50 | savedStateHandle[PASSWORD] = password 51 | } 52 | 53 | override fun onConfirmPasswordChange(confirmPassword: String) { 54 | savedStateHandle[CONFIRM_PASSWORD] = confirmPassword 55 | } 56 | 57 | override fun onSignupClick() { 58 | 59 | // TODO prevent continuous click 60 | 61 | if (isEmailValid(email.value).not()) { 62 | snackbarStateFlowHandle.showSnackBar(Res.string.authentication_invalid_email) 63 | return 64 | } 65 | 66 | if (password.value != confirmPassword.value) { 67 | snackbarStateFlowHandle.showSnackBar(Res.string.authentication_passwords_do_not_match) 68 | return 69 | } 70 | 71 | viewModelScope.launch { 72 | showOverlayLoading() 73 | 74 | val result = sigup(SignupInput(email = email.value, password = password.value)) 75 | when (result) { 76 | is Outcome.Failure -> { 77 | snackbarStateFlowHandle.showSnackBar(Res.string.authentication_failed) 78 | hideOverlayLoading() 79 | } 80 | is Outcome.Success -> { 81 | navigator.navigateToPhotoList() 82 | } 83 | } 84 | } 85 | } 86 | 87 | private fun showOverlayLoading() { 88 | mutableOverlayLoading.value = true 89 | } 90 | 91 | private fun hideOverlayLoading() { 92 | mutableOverlayLoading.value = false 93 | } 94 | } -------------------------------------------------------------------------------- /iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "c63c63846d9c539229e96de38d6af51417e28c0ee9a0bc48bd0f0f19d923c329", 3 | "pins" : [ 4 | { 5 | "identity" : "abseil-cpp-binary", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/google/abseil-cpp-binary.git", 8 | "state" : { 9 | "revision" : "194a6706acbd25e4ef639bcaddea16e8758a3e27", 10 | "version" : "1.2024011602.0" 11 | } 12 | }, 13 | { 14 | "identity" : "app-check", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/google/app-check.git", 17 | "state" : { 18 | "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", 19 | "version" : "11.2.0" 20 | } 21 | }, 22 | { 23 | "identity" : "firebase-ios-sdk", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/firebase/firebase-ios-sdk", 26 | "state" : { 27 | "revision" : "dbdfdc44bee8b8e4eaa5ec27eb12b9338f3f2bc1", 28 | "version" : "11.5.0" 29 | } 30 | }, 31 | { 32 | "identity" : "googleappmeasurement", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/google/GoogleAppMeasurement.git", 35 | "state" : { 36 | "revision" : "4f234bcbdae841d7015258fbbf8e7743a39b8200", 37 | "version" : "11.4.0" 38 | } 39 | }, 40 | { 41 | "identity" : "googledatatransport", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/google/GoogleDataTransport.git", 44 | "state" : { 45 | "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", 46 | "version" : "10.1.0" 47 | } 48 | }, 49 | { 50 | "identity" : "googleutilities", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/google/GoogleUtilities.git", 53 | "state" : { 54 | "revision" : "53156c7ec267db846e6b64c9f4c4e31ba4cf75eb", 55 | "version" : "8.0.2" 56 | } 57 | }, 58 | { 59 | "identity" : "grpc-binary", 60 | "kind" : "remoteSourceControl", 61 | "location" : "https://github.com/google/grpc-binary.git", 62 | "state" : { 63 | "revision" : "f56d8fc3162de9a498377c7b6cea43431f4f5083", 64 | "version" : "1.65.1" 65 | } 66 | }, 67 | { 68 | "identity" : "gtm-session-fetcher", 69 | "kind" : "remoteSourceControl", 70 | "location" : "https://github.com/google/gtm-session-fetcher.git", 71 | "state" : { 72 | "revision" : "5cfe5f090c982de9c58605d2a82a4fc77b774fbd", 73 | "version" : "4.1.0" 74 | } 75 | }, 76 | { 77 | "identity" : "interop-ios-for-google-sdks", 78 | "kind" : "remoteSourceControl", 79 | "location" : "https://github.com/google/interop-ios-for-google-sdks.git", 80 | "state" : { 81 | "revision" : "2d12673670417654f08f5f90fdd62926dc3a2648", 82 | "version" : "100.0.0" 83 | } 84 | }, 85 | { 86 | "identity" : "leveldb", 87 | "kind" : "remoteSourceControl", 88 | "location" : "https://github.com/firebase/leveldb.git", 89 | "state" : { 90 | "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", 91 | "version" : "1.22.5" 92 | } 93 | }, 94 | { 95 | "identity" : "nanopb", 96 | "kind" : "remoteSourceControl", 97 | "location" : "https://github.com/firebase/nanopb.git", 98 | "state" : { 99 | "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", 100 | "version" : "2.30910.0" 101 | } 102 | }, 103 | { 104 | "identity" : "promises", 105 | "kind" : "remoteSourceControl", 106 | "location" : "https://github.com/google/promises.git", 107 | "state" : { 108 | "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", 109 | "version" : "2.4.0" 110 | } 111 | }, 112 | { 113 | "identity" : "swift-protobuf", 114 | "kind" : "remoteSourceControl", 115 | "location" : "https://github.com/apple/swift-protobuf.git", 116 | "state" : { 117 | "revision" : "ebc7251dd5b37f627c93698e4374084d98409633", 118 | "version" : "1.28.2" 119 | } 120 | } 121 | ], 122 | "version" : 3 123 | } 124 | -------------------------------------------------------------------------------- /composeApp/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlinMultiplatform) 3 | alias(libs.plugins.androidApplication) 4 | alias(libs.plugins.jetbrainsCompose) 5 | alias(libs.plugins.compose.compiler) 6 | alias(libs.plugins.kotlinxSerialization) 7 | alias(libs.plugins.googleServices) 8 | alias(libs.plugins.dev.mokkery) 9 | } 10 | 11 | kotlin { 12 | jvmToolchain(libs.versions.jvm.toolchain.get().toInt()) 13 | 14 | jvm() 15 | androidTarget() 16 | 17 | listOf( 18 | iosX64(), 19 | iosArm64(), 20 | iosSimulatorArm64() 21 | ).forEach { iosTarget -> 22 | iosTarget.binaries.framework { 23 | baseName = "ComposeApp" 24 | isStatic = true 25 | } 26 | } 27 | 28 | sourceSets { 29 | 30 | androidMain.dependencies { 31 | implementation(compose.preview) 32 | implementation(libs.androidx.activity.compose) 33 | implementation(libs.androidx.activity.ktx) 34 | implementation(libs.koin.android) 35 | implementation(libs.ktor.client.android) 36 | implementation(project.dependencies.platform(libs.firebase)) 37 | } 38 | iosMain.dependencies { 39 | implementation(libs.ktor.client.darwin) 40 | } 41 | commonMain.dependencies { 42 | implementation(projects.compose) 43 | implementation(projects.operation) 44 | implementation(libs.aungthiha.snackbarStateflowHandle) 45 | implementation(projects.storage) 46 | 47 | implementation(projects.coroutines) 48 | 49 | implementation(projects.authentication.data) 50 | implementation(projects.authentication.domain) 51 | implementation(projects.authentication.presentation) 52 | 53 | implementation(projects.photos.data) 54 | implementation(projects.photos.domain) 55 | implementation(projects.photos.presentation) 56 | 57 | implementation(projects.session.data) 58 | implementation(projects.session.domain) 59 | 60 | implementation(compose.runtime) 61 | implementation(compose.foundation) 62 | implementation(compose.material3) 63 | implementation(compose.ui) 64 | implementation(compose.components.resources) 65 | implementation(compose.components.uiToolingPreview) 66 | 67 | implementation(libs.androidx.lifecycle.viewmodel) 68 | implementation(libs.androidx.lifecycle.runtime.compose) 69 | implementation(libs.androidx.navigation) 70 | implementation(libs.androidx.datastore) 71 | 72 | implementation(libs.kermit) 73 | 74 | implementation(libs.koin.core) 75 | implementation(libs.koin.compose) 76 | implementation(libs.koin.composeViewModel) 77 | 78 | implementation(libs.kotlinx.coroutines.core) 79 | 80 | implementation(libs.ktor.client.core) 81 | implementation(libs.ktor.client.content.negotiation) 82 | implementation(libs.ktor.client.auth) 83 | implementation(libs.ktor.serialization.kotlinx.json) 84 | implementation(libs.ktor.logging) 85 | } 86 | jvmTest.dependencies { 87 | implementation(libs.kotlin.test) 88 | implementation(libs.kotlinx.coroutines.test) 89 | implementation(libs.koin.test) 90 | } 91 | } 92 | } 93 | 94 | android { 95 | namespace = "aung.thiha.photo.album" 96 | compileSdk = libs.versions.android.compileSdk.get().toInt() 97 | 98 | sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") 99 | sourceSets["main"].res.srcDirs("src/androidMain/res") 100 | sourceSets["main"].resources.srcDirs("src/commonMain/resources") 101 | 102 | defaultConfig { 103 | applicationId = "aung.thiha.photo.album" 104 | minSdk = libs.versions.android.minSdk.get().toInt() 105 | targetSdk = libs.versions.android.targetSdk.get().toInt() 106 | versionCode = 1 107 | versionName = "1.0" 108 | } 109 | packaging { 110 | resources { 111 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 112 | } 113 | } 114 | buildTypes { 115 | getByName("release") { 116 | isMinifyEnabled = false 117 | } 118 | } 119 | buildFeatures { 120 | compose = true 121 | } 122 | dependencies { 123 | debugImplementation(compose.uiTooling) 124 | } 125 | } 126 | 127 | -------------------------------------------------------------------------------- /composeApp/src/jvmTest/kotlin/aung/thiha/coroutines/TestDispatcherExtension.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.coroutines 2 | 3 | import kotlinx.coroutines.Dispatchers 4 | import kotlinx.coroutines.ExperimentalCoroutinesApi 5 | import kotlinx.coroutines.test.TestCoroutineScheduler 6 | import kotlinx.coroutines.test.TestDispatcher 7 | import kotlinx.coroutines.test.UnconfinedTestDispatcher 8 | import kotlinx.coroutines.test.resetMain 9 | import kotlinx.coroutines.test.setMain 10 | import org.junit.jupiter.api.extension.AfterEachCallback 11 | import org.junit.jupiter.api.extension.BeforeEachCallback 12 | import org.junit.jupiter.api.extension.ExtensionContext 13 | 14 | /** 15 | * This class offers two mutually exclusive ways to control dispatcher behavior: 16 | * 17 | * 1. Use the primary constructor to manually provide one or all of [main], [io] and [default] dispatchers. 18 | * 19 | * 2. Use the secondary constructor with a shared [TestCoroutineScheduler] 20 | * -> Automatically sets [main], [io] and [default] dispatchers using that scheduler. 21 | * 22 | * The intent is to prevent developers from mixing both approaches. 23 | * For example, passing a shared [TestCoroutineScheduler] while also overriding individual dispatchers \ 24 | * could lead to inconsistent virtual time due to different schedulers being used. 25 | * 26 | * This restriction is **enforced** at the API level by not exposing a constructor that takes both. 27 | * 28 | * However, the primary constructor is still public, 29 | * so developers can manually pass in dispatchers that use **different** [TestCoroutineScheduler] instances. 30 | * To guard against this, [beforeEach] performs a **runtime validation**, 31 | * which throws an [IllegalStateException] if dispatchers use different schedulers. 32 | * 33 | * For more information why all TestDispatchers must share the same TestCoroutineScheduler, see: 34 | * https://developer.android.com/kotlin/coroutines/test 35 | */ 36 | @OptIn(ExperimentalCoroutinesApi::class) 37 | class TestDispatcherExtension( 38 | private val main: TestDispatcher = UnconfinedTestDispatcher(), 39 | private val io: TestDispatcher = main, 40 | private val default: TestDispatcher = main, 41 | ) : BeforeEachCallback, AfterEachCallback { 42 | 43 | constructor( 44 | testCoroutineScheduler: TestCoroutineScheduler 45 | ) : this( 46 | main = UnconfinedTestDispatcher(testCoroutineScheduler), 47 | io = UnconfinedTestDispatcher(testCoroutineScheduler), 48 | default = UnconfinedTestDispatcher(testCoroutineScheduler) 49 | ) 50 | 51 | override fun beforeEach(context: ExtensionContext) { 52 | 53 | val schedulers = listOf(main, io, default) 54 | .map { it.scheduler } 55 | .distinct() 56 | 57 | if (schedulers.size > 1) { 58 | throw IllegalStateException( 59 | "All TestDispatchers must share the same TestCoroutineScheduler. " + 60 | "Otherwise, virtual time won't be in sync.\n" + 61 | "Refer to Google’s guide on TestCoroutineScheduler and virtual time:\n" + 62 | "https://developer.android.com/kotlin/coroutines/test" 63 | ) 64 | } 65 | 66 | TestDispatcherHolder.testMain = main 67 | TestDispatcherHolder.testIo = io 68 | TestDispatcherHolder.testDefault = default 69 | 70 | /** 71 | * Quotes from Google: 72 | * 73 | * In local unit tests, the Main dispatcher that wraps the Android UI thread will be unavailable, 74 | * as these tests are executed on a local JVM and not an Android device 75 | * 76 | * some APIs such as viewModelScope use a hardcoded Main dispatcher under the hood. 77 | * 78 | * To replace the Main dispatcher with a TestDispatcher in all cases, 79 | * use the Dispatchers.setMain and Dispatchers.resetMain functions 80 | * 81 | * If the Main dispatcher has been replaced with a TestDispatcher, 82 | * any newly-created TestDispatchers will automatically use the scheduler from the Main dispatcher, 83 | * including the StandardTestDispatcher created by runTest if no other dispatcher is passed to it. 84 | * 85 | * Reference: https://developer.android.com/kotlin/coroutines/test#setting-main-dispatcher 86 | * ================================================================= 87 | * runNavTest from this project is written with this design in mind. 88 | * StandardTestDispatcher created by runNavTest will also automatically use the scheduler \ 89 | * from the Main dispatcher 90 | * */ 91 | Dispatchers.setMain(main) 92 | } 93 | 94 | override fun afterEach(context: ExtensionContext) { 95 | Dispatchers.resetMain() 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | agp = "8.12.0" 3 | jvm-toolchain = "24" 4 | android-compileSdk = "36" 5 | android-minSdk = "24" 6 | android-targetSdk = "35" 7 | androidx-activity = "1.10.1" 8 | androidx-appcompat = "1.7.0" 9 | androidx-constraintlayout = "2.2.1" 10 | androidx-core-ktx = "1.16.0" 11 | androidx-datastore = "1.1.7" 12 | androidx-espresso-core = "3.6.1" 13 | androidx-lifecycle = "2.9.0" 14 | androidx-material = "1.12.0" 15 | androidx-compose-material = "1.8.2" 16 | androidx-navigation = "2.9.0-beta02" 17 | androidx-test-junit = "1.2.1" 18 | coil = "3.0.2" 19 | compose-plugin = "1.8.1" 20 | composeShimmer = "1.3.1" 21 | coroutines = "1.10.2" 22 | firebase = "33.14.0" 23 | junit = "4.13.2" 24 | kermit = "1.0.0" 25 | kotlin = "2.2.10" 26 | koin = "4.1.0-RC1" 27 | ktor = "2.3.12" 28 | google-services = "4.4.2" 29 | jetbrainsKotlinJvm = "2.0.21" 30 | snackbarStateFlowHandle = "1.0.0" 31 | 32 | [libraries] 33 | androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "androidx-activity" } 34 | aungthiha-snackbarStateflowHandle = { module = "io.github.aungthiha:snackbar-stateflow-handle", version.ref = "snackbarStateFlowHandle" } 35 | compose-shimmer = { module = "com.valentinilk.shimmer:compose-shimmer", version.ref = "composeShimmer" } 36 | kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } 37 | kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } 38 | junit = { group = "junit", name = "junit", version.ref = "junit" } 39 | androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" } 40 | androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-junit" } 41 | androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidx-espresso-core" } 42 | androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" } 43 | android-compose-material = { module = "androidx.compose.material:material", version.ref = "androidx-compose-material" } 44 | androidx-material = { group = "com.google.android.material", name = "material", version.ref = "androidx-material" } 45 | androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "androidx-constraintlayout" } 46 | androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } 47 | androidx-datastore = { module = "androidx.datastore:datastore-preferences-core", version.ref = "androidx-datastore" } 48 | androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" } 49 | androidx-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } 50 | androidx-navigation = { module="org.jetbrains.androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } 51 | coil-compose = { module="io.coil-kt.coil3:coil-compose", version.ref = "coil" } 52 | coil-network = { module="io.coil-kt.coil3:coil-network-ktor2", version.ref="coil" } 53 | kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } 54 | koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } 55 | koin-compose = { module="io.insert-koin:koin-compose", version.ref = "koin"} 56 | koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } 57 | koin-composeViewModel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" } 58 | koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" } 59 | koin-test-junit5 = { module = "io.insert-koin:koin-test-junit5", version.ref = "koin" } 60 | kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } 61 | ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } 62 | ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" } 63 | ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } 64 | ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } 65 | ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } 66 | ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } 67 | ktor-logging = { module="io.ktor:ktor-client-logging", version.ref = "ktor" } 68 | kermit = { module="co.touchlab:kermit", version.ref = "kermit" } 69 | firebase = { module = "com.google.firebase:firebase-bom", version.ref = "firebase" } 70 | 71 | [plugins] 72 | androidApplication = { id = "com.android.application", version.ref = "agp" } 73 | androidLibrary = { id = "com.android.library", version.ref = "agp" } 74 | androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref ="agp" } 75 | javaLibrary = { id = "java-library"} 76 | jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "compose-plugin" } 77 | compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } 78 | kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } 79 | kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } 80 | googleServices = { id = "com.google.gms.google-services", version.ref = "google-services"} 81 | jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "jetbrainsKotlinJvm" } 82 | dev-mokkery = { id = "dev.mokkery", version = "2.9.0" } 83 | -------------------------------------------------------------------------------- /authentication/presentation/src/commonMain/kotlin/aung/thiha/photo/album/authentication/presentation/signup/signin/SigninScreen.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.authentication.presentation.signup.signin 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Spacer 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.layout.height 9 | import androidx.compose.foundation.layout.imePadding 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.text.KeyboardOptions 12 | import androidx.compose.material3.Button 13 | import androidx.compose.material3.FilledTonalButton 14 | import androidx.compose.material3.OutlinedTextField 15 | import androidx.compose.material3.Scaffold 16 | import androidx.compose.material3.SnackbarHost 17 | import androidx.compose.material3.SnackbarHostState 18 | import androidx.compose.material3.Text 19 | import androidx.compose.runtime.Composable 20 | import androidx.compose.runtime.getValue 21 | import androidx.compose.runtime.remember 22 | import androidx.compose.ui.Alignment 23 | import androidx.compose.ui.Modifier 24 | import androidx.compose.ui.platform.LocalSoftwareKeyboardController 25 | import androidx.compose.ui.text.input.KeyboardType 26 | import androidx.compose.ui.text.input.PasswordVisualTransformation 27 | import androidx.compose.ui.unit.dp 28 | import androidx.compose.ui.unit.sp 29 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 30 | import aung.thiha.compose.LoadingOverlay 31 | import io.github.aungthiha.snackbar.observeWithLifecycle 32 | import io.github.aungthiha.snackbar.showSnackbar 33 | import org.koin.compose.viewmodel.koinViewModel 34 | 35 | @Composable 36 | internal fun SinginContainer() { 37 | val viewModel = koinViewModel() 38 | 39 | val email by viewModel.email.collectAsStateWithLifecycle() 40 | val password by viewModel.password.collectAsStateWithLifecycle() 41 | val overlayLoading by viewModel.overlayLoading.collectAsStateWithLifecycle() 42 | 43 | val snackbarHostState = remember { SnackbarHostState() } 44 | 45 | viewModel.snackbarStateFlow.observeWithLifecycle { 46 | snackbarHostState.showSnackbar(it) 47 | } 48 | 49 | SigninScreen( 50 | snackbarHostState = snackbarHostState, 51 | email = email, 52 | password = password, 53 | overlayLoading = overlayLoading, 54 | listener = viewModel 55 | ) 56 | } 57 | 58 | @Composable 59 | fun SigninScreen( 60 | snackbarHostState: SnackbarHostState, 61 | email: String, 62 | password: String, 63 | overlayLoading: Boolean, 64 | listener: SigninScreenListener 65 | ) { 66 | 67 | val keyboard = LocalSoftwareKeyboardController.current 68 | 69 | Scaffold( 70 | snackbarHost = { 71 | SnackbarHost(hostState = snackbarHostState) 72 | }, 73 | ) { contentPadding -> 74 | 75 | Column( 76 | modifier = Modifier 77 | .fillMaxSize() 78 | .padding(contentPadding) 79 | .padding(16.dp) 80 | .imePadding(), 81 | horizontalAlignment = Alignment.CenterHorizontally, 82 | verticalArrangement = Arrangement.Center 83 | ) { 84 | Text( 85 | text = "Welcome to My Photo", 86 | fontSize = 24.sp, 87 | modifier = Modifier.padding(bottom = 32.dp) 88 | ) 89 | 90 | OutlinedTextField( 91 | value = email, 92 | onValueChange = listener::onEmailChange, 93 | label = { Text("email") }, 94 | placeholder = { Text("example@example.com") }, 95 | keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), 96 | modifier = Modifier.fillMaxWidth(), 97 | singleLine = true 98 | ) 99 | 100 | Spacer(modifier = Modifier.height(16.dp)) 101 | 102 | OutlinedTextField( 103 | value = password, 104 | onValueChange = listener::onPasswordChange, 105 | label = { Text("password") }, 106 | placeholder = { Text("your password") }, 107 | modifier = Modifier.fillMaxWidth(), 108 | visualTransformation = PasswordVisualTransformation(), 109 | keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), 110 | singleLine = true 111 | ) 112 | 113 | Spacer(modifier = Modifier.height(24.dp)) 114 | 115 | Button( 116 | onClick = { 117 | keyboard?.hide() 118 | listener.onSigninClick() 119 | }, 120 | modifier = Modifier 121 | .fillMaxWidth() 122 | .height(50.dp) 123 | ) { 124 | Text(text = "Sign in") 125 | } 126 | 127 | Spacer(modifier = Modifier.height(16.dp)) 128 | 129 | FilledTonalButton( 130 | onClick = { 131 | listener.onSignupClick() 132 | }, 133 | modifier = Modifier 134 | .fillMaxWidth() 135 | .height(50.dp) 136 | ) { 137 | Text(text = "Sign up") 138 | } 139 | 140 | Spacer(modifier = Modifier.height(16.dp)) 141 | 142 | } 143 | 144 | if (overlayLoading) { 145 | LoadingOverlay() 146 | } 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /authentication/presentation/src/commonMain/kotlin/aung/thiha/photo/album/authentication/presentation/signup/signup/SignupScreen.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.authentication.presentation.signup.signup 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Spacer 6 | import androidx.compose.foundation.layout.WindowInsets 7 | import androidx.compose.foundation.layout.fillMaxSize 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.height 10 | import androidx.compose.foundation.layout.imePadding 11 | import androidx.compose.foundation.layout.padding 12 | import androidx.compose.foundation.layout.systemBars 13 | import androidx.compose.foundation.layout.windowInsetsPadding 14 | import androidx.compose.foundation.text.KeyboardOptions 15 | import androidx.compose.material3.Button 16 | import androidx.compose.material3.OutlinedTextField 17 | import androidx.compose.material3.Scaffold 18 | import androidx.compose.material3.SnackbarHost 19 | import androidx.compose.material3.SnackbarHostState 20 | import androidx.compose.material3.Text 21 | import androidx.compose.runtime.Composable 22 | import androidx.compose.runtime.getValue 23 | import androidx.compose.runtime.remember 24 | import androidx.compose.ui.Alignment 25 | import androidx.compose.ui.Modifier 26 | import androidx.compose.ui.platform.LocalSoftwareKeyboardController 27 | import androidx.compose.ui.text.input.KeyboardType 28 | import androidx.compose.ui.text.input.PasswordVisualTransformation 29 | import androidx.compose.ui.unit.dp 30 | import androidx.compose.ui.unit.sp 31 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 32 | import aung.thiha.compose.AlbumTopAppBar 33 | import aung.thiha.compose.LoadingOverlay 34 | import io.github.aungthiha.snackbar.observeWithLifecycle 35 | import io.github.aungthiha.snackbar.showSnackbar 36 | import org.koin.compose.viewmodel.koinViewModel 37 | 38 | @Composable 39 | internal fun SignupContainer() { 40 | val viewModel = koinViewModel() 41 | 42 | val email by viewModel.email.collectAsStateWithLifecycle() 43 | val password by viewModel.password.collectAsStateWithLifecycle() 44 | val confirmPassword by viewModel.confirmPassword.collectAsStateWithLifecycle() 45 | val overlayLoading by viewModel.overlayLoading.collectAsStateWithLifecycle() 46 | 47 | val snackbarHostState : SnackbarHostState = remember { SnackbarHostState() } 48 | viewModel.snackbarStateFlow.observeWithLifecycle { 49 | snackbarHostState.showSnackbar(it) 50 | } 51 | 52 | SignupScreen( 53 | snackbarHostState = snackbarHostState, 54 | email = email, 55 | password = password, 56 | confirmPassword = confirmPassword, 57 | overlayLoading = overlayLoading, 58 | listener = viewModel 59 | ) 60 | } 61 | 62 | @Composable 63 | internal fun SignupScreen( 64 | snackbarHostState: SnackbarHostState, 65 | email: String, 66 | password: String, 67 | confirmPassword: String, 68 | overlayLoading: Boolean, 69 | listener: SignupScreenListener, 70 | ) { 71 | 72 | val keyboard = LocalSoftwareKeyboardController.current 73 | 74 | Scaffold( 75 | topBar = { 76 | AlbumTopAppBar { 77 | listener.onUpClick() 78 | } 79 | }, 80 | snackbarHost = { 81 | SnackbarHost(hostState = snackbarHostState) 82 | }, 83 | modifier = Modifier.windowInsetsPadding(WindowInsets.systemBars), 84 | ) { contentPadding -> 85 | 86 | Column( 87 | modifier = Modifier 88 | .fillMaxSize() 89 | .padding(contentPadding) 90 | .padding(16.dp) 91 | .imePadding(), 92 | horizontalAlignment = Alignment.CenterHorizontally, 93 | verticalArrangement = Arrangement.Center 94 | ) { 95 | Text( 96 | text = "Welcome to My Photo", 97 | fontSize = 24.sp, 98 | modifier = Modifier.padding(bottom = 32.dp) 99 | ) 100 | 101 | OutlinedTextField( 102 | value = email, 103 | onValueChange = listener::onEmailChange, 104 | label = { Text("email") }, 105 | placeholder = { Text("example@example.com") }, 106 | modifier = Modifier.fillMaxWidth(), 107 | keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), 108 | singleLine = true 109 | ) 110 | 111 | Spacer(modifier = Modifier.height(16.dp)) 112 | 113 | OutlinedTextField( 114 | value = password, 115 | onValueChange = listener::onPasswordChange, 116 | label = { Text("password") }, 117 | placeholder = { Text("your password") }, 118 | modifier = Modifier.fillMaxWidth(), 119 | visualTransformation = PasswordVisualTransformation(), 120 | keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), 121 | singleLine = true 122 | ) 123 | 124 | Spacer(modifier = Modifier.height(16.dp)) 125 | 126 | OutlinedTextField( 127 | value = confirmPassword, 128 | onValueChange = listener::onConfirmPasswordChange, 129 | label = { Text("confirm password") }, 130 | placeholder = { Text("confirm your password") }, 131 | modifier = Modifier.fillMaxWidth(), 132 | visualTransformation = PasswordVisualTransformation(), 133 | keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), 134 | singleLine = true 135 | ) 136 | 137 | Spacer(modifier = Modifier.height(24.dp)) 138 | 139 | Button( 140 | onClick = { 141 | keyboard?.hide() 142 | listener.onSignupClick() 143 | }, 144 | modifier = Modifier 145 | .fillMaxWidth() 146 | .height(50.dp) 147 | ) { 148 | Text(text = "Sign up") 149 | } 150 | 151 | Spacer(modifier = Modifier.height(16.dp)) 152 | 153 | } 154 | } 155 | if (overlayLoading) { 156 | LoadingOverlay() 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /photos/presentation/src/commonMain/kotlin/aung/thiha/photo/album/photos/presentation/overview/PhotoListScreen.kt: -------------------------------------------------------------------------------- 1 | package aung.thiha.photo.album.photos.presentation.overview 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.clickable 6 | import androidx.compose.foundation.layout.Arrangement 7 | import androidx.compose.foundation.layout.Box 8 | import androidx.compose.foundation.layout.Column 9 | import androidx.compose.foundation.layout.PaddingValues 10 | import androidx.compose.foundation.layout.Spacer 11 | import androidx.compose.foundation.layout.aspectRatio 12 | import androidx.compose.foundation.layout.fillMaxSize 13 | import androidx.compose.foundation.layout.fillMaxWidth 14 | import androidx.compose.foundation.layout.height 15 | import androidx.compose.foundation.layout.imePadding 16 | import androidx.compose.foundation.layout.padding 17 | import androidx.compose.foundation.lazy.grid.GridCells 18 | import androidx.compose.foundation.lazy.grid.LazyVerticalGrid 19 | import androidx.compose.foundation.shape.RoundedCornerShape 20 | import androidx.compose.material3.Button 21 | import androidx.compose.material3.CircularProgressIndicator 22 | import androidx.compose.material3.ExperimentalMaterial3Api 23 | import androidx.compose.material3.OutlinedButton 24 | import androidx.compose.material3.Scaffold 25 | import androidx.compose.material3.Text 26 | import androidx.compose.material3.TopAppBar 27 | import androidx.compose.runtime.Composable 28 | import androidx.compose.runtime.getValue 29 | import androidx.compose.ui.Alignment 30 | import androidx.compose.ui.Modifier 31 | import androidx.compose.ui.graphics.Color 32 | import androidx.compose.ui.unit.dp 33 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 34 | import aung.thiha.photo.album.photos.domain.model.Photo 35 | import coil3.compose.AsyncImagePainter 36 | import coil3.compose.rememberAsyncImagePainter 37 | import com.valentinilk.shimmer.shimmer 38 | import org.koin.compose.viewmodel.koinViewModel 39 | 40 | @Composable 41 | fun PhotoListContainer() { 42 | val viewModel = koinViewModel() 43 | val photoListState by viewModel.photoListState.collectAsStateWithLifecycle() 44 | 45 | PhotoListScreen( 46 | photoListState = photoListState, 47 | listener = viewModel 48 | ) 49 | } 50 | 51 | @OptIn(ExperimentalMaterial3Api::class) 52 | @Composable 53 | fun PhotoListScreen( 54 | photoListState: PhotoListState, 55 | listener: PhotoListScreenListener, 56 | ) { 57 | 58 | Scaffold( 59 | topBar = { 60 | TopAppBar( 61 | title = { 62 | Text("Photos") 63 | }, 64 | actions = { 65 | OutlinedButton( 66 | shape = RoundedCornerShape(16.dp), 67 | onClick = listener::onSignoutClick, 68 | modifier = Modifier.padding(end = 16.dp) 69 | ) { 70 | Text("Sign out") 71 | } 72 | } 73 | ) 74 | } 75 | ) { contentPadding -> 76 | 77 | Box(modifier = Modifier.padding(contentPadding)) { 78 | when (photoListState) { 79 | is PhotoListState.Content -> { 80 | if (photoListState.photos.isEmpty()) { 81 | EmptyPhotoGrid() 82 | } else { 83 | PhotoGrid(photoListState.photos) 84 | } 85 | } 86 | PhotoListState.Loading -> { 87 | Box( 88 | modifier = Modifier 89 | .fillMaxSize() 90 | .background(Color.White) 91 | .clickable(enabled = false) {} // Disables clicks on the overlay 92 | ) { 93 | CircularProgressIndicator( 94 | modifier = Modifier 95 | .align(Alignment.Center) 96 | ) 97 | } 98 | } 99 | PhotoListState.LoadingFailed -> { 100 | PhotoLoadingFailed(onRetry = listener::onSignoutClick) 101 | } 102 | } 103 | } 104 | } 105 | } 106 | 107 | @Composable 108 | fun PhotoGrid(photos: List) { 109 | LazyVerticalGrid( 110 | columns = GridCells.Fixed(2), 111 | modifier = Modifier.fillMaxSize(), 112 | contentPadding = PaddingValues(8.dp) 113 | ) { 114 | items(photos.size, key = { index -> photos[index].id }) { index -> 115 | val photo = photos[index] 116 | val painter = rememberAsyncImagePainter(model = photo.url) 117 | Box(modifier = Modifier.padding(8.dp)) { 118 | Image( 119 | painter = painter, 120 | contentDescription = "photo", 121 | ) 122 | if (painter.state.collectAsStateWithLifecycle().value is AsyncImagePainter.State.Loading) { 123 | Box(modifier = Modifier 124 | .aspectRatio(1f) 125 | .shimmer() 126 | ) { 127 | Box(modifier = Modifier.fillMaxSize().background(Color.LightGray)) 128 | } 129 | } 130 | 131 | } 132 | } 133 | } 134 | } 135 | 136 | @Composable 137 | fun PhotoLoadingFailed(onRetry: () -> Unit) { 138 | Column( 139 | modifier = Modifier 140 | .fillMaxSize() 141 | .padding(16.dp) 142 | .imePadding(), 143 | horizontalAlignment = Alignment.CenterHorizontally, 144 | verticalArrangement = Arrangement.Center 145 | ) { 146 | Text(text = "Loading Photos Failed!") 147 | 148 | Spacer(modifier = Modifier.height(16.dp)) 149 | 150 | Button( 151 | onClick = onRetry, 152 | modifier = Modifier 153 | .fillMaxWidth() 154 | .height(50.dp) 155 | ) { 156 | Text(text = "Retry") 157 | } 158 | } 159 | } 160 | 161 | @Composable 162 | fun EmptyPhotoGrid() { 163 | Column( 164 | modifier = Modifier 165 | .fillMaxSize() 166 | .padding(16.dp) 167 | .imePadding(), 168 | horizontalAlignment = Alignment.CenterHorizontally, 169 | verticalArrangement = Arrangement.Center 170 | ) { 171 | Text(text = "No Photos") 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /TESTING.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | ## Unit Tests 4 | 5 | - Sample unit tests can be found in [`DefaultNavigationDispatcherTest.kt`](composeApp/src/jvmTest/kotlin/aung/thiha/photo/album/navigation/DefaultNavigationDispatcherTest.kt). 6 | - This project uses [JUnit5](https://junit.org/) and [Mokkery](https://mokkery.dev/). See the official docs for API reference. 7 | 8 | --- 9 | 10 | ## Integration Tests 11 | 12 | > 💡 Integration tests must live in [`composeApp/src/jvmTest`](composeApp/src/jvmTest) as they involve dependencies across multiple modules. 13 | 14 | --- 15 | 16 | ### Set Up Fakes for Integration Tests 17 | 18 | 1. **Implement a fake** 19 | Take [`FakeAuthenticationDataSource.kt`](composeApp/src/jvmTest/kotlin/aung/thiha/photo/album/authentication/data/remote/service/FakeAuthenticationDataSource.kt) as a reference and implement the interface you want to fake. 20 | 21 | 2. **Create a Koin module to override the interface provision** 22 | For example, in [`AuthenticationDataModuleOverride.kt`](composeApp/src/jvmTest/kotlin/aung/thiha/photo/album/di/AuthenticationDataModuleOverride.kt): 23 | 24 | ```kotlin 25 | import aung.thiha.photo.album.di.core.fake 26 | import org.koin.dsl.module 27 | 28 | val authenticationDataModuleOverride = module { 29 | fake { 30 | FakeAuthenticationDataSource() 31 | } 32 | } 33 | ``` 34 | > 💡 Use the `fake` API as shown above. This allows you to provide the interface to the production code and inject the concrete implementation to your test. With the concrete implementation, you can stub responses. 35 | 36 | 3. **Add your override module to the `overrides` list** 37 | Update [KoinModuleOverrides.kt](composeApp/src/jvmTest/kotlin/aung/thiha/photo/album/di/KoinModuleOverrides.kt): 38 | ```kotlin 39 | val overrides = listOf( 40 | sessionStorageModule, 41 | authenticationDataModuleOverride 42 | ) 43 | ``` 44 | 45 | 4. **Annotate your test with the Koin test extension and inject your fake** 46 | ```kotlin 47 | import aung.thiha.photo.album.di.core.KoinTestExtension 48 | import org.koin.test.KoinTest 49 | import org.koin.test.inject 50 | 51 | @ExtendWith(KoinTestExtension::class) 52 | class SplashViewModelTest : KoinTest { 53 | 54 | private val authDataSource: FakeAuthenticationDataSource by inject() 55 | 56 | @Test 57 | fun stubrResponse() { 58 | authDataSource.tokenCheckResponses += TokenCheckResponse(message = "all good") 59 | } 60 | } 61 | ``` 62 | 63 | 5. **Enjoy!** 64 | 65 | --- 66 | 67 | ### Override Coroutine Dispatchers for Integration Tests 68 | 69 | > ⚠️ **Do not** use `kotlinx.coroutines.Dispatchers` directly like `Dispatchers.Main`, `Dispatchers.IO`, etc. 70 | > Production code must always use `CoroutineDispatchers` from [`AppDispatchers`](coroutines/src/commonMain/kotlin/aung/thiha/coroutines/AppDispatchers.kt). 71 | 72 | There are two main ways to override dispatchers during testing: 73 | 74 | #### Option 1: Quick Setup with `UnconfinedTestDispatcher` 75 | 76 | If you only need to use the same `UnconfinedTestDispatcher` for everything, simply annotate your test class: 77 | 78 | ```kotlin 79 | import aung.thiha.coroutines.TestDispatcherExtension 80 | import aung.thiha.photo.album.di.core.KoinTestExtension 81 | 82 | @ExtendWith(TestDispatcherExtension::class) 83 | @ExtendWith(KoinTestExtension::class) 84 | class SplashViewModelTest : KoinTest { 85 | // Tests here will run with UnconfinedTestDispatcher 86 | } 87 | ``` 88 | #### Option 2: Fine-Grained Dispatcher Control 89 | 90 | Use this approach if you need: 91 | - A custom `TestCoroutineScheduler` 92 | - Different `TestDispatchers` for `Main`, `IO` or `Default` 93 | ```kotlin 94 | import aung.thiha.coroutines.TestDispatcherExtension 95 | import aung.thiha.photo.album.di.core.KoinTestExtension 96 | 97 | @ExtendWith(KoinTestExtension::class) 98 | class SplashViewModelTest : KoinTest { 99 | 100 | val testScope = TestScope() 101 | 102 | /** 103 | * Option A: Override only the test scheduler 104 | * This automatically makes all dispatchers share the same scheduler. 105 | */ 106 | @JvmField 107 | @RegisterExtension 108 | val testDispatcherExtensionOverrideTestScheduler = TestDispatcherExtension( 109 | testCoroutineScheduler = testScope.testScheduler 110 | ) 111 | 112 | /** 113 | * Option B: Override specific dispatchers 114 | * 115 | * ⚠️ All TestDispatchers must share the same TestCoroutineScheduler! 116 | * Otherwise, virtual time won't be in sync and may lead to flaky tests 117 | * 118 | * Refer to Google’s guide on TestCoroutineScheduler and virtual time: 119 | * https://developer.android.com/kotlin/coroutines/test 120 | */ 121 | @OptIn(ExperimentalCoroutinesApi::class) 122 | @JvmField 123 | @RegisterExtension 124 | val testDispatcherExtensionOverrideDispatchers = TestDispatcherExtension( 125 | main = StandardTestDispatcher(testScope.testScheduler), 126 | io = StandardTestDispatcher(testScope.testScheduler), 127 | default = UnconfinedTestDispatcher(testScope.testScheduler) 128 | ) 129 | } 130 | ``` 131 | 132 | --- 133 | 134 | ### Assert Navigation in Integration Tests 135 | Use `runNavTest` to assert navigation behavior in integration tests. This gives you: 136 | 137 | - Access to `TestScope`, just like `runTest` 138 | - A `SpyNavigationHandler` (`spyNav`) to assert navigation outcomes 139 | - Declarative and readable assertions 140 | 141 | ```kotlin 142 | import aung.thiha.photo.album.navigation.runNavTest 143 | 144 | class SplashViewModelTest : KoinTest { 145 | @Test 146 | fun simpleNavTest() = runNavTest { spyNav -> 147 | spyNav shouldNavigateTo PhotoListRoute.withClearBackStack 148 | } 149 | 150 | @Test 151 | fun withClearBackStack() = runNavTest { spyNav -> 152 | spyNav shouldNavigateTo PhotoListRoute.withClearBackStack 153 | } 154 | 155 | @Test 156 | fun withLaunchSingleTop() = runNavTest { spyNav -> 157 | spyNav shouldNavigateTo PhotoListRoute.withLaunchSingleTop 158 | } 159 | 160 | @Test 161 | fun withPopupToNonInclusive() = runNavTest { spyNav -> 162 | spyNav shouldNavigateTo PhotoListRoute.withPopUpTo(SplashRoute) 163 | } 164 | 165 | @Test 166 | fun withPopupToInclusive() = runNavTest { spyNav -> 167 | spyNav shouldNavigateTo PhotoListRoute.withPopUpTo( 168 | popUpTo = SplashRoute, 169 | isInclusive = true 170 | ) 171 | } 172 | 173 | @Test 174 | @OptIn(ExperimentalCoroutinesApi::class) 175 | fun useTestScope() = runNavTest { spyNav -> 176 | backgroundScope.launch { 177 | // simulate background work 178 | } 179 | 180 | val dispatcher = StandardTestDispatcher(testScheduler) 181 | val job = launch(dispatcher) { 182 | delay(100) 183 | } 184 | job.join() 185 | 186 | runCurrent() 187 | advanceTimeBy(100) 188 | advanceUntilIdle() 189 | } 190 | } 191 | ``` 192 | 193 | --- 194 | 195 | ## Manual Tests 196 | > 💡 Take this part with a grain salt. There's no perfect approach here. This is simply what I recommend based on my experience. 197 | 198 | While it's ok to test only on Android during coding, it's important to test on both Android and iOS before marking a task **done**. 199 | A task isn't really done if it hasn't been tested on both platforms because what works on Android may not work on iOS. 200 | 201 | Think of it like this: before you open a PR, you do a final test. That final test should always include both Android and iOS. 202 | 203 | After the PR is merged, QA engineers should also make sure their test coverage includes both platforms. 204 | Unlike developer testing, this should cover multiple device configurations on each platform, depending on the affected areas. 205 | 206 | ## Snapshot Tests 207 | Coming Soon 208 | 209 | ## Instrumentation Tests 210 | Coming Soon -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | # This is normally unused 84 | # shellcheck disable=SC2034 85 | APP_BASE_NAME=${0##*/} 86 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 87 | APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit 88 | 89 | # Use the maximum available, or set MAX_FD != -1 to use that value. 90 | MAX_FD=maximum 91 | 92 | warn () { 93 | echo "$*" 94 | } >&2 95 | 96 | die () { 97 | echo 98 | echo "$*" 99 | echo 100 | exit 1 101 | } >&2 102 | 103 | # OS specific support (must be 'true' or 'false'). 104 | cygwin=false 105 | msys=false 106 | darwin=false 107 | nonstop=false 108 | case "$( uname )" in #( 109 | CYGWIN* ) cygwin=true ;; #( 110 | Darwin* ) darwin=true ;; #( 111 | MSYS* | MINGW* ) msys=true ;; #( 112 | NONSTOP* ) nonstop=true ;; 113 | esac 114 | 115 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 116 | 117 | 118 | # Determine the Java command to use to start the JVM. 119 | if [ -n "$JAVA_HOME" ] ; then 120 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 121 | # IBM's JDK on AIX uses strange locations for the executables 122 | JAVACMD=$JAVA_HOME/jre/sh/java 123 | else 124 | JAVACMD=$JAVA_HOME/bin/java 125 | fi 126 | if [ ! -x "$JAVACMD" ] ; then 127 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 128 | 129 | Please set the JAVA_HOME variable in your environment to match the 130 | location of your Java installation." 131 | fi 132 | else 133 | JAVACMD=java 134 | if ! command -v java >/dev/null 2>&1 135 | then 136 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | fi 142 | 143 | # Increase the maximum file descriptors if we can. 144 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 145 | case $MAX_FD in #( 146 | max*) 147 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 148 | # shellcheck disable=SC2039,SC3045 149 | MAX_FD=$( ulimit -H -n ) || 150 | warn "Could not query maximum file descriptor limit" 151 | esac 152 | case $MAX_FD in #( 153 | '' | soft) :;; #( 154 | *) 155 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 156 | # shellcheck disable=SC2039,SC3045 157 | ulimit -n "$MAX_FD" || 158 | warn "Could not set maximum file descriptor limit to $MAX_FD" 159 | esac 160 | fi 161 | 162 | # Collect all arguments for the java command, stacking in reverse order: 163 | # * args from the command line 164 | # * the main class name 165 | # * -classpath 166 | # * -D...appname settings 167 | # * --module-path (only if needed) 168 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 169 | 170 | # For Cygwin or MSYS, switch paths to Windows format before running java 171 | if "$cygwin" || "$msys" ; then 172 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 173 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 174 | 175 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 176 | 177 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 178 | for arg do 179 | if 180 | case $arg in #( 181 | -*) false ;; # don't mess with options #( 182 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 183 | [ -e "$t" ] ;; #( 184 | *) false ;; 185 | esac 186 | then 187 | arg=$( cygpath --path --ignore --mixed "$arg" ) 188 | fi 189 | # Roll the args list around exactly as many times as the number of 190 | # args, so each arg winds up back in the position where it started, but 191 | # possibly modified. 192 | # 193 | # NB: a `for` loop captures its iteration list before it begins, so 194 | # changing the positional parameters here affects neither the number of 195 | # iterations, nor the values presented in `arg`. 196 | shift # remove old arg 197 | set -- "$@" "$arg" # push replacement arg 198 | done 199 | fi 200 | 201 | 202 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 203 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 204 | 205 | # Collect all arguments for the java command: 206 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 207 | # and any embedded shellness will be escaped. 208 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 209 | # treated as '${Hostname}' itself on the command line. 210 | 211 | set -- \ 212 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 213 | -classpath "$CLASSPATH" \ 214 | org.gradle.wrapper.GradleWrapperMain \ 215 | "$@" 216 | 217 | # Stop when "xargs" is not available. 218 | if ! command -v xargs >/dev/null 2>&1 219 | then 220 | die "xargs is not available" 221 | fi 222 | 223 | # Use "xargs" to parse quoted args. 224 | # 225 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 226 | # 227 | # In Bash we could simply go: 228 | # 229 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 230 | # set -- "${ARGS[@]}" "$@" 231 | # 232 | # but POSIX shell has neither arrays nor command substitution, so instead we 233 | # post-process each arg (as a line of input to sed) to backslash-escape any 234 | # character that might be a shell metacharacter, then use eval to reverse 235 | # that process (while maintaining the separation between arguments), and wrap 236 | # the whole thing up as a single "set" statement. 237 | # 238 | # This will of course break if any of these variables contains a newline or 239 | # an unmatched quote. 240 | # 241 | 242 | eval "set -- $( 243 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 244 | xargs -n1 | 245 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 246 | tr '\n' ' ' 247 | )" '"$@"' 248 | 249 | exec "$JAVACMD" "$@" 250 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. --------------------------------------------------------------------------------