From c1c61231f7df51d5b628ccc60388ac8b82a33fa7 Mon Sep 17 00:00:00 2001 From: Aleksey Ivanovsky Date: Sun, 17 Aug 2025 10:45:58 +0200 Subject: [PATCH 1/3] [BACKEND] Add request to update group member --- .../github/ai/split/client/ApiClient.scala | 19 +++++- .../ai/split/client/ApiClientMain.scala | 16 +++-- backend/api.sh | 66 +++++++++++++++---- .../split/api/request/PutMemberRequest.scala | 12 ++++ .../api/response/PutMemberResponse.scala | 13 ++++ .../scala/com/github/ai/split/Layers.scala | 4 +- .../main/scala/com/github/ai/split/Main.scala | 1 + .../split/data/db/dao/ExpenseEntityDao.scala | 9 --- .../ai/split/data/db/dao/UserEntityDao.scala | 13 ++++ .../domain/usecases/UpdateMemberUseCase.scala | 48 ++++++++++++++ .../controllers/MemberController.scala | 25 ++++++- .../presentation/routes/MemberRoutes.scala | 6 ++ 12 files changed, 198 insertions(+), 34 deletions(-) create mode 100644 backend/api/src/main/scala/com/github/ai/split/api/request/PutMemberRequest.scala create mode 100644 backend/api/src/main/scala/com/github/ai/split/api/response/PutMemberResponse.scala create mode 100644 backend/app/src/main/scala/com/github/ai/split/domain/usecases/UpdateMemberUseCase.scala diff --git a/backend/api-client/src/main/scala/com/github/ai/split/client/ApiClient.scala b/backend/api-client/src/main/scala/com/github/ai/split/client/ApiClient.scala index 51ae4ce..226eb7a 100644 --- a/backend/api-client/src/main/scala/com/github/ai/split/client/ApiClient.scala +++ b/backend/api-client/src/main/scala/com/github/ai/split/client/ApiClient.scala @@ -1,7 +1,7 @@ package com.github.ai.split.client import com.github.ai.split.api.{NewExpenseDto, UserNameDto, UserUidDto} -import com.github.ai.split.api.request.{PostExpenseRequest, PostGroupRequest, PostMemberRequest} +import com.github.ai.split.api.request.{PostExpenseRequest, PostGroupRequest, PostMemberRequest, PutMemberRequest} import zio.* import zio.json.* import zio.http.* @@ -124,6 +124,23 @@ class ApiClient( ) } + def putMember( + memberUid: String, + password: String = DefaultPassword, + newName: String + ): ApiResponse = { + client.request( + Request.put( + path = s"$baseUrl/member/$memberUid?password=$password", + body = Body.fromString( + PutMemberRequest( + name = newName + ).toJsonPretty + ) + ) + ) + } + def deleteExpense( expenseUid: String, password: String = DefaultPassword diff --git a/backend/api-client/src/main/scala/com/github/ai/split/client/ApiClientMain.scala b/backend/api-client/src/main/scala/com/github/ai/split/client/ApiClientMain.scala index 8f0f093..c6a0164 100644 --- a/backend/api-client/src/main/scala/com/github/ai/split/client/ApiClientMain.scala +++ b/backend/api-client/src/main/scala/com/github/ai/split/client/ApiClientMain.scala @@ -12,17 +12,18 @@ object ApiClientMain extends ZIOAppDefault { """ |Commands: | - |group Gets default group - |group [GROUP_UID] Gets group by GROUP_UID + |group Get default group + |group [GROUP_UID] Get group by GROUP_UID |gen-group Generate new test group with members and expenses | - |post-expense Creates new expense in default group - |gen-expense Generates new expense in default group - |delete-expense [EXPENSE_UID] Deletes expense by EXPENSE_UID + |post-expense Create new expense in default group + |gen-expense Generate new expense in default group + |delete-expense [EXPENSE_UID] Delete expense by EXPENSE_UID | - |post-member [GROUP_UID] [USER_NAME] Create new member with USER_NAME in GROUP_UID + |post-member [GROUP_UID] [NAME] Create new member with NAME in GROUP_UID + |update-member [MEMBER_UID] [NAME] Update member name by MEMBER_UID |gen-members [GROUP_UID] - |delete-member [MEMBER_UID] Deletes member by MEMBER_UID + |delete-member [MEMBER_UID] Delete member by MEMBER_UID |help Print help |""".stripMargin @@ -68,6 +69,7 @@ object ApiClientMain extends ZIOAppDefault { case s"delete-expense $expenseUid" => api.deleteExpense(expenseUid = expenseUid).run case s"post-member $groupUid $userName" => api.postMember(groupUid = groupUid, userName = userName).run + case s"update-member $memberUid $name" => api.putMember(memberUid = memberUid, newName = name).run case s"gen-members $groupUid" => { api.postMember(groupUid = groupUid, userName = "Mickey").run api.postMember(groupUid = groupUid, userName = "Donald").run diff --git a/backend/api.sh b/backend/api.sh index f7a3c52..261b8af 100755 --- a/backend/api.sh +++ b/backend/api.sh @@ -2,6 +2,7 @@ # Script to run the simple-split-api-client.jar # Checks if jar exists, assembles if needed, then runs with all passed arguments +# Use --rebuild to force rebuilding the jar file JAR_NAME="simple-split-api-client.jar" TARGET_DIR="api-client/target" @@ -11,24 +12,63 @@ find_jar() { find "$TARGET_DIR" -name "$JAR_NAME" -type f 2>/dev/null | head -1 } -# Check if jar file exists -JAR_PATH=$(find_jar) - -if [ -n "$JAR_PATH" ]; then - echo "Found jar file at: $JAR_PATH" - java -jar "$JAR_PATH" "$@" -else - echo "Jar file not found. Compiling..." +# Function to build the jar +build_jar() { + echo "Building jar file..." sbt apiClient/assembly -warn +} + +# Function to run the jar with arguments +run_jar() { + local jar_path="$1" + shift + echo "Running jar file at: $jar_path" + java -jar "$jar_path" "$@" +} + +# Check for --rebuild option +REBUILD=false +ARGS=() + +for arg in "$@"; do + if [ "$arg" = "--rebuild" ]; then + REBUILD=true + else + ARGS+=("$arg") + fi +done + +# If rebuild is requested, force rebuild +if [ "$REBUILD" = true ]; then + echo "Rebuild requested. Forcing jar rebuild..." + build_jar - # Check again after assembly JAR_PATH=$(find_jar) - if [ -n "$JAR_PATH" ]; then - echo "Assembly completed. Found jar file at: $JAR_PATH" - java -jar "$JAR_PATH" "$@" + run_jar "$JAR_PATH" "${ARGS[@]}" else - echo "Error: Could not find jar file even after assembly" + echo "Error: Could not find jar file after rebuild" exit 1 fi +else + # Normal flow: check if jar exists + JAR_PATH=$(find_jar) + + if [ -n "$JAR_PATH" ]; then + run_jar "$JAR_PATH" "${ARGS[@]}" + else + echo "Jar file not found. Compiling..." + build_jar + + # Check again after assembly + JAR_PATH=$(find_jar) + + if [ -n "$JAR_PATH" ]; then + echo "Assembly completed." + run_jar "$JAR_PATH" "${ARGS[@]}" + else + echo "Error: Could not find jar file even after assembly" + exit 1 + fi + fi fi \ No newline at end of file diff --git a/backend/api/src/main/scala/com/github/ai/split/api/request/PutMemberRequest.scala b/backend/api/src/main/scala/com/github/ai/split/api/request/PutMemberRequest.scala new file mode 100644 index 0000000..4c00bef --- /dev/null +++ b/backend/api/src/main/scala/com/github/ai/split/api/request/PutMemberRequest.scala @@ -0,0 +1,12 @@ +package com.github.ai.split.api.request + +import zio.json.{DeriveJsonDecoder, DeriveJsonEncoder, JsonDecoder, JsonEncoder} + +case class PutMemberRequest( + name: String +) + +object PutMemberRequest { + implicit val encoder: JsonEncoder[PutMemberRequest] = DeriveJsonEncoder.gen[PutMemberRequest] + implicit val decoder: JsonDecoder[PutMemberRequest] = DeriveJsonDecoder.gen[PutMemberRequest] +} diff --git a/backend/api/src/main/scala/com/github/ai/split/api/response/PutMemberResponse.scala b/backend/api/src/main/scala/com/github/ai/split/api/response/PutMemberResponse.scala new file mode 100644 index 0000000..23c8e3e --- /dev/null +++ b/backend/api/src/main/scala/com/github/ai/split/api/response/PutMemberResponse.scala @@ -0,0 +1,13 @@ +package com.github.ai.split.api.response + +import com.github.ai.split.api.GroupDto +import zio.json.{DeriveJsonDecoder, DeriveJsonEncoder, JsonDecoder, JsonEncoder} + +case class PutMemberResponse( + group: GroupDto +) + +object PutMemberResponse { + implicit val encoder: JsonEncoder[PutMemberResponse] = DeriveJsonEncoder.gen[PutMemberResponse] + implicit val decoder: JsonDecoder[PutMemberResponse] = DeriveJsonDecoder.gen[PutMemberResponse] +} diff --git a/backend/app/src/main/scala/com/github/ai/split/Layers.scala b/backend/app/src/main/scala/com/github/ai/split/Layers.scala index 50326d8..722d06b 100644 --- a/backend/app/src/main/scala/com/github/ai/split/Layers.scala +++ b/backend/app/src/main/scala/com/github/ai/split/Layers.scala @@ -29,6 +29,7 @@ import com.github.ai.split.domain.usecases.{ ResolveUserReferencesUseCase, UpdateExpenseUseCase, UpdateGroupUseCase, + UpdateMemberUseCase, ValidateExpenseUseCase, ValidateMemberNameUseCase } @@ -71,6 +72,7 @@ object Layers { val validateExpenseUseCase = ZLayer.fromFunction(ValidateExpenseUseCase(_, _)) val removeExpenseUseCase = ZLayer.fromFunction(RemoveExpenseUseCase(_)) val exportGroupDataUseCase = ZLayer.fromFunction(ExportGroupDataUseCase(_, _)) + val updateMemberUseCase = ZLayer.fromFunction(UpdateMemberUseCase(_, _, _, _)) // Response use cases val assembleGroupResponseUseCase = ZLayer.fromFunction(AssembleGroupResponseUseCase(_, _, _, _, _, _)) @@ -79,6 +81,6 @@ object Layers { // Controllers val groupController = ZLayer.fromFunction(GroupController(_, _, _, _, _, _, _, _, _, _)) - val memberController = ZLayer.fromFunction(MemberController(_, _, _, _, _, _, _)) + val memberController = ZLayer.fromFunction(MemberController(_, _, _, _, _, _, _, _)) val expenseController = ZLayer.fromFunction(ExpenseController(_, _, _, _, _, _, _)) } diff --git a/backend/app/src/main/scala/com/github/ai/split/Main.scala b/backend/app/src/main/scala/com/github/ai/split/Main.scala index 056ca06..66904bf 100644 --- a/backend/app/src/main/scala/com/github/ai/split/Main.scala +++ b/backend/app/src/main/scala/com/github/ai/split/Main.scala @@ -66,6 +66,7 @@ object Main extends ZIOAppDefault { Layers.validateExpenseUseCase, Layers.removeExpenseUseCase, Layers.exportGroupDataUseCase, + Layers.updateMemberUseCase, // Response assemblers use cases Layers.assembleGroupResponseUseCase, diff --git a/backend/app/src/main/scala/com/github/ai/split/data/db/dao/ExpenseEntityDao.scala b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/ExpenseEntityDao.scala index 06b982b..e5c027c 100644 --- a/backend/app/src/main/scala/com/github/ai/split/data/db/dao/ExpenseEntityDao.scala +++ b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/ExpenseEntityDao.scala @@ -16,15 +16,6 @@ class ExpenseEntityDao( import quill._ - def getAll(): IO[DomainError, List[ExpenseEntity]] = { - val query = quote { - querySchema[ExpenseEntity]("expenses") - } - - run(query) - .mapError(_.toDomainError()) - } - def getByUid(uid: ExpenseUid): IO[DomainError, ExpenseEntity] = { val query = quote { querySchema[ExpenseEntity]("expenses") diff --git a/backend/app/src/main/scala/com/github/ai/split/data/db/dao/UserEntityDao.scala b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/UserEntityDao.scala index 4c59e45..dc45c2f 100644 --- a/backend/app/src/main/scala/com/github/ai/split/data/db/dao/UserEntityDao.scala +++ b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/UserEntityDao.scala @@ -15,6 +15,7 @@ class UserEntityDao( import quill._ + // TODO: refactor def getAll(): IO[DomainError, List[UserEntity]] = { val query = quote { querySchema[UserEntity]("users") @@ -101,6 +102,18 @@ class UserEntityDao( .mapError(_.toDomainError()) } + def update(user: UserEntity): IO[DomainError, UserEntity] = { + val updateQuery = quote { + querySchema[UserEntity]("users") + .filter(_.uid == lift(user.uid)) + .updateValue(lift(user)) + } + + run(updateQuery) + .map(_ => user) + .mapError(_.toDomainError()) + } + // TODO: remove function and refactor def getUserUidToUserMap(): IO[DomainError, Map[UserUid, UserEntity]] = { for { diff --git a/backend/app/src/main/scala/com/github/ai/split/domain/usecases/UpdateMemberUseCase.scala b/backend/app/src/main/scala/com/github/ai/split/domain/usecases/UpdateMemberUseCase.scala new file mode 100644 index 0000000..3ad8d0c --- /dev/null +++ b/backend/app/src/main/scala/com/github/ai/split/domain/usecases/UpdateMemberUseCase.scala @@ -0,0 +1,48 @@ +package com.github.ai.split.domain.usecases + +import com.github.ai.split.data.db.dao.GroupMemberEntityDao +import com.github.ai.split.entity.db.MemberUid +import zio.* +import zio.direct.* +import com.github.ai.split.entity.exception.DomainError +import com.github.ai.split.entity.db.GroupMemberEntity +import com.github.ai.split.data.db.dao.UserEntityDao +import com.github.ai.split.data.db.repository.GroupRepository + +class UpdateMemberUseCase( + private val groupRepository: GroupRepository, + private val memberDao: GroupMemberEntityDao, + private val userDao: UserEntityDao, + private val validateMemberUseCase: ValidateMemberNameUseCase +) { + + def updateMember( + memberUid: MemberUid, + newName: String + ): IO[DomainError, GroupMemberEntity] = { + defer { + val member = memberDao.getByUid(memberUid).run + val members = groupRepository.getMembers(member.groupUid).run + val user = userDao.getByUid(member.userUid).run + + val currentNames = members + .filter(member => member.entity.uid != memberUid) + .map(member => member.user.name) + + validateMemberUseCase + .validateNewMembers( + currentMemberNames = currentNames, + newMemberNames = List(newName) + ) + .run + + userDao + .update( + user.copy(name = newName) + ) + .run + + member + } + } +} diff --git a/backend/app/src/main/scala/com/github/ai/split/presentation/controllers/MemberController.scala b/backend/app/src/main/scala/com/github/ai/split/presentation/controllers/MemberController.scala index 917c29d..fd15df6 100644 --- a/backend/app/src/main/scala/com/github/ai/split/presentation/controllers/MemberController.scala +++ b/backend/app/src/main/scala/com/github/ai/split/presentation/controllers/MemberController.scala @@ -5,10 +5,11 @@ import com.github.ai.split.domain.usecases.{ AddUserUseCase, AssembleGroupResponseUseCase, GetGroupUseCase, - RemoveMembersUseCase + RemoveMembersUseCase, + UpdateMemberUseCase } -import com.github.ai.split.api.request.PostMemberRequest -import com.github.ai.split.api.response.{DeleteMemberResponse, PostMemberResponse} +import com.github.ai.split.api.request.{PostMemberRequest, PutMemberRequest} +import com.github.ai.split.api.response.{DeleteMemberResponse, PostMemberResponse, PutMemberResponse} import com.github.ai.split.domain.AccessResolverService import com.github.ai.split.entity.db.{GroupUid, MemberUid} import com.github.ai.split.utils.{parse, parsePasswordParam, parseUid, parseUidFromUrl} @@ -16,6 +17,7 @@ import com.github.ai.split.entity.exception.DomainError import zio.* import zio.http.{Request, Response} import zio.json.* +import zio.direct.* class MemberController( private val accessResolver: AccessResolverService, @@ -24,6 +26,7 @@ class MemberController( private val addUserUseCase: AddUserUseCase, private val addMemberUseCase: AddMembersUseCase, private val removeMembersUseCase: RemoveMembersUseCase, + private val updateMemberUseCase: UpdateMemberUseCase, private val assembleGroupUseCase: AssembleGroupResponseUseCase ) { @@ -44,6 +47,22 @@ class MemberController( } yield Response.json(PostMemberResponse(groupDto).toJsonPretty) } + def updateMember( + request: Request + ): IO[DomainError, Response] = { + defer { + val password = parsePasswordParam(request).run + val memberUid = parseUidFromUrl(request).map(uid => MemberUid(uid)).run + val body = request.body.parse[PutMemberRequest].run + accessResolver.canAccessToMember(memberUid = memberUid, password = password).run + + val member = updateMemberUseCase.updateMember(memberUid = memberUid, newName = body.name).run + + val groupDto = assembleGroupUseCase.assembleGroupDto(groupUid = member.groupUid).run + Response.json(PutMemberResponse(groupDto).toJsonPretty) + } + } + def removeMember( request: Request ): IO[DomainError, Response] = { diff --git a/backend/app/src/main/scala/com/github/ai/split/presentation/routes/MemberRoutes.scala b/backend/app/src/main/scala/com/github/ai/split/presentation/routes/MemberRoutes.scala index 3f6e273..a52475f 100644 --- a/backend/app/src/main/scala/com/github/ai/split/presentation/routes/MemberRoutes.scala +++ b/backend/app/src/main/scala/com/github/ai/split/presentation/routes/MemberRoutes.scala @@ -14,6 +14,12 @@ object MemberRoutes { response <- controller.createMember(request).mapError(_.toDomainResponse) } yield response }, + Method.PUT / "member" / string("memberId") -> handler { (request: Request) => + for { + controller <- ZIO.service[MemberController] + response <- controller.updateMember(request).mapError(_.toDomainResponse) + } yield response + }, Method.DELETE / "member" / string("memberId") -> handler { (request: Request) => for { controller <- ZIO.service[MemberController] From 31a922d9b591f94867405033f914c3a771a9e669 Mon Sep 17 00:00:00 2001 From: Aleksey Ivanovsky Date: Sun, 17 Aug 2025 10:49:00 +0200 Subject: [PATCH 2/3] Update Kotlin API classes --- .../com/github/ai/split/api/request/PutMemberRequest.kt | 8 ++++++++ .../github/ai/split/api/response/PutMemberResponse.kt | 9 +++++++++ 2 files changed, 17 insertions(+) create mode 100644 android/backend-api/src/main/kotlin/com/github/ai/split/api/request/PutMemberRequest.kt create mode 100644 android/backend-api/src/main/kotlin/com/github/ai/split/api/response/PutMemberResponse.kt diff --git a/android/backend-api/src/main/kotlin/com/github/ai/split/api/request/PutMemberRequest.kt b/android/backend-api/src/main/kotlin/com/github/ai/split/api/request/PutMemberRequest.kt new file mode 100644 index 0000000..a1e4f5b --- /dev/null +++ b/android/backend-api/src/main/kotlin/com/github/ai/split/api/request/PutMemberRequest.kt @@ -0,0 +1,8 @@ +package com.github.ai.split.api.request + +import kotlinx.serialization.Serializable + +@Serializable +data class PutMemberRequest( + val name: String +) \ No newline at end of file diff --git a/android/backend-api/src/main/kotlin/com/github/ai/split/api/response/PutMemberResponse.kt b/android/backend-api/src/main/kotlin/com/github/ai/split/api/response/PutMemberResponse.kt new file mode 100644 index 0000000..44b6a7d --- /dev/null +++ b/android/backend-api/src/main/kotlin/com/github/ai/split/api/response/PutMemberResponse.kt @@ -0,0 +1,9 @@ +package com.github.ai.split.api.response + +import kotlinx.serialization.Serializable +import com.github.ai.split.api.GroupDto + +@Serializable +data class PutMemberResponse( + val group: GroupDto +) \ No newline at end of file From 0ee5080c968d44ccadd1ccf639d491dea9f4f170 Mon Sep 17 00:00:00 2001 From: Aleksey Ivanovsky Date: Tue, 19 Aug 2025 21:32:21 +0200 Subject: [PATCH 3/3] [ANDROID] Add member update --- .../simplesplit/android/data/api/ApiClient.kt | 13 ++ .../data/repository/MemberRepository.kt | 13 ++ .../groupEditor/GroupEditorInteractor.kt | 48 ++-- .../screens/groupEditor/GroupEditorScreen.kt | 217 ++++++++++++------ .../groupEditor/GroupEditorViewModel.kt | 154 +++++++++++-- .../groupEditor/model/GroupEditorIntent.kt | 5 +- .../groupEditor/model/GroupEditorState.kt | 5 +- .../screens/groupEditor/model/MemberItem.kt | 9 + android/app/src/main/res/values/strings.xml | 1 + 9 files changed, 356 insertions(+), 109 deletions(-) create mode 100644 android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/MemberItem.kt diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/data/api/ApiClient.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/data/api/ApiClient.kt index 99b6498..22c6f8e 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/data/api/ApiClient.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/data/api/ApiClient.kt @@ -11,6 +11,7 @@ import com.github.ai.split.api.request.PostGroupRequest import com.github.ai.split.api.request.PostMemberRequest import com.github.ai.split.api.request.PutExpenseRequest import com.github.ai.split.api.request.PutGroupRequest +import com.github.ai.split.api.request.PutMemberRequest import com.github.ai.split.api.response.DeleteExpenseResponse import com.github.ai.split.api.response.DeleteMemberResponse import com.github.ai.split.api.response.GetGroupsResponse @@ -19,6 +20,7 @@ import com.github.ai.split.api.response.PostGroupResponse import com.github.ai.split.api.response.PostMemberResponse import com.github.ai.split.api.response.PutExpenseResponse import com.github.ai.split.api.response.PutGroupResponse +import com.github.ai.split.api.response.PutMemberResponse import io.ktor.client.HttpClient class ApiClient( @@ -130,6 +132,17 @@ class ApiClient( url = "$baseUrl/member/$memberUid?password=$password" ) + suspend fun putMember( + memberUid: String, + password: String, + request: PutMemberRequest + ): Either = + httpClient.sendRequest( + type = RequestType.PUT, + url = "$baseUrl/member/$memberUid?password=$password", + body = Some(request) + ) + companion object { const val PROD_SERVER_URL = "https://api.simplesplitapp.link" const val DEBUG_SERVER_URL = "http://10.0.2.2:8080" diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/data/repository/MemberRepository.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/data/repository/MemberRepository.kt index e8d2418..ae4acce 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/data/repository/MemberRepository.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/data/repository/MemberRepository.kt @@ -4,8 +4,10 @@ import arrow.core.Either import com.github.ai.simplesplit.android.data.api.ApiClient import com.github.ai.simplesplit.android.model.exception.AppException import com.github.ai.split.api.request.PostMemberRequest +import com.github.ai.split.api.request.PutMemberRequest import com.github.ai.split.api.response.DeleteMemberResponse import com.github.ai.split.api.response.PostMemberResponse +import com.github.ai.split.api.response.PutMemberResponse class MemberRepository( private val api: ApiClient @@ -28,4 +30,15 @@ class MemberRepository( memberUid = memberUid, password = password ) + + suspend fun updateMember( + memberUid: String, + password: String, + request: PutMemberRequest + ): Either = + api.putMember( + memberUid = memberUid, + password = password, + request = request + ) } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorInteractor.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorInteractor.kt index f44ae74..965a936 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorInteractor.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorInteractor.kt @@ -12,6 +12,9 @@ import com.github.ai.split.api.UserNameDto import com.github.ai.split.api.request.PostGroupRequest import com.github.ai.split.api.request.PostMemberRequest import com.github.ai.split.api.request.PutGroupRequest +import com.github.ai.split.api.request.PutMemberRequest + +typealias UserUidAndName = Pair class GroupEditorInteractor( private val groupRepository: GroupRepository, @@ -28,29 +31,36 @@ class GroupEditorInteractor( credentials: GroupCredentials, newTitle: String?, newPassword: String?, - membersToRemove: List, - membersToAdd: List + memberUidsToRemove: List, + memberNamesToAdd: List, + membersToUpdate: List ): Either = either { - if (membersToAdd.isNotEmpty()) { - for (member in membersToAdd) { - memberRepository.createMember( - password = credentials.password, - request = PostMemberRequest( - groupUid = credentials.groupUid, - name = member - ) - ).bind() - } + for (memberName in memberNamesToAdd) { + memberRepository.createMember( + password = credentials.password, + request = PostMemberRequest( + groupUid = credentials.groupUid, + name = memberName + ) + ).bind() } - if (membersToRemove.isNotEmpty()) { - for (member in membersToRemove) { - memberRepository.removeMember( - memberUid = member, - password = credentials.password - ).bind() - } + for (memberUid in memberUidsToRemove) { + memberRepository.removeMember( + memberUid = memberUid, + password = credentials.password + ).bind() + } + + for ((memberUid, memberName) in membersToUpdate) { + memberRepository.updateMember( + memberUid = memberUid, + password = credentials.password, + request = PutMemberRequest( + name = memberName + ) + ).bind() } val group = if (newTitle != null || diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorScreen.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorScreen.kt index 61800e0..ad8455c 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorScreen.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorScreen.kt @@ -1,5 +1,6 @@ package com.github.ai.simplesplit.android.presentation.screens.groupEditor +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -17,7 +18,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -25,6 +25,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview import com.github.ai.simplesplit.android.R import com.github.ai.simplesplit.android.presentation.core.compose.AppTextField import com.github.ai.simplesplit.android.presentation.core.compose.CenteredBox @@ -32,15 +33,19 @@ import com.github.ai.simplesplit.android.presentation.core.compose.ErrorMessageC import com.github.ai.simplesplit.android.presentation.core.compose.ErrorState import com.github.ai.simplesplit.android.presentation.core.compose.TopBar import com.github.ai.simplesplit.android.presentation.core.compose.TopBarMenuItem +import com.github.ai.simplesplit.android.presentation.core.compose.preview.ThemedScreenPreview import com.github.ai.simplesplit.android.presentation.core.compose.rememberCallback import com.github.ai.simplesplit.android.presentation.core.compose.rememberOnClickedCallback import com.github.ai.simplesplit.android.presentation.core.compose.theme.AppIcon import com.github.ai.simplesplit.android.presentation.core.compose.theme.AppTheme import com.github.ai.simplesplit.android.presentation.core.compose.theme.ElementMargin import com.github.ai.simplesplit.android.presentation.core.compose.theme.HalfMargin +import com.github.ai.simplesplit.android.presentation.core.compose.theme.LightTheme +import com.github.ai.simplesplit.android.presentation.core.compose.theme.MediumMargin import com.github.ai.simplesplit.android.presentation.core.compose.theme.SmallMargin import com.github.ai.simplesplit.android.presentation.screens.groupEditor.model.GroupEditorIntent import com.github.ai.simplesplit.android.presentation.screens.groupEditor.model.GroupEditorState +import com.github.ai.simplesplit.android.presentation.screens.groupEditor.model.MemberItem @Composable fun GroupEditorScreen(viewModel: GroupEditorViewModel) { @@ -63,6 +68,7 @@ private fun GroupEditorScreen( val onMenuItemClick = rememberCallback { _: TopBarMenuItem -> onIntent.invoke(GroupEditorIntent.OnDoneClick) } + Scaffold( topBar = { TopBar( @@ -74,14 +80,13 @@ private fun GroupEditorScreen( ) } ) { padding -> - Surface( + Box( modifier = Modifier .fillMaxSize() .padding( top = padding.calculateTopPadding(), bottom = padding.calculateBottomPadding() - ), - color = AppTheme.theme.colors.background + ) ) { when (state) { GroupEditorState.Loading -> { @@ -115,6 +120,33 @@ private fun RenderDataContent( val onCloseErrorClick = rememberOnClickedCallback { onIntent.invoke(GroupEditorIntent.OnCloseErrorClick) } + val onApplyMemberEditClick = rememberOnClickedCallback { + onIntent.invoke(GroupEditorIntent.OnApplyMemberEditClick) + } + val onCancelMemberEditClick = rememberOnClickedCallback { + onIntent.invoke(GroupEditorIntent.OnCancelMemberEditClick) + } + val onAddClick = rememberOnClickedCallback { + onIntent.invoke(GroupEditorIntent.OnAddMemberClick) + } + val onTitleChange = rememberCallback { newTitle: String -> + onIntent.invoke(GroupEditorIntent.OnTitleChanged(newTitle)) + } + val onPasswordChange = rememberCallback { newPassword: String -> + onIntent.invoke(GroupEditorIntent.OnPasswordChanged(newPassword)) + } + val onConfirmPasswordChange = rememberCallback { newConfirmPassword: String -> + onIntent.invoke(GroupEditorIntent.OnConfirmPasswordChanged(newConfirmPassword)) + } + val onMemberChange = rememberCallback { newMember: String -> + onIntent.invoke(GroupEditorIntent.OnMemberChanged(newMember)) + } + val onPasswordToggleClick = rememberCallback { isVisible: Boolean -> + onIntent.invoke(GroupEditorIntent.OnPasswordToggleClick(isVisible)) + } + val onConfirmPasswordToggleClick = rememberCallback { isVisible: Boolean -> + onIntent.invoke(GroupEditorIntent.OnConfirmPasswordToggleClick(isVisible)) + } Column( modifier = Modifier @@ -133,10 +165,7 @@ private fun RenderDataContent( value = state.title, error = state.titleError, label = stringResource(R.string.group_name), - // TODO: remember callback - onValueChange = { newValue -> - onIntent.invoke(GroupEditorIntent.OnTitleChanged(newValue)) - }, + onValueChange = onTitleChange, modifier = Modifier.fillMaxWidth() ) @@ -148,13 +177,8 @@ private fun RenderDataContent( label = stringResource(R.string.password), isPasswordToggleEnabled = true, isPasswordVisible = state.isPasswordVisible, - // TODO: remember callbacks - onPasswordToggleClicked = { isVisible -> - onIntent.invoke(GroupEditorIntent.OnPasswordToggleClick(isVisible)) - }, - onValueChange = { newValue -> - onIntent.invoke(GroupEditorIntent.OnPasswordChanged(newValue)) - }, + onPasswordToggleClicked = onPasswordToggleClick, + onValueChange = onPasswordChange, modifier = Modifier.fillMaxWidth() ) @@ -166,45 +190,60 @@ private fun RenderDataContent( label = stringResource(R.string.confirm_password), isPasswordToggleEnabled = true, isPasswordVisible = state.isConfirmPasswordVisible, - // TODO: remember callbacks - onPasswordToggleClicked = { isVisible -> - onIntent.invoke(GroupEditorIntent.OnConfirmPasswordToggleClick(isVisible)) - }, - onValueChange = { newValue -> - onIntent.invoke(GroupEditorIntent.OnConfirmPasswordChanged(newValue)) - }, + onPasswordToggleClicked = onConfirmPasswordToggleClick, + onValueChange = onConfirmPasswordChange, modifier = Modifier.fillMaxWidth() ) Spacer(modifier = Modifier.height(SmallMargin)) Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically + modifier = Modifier.fillMaxWidth() ) { AppTextField( value = state.member, error = state.memberError, label = stringResource(R.string.member_name), - // TODO: remember callback - onValueChange = { newValue -> - onIntent.invoke(GroupEditorIntent.OnMemberChanged(newValue)) - }, + onValueChange = onMemberChange, modifier = Modifier.weight(1f) ) Spacer(modifier = Modifier.width(width = HalfMargin)) - IconButton( - // TODO: remember callback - onClick = { - onIntent.invoke(GroupEditorIntent.OnAddMemberClick) + if (state.isApplyButtonVisible) { + IconButton( + onClick = onApplyMemberEditClick, + modifier = Modifier.padding(top = MediumMargin) + ) { + Icon( + imageVector = AppIcon.CHECK.vector, + contentDescription = null + ) + } + } + + if (state.isAddButtonVisible) { + IconButton( + onClick = onAddClick, + modifier = Modifier.padding(top = MediumMargin) + ) { + Icon( + imageVector = AppIcon.ADD.vector, + contentDescription = null + ) + } + } + + if (state.isCancelButtonVisible) { + IconButton( + onClick = onCancelMemberEditClick, + modifier = Modifier.padding(top = MediumMargin) + ) { + Icon( + imageVector = AppIcon.CLOSE.vector, + contentDescription = null + ) } - ) { - Icon( - imageVector = AppIcon.ADD.vector, - contentDescription = null - ) } } @@ -213,35 +252,85 @@ private fun RenderDataContent( Spacer(modifier = Modifier.height(SmallMargin)) } - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = AppTheme.theme.colors.cardSecondaryBackground - ) + MemberItemComposable( + member = member, + index = index, + onIntent = onIntent + ) + } + } +} + +@Composable +private fun MemberItemComposable( + member: MemberItem, + index: Int, + onIntent: (intent: GroupEditorIntent) -> Unit +) { + val onEditMemberClick = rememberOnClickedCallback { + onIntent.invoke(GroupEditorIntent.OnEditMemberClick(index)) + } + val onRemoveMemberClick = rememberOnClickedCallback { + onIntent.invoke(GroupEditorIntent.OnRemoveMemberClick(index)) + } + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = AppTheme.theme.colors.cardSecondaryBackground + ) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = member.name, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .weight(1f) + .padding(start = ElementMargin) + ) + + IconButton( + onClick = onEditMemberClick ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = member, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier - .weight(1f) - .padding(start = ElementMargin) - ) + Icon( + imageVector = AppIcon.EDIT.vector, + contentDescription = null + ) + } - IconButton( - // TODO: remember callback - onClick = { onIntent.invoke(GroupEditorIntent.OnRemoveMemberClick(index)) } - ) { - Icon( - imageVector = AppIcon.CLOSE.vector, - contentDescription = null - ) - } - } + IconButton( + onClick = onRemoveMemberClick + ) { + Icon( + imageVector = AppIcon.CLOSE.vector, + contentDescription = null + ) } } } -} \ No newline at end of file +} + +@Preview +@Composable +fun GroupEditorScreenDataPreview() { + ThemedScreenPreview(theme = LightTheme) { + GroupEditorScreen( + state = newDataState(), + onIntent = {} + ) + } +} + +private fun newDataState() = + GroupEditorState.Data( + members = listOf( + MemberItem("Donald"), + MemberItem("Mickey") + ), + isApplyButtonVisible = true, + isAddButtonVisible = true, + isCancelButtonVisible = true + ) \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorViewModel.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorViewModel.kt index 3760ed5..cd33e0e 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorViewModel.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/GroupEditorViewModel.kt @@ -9,6 +9,7 @@ import com.github.ai.simplesplit.android.presentation.screens.groupEditor.model. import com.github.ai.simplesplit.android.presentation.screens.groupEditor.model.GroupEditorIntent import com.github.ai.simplesplit.android.presentation.screens.groupEditor.model.GroupEditorMode import com.github.ai.simplesplit.android.presentation.screens.groupEditor.model.GroupEditorState +import com.github.ai.simplesplit.android.presentation.screens.groupEditor.model.MemberItem import com.github.ai.simplesplit.android.presentation.screens.groupEditor.model.ValidationResult import com.github.ai.simplesplit.android.utils.StringUtils import com.github.ai.simplesplit.android.utils.mutableStateFlow @@ -34,6 +35,7 @@ class GroupEditorViewModel( private var dataState by mutableStateFlow(GroupEditorState.Data()) private var data by mutableStateFlow(null) + private var selectedMember by mutableStateFlow(null) override fun handleIntent(intent: GroupEditorIntent): Flow { return when (intent) { @@ -50,6 +52,10 @@ class GroupEditorViewModel( is GroupEditorIntent.OnPasswordToggleClick -> onPasswordToggleClicked(intent) is GroupEditorIntent.OnConfirmPasswordToggleClick -> onConfirmPasswordToggleClicked(intent) + + GroupEditorIntent.OnCancelMemberEditClick -> onCancelMemberEditClicked() + GroupEditorIntent.OnApplyMemberEditClick -> onApplyMemberEditClicked() + is GroupEditorIntent.OnEditMemberClick -> onEditMemberClicked(intent.memberIndex) } } @@ -96,14 +102,82 @@ class GroupEditorViewModel( return emptyFlow() } - if (newMemberName in dataState.members) { + val isMemberAlreadyAdded = dataState.members + .any { member -> member.name == newMemberName } + + if (isMemberAlreadyAdded) { return emptyFlow() } dataState = dataState.copy( member = StringUtils.EMPTY, memberError = null, - members = dataState.members + newMemberName + members = dataState.members + MemberItem(newMemberName) + ) + + return flowOf(dataState) + } + + private fun onEditMemberClicked(memberIndex: Int): Flow { + val member = dataState.members.getOrNull(memberIndex) ?: return emptyFlow() + selectedMember = member + + dataState = dataState.copy( + member = member.name, + memberError = null, + isApplyButtonVisible = true, + isAddButtonVisible = false, + isCancelButtonVisible = true + ) + + return flowOf(dataState) + } + + private fun onCancelMemberEditClicked(): Flow { + dataState = dataState.copy( + member = StringUtils.EMPTY, + memberError = null, + isApplyButtonVisible = false, + isAddButtonVisible = true, + isCancelButtonVisible = false + ) + + return flowOf(dataState) + } + + private fun onApplyMemberEditClicked(): Flow { + val selectedMember = selectedMember ?: return emptyFlow() + + val newMemberName = dataState.member.trim() + + val isNameAlreadyExist = dataState.members + .filter { member -> member != selectedMember } + .any { member -> member.name == newMemberName } + + if (isNameAlreadyExist) { + dataState = dataState.copy( + memberError = resources.getString(R.string.non_unique_member_name) + ) + + return flowOf(dataState) + } + + val newMember = selectedMember.copy(name = newMemberName) + + val newMembers = dataState.members + .filter { member -> member != selectedMember } + .toMutableList() + .apply { + add(newMember) + } + + dataState = dataState.copy( + members = newMembers, + member = StringUtils.EMPTY, + memberError = null, + isApplyButtonVisible = false, + isAddButtonVisible = true, + isCancelButtonVisible = false ) return flowOf(dataState) @@ -170,20 +244,22 @@ class GroupEditorViewModel( interactor.createGroup( password = dataState.password.trim(), title = dataState.title.trim(), - members = dataState.members + members = dataState.members.map { member -> member.name } ) } is GroupEditorMode.EditGroup -> { - val membersToAdd = getNewMembers() - val memberToDelete = getMemberUidsToDelete() + val membersToAdd = getMembersToAdd() + val membersToDelete = getMemberUidsToDelete() + val membersToUpdate = getMembersToUpdate() interactor.updateGroup( credentials = args.mode.credentials, newTitle = dataState.title.ifBlank { null }, newPassword = dataState.password.ifBlank { null }, - membersToRemove = memberToDelete, - membersToAdd = membersToAdd + memberUidsToRemove = membersToDelete, + memberNamesToAdd = membersToAdd.map { it.name }, + membersToUpdate = membersToUpdate.map { Pair(it.uid ?: "", it.name) } ) } } @@ -205,7 +281,16 @@ class GroupEditorViewModel( private fun loadData(): Flow { return when (args.mode) { - is GroupEditorMode.NewGroup -> flowOf(GroupEditorState.Data()) + is GroupEditorMode.NewGroup -> { + dataState = dataState.copy( + isAddButtonVisible = true + ) + + flowOf( + dataState + ) + } + is GroupEditorMode.EditGroup -> flow { emit(GroupEditorState.Loading) @@ -220,7 +305,13 @@ class GroupEditorViewModel( data = group dataState = GroupEditorState.Data( title = group.title, - members = group.members.map { member -> member.name } + isAddButtonVisible = true, + members = group.members.map { member -> + MemberItem( + name = member.name, + uid = member.uid + ) + } ) emit(dataState) } @@ -229,27 +320,42 @@ class GroupEditorViewModel( } } - private fun getNewMembers(): List { - val memberNames = data?.members - ?.map { member -> member.name } - ?.toSet() - ?: emptySet() + private fun getMembersToAdd(): List { + return dataState.members + .filter { member -> member.uid == null } + } + + private fun getMemberUidsToDelete(): List { + val currentMember = data?.members ?: return emptyList() - val newMemberNames = dataState.members - .filter { memberName -> memberName !in memberNames } + val oldMemberUidToNameMap = currentMember.associateBy { member -> member.uid } - return newMemberNames + val newMemberUids = dataState.members + .mapNotNull { member -> member.uid } + .toSet() + + val uidsToDelete = oldMemberUidToNameMap.keys + .filter { uid -> uid !in newMemberUids } + + return uidsToDelete } - private fun getMemberUidsToDelete(): List { - val members = data?.members ?: return emptyList() + private fun getMembersToUpdate(): List { + val oldMembers = data?.members ?: return emptyList() - val memberNameToUidMap = members.associateBy { member -> member.name } - val removedNames = memberNameToUidMap.keys - dataState.members.toSet() + val oldMemberUidToNameMap = oldMembers + .map { member -> member.uid to member.name } + .toMap() - return removedNames.mapNotNull { name -> - memberNameToUidMap[name]?.uid - } + return dataState.members + .filter { member -> + if (member.uid != null) { + val oldName = oldMemberUidToNameMap[member.uid] + oldName != member.name + } else { + false + } + } } private fun onCloseErrorClicked(): Flow { diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorIntent.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorIntent.kt index 617c5e4..1f962d3 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorIntent.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorIntent.kt @@ -8,9 +8,11 @@ sealed class GroupEditorIntent( data object Initialize : GroupEditorIntent() data object OnBackClick : GroupEditorIntent() - data object OnAddMemberClick : GroupEditorIntent() data object OnDoneClick : GroupEditorIntent() data object OnCloseErrorClick : GroupEditorIntent() + data object OnAddMemberClick : GroupEditorIntent() + data object OnCancelMemberEditClick : GroupEditorIntent() + data object OnApplyMemberEditClick : GroupEditorIntent() data class OnTitleChanged(val title: String) : GroupEditorIntent(isImmediate = true) data class OnPasswordChanged(val password: String) : GroupEditorIntent(isImmediate = true) data class OnConfirmPasswordChanged( @@ -18,6 +20,7 @@ sealed class GroupEditorIntent( ) : GroupEditorIntent(isImmediate = true) data class OnMemberChanged(val member: String) : GroupEditorIntent(isImmediate = true) data class OnRemoveMemberClick(val memberIndex: Int) : GroupEditorIntent() + data class OnEditMemberClick(val memberIndex: Int) : GroupEditorIntent() data class OnPasswordToggleClick(val isVisible: Boolean) : GroupEditorIntent() data class OnConfirmPasswordToggleClick(val isVisible: Boolean) : GroupEditorIntent() } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorState.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorState.kt index 585102b..93a0e02 100644 --- a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorState.kt +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/GroupEditorState.kt @@ -15,11 +15,14 @@ sealed interface GroupEditorState { val password: String = StringUtils.EMPTY, val confirmPassword: String = StringUtils.EMPTY, val member: String = StringUtils.EMPTY, - val members: List = emptyList(), + val members: List = emptyList(), val titleError: String? = null, val passwordError: String? = null, val confirmPasswordError: String? = null, val memberError: String? = null, + val isApplyButtonVisible: Boolean = false, + val isAddButtonVisible: Boolean = false, + val isCancelButtonVisible: Boolean = false, val isPasswordVisible: Boolean = false, val isConfirmPasswordVisible: Boolean = false, val error: ErrorMessage? = null diff --git a/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/MemberItem.kt b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/MemberItem.kt new file mode 100644 index 0000000..0e233b6 --- /dev/null +++ b/android/app/src/main/java/com/github/ai/simplesplit/android/presentation/screens/groupEditor/model/MemberItem.kt @@ -0,0 +1,9 @@ +package com.github.ai.simplesplit.android.presentation.screens.groupEditor.model + +import androidx.compose.runtime.Immutable + +@Immutable +data class MemberItem( + val name: String, + val uid: String? = null +) \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index f0af920..45f4544 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -41,6 +41,7 @@ Confirm Password Passwords don\'t match Add at least 2 members + Non-unique member name No expenses yet in this group. Add the first expense to get started!