├── core ├── common │ ├── .gitignore │ ├── src │ │ └── commonMain │ │ │ ├── composeResources │ │ │ ├── font │ │ │ │ └── ADLaMDisplay_Regular.ttf │ │ │ └── drawable │ │ │ │ └── compose-multiplatform.xml │ │ │ └── kotlin │ │ │ └── com │ │ │ └── pmb │ │ │ └── common │ │ │ ├── ui │ │ │ ├── scaffold │ │ │ │ ├── model │ │ │ │ │ └── NavigationModels.kt │ │ │ │ ├── navigation │ │ │ │ │ ├── BottomNavigationBar.kt │ │ │ │ │ └── drawer │ │ │ │ │ │ └── drawerMeasurePolicy.kt │ │ │ │ ├── NavItem.kt │ │ │ │ └── components │ │ │ │ │ └── CompactAddGroupButton.kt │ │ │ └── emptyState │ │ │ │ └── EmptyListState.kt │ │ │ ├── theme │ │ │ ├── shapes.kt │ │ │ └── Type.kt │ │ │ └── viewmodel │ │ │ └── BaseViewmodel.kt │ └── build.gradle.kts ├── data │ ├── .gitignore │ └── src │ │ ├── commonMain │ │ └── kotlin │ │ │ ├── model │ │ │ ├── LoginRequest.kt │ │ │ ├── CreateGroupRequest.kt │ │ │ ├── RegisterRequest.kt │ │ │ └── CreateTransactionRequest.kt │ │ │ ├── repository │ │ │ ├── UserRepositoryImpl.kt │ │ │ ├── GroupsRepositoryImpl.kt │ │ │ ├── FriendsRepositoryImpl.kt │ │ │ ├── AuthRepositoryImpl.kt │ │ │ └── TransactionsRepositoryImpl.kt │ │ │ └── di │ │ │ └── dataModule.kt │ │ └── commonTest │ │ └── kotlin │ │ └── DataModuleTest.kt ├── domain │ ├── .gitignore │ ├── src │ │ ├── commonMain │ │ │ └── kotlin │ │ │ │ ├── model │ │ │ │ ├── User.kt │ │ │ │ ├── AuthResponse.kt │ │ │ │ ├── Group.kt │ │ │ │ └── Transaction.kt │ │ │ │ ├── usecase │ │ │ │ ├── user │ │ │ │ │ ├── GetUserInfoUseCase.kt │ │ │ │ │ └── SetUserInfoUseCase.kt │ │ │ │ ├── groups │ │ │ │ │ ├── GetGroupsUseCase.kt │ │ │ │ │ ├── DeleteGroupUseCase.kt │ │ │ │ │ ├── CreateGroupUseCase.kt │ │ │ │ │ └── UpdateGroupMembersUseCase.kt │ │ │ │ ├── friends │ │ │ │ │ ├── GetFriendsUseCase.kt │ │ │ │ │ ├── GetFriendRequestsUseCase.kt │ │ │ │ │ ├── RemoveFriendUseCase.kt │ │ │ │ │ ├── SendFriendRequestUseCase.kt │ │ │ │ │ ├── AcceptFriendRequestUseCase.kt │ │ │ │ │ └── RejectFriendRequestUseCase.kt │ │ │ │ ├── auth │ │ │ │ │ ├── LoginUserUseCase.kt │ │ │ │ │ └── RegisterUserUseCase.kt │ │ │ │ └── transactions │ │ │ │ │ ├── GetTransactionsUseCase.kt │ │ │ │ │ ├── ApproveTransactionUseCase.kt │ │ │ │ │ ├── DeleteTransactionUseCase.kt │ │ │ │ │ ├── RejectTransactionUseCase.kt │ │ │ │ │ └── CreateTransactionUseCase.kt │ │ │ │ └── repository │ │ │ │ ├── UserRepository.kt │ │ │ │ ├── AuthRepository.kt │ │ │ │ ├── GroupsRepository.kt │ │ │ │ ├── FriendsRepository.kt │ │ │ │ └── TransactionsRepository.kt │ │ └── commonTest │ │ │ └── kotlin │ │ │ ├── kotest │ │ │ ├── SimpleTest.kt │ │ │ └── UserManagerTest.kt │ │ │ └── usecase │ │ │ ├── friends │ │ │ ├── AcceptFriendRequestUseCaseTest.kt │ │ │ ├── RemoveFriendUseCaseTest.kt │ │ │ ├── SendFriendRequestUseCaseTest.kt │ │ │ ├── RejectFriendRequestUseCaseTest.kt │ │ │ ├── GetFriendsUseCaseTest.kt │ │ │ └── GetFriendRequestsUseCaseTest.kt │ │ │ ├── groups │ │ │ ├── DeleteGroupUseCaseTest.kt │ │ │ ├── GetGroupsUseCaseTest.kt │ │ │ ├── CreateGroupUseCaseTest.kt │ │ │ └── UpdateGroupMembersUseCaseTest.kt │ │ │ ├── transactions │ │ │ ├── DeleteTransactionUseCaseTest.kt │ │ │ ├── RejectTransactionUseCaseTest.kt │ │ │ ├── ApproveTransactionUseCaseTest.kt │ │ │ ├── GetTransactionsUseCaseTest.kt │ │ │ └── CreateTransactionUseCaseTest.kt │ │ │ └── auth │ │ │ ├── LoginUserUseCaseTest.kt │ │ │ └── RegisterUserUseCaseTest.kt │ └── build.gradle.kts ├── currency │ ├── .gitignore │ ├── src │ │ └── commonMain │ │ │ └── kotlin │ │ │ └── org │ │ │ └── milad │ │ │ └── expense_share │ │ │ ├── AmountSerializer.kt │ │ │ └── Amount.kt │ └── build.gradle.kts ├── navigation │ ├── .gitignore │ ├── src │ │ └── commonMain │ │ │ └── kotlin │ │ │ └── com │ │ │ └── milad │ │ │ └── navigation │ │ │ ├── AuthRoute.kt │ │ │ ├── RootRoute.kt │ │ │ └── MainRoute.kt │ └── build.gradle.kts └── network │ ├── .gitignore │ └── src │ ├── commonMain │ └── kotlin │ │ ├── Platform.kt │ │ ├── HttpResponseMockable.kt │ │ ├── client │ │ ├── ApiClient.kt │ │ ├── KtorApiClient.kt │ │ └── NetworkClient.kt │ │ ├── di │ │ └── networkModule.kt │ │ ├── token │ │ └── TokenProvider.kt │ │ ├── plugin │ │ └── ErrorHandler.kt │ │ └── NetworkManager.kt │ ├── iosMain │ └── kotlin │ │ └── Platform.ios.kt │ ├── wasmJsMain │ └── kotlin │ │ └── Platform.wasmJs.kt │ ├── jvmMain │ └── kotlin │ │ └── Platform.jvm.kt │ ├── androidMain │ └── kotlin │ │ └── Platform.android.kt │ └── commonTest │ └── kotlin │ ├── NetworkModuleTest.kt │ ├── NetworkManagerTest.kt │ └── client │ └── ApiClientTest.kt ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── composeApp └── src │ ├── androidMain │ ├── res │ │ ├── values │ │ │ └── strings.xml │ │ ├── mipmap-hdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ ├── mipmap-anydpi-v26 │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ └── drawable-v24 │ │ │ └── ic_launcher_foreground.xml │ ├── kotlin │ │ └── org │ │ │ └── milad │ │ │ └── expense_share │ │ │ ├── MainActivity.kt │ │ │ └── ExpenseShareApp.kt │ └── AndroidManifest.xml │ ├── wasmJsMain │ ├── resources │ │ ├── styles.css │ │ └── index.html │ └── kotlin │ │ └── org │ │ └── milad │ │ └── expense_share │ │ └── main.kt │ ├── commonMain │ ├── composeResources │ │ └── drawable │ │ │ ├── paris.jpg │ │ │ └── compose-multiplatform.xml │ └── kotlin │ │ └── org │ │ └── milad │ │ └── expense_share │ │ ├── auth │ │ ├── AuthNavHost.kt │ │ └── login │ │ │ └── LoginViewModel.kt │ │ ├── dashboard │ │ └── ExtraPaneContent.kt │ │ ├── profile │ │ └── ProfileScreen.kt │ │ ├── di │ │ └── koinModules.kt │ │ └── friends │ │ └── FriendsScreen.kt │ ├── main │ └── res │ │ └── xml │ │ └── network_security_config.xml │ ├── iosMain │ └── kotlin │ │ └── org │ │ └── milad │ │ └── expense_share │ │ └── MainViewController.kt │ └── jvmMain │ └── kotlin │ └── org │ └── milad │ └── expense_share │ └── main.kt ├── iosApp ├── iosApp │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── app-icon-1024.png │ │ │ └── Contents.json │ │ └── AccentColor.colorset │ │ │ └── Contents.json │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── iOSApp.swift │ ├── Info.plist │ └── ContentView.swift ├── Configuration │ └── Config.xcconfig └── iosApp.xcodeproj │ └── project.xcworkspace │ └── contents.xcworkspacedata ├── docs └── screenshot │ ├── Screenshot_20251213_152144.png │ ├── Screenshot_20251213_152240.png │ ├── Screenshot_20251213_152252.png │ ├── Screenshot_20251213_152329.png │ └── Screenshot 2025-12-13 at 15.24.58.png ├── server ├── src │ ├── main │ │ ├── kotlin │ │ │ └── org │ │ │ │ └── milad │ │ │ │ └── expense_share │ │ │ │ ├── data │ │ │ │ ├── models │ │ │ │ │ ├── User.kt │ │ │ │ │ ├── GroupMember.kt │ │ │ │ │ ├── Group.kt │ │ │ │ │ ├── TransactionStatus.kt │ │ │ │ │ ├── FriendRelation.kt │ │ │ │ │ └── Transaction.kt │ │ │ │ ├── db │ │ │ │ │ ├── table │ │ │ │ │ │ ├── Passwords.kt │ │ │ │ │ │ ├── FriendRelations.kt │ │ │ │ │ │ ├── Groups.kt │ │ │ │ │ │ ├── Users.kt │ │ │ │ │ │ ├── GroupMembers.kt │ │ │ │ │ │ ├── Friends.kt │ │ │ │ │ │ └── Transactions.kt │ │ │ │ │ ├── FakeDatabase.kt │ │ │ │ │ └── DatabaseFactory.kt │ │ │ │ ├── inMemoryRepository │ │ │ │ │ ├── InMemoryUserRepository.kt │ │ │ │ │ └── InMemoryGroupRepository.kt │ │ │ │ ├── dbMapper.kt │ │ │ │ └── repository │ │ │ │ │ └── UserRepositoryImpl.kt │ │ │ │ ├── presentation │ │ │ │ ├── friends │ │ │ │ │ └── model │ │ │ │ │ │ ├── FriendRequest.kt │ │ │ │ │ │ └── RequestsResponse.kt │ │ │ │ ├── auth │ │ │ │ │ ├── model │ │ │ │ │ │ ├── LoginRequest.kt │ │ │ │ │ │ └── RegisterRequest.kt │ │ │ │ │ └── authRoutes.kt │ │ │ │ ├── groups │ │ │ │ │ └── model │ │ │ │ │ │ ├── AddUserRequest.kt │ │ │ │ │ │ ├── UserGroupResponse.kt │ │ │ │ │ │ └── CreateGroupRequest.kt │ │ │ │ ├── api_model │ │ │ │ │ ├── SuccessResponse.kt │ │ │ │ │ └── ErrorResponse.kt │ │ │ │ └── transactions │ │ │ │ │ └── model │ │ │ │ │ └── CreateTransactionRequest.kt │ │ │ │ ├── domain │ │ │ │ ├── model │ │ │ │ │ └── AuthResponse.kt │ │ │ │ ├── repository │ │ │ │ │ ├── UserRepository.kt │ │ │ │ │ ├── GroupRepository.kt │ │ │ │ │ ├── FriendRepository.kt │ │ │ │ │ └── TransactionRepository.kt │ │ │ │ └── service │ │ │ │ │ ├── FriendsService.kt │ │ │ │ │ ├── AuthService.kt │ │ │ │ │ ├── TransactionService.kt │ │ │ │ │ └── GroupService.kt │ │ │ │ ├── application │ │ │ │ ├── configureKoin.kt │ │ │ │ ├── configureCORS.kt │ │ │ │ ├── configureStatusPages.kt │ │ │ │ ├── configureSecurity.kt │ │ │ │ └── configureRouting.kt │ │ │ │ ├── Application.kt │ │ │ │ ├── di │ │ │ │ └── appModule.kt │ │ │ │ ├── utils │ │ │ │ └── extension.kt │ │ │ │ └── security │ │ │ │ └── JwtConfig.kt │ │ └── resources │ │ │ ├── application.conf │ │ │ └── logback.xml │ └── test │ │ └── kotlin │ │ └── AppModuleTest.kt ├── build.gradle.kts └── README.md ├── gradle.properties ├── .gitignore ├── .run ├── server.run.xml ├── composeApp [wasmJs].run.xml ├── composeApp [jvm].run.xml └── kotest.run.xml ├── kotlin-js-store └── wasm │ └── yarn.lock ├── settings.gradle.kts └── gradlew.bat /core/common/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /core/data/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /core/domain/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /core/currency/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /core/navigation/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /core/network/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloner93/ExpenseShare/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | ExpenseShare 3 | -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /core/network/src/commonMain/kotlin/Platform.kt: -------------------------------------------------------------------------------- 1 | import io.ktor.client.engine.HttpClientEngine 2 | 3 | expect fun getKtorEngine(): HttpClientEngine -------------------------------------------------------------------------------- /docs/screenshot/Screenshot_20251213_152144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloner93/ExpenseShare/HEAD/docs/screenshot/Screenshot_20251213_152144.png -------------------------------------------------------------------------------- /docs/screenshot/Screenshot_20251213_152240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloner93/ExpenseShare/HEAD/docs/screenshot/Screenshot_20251213_152240.png -------------------------------------------------------------------------------- /docs/screenshot/Screenshot_20251213_152252.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloner93/ExpenseShare/HEAD/docs/screenshot/Screenshot_20251213_152252.png -------------------------------------------------------------------------------- /docs/screenshot/Screenshot_20251213_152329.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloner93/ExpenseShare/HEAD/docs/screenshot/Screenshot_20251213_152329.png -------------------------------------------------------------------------------- /iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /docs/screenshot/Screenshot 2025-12-13 at 15.24.58.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloner93/ExpenseShare/HEAD/docs/screenshot/Screenshot 2025-12-13 at 15.24.58.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloner93/ExpenseShare/HEAD/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloner93/ExpenseShare/HEAD/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /composeApp/src/wasmJsMain/resources/styles.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | width: 100%; 3 | height: 100%; 4 | margin: 0; 5 | padding: 0; 6 | overflow: hidden; 7 | } -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloner93/ExpenseShare/HEAD/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloner93/ExpenseShare/HEAD/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloner93/ExpenseShare/HEAD/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /composeApp/src/commonMain/composeResources/drawable/paris.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloner93/ExpenseShare/HEAD/composeApp/src/commonMain/composeResources/drawable/paris.jpg -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloner93/ExpenseShare/HEAD/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloner93/ExpenseShare/HEAD/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloner93/ExpenseShare/HEAD/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloner93/ExpenseShare/HEAD/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloner93/ExpenseShare/HEAD/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloner93/ExpenseShare/HEAD/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png -------------------------------------------------------------------------------- /core/common/src/commonMain/composeResources/font/ADLaMDisplay_Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloner93/ExpenseShare/HEAD/core/common/src/commonMain/composeResources/font/ADLaMDisplay_Regular.ttf -------------------------------------------------------------------------------- /core/domain/src/commonMain/kotlin/model/User.kt: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class User(val id: Int, val username: String, val phone: String) -------------------------------------------------------------------------------- /core/network/src/iosMain/kotlin/Platform.ios.kt: -------------------------------------------------------------------------------- 1 | import io.ktor.client.engine.HttpClientEngine 2 | import io.ktor.client.engine.darwin.Darwin 3 | 4 | actual fun getKtorEngine(): HttpClientEngine = Darwin.create() -------------------------------------------------------------------------------- /core/network/src/wasmJsMain/kotlin/Platform.wasmJs.kt: -------------------------------------------------------------------------------- 1 | 2 | import io.ktor.client.engine.HttpClientEngine 3 | import io.ktor.client.engine.js.Js 4 | 5 | actual fun getKtorEngine(): HttpClientEngine = Js.create() -------------------------------------------------------------------------------- /iosApp/iosApp/iOSApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @main 4 | struct iOSApp: App { 5 | var body: some Scene { 6 | WindowGroup { 7 | ContentView() 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /core/data/src/commonMain/kotlin/model/LoginRequest.kt: -------------------------------------------------------------------------------- 1 | package model 2 | import kotlinx.serialization.Serializable 3 | 4 | 5 | @Serializable 6 | data class LoginRequest(val phone: String, val password: String) 7 | -------------------------------------------------------------------------------- /iosApp/Configuration/Config.xcconfig: -------------------------------------------------------------------------------- 1 | TEAM_ID= 2 | 3 | PRODUCT_NAME=ExpenseShare 4 | PRODUCT_BUNDLE_IDENTIFIER=org.milad.expense_share.ExpenseShare$(TEAM_ID) 5 | 6 | CURRENT_PROJECT_VERSION=1 7 | MARKETING_VERSION=1.0 -------------------------------------------------------------------------------- /core/network/src/commonMain/kotlin/HttpResponseMockable.kt: -------------------------------------------------------------------------------- 1 | import io.ktor.client.statement.HttpResponse 2 | import io.mockative.Mockable 3 | 4 | @Mockable(HttpResponse::class) 5 | internal class HttpResponseMockable 6 | -------------------------------------------------------------------------------- /core/domain/src/commonMain/kotlin/model/AuthResponse.kt: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class AuthResponse( 7 | val token: String, 8 | val user: User 9 | ) -------------------------------------------------------------------------------- /core/data/src/commonMain/kotlin/model/CreateGroupRequest.kt: -------------------------------------------------------------------------------- 1 | package model 2 | import kotlinx.serialization.Serializable 3 | 4 | 5 | @Serializable 6 | data class CreateGroupRequest(val name: String, val memberIds: List) 7 | -------------------------------------------------------------------------------- /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 | } 12 | -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/data/models/User.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.data.models 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class User(val id: Int, val username: String, val phone: String) -------------------------------------------------------------------------------- /core/network/src/jvmMain/kotlin/Platform.jvm.kt: -------------------------------------------------------------------------------- 1 | import io.ktor.client.engine.HttpClientEngine 2 | import io.ktor.client.engine.HttpClientEngineFactory 3 | import io.ktor.client.engine.cio.CIO 4 | 5 | actual fun getKtorEngine(): HttpClientEngine = CIO.create() -------------------------------------------------------------------------------- /core/network/src/androidMain/kotlin/Platform.android.kt: -------------------------------------------------------------------------------- 1 | import io.ktor.client.engine.HttpClientEngine 2 | import io.ktor.client.engine.okhttp.OkHttp 3 | import io.ktor.client.engine.okhttp.OkHttpEngine 4 | 5 | actual fun getKtorEngine(): HttpClientEngine = OkHttp.create() -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/presentation/friends/model/FriendRequest.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.presentation.friends.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class FriendRequest(val phone: String) -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/data/models/GroupMember.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.data.models 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class GroupMember( 7 | val groupId: Int, 8 | val userId: Int 9 | ) -------------------------------------------------------------------------------- /core/data/src/commonMain/kotlin/model/RegisterRequest.kt: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class RegisterRequest( 7 | val phone: String, 8 | val username: String, 9 | val password: String 10 | ) 11 | -------------------------------------------------------------------------------- /core/domain/src/commonMain/kotlin/usecase/user/GetUserInfoUseCase.kt: -------------------------------------------------------------------------------- 1 | package usecase.user 2 | 3 | import repository.UserRepository 4 | 5 | class GetUserInfoUseCase(private val userRepository: UserRepository) { 6 | suspend operator fun invoke() = userRepository.getInfo() 7 | } -------------------------------------------------------------------------------- /core/domain/src/commonMain/kotlin/repository/UserRepository.kt: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import io.mockative.Mockable 4 | import model.User 5 | 6 | @Mockable 7 | interface UserRepository { 8 | suspend fun setUserInfo(user: User) 9 | suspend fun getInfo(): User 10 | } -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/presentation/auth/model/LoginRequest.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.presentation.auth.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class LoginRequest(val phone: String, val password: String) -------------------------------------------------------------------------------- /core/domain/src/commonMain/kotlin/usecase/groups/GetGroupsUseCase.kt: -------------------------------------------------------------------------------- 1 | package usecase.groups 2 | 3 | import repository.GroupsRepository 4 | 5 | class GetGroupsUseCase(private val groupRepository: GroupsRepository) { 6 | suspend operator fun invoke() = groupRepository.getGroups() 7 | } -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/data/models/Group.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.data.models 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class Group( 7 | val id: Int, 8 | val name: String, 9 | val ownerId: Int 10 | ) -------------------------------------------------------------------------------- /core/common/src/commonMain/kotlin/com/pmb/common/ui/scaffold/model/NavigationModels.kt: -------------------------------------------------------------------------------- 1 | package com.pmb.common.ui.scaffold.model 2 | 3 | enum class NavigationContentPosition { 4 | TOP, 5 | CENTER 6 | } 7 | 8 | enum class NavigationLayoutType { 9 | HEADER, 10 | CONTENT 11 | } -------------------------------------------------------------------------------- /core/domain/src/commonMain/kotlin/usecase/friends/GetFriendsUseCase.kt: -------------------------------------------------------------------------------- 1 | package usecase.friends 2 | 3 | import repository.FriendsRepository 4 | 5 | class GetFriendsUseCase(private val friendsRepository: FriendsRepository) { 6 | suspend operator fun invoke() = friendsRepository.getFriends() 7 | } -------------------------------------------------------------------------------- /iosApp/iosApp/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CADisableMinimumFrameDurationOnPhone 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/data/models/TransactionStatus.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.data.models 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | enum class TransactionStatus { 7 | PENDING, 8 | APPROVED, 9 | REJECTED 10 | } 11 | -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/presentation/groups/model/AddUserRequest.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.presentation.groups.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class AddUserRequest( 7 | val memberIds: List = emptyList() 8 | ) -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/presentation/api_model/SuccessResponse.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.presentation.api_model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class SuccessResponse( 7 | val success: Boolean = true, 8 | val data: T 9 | ) -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/presentation/auth/model/RegisterRequest.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.presentation.auth.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | 6 | @Serializable 7 | data class RegisterRequest(val username: String, val phone: String, val password: String) -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/data/db/table/Passwords.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.data.db.table 2 | 3 | import org.jetbrains.exposed.sql.Table 4 | 5 | object Passwords : Table("passwords") { 6 | val userId = integer("user_id") references Users.id 7 | val hash = varchar("hash", 255) 8 | } -------------------------------------------------------------------------------- /core/domain/src/commonMain/kotlin/model/Group.kt: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class Group( 7 | val id: Int, 8 | val name: String, 9 | val ownerId: Int, 10 | val members: List, 11 | val transactions: List 12 | ) -------------------------------------------------------------------------------- /core/domain/src/commonMain/kotlin/usecase/friends/GetFriendRequestsUseCase.kt: -------------------------------------------------------------------------------- 1 | package usecase.friends 2 | 3 | import repository.FriendsRepository 4 | 5 | class GetFriendRequestsUseCase(private val friendsRepository: FriendsRepository) { 6 | suspend operator fun invoke() = friendsRepository.getFriendRequests() 7 | } 8 | -------------------------------------------------------------------------------- /core/domain/src/commonMain/kotlin/usecase/friends/RemoveFriendUseCase.kt: -------------------------------------------------------------------------------- 1 | package usecase.friends 2 | 3 | import repository.FriendsRepository 4 | 5 | class RemoveFriendUseCase(private val friendsRepository: FriendsRepository) { 6 | suspend operator fun invoke(phone: String) = friendsRepository.removeFriend(phone) 7 | } -------------------------------------------------------------------------------- /core/navigation/src/commonMain/kotlin/com/milad/navigation/AuthRoute.kt: -------------------------------------------------------------------------------- 1 | package com.milad.navigation 2 | import kotlinx.serialization.Serializable 3 | 4 | sealed interface AuthRoute { 5 | @Serializable 6 | data object Login : AuthRoute 7 | 8 | @Serializable 9 | data object Register : AuthRoute 10 | } -------------------------------------------------------------------------------- /core/navigation/src/commonMain/kotlin/com/milad/navigation/RootRoute.kt: -------------------------------------------------------------------------------- 1 | package com.milad.navigation 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | sealed interface RootRoute { 6 | @Serializable 7 | data object Auth : RootRoute 8 | 9 | @Serializable 10 | data object Main : RootRoute 11 | } -------------------------------------------------------------------------------- /core/domain/src/commonMain/kotlin/usecase/auth/LoginUserUseCase.kt: -------------------------------------------------------------------------------- 1 | package usecase.auth 2 | 3 | import repository.AuthRepository 4 | 5 | class LoginUserUseCase(private val authRepository: AuthRepository) { 6 | suspend operator fun invoke(phone: String, password: String) = 7 | authRepository.login(phone, password) 8 | } -------------------------------------------------------------------------------- /core/domain/src/commonMain/kotlin/usecase/groups/DeleteGroupUseCase.kt: -------------------------------------------------------------------------------- 1 | package usecase.groups 2 | 3 | import repository.GroupsRepository 4 | 5 | class DeleteGroupUseCase(private val groupRepository: GroupsRepository) { 6 | suspend operator fun invoke(groupId: String) = 7 | groupRepository.deleteGroup(groupId) 8 | } -------------------------------------------------------------------------------- /composeApp/src/main/res/xml/network_security_config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /core/domain/src/commonMain/kotlin/usecase/friends/SendFriendRequestUseCase.kt: -------------------------------------------------------------------------------- 1 | package usecase.friends 2 | 3 | import repository.FriendsRepository 4 | 5 | class SendFriendRequestUseCase(private val friendsRepository: FriendsRepository) { 6 | suspend operator fun invoke(phone: String) = friendsRepository.sendFriendRequest(phone) 7 | } -------------------------------------------------------------------------------- /core/domain/src/commonMain/kotlin/usecase/friends/AcceptFriendRequestUseCase.kt: -------------------------------------------------------------------------------- 1 | package usecase.friends 2 | 3 | import repository.FriendsRepository 4 | 5 | class AcceptFriendRequestUseCase(private val friendsRepository: FriendsRepository) { 6 | suspend operator fun invoke(phone: String) = friendsRepository.acceptFriendRequest(phone) 7 | } -------------------------------------------------------------------------------- /core/domain/src/commonMain/kotlin/usecase/friends/RejectFriendRequestUseCase.kt: -------------------------------------------------------------------------------- 1 | package usecase.friends 2 | 3 | import repository.FriendsRepository 4 | 5 | class RejectFriendRequestUseCase(private val friendsRepository: FriendsRepository) { 6 | suspend operator fun invoke(phone: String) = friendsRepository.rejectFriendRequest(phone) 7 | } -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /core/domain/src/commonMain/kotlin/usecase/groups/CreateGroupUseCase.kt: -------------------------------------------------------------------------------- 1 | package usecase.groups 2 | 3 | import repository.GroupsRepository 4 | 5 | class CreateGroupUseCase(private val groupRepository: GroupsRepository) { 6 | suspend operator fun invoke(name: String, memberIds: List) = 7 | groupRepository.createGroup(name, memberIds) 8 | } -------------------------------------------------------------------------------- /core/domain/src/commonMain/kotlin/usecase/user/SetUserInfoUseCase.kt: -------------------------------------------------------------------------------- 1 | package usecase.user 2 | 3 | import model.User 4 | import repository.UserRepository 5 | 6 | class SetUserInfoUseCase(private val userRepository: UserRepository) { 7 | suspend operator fun invoke( 8 | user: User, 9 | ) = userRepository.setUserInfo(user) 10 | } -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/domain/model/AuthResponse.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.domain.model 2 | 3 | import kotlinx.serialization.Serializable 4 | import org.milad.expense_share.data.models.User 5 | 6 | @Serializable 7 | data class AuthResponse( 8 | val token: String? = null, 9 | val user: User? = null 10 | ) -------------------------------------------------------------------------------- /core/domain/src/commonMain/kotlin/usecase/transactions/GetTransactionsUseCase.kt: -------------------------------------------------------------------------------- 1 | package usecase.transactions 2 | 3 | import repository.TransactionsRepository 4 | 5 | class GetTransactionsUseCase(private val transactionsRepository: TransactionsRepository) { 6 | suspend operator fun invoke(groupId: String) = transactionsRepository.getTransactions(groupId) 7 | } -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/presentation/api_model/ErrorResponse.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.presentation.api_model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class ErrorResponse( 7 | val message: String, 8 | val code: String, 9 | val details: Map? = null 10 | ) -------------------------------------------------------------------------------- /core/domain/src/commonMain/kotlin/usecase/auth/RegisterUserUseCase.kt: -------------------------------------------------------------------------------- 1 | package usecase.auth 2 | 3 | import repository.AuthRepository 4 | 5 | class RegisterUserUseCase(private val authRepository: AuthRepository) { 6 | suspend operator fun invoke(phone: String, username: String, password: String) = 7 | authRepository.register(phone, username, password) 8 | } -------------------------------------------------------------------------------- /core/domain/src/commonMain/kotlin/usecase/groups/UpdateGroupMembersUseCase.kt: -------------------------------------------------------------------------------- 1 | package usecase.groups 2 | 3 | import repository.GroupsRepository 4 | 5 | class UpdateGroupMembersUseCase(private val groupRepository: GroupsRepository) { 6 | suspend operator fun invoke(groupId: String, memberIds: List) = 7 | groupRepository.updateGroupMembers(groupId, memberIds) 8 | } -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/presentation/friends/model/RequestsResponse.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.presentation.friends.model 2 | 3 | import kotlinx.serialization.Serializable 4 | import org.milad.expense_share.data.models.User 5 | 6 | @Serializable 7 | data class RequestsResponse( 8 | val incoming: List, 9 | val outgoing: List 10 | ) -------------------------------------------------------------------------------- /core/navigation/src/commonMain/kotlin/com/milad/navigation/MainRoute.kt: -------------------------------------------------------------------------------- 1 | package com.milad.navigation 2 | import kotlinx.serialization.Serializable 3 | 4 | sealed interface MainRoute { 5 | @Serializable 6 | data object Dashboard : MainRoute 7 | 8 | @Serializable 9 | data object Friends : MainRoute 10 | 11 | @Serializable 12 | data object Profile : MainRoute 13 | } -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/data/db/table/FriendRelations.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.data.db.table 2 | 3 | import org.jetbrains.exposed.sql.Table 4 | 5 | object FriendRelations : Table("friend_relations") { 6 | val userId = integer("user_id") references Users.id 7 | val friendId = integer("friend_id") references Users.id 8 | val status = varchar("status", 20) 9 | } -------------------------------------------------------------------------------- /core/data/src/commonMain/kotlin/repository/UserRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import model.User 4 | 5 | class UserRepositoryImpl : UserRepository { 6 | private lateinit var userInfo: User 7 | 8 | override suspend fun setUserInfo(user: User) { 9 | userInfo = user 10 | } 11 | 12 | override suspend fun getInfo(): User { 13 | return userInfo 14 | } 15 | } -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/data/db/table/Groups.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.data.db.table 2 | 3 | import org.jetbrains.exposed.sql.Table 4 | 5 | object Groups : Table("groups") { 6 | val id = integer("id").autoIncrement() 7 | val name = varchar("name", 100) 8 | val ownerId = integer("owner_id") references Users.id 9 | override val primaryKey = PrimaryKey(id) 10 | } -------------------------------------------------------------------------------- /core/domain/src/commonMain/kotlin/repository/AuthRepository.kt: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import io.mockative.Mockable 4 | import kotlinx.coroutines.flow.Flow 5 | import model.User 6 | 7 | @Mockable 8 | interface AuthRepository { 9 | suspend fun register(phone: String, username: String, password: String): Flow> 10 | suspend fun login(phone: String, password: String): Flow> 11 | } -------------------------------------------------------------------------------- /core/domain/src/commonMain/kotlin/usecase/transactions/ApproveTransactionUseCase.kt: -------------------------------------------------------------------------------- 1 | package usecase.transactions 2 | 3 | import repository.TransactionsRepository 4 | 5 | class ApproveTransactionUseCase(private val transactionsRepository: TransactionsRepository) { 6 | suspend operator fun invoke(groupId: String, transactionId: String) = 7 | transactionsRepository.approveTransaction(groupId, transactionId) 8 | } -------------------------------------------------------------------------------- /core/domain/src/commonMain/kotlin/usecase/transactions/DeleteTransactionUseCase.kt: -------------------------------------------------------------------------------- 1 | package usecase.transactions 2 | 3 | import repository.TransactionsRepository 4 | 5 | class DeleteTransactionUseCase (private val transactionsRepository: TransactionsRepository) { 6 | suspend operator fun invoke(groupId: String, transactionId: String) = 7 | transactionsRepository.deleteTransaction(groupId, transactionId) 8 | } -------------------------------------------------------------------------------- /core/domain/src/commonMain/kotlin/usecase/transactions/RejectTransactionUseCase.kt: -------------------------------------------------------------------------------- 1 | package usecase.transactions 2 | 3 | import repository.TransactionsRepository 4 | 5 | class RejectTransactionUseCase (private val transactionsRepository: TransactionsRepository) { 6 | suspend operator fun invoke(groupId: String, transactionId: String) = 7 | transactionsRepository.rejectTransaction(groupId, transactionId) 8 | } -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/data/db/table/Users.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.data.db.table 2 | 3 | import org.jetbrains.exposed.sql.Table 4 | 5 | object Users : Table("users") { 6 | val id = integer("id").autoIncrement() 7 | val username = varchar("username", 100) 8 | val phone = varchar("phone", 20).uniqueIndex() 9 | override val primaryKey = PrimaryKey(id) 10 | } 11 | -------------------------------------------------------------------------------- /composeApp/src/wasmJsMain/resources/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ExpenseShare 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/application/configureKoin.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.application 2 | 3 | import io.ktor.server.application.Application 4 | import io.ktor.server.application.install 5 | import org.koin.ktor.plugin.Koin 6 | import org.milad.expense_share.di.appModule 7 | 8 | internal fun Application.configureKoin() { 9 | install(Koin) { 10 | modules(appModule) 11 | } 12 | } -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/data/models/FriendRelation.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.data.models 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | enum class FriendRelationStatus{ 7 | PENDING, ACCEPTED, REJECTED 8 | } 9 | 10 | @Serializable 11 | data class FriendRelation( 12 | val userId: Int, 13 | val friendId: Int, 14 | var status: FriendRelationStatus 15 | ) -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | #Kotlin 2 | kotlin.code.style=official 3 | kotlin.daemon.jvmargs=-Xmx3072M 4 | 5 | #Gradle 6 | org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8 7 | #org.gradle.configuration-cache=true 8 | org.gradle.caching=true 9 | 10 | #Android 11 | android.nonTransitiveRClass=true 12 | android.useAndroidX=true 13 | 14 | ksp.useKSP2=true 15 | ksp.incremental=false 16 | 17 | # temperory desabled 18 | io.mockative.enabled=false -------------------------------------------------------------------------------- /core/domain/src/commonTest/kotlin/kotest/SimpleTest.kt: -------------------------------------------------------------------------------- 1 | package kotest 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.shouldBe 5 | 6 | class SimpleTest : StringSpec({ 7 | 8 | "1 + 1 should equal 2" { 9 | val sum = 1 + 1 10 | sum shouldBe 2 11 | } 12 | 13 | "string length should be correct" { 14 | val text = "Kotest" 15 | text.length shouldBe 6 16 | } 17 | }) -------------------------------------------------------------------------------- /server/src/main/resources/application.conf: -------------------------------------------------------------------------------- 1 | ktor { 2 | deployment { 3 | port = 8082 4 | } 5 | 6 | application { 7 | modules = [ org.milad.expense_share.ApplicationKt.main ] 8 | } 9 | } 10 | 11 | jwt { 12 | secret = "LKJHkjh$KHdGDU@EGjBDj%BDjhgreG" 13 | issuer = "http://0.0.0.0:8082/" 14 | audience = "http://0.0.0.0:8082/test" 15 | realm = "Access to 'test'" 16 | validityMs = 86400000 17 | } -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/data/db/table/GroupMembers.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.data.db.table 2 | 3 | import org.jetbrains.exposed.sql.Table 4 | 5 | object GroupMembers : Table("group_members") { 6 | val id = integer("id").autoIncrement() 7 | val groupId = integer("group_id") references Groups.id 8 | val userId = integer("user_id") references Users.id 9 | override val primaryKey = PrimaryKey(id) 10 | } -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/domain/repository/UserRepository.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.domain.repository 2 | 3 | import org.milad.expense_share.data.models.User 4 | 5 | interface UserRepository { 6 | fun create(user: User, passwordHash: String): User 7 | fun findByPhone(phone: String): User? 8 | fun findById(id: Int): User? 9 | fun verifyUser(phone: String, checkPassword: (String) -> Boolean): User? 10 | fun lastIndexOfUser(): Int 11 | } -------------------------------------------------------------------------------- /core/common/src/commonMain/kotlin/com/pmb/common/theme/shapes.kt: -------------------------------------------------------------------------------- 1 | package com.pmb.common.theme 2 | 3 | import androidx.compose.foundation.shape.RoundedCornerShape 4 | import androidx.compose.material3.Shapes 5 | import androidx.compose.ui.unit.dp 6 | 7 | val shapes = Shapes( 8 | extraSmall = RoundedCornerShape(4.dp), 9 | small = RoundedCornerShape(8.dp), 10 | medium = RoundedCornerShape(16.dp), 11 | large = RoundedCornerShape(24.dp), 12 | extraLarge = RoundedCornerShape(32.dp), 13 | ) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .kotlin 3 | .gradle 4 | **/build/ 5 | xcuserdata 6 | !src/**/build/ 7 | local.properties 8 | .idea 9 | .idea/ 10 | .idea/* 11 | .build/* 12 | .DS_Store 13 | captures 14 | .externalNativeBuild 15 | .cxx 16 | *.xcodeproj/* 17 | !*.xcodeproj/project.pbxproj 18 | !*.xcodeproj/xcshareddata/ 19 | !*.xcodeproj/project.xcworkspace/ 20 | !*.xcworkspace/contents.xcworkspacedata 21 | **/xcshareddata/WorkspaceSettings.xcsettings 22 | 23 | */kotzilla.json 24 | composeApp/src/commonMain/composeResources/files/kotzilla.key -------------------------------------------------------------------------------- /core/data/src/commonMain/kotlin/model/CreateTransactionRequest.kt: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import kotlinx.serialization.Serializable 4 | import org.milad.expense_share.Amount 5 | 6 | 7 | @Serializable 8 | data class CreateTransactionRequest( 9 | val title: String, 10 | val amount: Amount, 11 | val description: String?, 12 | val payers: List?, 13 | val shareDetails: ShareDetailsRequest?, 14 | ) 15 | @Serializable 16 | data class ShareMemberRequest( 17 | val userId: Int, 18 | val share: Amount 19 | ) 20 | -------------------------------------------------------------------------------- /.run/server.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | -------------------------------------------------------------------------------- /core/domain/src/commonMain/kotlin/repository/GroupsRepository.kt: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import io.mockative.Mockable 4 | import kotlinx.coroutines.flow.Flow 5 | import model.Group 6 | 7 | @Mockable 8 | interface GroupsRepository { 9 | suspend fun getGroups(): Flow>> 10 | suspend fun createGroup(name: String, memberIds: List): Flow> 11 | suspend fun updateGroupMembers(groupId: String, memberIds: List): Flow> 12 | suspend fun deleteGroup(groupId: String): Flow> 13 | } -------------------------------------------------------------------------------- /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 | 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() 17 | } 18 | } 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/org/milad/expense_share/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import androidx.activity.enableEdgeToEdge 7 | 8 | class MainActivity : ComponentActivity() { 9 | override fun onCreate(savedInstanceState: Bundle?) { 10 | enableEdgeToEdge() 11 | super.onCreate(savedInstanceState) 12 | 13 | setContent { 14 | AppEntryPoint() 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/presentation/groups/model/UserGroupResponse.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.presentation.groups.model 2 | 3 | import kotlinx.serialization.Serializable 4 | import org.milad.expense_share.data.models.Transaction 5 | import org.milad.expense_share.data.models.User 6 | 7 | @Serializable 8 | data class UserGroupResponse( 9 | val id: Int, 10 | val name: String, 11 | val ownerId: Int, 12 | val members: List = emptyList(), 13 | val transactions: List = emptyList() 14 | ) 15 | 16 | -------------------------------------------------------------------------------- /composeApp/src/iosMain/kotlin/org/milad/expense_share/MainViewController.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share 2 | 3 | import androidx.compose.ui.window.ComposeUIViewController 4 | import io.kotzilla.sdk.analytics.koin.analytics 5 | import org.koin.core.context.startKoin 6 | import org.koin.core.logger.Level 7 | import org.milad.expense_share.di.appModules 8 | 9 | fun MainViewController() = ComposeUIViewController { 10 | startKoin { 11 | printLogger(Level.DEBUG) 12 | modules(appModules) 13 | analytics() 14 | } 15 | AppEntryPoint() 16 | } -------------------------------------------------------------------------------- /core/network/src/commonMain/kotlin/client/ApiClient.kt: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import io.ktor.client.request.HttpRequestBuilder 4 | import io.ktor.client.statement.HttpResponse 5 | import io.mockative.Mockable 6 | 7 | @Mockable 8 | interface ApiClient { 9 | suspend fun post(builder: HttpRequestBuilder.() -> Unit): HttpResponse 10 | suspend fun get(builder: HttpRequestBuilder.() -> Unit): HttpResponse 11 | suspend fun put(builder: HttpRequestBuilder.() -> Unit): HttpResponse 12 | suspend fun delete(builder: HttpRequestBuilder.() -> Unit): HttpResponse 13 | } -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/data/db/table/Friends.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.data.db.table 2 | 3 | import org.jetbrains.exposed.sql.Table 4 | import org.milad.expense_share.data.models.FriendRelationStatus 5 | 6 | object Friends : Table("friends") { 7 | val id = integer("id").autoIncrement() 8 | val userId = integer("user_id") references Users.id 9 | val friendId = integer("friend_id") references Users.id 10 | val status = enumerationByName("status", 20, FriendRelationStatus::class) 11 | override val primaryKey = PrimaryKey(id) 12 | } -------------------------------------------------------------------------------- /core/network/src/commonMain/kotlin/di/networkModule.kt: -------------------------------------------------------------------------------- 1 | package di 2 | 3 | import NetworkManager 4 | import client.ApiClient 5 | import client.KtorApiClient 6 | import client.createHttpClient 7 | import org.koin.core.module.Module 8 | import org.koin.dsl.module 9 | import token.InMemoryTokenProvider 10 | import token.TokenProvider 11 | 12 | val networkModule: Module = module { 13 | single { InMemoryTokenProvider() as TokenProvider } 14 | single { createHttpClient(get()) } 15 | single { KtorApiClient(get()) as ApiClient } 16 | single { NetworkManager(get()) } 17 | } -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/presentation/groups/model/CreateGroupRequest.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.presentation.groups.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class CreateGroupRequest( 7 | val name: String, 8 | val memberIds: List = emptyList() 9 | ) 10 | 11 | //@Serializable 12 | //data class CreateGroupRequest( 13 | // val title: String, 14 | // val amount: Double, 15 | // val description: String?, 16 | // val payers: List?, 17 | // val splitDetails: ShareDetailsRequest?, 18 | //) -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/domain/repository/GroupRepository.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.domain.repository 2 | 3 | import org.milad.expense_share.presentation.groups.model.UserGroupResponse 4 | 5 | interface GroupRepository { 6 | fun createGroup(ownerId: Int, name: String, memberIds: List): UserGroupResponse 7 | fun addUsersToGroup(ownerId: Int, groupId: Int, memberIds: List): Boolean 8 | fun getUsersOfGroup(groupId: Int): List 9 | fun getGroupsOfUser(userId: Int): List 10 | fun deleteGroup(ownerId: Int, groupId: Int): Boolean 11 | } -------------------------------------------------------------------------------- /composeApp/src/jvmMain/kotlin/org/milad/expense_share/main.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share 2 | 3 | import androidx.compose.ui.window.Window 4 | import androidx.compose.ui.window.application 5 | import org.koin.core.context.GlobalContext.startKoin 6 | import org.koin.core.logger.Level 7 | import org.milad.expense_share.di.appModules 8 | 9 | fun main() = application { 10 | startKoin { 11 | printLogger(Level.INFO) 12 | modules(appModules) 13 | } 14 | Window( 15 | onCloseRequest = ::exitApplication, 16 | title = "ExpenseShare", 17 | ) { 18 | AppEntryPoint() 19 | } 20 | } -------------------------------------------------------------------------------- /composeApp/src/wasmJsMain/kotlin/org/milad/expense_share/main.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share 2 | 3 | import androidx.compose.ui.ExperimentalComposeUiApi 4 | import androidx.compose.ui.window.ComposeViewport 5 | import kotlinx.browser.document 6 | import org.koin.core.context.GlobalContext.startKoin 7 | import org.koin.core.logger.Level 8 | import org.milad.expense_share.di.appModules 9 | 10 | @OptIn(ExperimentalComposeUiApi::class) 11 | fun main() { 12 | startKoin { 13 | printLogger(Level.INFO) 14 | modules(appModules) 15 | } 16 | 17 | ComposeViewport(document.body!!) { 18 | 19 | AppEntryPoint() 20 | } 21 | } -------------------------------------------------------------------------------- /core/domain/src/commonMain/kotlin/repository/FriendsRepository.kt: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import io.mockative.Mockable 4 | import kotlinx.coroutines.flow.Flow 5 | import model.User 6 | 7 | @Mockable 8 | interface FriendsRepository { 9 | suspend fun getFriends(): Flow>> 10 | suspend fun getFriendRequests(): Flow>> 11 | suspend fun sendFriendRequest(phone: String): Flow> 12 | suspend fun acceptFriendRequest(phone: String): Flow> 13 | suspend fun rejectFriendRequest(phone: String): Flow> 14 | suspend fun removeFriend(phone: String): Flow> 15 | } -------------------------------------------------------------------------------- /kotlin-js-store/wasm/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@js-joda/core@3.2.0": 6 | version "3.2.0" 7 | resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273" 8 | integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg== 9 | 10 | ws@8.18.0: 11 | version "8.18.0" 12 | resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc" 13 | integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw== 14 | -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/domain/repository/FriendRepository.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.domain.repository 2 | 3 | import org.milad.expense_share.data.models.User 4 | 5 | interface FriendRepository { 6 | fun sendFriendRequest(fromId: Int, toPhone: String): Boolean 7 | fun removeFriend(userId: Int, friendPhone: String): Boolean 8 | fun getFriends(userId: Int): List 9 | fun rejectFriendRequest(userId: Int, friendPhone: String): Boolean 10 | fun acceptFriendRequest(userId: Int, friendPhone: String): Boolean 11 | fun getIncomingRequests(userId: Int): List 12 | fun getOutgoingRequests(userId: Int): List 13 | } -------------------------------------------------------------------------------- /core/network/src/commonMain/kotlin/token/TokenProvider.kt: -------------------------------------------------------------------------------- 1 | package token 2 | 3 | import io.ktor.client.plugins.auth.providers.BearerTokens 4 | import kotlinx.coroutines.flow.MutableStateFlow 5 | 6 | interface TokenProvider { 7 | fun loadTokens(): BearerTokens? 8 | fun setToken(accessToken: String) 9 | fun clearToken() 10 | } 11 | 12 | internal class InMemoryTokenProvider : TokenProvider { 13 | private val state = MutableStateFlow(null) 14 | 15 | override fun loadTokens(): BearerTokens? = state.value 16 | 17 | override fun setToken(accessToken: String) { 18 | state.value = BearerTokens(accessToken , refreshToken = null) 19 | } 20 | 21 | override fun clearToken() { 22 | state.value = null 23 | } 24 | } -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/org/milad/expense_share/ExpenseShareApp.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share 2 | 3 | import android.app.Application 4 | import io.kotzilla.sdk.analytics.koin.analytics 5 | import org.koin.android.ext.koin.androidContext 6 | import org.koin.android.ext.koin.androidLogger 7 | import org.koin.core.context.GlobalContext.startKoin 8 | import org.koin.core.logger.Level 9 | import org.milad.expense_share.di.appModules 10 | 11 | class ExpenseShareApp : Application() { 12 | override fun onCreate() { 13 | super.onCreate() 14 | startKoin { 15 | androidContext(this@ExpenseShareApp) 16 | androidLogger(Level.DEBUG) 17 | modules(appModules) 18 | 19 | analytics() 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /core/currency/src/commonMain/kotlin/org/milad/expense_share/AmountSerializer.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share 2 | 3 | import kotlinx.serialization.KSerializer 4 | import kotlinx.serialization.descriptors.PrimitiveKind 5 | import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor 6 | import kotlinx.serialization.encoding.Decoder 7 | import kotlinx.serialization.encoding.Encoder 8 | 9 | object AmountSerializer : KSerializer { 10 | override val descriptor = PrimitiveSerialDescriptor("Amount", PrimitiveKind.LONG) 11 | 12 | override fun serialize(encoder: Encoder, value: Amount) { 13 | encoder.encodeLong(value.value) 14 | } 15 | 16 | override fun deserialize(decoder: Decoder): Amount { 17 | return Amount(decoder.decodeLong()) 18 | } 19 | } -------------------------------------------------------------------------------- /core/domain/src/commonMain/kotlin/usecase/transactions/CreateTransactionUseCase.kt: -------------------------------------------------------------------------------- 1 | package usecase.transactions 2 | 3 | import model.PayerDto 4 | import model.ShareDetailsRequest 5 | import org.milad.expense_share.Amount 6 | import repository.TransactionsRepository 7 | 8 | class CreateTransactionUseCase(private val transactionsRepository: TransactionsRepository) { 9 | suspend operator fun invoke( 10 | groupId: Int, 11 | title: String, 12 | amount: Amount, 13 | description: String?, 14 | payers: List?, 15 | shareDetails: ShareDetailsRequest?, 16 | ) = transactionsRepository.createTransaction( 17 | groupId, 18 | title, 19 | amount, 20 | description, 21 | payers, 22 | shareDetails 23 | ) 24 | } -------------------------------------------------------------------------------- /server/src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "app-icon-1024.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | }, 9 | { 10 | "appearances" : [ 11 | { 12 | "appearance" : "luminosity", 13 | "value" : "dark" 14 | } 15 | ], 16 | "idiom" : "universal", 17 | "platform" : "ios", 18 | "size" : "1024x1024" 19 | }, 20 | { 21 | "appearances" : [ 22 | { 23 | "appearance" : "luminosity", 24 | "value" : "tinted" 25 | } 26 | ], 27 | "idiom" : "universal", 28 | "platform" : "ios", 29 | "size" : "1024x1024" 30 | } 31 | ], 32 | "info" : { 33 | "author" : "xcode", 34 | "version" : 1 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/application/configureCORS.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.application 2 | 3 | import io.ktor.http.HttpHeaders 4 | import io.ktor.http.HttpMethod 5 | import io.ktor.server.application.Application 6 | import io.ktor.server.application.install 7 | import io.ktor.server.plugins.cors.routing.CORS 8 | 9 | fun Application.configureCORS() { 10 | install(CORS) { 11 | anyHost() 12 | // USE IN PRODUCTION 13 | // allowHost("XYZ.com", schemes = listOf("https")) 14 | allowHeader(HttpHeaders.ContentType) 15 | allowHeader(HttpHeaders.Authorization) 16 | allowHeader(HttpHeaders.Accept) 17 | allowMethod(HttpMethod.Get) 18 | allowMethod(HttpMethod.Post) 19 | allowMethod(HttpMethod.Put) 20 | allowMethod(HttpMethod.Delete) 21 | allowMethod(HttpMethod.Options) 22 | 23 | allowCredentials = true 24 | allowNonSimpleContentTypes = true 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /core/data/src/commonMain/kotlin/di/dataModule.kt: -------------------------------------------------------------------------------- 1 | package di 2 | 3 | import org.koin.dsl.module 4 | import repository.AuthRepository 5 | import repository.AuthRepositoryImpl 6 | import repository.FriendsRepository 7 | import repository.FriendsRepositoryImpl 8 | import repository.GroupsRepository 9 | import repository.GroupsRepositoryImpl 10 | import repository.TransactionsRepository 11 | import repository.TransactionsRepositoryImpl 12 | import repository.UserRepository 13 | import repository.UserRepositoryImpl 14 | 15 | val dataModule = module { 16 | 17 | single { UserRepositoryImpl() as UserRepository } 18 | single { AuthRepositoryImpl(get(), get(), get()) as AuthRepository } 19 | single { FriendsRepositoryImpl(get()) as FriendsRepository } 20 | single { GroupsRepositoryImpl(get()) as GroupsRepository } 21 | single { TransactionsRepositoryImpl(get()) as TransactionsRepository } 22 | 23 | } 24 | 25 | val dataAggregator = module { 26 | includes(networkModule) 27 | includes(dataModule) 28 | } -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/presentation/transactions/model/CreateTransactionRequest.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.presentation.transactions.model 2 | 3 | import kotlinx.serialization.Serializable 4 | import org.milad.expense_share.Amount 5 | import org.milad.expense_share.data.models.User 6 | 7 | @Serializable 8 | data class CreateTransactionRequest( 9 | val title: String, 10 | val amount: Amount, 11 | val description: String, 12 | val payers: List, 13 | val shareDetails: ShareDetailsRequest 14 | ) 15 | 16 | @Serializable 17 | data class PayerRequest( 18 | val user: User, 19 | val amountPaid: Amount 20 | ) 21 | 22 | @Serializable 23 | data class ShareDetailsRequest( 24 | val type: ShareType, 25 | val members: List = emptyList() 26 | ) 27 | 28 | @Serializable 29 | data class ShareMemberRequest( 30 | val user: User, 31 | val share: Amount 32 | ) 33 | 34 | enum class ShareType { Equal, Percent, Weight, Manual } 35 | -------------------------------------------------------------------------------- /core/domain/src/commonMain/kotlin/repository/TransactionsRepository.kt: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import io.mockative.Mockable 4 | import kotlinx.coroutines.flow.Flow 5 | import model.PayerDto 6 | import model.ShareDetailsRequest 7 | import model.Transaction 8 | import org.milad.expense_share.Amount 9 | 10 | @Mockable 11 | interface TransactionsRepository { 12 | suspend fun getTransactions(groupId: String): Flow>> 13 | suspend fun createTransaction( 14 | groupId: Int, 15 | title: String, 16 | amount: Amount, 17 | description: String?, 18 | payers: List?, 19 | shareDetails: ShareDetailsRequest?, 20 | ): Flow> 21 | 22 | suspend fun approveTransaction(groupId: String, transactionId: String): Flow> 23 | suspend fun rejectTransaction(groupId: String, transactionId: String): Flow> 24 | suspend fun deleteTransaction(groupId: String, transactionId: String): Flow> 25 | } -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/domain/repository/TransactionRepository.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.domain.repository 2 | 3 | import org.milad.expense_share.Amount 4 | import org.milad.expense_share.data.models.Transaction 5 | import org.milad.expense_share.presentation.transactions.model.PayerRequest 6 | import org.milad.expense_share.presentation.transactions.model.ShareDetailsRequest 7 | 8 | interface TransactionRepository { 9 | fun createTransaction( 10 | groupId: Int, 11 | userId: Int, 12 | title: String, 13 | amount: Amount, 14 | description: String, 15 | payers: List, 16 | shareDetails: ShareDetailsRequest 17 | ): Transaction? 18 | 19 | fun getTransactions(userId: Int, groupId: Int): List 20 | fun approveTransaction(transactionId: Int, managerId: Int): Boolean 21 | fun rejectTransaction(transactionId: Int, managerId: Int): Boolean 22 | fun deleteTransaction(transactionId: Int, managerId: Int): Boolean 23 | } 24 | -------------------------------------------------------------------------------- /core/network/src/commonMain/kotlin/client/KtorApiClient.kt: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import io.ktor.client.HttpClient 4 | import io.ktor.client.request.HttpRequestBuilder 5 | import io.ktor.client.request.delete 6 | import io.ktor.client.request.get 7 | import io.ktor.client.request.post 8 | import io.ktor.client.request.put 9 | import io.ktor.client.statement.HttpResponse 10 | 11 | class KtorApiClient(private val httpClient: HttpClient) : ApiClient { 12 | override suspend fun post(builder: HttpRequestBuilder.() -> Unit): HttpResponse { 13 | return httpClient.post(builder) 14 | } 15 | 16 | override suspend fun get(builder: HttpRequestBuilder.() -> Unit): HttpResponse { 17 | return httpClient.get(builder) 18 | } 19 | 20 | override suspend fun put(builder: HttpRequestBuilder.() -> Unit): HttpResponse { 21 | return httpClient.put(builder) 22 | } 23 | 24 | override suspend fun delete(builder: HttpRequestBuilder.() -> Unit): HttpResponse { 25 | return httpClient.delete(builder) 26 | } 27 | } 28 | 29 | -------------------------------------------------------------------------------- /.run/composeApp [wasmJs].run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 16 | 18 | true 19 | true 20 | false 21 | false 22 | 23 | 24 | -------------------------------------------------------------------------------- /core/domain/src/commonMain/kotlin/model/Transaction.kt: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import kotlinx.serialization.Serializable 4 | import org.milad.expense_share.Amount 5 | 6 | @Serializable 7 | data class Transaction( 8 | val id: Int, 9 | val groupId: Int, 10 | val title: String, 11 | val amount: Amount, 12 | val description: String, 13 | val createdBy: Int, 14 | var status: TransactionStatus, 15 | val createdAt: Long, 16 | val transactionDate: Long, 17 | var approvedBy: Int? = null, 18 | val payers: List, 19 | val shareDetails: ShareDetailsRequest, 20 | ) 21 | 22 | @Serializable 23 | enum class TransactionStatus { 24 | PENDING, 25 | APPROVED, 26 | REJECTED 27 | } 28 | 29 | @Serializable 30 | data class PayerDto( 31 | val user: User, 32 | val amountPaid: Amount, 33 | ) 34 | 35 | @Serializable 36 | data class ShareDetailsRequest( 37 | val type: String, 38 | val members: List, 39 | ) 40 | 41 | @Serializable 42 | data class MemberShareDto( 43 | val user: User, 44 | val share: Amount = Amount(0), 45 | ) -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/data/inMemoryRepository/InMemoryUserRepository.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.data.inMemoryRepository 2 | 3 | import org.milad.expense_share.data.db.FakeDatabase.users 4 | import org.milad.expense_share.data.models.User 5 | import org.milad.expense_share.domain.repository.UserRepository 6 | 7 | class InMemoryUserRepository : UserRepository { 8 | override fun create(user: User, passwordHash: String): User { 9 | users.add(user to passwordHash) 10 | return user 11 | } 12 | 13 | override fun findByPhone(phone: String): User? { 14 | return users.firstOrNull { it.first.phone == phone }?.first 15 | } 16 | 17 | override fun findById(id: Int): User? { 18 | return users.firstOrNull { it.first.id == id }?.first 19 | } 20 | 21 | override fun verifyUser(phone: String, checkPassword: (String) -> Boolean): User? { 22 | return users.find { it.first.phone == phone && checkPassword(it.second) }?.first 23 | } 24 | 25 | override fun lastIndexOfUser(): Int { 26 | return users.size 27 | } 28 | } -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/data/models/Transaction.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.data.models 2 | 3 | import kotlinx.serialization.Serializable 4 | import org.milad.expense_share.Amount 5 | import org.milad.expense_share.presentation.transactions.model.PayerRequest 6 | import org.milad.expense_share.presentation.transactions.model.ShareDetailsRequest 7 | 8 | @Serializable 9 | data class Transaction( 10 | val id: Int, 11 | val groupId: Int, 12 | val title: String, 13 | val amount: Amount, 14 | val description: String, 15 | val createdBy: Int, 16 | var status: TransactionStatus = TransactionStatus.PENDING, 17 | val createdAt: Long = System.currentTimeMillis(), 18 | val transactionDate: Long = System.currentTimeMillis(), 19 | var approvedBy: Int? = null, 20 | val payers: List? = null, 21 | val shareDetails: ShareDetailsRequest? = null 22 | ) 23 | //io.ktor.serialization.JsonConvertException: Illegal input: Field 'splitDetails' is required for type with serial name 'model.Transaction', but it was missing at path: $.data[13].transactions[0] -------------------------------------------------------------------------------- /.run/composeApp [jvm].run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 16 | 18 | false 19 | true 20 | false 21 | false 22 | 23 | 24 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 15 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /core/data/src/commonMain/kotlin/repository/GroupsRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import NetworkManager 4 | import kotlinx.coroutines.flow.Flow 5 | import model.CreateGroupRequest 6 | import model.Group 7 | 8 | 9 | class GroupsRepositoryImpl(private val networkManager: NetworkManager) : GroupsRepository { 10 | override suspend fun getGroups(): Flow>> { 11 | return networkManager.get>("/groups") 12 | } 13 | 14 | override suspend fun createGroup( 15 | name: String, 16 | memberIds: List 17 | ): Flow> { 18 | return networkManager.post("/groups/create", body = CreateGroupRequest(name, memberIds)) 19 | } 20 | 21 | override suspend fun updateGroupMembers( 22 | groupId: String, 23 | memberIds: List 24 | ): Flow> { 25 | return networkManager.put("/groups/$groupId/updateMembers", body = memberIds) 26 | } 27 | 28 | override suspend fun deleteGroup(groupId: String): Flow> { 29 | return networkManager.delete("/groups/$groupId") 30 | } 31 | } -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/application/configureStatusPages.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.application 2 | 3 | import io.ktor.server.application.* 4 | import io.ktor.server.plugins.statuspages.* 5 | import io.ktor.server.response.* 6 | import io.ktor.http.* 7 | import org.milad.expense_share.presentation.api_model.ErrorResponse 8 | 9 | fun Application.configureStatusPages() { 10 | install(StatusPages) { 11 | exception { call, cause -> 12 | call.respond( 13 | HttpStatusCode.InternalServerError, 14 | ErrorResponse( 15 | message = "Internal server error", 16 | code = "INTERNAL_ERROR", 17 | details = mapOf("error" to cause.localizedMessage) 18 | ) 19 | ) 20 | cause.printStackTrace() 21 | } 22 | 23 | status(HttpStatusCode.NotFound) { call, status -> 24 | call.respond( 25 | status, 26 | ErrorResponse("Resource not found", "NOT_FOUND") 27 | ) 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/application/configureSecurity.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.application 2 | 3 | import com.auth0.jwt.JWT 4 | import io.ktor.server.application.Application 5 | import io.ktor.server.application.install 6 | import io.ktor.server.auth.Authentication 7 | import io.ktor.server.auth.jwt.JWTPrincipal 8 | import io.ktor.server.auth.jwt.jwt 9 | import org.milad.expense_share.security.JwtConfig 10 | 11 | fun Application.configureSecurity() { 12 | 13 | JwtConfig.init(environment.config) 14 | 15 | install(Authentication) { 16 | jwt("auth-jwt") { 17 | realm = JwtConfig.getRealm() 18 | verifier( 19 | JWT 20 | .require(JwtConfig.getAlgorithm()) 21 | .withIssuer(JwtConfig.getIssuer()) 22 | .build() 23 | ) 24 | validate { credential -> 25 | if (credential.payload.getClaim("phone").asString().isNotEmpty()) { 26 | JWTPrincipal(credential.payload) 27 | } else null 28 | } 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/Application.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share 2 | 3 | import io.ktor.serialization.kotlinx.json.json 4 | import io.ktor.server.application.Application 5 | import io.ktor.server.application.install 6 | import io.ktor.server.plugins.calllogging.CallLogging 7 | import io.ktor.server.plugins.contentnegotiation.ContentNegotiation 8 | import org.milad.expense_share.application.configureCORS 9 | import org.milad.expense_share.application.configureKoin 10 | import org.milad.expense_share.application.configureRouting 11 | import org.milad.expense_share.application.configureSecurity 12 | import org.milad.expense_share.application.configureStatusPages 13 | import org.milad.expense_share.data.db.DatabaseFactory 14 | import org.slf4j.event.Level 15 | 16 | fun Application.main() { 17 | install(ContentNegotiation) { 18 | json() 19 | } 20 | install(CallLogging) { 21 | level = Level.INFO 22 | filter { call -> true } 23 | } 24 | 25 | DatabaseFactory.init() 26 | 27 | configureCORS() 28 | configureStatusPages() 29 | configureSecurity() 30 | configureKoin() 31 | configureRouting() 32 | } -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "ExpenseShare" 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 | plugins { 32 | id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" 33 | } 34 | 35 | include(":composeApp") 36 | 37 | include(":server") 38 | include(":core:network") 39 | include(":core:data") 40 | include(":core:domain") 41 | include(":core:common") 42 | include(":core:navigation") 43 | include(":core:currency") 44 | -------------------------------------------------------------------------------- /.run/kotest.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 16 | 21 | 23 | false 24 | true 25 | false 26 | true 27 | 28 | 29 | -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/application/configureRouting.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.application 2 | 3 | import io.ktor.server.application.Application 4 | import io.ktor.server.routing.routing 5 | import org.koin.ktor.ext.inject 6 | import org.milad.expense_share.domain.service.AuthService 7 | import org.milad.expense_share.domain.service.FriendsService 8 | import org.milad.expense_share.domain.service.GroupService 9 | import org.milad.expense_share.domain.service.TransactionService 10 | import org.milad.expense_share.presentation.auth.authRoutes 11 | import org.milad.expense_share.presentation.friends.friendRoutes 12 | import org.milad.expense_share.presentation.groups.groupsRoutes 13 | import org.milad.expense_share.presentation.transactions.transactionsRoutes 14 | 15 | internal fun Application.configureRouting() { 16 | val authService by inject() 17 | val groupService by inject() 18 | val transactionService by inject() 19 | val friendsService by inject() 20 | 21 | routing { 22 | authRoutes(authService) 23 | groupsRoutes(groupService) 24 | transactionsRoutes(transactionService) 25 | friendRoutes(friendsService) 26 | } 27 | } -------------------------------------------------------------------------------- /core/data/src/commonMain/kotlin/repository/FriendsRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import NetworkManager 4 | import kotlinx.coroutines.flow.Flow 5 | import model.User 6 | 7 | 8 | class FriendsRepositoryImpl(private val networkManager: NetworkManager) : FriendsRepository { 9 | override suspend fun getFriends(): Flow>> { 10 | return networkManager.get>("/friends") 11 | } 12 | 13 | override suspend fun getFriendRequests(): Flow>> { 14 | return networkManager.get>("/friends/requests") 15 | } 16 | 17 | override suspend fun sendFriendRequest(phone: String): Flow> { 18 | return networkManager.post("/friends/request", body = phone) 19 | } 20 | 21 | override suspend fun acceptFriendRequest(phone: String): Flow> { 22 | return networkManager.post("/friends/accept", body = phone) 23 | } 24 | 25 | override suspend fun rejectFriendRequest(phone: String): Flow> { 26 | return networkManager.post("/friends/reject", body = phone) 27 | } 28 | 29 | override suspend fun removeFriend(phone: String): Flow> { 30 | return networkManager.delete("/friends/$phone") 31 | } 32 | } -------------------------------------------------------------------------------- /core/common/src/commonMain/kotlin/com/pmb/common/ui/scaffold/navigation/BottomNavigationBar.kt: -------------------------------------------------------------------------------- 1 | package com.pmb.common.ui.scaffold.navigation 2 | 3 | import androidx.compose.foundation.layout.fillMaxWidth 4 | import androidx.compose.material3.Icon 5 | import androidx.compose.material3.NavigationBar 6 | import androidx.compose.material3.NavigationBarItem 7 | import androidx.compose.material3.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Modifier 10 | import com.pmb.common.ui.scaffold.NavItem 11 | 12 | /** 13 | * Bottom navigation bar for compact screen sizes 14 | */ 15 | @Composable 16 | fun BottomNavigationBar( 17 | selectedItem: NavItem, 18 | onItemSelected: (NavItem) -> Unit, 19 | modifier: Modifier = Modifier 20 | ) { 21 | NavigationBar(modifier = modifier.fillMaxWidth()) { 22 | NavItem.entries.forEach { navItem -> 23 | NavigationBarItem( 24 | selected = selectedItem == navItem, 25 | onClick = { onItemSelected(navItem) }, 26 | icon = { 27 | Icon( 28 | imageVector = navItem.icon, 29 | contentDescription = navItem.title 30 | ) 31 | }, 32 | label = { Text(navItem.title) } 33 | ) 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/di/appModule.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.di 2 | 3 | import org.koin.dsl.module 4 | import org.milad.expense_share.data.repository.FriendRepositoryImpl 5 | import org.milad.expense_share.data.repository.GroupRepositoryImpl 6 | import org.milad.expense_share.data.repository.TransactionRepositoryImpl 7 | import org.milad.expense_share.data.repository.UserRepositoryImpl 8 | import org.milad.expense_share.domain.repository.FriendRepository 9 | import org.milad.expense_share.domain.repository.GroupRepository 10 | import org.milad.expense_share.domain.repository.TransactionRepository 11 | import org.milad.expense_share.domain.repository.UserRepository 12 | import org.milad.expense_share.domain.service.AuthService 13 | import org.milad.expense_share.domain.service.FriendsService 14 | import org.milad.expense_share.domain.service.GroupService 15 | import org.milad.expense_share.domain.service.TransactionService 16 | 17 | val appModule = module { 18 | 19 | single { FriendRepositoryImpl() as FriendRepository } 20 | single { UserRepositoryImpl() as UserRepository } 21 | single { GroupRepositoryImpl() as GroupRepository } 22 | single { TransactionRepositoryImpl() as TransactionRepository } 23 | 24 | single { AuthService(get()) } 25 | single { FriendsService(get()) } 26 | single { GroupService(get(), get(), get()) } 27 | single { TransactionService(get()) } 28 | } -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/utils/extension.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.utils 2 | 3 | import io.ktor.server.auth.jwt.JWTPrincipal 4 | import org.milad.expense_share.presentation.auth.model.LoginRequest 5 | import org.milad.expense_share.presentation.auth.model.RegisterRequest 6 | 7 | internal fun JWTPrincipal?.getUserId(): Int? { 8 | return this?.payload?.getClaim("id")?.asInt() 9 | } 10 | 11 | internal fun io.ktor.server.application.ApplicationCall.getIntParameter(name: String): Int? { 12 | return parameters[name]?.toIntOrNull() 13 | } 14 | internal fun io.ktor.server.application.ApplicationCall.getStringParameter(name: String): String? { 15 | return parameters[name]?.takeIf { it.isNotBlank() } 16 | } 17 | 18 | internal fun RegisterRequest.validate(): String? { 19 | return when { 20 | username.isBlank() -> "Username is required" 21 | username.length < 3 -> "Username must be at least 3 characters" 22 | phone.isBlank() -> "Phone number is required" 23 | phone.length < 10 -> "Phone number must be at least 10 digits" 24 | password.isBlank() -> "Password is required" 25 | password.length < 4 -> "Password must be at least 4 characters" 26 | else -> null 27 | } 28 | } 29 | 30 | internal fun LoginRequest.validate(): String? { 31 | return when { 32 | phone.isBlank() -> "Phone number is required" 33 | password.isBlank() -> "Password is required" 34 | else -> null 35 | } 36 | } -------------------------------------------------------------------------------- /core/domain/src/commonTest/kotlin/usecase/friends/AcceptFriendRequestUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | package usecase.friends 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.shouldBe 5 | import io.mockative.coEvery 6 | import io.mockative.coVerify 7 | import io.mockative.mock 8 | import io.mockative.of 9 | import kotlinx.coroutines.flow.first 10 | import kotlinx.coroutines.flow.flowOf 11 | import repository.FriendsRepository 12 | 13 | class AcceptFriendRequestUseCaseTest : StringSpec({ 14 | 15 | val repo = mock(of()) 16 | val useCase = AcceptFriendRequestUseCase(repo) 17 | 18 | "should succeed when friend request is accepted" { 19 | // Arrange 20 | coEvery { repo.acceptFriendRequest("0912000000") } returns flowOf(Result.success(Unit)) 21 | 22 | // Act 23 | val result = useCase("0912000000").first() 24 | 25 | // Assert 26 | result.isSuccess shouldBe true 27 | coVerify { repo.acceptFriendRequest("0912000000") } 28 | } 29 | 30 | "should fail when accepting friend request fails" { 31 | val error = RuntimeException("accept failed") 32 | 33 | coEvery { repo.acceptFriendRequest("0912000000") } returns flowOf(Result.failure(error)) 34 | 35 | val result = useCase("0912000000").first() 36 | 37 | result.isFailure shouldBe true 38 | result.exceptionOrNull() shouldBe error 39 | coVerify { repo.acceptFriendRequest("0912000000") } 40 | } 41 | }) 42 | -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/security/JwtConfig.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.security 2 | 3 | import com.auth0.jwt.JWT 4 | import com.auth0.jwt.algorithms.Algorithm 5 | import io.ktor.server.config.ApplicationConfig 6 | import org.milad.expense_share.data.models.User 7 | import java.util.Date 8 | 9 | object JwtConfig { 10 | private lateinit var secret: String 11 | private lateinit var issuer: String 12 | private lateinit var realm: String 13 | private var validityInMs: Long = 0 14 | 15 | private lateinit var algorithm: Algorithm 16 | 17 | fun init(config: ApplicationConfig) { 18 | 19 | secret = config.property("jwt.secret").getString() 20 | issuer = config.property("jwt.issuer").getString() 21 | realm = config.property("jwt.realm").getString() 22 | validityInMs = config.property("jwt.validityMs").getString().toLong() 23 | 24 | algorithm = Algorithm.HMAC256(secret) 25 | } 26 | 27 | fun generateToken(user: User): String { 28 | return JWT.create() 29 | .withSubject("Authentication") 30 | .withIssuer(issuer) 31 | .withClaim("id", user.id) 32 | .withClaim("username", user.username) 33 | .withClaim("phone", user.phone) 34 | .withExpiresAt(Date(System.currentTimeMillis() + validityInMs)) 35 | .sign(algorithm) 36 | } 37 | fun getRealm() = realm 38 | fun getAlgorithm() = algorithm 39 | fun getIssuer() = issuer 40 | } -------------------------------------------------------------------------------- /core/domain/src/commonTest/kotlin/usecase/friends/RemoveFriendUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | package usecase.friends 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.shouldBe 5 | import io.mockative.coEvery 6 | import io.mockative.coVerify 7 | import io.mockative.mock 8 | import io.mockative.of 9 | import kotlinx.coroutines.flow.first 10 | import kotlinx.coroutines.flow.flowOf 11 | import repository.FriendsRepository 12 | 13 | class RemoveFriendUseCaseTest : StringSpec({ 14 | 15 | val repo = mock(of()) 16 | 17 | val useCase = RemoveFriendUseCase(repo) 18 | 19 | "should return Unit when successful" { 20 | // Arrange 21 | coEvery { repo.removeFriend("09123456778") } 22 | .returns(flowOf(Result.success(Unit))) 23 | 24 | // Act 25 | val result = useCase("09123456778").first() 26 | 27 | // Assert 28 | result.isSuccess shouldBe true 29 | result.getOrNull() shouldBe Unit 30 | coVerify { repo.removeFriend("09123456778") }.wasInvoked(exactly = 1) 31 | } 32 | 33 | "should return failure when repository fails" { 34 | val error = RuntimeException("network error") 35 | coEvery { repo.removeFriend("09123456778") } 36 | .returns(flowOf(Result.failure(error))) 37 | 38 | val result = useCase("09123456778").first() 39 | 40 | result.isFailure shouldBe true 41 | result.exceptionOrNull() shouldBe error 42 | coVerify { repo.removeFriend("09123456778") }.wasInvoked(exactly = 1) 43 | } 44 | }) 45 | -------------------------------------------------------------------------------- /core/data/src/commonTest/kotlin/DataModuleTest.kt: -------------------------------------------------------------------------------- 1 | import di.dataAggregator 2 | import io.kotest.core.spec.style.StringSpec 3 | import io.kotest.koin.KoinExtension 4 | import io.kotest.matchers.shouldBe 5 | import io.kotest.matchers.shouldNotBe 6 | import repository.AuthRepository 7 | import repository.FriendsRepository 8 | import repository.GroupsRepository 9 | import repository.TransactionsRepository 10 | 11 | import org.koin.test.KoinTest 12 | import org.koin.test.inject 13 | 14 | class DataModuleTest : KoinTest, StringSpec() { 15 | init { 16 | extension(KoinExtension(module = dataAggregator)) 17 | 18 | "AuthRepository should be injected correctly" { 19 | val repo: AuthRepository by inject() 20 | repo shouldNotBe null 21 | } 22 | 23 | "FriendsRepository should be injected correctly" { 24 | val repo: FriendsRepository by inject() 25 | repo shouldNotBe null 26 | } 27 | 28 | "GroupsRepository should be injected correctly" { 29 | val repo: GroupsRepository by inject() 30 | repo shouldNotBe null 31 | } 32 | 33 | "TransactionsRepository should be injected correctly" { 34 | val repo: TransactionsRepository by inject() 35 | repo shouldNotBe null 36 | } 37 | 38 | "All repositories should be single instances" { 39 | val authRepo1: AuthRepository by inject() 40 | val authRepo2: AuthRepository by inject() 41 | authRepo1 shouldBe authRepo2 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /core/domain/src/commonTest/kotlin/usecase/friends/SendFriendRequestUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | package usecase.friends 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.shouldBe 5 | import io.mockative.coEvery 6 | import io.mockative.coVerify 7 | import io.mockative.mock 8 | import io.mockative.of 9 | import kotlinx.coroutines.flow.first 10 | import kotlinx.coroutines.flow.flowOf 11 | import repository.FriendsRepository 12 | 13 | class SendFriendRequestUseCaseTest : StringSpec({ 14 | 15 | val repo = mock(of()) 16 | val useCase = SendFriendRequestUseCase(repo) 17 | val phone = "09131234556" 18 | 19 | "should return unit when successful" { 20 | 21 | // Arrange 22 | coEvery { repo.sendFriendRequest(phone) } 23 | .returns(flowOf(Result.success(Unit))) 24 | 25 | // Act 26 | val result = useCase(phone).first() 27 | 28 | // Assert 29 | result.isSuccess shouldBe true 30 | result.getOrNull() shouldBe Unit 31 | coVerify { repo.sendFriendRequest(phone) }.wasInvoked(exactly = 1) 32 | } 33 | 34 | "should return failure when repository fails" { 35 | 36 | val error = RuntimeException("network error") 37 | coEvery { repo.sendFriendRequest(phone) } 38 | .returns(flowOf(Result.failure(error))) 39 | 40 | val result = useCase(phone).first() 41 | 42 | result.isFailure shouldBe true 43 | result.exceptionOrNull() shouldBe error 44 | coVerify { repo.sendFriendRequest(phone) }.wasInvoked(exactly = 1) 45 | } 46 | }) -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/data/db/FakeDatabase.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.data.db 2 | 3 | import org.milad.expense_share.data.models.FriendRelation 4 | import org.milad.expense_share.data.models.FriendRelationStatus 5 | import org.milad.expense_share.data.models.Group 6 | import org.milad.expense_share.data.models.GroupMember 7 | import org.milad.expense_share.data.models.Transaction 8 | import org.milad.expense_share.data.models.User 9 | 10 | object FakeDatabase { 11 | val users = mutableListOf>( 12 | User(1, "Milad", "09120000001") to "pass1", 13 | User(2, "Sara", "09120000002") to "pass2", 14 | User(3, "Reza", "09120000003") to "pass3", 15 | User(4, "Niloofar", "09120000004") to "pass4" 16 | ) 17 | val groups = mutableListOf( 18 | Group(id = 1, name = "Trip to North", ownerId = 1), 19 | Group(id = 2, name = "Work Lunch", ownerId = 2) 20 | ) 21 | val groupMembers = mutableListOf( 22 | GroupMember(groupId = 1, userId = 1), 23 | GroupMember(groupId = 1, userId = 2), 24 | GroupMember(groupId = 1, userId = 3), 25 | GroupMember(groupId = 2, userId = 2), 26 | GroupMember(groupId = 2, userId = 4) 27 | ) 28 | val friends = mutableListOf( 29 | FriendRelation(1, 2, FriendRelationStatus.ACCEPTED), 30 | FriendRelation(1, 3, FriendRelationStatus.ACCEPTED), 31 | FriendRelation(2, 4, FriendRelationStatus.PENDING) 32 | ) 33 | val transactions = mutableListOf( 34 | 35 | ) 36 | } -------------------------------------------------------------------------------- /core/domain/src/commonTest/kotlin/usecase/friends/RejectFriendRequestUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | package usecase.friends 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.shouldBe 5 | import io.mockative.coEvery 6 | import io.mockative.coVerify 7 | import io.mockative.mock 8 | import io.mockative.of 9 | import kotlinx.coroutines.flow.first 10 | import kotlinx.coroutines.flow.flowOf 11 | import repository.FriendsRepository 12 | 13 | class RejectFriendRequestUseCaseTest : StringSpec({ 14 | 15 | val repo = mock(of()) 16 | 17 | val useCase = RejectFriendRequestUseCase(repo) 18 | 19 | "should return Unit when successful" { 20 | // Arrange 21 | 22 | coEvery { repo.rejectFriendRequest("09123456778") } 23 | .returns(flowOf(Result.success(Unit))) 24 | 25 | // Act 26 | val result = useCase("09123456778").first() 27 | 28 | // Assert 29 | result.isSuccess shouldBe true 30 | result.getOrNull() shouldBe Unit 31 | coVerify { repo.rejectFriendRequest("09123456778") }.wasInvoked(exactly = 1) 32 | } 33 | 34 | "should return failure when repository fails" { 35 | val error = RuntimeException("network error") 36 | coEvery { repo.rejectFriendRequest("09123456778") } 37 | .returns(flowOf(Result.failure(error))) 38 | 39 | val result = useCase("09123456778").first() 40 | 41 | result.isFailure shouldBe true 42 | result.exceptionOrNull() shouldBe error 43 | coVerify { repo.rejectFriendRequest("09123456778") }.wasInvoked(exactly = 1) 44 | } 45 | }) 46 | -------------------------------------------------------------------------------- /core/domain/src/commonTest/kotlin/usecase/friends/GetFriendsUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | package usecase.friends 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.shouldBe 5 | import io.mockative.coEvery 6 | import io.mockative.coVerify 7 | import io.mockative.mock 8 | import io.mockative.of 9 | import kotlinx.coroutines.flow.first 10 | import kotlinx.coroutines.flow.flowOf 11 | import model.User 12 | import repository.FriendsRepository 13 | 14 | class GetFriendsUseCaseTest : StringSpec({ 15 | 16 | val repo = mock(of()) 17 | 18 | val useCase = GetFriendsUseCase(repo) 19 | 20 | "should return list of friend when successful" { 21 | // Arrange 22 | val users = listOf( 23 | User(0, "milad", "09123456778"), 24 | User(10, "mahdi", "09123456779") 25 | ) 26 | 27 | coEvery { repo.getFriends() } 28 | .returns(flowOf(Result.success(users))) 29 | 30 | // Act 31 | val result = useCase().first() 32 | 33 | // Assert 34 | result.isSuccess shouldBe true 35 | result.getOrNull() shouldBe users 36 | coVerify { repo.getFriends() }.wasInvoked(exactly = 1) 37 | } 38 | 39 | "should return failure when repository fails" { 40 | val error = RuntimeException("network error") 41 | coEvery { repo.getFriends() } 42 | .returns(flowOf(Result.failure(error))) 43 | 44 | val result = useCase().first() 45 | 46 | result.isFailure shouldBe true 47 | result.exceptionOrNull() shouldBe error 48 | coVerify { repo.getFriends() }.wasInvoked(exactly = 1) 49 | } 50 | }) 51 | -------------------------------------------------------------------------------- /core/network/src/commonTest/kotlin/NetworkModuleTest.kt: -------------------------------------------------------------------------------- 1 | import client.ApiClient 2 | import client.KtorApiClient 3 | import di.networkModule 4 | import io.kotest.core.spec.style.StringSpec 5 | import io.kotest.koin.KoinExtension 6 | import io.kotest.matchers.shouldBe 7 | import io.kotest.matchers.shouldNotBe 8 | import io.ktor.client.HttpClient 9 | import org.koin.core.context.stopKoin 10 | import org.koin.test.KoinTest 11 | import org.koin.test.inject 12 | import token.TokenProvider 13 | 14 | class NetworkModuleTest : KoinTest, StringSpec() { 15 | init { 16 | extension(KoinExtension(module = networkModule)) 17 | 18 | "TokenProvider should be injected correctly" { 19 | val tokenProvider: TokenProvider by inject() 20 | tokenProvider shouldNotBe null 21 | } 22 | 23 | "HttpClient should be injected correctly" { 24 | val httpClient: HttpClient by inject() 25 | httpClient shouldNotBe null 26 | } 27 | 28 | "ApiClient should be injected as KtorApiClient" { 29 | val apiClient: ApiClient by inject() 30 | apiClient shouldNotBe null 31 | apiClient::class shouldBe KtorApiClient::class 32 | } 33 | 34 | "NetworkManager should be injected correctly" { 35 | val networkManager: NetworkManager by inject() 36 | networkManager shouldNotBe null 37 | } 38 | 39 | "All components should be single instances" { 40 | val networkManager1: NetworkManager by inject() 41 | val networkManager2: NetworkManager by inject() 42 | networkManager1 shouldBe networkManager2 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /core/domain/src/commonTest/kotlin/usecase/groups/DeleteGroupUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | package usecase.groups 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.shouldBe 5 | import io.mockative.coEvery 6 | import io.mockative.coVerify 7 | import io.mockative.mock 8 | import io.mockative.of 9 | import kotlinx.coroutines.flow.first 10 | import kotlinx.coroutines.flow.flowOf 11 | import repository.GroupsRepository 12 | 13 | class DeleteGroupUseCaseTest : StringSpec({ 14 | val repo = mock(of()) 15 | val usecase = DeleteGroupUseCase(repo) 16 | 17 | "should complete successfully when repository succeeds" { 18 | // Arrange 19 | val groupId = "0" 20 | 21 | coEvery { repo.deleteGroup(groupId) } 22 | .returns(flowOf(Result.success(Unit))) 23 | 24 | // Act 25 | val result = usecase(groupId).first() 26 | 27 | // Assert 28 | result.isSuccess shouldBe true 29 | result.getOrNull() shouldBe Unit 30 | coVerify { repo.deleteGroup(groupId) } 31 | .wasInvoked(exactly = 1) 32 | } 33 | "should return failure when repository fails" { 34 | // Arrange 35 | val groupId = "0" 36 | val error = RuntimeException("network error") 37 | 38 | coEvery { repo.deleteGroup(groupId) } 39 | .returns(flowOf(Result.failure(error))) 40 | 41 | // Act 42 | val result = usecase(groupId).first() 43 | 44 | // Assert 45 | result.isSuccess shouldBe false 46 | result.exceptionOrNull() shouldBe error 47 | coVerify { repo.deleteGroup(groupId) } 48 | .wasInvoked(exactly = 1) 49 | } 50 | }) -------------------------------------------------------------------------------- /core/currency/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl 2 | import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig 3 | 4 | plugins { 5 | alias(libs.plugins.kotlinMultiplatform) 6 | alias(libs.plugins.androidMultiplatformLibrary) 7 | kotlin("plugin.serialization") version "2.2.21" 8 | } 9 | 10 | kotlin { 11 | androidLibrary { 12 | namespace = "org.milad.expense_share.currency" 13 | compileSdk = libs.versions.android.compileSdk.get().toInt() 14 | minSdk = libs.versions.android.minSdk.get().toInt() 15 | } 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 | jvm() 29 | 30 | @OptIn(ExperimentalWasmDsl::class) 31 | wasmJs { 32 | browser { 33 | 34 | val rootDirPath = project.rootDir.path 35 | val projectDirPath = project.projectDir.path 36 | commonWebpackConfig { 37 | devServer = (devServer ?: KotlinWebpackConfig.DevServer()).apply { 38 | static = (static ?: mutableListOf()).apply { 39 | // Serve sources to debug inside browser 40 | add(rootDirPath) 41 | add(projectDirPath) 42 | } 43 | } 44 | } 45 | } 46 | } 47 | sourceSets { 48 | commonMain.dependencies { 49 | implementation(libs.kotlinx.serialization.json) 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/org/milad/expense_share/auth/AuthNavHost.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.auth 2 | 3 | import androidx.compose.animation.EnterTransition 4 | import androidx.compose.animation.ExitTransition 5 | import androidx.compose.runtime.Composable 6 | import androidx.navigation.compose.NavHost 7 | import androidx.navigation.compose.composable 8 | import androidx.navigation.compose.rememberNavController 9 | import com.milad.navigation.AuthRoute 10 | import org.milad.expense_share.auth.login.LoginScreen 11 | import org.milad.expense_share.auth.register.RegisterScreen 12 | 13 | @Composable 14 | fun AuthNavHost( 15 | onAuthSuccess: () -> Unit, 16 | ) { 17 | val navController = rememberNavController() 18 | 19 | NavHost( 20 | navController = navController, 21 | startDestination = AuthRoute.Login, 22 | enterTransition = { EnterTransition.None }, 23 | exitTransition = { ExitTransition.None }, 24 | popEnterTransition = { EnterTransition.None }, 25 | popExitTransition = { ExitTransition.None }, 26 | ) { 27 | composable { 28 | LoginScreen( 29 | onLoginSuccess = onAuthSuccess, 30 | showBackButton = false, 31 | onNavigateToRegister = { 32 | navController.navigate(AuthRoute.Register) 33 | } 34 | ) 35 | } 36 | 37 | composable { 38 | RegisterScreen( 39 | onRegisterSuccess = onAuthSuccess, 40 | onNavigateToLogin = { 41 | navController.popBackStack() 42 | } 43 | ) 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /core/domain/src/commonTest/kotlin/usecase/friends/GetFriendRequestsUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | package usecase.friends 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.shouldBe 5 | import io.mockative.coEvery 6 | import io.mockative.coVerify 7 | import io.mockative.mock 8 | import io.mockative.of 9 | import kotlinx.coroutines.flow.first 10 | import kotlinx.coroutines.flow.flowOf 11 | import model.User 12 | import repository.FriendsRepository 13 | 14 | class GetFriendRequestsUseCaseTest : StringSpec({ 15 | 16 | val repo = mock(of()) 17 | 18 | val useCase = GetFriendRequestsUseCase(repo) 19 | 20 | "should return list of friend requests when successful" { 21 | // Arrange 22 | val users = listOf( 23 | User(0, "milad", "09123456778"), 24 | User(10, "mahdi", "09123456779") 25 | ); 26 | 27 | coEvery { repo.getFriendRequests() } 28 | .returns(flowOf(Result.success(users))) 29 | 30 | // Act 31 | val result = useCase().first() 32 | 33 | // Assert 34 | result.isSuccess shouldBe true 35 | result.getOrNull() shouldBe users 36 | coVerify { repo.getFriendRequests() }.wasInvoked(exactly = 1) 37 | } 38 | 39 | "should return failure when repository fails" { 40 | val error = RuntimeException("network error") 41 | coEvery { repo.getFriendRequests() } 42 | .returns(flowOf(Result.failure(error))) 43 | 44 | val result = useCase().first() 45 | 46 | result.isFailure shouldBe true 47 | result.exceptionOrNull() shouldBe error 48 | coVerify { repo.getFriendRequests() }.wasInvoked(exactly = 1) 49 | } 50 | }) 51 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml: -------------------------------------------------------------------------------- 1 | 8 | 11 | 14 | 16 | 17 | 22 | 23 | 24 | 25 | 26 | 27 | 29 | 30 | 36 | 37 | 38 | 39 | 40 | 41 | 44 | -------------------------------------------------------------------------------- /core/common/src/commonMain/composeResources/drawable/compose-multiplatform.xml: -------------------------------------------------------------------------------- 1 | 8 | 11 | 14 | 16 | 17 | 22 | 23 | 24 | 25 | 26 | 27 | 29 | 30 | 36 | 37 | 38 | 39 | 40 | 41 | 44 | -------------------------------------------------------------------------------- /core/common/src/commonMain/kotlin/com/pmb/common/ui/scaffold/NavItem.kt: -------------------------------------------------------------------------------- 1 | package com.pmb.common.ui.scaffold 2 | 3 | import androidx.compose.material.icons.Icons 4 | import androidx.compose.material.icons.filled.Dashboard 5 | import androidx.compose.material.icons.filled.People 6 | import androidx.compose.material.icons.filled.Settings 7 | import androidx.compose.material3.adaptive.WindowAdaptiveInfo 8 | import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.graphics.vector.ImageVector 11 | import androidx.compose.ui.unit.Dp 12 | import androidx.compose.ui.unit.dp 13 | import androidx.window.core.layout.WindowHeightSizeClass 14 | import androidx.window.core.layout.WindowSizeClass 15 | import androidx.window.core.layout.WindowWidthSizeClass 16 | 17 | enum class NavItem(val title: String, val icon: ImageVector) { 18 | Dashboard("Dashboard", Icons.Default.Dashboard), 19 | Friends("Friends", Icons.Default.People), 20 | Profile("Profile", Icons.Default.Settings) 21 | } 22 | 23 | @Composable 24 | fun calculateAppScreenSize(adaptiveInfo: WindowAdaptiveInfo, windowWidth: Dp) = when { 25 | adaptiveInfo.windowPosture.isTabletop -> NavigationSuiteType.NavigationBar 26 | adaptiveInfo.windowSizeClass.isCompact() -> NavigationSuiteType.NavigationBar 27 | adaptiveInfo.windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED && windowWidth >= 1200.dp -> NavigationSuiteType.NavigationDrawer 28 | 29 | else -> NavigationSuiteType.NavigationRail 30 | } 31 | 32 | private fun WindowSizeClass.isCompact() = 33 | windowWidthSizeClass == WindowWidthSizeClass.COMPACT || windowHeightSizeClass == WindowHeightSizeClass.COMPACT 34 | 35 | -------------------------------------------------------------------------------- /core/domain/src/commonTest/kotlin/usecase/groups/GetGroupsUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | package usecase.groups 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.shouldBe 5 | import io.mockative.coEvery 6 | import io.mockative.coVerify 7 | import io.mockative.mock 8 | import io.mockative.of 9 | import kotlinx.coroutines.flow.first 10 | import kotlinx.coroutines.flow.flowOf 11 | import model.Group 12 | import repository.GroupsRepository 13 | 14 | class GetGroupsUseCaseTest : StringSpec({ 15 | val repo = mock(of()) 16 | val usecase = GetGroupsUseCase(repo) 17 | 18 | "should return groups when repository succeeds" { 19 | // Arrange 20 | val groups = listOf( 21 | Group(id = 0, name = "a", ownerId = 0), 22 | Group(id = 1, name = "b", ownerId = 0) 23 | ) 24 | 25 | coEvery { repo.getGroups() } 26 | .returns(flowOf(Result.success(groups))) 27 | 28 | // Act 29 | val result = usecase().first() 30 | 31 | // Assert 32 | result.isSuccess shouldBe true 33 | result.getOrNull() shouldBe groups 34 | coVerify { repo.getGroups() } 35 | .wasInvoked(exactly = 1) 36 | } 37 | "should return failure when repository fails" { 38 | // Arrange 39 | val error = RuntimeException("network error") 40 | 41 | coEvery { repo.getGroups() } 42 | .returns(flowOf(Result.failure(error))) 43 | 44 | // Act 45 | val result = usecase().first() 46 | 47 | // Assert 48 | result.isSuccess shouldBe false 49 | result.exceptionOrNull() shouldBe error 50 | coVerify { repo.getGroups() } 51 | .wasInvoked(exactly = 1) 52 | } 53 | }) -------------------------------------------------------------------------------- /core/domain/src/commonTest/kotlin/kotest/UserManagerTest.kt: -------------------------------------------------------------------------------- 1 | package kotest 2 | import io.kotest.core.spec.style.StringSpec 3 | import io.kotest.engine.runBlocking 4 | import io.kotest.matchers.collections.shouldContainExactly 5 | import kotlinx.coroutines.flow.filter 6 | import kotlinx.coroutines.flow.toList 7 | class UserManagerTest : StringSpec({ 8 | 9 | val userManager = UserManager() 10 | 11 | "getUsersFlow should emit all users" { 12 | runBlocking { 13 | val result = userManager.getUsersFlow().toList() 14 | result.map { it.name } shouldContainExactly listOf("Alice", "Bob", "Charlie", "David") 15 | } 16 | } 17 | 18 | "getUsersStartingWith should filter users correctly" { 19 | runBlocking { 20 | val result = userManager.getUsersStartingWith('C').toList() 21 | result.map { it.name } shouldContainExactly listOf("Charlie") 22 | } 23 | } 24 | 25 | "getUsersStartingWith with no match should return empty list" { 26 | runBlocking { 27 | val result = userManager.getUsersStartingWith('Z').toList() 28 | result shouldContainExactly emptyList() 29 | } 30 | } 31 | }) 32 | 33 | data class User(val id: Int, val name: String) 34 | 35 | class UserManager { 36 | private val users = listOf( 37 | User(1, "Alice"), 38 | User(2, "Bob"), 39 | User(3, "Charlie"), 40 | User(4, "David") 41 | ) 42 | 43 | fun getUsersFlow() = kotlinx.coroutines.flow.flow { 44 | for (user in users) { 45 | kotlinx.coroutines.delay(50) // شبیه‌سازی async 46 | emit(user) 47 | } 48 | } 49 | 50 | fun getUsersStartingWith(letter: Char) = getUsersFlow() 51 | .filter { it.name.startsWith(letter) } 52 | } -------------------------------------------------------------------------------- /core/domain/src/commonTest/kotlin/usecase/transactions/DeleteTransactionUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | package usecase.transactions 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.shouldBe 5 | import io.mockative.coEvery 6 | import io.mockative.coVerify 7 | import io.mockative.mock 8 | import io.mockative.of 9 | import kotlinx.coroutines.flow.first 10 | import kotlinx.coroutines.flow.flowOf 11 | import repository.TransactionsRepository 12 | 13 | class DeleteTransactionUseCaseTest : StringSpec({ 14 | val repo = mock(of()) 15 | val usecase = DeleteTransactionUseCase(repo) 16 | 17 | "should complete successfully when repository succeeds" { 18 | val groupId = "123" 19 | val transactionId = "1" 20 | 21 | coEvery { repo.deleteTransaction(groupId, transactionId) } 22 | .returns(flowOf(Result.success(Unit))) 23 | 24 | val result = usecase(groupId, transactionId).first() 25 | 26 | result.isSuccess shouldBe true 27 | result.getOrNull() shouldBe Unit 28 | coVerify { repo.deleteTransaction(groupId, transactionId) } 29 | .wasInvoked(exactly = 1) 30 | } 31 | 32 | "should return failure when repository fails" { 33 | val groupId = "123" 34 | val transactionId = "1" 35 | val error = RuntimeException("network error") 36 | 37 | coEvery { repo.deleteTransaction(groupId, transactionId) } 38 | .returns(flowOf(Result.failure(error))) 39 | 40 | val result = usecase(groupId, transactionId).first() 41 | 42 | result.isSuccess shouldBe false 43 | result.exceptionOrNull() shouldBe error 44 | coVerify { repo.deleteTransaction(groupId, transactionId) } 45 | .wasInvoked(exactly = 1) 46 | } 47 | }) 48 | -------------------------------------------------------------------------------- /core/domain/src/commonTest/kotlin/usecase/transactions/RejectTransactionUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | package usecase.transactions 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.shouldBe 5 | import io.mockative.coEvery 6 | import io.mockative.coVerify 7 | import io.mockative.mock 8 | import io.mockative.of 9 | import kotlinx.coroutines.flow.first 10 | import kotlinx.coroutines.flow.flowOf 11 | import repository.TransactionsRepository 12 | 13 | class RejectTransactionUseCaseTest : StringSpec({ 14 | val repo = mock(of()) 15 | val usecase = RejectTransactionUseCase(repo) 16 | 17 | "should complete successfully when repository succeeds" { 18 | val groupId = "123" 19 | val transactionId = "1" 20 | 21 | coEvery { repo.rejectTransaction(groupId, transactionId) } 22 | .returns(flowOf(Result.success(Unit))) 23 | 24 | val result = usecase(groupId, transactionId).first() 25 | 26 | result.isSuccess shouldBe true 27 | result.getOrNull() shouldBe Unit 28 | coVerify { repo.rejectTransaction(groupId, transactionId) } 29 | .wasInvoked(exactly = 1) 30 | } 31 | 32 | "should return failure when repository fails" { 33 | val groupId = "123" 34 | val transactionId = "1" 35 | val error = RuntimeException("network error") 36 | 37 | coEvery { repo.rejectTransaction(groupId, transactionId) } 38 | .returns(flowOf(Result.failure(error))) 39 | 40 | val result = usecase(groupId, transactionId).first() 41 | 42 | result.isSuccess shouldBe false 43 | result.exceptionOrNull() shouldBe error 44 | coVerify { repo.rejectTransaction(groupId, transactionId) } 45 | .wasInvoked(exactly = 1) 46 | } 47 | }) 48 | -------------------------------------------------------------------------------- /core/domain/src/commonTest/kotlin/usecase/transactions/ApproveTransactionUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | package usecase.transactions 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.shouldBe 5 | import io.mockative.coEvery 6 | import io.mockative.coVerify 7 | import io.mockative.mock 8 | import io.mockative.of 9 | import kotlinx.coroutines.flow.first 10 | import kotlinx.coroutines.flow.flowOf 11 | import repository.TransactionsRepository 12 | 13 | class ApproveTransactionUseCaseTest : StringSpec({ 14 | val repo = mock(of()) 15 | val usecase = ApproveTransactionUseCase(repo) 16 | 17 | "should complete successfully when repository succeeds" { 18 | val groupId = "123" 19 | val transactionId = "1" 20 | 21 | coEvery { repo.approveTransaction(groupId, transactionId) } 22 | .returns(flowOf(Result.success(Unit))) 23 | 24 | val result = usecase(groupId, transactionId).first() 25 | 26 | result.isSuccess shouldBe true 27 | result.getOrNull() shouldBe Unit 28 | coVerify { repo.approveTransaction(groupId, transactionId) } 29 | .wasInvoked(exactly = 1) 30 | } 31 | 32 | "should return failure when repository fails" { 33 | val groupId = "123" 34 | val transactionId = "1" 35 | val error = RuntimeException("network error") 36 | 37 | coEvery { repo.approveTransaction(groupId, transactionId) } 38 | .returns(flowOf(Result.failure(error))) 39 | 40 | val result = usecase(groupId, transactionId).first() 41 | 42 | result.isSuccess shouldBe false 43 | result.exceptionOrNull() shouldBe error 44 | coVerify { repo.approveTransaction(groupId, transactionId) } 45 | .wasInvoked(exactly = 1) 46 | } 47 | }) 48 | -------------------------------------------------------------------------------- /core/network/src/commonMain/kotlin/plugin/ErrorHandler.kt: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import io.ktor.client.HttpClientConfig 4 | import io.ktor.client.plugins.HttpResponseValidator 5 | import io.ktor.client.plugins.ResponseException 6 | import io.ktor.client.statement.bodyAsText 7 | import io.ktor.http.HttpStatusCode 8 | 9 | fun HttpClientConfig<*>.installErrorHandler() { 10 | expectSuccess = true 11 | 12 | HttpResponseValidator { 13 | handleResponseExceptionWithRequest { exception, _ -> 14 | 15 | if (exception !is ResponseException) { 16 | throw exception 17 | } 18 | 19 | val response = exception.response 20 | val statusCode = response.status.value 21 | val responseBody = try { 22 | response.bodyAsText() 23 | } catch (e: Exception) { 24 | "" 25 | } 26 | 27 | when (statusCode) { 28 | HttpStatusCode.Unauthorized.value -> throw UnauthorizedException(responseBody) 29 | HttpStatusCode.NotFound.value -> throw NotFoundException(responseBody) 30 | in 500..599 -> throw ServerException("Server error with status code: $statusCode") 31 | else -> throw GenericApiException(responseBody, statusCode) 32 | } 33 | } 34 | } 35 | } 36 | 37 | sealed class ApiException(message: String) : Exception(message) 38 | 39 | class UnauthorizedException(message: String = "Authentication failed") : ApiException(message) 40 | class NotFoundException(message: String = "Resource not found") : ApiException(message) 41 | class ServerException(message: String = "Internal server error") : ApiException(message) 42 | class GenericApiException(message: String, val code: Int) : ApiException("HTTP Error: $code - $message") 43 | -------------------------------------------------------------------------------- /core/data/src/commonMain/kotlin/repository/AuthRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import NetworkManager 4 | import kotlinx.coroutines.flow.Flow 5 | import kotlinx.coroutines.flow.map 6 | import model.AuthResponse 7 | import model.LoginRequest 8 | import model.RegisterRequest 9 | import model.User 10 | import token.TokenProvider 11 | 12 | class AuthRepositoryImpl( 13 | private val networkManager: NetworkManager, 14 | private val tokenProvider: TokenProvider, 15 | private val userRepository: UserRepository 16 | ) : AuthRepository { 17 | override suspend fun register( 18 | phone: String, 19 | username: String, 20 | password: String, 21 | ): Flow> { 22 | return networkManager.post( 23 | endpoint = "auth/register", 24 | body = RegisterRequest(phone, username, password) 25 | ).map { result -> 26 | result.map { (token, user) -> 27 | tokenProvider.setToken(token) 28 | userRepository.setUserInfo(user) 29 | user 30 | }.onFailure { 31 | tokenProvider.clearToken() 32 | } 33 | } 34 | } 35 | 36 | override suspend fun login( 37 | phone: String, 38 | password: String, 39 | ): Flow> { 40 | return networkManager.post( 41 | endpoint = "auth/login", 42 | body = LoginRequest(phone, password) 43 | ).map { result -> 44 | result.map { (token, user) -> 45 | tokenProvider.setToken(token) 46 | userRepository.setUserInfo(user) 47 | user 48 | }.onFailure { 49 | tokenProvider.clearToken() 50 | } 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /core/network/src/commonTest/kotlin/NetworkManagerTest.kt: -------------------------------------------------------------------------------- 1 | import client.ApiClient 2 | import io.kotest.core.spec.style.DescribeSpec 3 | import io.kotest.matchers.result.shouldBeFailure 4 | import io.kotest.matchers.result.shouldBeSuccess 5 | import io.kotest.matchers.shouldBe 6 | import io.mockative.mock 7 | import io.mockative.of 8 | import kotlinx.coroutines.flow.first 9 | import plugin.UnauthorizedException 10 | 11 | class NetworkManagerTest : DescribeSpec({ 12 | val client = mock(of()) 13 | val networkManager: NetworkManager = NetworkManager(client) 14 | 15 | describe("Safe Network Call") { 16 | context("when request is successful") { 17 | it("should emit success when response.success = true") { 18 | val flow = networkManager.safeNetworkCall { 19 | SuccessResponse(true, "Hello") 20 | }.first() 21 | 22 | flow.shouldBeSuccess() { 23 | it.shouldBe("Hello") 24 | } 25 | } 26 | } 27 | context("when request is fail") { 28 | it("should emit failure when response.success = false") { 29 | val flow = networkManager.safeNetworkCall { 30 | SuccessResponse(false, "Hello") 31 | }.first() 32 | 33 | flow.shouldBeFailure() { 34 | it.message shouldBe "Request failed" 35 | } 36 | } 37 | it("should emit failure when block throws exception") { 38 | val flow = networkManager.safeNetworkCall { 39 | throw UnauthorizedException() 40 | }.first() 41 | 42 | flow.shouldBeFailure() { 43 | it.message shouldBe "Authentication failed" 44 | } 45 | } 46 | } 47 | } 48 | }) -------------------------------------------------------------------------------- /core/domain/src/commonTest/kotlin/usecase/groups/CreateGroupUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | package usecase.groups 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.shouldBe 5 | import io.mockative.coEvery 6 | import io.mockative.coVerify 7 | import io.mockative.mock 8 | import io.mockative.of 9 | import kotlinx.coroutines.flow.first 10 | import kotlinx.coroutines.flow.flowOf 11 | import model.Group 12 | import repository.GroupsRepository 13 | 14 | class CreateGroupUseCaseTest : StringSpec({ 15 | val repo = mock(of()) 16 | val usecase = CreateGroupUseCase(repo) 17 | 18 | val groupName = "group name" 19 | val members = listOf("member1", "member2") 20 | 21 | "should return created group when repository succeeds" { 22 | // Arrange 23 | val group = Group(id = 0, name = groupName, ownerId = 0) 24 | 25 | coEvery { repo.createGroup(groupName, members) } 26 | .returns(flowOf(Result.success(group))) 27 | 28 | // Act 29 | val result = usecase(groupName, members).first() 30 | 31 | // Assert 32 | result.isSuccess shouldBe true 33 | result.getOrNull() shouldBe group 34 | coVerify { repo.createGroup(groupName, members) } 35 | .wasInvoked(exactly = 1) 36 | } 37 | "should return failure when repository fails" { 38 | // Arrange 39 | val error = RuntimeException("network error") 40 | 41 | coEvery { repo.createGroup(groupName, members) } 42 | .returns(flowOf(Result.failure(error))) 43 | 44 | // Act 45 | val result = usecase(groupName, members).first() 46 | 47 | // Assert 48 | result.isSuccess shouldBe false 49 | result.exceptionOrNull() shouldBe error 50 | coVerify { repo.createGroup(groupName, members) } 51 | .wasInvoked(exactly = 1) 52 | } 53 | }) 54 | 55 | -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/data/dbMapper.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.data 2 | 3 | import org.jetbrains.exposed.sql.ResultRow 4 | import org.milad.expense_share.data.db.table.Friends 5 | import org.milad.expense_share.data.db.table.GroupMembers 6 | import org.milad.expense_share.data.db.table.Groups 7 | import org.milad.expense_share.data.db.table.Transactions 8 | import org.milad.expense_share.data.db.table.Users 9 | import org.milad.expense_share.data.models.FriendRelation 10 | import org.milad.expense_share.data.models.Group 11 | import org.milad.expense_share.data.models.GroupMember 12 | import org.milad.expense_share.data.models.Transaction 13 | import org.milad.expense_share.data.models.User 14 | 15 | fun ResultRow.toUser() = User( 16 | id = this[Users.id], 17 | username = this[Users.username], 18 | phone = this[Users.phone] 19 | ) 20 | 21 | fun ResultRow.toFriendRelation() = FriendRelation( 22 | userId = this[Friends.userId], 23 | friendId = this[Friends.friendId], 24 | status = this[Friends.status] 25 | ) 26 | 27 | fun ResultRow.toGroup() = Group( 28 | id = this[Groups.id], 29 | name = this[Groups.name], 30 | ownerId = this[Groups.ownerId] 31 | ) 32 | 33 | fun ResultRow.toGroupMember() = GroupMember( 34 | groupId = this[GroupMembers.groupId], 35 | userId = this[GroupMembers.userId] 36 | ) 37 | 38 | fun ResultRow.toTransaction() = Transaction( 39 | id = this[Transactions.id], 40 | groupId = this[Transactions.groupId], 41 | title = this[Transactions.title], 42 | amount = this[Transactions.amount], 43 | description = this[Transactions.description], 44 | createdBy = this[Transactions.createdBy], 45 | status = this[Transactions.status], 46 | createdAt = this[Transactions.createdAt], 47 | transactionDate = this[Transactions.transactionDate], 48 | approvedBy = this[Transactions.approvedBy] 49 | ) 50 | -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/domain/service/FriendsService.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.domain.service 2 | 3 | import org.milad.expense_share.data.models.User 4 | import org.milad.expense_share.domain.repository.FriendRepository 5 | 6 | class FriendsService(private val repository: FriendRepository) { 7 | 8 | fun sendRequest(userId: Int, phone: String): Result { 9 | return if (repository.sendFriendRequest(userId, phone)) { 10 | Result.success("Friend request sent successfully") 11 | } else { 12 | Result.failure(IllegalStateException("User not found or request already exists")) 13 | } 14 | } 15 | 16 | fun acceptRequest(userId: Int, phone: String): Result { 17 | return if (repository.acceptFriendRequest(userId, phone)) { 18 | Result.success("Friend request accepted") 19 | } else { 20 | Result.failure(IllegalStateException("No pending request found")) 21 | } 22 | } 23 | 24 | fun rejectRequest(userId: Int, phone: String): Result { 25 | return if (repository.rejectFriendRequest(userId, phone)) { 26 | Result.success("Friend request rejected") 27 | } else { 28 | Result.failure(IllegalStateException("No pending request found")) 29 | } 30 | } 31 | 32 | fun removeFriend(userId: Int, phone: String): Result { 33 | return if (repository.removeFriend(userId, phone)) { 34 | Result.success("Friend removed") 35 | } else { 36 | Result.failure(IllegalStateException("Friend not found")) 37 | } 38 | } 39 | 40 | fun listFriends(userId: Int): List = repository.getFriends(userId) 41 | 42 | fun listRequests(userId: Int): Pair, List> = 43 | repository.getIncomingRequests(userId) to repository.getOutgoingRequests(userId) 44 | } 45 | -------------------------------------------------------------------------------- /core/common/src/commonMain/kotlin/com/pmb/common/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.pmb.common.theme 2 | import androidx.compose.material3.Typography 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.text.font.FontFamily 5 | import androidx.compose.ui.text.font.FontWeight 6 | import expenseshare.core.common.generated.resources.ADLaMDisplay_Regular 7 | import expenseshare.core.common.generated.resources.Res 8 | import org.jetbrains.compose.resources.Font 9 | 10 | @Composable 11 | fun appTypography(): Typography { 12 | val bodyFont = FontFamily( 13 | Font(Res.font.ADLaMDisplay_Regular, weight = FontWeight.Normal) 14 | ) 15 | 16 | val displayFont = FontFamily( 17 | Font(Res.font.ADLaMDisplay_Regular, weight = FontWeight.Bold) 18 | ) 19 | 20 | val base = Typography() 21 | 22 | return Typography( 23 | displayLarge = base.displayLarge.copy(fontFamily = displayFont), 24 | displayMedium = base.displayMedium.copy(fontFamily = displayFont), 25 | displaySmall = base.displaySmall.copy(fontFamily = displayFont), 26 | headlineLarge = base.headlineLarge.copy(fontFamily = displayFont), 27 | headlineMedium = base.headlineMedium.copy(fontFamily = displayFont), 28 | headlineSmall = base.headlineSmall.copy(fontFamily = displayFont), 29 | titleLarge = base.titleLarge.copy(fontFamily = displayFont), 30 | titleMedium = base.titleMedium.copy(fontFamily = displayFont), 31 | titleSmall = base.titleSmall.copy(fontFamily = displayFont), 32 | bodyLarge = base.bodyLarge.copy(fontFamily = bodyFont), 33 | bodyMedium = base.bodyMedium.copy(fontFamily = bodyFont), 34 | bodySmall = base.bodySmall.copy(fontFamily = bodyFont), 35 | labelLarge = base.labelLarge.copy(fontFamily = bodyFont), 36 | labelMedium = base.labelMedium.copy(fontFamily = bodyFont), 37 | labelSmall = base.labelSmall.copy(fontFamily = bodyFont), 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /server/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlinJvm) 3 | alias(libs.plugins.ktor) 4 | application 5 | kotlin("plugin.serialization") version "2.2.21" 6 | alias(libs.plugins.kotest) 7 | alias(libs.plugins.jacoco) 8 | } 9 | jacoco { 10 | toolVersion = libs.versions.jacoco.get() 11 | } 12 | 13 | group = "org.milad.expense_share" 14 | version = "1.0.0" 15 | application { 16 | mainClass.set("io.ktor.server.netty.EngineMain") 17 | 18 | val isDevelopment: Boolean = project.ext.has("development") 19 | applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment") 20 | } 21 | 22 | dependencies { 23 | implementation(projects.core.currency) 24 | implementation(project.dependencies.platform(libs.koin.bom)) 25 | implementation(libs.koin.core) 26 | implementation(libs.koin.ktor) 27 | 28 | implementation(libs.logback) 29 | implementation(libs.ktor.server.core) 30 | implementation(libs.ktor.server.status.pages) 31 | implementation(libs.ktor.server.netty) 32 | implementation(libs.ktor.server.content.negotiation) 33 | implementation(libs.ktor.serialization.kotlinx.json) 34 | implementation(libs.ktor.server.call.logging) 35 | implementation(libs.ktor.server.cros) 36 | implementation(libs.ktor.server.auth) 37 | implementation(libs.ktor.server.auth.jwt) 38 | 39 | implementation(libs.jbcrypt) 40 | 41 | implementation(libs.exposed.core) 42 | implementation(libs.exposed.dao) 43 | implementation(libs.exposed.jdbc) 44 | implementation(libs.exposed.kotlin.datetime) 45 | 46 | implementation(libs.postgresql) 47 | 48 | implementation(libs.hikaricp) 49 | 50 | testImplementation(libs.kotest.framework.engine) 51 | testImplementation(libs.kotest.assertions.core) 52 | testImplementation(libs.kotest.extensions.koin) 53 | testImplementation(libs.kotest.property) 54 | testImplementation(libs.koin.test.junit5) 55 | 56 | testImplementation(libs.ktor.server.test.host) 57 | } -------------------------------------------------------------------------------- /core/data/src/commonMain/kotlin/repository/TransactionsRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import NetworkManager 4 | import kotlinx.coroutines.flow.Flow 5 | import model.CreateTransactionRequest 6 | import model.PayerDto 7 | import model.ShareDetailsRequest 8 | import model.Transaction 9 | import org.milad.expense_share.Amount 10 | 11 | 12 | class TransactionsRepositoryImpl(private val networkManager: NetworkManager) : 13 | TransactionsRepository { 14 | override suspend fun getTransactions(groupId: String): Flow>> { 15 | return networkManager.get>("/groups/$groupId/transactions") 16 | } 17 | 18 | override suspend fun createTransaction( 19 | groupId: Int, 20 | title: String, 21 | amount: Amount, 22 | description: String?, 23 | payers: List?, 24 | shareDetails: ShareDetailsRequest? 25 | ): Flow> { 26 | return networkManager.post( 27 | "/groups/$groupId/transactions", 28 | body = CreateTransactionRequest( 29 | title, 30 | amount, 31 | description, 32 | payers = payers, 33 | shareDetails = shareDetails 34 | ) 35 | ) 36 | } 37 | 38 | override suspend fun approveTransaction( 39 | groupId: String, transactionId: String 40 | ): Flow> { 41 | return networkManager.post("/groups/$groupId/transactions/$transactionId/approve", Unit) 42 | } 43 | 44 | override suspend fun rejectTransaction( 45 | groupId: String, transactionId: String 46 | ): Flow> { 47 | return networkManager.post("/groups/$groupId/transactions/$transactionId/reject", Unit) 48 | } 49 | 50 | override suspend fun deleteTransaction( 51 | groupId: String, 52 | transactionId: String 53 | ): Flow> { 54 | return networkManager.delete("/groups/$groupId/transactions/$transactionId") 55 | } 56 | } -------------------------------------------------------------------------------- /core/common/src/commonMain/kotlin/com/pmb/common/viewmodel/BaseViewmodel.kt: -------------------------------------------------------------------------------- 1 | package com.pmb.common.viewmodel 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import kotlinx.coroutines.flow.MutableSharedFlow 6 | import kotlinx.coroutines.flow.MutableStateFlow 7 | import kotlinx.coroutines.flow.asSharedFlow 8 | import kotlinx.coroutines.flow.asStateFlow 9 | import kotlinx.coroutines.flow.update 10 | import kotlinx.coroutines.launch 11 | 12 | /** 13 | * A base class for ViewModels in a Multiplatform project, following MVI-like principles. 14 | * 15 | * @param STATE The type of the state managed by this ViewModel. 16 | * @param ACTION The type of the actions that can be handled by this ViewModel. 17 | * @param EVENT The type of the one-time events that can be emitted by this ViewModel. 18 | * @param initialState The initial state of the ViewModel. 19 | */ 20 | abstract class BaseViewModel( 21 | initialState: STATE 22 | ) : ViewModel() { 23 | 24 | private val _viewState = MutableStateFlow(initialState) 25 | val viewState = _viewState.asStateFlow() 26 | 27 | private val _viewEvent = MutableSharedFlow() 28 | val viewEvent = _viewEvent.asSharedFlow() 29 | 30 | /** 31 | * The primary entry point for the UI to send actions to the ViewModel. 32 | */ 33 | abstract fun handle(action: ACTION) 34 | 35 | /** 36 | * Updates the current state. 37 | * The lambda provides the current state for safe, atomic updates. 38 | */ 39 | protected fun setState(reducer: (currentState: STATE) -> STATE) { 40 | _viewState.update(reducer) 41 | } 42 | 43 | /** 44 | * Posts a one-time event to the UI (e.g., for navigation or showing a toast). 45 | */ 46 | protected fun postEvent(event: EVENT) { 47 | viewModelScope.launch { 48 | _viewEvent.emit(event) 49 | } 50 | } 51 | } 52 | 53 | interface BaseViewAction 54 | interface BaseViewState 55 | interface BaseViewEvent 56 | 57 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/org/milad/expense_share/dashboard/ExtraPaneContent.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalMaterial3AdaptiveApi::class) 2 | 3 | package org.milad.expense_share.dashboard 4 | 5 | import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.runtime.collectAsState 8 | import androidx.compose.runtime.getValue 9 | import org.milad.expense_share.expenses.AddExpense 10 | import org.milad.expense_share.group.AddGroupScreen 11 | 12 | @Composable 13 | fun ExtraPaneContent( 14 | viewModel: DashboardViewModel, 15 | onBackClick: () -> Unit, 16 | ) { 17 | 18 | val state by viewModel.viewState.collectAsState() 19 | val content = state.extraPaneContentState 20 | when (content) { 21 | ExtraPaneContentState.AddExpense -> { 22 | AddExpense( 23 | allUsers = state.selectedGroup?.members ?: emptyList(), 24 | onBackClick = onBackClick, 25 | isLoading = state.extraPaneLoading, 26 | hasError = state.extraPaneError 27 | ) { name, amount, desc, payer, shareDetails -> 28 | viewModel.handle( 29 | DashboardAction.AddExpense( 30 | name, 31 | amount, 32 | desc, 33 | payer, 34 | shareDetails 35 | ) 36 | ) 37 | } 38 | } 39 | 40 | ExtraPaneContentState.AddGroup -> { 41 | AddGroupScreen( 42 | listOfFriends = state.friends, 43 | onBackClick = onBackClick, 44 | isLoading = state.extraPaneLoading, 45 | hasError = state.extraPaneError, 46 | ) { name, list -> 47 | viewModel.handle(DashboardAction.AddGroup(name, list)) 48 | } 49 | } 50 | 51 | ExtraPaneContentState.AddMember -> { 52 | } 53 | 54 | ExtraPaneContentState.None -> { 55 | } 56 | } 57 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/org/milad/expense_share/profile/ProfileScreen.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.profile 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.padding 9 | import androidx.compose.material3.Button 10 | import androidx.compose.material3.MaterialTheme 11 | import androidx.compose.material3.Scaffold 12 | import androidx.compose.material3.Text 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.text.style.TextAlign 17 | import androidx.compose.ui.unit.dp 18 | import org.jetbrains.compose.ui.tooling.preview.Preview 19 | 20 | @Composable 21 | fun ProfileScreen( 22 | // viewModel: ProfileViewModel = koinViewModel() 23 | onLogout: () -> Unit, 24 | ) { 25 | Scaffold {paddingValues -> 26 | Column( 27 | modifier = Modifier 28 | .fillMaxSize() 29 | .padding(paddingValues), 30 | verticalArrangement = Arrangement.Center, 31 | horizontalAlignment = Alignment.CenterHorizontally, 32 | ) { 33 | Text( 34 | modifier = Modifier.padding(8.dp), 35 | text = "Screen under construction", 36 | style = MaterialTheme.typography.titleLarge, 37 | textAlign = TextAlign.Center, 38 | color = MaterialTheme.colorScheme.primary, 39 | ) 40 | Spacer(Modifier.height(16.dp)) 41 | 42 | Button( 43 | onClick = { 44 | onLogout() 45 | } 46 | ) { 47 | Text("Logout") 48 | } 49 | } 50 | } 51 | 52 | } 53 | 54 | @Preview 55 | @Composable 56 | fun ProfileScreenPreview() { 57 | ProfileScreen(onLogout = {}) 58 | } -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/presentation/auth/authRoutes.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.presentation.auth 2 | 3 | import io.ktor.http.HttpStatusCode 4 | import io.ktor.server.request.receive 5 | import io.ktor.server.response.respond 6 | import io.ktor.server.routing.Routing 7 | import io.ktor.server.routing.post 8 | import io.ktor.server.routing.route 9 | import org.milad.expense_share.domain.service.AuthService 10 | import org.milad.expense_share.presentation.auth.model.LoginRequest 11 | import org.milad.expense_share.presentation.auth.model.RegisterRequest 12 | import org.milad.expense_share.presentation.api_model.ErrorResponse 13 | import org.milad.expense_share.presentation.api_model.SuccessResponse 14 | 15 | internal fun Routing.authRoutes(authService: AuthService) { 16 | 17 | route("/auth") { 18 | 19 | post("/register") { 20 | val request = call.receive() 21 | 22 | authService.register( 23 | username = request.username, 24 | phone = request.phone, 25 | password = request.password 26 | ).onSuccess { 27 | call.respond(HttpStatusCode.Created, SuccessResponse(data = it)) 28 | }.onFailure { 29 | call.respond( 30 | HttpStatusCode.BadRequest, 31 | ErrorResponse(it.message ?: "Registration failed", "REGISTER_FAILED") 32 | ) 33 | } 34 | } 35 | 36 | post("/login") { 37 | val request = call.receive() 38 | 39 | authService.login(request.phone, request.password) 40 | .onSuccess { 41 | call.respond(HttpStatusCode.OK, SuccessResponse(data = it)) 42 | } 43 | .onFailure { 44 | call.respond( 45 | HttpStatusCode.Unauthorized, 46 | ErrorResponse(it.message ?: "Invalid credentials", "INVALID_CREDENTIALS") 47 | ) 48 | } 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/data/db/DatabaseFactory.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.data.db 2 | 3 | import com.zaxxer.hikari.HikariConfig 4 | import com.zaxxer.hikari.HikariDataSource 5 | import org.jetbrains.exposed.sql.Database 6 | import org.jetbrains.exposed.sql.SchemaUtils 7 | import org.jetbrains.exposed.sql.transactions.transaction 8 | import org.milad.expense_share.data.db.table.FriendRelations 9 | import org.milad.expense_share.data.db.table.Friends 10 | import org.milad.expense_share.data.db.table.GroupMembers 11 | import org.milad.expense_share.data.db.table.Groups 12 | import org.milad.expense_share.data.db.table.Passwords 13 | import org.milad.expense_share.data.db.table.TransactionPayers 14 | import org.milad.expense_share.data.db.table.TransactionShareMembers 15 | import org.milad.expense_share.data.db.table.TransactionShares 16 | import org.milad.expense_share.data.db.table.Transactions 17 | import org.milad.expense_share.data.db.table.Users 18 | 19 | object DatabaseFactory { 20 | 21 | fun init() { 22 | val db = Database.connect(hikari()) 23 | 24 | transaction(db) { 25 | SchemaUtils.create( 26 | Users, 27 | Passwords, 28 | Friends, 29 | FriendRelations, 30 | Groups, 31 | GroupMembers, 32 | Transactions, 33 | TransactionPayers, 34 | TransactionShares, 35 | TransactionShareMembers 36 | ) 37 | } 38 | } 39 | 40 | private fun hikari(): HikariDataSource { 41 | val config = HikariConfig().apply { 42 | driverClassName = "org.postgresql.Driver" 43 | jdbcUrl = "jdbc:postgresql://localhost:5432/expenseshare" 44 | username = "postgres" 45 | password = "miladmilad" 46 | 47 | maximumPoolSize = 10 48 | isAutoCommit = false 49 | transactionIsolation = "TRANSACTION_REPEATABLE_READ" 50 | validate() 51 | } 52 | return HikariDataSource(config) 53 | } 54 | } -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/data/repository/UserRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.data.repository 2 | 3 | import org.jetbrains.exposed.sql.insert 4 | import org.jetbrains.exposed.sql.max 5 | import org.jetbrains.exposed.sql.selectAll 6 | import org.jetbrains.exposed.sql.transactions.transaction 7 | import org.milad.expense_share.data.db.table.Passwords 8 | import org.milad.expense_share.data.db.table.Users 9 | import org.milad.expense_share.data.models.User 10 | import org.milad.expense_share.data.toUser 11 | import org.milad.expense_share.domain.repository.UserRepository 12 | 13 | class UserRepositoryImpl : UserRepository { 14 | override fun create(user: User, passwordHash: String): User = transaction { 15 | val id = Users.insert { 16 | it[username] = user.username 17 | it[phone] = user.phone 18 | } get Users.id 19 | 20 | 21 | Passwords.insert { 22 | it[userId] = id 23 | it[hash] = passwordHash 24 | } 25 | 26 | user.copy(id = id) 27 | } 28 | 29 | override fun findByPhone(phone: String): User? = transaction { 30 | Users.selectAll() 31 | .where { Users.phone eq phone } 32 | .map { it.toUser() } 33 | .singleOrNull() 34 | } 35 | 36 | override fun findById(id: Int): User? = transaction { 37 | Users.selectAll() 38 | .where { Users.id eq id } 39 | .map { it.toUser() } 40 | .singleOrNull() 41 | } 42 | 43 | override fun verifyUser(phone: String, checkPassword: (String) -> Boolean): User? = 44 | transaction { 45 | val row = (Users innerJoin Passwords) 46 | .selectAll().where { Users.phone eq phone } 47 | .singleOrNull() ?: return@transaction null 48 | 49 | val hash = row[Passwords.hash] 50 | if (checkPassword(hash)) row.toUser() else null 51 | } 52 | 53 | override fun lastIndexOfUser(): Int = transaction { 54 | Users.select(Users.id.max()) 55 | .singleOrNull()?.get(Users.id.max()) ?: 0 56 | } 57 | } -------------------------------------------------------------------------------- /core/domain/src/commonTest/kotlin/usecase/auth/LoginUserUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | package usecase.auth 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.shouldBe 5 | import io.mockative.any 6 | import io.mockative.coEvery 7 | import io.mockative.coVerify 8 | import io.mockative.mock 9 | import io.mockative.of 10 | import kotlinx.coroutines.flow.first 11 | import kotlinx.coroutines.flow.flow 12 | import kotlinx.coroutines.flow.flowOf 13 | import model.User 14 | import repository.AuthRepository 15 | 16 | class LoginUserUseCaseTest : StringSpec({ 17 | 18 | val repo = mock(of()) 19 | val useCase = LoginUserUseCase(repo) 20 | 21 | "should return user on successful login" { 22 | // Arrange 23 | val expectedUser = User(1, "milad", "09123456789") 24 | coEvery { repo.login("09123456789", "123456") } 25 | .returns(flowOf(Result.success(expectedUser))) 26 | 27 | // Act 28 | val result = useCase("09123456789", "123456").first() 29 | 30 | // Assert 31 | result.isSuccess shouldBe true 32 | result.getOrNull() shouldBe expectedUser 33 | coVerify { repo.login("09123456789", "123456") } 34 | .wasInvoked(atLeast = 1) 35 | } 36 | 37 | "should return failure on login error" { 38 | // Arrange 39 | val expectedException = RuntimeException("Login failed") 40 | coEvery { repo.login("09123456789", "123456") } 41 | .returns(flowOf(Result.failure(expectedException))) 42 | 43 | // Act 44 | val result = useCase("09123456789", "123456").first() 45 | 46 | // Assert 47 | result.isFailure shouldBe true 48 | result.exceptionOrNull() shouldBe expectedException 49 | } 50 | 51 | "should call repository with correct parameters" { 52 | // Arrange 53 | coEvery { repo.login(any(), any()) } returns flow { 54 | emit(Result.success(User(1, "milad", "09123456789"))) 55 | } 56 | 57 | // Act 58 | useCase("09123456789", "123456") 59 | 60 | // Assert 61 | coVerify { repo.login("09123456789", "123456") } 62 | .wasInvoked(atLeast = 1) 63 | } 64 | }) 65 | -------------------------------------------------------------------------------- /core/domain/src/commonTest/kotlin/usecase/transactions/GetTransactionsUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | package usecase.transactions 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.shouldBe 5 | import io.mockative.coEvery 6 | import io.mockative.coVerify 7 | import io.mockative.mock 8 | import io.mockative.of 9 | import kotlinx.coroutines.flow.first 10 | import kotlinx.coroutines.flow.flowOf 11 | import model.Transaction 12 | import model.TransactionStatus 13 | import repository.TransactionsRepository 14 | 15 | class GetTransactionsUseCaseTest : StringSpec({ 16 | val repo = mock(of()) 17 | val usecase = GetTransactionsUseCase(repo) 18 | 19 | "should return transactions when repository succeeds" { 20 | val groupId = "1" 21 | val transactions = listOf( 22 | Transaction( 23 | id = 1, 24 | groupId = 100, 25 | title = "Dinner at Restaurant", 26 | amount = 250.0, 27 | description = "Team dinner with colleagues", 28 | createdBy = 42, 29 | status = TransactionStatus.PENDING, 30 | createdAt = 0, 31 | transactionDate = 0, 32 | approvedBy = null 33 | ) 34 | ) 35 | 36 | coEvery { repo.getTransactions(groupId) } 37 | .returns(flowOf(Result.success(transactions))) 38 | 39 | val result = usecase(groupId).first() 40 | 41 | result.isSuccess shouldBe true 42 | result.getOrNull() shouldBe transactions 43 | coVerify { repo.getTransactions(groupId) } 44 | .wasInvoked(exactly = 1) 45 | } 46 | 47 | "should return failure when repository fails" { 48 | val groupId = "123" 49 | val error = RuntimeException("network error") 50 | 51 | coEvery { repo.getTransactions(groupId) } 52 | .returns(flowOf(Result.failure(error))) 53 | 54 | val result = usecase(groupId).first() 55 | 56 | result.isSuccess shouldBe false 57 | result.exceptionOrNull() shouldBe error 58 | coVerify { repo.getTransactions(groupId) } 59 | .wasInvoked(exactly = 1) 60 | } 61 | }) 62 | -------------------------------------------------------------------------------- /core/currency/src/commonMain/kotlin/org/milad/expense_share/Amount.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share 2 | 3 | import kotlinx.serialization.Serializable 4 | import kotlin.jvm.JvmInline 5 | 6 | @Serializable(with = AmountSerializer::class) 7 | @JvmInline 8 | value class Amount(val value: Long) : Comparable { 9 | constructor(value: String) : this(if (value.equals("")) 0 else value.toLong()) 10 | 11 | operator fun plus(other: Amount): Amount = Amount(value + other.value) 12 | 13 | operator fun minus(other: Amount): Amount = Amount(value - other.value) 14 | operator fun minus(other: Long): Amount = Amount(value - other) 15 | 16 | operator fun times(multiplier: Long): Amount = Amount(value * multiplier) 17 | operator fun times(multiplier: Int): Amount = Amount(value * multiplier) 18 | 19 | operator fun div(divisor: Int): Amount = Amount(value / divisor) 20 | 21 | operator fun unaryMinus(): Amount = Amount(-value) 22 | 23 | override operator fun compareTo(other: Amount) = value compareTo other.value 24 | operator fun compareTo(other: Long) = value compareTo other 25 | operator fun compareTo(other: Double) = value compareTo other.toLong() 26 | operator fun compareTo(other: Int) = value compareTo other.toLong() 27 | 28 | fun isPositive(): Boolean = value > 0 29 | fun isNegative(): Boolean = value < 0 30 | fun isZero(): Boolean = value == 0L 31 | 32 | fun abs(): Amount = Amount(kotlin.math.abs(value)) 33 | } 34 | 35 | fun Amount.showSeparate(): String { 36 | val s = value.toString() 37 | val isNegative = s.startsWith('-') 38 | val digits = if (isNegative) s.drop(1) else s 39 | 40 | val separated = digits 41 | .reversed() 42 | .chunked(3) 43 | .joinToString(",") 44 | .reversed() 45 | 46 | return if (isNegative) "-$separated" else separated 47 | } 48 | 49 | fun Long.showSeparate(): String { 50 | val s = this.toString() 51 | val isNegative = s.startsWith('-') 52 | val digits = if (isNegative) s.drop(1) else s 53 | 54 | val separated = digits 55 | .reversed() 56 | .chunked(3) 57 | .joinToString(",") 58 | .reversed() 59 | 60 | return if (isNegative) "-$separated" else separated 61 | } -------------------------------------------------------------------------------- /core/common/src/commonMain/kotlin/com/pmb/common/ui/scaffold/navigation/drawer/drawerMeasurePolicy.kt: -------------------------------------------------------------------------------- 1 | package com.pmb.common.ui.scaffold.navigation.drawer 2 | 3 | import androidx.compose.ui.layout.Measurable 4 | import androidx.compose.ui.layout.MeasurePolicy 5 | import androidx.compose.ui.layout.layoutId 6 | import androidx.compose.ui.unit.offset 7 | import com.pmb.common.ui.scaffold.model.NavigationContentPosition 8 | import com.pmb.common.ui.scaffold.model.NavigationLayoutType 9 | 10 | /** 11 | * Custom measure policy for drawer layouts that positions header and content 12 | * based on the specified navigation content position 13 | */ 14 | fun drawerMeasurePolicy( 15 | contentPosition: NavigationContentPosition 16 | ): MeasurePolicy { 17 | return MeasurePolicy { measurables, constraints -> 18 | lateinit var headerMeasurable: Measurable 19 | lateinit var contentMeasurable: Measurable 20 | 21 | measurables.forEach { 22 | when (it.layoutId) { 23 | NavigationLayoutType.HEADER -> headerMeasurable = it 24 | NavigationLayoutType.CONTENT -> contentMeasurable = it 25 | else -> error("Unknown layoutId encountered!") 26 | } 27 | } 28 | 29 | val headerPlaceable = headerMeasurable.measure(constraints) 30 | val contentPlaceable = contentMeasurable.measure( 31 | constraints.offset(vertical = -headerPlaceable.height) 32 | ) 33 | 34 | layout(constraints.maxWidth, constraints.maxHeight) { 35 | // Place the header at the top 36 | headerPlaceable.placeRelative(0, 0) 37 | 38 | // Calculate vertical space not used by content 39 | val nonContentVerticalSpace = constraints.maxHeight - contentPlaceable.height 40 | 41 | val contentPlaceableY = when (contentPosition) { 42 | NavigationContentPosition.TOP -> 0 43 | NavigationContentPosition.CENTER -> nonContentVerticalSpace / 2 44 | } 45 | // Ensure we don't overlap with the header 46 | .coerceAtLeast(headerPlaceable.height) 47 | 48 | contentPlaceable.placeRelative(0, contentPlaceableY) 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /core/domain/src/commonTest/kotlin/usecase/auth/RegisterUserUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | package usecase.auth 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.shouldBe 5 | import io.mockative.any 6 | import io.mockative.coEvery 7 | import io.mockative.coVerify 8 | import io.mockative.mock 9 | import io.mockative.of 10 | import kotlinx.coroutines.flow.first 11 | import kotlinx.coroutines.flow.flowOf 12 | import model.User 13 | import repository.AuthRepository 14 | 15 | class RegisterUserUseCaseTest : StringSpec({ 16 | 17 | val repo = mock(of()) 18 | val useCase = RegisterUserUseCase(repo) 19 | 20 | "should return user on successful register" { 21 | // Arrange 22 | val expectedUser = User(1, "milad", "09123456789") 23 | coEvery { repo.register("09123456789", "milad", "123456") } 24 | .returns(flowOf(Result.success(expectedUser))) 25 | 26 | // Act 27 | val result = useCase("09123456789", "milad", "123456").first() 28 | 29 | // Assert 30 | result.isSuccess shouldBe true 31 | result.getOrNull() shouldBe expectedUser 32 | coVerify { repo.register("09123456789", "milad", "123456") } 33 | .wasInvoked(exactly = 1) 34 | } 35 | 36 | "should return failure on error from repository" { 37 | // Arrange 38 | val expectedException = RuntimeException("Register failed") 39 | coEvery { repo.register("09123456789", "milad", "123456") } 40 | .returns(flowOf(Result.failure(expectedException))) 41 | 42 | // Act 43 | val result = useCase("09123456789", "milad", "123456").first() 44 | 45 | // Assert 46 | result.isFailure shouldBe true 47 | result.exceptionOrNull() shouldBe expectedException 48 | } 49 | 50 | "should call repository with correct parameters" { 51 | // Arrange 52 | coEvery { repo.register(any(), any(), any()) } returns flowOf( 53 | Result.success(User(1, "milad", "09123456789")) 54 | ) 55 | 56 | // Act 57 | useCase("09123456789", "milad", "123456") 58 | 59 | // Assert 60 | coVerify { repo.register("09123456789", "milad", "123456") } 61 | .wasInvoked(atLeast = 1) 62 | } 63 | 64 | }) -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/org/milad/expense_share/di/koinModules.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.di 2 | 3 | import di.dataAggregator 4 | import org.koin.core.module.dsl.viewModel 5 | import org.koin.dsl.module 6 | import org.milad.expense_share.auth.login.LoginViewModel 7 | import org.milad.expense_share.auth.register.RegisterViewModel 8 | import org.milad.expense_share.dashboard.DashboardViewModel 9 | import usecase.auth.LoginUserUseCase 10 | import usecase.auth.RegisterUserUseCase 11 | import usecase.friends.GetFriendsUseCase 12 | import usecase.groups.CreateGroupUseCase 13 | import usecase.groups.GetGroupsUseCase 14 | import usecase.transactions.ApproveTransactionUseCase 15 | import usecase.transactions.CreateTransactionUseCase 16 | import usecase.transactions.DeleteTransactionUseCase 17 | import usecase.transactions.GetTransactionsUseCase 18 | import usecase.transactions.RejectTransactionUseCase 19 | import usecase.user.GetUserInfoUseCase 20 | 21 | val domainModule = module { 22 | factory { GetGroupsUseCase(get()) } 23 | factory { RegisterUserUseCase(get()) } 24 | factory { LoginUserUseCase(get()) } 25 | factory { CreateGroupUseCase(get()) } 26 | factory { GetFriendsUseCase(get()) } 27 | factory { GetUserInfoUseCase(get()) } 28 | 29 | factory { CreateTransactionUseCase(get()) } 30 | factory { GetTransactionsUseCase(get()) } 31 | factory { ApproveTransactionUseCase(get()) } 32 | factory { DeleteTransactionUseCase(get()) } 33 | factory { RejectTransactionUseCase(get()) } 34 | 35 | } 36 | val dashboardModule = module { 37 | viewModel { 38 | DashboardViewModel( 39 | get(), 40 | get(), 41 | get(), 42 | get(), 43 | get(), 44 | get(), 45 | get(), 46 | get() 47 | ) 48 | } 49 | } 50 | val registerModule = module { 51 | viewModel { 52 | RegisterViewModel( 53 | get() 54 | ) 55 | } 56 | } 57 | val loginModule = module { 58 | viewModel { 59 | LoginViewModel( 60 | get() 61 | ) 62 | } 63 | } 64 | 65 | val appModules = module { 66 | includes(domainModule) 67 | includes(dataAggregator) 68 | includes(dashboardModule, registerModule, loginModule) 69 | } -------------------------------------------------------------------------------- /core/domain/src/commonTest/kotlin/usecase/groups/UpdateGroupMembersUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | package usecase.groups 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.shouldBe 5 | import io.mockative.coEvery 6 | import io.mockative.coVerify 7 | import io.mockative.mock 8 | import io.mockative.of 9 | import kotlinx.coroutines.flow.first 10 | import kotlinx.coroutines.flow.flowOf 11 | import repository.GroupsRepository 12 | 13 | class UpdateGroupMembersUseCaseTest : StringSpec({ 14 | val repo = mock(of()) 15 | val usecase = UpdateGroupMembersUseCase(repo) 16 | val groupsId = "0" 17 | val members = listOf("1", "2") 18 | 19 | "should complete successfully when repository succeeds" { 20 | // Arrange 21 | 22 | coEvery { 23 | repo.updateGroupMembers( 24 | groupId = groupsId, 25 | memberIds = members 26 | ) 27 | }.returns(flowOf(Result.success(Unit))) 28 | 29 | // Act 30 | val result = usecase( 31 | groupId = groupsId, 32 | memberIds = members 33 | ).first() 34 | 35 | // Assert 36 | result.isSuccess shouldBe true 37 | result.getOrNull() shouldBe Unit 38 | coVerify { 39 | repo.updateGroupMembers( 40 | groupId = groupsId, 41 | memberIds = members 42 | ) 43 | }.wasInvoked(exactly = 1) 44 | } 45 | "should return failure when repository fails" { 46 | // Arrange 47 | val error = RuntimeException("network error") 48 | 49 | coEvery { 50 | repo.updateGroupMembers( 51 | groupId = groupsId, 52 | memberIds = members 53 | ) 54 | }.returns(flowOf(Result.failure(error))) 55 | 56 | // Act 57 | val result = usecase( 58 | groupId = groupsId, 59 | memberIds = members 60 | ).first() 61 | 62 | // Assert 63 | result.isSuccess shouldBe false 64 | result.exceptionOrNull() shouldBe error 65 | coVerify { 66 | repo.updateGroupMembers( 67 | groupId = groupsId, 68 | memberIds = members 69 | ) 70 | } 71 | .wasInvoked(exactly = 1) 72 | } 73 | }) -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/domain/service/AuthService.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.domain.service 2 | 3 | import org.milad.expense_share.data.models.User 4 | import org.milad.expense_share.domain.model.AuthResponse 5 | import org.milad.expense_share.domain.repository.UserRepository 6 | import org.milad.expense_share.security.JwtConfig 7 | import org.mindrot.jbcrypt.BCrypt 8 | 9 | class AuthService( 10 | private val userRepository: UserRepository, 11 | ) { 12 | fun register(username: String, phone: String, password: String): Result { 13 | return try { 14 | if (userRepository.findByPhone(phone) != null) { 15 | return Result.failure(IllegalArgumentException("Phone already registered")) 16 | } 17 | 18 | val passwordHash = hashPassword(password) 19 | val user = User( 20 | id = userRepository.lastIndexOfUser() + 1, 21 | username = username, 22 | phone = phone 23 | ) 24 | userRepository.create(user, passwordHash) 25 | 26 | val token = JwtConfig.generateToken(user) 27 | Result.success( 28 | AuthResponse( 29 | token = token, 30 | user = user 31 | ) 32 | ) 33 | } catch (e: Exception) { 34 | Result.failure(e) 35 | } 36 | } 37 | 38 | fun login(phone: String, password: String): Result { 39 | return try { 40 | val user = 41 | userRepository.verifyUser(phone, checkPassword = { checkPassword(password, it) }) 42 | ?: return Result.failure(IllegalArgumentException("Invalid phone or password")) 43 | 44 | val token = JwtConfig.generateToken(user) 45 | Result.success( 46 | AuthResponse( 47 | token = token, 48 | user = user 49 | ) 50 | ) 51 | } catch (e: Exception) { 52 | Result.failure(e) 53 | } 54 | } 55 | 56 | private fun hashPassword(password: String): String = 57 | BCrypt.hashpw(password, BCrypt.gensalt()) 58 | 59 | private fun checkPassword(password: String, hash: String): Boolean = 60 | BCrypt.checkpw(password, hash) 61 | } -------------------------------------------------------------------------------- /core/common/src/commonMain/kotlin/com/pmb/common/ui/scaffold/components/CompactAddGroupButton.kt: -------------------------------------------------------------------------------- 1 | package com.pmb.common.ui.scaffold.components 2 | 3 | import androidx.compose.foundation.layout.fillMaxWidth 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.foundation.layout.size 6 | import androidx.compose.material.icons.Icons 7 | import androidx.compose.material.icons.filled.Add 8 | import androidx.compose.material3.ExtendedFloatingActionButton 9 | import androidx.compose.material3.FloatingActionButton 10 | import androidx.compose.material3.Icon 11 | import androidx.compose.material3.MaterialTheme 12 | import androidx.compose.material3.Text 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.text.style.TextAlign 16 | import androidx.compose.ui.unit.dp 17 | 18 | /** 19 | * Compact FAB for Navigation Rail 20 | */ 21 | @Composable 22 | fun CompactAddGroupButton( 23 | onClick: () -> Unit, 24 | modifier: Modifier = Modifier 25 | ) { 26 | FloatingActionButton( 27 | onClick = onClick, 28 | modifier = modifier.padding(top = 8.dp, bottom = 32.dp), 29 | containerColor = MaterialTheme.colorScheme.primary, 30 | contentColor = MaterialTheme.colorScheme.onPrimary, 31 | ) { 32 | Icon( 33 | imageVector = Icons.Default.Add, 34 | contentDescription = "Add Group", 35 | modifier = Modifier.size(18.dp), 36 | ) 37 | } 38 | } 39 | 40 | /** 41 | * Extended FAB for Drawer layouts 42 | */ 43 | @Composable 44 | fun ExtendedAddGroupButton( 45 | onClick: () -> Unit, 46 | modifier: Modifier = Modifier 47 | ) { 48 | ExtendedFloatingActionButton( 49 | onClick = onClick, 50 | modifier = modifier 51 | .fillMaxWidth() 52 | .padding(top = 8.dp, bottom = 40.dp), 53 | containerColor = MaterialTheme.colorScheme.primary, 54 | contentColor = MaterialTheme.colorScheme.onPrimary, 55 | ) { 56 | Icon( 57 | imageVector = Icons.Default.Add, 58 | contentDescription = "Add Group", 59 | modifier = Modifier.size(18.dp), 60 | ) 61 | Text( 62 | text = "Add Group", 63 | modifier = Modifier.weight(1f), 64 | textAlign = TextAlign.Center, 65 | ) 66 | } 67 | } -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 | # Project Structure 2 | 3 | This project follows a layered architecture for better separation of concerns and scalability. 4 | 5 | ``` 6 | src/ 7 | └── main/ 8 | ├── kotlin/ 9 | │ └── org/milad/expense_share/ 10 | │ ├── application/ 11 | │ │ ├── Application.kt # Entry point (main/module) 12 | │ │ └── plugins/ # Ktor plugins (Auth, Routing, Serialization, etc.) 13 | │ │ 14 | │ ├── data/ 15 | │ │ ├── db/ # Database configuration and migrations 16 | │ │ ├── repository/ # Repository implementations (e.g., InMemoryUserRepository) 17 | │ │ └── entity/ # Database entities 18 | │ │ 19 | │ ├── domain/ 20 | │ │ ├── model/ # Core business models (User, Transaction, etc.) 21 | │ │ ├── repository/ # Repository interfaces (e.g., UserRepository) 22 | │ │ └── service/ # Business logic (e.g., AuthService, UserService) 23 | │ │ 24 | │ ├── presentation/ 25 | │ │ └── auth/ # HTTP routes/controllers (e.g., authRoutes.kt) 26 | │ │ 27 | │ ├── security/ # Authentication & authorization (JwtConfig, AuthConfig) 28 | │ │ 29 | │ └── utils/ # Validation, error handling, helper utilities 30 | │ 31 | └── resources/ 32 | ├── application.conf # Application configuration 33 | └── db/migrations/ # Database migration scripts (Flyway/Liquibase) 34 | ``` 35 | 36 | --- 37 | 38 | ## Layer Responsibilities 39 | 40 | - **application/** → Application entry point and Ktor plugin setup. 41 | - **data/** → Data persistence logic and database integration. 42 | - **domain/** → Core business logic, models, repository contracts, and services. 43 | - **presentation/** → API routes and controllers (only handle HTTP request/response). 44 | - **security/** → Authentication and authorization logic. 45 | - **utils/** → Shared utilities (validation, error response handling, etc.). 46 | - **resources/** → Configuration and migration scripts. 47 | -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/domain/service/TransactionService.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.domain.service 2 | 3 | import org.milad.expense_share.Amount 4 | import org.milad.expense_share.data.models.Transaction 5 | import org.milad.expense_share.domain.repository.TransactionRepository 6 | import org.milad.expense_share.presentation.transactions.model.PayerRequest 7 | import org.milad.expense_share.presentation.transactions.model.ShareDetailsRequest 8 | 9 | class TransactionService(private val transactionRepository: TransactionRepository) { 10 | 11 | fun createTransaction( 12 | groupId: Int, 13 | userId: Int, 14 | title: String, 15 | amount: Amount, 16 | description: String, 17 | payers: List, 18 | shareDetails: ShareDetailsRequest, 19 | ): Result { 20 | val transaction = 21 | transactionRepository.createTransaction( 22 | groupId, 23 | userId, 24 | title, 25 | amount, 26 | description, 27 | payers, 28 | shareDetails 29 | ) 30 | return transaction?.let { Result.success(it) } 31 | ?: Result.failure(IllegalStateException("Group not found or access denied")) 32 | } 33 | 34 | fun getTransactions(userId: Int, groupId: Int): List = 35 | transactionRepository.getTransactions(userId, groupId) 36 | 37 | fun approve(transactionId: Int, userId: Int): Result = 38 | if (transactionRepository.approveTransaction(transactionId, userId)) 39 | Result.success("Transaction approved successfully") 40 | else Result.failure(IllegalAccessException("Only group owner can approve")) 41 | 42 | fun reject(transactionId: Int, userId: Int): Result = 43 | if (transactionRepository.rejectTransaction(transactionId, userId)) 44 | Result.success("Transaction rejected successfully") 45 | else Result.failure(IllegalAccessException("Only group owner can reject")) 46 | 47 | fun delete(transactionId: Int, userId: Int): Result = 48 | if (transactionRepository.deleteTransaction(transactionId, userId)) 49 | Result.success("Transaction deleted successfully") 50 | else Result.failure(IllegalAccessException("Only group owner can delete")) 51 | } 52 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/org/milad/expense_share/friends/FriendsScreen.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.friends 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.material3.MaterialTheme 9 | import androidx.compose.material3.Text 10 | import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi 11 | import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold 12 | import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.runtime.rememberCoroutineScope 15 | import androidx.compose.ui.Alignment 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.text.style.TextAlign 18 | import androidx.compose.ui.unit.dp 19 | import org.jetbrains.compose.ui.tooling.preview.Preview 20 | 21 | @OptIn(ExperimentalMaterial3AdaptiveApi::class) 22 | @Composable 23 | fun FriendsScreen( 24 | // viewModel: FriendsViewModel = koinViewModel(), 25 | ) { 26 | val navigator = rememberListDetailPaneScaffoldNavigator() 27 | val scope = rememberCoroutineScope() 28 | 29 | 30 | 31 | ListDetailPaneScaffold( 32 | modifier = Modifier.background(color = MaterialTheme.colorScheme.background), 33 | directive = navigator.scaffoldDirective, 34 | value = navigator.scaffoldValue, 35 | listPane = { 36 | Column( 37 | modifier = Modifier 38 | .fillMaxSize(), 39 | verticalArrangement = Arrangement.Center, 40 | horizontalAlignment = Alignment.CenterHorizontally, 41 | ) { 42 | Text( 43 | modifier = Modifier.padding(8.dp), 44 | text = "Screen under construction", 45 | style = MaterialTheme.typography.titleLarge, 46 | textAlign = TextAlign.Center, 47 | color = MaterialTheme.colorScheme.primary, 48 | ) 49 | } 50 | }, 51 | detailPane = { 52 | 53 | } 54 | ) 55 | } 56 | 57 | @Preview 58 | @Composable 59 | fun FriendsScreenPreview() { 60 | FriendsScreen() 61 | } -------------------------------------------------------------------------------- /core/network/src/commonMain/kotlin/client/NetworkClient.kt: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import getKtorEngine 4 | import io.ktor.client.HttpClient 5 | import io.ktor.client.engine.HttpClientEngine 6 | import io.ktor.client.plugins.HttpTimeout 7 | import io.ktor.client.plugins.contentnegotiation.ContentNegotiation 8 | import io.ktor.client.plugins.defaultRequest 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.client.request.header 13 | import io.ktor.http.ContentType 14 | import io.ktor.http.HttpHeaders 15 | import io.ktor.http.contentType 16 | import io.ktor.serialization.kotlinx.json.json 17 | import kotlinx.serialization.json.Json 18 | import plugin.installErrorHandler 19 | import token.TokenProvider 20 | 21 | data class HttpConfig( 22 | val baseUrl: String = "http://0.0.0.0:8082", 23 | val timeoutMillis: Long = 15000, 24 | val isDebug: Boolean = true, 25 | val refreshTokenEndpoint: String = "/auth/refresh" // FIXME 26 | ) 27 | 28 | fun createHttpClient( 29 | tokenProvider: TokenProvider, 30 | config: HttpConfig = HttpConfig(), 31 | engine: HttpClientEngine? = null 32 | ) = HttpClient(engine = engine ?: getKtorEngine()) { 33 | defaultRequest { 34 | url(config.baseUrl) 35 | contentType(ContentType.Application.Json) 36 | 37 | tokenProvider.loadTokens()?.let { 38 | header(HttpHeaders.Authorization, "Bearer ${it.accessToken}") 39 | } 40 | } 41 | install(ContentNegotiation) { 42 | json(Json { 43 | prettyPrint = config.isDebug 44 | isLenient = true 45 | ignoreUnknownKeys = true 46 | }) 47 | } 48 | install(HttpTimeout) { 49 | requestTimeoutMillis = config.timeoutMillis 50 | connectTimeoutMillis = config.timeoutMillis 51 | socketTimeoutMillis = config.timeoutMillis 52 | } 53 | install(Logging) { 54 | logger = object : Logger { 55 | override fun log(message: String) { 56 | print("\nktor: $message\n") 57 | } 58 | } 59 | level = LogLevel.ALL 60 | } 61 | 62 | // remove handle token 63 | /* install(Auth) { 64 | bearer { 65 | loadTokens { tokenProvider.loadTokens() } 66 | refreshTokens { null } 67 | } 68 | }*/ 69 | 70 | installErrorHandler() 71 | } -------------------------------------------------------------------------------- /core/domain/src/commonTest/kotlin/usecase/transactions/CreateTransactionUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | package usecase.transactions 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.shouldBe 5 | import io.mockative.coEvery 6 | import io.mockative.coVerify 7 | import io.mockative.mock 8 | import io.mockative.of 9 | import kotlinx.coroutines.flow.first 10 | import kotlinx.coroutines.flow.flowOf 11 | import model.Transaction 12 | import model.TransactionStatus 13 | import repository.TransactionsRepository 14 | 15 | class CreateTransactionUseCaseTest : StringSpec({ 16 | val repo = mock(of()) 17 | val usecase = CreateTransactionUseCase(repo) 18 | 19 | "should return created transaction when repository succeeds" { 20 | val groupId = "123" 21 | val transaction = Transaction( 22 | id = 1, 23 | groupId = 123, 24 | title = "Dinner at Restaurant", 25 | amount = 250.0, 26 | description = "Team dinner with colleagues", 27 | createdBy = 42, 28 | status = TransactionStatus.PENDING, 29 | createdAt = 0, 30 | transactionDate = 0, 31 | approvedBy = null 32 | ) 33 | 34 | coEvery { repo.createTransaction(groupId, "Dinner at Restaurant", 250.0, "Team dinner with colleagues") } 35 | .returns(flowOf(Result.success(transaction))) 36 | 37 | val result = usecase(groupId, "Dinner at Restaurant", 250.0, "Team dinner with colleagues").first() 38 | 39 | result.isSuccess shouldBe true 40 | result.getOrNull() shouldBe transaction 41 | coVerify { repo.createTransaction(groupId, "Dinner at Restaurant", 250.0, "Team dinner with colleagues") } 42 | .wasInvoked(exactly = 1) 43 | } 44 | 45 | "should return failure when repository fails" { 46 | val groupId = "123" 47 | val error = RuntimeException("network error") 48 | 49 | coEvery { repo.createTransaction(groupId, "Dinner at Restaurant", 250.0, "Team dinner with colleagues") } 50 | .returns(flowOf(Result.failure(error))) 51 | 52 | val result = usecase(groupId, "Dinner at Restaurant", 250.0, "Team dinner with colleagues").first() 53 | 54 | result.isSuccess shouldBe false 55 | result.exceptionOrNull() shouldBe error 56 | coVerify { repo.createTransaction(groupId, "Dinner at Restaurant", 250.0, "Team dinner with colleagues") } 57 | .wasInvoked(exactly = 1) 58 | } 59 | }) 60 | -------------------------------------------------------------------------------- /core/navigation/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl 2 | import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig 3 | 4 | plugins { 5 | alias(libs.plugins.kotlinMultiplatform) 6 | alias(libs.plugins.androidMultiplatformLibrary) 7 | kotlin("plugin.serialization") version "2.2.21" 8 | alias(libs.plugins.kotest) 9 | alias(libs.plugins.ksp) 10 | alias(libs.plugins.jacoco) 11 | } 12 | 13 | kotlin { 14 | androidLibrary { 15 | namespace = "com.pmb.expense_share.navigation" 16 | compileSdk = libs.versions.android.compileSdk.get().toInt() 17 | minSdk = libs.versions.android.minSdk.get().toInt() 18 | } 19 | 20 | listOf( 21 | iosX64(), 22 | iosArm64(), 23 | iosSimulatorArm64() 24 | ).forEach { iosTarget -> 25 | iosTarget.binaries.framework { 26 | baseName = "ComposeApp" 27 | isStatic = true 28 | } 29 | } 30 | 31 | jvm() 32 | 33 | @OptIn(ExperimentalWasmDsl::class) 34 | wasmJs { 35 | browser { 36 | val rootDirPath = project.rootDir.path 37 | val projectDirPath = project.projectDir.path 38 | commonWebpackConfig { 39 | devServer = (devServer ?: KotlinWebpackConfig.DevServer()).apply { 40 | static = (static ?: mutableListOf()).apply { 41 | // Serve sources to debug inside browser 42 | add(rootDirPath) 43 | add(projectDirPath) 44 | } 45 | } 46 | } 47 | } 48 | } 49 | 50 | sourceSets { 51 | commonMain.dependencies { 52 | implementation(libs.kotlinx.serialization.json) 53 | } 54 | tasks.withType().configureEach { 55 | useJUnitPlatform() 56 | filter { 57 | isFailOnNoMatchingTests = false 58 | } 59 | testLogging { 60 | showExceptions = true 61 | showStandardStreams = true 62 | events = setOf( 63 | org.gradle.api.tasks.testing.logging.TestLogEvent.FAILED, 64 | org.gradle.api.tasks.testing.logging.TestLogEvent.PASSED 65 | ) 66 | exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL 67 | } 68 | } 69 | } 70 | } 71 | 72 | jacoco { 73 | toolVersion = libs.versions.jacoco.get() 74 | } 75 | -------------------------------------------------------------------------------- /core/network/src/commonTest/kotlin/client/ApiClientTest.kt: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import io.kotest.assertions.throwables.shouldThrow 4 | import io.kotest.core.spec.style.StringSpec 5 | import io.kotest.matchers.shouldBe 6 | import io.ktor.client.request.HttpRequestBuilder 7 | import io.ktor.client.statement.HttpResponse 8 | import io.ktor.http.HttpStatusCode 9 | import io.mockative.Mockable 10 | import io.mockative.any 11 | import io.mockative.coEvery 12 | import io.mockative.coVerify 13 | import io.mockative.every 14 | import io.mockative.mock 15 | import io.mockative.of 16 | import kotlinx.coroutines.test.runTest 17 | 18 | 19 | class ApiClientTest : StringSpec() { 20 | private val apiClient = mock(of()) 21 | private val res = mock(of()) 22 | 23 | init { 24 | "should mock POST and return custom response" { 25 | runTest { 26 | 27 | every { res.status }.returns(HttpStatusCode.OK) 28 | 29 | coEvery { apiClient.post(any Unit>()) } 30 | .returns(res) 31 | 32 | val response = apiClient.post { /* builder mock */ } 33 | response.status shouldBe HttpStatusCode.OK 34 | } 35 | } 36 | 37 | "should mock GET and simulate failure" { 38 | runTest { 39 | coEvery { apiClient.get(any()) } throws Exception("Network error") 40 | 41 | shouldThrow { 42 | apiClient.get { /* builder */ } 43 | }.message shouldBe "Network error" 44 | } 45 | } 46 | 47 | "should verify PUT call with builder" { 48 | runTest { 49 | every { res.status }.returns(HttpStatusCode.OK) 50 | 51 | coEvery { apiClient.put(any Unit>()) } 52 | .returns(res) 53 | 54 | apiClient.put { /* simulate builder */ } 55 | 56 | coVerify { apiClient.put(any()) }.wasInvoked(exactly = 1) 57 | } 58 | } 59 | 60 | "should stub DELETE and check invocation" { 61 | runTest { 62 | 63 | every { res.status }.returns(HttpStatusCode.NoContent) 64 | 65 | coEvery { apiClient.delete(any Unit>()) } 66 | .returns(res) 67 | 68 | val response = apiClient.delete { /* builder */ } 69 | response.status shouldBe HttpStatusCode.NoContent 70 | 71 | coVerify { apiClient.delete(any()) }.wasInvoked(1) 72 | } 73 | } 74 | } 75 | } -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/data/db/table/Transactions.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.data.db.table 2 | 3 | import org.jetbrains.exposed.sql.Column 4 | import org.jetbrains.exposed.sql.ColumnType 5 | import org.jetbrains.exposed.sql.ReferenceOption 6 | import org.jetbrains.exposed.sql.Table 7 | import org.milad.expense_share.Amount 8 | import org.milad.expense_share.data.models.TransactionStatus 9 | import java.sql.ResultSet 10 | 11 | object Transactions : Table("transactions") { 12 | val id = integer("id").autoIncrement() 13 | val groupId = integer("group_id") references Groups.id 14 | val title = varchar("title", 255) 15 | val amount = amount("amount") 16 | val description = text("description") 17 | val createdBy = integer("created_by") references Users.id 18 | val status = enumerationByName("status", 20, TransactionStatus::class) 19 | val approvedBy = integer("approved_by").nullable() 20 | val createdAt = long("created_at") 21 | val transactionDate = long("transaction_date") 22 | override val primaryKey = PrimaryKey(id) 23 | } 24 | 25 | object TransactionPayers : Table("transaction_payers") { 26 | val id = integer("id").autoIncrement() 27 | val transactionId = integer("transaction_id").references(Transactions.id, onDelete = ReferenceOption.CASCADE) 28 | val userId = integer("user_id") references Users.id 29 | val amountPaid = amount("amount_paid") 30 | 31 | override val primaryKey = PrimaryKey(id) 32 | } 33 | 34 | object TransactionShares : Table("transaction_shares") { 35 | val id = integer("id").autoIncrement() 36 | val transactionId = integer("transaction_id").references(Transactions.id, onDelete = ReferenceOption.CASCADE) 37 | val type = varchar("type", 20) 38 | 39 | override val primaryKey = PrimaryKey(id) 40 | } 41 | 42 | object TransactionShareMembers : Table("transaction_share_members") { 43 | val id = integer("id").autoIncrement() 44 | val shareId = integer("share_id").references(TransactionShares.id, onDelete = ReferenceOption.CASCADE) 45 | val userId = integer("user_id") references Users.id 46 | val share = amount("share") 47 | 48 | override val primaryKey = PrimaryKey(id) 49 | } 50 | 51 | class AmountColumnType : ColumnType() { 52 | override fun sqlType(): String = "BIGINT" 53 | 54 | override fun valueFromDB(value: Any): Amount = when (value) { 55 | is Long -> Amount(value) 56 | is Number -> Amount(value.toLong()) 57 | else -> error("Unexpected value of type ${value::class} for Amount") 58 | } 59 | 60 | override fun notNullValueToDB(value: Amount): Any = value.value 61 | 62 | override fun readObject(rs: ResultSet, index: Int): Any? = rs.getLong(index) 63 | } 64 | 65 | fun Table.amount(name: String): Column = registerColumn(name, AmountColumnType()) -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/data/inMemoryRepository/InMemoryGroupRepository.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.data.inMemoryRepository 2 | 3 | import org.milad.expense_share.data.db.FakeDatabase.groupMembers 4 | import org.milad.expense_share.data.db.FakeDatabase.groups 5 | import org.milad.expense_share.data.models.Group 6 | import org.milad.expense_share.data.models.GroupMember 7 | import org.milad.expense_share.domain.repository.GroupRepository 8 | import org.milad.expense_share.presentation.groups.model.UserGroupResponse 9 | 10 | class InMemoryGroupRepository : GroupRepository { 11 | 12 | override fun createGroup( 13 | ownerId: Int, 14 | name: String, 15 | memberIds: List 16 | ): UserGroupResponse { 17 | val group = Group( 18 | id = groups.size + 1, 19 | name = name, 20 | ownerId = ownerId 21 | ) 22 | 23 | groups.add(group) 24 | 25 | groupMembers.add(GroupMember(groupId = group.id, userId = ownerId)) 26 | 27 | memberIds.forEach { memberId -> 28 | if (!groupMembers.any { it.groupId == group.id && it.userId == memberId }) { 29 | groupMembers.add(GroupMember(groupId = group.id, userId = memberId)) 30 | } 31 | } 32 | 33 | return UserGroupResponse( 34 | id = group.id, 35 | name = group.name, 36 | ownerId = group.ownerId 37 | ) 38 | } 39 | 40 | override fun addUsersToGroup( 41 | ownerId: Int, 42 | groupId: Int, 43 | memberIds: List 44 | ): Boolean { 45 | groups.find { it.id == groupId && it.ownerId == ownerId } 46 | ?: throw IllegalArgumentException("Group not found or you are not the owner") 47 | 48 | groupMembers.removeIf { it.groupId == groupId } 49 | 50 | groupMembers.add(GroupMember(groupId = groupId, userId = ownerId)) 51 | 52 | memberIds.forEach { memberId -> 53 | if (!groupMembers.any { it.groupId == groupId && it.userId == memberId }) { 54 | groupMembers.add(GroupMember(groupId = groupId, userId = memberId)) 55 | } 56 | } 57 | 58 | return true 59 | } 60 | 61 | override fun getUsersOfGroup(groupId: Int): List { 62 | return groupMembers.filter { it.groupId == groupId }.map { it.userId } 63 | } 64 | 65 | override fun getGroupsOfUser(userId: Int): List { 66 | val list = groups.filter { it.ownerId == userId }.map { group -> 67 | UserGroupResponse( 68 | id = group.id, 69 | name = group.name, 70 | ownerId = group.ownerId) 71 | } 72 | 73 | return list 74 | } 75 | 76 | override fun deleteGroup(ownerId: Int, groupId: Int): Boolean { 77 | val group = groups.find { it.ownerId == ownerId && it.id == groupId } 78 | 79 | return groups.remove(group) 80 | } 81 | 82 | } -------------------------------------------------------------------------------- /core/network/src/commonMain/kotlin/NetworkManager.kt: -------------------------------------------------------------------------------- 1 | import client.ApiClient 2 | import io.ktor.client.call.body 3 | import io.ktor.client.request.header 4 | import io.ktor.client.request.setBody 5 | import io.ktor.http.path 6 | import kotlinx.coroutines.delay 7 | import kotlinx.coroutines.flow.Flow 8 | import kotlinx.coroutines.flow.catch 9 | import kotlinx.coroutines.flow.flow 10 | import kotlinx.serialization.Serializable 11 | 12 | 13 | @Serializable 14 | data class SuccessResponse( 15 | val success: Boolean = true, 16 | val data: T 17 | ) 18 | 19 | typealias ApiResult = Flow> 20 | 21 | inline fun safeNetworkCall( 22 | crossinline block: suspend () -> SuccessResponse 23 | ): Flow> = flow { 24 | // delay(1500) 25 | val response = block() 26 | if (response.success) { 27 | emit(Result.success(response.data)) 28 | } else { 29 | emit(Result.failure(IllegalStateException("Request failed"))) 30 | } 31 | }.catch { e -> 32 | emit(Result.failure(e)) 33 | } 34 | 35 | class NetworkManager(val client: ApiClient) { 36 | 37 | suspend inline fun get( 38 | endpoint: String, 39 | params: Map = mapOf(), 40 | headers: Map = mapOf() 41 | ): ApiResult = safeNetworkCall { 42 | client.get { 43 | url { 44 | path(endpoint) 45 | params.forEach { (k, v) -> parameters.append(k, v) } 46 | } 47 | headers.forEach { (k, v) -> header(k, v) } 48 | }.body>() 49 | } 50 | 51 | suspend inline fun post( 52 | endpoint: String, 53 | body: Req? = null, 54 | headers: Map = mapOf() 55 | ): ApiResult = safeNetworkCall { 56 | client.post { 57 | url { path(endpoint) } 58 | headers.forEach { (k, v) -> header(k, v) } 59 | body?.let { setBody(it) } 60 | }.body>() 61 | } 62 | 63 | suspend inline fun put( 64 | endpoint: String, 65 | body: Req? = null, 66 | headers: Map = mapOf() 67 | ): ApiResult = safeNetworkCall { 68 | client.put { 69 | url { path(endpoint) } 70 | headers.forEach { (k, v) -> header(k, v) } 71 | body?.let { setBody(it) } 72 | }.body>() 73 | } 74 | 75 | suspend inline fun delete( 76 | endpoint: String, 77 | params: Map = mapOf(), 78 | headers: Map = mapOf() 79 | ): ApiResult = safeNetworkCall { 80 | client.delete { 81 | url { 82 | path(endpoint) 83 | params.forEach { (k, v) -> parameters.append(k, v) } 84 | } 85 | headers.forEach { (k, v) -> header(k, v) } 86 | }.body>() 87 | } 88 | } -------------------------------------------------------------------------------- /core/common/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl 2 | import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig 3 | 4 | plugins { 5 | alias(libs.plugins.kotlinMultiplatform) 6 | alias(libs.plugins.androidMultiplatformLibrary) 7 | alias(libs.plugins.composeMultiplatform) 8 | alias(libs.plugins.composeCompiler) 9 | } 10 | 11 | kotlin { 12 | androidLibrary { 13 | namespace = "org.milad.common" 14 | compileSdk = libs.versions.android.compileSdk.get().toInt() 15 | minSdk = libs.versions.android.minSdk.get().toInt() 16 | } 17 | 18 | listOf( 19 | iosX64(), 20 | iosArm64(), 21 | iosSimulatorArm64() 22 | ).forEach { iosTarget -> 23 | iosTarget.binaries.framework { 24 | baseName = "ComposeApp" 25 | isStatic = true 26 | } 27 | } 28 | 29 | jvm() 30 | 31 | @OptIn(ExperimentalWasmDsl::class) 32 | wasmJs { 33 | browser { 34 | 35 | val rootDirPath = project.rootDir.path 36 | val projectDirPath = project.projectDir.path 37 | commonWebpackConfig { 38 | devServer = (devServer ?: KotlinWebpackConfig.DevServer()).apply { 39 | static = (static ?: mutableListOf()).apply { 40 | // Serve sources to debug inside browser 41 | add(rootDirPath) 42 | add(projectDirPath) 43 | } 44 | } 45 | } 46 | } 47 | } 48 | sourceSets { 49 | commonMain.dependencies { 50 | implementation(libs.kotlinx.coroutines.core) 51 | implementation(libs.androidx.lifecycle.viewmodelCompose) 52 | implementation(compose.components.resources) 53 | implementation(compose.material3) 54 | implementation(libs.compose.material3.adaptive.navigation) 55 | implementation(libs.compose.material3.adaptive.layout) 56 | implementation(compose.material3AdaptiveNavigationSuite) 57 | implementation(libs.material.icons.extended) 58 | implementation(compose.ui) 59 | implementation(compose.foundation) 60 | implementation(compose.runtime) 61 | implementation(projects.core.navigation) 62 | } 63 | 64 | tasks.withType().configureEach { 65 | useJUnitPlatform() 66 | filter { 67 | isFailOnNoMatchingTests = false 68 | } 69 | testLogging { 70 | showExceptions = true 71 | showStandardStreams = true 72 | events = setOf( 73 | org.gradle.api.tasks.testing.logging.TestLogEvent.FAILED, 74 | org.gradle.api.tasks.testing.logging.TestLogEvent.PASSED 75 | ) 76 | exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL 77 | } 78 | } 79 | } 80 | } -------------------------------------------------------------------------------- /server/src/test/kotlin/AppModuleTest.kt: -------------------------------------------------------------------------------- 1 | import io.kotest.core.spec.style.DescribeSpec 2 | import io.kotest.koin.KoinExtension 3 | import io.kotest.matchers.shouldBe 4 | import io.kotest.matchers.shouldNotBe 5 | import org.koin.test.KoinTest 6 | import org.koin.test.inject 7 | import org.milad.expense_share.di.appModule 8 | import org.milad.expense_share.domain.repository.FriendRepository 9 | import org.milad.expense_share.domain.repository.GroupRepository 10 | import org.milad.expense_share.domain.repository.TransactionRepository 11 | import org.milad.expense_share.domain.repository.UserRepository 12 | import org.milad.expense_share.domain.service.AuthService 13 | import org.milad.expense_share.domain.service.FriendsService 14 | import org.milad.expense_share.domain.service.GroupService 15 | import org.milad.expense_share.domain.service.TransactionService 16 | 17 | class AppModuleTest : KoinTest, DescribeSpec() { 18 | init { 19 | extension(KoinExtension(module = appModule)) 20 | 21 | describe("Repository injection") { 22 | it("FriendRepository should be injected correctly") { 23 | val repo: FriendRepository by inject() 24 | repo shouldNotBe null 25 | } 26 | it("UserRepository should be injected correctly") { 27 | val repo: UserRepository by inject() 28 | repo shouldNotBe null 29 | } 30 | it("GroupRepository should be injected correctly") { 31 | val repo: GroupRepository by inject() 32 | repo shouldNotBe null 33 | } 34 | it("TransactionRepository should be injected correctly") { 35 | val repo: TransactionRepository by inject() 36 | repo shouldNotBe null 37 | } 38 | it("All repositories should be single instances") { 39 | val authRepo1: UserRepository by inject() 40 | val authRepo2: UserRepository by inject() 41 | authRepo1 shouldBe authRepo2 42 | } 43 | } 44 | 45 | describe("Service injection") { 46 | it("AuthService should be injected correctly") { 47 | val repo by inject() 48 | repo shouldNotBe null 49 | } 50 | it("FriendsService should be injected correctly") { 51 | val repo by inject() 52 | repo shouldNotBe null 53 | } 54 | it("GroupService should be injected correctly") { 55 | val repo by inject() 56 | repo shouldNotBe null 57 | } 58 | it("TransactionService should be injected correctly") { 59 | val repo by inject() 60 | repo shouldNotBe null 61 | } 62 | it("All services should be single instances") { 63 | val authRepo1 by inject() 64 | val authRepo2 by inject() 65 | authRepo1 shouldBe authRepo2 66 | } 67 | } 68 | } 69 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/org/milad/expense_share/auth/login/LoginViewModel.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.auth.login 2 | 3 | import androidx.lifecycle.viewModelScope 4 | import com.pmb.common.viewmodel.BaseViewAction 5 | import com.pmb.common.viewmodel.BaseViewEvent 6 | import com.pmb.common.viewmodel.BaseViewModel 7 | import com.pmb.common.viewmodel.BaseViewState 8 | import kotlinx.coroutines.launch 9 | import usecase.auth.LoginUserUseCase 10 | 11 | class LoginViewModel( 12 | private val loginUserUseCase: LoginUserUseCase 13 | ) : BaseViewModel( 14 | initialState = LoginState() 15 | ) { 16 | 17 | override fun handle(action: LoginAction) { 18 | when (action) { 19 | is LoginAction.NavigateBack -> postEvent(LoginEvent.NavigateToRegister) 20 | is LoginAction.UpdatePhone -> setState { it.copy(phone = action.value) } 21 | is LoginAction.UpdatePassword -> setState { it.copy(password = action.value) } 22 | is LoginAction.Login -> loginUser() 23 | } 24 | } 25 | 26 | private fun loginUser() { 27 | if (!validateForm()) return 28 | 29 | viewModelScope.launch { 30 | setState { it.copy(isLoading = true, error = null) } 31 | loginUserUseCase( 32 | phone = viewState.value.phone, 33 | password = viewState.value.password 34 | ).collect { result -> 35 | result.onSuccess { 36 | setState { it.copy(isLoading = false) } 37 | postEvent(LoginEvent.LoginSuccess) 38 | }.onFailure { e -> 39 | setState { it.copy(isLoading = false, error = e) } 40 | postEvent(LoginEvent.ShowToast("Error: ${e.message}")) 41 | } 42 | } 43 | } 44 | } 45 | 46 | private fun validateForm(): Boolean { 47 | var isValid = true 48 | val currentState = viewState.value 49 | 50 | if (currentState.phone.isBlank()) { 51 | setState { it.copy(phoneError = "Phone is required") } 52 | isValid = false 53 | } 54 | if (currentState.password.isBlank()) { 55 | setState { it.copy(passwordError = "Password is required") } 56 | isValid = false 57 | } 58 | 59 | return isValid 60 | } 61 | } 62 | 63 | sealed interface LoginAction : BaseViewAction { 64 | data object NavigateBack : LoginAction 65 | data class UpdatePhone(val value: String) : LoginAction 66 | data class UpdatePassword(val value: String) : LoginAction 67 | data object Login : LoginAction 68 | } 69 | 70 | data class LoginState( 71 | val phone: String = "09137511005", 72 | val phoneError: String? = null, 73 | val password: String = "milad", 74 | val passwordError: String? = null, 75 | val isLoading: Boolean = false, 76 | val error: Throwable? = null, 77 | ) : BaseViewState 78 | 79 | sealed interface LoginEvent : BaseViewEvent { 80 | data class ShowToast(val message: String) : LoginEvent 81 | data object LoginSuccess : LoginEvent 82 | data object NavigateToRegister : LoginEvent 83 | } -------------------------------------------------------------------------------- /server/src/main/kotlin/org/milad/expense_share/domain/service/GroupService.kt: -------------------------------------------------------------------------------- 1 | package org.milad.expense_share.domain.service 2 | 3 | import org.milad.expense_share.data.models.User 4 | import org.milad.expense_share.domain.repository.GroupRepository 5 | import org.milad.expense_share.domain.repository.TransactionRepository 6 | import org.milad.expense_share.domain.repository.UserRepository 7 | import org.milad.expense_share.presentation.groups.model.UserGroupResponse 8 | 9 | class GroupService( 10 | private val groupRepository: GroupRepository, 11 | private val userRepository: UserRepository, 12 | private val transactionRepository: TransactionRepository 13 | ) { 14 | 15 | fun createGroup(ownerId: Int, name: String, members: List): Result { 16 | return try { 17 | val group = groupRepository.createGroup(ownerId, name, members) 18 | Result.success(group) 19 | } catch (e: Exception) { 20 | Result.failure(e) 21 | } 22 | } 23 | 24 | fun getUserGroups(userId: Int): Result> { 25 | try { 26 | val listOfGroup = groupRepository.getGroupsOfUser(userId) 27 | val listOfUserGroupResponse = mutableListOf() 28 | 29 | listOfGroup.forEach { group -> 30 | val transactions = transactionRepository.getTransactions(userId, group.id) 31 | val members = getUsersOfGroup(group.id) 32 | 33 | val userGroup = UserGroupResponse( 34 | id = group.id, 35 | name = group.name, 36 | ownerId = group.ownerId, 37 | members = members, 38 | transactions = transactions 39 | ) 40 | 41 | listOfUserGroupResponse.add(userGroup) 42 | } 43 | return Result.success(listOfUserGroupResponse) 44 | 45 | } catch (e: Exception) { 46 | return Result.failure(e) 47 | } 48 | } 49 | 50 | fun getUsersOfGroup(groupId: Int): List { 51 | val userIds = groupRepository.getUsersOfGroup(groupId) 52 | val users = mutableListOf() 53 | userIds.forEach { userId -> 54 | val user = userRepository.findById(userId) 55 | if (user != null) { 56 | users.add(user) 57 | } 58 | } 59 | return users 60 | } 61 | 62 | fun addUsers(userId: Int, groupId: Int, memberIds: List): Result { 63 | return if (groupRepository.addUsersToGroup(userId, groupId, memberIds)) { 64 | Result.success("Users added successfully") 65 | } else { 66 | Result.failure(IllegalAccessException("Only group owner can add members")) 67 | } 68 | } 69 | 70 | fun deleteGroup(userId: Int, groupId: Int): Result { 71 | return if (groupRepository.deleteGroup(userId, groupId)) { 72 | Result.success("Group deleted successfully") 73 | } else { 74 | Result.failure(IllegalAccessException("Only group owner can delete the group")) 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /core/common/src/commonMain/kotlin/com/pmb/common/ui/emptyState/EmptyListState.kt: -------------------------------------------------------------------------------- 1 | package com.pmb.common.ui.emptyState 2 | 3 | 4 | import androidx.compose.animation.core.FastOutSlowInEasing 5 | import androidx.compose.animation.core.RepeatMode 6 | import androidx.compose.animation.core.animateFloat 7 | import androidx.compose.animation.core.infiniteRepeatable 8 | import androidx.compose.animation.core.rememberInfiniteTransition 9 | import androidx.compose.animation.core.tween 10 | import androidx.compose.foundation.layout.Arrangement 11 | import androidx.compose.foundation.layout.Column 12 | import androidx.compose.foundation.layout.Spacer 13 | import androidx.compose.foundation.layout.fillMaxSize 14 | import androidx.compose.foundation.layout.height 15 | import androidx.compose.foundation.layout.padding 16 | import androidx.compose.foundation.layout.size 17 | import androidx.compose.material.icons.Icons 18 | import androidx.compose.material.icons.outlined.Inbox 19 | import androidx.compose.material3.Icon 20 | import androidx.compose.material3.MaterialTheme 21 | import androidx.compose.material3.Text 22 | import androidx.compose.runtime.Composable 23 | import androidx.compose.runtime.getValue 24 | import androidx.compose.ui.Alignment 25 | import androidx.compose.ui.Modifier 26 | import androidx.compose.ui.draw.scale 27 | import androidx.compose.ui.text.style.TextAlign 28 | import androidx.compose.ui.unit.dp 29 | 30 | 31 | @Composable 32 | fun EmptyListState( 33 | modifier: Modifier = Modifier.fillMaxSize(), 34 | title: String = "No Items Found", 35 | subtitle: String = "There are no items to display right now. Try refreshing or create a new one.", 36 | ) { 37 | val colorScheme = MaterialTheme.colorScheme 38 | val typography = MaterialTheme.typography 39 | 40 | val transition = rememberInfiniteTransition(label = "empty_list_anim") 41 | val scale by transition.animateFloat( 42 | initialValue = 0.98f, 43 | targetValue = 1.02f, 44 | animationSpec = infiniteRepeatable( 45 | animation = tween(1400, easing = FastOutSlowInEasing), 46 | repeatMode = RepeatMode.Reverse 47 | ), 48 | label = "scale" 49 | ) 50 | 51 | Column( 52 | modifier = modifier 53 | .padding(32.dp) 54 | .scale(scale), 55 | horizontalAlignment = Alignment.CenterHorizontally, 56 | verticalArrangement = Arrangement.Center 57 | ) { 58 | 59 | Icon( 60 | imageVector = Icons.Outlined.Inbox, 61 | contentDescription = null, 62 | tint = colorScheme.primary.copy(alpha = 0.85f), 63 | modifier = Modifier.size(80.dp) 64 | ) 65 | 66 | Spacer(Modifier.height(20.dp)) 67 | 68 | Text( 69 | text = title, 70 | style = typography.titleLarge, 71 | color = colorScheme.onBackground 72 | ) 73 | 74 | Spacer(Modifier.height(8.dp)) 75 | 76 | Text( 77 | text = subtitle, 78 | style = typography.bodyMedium, 79 | color = colorScheme.onSurfaceVariant, 80 | textAlign = TextAlign.Center, 81 | modifier = Modifier.padding(horizontal = 24.dp) 82 | ) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /core/domain/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl 2 | import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig 3 | 4 | plugins { 5 | alias(libs.plugins.kotlinMultiplatform) 6 | alias(libs.plugins.androidMultiplatformLibrary) 7 | kotlin("plugin.serialization") version "2.2.21" 8 | alias(libs.plugins.kotest) 9 | alias(libs.plugins.ksp) 10 | id("io.mockative") 11 | alias(libs.plugins.jacoco) 12 | } 13 | 14 | jacoco { 15 | toolVersion = libs.versions.jacoco.get() 16 | } 17 | kotlin { 18 | androidLibrary { 19 | namespace = "org.milad.expense_share.domain" 20 | compileSdk = libs.versions.android.compileSdk.get().toInt() 21 | minSdk = libs.versions.android.minSdk.get().toInt() 22 | } 23 | 24 | listOf( 25 | iosX64(), 26 | iosArm64(), 27 | iosSimulatorArm64() 28 | ).forEach { iosTarget -> 29 | iosTarget.binaries.framework { 30 | baseName = "ComposeApp" 31 | isStatic = true 32 | } 33 | } 34 | 35 | jvm() 36 | 37 | @OptIn(ExperimentalWasmDsl::class) 38 | wasmJs { 39 | browser { 40 | val rootDirPath = project.rootDir.path 41 | val projectDirPath = project.projectDir.path 42 | commonWebpackConfig { 43 | devServer = (devServer ?: KotlinWebpackConfig.DevServer()).apply { 44 | static = (static ?: mutableListOf()).apply { 45 | // Serve sources to debug inside browser 46 | add(rootDirPath) 47 | add(projectDirPath) 48 | } 49 | } 50 | } 51 | } 52 | } 53 | 54 | sourceSets { 55 | commonMain.dependencies { 56 | implementation(libs.kotlinx.coroutines.core) 57 | implementation("io.mockative:mockative:3.0.1") 58 | implementation(libs.kotlinx.serialization.json) 59 | implementation(projects.core.currency) 60 | } 61 | commonTest { 62 | dependencies { 63 | implementation(libs.kotest.framework.engine) 64 | implementation(libs.kotest.assertions.core) 65 | implementation("io.mockative:mockative:3.0.1") 66 | } 67 | } 68 | jvmMain.dependencies { 69 | implementation(kotlin("reflect")) 70 | } 71 | jvmTest.dependencies { 72 | implementation(libs.kotest.runner.junit5) 73 | implementation(kotlin("reflect")) 74 | } 75 | tasks.withType().configureEach { 76 | useJUnitPlatform() 77 | filter { 78 | isFailOnNoMatchingTests = false 79 | } 80 | testLogging { 81 | showExceptions = true 82 | showStandardStreams = true 83 | events = setOf( 84 | org.gradle.api.tasks.testing.logging.TestLogEvent.FAILED, 85 | org.gradle.api.tasks.testing.logging.TestLogEvent.PASSED 86 | ) 87 | exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL 88 | } 89 | } 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 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | --------------------------------------------------------------------------------