├── 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 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
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 |
5 |
6 |
7 |
8 |
11 |
16 |
17 |
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 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
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 |
5 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
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 |
--------------------------------------------------------------------------------