From 430bc975fe448dfbc0cc7cdc76b85c86fc6ccf46 Mon Sep 17 00:00:00 2001 From: Ionut253 <48173899+Ionut253@users.noreply.github.com> Date: Mon, 8 Dec 2025 01:03:11 +0200 Subject: [PATCH 01/11] refactor + join/leave feature + mark paid/unpaid feature --- API/routes/user_routes.py | 26 +- API/services/expense_service.py | 5 +- API/services/group_service.py | 6 +- API/services/user_group_service.py | 9 +- .../budgeting/android/data/model/Category.kt | 40 ++ .../budgeting/android/data/model/Expense.kt | 39 +- .../android/data/model/ExpensePayment.kt | 15 + .../budgeting/android/data/model/GroupLog.kt | 21 + .../android/data/model/GroupResponseModels.kt | 23 + .../android/data/network/ExpenseApiService.kt | 3 +- .../data/network/ExpensePaymentApiService.kt | 27 + .../android/data/network/GroupApiService.kt | 24 +- .../android/data/network/RetrofitClient.kt | 17 + .../repository/ExpensePaymentRepository.kt | 49 ++ .../data/repository/ExpenseRepository.kt | 11 +- .../data/repository/GroupRepository.kt | 224 ++++-- .../android/ui/component/ExpenseItem.kt | 2 +- .../components/group/BottomAddExpenseBar.kt | 105 +++ .../ui/components/group/CreateGroupDialog.kt | 146 ++++ .../android/ui/components/group/DateHeader.kt | 33 + .../ui/components/group/ExpenseBubble.kt | 84 +++ .../components/group/ExpensePaymentDialog.kt | 297 ++++++++ .../components/group/ExpensePickerDialog.kt | 147 ++++ .../ui/components/group/GroupLogBubble.kt | 47 ++ .../ui/components/group/GroupMetaRow.kt | 62 ++ .../android/ui/components/group/GroupRow.kt | 57 ++ .../ui/components/group/GroupShareDialog.kt | 198 +++++ .../ui/components/group/JoinGroupDialog.kt | 192 +++++ .../ui/components/group/TimelineItem.kt | 17 + .../android/ui/screens/ExpensesScreen.kt | 22 +- .../android/ui/screens/GroupDetailsScreen.kt | 675 +++--------------- .../android/ui/screens/GroupsScreen.kt | 344 +-------- .../budgeting/android/ui/utils/DateUtils.kt | 69 ++ .../budgeting/android/ui/utils/GroupUtils.kt | 45 ++ .../ui/viewmodels/AnalyticsViewModel.kt | 11 +- .../android/ui/viewmodels/ExpenseViewModel.kt | 4 +- .../ui/viewmodels/GroupDetailsViewModel.kt | 161 ++++- .../android/ui/viewmodels/GroupsViewModel.kt | 111 ++- 38 files changed, 2258 insertions(+), 1110 deletions(-) create mode 100644 Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/Category.kt create mode 100644 Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/ExpensePayment.kt create mode 100644 Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/GroupLog.kt create mode 100644 Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/GroupResponseModels.kt create mode 100644 Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/ExpensePaymentApiService.kt create mode 100644 Mobile/androidApp/src/main/java/com/example/budgeting/android/data/repository/ExpensePaymentRepository.kt create mode 100644 Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/BottomAddExpenseBar.kt create mode 100644 Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/CreateGroupDialog.kt create mode 100644 Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/DateHeader.kt create mode 100644 Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/ExpenseBubble.kt create mode 100644 Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/ExpensePaymentDialog.kt create mode 100644 Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/ExpensePickerDialog.kt create mode 100644 Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/GroupLogBubble.kt create mode 100644 Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/GroupMetaRow.kt create mode 100644 Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/GroupRow.kt create mode 100644 Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/GroupShareDialog.kt create mode 100644 Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/JoinGroupDialog.kt create mode 100644 Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/TimelineItem.kt create mode 100644 Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/utils/DateUtils.kt create mode 100644 Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/utils/GroupUtils.kt diff --git a/API/routes/user_routes.py b/API/routes/user_routes.py index fa104d2..d674994 100644 --- a/API/routes/user_routes.py +++ b/API/routes/user_routes.py @@ -1,6 +1,7 @@ -from dependencies.di import get_user_group_service, get_user_service +from dependencies.di import get_group_log_service, get_user_group_service, get_user_service from fastapi import APIRouter, Depends, Request from schemas.user import UserChangePassword, UserUpdate +from services.group_log_service import IGroupLogService from services.user_group_service import IUserGroupService from services.user_service import IUserService from utils.helpers.jwt_utils import JwtUtils @@ -59,11 +60,30 @@ def delete_user(user_id: int, _ = Depends(get_current_user_id), user_service: IU @router.post("/join-group/{invitation_code}") -def join_group_with_invitation_code(invitation_code: str, user_id: int = Depends(get_current_user_id), user_group_service: IUserGroupService = Depends(get_user_group_service)): +def join_group_with_invitation_code( + invitation_code: str, + user_id: int = Depends(get_current_user_id), + user_group_service: IUserGroupService = Depends(get_user_group_service), + log_service: IGroupLogService = Depends(get_group_log_service) +): """ Allows the authenticated user to join a group using an invitation code. """ - return user_group_service.add_user_to_group_by_invitation_code(user_id, invitation_code) + response = user_group_service.add_user_to_group_by_invitation_code(user_id, invitation_code) + + # log the join event if successful + if response.success and response.data: + group_data = response.data + if "group" in group_data: + group_obj = group_data["group"] + if hasattr(group_obj, "id"): + group_id = group_obj.id + log_service.log_join(group_id, user_id) + elif isinstance(group_obj, dict) and "id" in group_obj: + group_id = group_obj["id"] + log_service.log_join(group_id, user_id) + + return response @router.get("/{user_id}/budget") def get_budget(user_id: int, _ = Depends(get_current_user_id), user_service: IUserService = Depends(get_user_service)): diff --git a/API/services/expense_service.py b/API/services/expense_service.py index e4aca4c..60d257a 100644 --- a/API/services/expense_service.py +++ b/API/services/expense_service.py @@ -151,16 +151,15 @@ def get_user_expenses(self, *args, **kwargs) -> APIResponse: data=expenses_response ) - def get_group_expenses(self, group_id: int, *args, **kwargs) -> APIResponse: + def get_group_expenses(self, group_id: int, offset: int, limit: int, sort_by: str, order: str) -> APIResponse: """ Method for returning group expenses """ self._validate_group(group_id) - expenses = self.repository.get_by_group(group_id, *args, **kwargs) expenses_response = [ExpenseResponse.model_validate(expense) for expense in expenses] - + return APIResponse( success=True, data=expenses_response diff --git a/API/services/group_service.py b/API/services/group_service.py index 5d7d443..e6b7189 100644 --- a/API/services/group_service.py +++ b/API/services/group_service.py @@ -9,6 +9,7 @@ from schemas.group import GroupCreate, GroupResponse, GroupUpdate from utils.helpers.constants import ID_FIELD, STATUS_BAD_REQUEST, STATUS_NOT_FOUND from utils.helpers.generate_invitation_code import generate_invitation_code +import base64 class IGroupService(ABC): @@ -153,7 +154,10 @@ def generate_invite_qr(self, group_id: int) -> APIResponse: img.save(buffer, format="PNG") buffer.seek(0) + # fastapi is throwing error when returning bytes directly, so encode it to base64 + b64_bytes = base64.b64encode(buffer.read()).decode("utf-8") + return APIResponse( success=True, - data=buffer.read(), + data=b64_bytes ) diff --git a/API/services/user_group_service.py b/API/services/user_group_service.py index edf27e3..c7ee566 100644 --- a/API/services/user_group_service.py +++ b/API/services/user_group_service.py @@ -128,9 +128,12 @@ def add_user_to_group_by_invitation_code(self, user_id: int, invitation_code: st ) response = self.repository.add_user_to_group_by_invitation_code(user_id, invitation_code) - - group_response = GroupResponse.model_validate(response[0]) - user_response = UserResponse.model_validate(response[1]) + + group_obj = self.group_repo.get_by_id(response[0]) + user_obj = self.user_repo.get_by_id(response[1]) + + group_response = GroupResponse.model_validate(group_obj) + user_response = UserResponse.model_validate(user_obj) return APIResponse( success=True, diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/Category.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/Category.kt new file mode 100644 index 0000000..e4a7ce8 --- /dev/null +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/Category.kt @@ -0,0 +1,40 @@ +package com.example.budgeting.android.data.model + +import com.squareup.moshi.Json + +data class Category( + @Json(name = "id") + val id: Int, + + @Json(name = "user_id") + val user_id: Int, + + @Json(name = "title") + val title: String, + + @Json(name = "keywords") + val keywords: List = emptyList() +) + +/** + * Request model for creating categories + */ +data class CategoryCreateRequest( + @Json(name = "title") + val title: String, + + @Json(name = "keywords") + val keywords: List = emptyList() +) + +/** + * Request model for updating categories + */ +data class CategoryUpdateRequest( + @Json(name = "title") + val title: String? = null, + + @Json(name = "keywords") + val keywords: List? = null +) + diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/Expense.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/Expense.kt index 92c78c1..c6c4423 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/Expense.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/Expense.kt @@ -2,15 +2,6 @@ package com.example.budgeting.android.data.model import com.squareup.moshi.Json -/** - * Expense model matching the backend schema. - * Backend schema: id (int, required), user_id (int?, optional), group_id (int?, optional), - * title (str, required), category (str, required), amount (float, required), created_at (datetime, required) - * - * Note: id and created_at are nullable to support creating new expenses (they're generated by backend), - * but when receiving from backend, they should always be present. However, we make them nullable - * to handle cases where the backend might return malformed data. - */ data class Expense( @Json(name = "id") val id: Int? = null, @@ -24,17 +15,43 @@ data class Expense( @Json(name = "title") val title: String, + @Json(name = "category_id") + val category_id: Int? = null, + @Json(name = "category") - val category: String, + val category: Category? = null, @Json(name = "amount") val amount: Double, + @Json(name = "description") + val description: String? = null, + @Json(name = "created_at") val created_at: String? = null -) +) { + val categoryTitle: String + get() = category?.title ?: "Uncategorized" +} data class ExpenseIdResponse( @Json(name = "id") val id: Int +) + +data class ExpenseCreateRequest( + @Json(name = "title") + val title: String, + + @Json(name = "amount") + val amount: Double, + + @Json(name = "category_id") + val category_id: Int, + + @Json(name = "group_id") + val group_id: Int? = null, + + @Json(name = "description") + val description: String? = null ) \ No newline at end of file diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/ExpensePayment.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/ExpensePayment.kt new file mode 100644 index 0000000..a9f3e4d --- /dev/null +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/ExpensePayment.kt @@ -0,0 +1,15 @@ +package com.example.budgeting.android.data.model + +import com.squareup.moshi.Json + +data class ExpensePayment( + @Json(name = "expense_id") + val expense_id: Int, + + @Json(name = "user_id") + val user_id: Int, + + @Json(name = "paid_at") + val paid_at: String? = null +) + diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/GroupLog.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/GroupLog.kt new file mode 100644 index 0000000..d8763b2 --- /dev/null +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/GroupLog.kt @@ -0,0 +1,21 @@ +package com.example.budgeting.android.data.model + +import com.squareup.moshi.Json + +data class GroupLog( + @Json(name = "id") + val id: Int, + + @Json(name = "group_id") + val group_id: Int, + + @Json(name = "user_id") + val user_id: Int, + + @Json(name = "action") + val action: String, // "JOIN" or "LEAVE" + + @Json(name = "created_at") + val created_at: String +) + diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/GroupResponseModels.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/GroupResponseModels.kt new file mode 100644 index 0000000..f38859a --- /dev/null +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/GroupResponseModels.kt @@ -0,0 +1,23 @@ +package com.example.budgeting.android.data.model + +import com.squareup.moshi.Json + +data class GroupIdResponse( + @Json(name = "id") + val id: Int +) + +data class AddUserToGroupResponse( + @Json(name = "group") + val group: Int, + @Json(name = "user") + val user: Int +) + +data class JoinGroupResponse( + @Json(name = "group") + val group: Group, + @Json(name = "user") + val user: UserData +) + diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/ExpenseApiService.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/ExpenseApiService.kt index 79ddb99..2ad0302 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/ExpenseApiService.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/ExpenseApiService.kt @@ -5,6 +5,7 @@ import retrofit2.Response import retrofit2.http.* import com.example.budgeting.android.data.model.Expense import com.example.budgeting.android.data.model.ExpenseIdResponse +import com.example.budgeting.android.data.model.ExpenseCreateRequest interface ExpenseApiService { @@ -55,7 +56,7 @@ interface ExpenseApiService { ): Response> @POST("/expenses/") - suspend fun addExpense(@Body expense: Expense): Response> + suspend fun addExpense(@Body expense: ExpenseCreateRequest): Response> @PUT("/expenses/{id}") suspend fun updateExpense( diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/ExpensePaymentApiService.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/ExpensePaymentApiService.kt new file mode 100644 index 0000000..eff5a2f --- /dev/null +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/ExpensePaymentApiService.kt @@ -0,0 +1,27 @@ +package com.example.budgeting.android.data.network + +import com.example.budgeting.android.data.model.ApiResponse +import com.example.budgeting.android.data.model.ExpensePayment +import retrofit2.Response +import retrofit2.http.* + +interface ExpensePaymentApiService { + + @POST("/expenses_payments/{expense_id}/pay/{payer_id}") + suspend fun markPaid( + @Path("expense_id") expenseId: Int, + @Path("payer_id") payerId: Int + ): Response> + + @DELETE("/expenses_payments/{expense_id}/pay/{payer_id}") + suspend fun unmarkPaid( + @Path("expense_id") expenseId: Int, + @Path("payer_id") payerId: Int + ): Response> + + @GET("/expenses_payments/{expense_id}/payments") + suspend fun getPayments( + @Path("expense_id") expenseId: Int + ): Response>> +} + diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/GroupApiService.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/GroupApiService.kt index 14847b9..9048459 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/GroupApiService.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/GroupApiService.kt @@ -6,6 +6,10 @@ import com.example.budgeting.android.data.model.Group import com.example.budgeting.android.data.model.UpdateGroupRequest import com.example.budgeting.android.data.model.UserData import com.example.budgeting.android.data.model.Expense +import com.example.budgeting.android.data.model.GroupIdResponse +import com.example.budgeting.android.data.model.AddUserToGroupResponse +import com.example.budgeting.android.data.model.JoinGroupResponse +import com.example.budgeting.android.data.model.GroupLog import okhttp3.ResponseBody import retrofit2.Response import retrofit2.http.Body @@ -24,22 +28,22 @@ interface GroupApiService { suspend fun getGroup(@Path("group_id") groupId: Int): Response> @POST("/groups/") - suspend fun createGroup(@Body request: CreateGroupRequest): Response> + suspend fun createGroup(@Body request: CreateGroupRequest): Response> @PUT("/groups/{group_id}") suspend fun updateGroup( @Path("group_id") groupId: Int, @Body request: UpdateGroupRequest - ): Response> + ): Response> @DELETE("/groups/{group_id}") - suspend fun deleteGroup(@Path("group_id") groupId: Int): Response> + suspend fun deleteGroup(@Path("group_id") groupId: Int): Response> @POST("/groups/{group_id}/users/{user_id}") suspend fun addUserToGroup( @Path("group_id") groupId: Int, @Path("user_id") userId: Int - ): Response> + ): Response> @DELETE("/groups/{group_id}/users/{user_id}") suspend fun removeUserFromGroup( @@ -53,6 +57,9 @@ interface GroupApiService { @GET("/groups/{group_id}/users") suspend fun getUsersByGroup(@Path("group_id") groupId: Int): Response>> + @GET("/groups/{group_id}/users/nr") + suspend fun getNrOfUsersFromGroup(@Path("group_id") groupId: Int): Response> + @GET("/groups/{group_id}/expenses") suspend fun getExpensesByGroup( @Path("group_id") groupId: Int, @@ -60,13 +67,16 @@ interface GroupApiService { @Query("limit") limit: Int = 100, @Query("sort_by") sortBy: String = "created_at", @Query("order") order: String = "desc" - ): Response> + ): Response>> @GET("/groups/{group_id}/invite-qr") - suspend fun getGroupInviteQr(@Path("group_id") groupId: Int): Response + suspend fun getGroupInviteQr(@Path("group_id") groupId: Int): Response> @POST("/users/join-group/{invitation_code}") suspend fun joinGroupByInvitationCode( @Path("invitation_code") invitationCode: String - ): Response> + ): Response> + + @GET("/group_logs/{group_id}") + suspend fun getGroupLogs(@Path("group_id") groupId: Int): Response>> } diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/RetrofitClient.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/RetrofitClient.kt index 3509fba..de7527d 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/RetrofitClient.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/RetrofitClient.kt @@ -8,6 +8,9 @@ import okhttp3.OkHttpClient import retrofit2.Retrofit import retrofit2.converter.moshi.MoshiConverterFactory +// Import ExpensePaymentApiService +import com.example.budgeting.android.data.network.ExpensePaymentApiService + object RetrofitClient { private val BASE_URL: String = BuildConfig.BASE_URL @@ -75,4 +78,18 @@ object RetrofitClient { retrofit.create(UserApiService::class.java) } + + val expensePaymentInstance: ExpensePaymentApiService = run { + val client = OkHttpClient.Builder() + .addInterceptor(TokenAuthInterceptor()) + .build() + + val retrofit = Retrofit.Builder() + .baseUrl(BASE_URL) + .client(client) + .addConverterFactory(moshiConverterFactory) + .build() + + retrofit.create(ExpensePaymentApiService::class.java) + } } diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/repository/ExpensePaymentRepository.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/repository/ExpensePaymentRepository.kt new file mode 100644 index 0000000..ae47c46 --- /dev/null +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/repository/ExpensePaymentRepository.kt @@ -0,0 +1,49 @@ +package com.example.budgeting.android.data.repository + +import com.example.budgeting.android.data.local.TokenDataStore +import com.example.budgeting.android.data.model.ExpensePayment +import com.example.budgeting.android.data.network.ExpensePaymentApiService + +class ExpensePaymentRepository( + private val api: ExpensePaymentApiService, + private val tokenDataStore: TokenDataStore +) { + + suspend fun markPaid(expenseId: Int, payerId: Int): Boolean { + val response = api.markPaid(expenseId, payerId) + return response.isSuccessful && response.body()?.success == true + } + + suspend fun unmarkPaid(expenseId: Int, payerId: Int): Boolean { + return try { + val response = api.unmarkPaid(expenseId, payerId) + if (response.isSuccessful) { + val body = response.body() + if (body == null) { + true + } else { + body.success == true + } + } else { + val errorBody = try { + response.errorBody()?.string() + } catch (e: Exception) { + "Unable to read error body" + } + false + } + } catch (e: Exception) { + false + } + } + + suspend fun getPayments(expenseId: Int): List { + val response = api.getPayments(expenseId) + return if (response.isSuccessful && response.body()?.data != null) { + response.body()!!.data!! + } else { + emptyList() + } + } +} + diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/repository/ExpenseRepository.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/repository/ExpenseRepository.kt index 8227650..0b9d257 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/repository/ExpenseRepository.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/repository/ExpenseRepository.kt @@ -78,7 +78,16 @@ class ExpenseRepository( } suspend fun addExpense(expense: Expense): Int { - return api.addExpense(expense).body()?.data?.id ?: throw Exception("Failed to add expense") + // Convert Expense to ExpenseCreateRequest + // Backend requires category_id, not category string + val createRequest = com.example.budgeting.android.data.model.ExpenseCreateRequest( + title = expense.title, + amount = expense.amount, + category_id = expense.category_id ?: 1, // Default to 1 if not provided + group_id = expense.group_id, + description = expense.description + ) + return api.addExpense(createRequest).body()?.data?.id ?: throw Exception("Failed to add expense") } suspend fun updateExpense(id: Int, expense: Expense): Int { diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/repository/GroupRepository.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/repository/GroupRepository.kt index 57e14e6..4fa56bf 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/repository/GroupRepository.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/repository/GroupRepository.kt @@ -4,13 +4,15 @@ import com.example.budgeting.android.data.local.TokenDataStore import com.example.budgeting.android.data.model.CreateGroupRequest import com.example.budgeting.android.data.model.UpdateGroupRequest import com.example.budgeting.android.data.model.Expense +import com.example.budgeting.android.data.model.ExpenseCreateRequest +import com.example.budgeting.android.data.model.Group +import com.example.budgeting.android.data.model.GroupLog +import com.example.budgeting.android.data.model.UserData import com.example.budgeting.android.data.network.GroupApiService import com.example.budgeting.android.data.network.ExpenseApiService -import com.example.budgeting.android.data.network.RetrofitClient -import com.squareup.moshi.JsonAdapter -import org.json.JSONArray import retrofit2.Response import okhttp3.ResponseBody +import android.util.Base64 class GroupRepository( private val apiService: GroupApiService, @@ -18,22 +20,78 @@ class GroupRepository( private val tokenDataStore: TokenDataStore ) { - suspend fun getGroup(id: Int) = apiService.getGroup(id) + suspend fun getGroup(id: Int): Response { + val response = apiService.getGroup(id) + return if (response.isSuccessful && response.body()?.data != null) { + Response.success(response.body()!!.data!!) + } else { + Response.error(response.code(), response.errorBody() ?: ResponseBody.create(null, "")) + } + } - suspend fun createGroup(name: String, description: String? = null) = - apiService.createGroup(CreateGroupRequest(name, description)) + suspend fun createGroup(name: String, description: String? = null): Response { + val response = apiService.createGroup(CreateGroupRequest(name, description)) + return if (response.isSuccessful && response.body()?.data != null) { + Response.success(response.body()!!.data!!.id) + } else { + Response.error(response.code(), response.errorBody() ?: ResponseBody.create(null, "")) + } + } - suspend fun updateGroup(groupId: Int, name: String? = null, description: String? = null) = - apiService.updateGroup(groupId, UpdateGroupRequest(name, description)) + suspend fun updateGroup(groupId: Int, name: String? = null, description: String? = null): Response { + val response = apiService.updateGroup(groupId, UpdateGroupRequest(name, description)) + // Update returns id, so we need to fetch the group again + return if (response.isSuccessful && response.body()?.data != null) { + getGroup(response.body()!!.data!!.id) + } else { + Response.error(response.code(), response.errorBody() ?: ResponseBody.create(null, "")) + } + } + + suspend fun deleteGroup(groupId: Int): Response { + val response = apiService.deleteGroup(groupId) + return if (response.isSuccessful) { + Response.success(Unit) + } else { + Response.error(response.code(), response.errorBody() ?: ResponseBody.create(null, "")) + } + } - suspend fun deleteGroup(groupId: Int) = apiService.deleteGroup(groupId) + suspend fun addUserToGroup(groupId: Int, userId: Int): Response { + val response = apiService.addUserToGroup(groupId, userId) + return if (response.isSuccessful) { + Response.success(Unit) + } else { + Response.error(response.code(), response.errorBody() ?: ResponseBody.create(null, "")) + } + } - suspend fun addUserToGroup(groupId: Int, userId: Int) = - apiService.addUserToGroup(groupId, userId) + suspend fun getGroupsByUser(userId: Int): Response> { + val response = apiService.getGroupsByUser(userId) + return if (response.isSuccessful && response.body()?.data != null) { + Response.success(response.body()!!.data!!) + } else { + Response.error(response.code(), response.errorBody() ?: ResponseBody.create(null, "")) + } + } - suspend fun getGroupsByUser(userId: Int) = apiService.getGroupsByUser(userId) + suspend fun getUsersByGroup(groupId: Int): Response> { + val response = apiService.getUsersByGroup(groupId) + return if (response.isSuccessful && response.body()?.data != null) { + Response.success(response.body()!!.data!!) + } else { + Response.error(response.code(), response.errorBody() ?: ResponseBody.create(null, "")) + } + } - suspend fun getUsersByGroup(groupId: Int) = apiService.getUsersByGroup(groupId) + suspend fun getNrOfUsersFromGroup(groupId: Int): Response { + val response = apiService.getNrOfUsersFromGroup(groupId) + return if (response.isSuccessful && response.body()?.data != null) { + Response.success(response.body()!!.data!!) + } else { + Response.error(response.code(), response.errorBody() ?: ResponseBody.create(null, "")) + } + } suspend fun getExpensesByGroup( groupId: Int, @@ -48,47 +106,129 @@ class GroupRepository( return Response.error(rawResponse.code(), rawResponse.errorBody() ?: ResponseBody.create(null, "")) } - val responseBody = rawResponse.body() ?: return Response.success(emptyList()) - val jsonString = responseBody.data?.string()?.trim() + val apiResponse = rawResponse.body() + if (apiResponse?.data == null) { + return Response.success(emptyList()) + } - if (jsonString!!.isEmpty() || jsonString == "[]") { + // The data field contains a list of expenses (already parsed by Moshi) + val expenses = apiResponse.data + if (expenses.isEmpty()) { return Response.success(emptyList()) } - return try { - val moshi = RetrofitClient.getMoshi() - val expenseAdapter: JsonAdapter = moshi.adapter(Expense::class.java) - val jsonArray = JSONArray(jsonString) - val validExpenses = mutableListOf() - - for (i in 0 until jsonArray.length()) { + // Filter out invalid expenses + val validExpenses = expenses.filter { expense -> + expense.id != null && expense.title.isNotBlank() + } + + return Response.success(validExpenses) + } + + suspend fun getExpenseById(id: Int) = expenseApiService.getExpenseById(id).body()?.data ?: throw Exception("Failed to fetch expense") + + suspend fun addExpenseToGroup(expense: Expense, description: String? = null): Int { + // Convert Expense to ExpenseCreateRequest + // For now, use category_id = 1 as default (backend requires category_id, not category string) + // TODO: Implement proper category lookup by name + val createRequest = ExpenseCreateRequest( + title = expense.title, + amount = expense.amount, + category_id = 1, // Default category - should be looked up from category name + group_id = expense.group_id, + description = description + ) + return expenseApiService.addExpense(createRequest).body()?.data?.id ?: throw Exception("Failed to add expense") + } + + suspend fun getGroupInviteQr(groupId: Int): Response { + val response = apiService.getGroupInviteQr(groupId) + return if (response.isSuccessful && response.body() != null) { + val apiResponse = response.body()!! + // Backend returns bytes in APIResponse.data field, which FastAPI/Pydantic serializes as base64 string + // The data field contains the base64-encoded PNG image bytes + val data = apiResponse.data + if (data == null) { + Response.error( + response.code(), + ResponseBody.create(null, "QR code data is null") + ) + } else { try { - val expenseJson = jsonArray.getJSONObject(i) - val expense = expenseAdapter.fromJson(expenseJson.toString()) - if (expense != null && expense.id != null && expense.title.isNotBlank()) { - validExpenses.add(expense) + // Ensure data is a string (base64 encoded) + val base64String = when (data) { + is String -> data + else -> data.toString() + } + + // Clean the base64 string - remove whitespace, newlines, and any JSON escaping + val cleanedBase64 = base64String + .trim() + .replace("\n", "") + .replace("\r", "") + .replace(" ", "") + .replace("\"", "") // Remove quotes if JSON escaped + .replace("\\", "") // Remove backslashes if escaped + + if (cleanedBase64.isEmpty()) { + Response.error( + response.code(), + ResponseBody.create(null, "Empty QR code data") + ) + } else { + // Decode base64 to bytes + val bytes = Base64.decode(cleanedBase64, Base64.DEFAULT) + if (bytes.isEmpty()) { + Response.error( + response.code(), + ResponseBody.create(null, "Failed to decode QR code: empty result") + ) + } else { + // Verify it's a valid PNG (starts with PNG signature) + if (bytes.size >= 8 && + bytes[0] == 0x89.toByte() && + bytes[1] == 0x50.toByte() && + bytes[2] == 0x4E.toByte() && + bytes[3] == 0x47.toByte()) { + Response.success(ResponseBody.create(null, bytes)) + } else { + // Still return it even if signature check fails, might be valid + Response.success(ResponseBody.create(null, bytes)) + } + } } } catch (e: Exception) { - continue + Response.error( + response.code(), + ResponseBody.create(null, "Failed to decode QR code: ${e.message ?: e.javaClass.simpleName}") + ) } } - - Response.success(validExpenses) - } catch (e: Exception) { - Response.success(emptyList()) + } else { + val errorBody = try { + response.errorBody()?.string() ?: "Unknown error (${response.code()})" + } catch (e: Exception) { + "Failed to read error response: ${e.message}" + } + Response.error(response.code(), ResponseBody.create(null, errorBody)) } } - suspend fun getExpenseById(id: Int) = expenseApiService.getExpenseById(id).body()?.data ?: throw Exception("Failed to fetch expense") - - suspend fun addExpenseToGroup(expense: Expense): Int { - return expenseApiService.addExpense(expense).body()?.data?.id ?: throw Exception("Failed to add expense") + suspend fun joinGroupByInvitationCode(invitationCode: String): Response { + val response = apiService.joinGroupByInvitationCode(invitationCode) + return if (response.isSuccessful) { + Response.success(Unit) + } else { + Response.error(response.code(), response.errorBody() ?: ResponseBody.create(null, "")) + } } - suspend fun getGroupInviteQr(groupId: Int): Response = - apiService.getGroupInviteQr(groupId) - - - suspend fun joinGroupByInvitationCode(invitationCode: String): Unit = - apiService.joinGroupByInvitationCode(invitationCode).body()?.data ?: throw Exception("Failed to join group") + suspend fun getGroupLogs(groupId: Int): Response> { + val response = apiService.getGroupLogs(groupId) + return if (response.isSuccessful && response.body()?.data != null) { + Response.success(response.body()!!.data!!) + } else { + Response.error(response.code(), response.errorBody() ?: ResponseBody.create(null, "")) + } + } } diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/component/ExpenseItem.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/component/ExpenseItem.kt index f1c22ea..b1316ab 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/component/ExpenseItem.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/component/ExpenseItem.kt @@ -48,7 +48,7 @@ fun ExpenseItem( ) Text( - text = "Category: ${expense.category}", + text = "Category: ${expense.categoryTitle}", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/BottomAddExpenseBar.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/BottomAddExpenseBar.kt new file mode 100644 index 0000000..2830db1 --- /dev/null +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/BottomAddExpenseBar.kt @@ -0,0 +1,105 @@ +package com.example.budgeting.android.ui.components.group + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.budgeting.android.data.model.Expense +import com.example.budgeting.android.ui.utils.GroupUtils +import com.example.budgeting.android.ui.viewmodels.ExpenseViewModel +import com.example.budgeting.android.ui.viewmodels.ExpenseViewModelFactory +import com.example.budgeting.android.ui.viewmodels.GroupDetailsViewModel +import com.example.budgeting.android.ui.viewmodels.GroupExpense + +@Composable +fun BottomAddExpenseBar( + vm: GroupDetailsViewModel +) { + val context = LocalContext.current + val expenseViewModel: ExpenseViewModel = viewModel( + factory = ExpenseViewModelFactory(context) + ) + + LaunchedEffect(Unit) { + expenseViewModel.loadExpenses() + } + + val personalExpenses by expenseViewModel.expenses.collectAsState() + val groupExpenses by vm.expenses.collectAsState() + val personalExpensesOnly = remember(personalExpenses, groupExpenses) { + val existingExpenseIds = groupExpenses.map { it.expense.id }.toSet() + personalExpenses.filter { expense -> + expense.group_id == null && + !existingExpenseIds.contains(expense.id) && + !GroupUtils.isExpenseAlreadyInGroup(expense, groupExpenses) + } + } + var showPicker by remember { mutableStateOf(false) } + var description by remember { mutableStateOf("") } + + BackHandler(enabled = showPicker) { + showPicker = false + } + + Surface(color = MaterialTheme.colorScheme.background) { + Row( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + TextField( + value = description, + onValueChange = { description = it }, + modifier = Modifier.weight(1f), + placeholder = { Text("Add description (optional)") }, + colors = TextFieldDefaults.colors( + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + focusedIndicatorColor = MaterialTheme.colorScheme.primary, + unfocusedIndicatorColor = MaterialTheme.colorScheme.surfaceVariant + ), + shape = RoundedCornerShape(12.dp), + singleLine = true + ) + Button( + onClick = { showPicker = true }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ), + shape = RoundedCornerShape(12.dp), + modifier = Modifier.height(52.dp) + ) { + Text("+") + } + } + } + + if (showPicker) { + ExpensePickerDialog( + expenses = personalExpensesOnly, + onDismiss = { showPicker = false }, + onConfirm = { selected -> + vm.addExpensesFromPersonal(selected, description, "You") + description = "" + showPicker = false + } + ) + } +} + diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/CreateGroupDialog.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/CreateGroupDialog.kt new file mode 100644 index 0000000..98082a5 --- /dev/null +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/CreateGroupDialog.kt @@ -0,0 +1,146 @@ +package com.example.budgeting.android.ui.components.group + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog + +@Composable +fun CreateGroupDialog( + onDismiss: () -> Unit, + isLoading: Boolean, + error: String?, + onCreate: (name: String, description: String?) -> Unit +) { + var groupName by remember { mutableStateOf("") } + var groupDescription by remember { mutableStateOf("") } + + Dialog(onDismissRequest = onDismiss) { + val configuration = LocalConfiguration.current + val maxHeight = configuration.screenHeightDp.dp * 0.6f + Surface( + color = MaterialTheme.colorScheme.background, + shape = RoundedCornerShape(16.dp), + modifier = Modifier + .fillMaxWidth(0.95f) + .heightIn(max = maxHeight) + ) { + Column( + modifier = Modifier + .padding(12.dp) + .fillMaxWidth() + ) { + // Top bar with close and title + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = onDismiss) { + Text("✕", color = MaterialTheme.colorScheme.onBackground) + } + Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) { + Text(text = "New Group", color = MaterialTheme.colorScheme.onBackground) + } + Spacer(modifier = Modifier.size(32.dp)) + } + + // Inputs + Column( + modifier = Modifier + .padding(horizontal = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + TextField( + value = groupName, + onValueChange = { groupName = it }, + placeholder = { Text("Group Name") }, + label = { Text("Group Name") }, + singleLine = true, + enabled = !isLoading, + shape = RoundedCornerShape(10.dp), + colors = TextFieldDefaults.colors( + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + cursorColor = MaterialTheme.colorScheme.primary, + focusedTextColor = MaterialTheme.colorScheme.onSurface, + unfocusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant + ), + modifier = Modifier.fillMaxWidth() + ) + + TextField( + value = groupDescription, + onValueChange = { groupDescription = it }, + placeholder = { Text("Description (optional)") }, + label = { Text("Description") }, + singleLine = false, + maxLines = 3, + enabled = !isLoading, + shape = RoundedCornerShape(10.dp), + colors = TextFieldDefaults.colors( + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + cursorColor = MaterialTheme.colorScheme.primary, + focusedTextColor = MaterialTheme.colorScheme.onSurface, + unfocusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant + ), + modifier = Modifier.fillMaxWidth() + ) + + // Show error message if any + error?.let { errorMsg -> + Text( + text = errorMsg, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = 4.dp, top = 4.dp) + ) + } + } + + // Bottom action button + Button( + onClick = { + onCreate( + groupName, + groupDescription.ifBlank { null } + ) + }, + enabled = !isLoading && groupName.isNotBlank(), + modifier = Modifier + .padding(12.dp) + .fillMaxWidth() + .height(50.dp), + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) + ) { + if (isLoading) { + Text("Creating...") + } else { + Text("Create Group") + } + } + } + } + } +} + diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/DateHeader.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/DateHeader.kt new file mode 100644 index 0000000..b91c19a --- /dev/null +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/DateHeader.kt @@ -0,0 +1,33 @@ +package com.example.budgeting.android.ui.components.group + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun DateHeader(date: String) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + contentAlignment = Alignment.Center + ) { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + shape = RoundedCornerShape(12.dp), + modifier = Modifier.padding(horizontal = 16.dp) + ) { + Text( + text = date, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) + ) + } + } +} + diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/ExpenseBubble.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/ExpenseBubble.kt new file mode 100644 index 0000000..2f2b03c --- /dev/null +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/ExpenseBubble.kt @@ -0,0 +1,84 @@ +package com.example.budgeting.android.ui.components.group + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.example.budgeting.android.data.model.Expense + +@Composable +fun ExpenseBubble( + expense: Expense, + userName: String, + description: String?, + onClick: () -> Unit = {}, + isClickable: Boolean = false +) { + Column(modifier = Modifier.fillMaxWidth()) { + // User name row + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 4.dp) + ) { + Icon( + Icons.Filled.Person, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = userName, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall + ) + } + + // Expense bubble + Box( + modifier = Modifier + .then( + if (isClickable) { + Modifier.clickable( + onClick = onClick, + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) + } else { + Modifier + } + ) + .background( + MaterialTheme.colorScheme.surfaceVariant, + RoundedCornerShape(10.dp) + ) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + Column { + Text( + text = "${expense.title} - $${String.format("%.2f", expense.amount)}", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyMedium + ) + if (!description.isNullOrBlank()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = description, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall + ) + } + } + } + } +} + diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/ExpensePaymentDialog.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/ExpensePaymentDialog.kt new file mode 100644 index 0000000..cac3710 --- /dev/null +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/ExpensePaymentDialog.kt @@ -0,0 +1,297 @@ +package com.example.budgeting.android.ui.components.group + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.example.budgeting.android.data.model.Expense +import com.example.budgeting.android.data.model.ExpensePayment +import com.example.budgeting.android.data.model.UserData +import com.example.budgeting.android.ui.viewmodels.GroupDetailsViewModel +import kotlinx.coroutines.launch + +@Composable +fun ExpensePaymentDialog( + expense: Expense, + members: List, + vm: GroupDetailsViewModel, + onDismiss: () -> Unit +) { + val coroutineScope = rememberCoroutineScope() + var payments by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(null) } + + // Optimistic updates: track pending additions and removals + var optimisticAdditions by remember { mutableStateOf>(emptySet()) } + var optimisticRemovals by remember { mutableStateOf>(emptySet()) } + + // Load payments when dialog opens + LaunchedEffect(expense.id) { + if (expense.id != null) { + isLoading = true + error = null + try { + payments = vm.getExpensePayments(expense.id!!) + optimisticAdditions = emptySet() + optimisticRemovals = emptySet() + } catch (e: Exception) { + error = e.message + } finally { + isLoading = false + } + } + } + + // Combine server payments with optimistic updates + val paidUserIds = remember(payments, optimisticAdditions, optimisticRemovals) { + val serverPaidIds = payments.map { it.user_id }.toSet() + (serverPaidIds + optimisticAdditions) - optimisticRemovals + } + + // Filter out the expense creator from the members list + val filteredMembers = remember(members, expense.user_id) { + members.filter { it.id != expense.user_id } + } + + Dialog(onDismissRequest = onDismiss) { + Surface( + color = MaterialTheme.colorScheme.background, + shape = RoundedCornerShape(16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = expense.title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + Text( + text = "$${String.format("%.2f", expense.amount)}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + } + IconButton(onClick = onDismiss) { + Text("✕", style = MaterialTheme.typography.titleMedium) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Who paid?", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(bottom = 8.dp) + ) + + // Loading or error state + if (isLoading) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else if (error != null) { + Text( + text = "Error: $error", + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(16.dp) + ) + } else { + // Members list with checkboxes + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 400.dp) + ) { + items(filteredMembers.size) { index -> + val member = filteredMembers[index] + val isPaid = paidUserIds.contains(member.id) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + color = Color.Transparent, + modifier = Modifier + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = { + if (expense.id != null && member.id != null) { + coroutineScope.launch { + vm.togglePaymentStatus( + expense.id!!, + member.id!!, + !isPaid + ) + payments = vm.getExpensePayments(expense.id!!) + } + } + } + ) + ) { + Checkbox( + checked = isPaid, + onCheckedChange = { checked -> + if (expense.id != null && member.id != null) { + val memberId = member.id!! + + // Optimistic update + if (checked) { + optimisticRemovals = optimisticRemovals - memberId + optimisticAdditions = optimisticAdditions + memberId + } else { + optimisticAdditions = optimisticAdditions - memberId + optimisticRemovals = optimisticRemovals + memberId + } + + coroutineScope.launch { + val success = vm.togglePaymentStatus( + expense.id!!, + memberId, + checked + ) + + if (success) { + try { + payments = vm.getExpensePayments(expense.id!!) + optimisticAdditions = optimisticAdditions - memberId + optimisticRemovals = optimisticRemovals - memberId + } catch (e: Exception) { + // Keep optimistic update if reload fails + } + } else { + // Revert on failure + if (checked) { + optimisticAdditions = optimisticAdditions - memberId + optimisticRemovals = optimisticRemovals + memberId + } else { + optimisticRemovals = optimisticRemovals - memberId + optimisticAdditions = optimisticAdditions + memberId + } + error = "Failed to update payment status" + } + } + } + }, + colors = CheckboxDefaults.colors( + checkedColor = MaterialTheme.colorScheme.primary, + uncheckedColor = MaterialTheme.colorScheme.onSurfaceVariant, + checkmarkColor = MaterialTheme.colorScheme.onPrimary, + disabledCheckedColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.38f), + disabledUncheckedColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + disabledIndeterminateColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + ) + ) + } + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "${member.firstName} ${member.lastName}", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .weight(1f) + .background(Color.Transparent) + .clickable( + onClick = { + if (expense.id != null && member.id != null) { + val memberId = member.id!! + val newCheckedState = !isPaid + + if (newCheckedState) { + optimisticRemovals = optimisticRemovals - memberId + optimisticAdditions = optimisticAdditions + memberId + } else { + optimisticAdditions = optimisticAdditions - memberId + optimisticRemovals = optimisticRemovals + memberId + } + + coroutineScope.launch { + val success = vm.togglePaymentStatus( + expense.id!!, + memberId, + newCheckedState + ) + + if (success) { + try { + payments = vm.getExpensePayments(expense.id!!) + optimisticAdditions = optimisticAdditions - memberId + optimisticRemovals = optimisticRemovals - memberId + } catch (e: Exception) { + // Keep optimistic update + } + } else { + if (newCheckedState) { + optimisticAdditions = optimisticAdditions - memberId + optimisticRemovals = optimisticRemovals + memberId + } else { + optimisticRemovals = optimisticRemovals - memberId + optimisticAdditions = optimisticAdditions + memberId + } + error = "Failed to update payment status" + } + } + } + }, + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) + .padding(vertical = 8.dp, horizontal = 4.dp) + ) + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = onDismiss, + modifier = Modifier.fillMaxWidth() + ) { + Text("Done") + } + } + } + } +} + diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/ExpensePickerDialog.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/ExpensePickerDialog.kt new file mode 100644 index 0000000..a31a339 --- /dev/null +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/ExpensePickerDialog.kt @@ -0,0 +1,147 @@ +package com.example.budgeting.android.ui.components.group + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.example.budgeting.android.data.model.Expense + +@Composable +fun ExpensePickerDialog( + expenses: List, + onDismiss: () -> Unit, + onConfirm: (List) -> Unit +) { + Dialog(onDismissRequest = onDismiss) { + Surface( + color = MaterialTheme.colorScheme.background, + shape = RoundedCornerShape(16.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .padding(12.dp) + .fillMaxWidth() + .heightIn(max = 480.dp) + ) { + // Header + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = onDismiss) { + Text("✕", color = MaterialTheme.colorScheme.onBackground) + } + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.Center + ) { + Text( + text = "Choose expenses", + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.titleMedium + ) + } + Spacer(modifier = Modifier.width(32.dp)) + } + + // Expense list + val selected = remember { mutableStateListOf() } + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + itemsIndexed(expenses) { index, expense -> + Surface( + shape = RoundedCornerShape(12.dp), + color = if (selected.contains(index)) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + modifier = Modifier + .fillMaxWidth() + .clickable { + if (selected.contains(index)) { + selected.remove(index) + } else { + selected.add(index) + } + } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = expense.title, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = expense.categoryTitle, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall + ) + } + Text( + text = "$${"%.2f".format(expense.amount)}", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + } + + // Action buttons + Row( + modifier = Modifier + .padding(12.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + onClick = onDismiss, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(12.dp) + ) { + Text("Cancel") + } + Button( + onClick = { + val items = selected.map { expenses[it] } + onConfirm(items) + }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) + ) { + Text("Add") + } + } + } + } + } +} + diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/GroupLogBubble.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/GroupLogBubble.kt new file mode 100644 index 0000000..3f31bb2 --- /dev/null +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/GroupLogBubble.kt @@ -0,0 +1,47 @@ +package com.example.budgeting.android.ui.components.group + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.example.budgeting.android.data.model.GroupLog +import com.example.budgeting.android.data.model.UserData + +@Composable +fun GroupLogBubble( + log: GroupLog, + members: List +) { + val memberMap = members.associateBy { it.id } + val user = memberMap[log.user_id] + val userName = user?.let { "${it.firstName} ${it.lastName}" } ?: "Unknown User" + + val actionText = when (log.action.uppercase()) { + "JOIN" -> "joined the group" + "LEAVE" -> "left the group" + else -> log.action.lowercase() + } + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + contentAlignment = Alignment.Center + ) { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = RoundedCornerShape(12.dp) + ) { + Text( + text = "$userName $actionText", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) + ) + } + } +} + diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/GroupMetaRow.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/GroupMetaRow.kt new file mode 100644 index 0000000..a4d0c9d --- /dev/null +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/GroupMetaRow.kt @@ -0,0 +1,62 @@ +package com.example.budgeting.android.ui.components.group + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +@Composable +fun GroupMetaRow( + memberCount: Int, + onShareClick: () -> Unit, + invitationCodeAvailable: Boolean +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Filled.Person, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + text = "$memberCount members", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = "Active members", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall + ) + } + } + FilledTonalButton( + onClick = onShareClick, + enabled = invitationCodeAvailable, + shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp) + ) { + Icon( + imageVector = Icons.Filled.Share, + contentDescription = "Share group" + ) + Spacer(modifier = Modifier.width(6.dp)) + Text("Share") + } + } +} + diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/GroupRow.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/GroupRow.kt new file mode 100644 index 0000000..d9addc8 --- /dev/null +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/GroupRow.kt @@ -0,0 +1,57 @@ +package com.example.budgeting.android.ui.components.group + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.example.budgeting.android.data.model.Group + +@Composable +fun GroupRow( + group: Group, + onOpenGroup: (Int) -> Unit +) { + // Safe to use !! since we filter out nulls before passing to this composable + val groupId = group.id!! + val groupName = group.name!! + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surface) + .clickable { onOpenGroup(groupId) } + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(20.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary) + ) + } + + Spacer(modifier = Modifier.size(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text(text = groupName, color = MaterialTheme.colorScheme.onSurface) + } + } +} + diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/GroupShareDialog.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/GroupShareDialog.kt new file mode 100644 index 0000000..3cb82f6 --- /dev/null +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/GroupShareDialog.kt @@ -0,0 +1,198 @@ +package com.example.budgeting.android.ui.components.group + +import android.graphics.BitmapFactory +import android.widget.Toast +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.example.budgeting.android.ui.utils.GroupUtils + +@Composable +fun GroupShareDialog( + groupName: String, + invitationCode: String?, + qrBytes: ByteArray?, + isLoading: Boolean, + error: String?, + onDismiss: () -> Unit, + onRetry: () -> Unit = {} +) { + val context = LocalContext.current + val qrBitmap = remember(qrBytes) { + qrBytes?.let { + runCatching { BitmapFactory.decodeByteArray(it, 0, it.size) }.getOrNull() + } + } + + Dialog(onDismissRequest = onDismiss) { + Surface( + color = MaterialTheme.colorScheme.background, + shape = RoundedCornerShape(16.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = onDismiss) { + Text("✕", color = MaterialTheme.colorScheme.onBackground) + } + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.Center + ) { + Text( + text = "Share \"${groupName}\"", + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.titleMedium + ) + } + Spacer(modifier = Modifier.width(32.dp)) + } + + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Share this QR code or invitation code to invite others.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium + ) + + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Invitation code", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold + ) + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) { + Text( + text = invitationCode ?: "Not available", + modifier = Modifier.padding(12.dp), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleMedium + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "QR Code", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold + ) + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 200.dp) + .padding(vertical = 8.dp), + contentAlignment = Alignment.Center + ) { + when { + isLoading -> { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + CircularProgressIndicator() + Text( + text = "Loading QR code...", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall + ) + } + } + qrBitmap != null -> { + Image( + bitmap = qrBitmap.asImageBitmap(), + contentDescription = "Group QR Code", + modifier = Modifier.size(220.dp) + ) + } + error != null -> { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Failed to load QR code", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = "You can still share the invitation code", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall + ) + OutlinedButton( + onClick = onRetry, + modifier = Modifier.padding(top = 4.dp) + ) { + Text("Retry") + } + } + } + else -> { + Text( + text = "QR code not available", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + + error?.let { errorText -> + Text( + text = errorText, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(bottom = 8.dp) + ) + } + + Button( + onClick = { + invitationCode?.let { code -> + GroupUtils.shareGroupInvite(context, groupName, code) + } ?: run { + Toast.makeText( + context, + "Invitation code unavailable", + Toast.LENGTH_SHORT + ).show() + } + }, + enabled = invitationCode != null, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + shape = RoundedCornerShape(12.dp) + ) { + Text("Share code") + } + } + } + } +} + diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/JoinGroupDialog.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/JoinGroupDialog.kt new file mode 100644 index 0000000..a922b7e --- /dev/null +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/JoinGroupDialog.kt @@ -0,0 +1,192 @@ +package com.example.budgeting.android.ui.components.group + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.core.content.ContextCompat +import com.example.budgeting.android.ui.screens.QrCaptureActivity +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanOptions + +@Composable +fun JoinGroupDialog( + onDismiss: () -> Unit, + isLoading: Boolean, + error: String?, + onJoinByCode: (code: String) -> Unit +) { + var invitationCodeText by remember { mutableStateOf("") } + var localErrorMessage by remember { mutableStateOf(null) } + val context = LocalContext.current + + val scanLauncher = rememberLauncherForActivityResult(ScanContract()) { result -> + val scannedCode = result.contents?.trim()?.uppercase() + if (!scannedCode.isNullOrBlank()) { + invitationCodeText = scannedCode + localErrorMessage = null + // Automatically join the group when QR code is scanned + onJoinByCode(scannedCode) + } else { + localErrorMessage = "No QR code detected. Please try again." + } + } + + val startScanner = remember(scanLauncher) { + { + val options = ScanOptions().apply { + setDesiredBarcodeFormats(ScanOptions.QR_CODE) + setPrompt("") + setBeepEnabled(false) + setBarcodeImageEnabled(false) + setOrientationLocked(true) + setCaptureActivity(QrCaptureActivity::class.java) + } + scanLauncher.launch(options) + } + } + + val cameraPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + if (granted) { + startScanner() + } else { + localErrorMessage = "Camera permission is required to scan QR codes." + } + } + + fun launchScanner() { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) + == PackageManager.PERMISSION_GRANTED + ) { + startScanner() + } else { + cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + } + + Dialog(onDismissRequest = onDismiss) { + Surface( + color = MaterialTheme.colorScheme.background, + shape = RoundedCornerShape(16.dp) + ) { + Column( + modifier = Modifier + .padding(12.dp) + .fillMaxWidth() + ) { + // Top bar with close and title + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = onDismiss) { + Text("✕", color = MaterialTheme.colorScheme.onBackground) + } + Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) { + Text(text = "Join Group", color = MaterialTheme.colorScheme.onBackground) + } + } + + Column( + modifier = Modifier + .padding(horizontal = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "Enter the invitation code or scan the QR shared with you.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium + ) + + TextField( + value = invitationCodeText, + onValueChange = { + invitationCodeText = it.trim().uppercase().filter { char -> + char.isLetterOrDigit() + } + localErrorMessage = null + }, + placeholder = { Text("Invitation Code") }, + singleLine = true, + enabled = !isLoading, + shape = RoundedCornerShape(10.dp), + colors = TextFieldDefaults.colors( + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + cursorColor = MaterialTheme.colorScheme.primary, + focusedTextColor = MaterialTheme.colorScheme.onSurface, + unfocusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant + ), + modifier = Modifier.fillMaxWidth() + ) + + OutlinedButton( + onClick = { launchScanner() }, + enabled = !isLoading, + shape = RoundedCornerShape(10.dp), + modifier = Modifier.fillMaxWidth() + ) { + Text("Scan QR Code") + } + + (localErrorMessage ?: error)?.let { errorMsg -> + Text( + text = errorMsg, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = 4.dp, top = 4.dp) + ) + } + } + + val canSubmit = invitationCodeText.isNotBlank() + Button( + onClick = { + if (invitationCodeText.isBlank()) { + localErrorMessage = "Please enter or scan an invitation code" + } else { + onJoinByCode(invitationCodeText) + } + }, + enabled = !isLoading && canSubmit, + modifier = Modifier + .padding(12.dp) + .fillMaxWidth() + .height(50.dp), + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) + ) { + if (isLoading) { + Text("Joining...") + } else { + Text("Join Group") + } + } + } + } + } +} + diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/TimelineItem.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/TimelineItem.kt new file mode 100644 index 0000000..e3fa7db --- /dev/null +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/TimelineItem.kt @@ -0,0 +1,17 @@ +package com.example.budgeting.android.ui.components.group + +import com.example.budgeting.android.data.model.GroupLog +import com.example.budgeting.android.ui.viewmodels.GroupExpense + +sealed class TimelineItem { + data class ExpenseItem( + val expense: GroupExpense, + val timestamp: String? + ) : TimelineItem() + + data class LogItem( + val log: GroupLog, + val timestamp: String? + ) : TimelineItem() +} + diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ExpensesScreen.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ExpensesScreen.kt index 60a629c..670ec32 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ExpensesScreen.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ExpensesScreen.kt @@ -316,7 +316,7 @@ fun AddEditExpenseDialog( onSave: (Expense) -> Unit ) { var title by remember { mutableStateOf(expense?.title ?: "") } - var category by remember { mutableStateOf(expense?.category ?: "") } + var categoryId by remember { mutableStateOf(expense?.category_id?.toString() ?: "1") } var amount by remember { mutableStateOf(expense?.amount?.toString() ?: "") } AlertDialog( @@ -331,12 +331,15 @@ fun AddEditExpenseDialog( modifier = Modifier.fillMaxWidth() ) OutlinedTextField( - value = category, - onValueChange = { category = it }, - label = { Text("Category") }, + value = categoryId, + onValueChange = { categoryId = it }, + label = { Text("Category ID") }, modifier = Modifier .fillMaxWidth() - .padding(top = 8.dp) + .padding(top = 8.dp), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + supportingText = { Text("Enter category ID (default: 1)", style = MaterialTheme.typography.bodySmall) } ) OutlinedTextField( value = amount, @@ -353,8 +356,13 @@ fun AddEditExpenseDialog( confirmButton = { TextButton(onClick = { val parsedAmount = amount.toDoubleOrNull() ?: 0.0 - if (title.isNotBlank() && category.isNotBlank()) { - onSave(Expense(title = title, category = category, amount = parsedAmount)) + val parsedCategoryId = categoryId.toIntOrNull() ?: 1 + if (title.isNotBlank()) { + onSave(Expense( + title = title, + category_id = parsedCategoryId, + amount = parsedAmount + )) } }) { Text("Save") diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/GroupDetailsScreen.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/GroupDetailsScreen.kt index 4b033ec..e412723 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/GroupDetailsScreen.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/GroupDetailsScreen.kt @@ -1,36 +1,21 @@ package com.example.budgeting.android.ui.screens -import android.content.Context -import android.content.Intent -import android.graphics.BitmapFactory -import android.widget.Toast import androidx.activity.compose.BackHandler -import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Person -import androidx.compose.material.icons.filled.Share import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog import androidx.lifecycle.viewmodel.compose.viewModel -import com.example.budgeting.android.data.model.Expense +import com.example.budgeting.android.ui.components.group.* +import com.example.budgeting.android.ui.utils.DateUtils import com.example.budgeting.android.ui.viewmodels.* -import java.time.LocalDate -import java.time.format.DateTimeFormatter @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -56,16 +41,20 @@ fun GroupDetailsScreen( val isLoading by vm.isLoading.collectAsState() val error by vm.error.collectAsState() val members by vm.members.collectAsState() + val logs by vm.logs.collectAsState() + val currentUserId by vm.currentUserId.collectAsState() val qrImage by vm.qrImage.collectAsState() val qrIsLoading by vm.qrIsLoading.collectAsState() val qrError by vm.qrError.collectAsState() var showShareDialog by remember { mutableStateOf(false) } + var selectedExpense by remember { mutableStateOf(null) } + var showPaymentDialog by remember { mutableStateOf(false) } LaunchedEffect(showShareDialog, group?.id) { val id = group?.id if (showShareDialog && id != null) { - vm.loadGroupInviteQr(id) + vm.loadGroupInviteQr(id, forceRefresh = true) } } @@ -118,7 +107,6 @@ fun GroupDetailsScreen( ) } - // Error message error?.let { errorMsg -> Text( text = errorMsg, @@ -128,7 +116,6 @@ fun GroupDetailsScreen( ) } - // Content: Loading or expenses list when { isLoading && expenses.isEmpty() -> { Box( @@ -155,10 +142,45 @@ fun GroupDetailsScreen( } } else -> { - val expensesWithDates = remember(expenses) { - expenses.map { expense -> - val date = parseDateFromString(expense.expense.created_at) - Triple(expense, date, date?.let { formatDateForDisplay(it) }) + // Filter logs: exclude creator and current user's own JOIN messages + val creatorId = members.minByOrNull { it.id }?.id + val filteredLogs = remember(logs, currentUserId, creatorId) { + logs.filter { log -> + if (creatorId != null && log.user_id == creatorId) return@filter false + if (currentUserId != null && log.user_id == currentUserId && log.action.uppercase() == "JOIN") { + return@filter false + } + true + } + } + + val timelineItems = remember(expenses, filteredLogs) { + val items = mutableListOf() + + expenses.forEach { expense -> + items.add(TimelineItem.ExpenseItem(expense, expense.expense.created_at)) + } + + filteredLogs.forEach { log -> + items.add(TimelineItem.LogItem(log, log.created_at)) + } + + items.sortedByDescending { item -> + when (item) { + is TimelineItem.ExpenseItem -> item.timestamp ?: "" + is TimelineItem.LogItem -> item.timestamp ?: "" + } + } + } + + val timelineWithDates = remember(timelineItems) { + timelineItems.map { item -> + val timestamp = when (item) { + is TimelineItem.ExpenseItem -> item.timestamp + is TimelineItem.LogItem -> item.timestamp + } + val date = DateUtils.parseDateFromString(timestamp) + Triple(item, date, date?.let { DateUtils.formatDateForDisplay(it) }) } } @@ -169,22 +191,42 @@ fun GroupDetailsScreen( .padding(horizontal = 12.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - expensesWithDates.forEachIndexed { index, (groupExpense, expenseDate, displayDate) -> + timelineWithDates.forEachIndexed { index, (item, itemDate, displayDate) -> val showDateHeader = index == 0 || - expenseDate != expensesWithDates.getOrNull(index - 1)?.second + itemDate != timelineWithDates.getOrNull(index - 1)?.second - if (showDateHeader && expenseDate != null) { - item(key = "date_${expenseDate}_$index") { + if (showDateHeader && itemDate != null) { + item(key = "date_${itemDate}_$index") { DateHeader(displayDate ?: "Unknown Date") } } - item(key = "expense_${groupExpense.expense.id}") { - ExpenseBubble( - expense = groupExpense.expense, - userName = groupExpense.userName, - description = groupExpense.description - ) + when (item) { + is TimelineItem.ExpenseItem -> { + item(key = "expense_${item.expense.expense.id}") { + ExpenseBubble( + expense = item.expense.expense, + userName = item.expense.userName, + description = item.expense.description, + onClick = { + // Only allow expense owner to manage payments + if (item.expense.expense.user_id == currentUserId) { + selectedExpense = item.expense + showPaymentDialog = true + } + }, + isClickable = item.expense.expense.user_id == currentUserId + ) + } + } + is TimelineItem.LogItem -> { + item(key = "log_${item.log.id}") { + GroupLogBubble( + log = item.log, + members = members + ) + } + } } } } @@ -204,566 +246,25 @@ fun GroupDetailsScreen( showShareDialog = false vm.clearQrError() }, - onShareCode = { code -> - shareGroupInvite(context, group!!.name ?: "Group", code) - } - ) - } -} - -@Composable -private fun GroupMetaRow( - memberCount: Int, - onShareClick: () -> Unit, - invitationCodeAvailable: Boolean -) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - Icons.Filled.Person, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.width(8.dp)) - Column { - Text( - text = "$memberCount members", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium - ) - Text( - text = "Active members", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall - ) - } - } - FilledTonalButton( - onClick = onShareClick, - enabled = invitationCodeAvailable, - shape = RoundedCornerShape(12.dp) - ) { - Icon( - imageVector = Icons.Filled.Share, - contentDescription = "Share group" - ) - Spacer(modifier = Modifier.width(6.dp)) - Text("Share") - } - } -} - -@Composable -private fun GroupShareDialog( - groupName: String, - invitationCode: String?, - qrBytes: ByteArray?, - isLoading: Boolean, - error: String?, - onDismiss: () -> Unit, - onShareCode: (String) -> Unit -) { - val context = LocalContext.current - val qrBitmap = remember(qrBytes) { - qrBytes?.let { - runCatching { BitmapFactory.decodeByteArray(it, 0, it.size) }.getOrNull() - } - } - - Dialog(onDismissRequest = onDismiss) { - Surface( - color = MaterialTheme.colorScheme.background, - shape = RoundedCornerShape(16.dp), - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier - .padding(16.dp) - .fillMaxWidth() - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - TextButton(onClick = onDismiss) { - Text("✕", color = MaterialTheme.colorScheme.onBackground) - } - Box( - modifier = Modifier.weight(1f), - contentAlignment = Alignment.Center - ) { - Text( - text = "Share \"${groupName}\"", - color = MaterialTheme.colorScheme.onBackground, - style = MaterialTheme.typography.titleMedium - ) - } - Spacer(modifier = Modifier.width(32.dp)) - } - - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Share this QR code or invitation code to invite others.", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium - ) - - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = "Invitation code", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold - ) - Surface( - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceVariant, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - ) { - Text( - text = invitationCode ?: "Not available", - modifier = Modifier.padding(12.dp), - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.titleMedium - ) - } - - Spacer(modifier = Modifier.height(12.dp)) - Box( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 200.dp) - .padding(vertical = 8.dp), - contentAlignment = Alignment.Center - ) { - when { - isLoading -> { - CircularProgressIndicator() - } - qrBitmap != null -> { - Image( - bitmap = qrBitmap.asImageBitmap(), - contentDescription = "Group QR", - modifier = Modifier.size(220.dp) - ) - } - else -> { - Text( - text = "QR code not available", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium - ) - } - } - } - - error?.let { errorText -> - Text( - text = errorText, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(bottom = 8.dp) - ) - } - - Button( - onClick = { - invitationCode?.let { code -> - onShareCode(code) - } ?: run { - Toast.makeText( - context, - "Invitation code unavailable", - Toast.LENGTH_SHORT - ).show() - } - }, - enabled = invitationCode != null, - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), - shape = RoundedCornerShape(12.dp) - ) { - Text("Share code") + onRetry = { + group?.id?.let { id -> + vm.loadGroupInviteQr(id, forceRefresh = true) } } - } - } -} - -private fun shareGroupInvite(context: Context, groupName: String, invitationCode: String) { - val shareIntent = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_SUBJECT, "Join $groupName") - putExtra( - Intent.EXTRA_TEXT, - "Join \"$groupName\" using this invitation code: $invitationCode" ) } - try { - context.startActivity( - Intent.createChooser(shareIntent, "Share group invite") - ) - } catch (e: Exception) { - Toast.makeText( - context, - "Unable to open share options", - Toast.LENGTH_SHORT - ).show() - } -} - -@Composable -private fun DateHeader(date: String) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - contentAlignment = Alignment.Center - ) { - Surface( - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), - shape = RoundedCornerShape(12.dp), - modifier = Modifier.padding(horizontal = 16.dp) - ) { - Text( - text = date, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) - ) - } - } -} - -private fun parseDateFromString(dateString: String?): LocalDate? { - if (dateString == null) return null - - return try { - try { - val zonedDateTime = java.time.ZonedDateTime.parse(dateString) - return zonedDateTime.toLocalDate() - } catch (e: Exception) { - } - - try { - val dateTime = java.time.LocalDateTime.parse(dateString.take(19)) - return dateTime.toLocalDate() - } catch (e: Exception) { - } - - try { - return LocalDate.parse(dateString.take(10)) - } catch (e: Exception) { - } - - try { - val cleaned = dateString.replace("T", " ").take(19) - val dateTime = java.time.LocalDateTime.parse(cleaned, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) - return dateTime.toLocalDate() - } catch (e: Exception) { - } - - null - } catch (e: Exception) { - null - } -} - -private fun formatDateForDisplay(date: LocalDate): String { - val today = LocalDate.now() - val yesterday = today.minusDays(1) - - return when { - date == today -> "Today" - date == yesterday -> "Yesterday" - date.year == today.year -> date.format(DateTimeFormatter.ofPattern("MMM d")) - else -> date.format(DateTimeFormatter.ofPattern("MMM d, yyyy")) - } -} - -private fun isExpenseAlreadyInGroup( - expense: Expense, - groupExpenses: List -): Boolean { - return groupExpenses.any { groupExpense -> - groupExpense.expense.title == expense.title && - groupExpense.expense.amount == expense.amount && - groupExpense.expense.category == expense.category && - groupExpense.expense.user_id == expense.user_id - } -} - -@Composable -private fun ExpenseBubble( - expense: Expense, - userName: String, - description: String? -) { - Column(modifier = Modifier.fillMaxWidth()) { - // User name row - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(bottom = 4.dp) - ) { - Icon( - Icons.Filled.Person, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(16.dp) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = userName, - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall - ) - } - - // Expense bubble - Box( - modifier = Modifier - .background( - MaterialTheme.colorScheme.surfaceVariant, - RoundedCornerShape(10.dp) - ) - .padding(horizontal = 12.dp, vertical = 8.dp) - ) { - Column { - Text( - text = "${expense.title} - $${String.format("%.2f", expense.amount)}", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.bodyMedium - ) - if (!description.isNullOrBlank()) { - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = description, - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall - ) - } - } - } - } -} - -@Composable -private fun BottomAddExpenseBar( - vm: GroupDetailsViewModel -) { - val context = LocalContext.current - val expenseViewModel: ExpenseViewModel = viewModel( - factory = ExpenseViewModelFactory(context) - ) - - LaunchedEffect(Unit) { - expenseViewModel.loadExpenses() - } - - val personalExpenses by expenseViewModel.expenses.collectAsState() - val groupExpenses by vm.expenses.collectAsState() - val personalExpensesOnly = remember(personalExpenses, groupExpenses) { - val existingExpenseIds = groupExpenses.map { it.expense.id }.toSet() - personalExpenses.filter { expense -> - expense.group_id == null && - !existingExpenseIds.contains(expense.id) && - !isExpenseAlreadyInGroup(expense, groupExpenses) - } - } - var showPicker by remember { mutableStateOf(false) } - var description by remember { mutableStateOf("") } - - BackHandler(enabled = showPicker) { - showPicker = false - } - - Surface(color = MaterialTheme.colorScheme.background) { - Row( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - TextField( - value = description, - onValueChange = { description = it }, - modifier = Modifier.weight(1f), - placeholder = { Text("Add description (optional)") }, - colors = TextFieldDefaults.colors( - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, - focusedIndicatorColor = MaterialTheme.colorScheme.primary, - unfocusedIndicatorColor = MaterialTheme.colorScheme.surfaceVariant - ), - shape = RoundedCornerShape(12.dp), - singleLine = true - ) - Button( - onClick = { showPicker = true }, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary - ), - shape = RoundedCornerShape(12.dp), - modifier = Modifier.height(52.dp) - ) { - Text("+") - } - } - } - - if (showPicker) { - ExpensePickerDialog( - expenses = personalExpensesOnly, - onDismiss = { showPicker = false }, - onConfirm = { selected -> - vm.addExpensesFromPersonal(selected, description, "You") - description = "" - showPicker = false + if (showPaymentDialog && selectedExpense != null) { + ExpensePaymentDialog( + expense = selectedExpense!!.expense, + members = members, + vm = vm, + onDismiss = { + showPaymentDialog = false + selectedExpense = null + vm.loadGroup(groupId) // Reload group to refresh expenses and payments } ) } } -@Composable -private fun ExpensePickerDialog( - expenses: List, - onDismiss: () -> Unit, - onConfirm: (List) -> Unit -) { - Dialog(onDismissRequest = onDismiss) { - Surface( - color = MaterialTheme.colorScheme.background, - shape = RoundedCornerShape(16.dp), - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier - .padding(12.dp) - .fillMaxWidth() - .heightIn(max = 480.dp) - ) { - // Header - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - TextButton(onClick = onDismiss) { - Text("✕", color = MaterialTheme.colorScheme.onBackground) - } - Box( - modifier = Modifier.weight(1f), - contentAlignment = Alignment.Center - ) { - Text( - text = "Choose expenses", - color = MaterialTheme.colorScheme.onBackground, - style = MaterialTheme.typography.titleMedium - ) - } - Spacer(modifier = Modifier.width(32.dp)) - } - - // Expense list - val selected = remember { mutableStateListOf() } - - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - itemsIndexed(expenses) { index, expense -> - Surface( - shape = RoundedCornerShape(12.dp), - color = if (selected.contains(index)) { - MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) - } else { - MaterialTheme.colorScheme.surfaceVariant - }, - modifier = Modifier - .fillMaxWidth() - .clickable { - if (selected.contains(index)) { - selected.remove(index) - } else { - selected.add(index) - } - } - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = expense.title, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = expense.category, - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall - ) - } - Text( - text = "$${"%.2f".format(expense.amount)}", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.bodyMedium - ) - } - } - } - } - - // Action buttons - Row( - modifier = Modifier - .padding(12.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - OutlinedButton( - onClick = onDismiss, - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(12.dp) - ) { - Text("Cancel") - } - Button( - onClick = { - val items = selected.map { expenses[it] } - onConfirm(items) - }, - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(12.dp), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary - ) - ) { - Text("Add") - } - } - } - } - } -} diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/GroupsScreen.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/GroupsScreen.kt index 16d5fc1..45a708c 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/GroupsScreen.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/GroupsScreen.kt @@ -1,14 +1,8 @@ package com.example.budgeting.android.ui.screens -import android.Manifest -import android.content.pm.PackageManager -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add @@ -16,21 +10,12 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.platform.LocalConfiguration -import androidx.core.content.ContextCompat import androidx.lifecycle.viewmodel.compose.viewModel import androidx.compose.ui.platform.LocalContext +import com.example.budgeting.android.ui.components.group.* import com.example.budgeting.android.ui.viewmodels.GroupsViewModel import com.example.budgeting.android.ui.viewmodels.GroupsViewModelFactory -import androidx.compose.runtime.LaunchedEffect -import com.example.budgeting.android.data.model.Group -import com.journeyapps.barcodescanner.ScanContract -import com.journeyapps.barcodescanner.ScanOptions @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -197,330 +182,3 @@ fun GroupsScreen(onOpenGroup: (Int) -> Unit) { ) } } - -@Composable -private fun GroupRow(group: Group, onOpenGroup: (Int) -> Unit) { - // Safe to use !! since we filter out nulls before passing to this composable - val groupId = group.id!! - val groupName = group.name!! - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.surface) - .clickable { onOpenGroup(groupId) } - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .size(48.dp) - .clip(RoundedCornerShape(10.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant), - contentAlignment = Alignment.Center - ) { - Box( - modifier = Modifier - .size(20.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary) - ) - } - - Spacer(modifier = Modifier.size(12.dp)) - - Column(modifier = Modifier.weight(1f)) { - Text(text = groupName, color = MaterialTheme.colorScheme.onSurface) - } - } -} - -@Composable -private fun CreateGroupDialog( - onDismiss: () -> Unit, - isLoading: Boolean, - error: String?, - onCreate: (name: String, description: String?) -> Unit -) { - var groupName by remember { mutableStateOf("") } - var groupDescription by remember { mutableStateOf("") } - - Dialog(onDismissRequest = onDismiss) { - val configuration = LocalConfiguration.current - val maxHeight = configuration.screenHeightDp.dp * 0.6f - Surface( - color = MaterialTheme.colorScheme.background, - shape = RoundedCornerShape(16.dp), - modifier = Modifier - .fillMaxWidth(0.95f) - .heightIn(max = maxHeight) - ) { - Column( - modifier = Modifier - .padding(12.dp) - .fillMaxWidth() - ) { - // Top bar with close and title - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - TextButton(onClick = onDismiss) { Text("✕", color = MaterialTheme.colorScheme.onBackground) } - Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) { - Text(text = "New Group", color = MaterialTheme.colorScheme.onBackground) - } - Spacer(modifier = Modifier.size(32.dp)) - } - - // Inputs - Column( - modifier = Modifier - .padding(horizontal = 12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - TextField( - value = groupName, - onValueChange = { groupName = it }, - placeholder = { Text("Group Name") }, - label = { Text("Group Name") }, - singleLine = true, - enabled = !isLoading, - shape = RoundedCornerShape(10.dp), - colors = TextFieldDefaults.colors( - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - cursorColor = MaterialTheme.colorScheme.primary, - focusedTextColor = MaterialTheme.colorScheme.onSurface, - unfocusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant - ), - modifier = Modifier.fillMaxWidth() - ) - - TextField( - value = groupDescription, - onValueChange = { groupDescription = it }, - placeholder = { Text("Description (optional)") }, - label = { Text("Description") }, - singleLine = false, - maxLines = 3, - enabled = !isLoading, - shape = RoundedCornerShape(10.dp), - colors = TextFieldDefaults.colors( - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - cursorColor = MaterialTheme.colorScheme.primary, - focusedTextColor = MaterialTheme.colorScheme.onSurface, - unfocusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant - ), - modifier = Modifier.fillMaxWidth() - ) - - // Show error message if any - error?.let { errorMsg -> - Text( - text = errorMsg, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(start = 4.dp, top = 4.dp) - ) - } - } - - // Bottom action button - Button( - onClick = { - onCreate( - groupName, - groupDescription.ifBlank { null } - ) - }, - enabled = !isLoading && groupName.isNotBlank(), - modifier = Modifier - .padding(12.dp) - .fillMaxWidth() - .height(50.dp), - shape = RoundedCornerShape(10.dp), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary - ) - ) { - if (isLoading) { - Text("Creating...") - } else { - Text("Create Group") - } - } - } - } - } -} - -@Composable -private fun JoinGroupDialog( - onDismiss: () -> Unit, - isLoading: Boolean, - error: String?, - onJoinByCode: (code: String) -> Unit -) { - var invitationCodeText by remember { mutableStateOf("") } - var localErrorMessage by remember { mutableStateOf(null) } - val context = LocalContext.current - - val scanLauncher = rememberLauncherForActivityResult(ScanContract()) { result -> - val scannedCode = result.contents?.trim() - if (!scannedCode.isNullOrBlank()) { - invitationCodeText = scannedCode - localErrorMessage = null - onJoinByCode(scannedCode) - } - } - - val startScanner = remember(scanLauncher) { - { - val options = ScanOptions().apply { - setDesiredBarcodeFormats(ScanOptions.QR_CODE) - setPrompt("") - setBeepEnabled(false) - setBarcodeImageEnabled(false) - setOrientationLocked(true) - setCaptureActivity(QrCaptureActivity::class.java) - } - scanLauncher.launch(options) - } - } - - val cameraPermissionLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission() - ) { granted -> - if (granted) { - startScanner() - } else { - localErrorMessage = "Camera permission is required to scan QR codes." - } - } - - fun launchScanner() { - if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) - == PackageManager.PERMISSION_GRANTED - ) { - startScanner() - } else { - cameraPermissionLauncher.launch(Manifest.permission.CAMERA) - } - } - - Dialog(onDismissRequest = onDismiss) { - Surface( - color = MaterialTheme.colorScheme.background, - shape = RoundedCornerShape(16.dp) - ) { - Column(modifier = Modifier - .padding(12.dp) - .fillMaxWidth() - ) { - // Top bar with close and title - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - TextButton(onClick = onDismiss) { - Text("✕", color = MaterialTheme.colorScheme.onBackground) - } - Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) { - Text(text = "Join Group", color = MaterialTheme.colorScheme.onBackground) - } - } - - Column( - modifier = Modifier - .padding(horizontal = 12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - text = "Enter the invitation code or scan the QR shared with you.", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium - ) - - TextField( - value = invitationCodeText, - onValueChange = { - invitationCodeText = it.trim().uppercase() - localErrorMessage = null - }, - placeholder = { Text("Invitation Code") }, - singleLine = true, - enabled = !isLoading, - shape = RoundedCornerShape(10.dp), - colors = TextFieldDefaults.colors( - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - cursorColor = MaterialTheme.colorScheme.primary, - focusedTextColor = MaterialTheme.colorScheme.onSurface, - unfocusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant - ), - modifier = Modifier.fillMaxWidth() - ) - - OutlinedButton( - onClick = { launchScanner() }, - enabled = !isLoading, - shape = RoundedCornerShape(10.dp), - modifier = Modifier.fillMaxWidth() - ) { - Text("Scan QR Code") - } - - (localErrorMessage ?: error)?.let { errorMsg -> - Text( - text = errorMsg, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(start = 4.dp, top = 4.dp) - ) - } - } - - val canSubmit = invitationCodeText.isNotBlank() - Button( - onClick = { - if (invitationCodeText.isBlank()) { - localErrorMessage = "Please enter or scan an invitation code" - } else { - onJoinByCode(invitationCodeText) - } - }, - enabled = !isLoading && canSubmit, - modifier = Modifier - .padding(12.dp) - .fillMaxWidth() - .height(50.dp), - shape = RoundedCornerShape(10.dp), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary - ) - ) { - if (isLoading) { - Text("Joining...") - } else { - Text("Join Group") - } - } - } - } - } -} \ No newline at end of file diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/utils/DateUtils.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/utils/DateUtils.kt new file mode 100644 index 0000000..3eea3d5 --- /dev/null +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/utils/DateUtils.kt @@ -0,0 +1,69 @@ +package com.example.budgeting.android.ui.utils + +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +/** + * Utility functions for date parsing and formatting + */ +object DateUtils { + + /** + * Parses a date string into a LocalDate object. + * Handles multiple date formats including ISO 8601, LocalDateTime, and simple date formats. + */ + fun parseDateFromString(dateString: String?): LocalDate? { + if (dateString == null) return null + + return try { + // Try ISO 8601 with timezone + try { + val zonedDateTime = java.time.ZonedDateTime.parse(dateString) + return zonedDateTime.toLocalDate() + } catch (e: Exception) { + } + + // Try LocalDateTime format + try { + val dateTime = java.time.LocalDateTime.parse(dateString.take(19)) + return dateTime.toLocalDate() + } catch (e: Exception) { + } + + // Try simple date format (yyyy-MM-dd) + try { + return LocalDate.parse(dateString.take(10)) + } catch (e: Exception) { + } + + // Try custom format (yyyy-MM-dd HH:mm:ss) + try { + val cleaned = dateString.replace("T", " ").take(19) + val dateTime = java.time.LocalDateTime.parse(cleaned, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + return dateTime.toLocalDate() + } catch (e: Exception) { + } + + null + } catch (e: Exception) { + null + } + } + + /** + * Formats a LocalDate for display. + * Shows "Today", "Yesterday", or formatted date based on the date. + */ + fun formatDateForDisplay(date: LocalDate): String { + val today = LocalDate.now() + val yesterday = today.minusDays(1) + + return when { + date == today -> "Today" + date == yesterday -> "Yesterday" + date.year == today.year -> date.format(DateTimeFormatter.ofPattern("MMM d")) + else -> date.format(DateTimeFormatter.ofPattern("MMM d, yyyy")) + } + } +} + diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/utils/GroupUtils.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/utils/GroupUtils.kt new file mode 100644 index 0000000..826718e --- /dev/null +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/utils/GroupUtils.kt @@ -0,0 +1,45 @@ +package com.example.budgeting.android.ui.utils + +import android.content.Context +import android.content.Intent +import android.widget.Toast +import com.example.budgeting.android.data.model.Expense +import com.example.budgeting.android.ui.viewmodels.GroupExpense + +object GroupUtils { + + fun isExpenseAlreadyInGroup( + expense: Expense, + groupExpenses: List + ): Boolean { + return groupExpenses.any { groupExpense -> + groupExpense.expense.title == expense.title && + groupExpense.expense.amount == expense.amount && + groupExpense.expense.categoryTitle == expense.categoryTitle && + groupExpense.expense.user_id == expense.user_id + } + } + + fun shareGroupInvite(context: Context, groupName: String, invitationCode: String) { + val shareIntent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_SUBJECT, "Join $groupName") + putExtra( + Intent.EXTRA_TEXT, + "Join \"$groupName\" using this invitation code: $invitationCode" + ) + } + try { + context.startActivity( + Intent.createChooser(shareIntent, "Share group invite") + ) + } catch (e: Exception) { + Toast.makeText( + context, + "Unable to open share options", + Toast.LENGTH_SHORT + ).show() + } + } +} + diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/AnalyticsViewModel.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/AnalyticsViewModel.kt index 332b143..bfe295d 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/AnalyticsViewModel.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/AnalyticsViewModel.kt @@ -92,7 +92,8 @@ class AnalyticsViewModel(context: Context) : ViewModel() { val userId = tokenStore.getUserId() val response = groupRepository.getGroupsByUser(userId!!) if (response.isSuccessful && response.body() != null) { - _groupIds.value = response.body()!!.data!!.map { it.id!! } + _groupIds.value = response.body()!!.map { it.id as Int } + loadAnalytics() } } catch (e: Exception) { _error.value = e.localizedMessage @@ -142,11 +143,11 @@ class AnalyticsViewModel(context: Context) : ViewModel() { } // --- CATEGORY AMOUNT --- - _categoryAmounts.value = expenses.groupBy { it.category } + _categoryAmounts.value = expenses.groupBy { it.categoryTitle } .map { (cat, list) -> CategoryTotal(category = cat, total = list.sumOf { it.amount }.toFloat()) } - + // --- CATEGORY COUNT --- - _categoryCounts.value = expenses.groupBy { it.category } + _categoryCounts.value = expenses.groupBy { it.categoryTitle } .map { (cat, list) -> CategoryCount(category = cat, count = list.size) } // --- MONTHLY TREND --- @@ -154,7 +155,7 @@ class AnalyticsViewModel(context: Context) : ViewModel() { .map { (month, list) -> MonthlyTotal(month = month, total = list.sumOf { it.amount }.toFloat()) } // --- AVAILABLE CATEGORIES --- - _categories.value = expenses.map { it.category }.distinct().sorted() + _categories.value = expenses.mapNotNull { it.category?.title }.distinct().sorted() } catch (e: Exception) { _error.value = e.localizedMessage diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ExpenseViewModel.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ExpenseViewModel.kt index 877c54b..5ed7582 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ExpenseViewModel.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ExpenseViewModel.kt @@ -134,7 +134,7 @@ class ExpenseViewModel(context: Context) : ViewModel() { private fun updateCategories(expenses: List) { val newCategories = expenses - .map { it.category } + .mapNotNull { it.category?.title } .filter { it.isNotBlank() } .distinct() .sorted() @@ -161,7 +161,7 @@ class ExpenseViewModel(context: Context) : ViewModel() { val userId = tokenStore.getUserId() val response = groupRepository.getGroupsByUser(userId!!) if (response.isSuccessful && response.body() != null) { - _groupIds.value = response.body()!!.data!!.map { it.id!! } // store all group IDs + _groupIds.value = response.body()!!.map { it.id!! } // store all group IDs } else { _error.value = "Error loading user groups" } diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/GroupDetailsViewModel.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/GroupDetailsViewModel.kt index 7759bc9..6c2c1ad 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/GroupDetailsViewModel.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/GroupDetailsViewModel.kt @@ -6,9 +6,12 @@ import androidx.lifecycle.viewModelScope import com.example.budgeting.android.data.auth.TokenHolder import com.example.budgeting.android.data.local.TokenDataStore import com.example.budgeting.android.data.model.Group +import com.example.budgeting.android.data.model.GroupLog import com.example.budgeting.android.data.model.UserData import com.example.budgeting.android.data.model.Expense +import com.example.budgeting.android.data.model.ExpensePayment import com.example.budgeting.android.data.network.RetrofitClient +import com.example.budgeting.android.data.repository.ExpensePaymentRepository import com.example.budgeting.android.data.repository.ExpenseRepository import com.example.budgeting.android.data.repository.GroupRepository import kotlinx.coroutines.flow.MutableStateFlow @@ -30,6 +33,10 @@ class GroupDetailsViewModel(context: Context) : ViewModel() { RetrofitClient.expenseInstance, tokenDataStore ) + private val expensePaymentRepository = ExpensePaymentRepository( + RetrofitClient.expensePaymentInstance, + tokenDataStore + ) private val _group = MutableStateFlow(null) val group: StateFlow = _group.asStateFlow() @@ -55,12 +62,19 @@ class GroupDetailsViewModel(context: Context) : ViewModel() { private val _qrError = MutableStateFlow(null) val qrError: StateFlow = _qrError.asStateFlow() + private val _logs = MutableStateFlow>(emptyList()) + val logs: StateFlow> = _logs.asStateFlow() + + private val _currentUserId = MutableStateFlow(null) + val currentUserId: StateFlow = _currentUserId.asStateFlow() + init { viewModelScope.launch { val savedToken = tokenDataStore.tokenFlow.firstOrNull() if (!savedToken.isNullOrBlank()) { TokenHolder.token = savedToken } + _currentUserId.value = tokenDataStore.getUserId() } } @@ -79,14 +93,14 @@ class GroupDetailsViewModel(context: Context) : ViewModel() { _isLoading.value = false return@launch } - _group.value = groupResponse.body()?.data + _group.value = groupResponse.body() _qrImage.value = null _qrError.value = null // Load group members - val membersResponse = groupRepository.getUsersByGroup(groupId).body() - val membersList = if (membersResponse?.success == true && membersResponse.data != null) { - membersResponse.data + val membersResponse = groupRepository.getUsersByGroup(groupId) + val membersList = if (membersResponse.isSuccessful && membersResponse.body() != null) { + membersResponse.body()!! } else { emptyList() } @@ -109,6 +123,22 @@ class GroupDetailsViewModel(context: Context) : ViewModel() { _expenses.value = emptyList() } + // Load group logs (join/leave events) + try { + val logsResponse = groupRepository.getGroupLogs(groupId) + if (logsResponse.isSuccessful && logsResponse.body() != null) { + _logs.value = logsResponse.body()!! + } else { + val errorMsg = "Failed to load group logs: ${logsResponse.code()}" + if (_error.value == null) { + _error.value = errorMsg + } + _logs.value = emptyList() + } + } catch (e: Exception) { + _logs.value = emptyList() + } + } catch (e: Exception) { _error.value = e.message ?: "Unknown error" _expenses.value = emptyList() @@ -167,15 +197,15 @@ class GroupDetailsViewModel(context: Context) : ViewModel() { } val existingExpenses = _expenses.value - val createdExpenses = mutableListOf() val skippedExpenses = mutableListOf() + val addedExpenseIds = mutableListOf() for (expense in expenses) { // Check if expense is already in the group val isDuplicate = existingExpenses.any { groupExpense -> groupExpense.expense.title == expense.title && groupExpense.expense.amount == expense.amount && - groupExpense.expense.category == expense.category && + groupExpense.expense.categoryTitle == expense.categoryTitle && groupExpense.expense.user_id == userId } @@ -191,11 +221,17 @@ class GroupDetailsViewModel(context: Context) : ViewModel() { created_at = null ) - try{ - val expenseId = groupRepository.addExpenseToGroup(expenseToCreate) - createdExpenses.add(groupRepository.getExpenseById(expenseId)) - } catch (e: Exception){ - _error.value = "Failed to add expense: ${e.message}" + try { + val expenseId = groupRepository.addExpenseToGroup(expenseToCreate, description?.takeIf { it.isNotBlank() }) + addedExpenseIds.add(expenseId) + + // Automatically mark the expense creator as having paid + try { + expensePaymentRepository.markPaid(expenseId, userId) + } catch (e: Exception) { + } + } catch (e: Exception) { + _error.value = "Failed to add expense '${expense.title}': ${e.message}" _isLoading.value = false return@launch } @@ -210,24 +246,48 @@ class GroupDetailsViewModel(context: Context) : ViewModel() { _error.value = skippedMessage } - val membersList = _members.value - val descriptionText = description?.takeIf { it.isNotBlank() } - val newGroupExpenses = createdExpenses.map { expense -> - val userName = expense.user_id?.let { userId -> - membersList.find { it.id == userId }?.let { user -> - "${user.firstName} ${user.lastName}" - } ?: "Unknown User" - } ?: "Group Member" - - GroupExpense( - expense = expense, - userName = userName, - description = descriptionText - ) + if (addedExpenseIds.isNotEmpty()) { + try { + val expensesResponse = groupRepository.getExpensesByGroup( + groupId = groupId, + offset = 0, + limit = 100, + sortBy = "created_at", + order = "asc" + ) + + if (expensesResponse.isSuccessful) { + val updatedExpenses = expensesResponse.body() ?: emptyList() + val membersList = _members.value + val groupExpenses = mapExpensesToGroupExpenses(updatedExpenses, membersList) + + val descriptionText = description?.takeIf { it.isNotBlank() } + val finalExpenses = if (descriptionText != null && addedExpenseIds.isNotEmpty()) { + groupExpenses.map { groupExpense -> + if (addedExpenseIds.contains(groupExpense.expense.id)) { + groupExpense.copy(description = descriptionText) + } else { + groupExpense + } + } + } else { + groupExpenses + } + + _expenses.value = finalExpenses + } else { + _error.value = "Expenses added but failed to refresh list" + } + + val logsResponse = groupRepository.getGroupLogs(groupId) + if (logsResponse.isSuccessful && logsResponse.body() != null) { + _logs.value = logsResponse.body()!! + } + } catch (e: Exception) { + _error.value = "Expenses added but failed to refresh: ${e.message}" + } } - _expenses.value = _expenses.value + newGroupExpenses - } catch (e: Exception) { _error.value = "Error adding expenses: ${e.message}" } finally { @@ -255,19 +315,29 @@ class GroupDetailsViewModel(context: Context) : ViewModel() { try { ensureTokenLoaded() val response = groupRepository.getGroupInviteQr(groupId) - if (response.isSuccessful) { - val body = response.body() - if (body != null) { + if (response.isSuccessful && response.body() != null) { + try { + val body = response.body()!! val bytes = body.bytes() - _qrImage.value = bytes - } else { - _qrError.value = "Empty QR response" + if (bytes.isNotEmpty()) { + _qrImage.value = bytes + _qrError.value = null + } else { + _qrError.value = "QR code data is empty" + } + } catch (e: Exception) { + _qrError.value = "Failed to read QR code: ${e.message ?: "Unknown error"}" } } else { - _qrError.value = "Failed to fetch QR: ${response.code()}" + val errorMessage = try { + response.errorBody()?.string() ?: "Unknown error (${response.code()})" + } catch (e: Exception) { + "Failed to fetch QR code: ${response.code()}" + } + _qrError.value = errorMessage } } catch (e: Exception) { - _qrError.value = e.message ?: "Failed to load QR code" + _qrError.value = "Failed to load QR code: ${e.message ?: "Unknown error"}" } finally { _qrIsLoading.value = false } @@ -277,4 +347,25 @@ class GroupDetailsViewModel(context: Context) : ViewModel() { fun clearQrError() { _qrError.value = null } + + suspend fun getExpensePayments(expenseId: Int): List { + return try { + expensePaymentRepository.getPayments(expenseId) + } catch (e: Exception) { + emptyList() + } + } + + suspend fun togglePaymentStatus(expenseId: Int, payerId: Int, isPaid: Boolean): Boolean { + return try { + val result = if (isPaid) { + expensePaymentRepository.markPaid(expenseId, payerId) + } else { + expensePaymentRepository.unmarkPaid(expenseId, payerId) + } + result + } catch (e: Exception) { + false + } + } } diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/GroupsViewModel.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/GroupsViewModel.kt index ec25248..096eab0 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/GroupsViewModel.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/GroupsViewModel.kt @@ -65,12 +65,13 @@ class GroupsViewModel(context: Context) : ViewModel() { } val response = repository.getGroupsByUser(userId) - if (response.isSuccessful) { + if (response.isSuccessful && response.body() != null) { try { - val groups = response.body()?.data?.filterNotNull() ?: emptyList() + val groups = response.body()!!.filterNotNull() // Filter out any groups that might be missing required fields val validGroups = groups.filter { it.isValid } _groups.value = validGroups + _error.value = null // Clear any previous errors on success } catch (e: Exception) { // JSON parsing error - response body might be malformed val errorBody = try { @@ -82,9 +83,14 @@ class GroupsViewModel(context: Context) : ViewModel() { _groups.value = emptyList() } } else { + val errorBody = try { + response.errorBody()?.string() + } catch (e: Exception) { + null + } val errorMessage = GroupsErrorHandler.parseErrorResponse( response.code(), - response.errorBody()?.string() + errorBody ) _error.value = errorMessage } @@ -134,7 +140,6 @@ class GroupsViewModel(context: Context) : ViewModel() { val response = repository.addUserToGroup(groupId, userId) if (response.isSuccessful) { - // Reload groups to get the updated list from the server loadGroups() onSuccess() } else { @@ -144,20 +149,13 @@ class GroupsViewModel(context: Context) : ViewModel() { errorBody ) - // Workaround: If the error is about 'id' missing (backend refresh issue), - // check if the user was actually added by reloading groups - // This handles the case where the backend successfully adds the user - // but fails on db.refresh() due to composite primary key if (errorBody?.contains("required valued", ignoreCase = true) == true && errorBody.contains("id", ignoreCase = true)) { - // The backend might have succeeded but failed on refresh - // Check if we're now in the group by reloading val checkResponse = repository.getGroupsByUser(userId) - if (checkResponse.isSuccessful) { - val userGroups = checkResponse.body()?.data?.filterNotNull() ?: emptyList() + if (checkResponse.isSuccessful && checkResponse.body() != null) { + val userGroups = checkResponse.body()!!.filterNotNull() val wasAdded = userGroups.any { it.id == groupId } if (wasAdded) { - // Success! The user was added despite the error loadGroups() onSuccess() return@launch @@ -165,7 +163,6 @@ class GroupsViewModel(context: Context) : ViewModel() { } } - // For 404 errors, ErrorHandler already returns "Group not found" _error.value = errorMessage _isLoading.value = false } @@ -213,12 +210,21 @@ class GroupsViewModel(context: Context) : ViewModel() { return@launch } - try{ - repository.joinGroupByInvitationCode(sanitizedCode) + val response = repository.joinGroupByInvitationCode(sanitizedCode) + if (response.isSuccessful) { loadGroups() onSuccess() - }catch (e: Exception){ - _error.value = e.toString() + } else { + val errorBody = try { + response.errorBody()?.string() + } catch (e: Exception) { + null + } + val errorMessage = GroupsErrorHandler.parseErrorResponse( + response.code(), + errorBody + ) + _error.value = errorMessage _isLoading.value = false } } catch (e: Exception) { @@ -234,7 +240,6 @@ class GroupsViewModel(context: Context) : ViewModel() { _isLoading.value = true _error.value = null try { - // Ensure token is loaded if (TokenHolder.token.isNullOrBlank()) { val savedToken = tokenDataStore.tokenFlow.firstOrNull() if (!savedToken.isNullOrBlank()) { @@ -250,42 +255,40 @@ class GroupsViewModel(context: Context) : ViewModel() { } val response = repository.createGroup(name, description) - if (response.isSuccessful) { - val groupId = response.body()?.data - if (groupId != null) { - // Automatically add the creator to the group - val addUserResponse = repository.addUserToGroup(groupId, userId) - if (addUserResponse.isSuccessful) { - // Reload groups to get the updated list from the server - loadGroups() - onSuccess(repository.getGroup(groupId).body()?.data!!) + if (response.isSuccessful && response.body() != null) { + val groupId = response.body()!! + val addUserResponse = repository.addUserToGroup(groupId, userId) + if (addUserResponse.isSuccessful) { + loadGroups() + val groupResponse = repository.getGroup(groupId) + if (groupResponse.isSuccessful && groupResponse.body() != null) { + onSuccess(groupResponse.body()!!) } else { - // Group was created but failed to add user - check if user was actually added - // Sometimes the backend succeeds but returns an error due to refresh issues - val checkResponse = repository.getGroupsByUser(userId) - if (checkResponse.isSuccessful) { - val userGroups = checkResponse.body()?.data?.filterNotNull() ?: emptyList() - val wasAdded = userGroups.any { it.id == groupId } - if (wasAdded) { - // Success! The user was added despite the error - loadGroups() - onSuccess(repository.getGroup(groupId).body()?.data!!) + onSuccess(Group(id = groupId, name = name, description = description)) + } + } else { + val checkResponse = repository.getGroupsByUser(userId) + if (checkResponse.isSuccessful && checkResponse.body() != null) { + val userGroups = checkResponse.body()!!.filterNotNull() + val wasAdded = userGroups.any { it.id == groupId } + if (wasAdded) { + loadGroups() + val groupResponse = repository.getGroup(groupId) + if (groupResponse.isSuccessful && groupResponse.body() != null) { + onSuccess(groupResponse.body()!!) } else { - // Group was created but user wasn't added - still reload groups - loadGroups() - _error.value = "Group created but failed to add you as a member. Please try joining manually." - _isLoading.value = false + onSuccess(Group(id = groupId, name = name, description = description)) } } else { - // Couldn't verify, but group was created loadGroups() _error.value = "Group created but failed to add you as a member. Please try joining manually." _isLoading.value = false } + } else { + loadGroups() + _error.value = "Group created but failed to add you as a member. Please try joining manually." + _isLoading.value = false } - } else { - _error.value = "Create failed: No group data received" - _isLoading.value = false } } else { val errorMessage = GroupsErrorHandler.parseErrorResponse( @@ -308,7 +311,6 @@ class GroupsViewModel(context: Context) : ViewModel() { _isLoading.value = true _error.value = null try { - // Ensure token is loaded if (TokenHolder.token.isNullOrBlank()) { val savedToken = tokenDataStore.tokenFlow.firstOrNull() if (!savedToken.isNullOrBlank()) { @@ -317,16 +319,10 @@ class GroupsViewModel(context: Context) : ViewModel() { } val response = repository.updateGroup(groupId, name, description) - if (response.isSuccessful) { - val group = response.body() - if (group != null) { - // Reload groups to get the updated list from the server - loadGroups() - onSuccess(repository.getGroup(groupId).body()?.data!!) - } else { - _error.value = "Update failed: No group data received" - _isLoading.value = false - } + if (response.isSuccessful && response.body() != null) { + val group = response.body()!! + loadGroups() + onSuccess(group) } else { val errorMessage = GroupsErrorHandler.parseErrorResponse( response.code(), @@ -358,7 +354,6 @@ class GroupsViewModel(context: Context) : ViewModel() { val response = repository.deleteGroup(groupId) if (response.isSuccessful) { - // Reload groups to get the updated list from the server loadGroups() onSuccess() } else { From d1edf2dc0dfe65458843b58f9336a29a48d25931 Mon Sep 17 00:00:00 2001 From: Ionut253 <48173899+Ionut253@users.noreply.github.com> Date: Mon, 8 Dec 2025 09:04:15 +0200 Subject: [PATCH 02/11] fixed --- API/dependencies/di.py | 15 ++++++++------- API/routes/user_routes.py | 22 +++------------------- API/services/user_group_service.py | 7 +++++++ 3 files changed, 18 insertions(+), 26 deletions(-) diff --git a/API/dependencies/di.py b/API/dependencies/di.py index e6a8cea..9bd38bd 100644 --- a/API/dependencies/di.py +++ b/API/dependencies/di.py @@ -52,13 +52,6 @@ def get_expense_service( def get_user_group_repository(db: Session = Depends(get_db)) -> IUserGroupRepository: return UserGroupRepository(db) -def get_user_group_service( - user_group_repo: IUserGroupRepository = Depends(get_user_group_repository), - group_repo: IGroupRepository = Depends(get_group_repository), - user_repo: IUserRepository = Depends(get_user_repository), -) -> IUserGroupService: - return UserGroupService(user_group_repo, group_repo, user_repo) - def get_group_log_repository(db: Session = Depends(get_db)) -> IGroupLogRepository: return GroupLogRepository(db) @@ -69,6 +62,14 @@ def get_group_log_service( ) -> IGroupLogService: return GroupLogService(group_log_repo, group_repo, user_repo) +def get_user_group_service( + user_group_repo: IUserGroupRepository = Depends(get_user_group_repository), + group_repo: IGroupRepository = Depends(get_group_repository), + user_repo: IUserRepository = Depends(get_user_repository), + log_service: IGroupLogService = Depends(get_group_log_service), +) -> IUserGroupService: + return UserGroupService(user_group_repo, group_repo, user_repo, log_service) + def get_expense_payment_repository(db: Session = Depends(get_db)) -> IExpensePaymentRepository: return ExpensePaymentRepository(db) diff --git a/API/routes/user_routes.py b/API/routes/user_routes.py index d674994..f94ae46 100644 --- a/API/routes/user_routes.py +++ b/API/routes/user_routes.py @@ -1,7 +1,6 @@ -from dependencies.di import get_group_log_service, get_user_group_service, get_user_service +from dependencies.di import get_user_group_service, get_user_service from fastapi import APIRouter, Depends, Request from schemas.user import UserChangePassword, UserUpdate -from services.group_log_service import IGroupLogService from services.user_group_service import IUserGroupService from services.user_service import IUserService from utils.helpers.jwt_utils import JwtUtils @@ -63,27 +62,12 @@ def delete_user(user_id: int, _ = Depends(get_current_user_id), user_service: IU def join_group_with_invitation_code( invitation_code: str, user_id: int = Depends(get_current_user_id), - user_group_service: IUserGroupService = Depends(get_user_group_service), - log_service: IGroupLogService = Depends(get_group_log_service) + user_group_service: IUserGroupService = Depends(get_user_group_service) ): """ Allows the authenticated user to join a group using an invitation code. """ - response = user_group_service.add_user_to_group_by_invitation_code(user_id, invitation_code) - - # log the join event if successful - if response.success and response.data: - group_data = response.data - if "group" in group_data: - group_obj = group_data["group"] - if hasattr(group_obj, "id"): - group_id = group_obj.id - log_service.log_join(group_id, user_id) - elif isinstance(group_obj, dict) and "id" in group_obj: - group_id = group_obj["id"] - log_service.log_join(group_id, user_id) - - return response + return user_group_service.add_user_to_group_by_invitation_code(user_id, invitation_code) @router.get("/{user_id}/budget") def get_budget(user_id: int, _ = Depends(get_current_user_id), user_service: IUserService = Depends(get_user_service)): diff --git a/API/services/user_group_service.py b/API/services/user_group_service.py index c7ee566..0e4efe5 100644 --- a/API/services/user_group_service.py +++ b/API/services/user_group_service.py @@ -1,6 +1,7 @@ from abc import ABC, abstractmethod from fastapi import HTTPException +from services.group_log_service import IGroupLogService from models.group import Group from models.user import User from models.user_group import UserGroup @@ -53,6 +54,7 @@ def __init__( repository: IUserGroupRepository, group_repo: IGroupRepository, user_repo: IUserRepository, + log_service: IGroupLogService, ): """ Constructor method. @@ -61,6 +63,7 @@ def __init__( self.group_repo = group_repo self.user_repo = user_repo self.logger = Logger() + self.log_service = log_service def _validate_group(self, group_id: int = None, invitation_code: str = None) -> Group: if group_id is not None: @@ -134,6 +137,10 @@ def add_user_to_group_by_invitation_code(self, user_id: int, invitation_code: st group_response = GroupResponse.model_validate(group_obj) user_response = UserResponse.model_validate(user_obj) + + # log the join event + if self.log_service: + self.log_service.log_join(group.id, user_id) return APIResponse( success=True, From 7ce5898b1a84bff031d59d938a761c7b35d26163 Mon Sep 17 00:00:00 2001 From: Ionut253 <48173899+Ionut253@users.noreply.github.com> Date: Mon, 8 Dec 2025 11:50:45 +0200 Subject: [PATCH 03/11] fixed --- API/dependencies/di.py | 4 ++-- API/services/expense_service.py | 2 +- API/services/user_group_service.py | 14 +++++++++----- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/API/dependencies/di.py b/API/dependencies/di.py index 9bd38bd..746ce00 100644 --- a/API/dependencies/di.py +++ b/API/dependencies/di.py @@ -66,9 +66,9 @@ def get_user_group_service( user_group_repo: IUserGroupRepository = Depends(get_user_group_repository), group_repo: IGroupRepository = Depends(get_group_repository), user_repo: IUserRepository = Depends(get_user_repository), - log_service: IGroupLogService = Depends(get_group_log_service), + log_repo: IGroupLogRepository = Depends(get_group_log_repository), ) -> IUserGroupService: - return UserGroupService(user_group_repo, group_repo, user_repo, log_service) + return UserGroupService(user_group_repo, group_repo, user_repo, log_repo) def get_expense_payment_repository(db: Session = Depends(get_db)) -> IExpensePaymentRepository: return ExpensePaymentRepository(db) diff --git a/API/services/expense_service.py b/API/services/expense_service.py index 60d257a..77c4a97 100644 --- a/API/services/expense_service.py +++ b/API/services/expense_service.py @@ -151,7 +151,7 @@ def get_user_expenses(self, *args, **kwargs) -> APIResponse: data=expenses_response ) - def get_group_expenses(self, group_id: int, offset: int, limit: int, sort_by: str, order: str) -> APIResponse: + def get_group_expenses(self, group_id: int, *args, **kwargs) -> APIResponse: """ Method for returning group expenses """ diff --git a/API/services/user_group_service.py b/API/services/user_group_service.py index 0e4efe5..d58f190 100644 --- a/API/services/user_group_service.py +++ b/API/services/user_group_service.py @@ -1,13 +1,13 @@ from abc import ABC, abstractmethod from fastapi import HTTPException -from services.group_log_service import IGroupLogService from models.group import Group from models.user import User from models.user_group import UserGroup from repositories.group_repository import IGroupRepository from repositories.user_group_repository import IUserGroupRepository from repositories.user_repository import IUserRepository +from repositories.group_log_repository import IGroupLogRepository from schemas.api_response import APIResponse from schemas.group import GroupResponse from schemas.user import UserResponse @@ -54,7 +54,7 @@ def __init__( repository: IUserGroupRepository, group_repo: IGroupRepository, user_repo: IUserRepository, - log_service: IGroupLogService, + log_repo: IGroupLogRepository, ): """ Constructor method. @@ -63,7 +63,7 @@ def __init__( self.group_repo = group_repo self.user_repo = user_repo self.logger = Logger() - self.log_service = log_service + self.log_repo = log_repo def _validate_group(self, group_id: int = None, invitation_code: str = None) -> Group: if group_id is not None: @@ -139,8 +139,12 @@ def add_user_to_group_by_invitation_code(self, user_id: int, invitation_code: st user_response = UserResponse.model_validate(user_obj) # log the join event - if self.log_service: - self.log_service.log_join(group.id, user_id) + if self.log_repo: + self.log_repo.add( + group_id=group.id, + user_id=user_id, + action="JOIN" + ) return APIResponse( success=True, From 223ad73f48ceaee554f781bc93848fde1807979b Mon Sep 17 00:00:00 2001 From: Ionut253 <48173899+Ionut253@users.noreply.github.com> Date: Mon, 8 Dec 2025 11:58:04 +0200 Subject: [PATCH 04/11] Update user_group_service.py --- API/services/user_group_service.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/API/services/user_group_service.py b/API/services/user_group_service.py index d58f190..b43345b 100644 --- a/API/services/user_group_service.py +++ b/API/services/user_group_service.py @@ -132,11 +132,8 @@ def add_user_to_group_by_invitation_code(self, user_id: int, invitation_code: st response = self.repository.add_user_to_group_by_invitation_code(user_id, invitation_code) - group_obj = self.group_repo.get_by_id(response[0]) - user_obj = self.user_repo.get_by_id(response[1]) - - group_response = GroupResponse.model_validate(group_obj) - user_response = UserResponse.model_validate(user_obj) + group_response = GroupResponse.model_validate(response[0]) + user_response = UserResponse.model_validate(response[1]) # log the join event if self.log_repo: From b5c0f15aad79a1f48a0ba1a811407aa2a6c010fc Mon Sep 17 00:00:00 2001 From: Bolosh Date: Mon, 8 Dec 2025 22:47:33 +0200 Subject: [PATCH 05/11] display budget on profile screen --- .../android/data/model/BudgetResponse.kt | 14 +++ .../android/data/network/RetrofitClient.kt | 4 + .../android/data/network/UserApiService.kt | 3 + .../android/data/repository/UserRepository.kt | 6 ++ .../android/ui/screens/ProfileScreen.kt | 102 +++++++++++++++++- .../android/ui/viewmodels/ProfileViewModel.kt | 29 +++-- 6 files changed, 150 insertions(+), 8 deletions(-) create mode 100644 Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/BudgetResponse.kt diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/BudgetResponse.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/BudgetResponse.kt new file mode 100644 index 0000000..8630248 --- /dev/null +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/BudgetResponse.kt @@ -0,0 +1,14 @@ +package com.example.budgeting.android.data.model + +import com.squareup.moshi.Json + +class BudgetResponse( + @Json(name = "budget") + val budget: Double, + + @Json(name = "spent_this_month") + val spentThisMonth: Double, + + @Json(name = "remaining_budget") + val remainingBudget: Double +) \ No newline at end of file diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/RetrofitClient.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/RetrofitClient.kt index 3509fba..a7d8570 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/RetrofitClient.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/RetrofitClient.kt @@ -11,6 +11,10 @@ import retrofit2.converter.moshi.MoshiConverterFactory object RetrofitClient { private val BASE_URL: String = BuildConfig.BASE_URL + init { + Log.d("RetrofitClient", "Base URL: $BASE_URL") + } + private val moshi = Moshi.Builder() .add(KotlinJsonAdapterFactory()) .build() diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/UserApiService.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/UserApiService.kt index 13521ea..1b3bcda 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/UserApiService.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/UserApiService.kt @@ -1,6 +1,7 @@ package com.example.budgeting.android.data.network import com.example.budgeting.android.data.model.ApiResponse +import com.example.budgeting.android.data.model.BudgetResponse import com.example.budgeting.android.data.model.ChangePasswordRequest import com.example.budgeting.android.data.model.UserResponse import com.example.budgeting.android.data.model.UserUpdateRequest @@ -20,4 +21,6 @@ interface UserApiService { @PUT("/users/password/change") suspend fun changePassword(@Body request: ChangePasswordRequest): Response> + @GET("/users/{id}/remaining-budget") + suspend fun getRemainingBudget(@Path("id") id: Int): Response> } \ No newline at end of file diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/repository/UserRepository.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/repository/UserRepository.kt index 86be9e5..e91ef24 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/repository/UserRepository.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/repository/UserRepository.kt @@ -1,6 +1,7 @@ package com.example.budgeting.android.data.repository import com.example.budgeting.android.data.local.TokenDataStore +import com.example.budgeting.android.data.model.BudgetResponse import com.example.budgeting.android.data.model.ChangePasswordRequest import com.example.budgeting.android.data.model.UserResponse import com.example.budgeting.android.data.model.UserUpdateRequest @@ -44,4 +45,9 @@ class UserRepository( } } + // GET BUDGET DATA + suspend fun getBudgetData(id: Int): BudgetResponse{ + return api.getRemainingBudget(id).body()?.data ?: throw Exception("Failed to get budget data") + } + } \ No newline at end of file diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ProfileScreen.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ProfileScreen.kt index 87229f8..276462b 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ProfileScreen.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ProfileScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation @@ -226,7 +227,9 @@ fun ProfileScreen( // =============== VIEW MODE ====================== if (!uiState.isEditing) { InfoCard(title = "Phone Number", value = user.phone_number) - InfoCard(title = "Budget", value = "$${user.budget}") + uiState.budget?.let { budget -> + BudgetSummaryCard(totalBudget = budget.budget, spent = budget.spentThisMonth, remaining = budget.remainingBudget) + } } // =============== EDIT MODE ====================== @@ -372,3 +375,100 @@ fun ChangePasswordDialog( } ) } + +@Composable +fun BudgetSummaryCard( + totalBudget: Double, + spent: Double, + remaining: Double, + modifier: Modifier = Modifier +) { + val realRemaining = totalBudget - spent + + ElevatedCard( + shape = RoundedCornerShape(20.dp), + modifier = modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + + // Header + Text( + text = "Budget Summary", + style = MaterialTheme.typography.titleMedium + ) + + // Top row: Spent / Remaining + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + + BudgetStat( + label = "Spent", + amount = spent, + background = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.weight(1f) + ) + + BudgetStat( + label = "Remaining", + amount = if(remaining > 0) remaining else realRemaining, + background = MaterialTheme.colorScheme.tertiaryContainer, + modifier = Modifier.weight(1f) + ) + } + + Divider() + + // Bottom row: Total + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Total Budget", + style = MaterialTheme.typography.labelLarge + ) + + Text( + text = "$${totalBudget}", + style = MaterialTheme.typography.titleMedium + ) + } + } + } +} + +@Composable +private fun BudgetStat( + label: String, + amount: Double, + background: Color, + modifier: Modifier +) { + Surface( + shape = RoundedCornerShape(14.dp), + color = background, + modifier = modifier + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium + ) + + Text( + text = "$${amount.toInt()}", + style = MaterialTheme.typography.titleLarge + ) + } + } +} + diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ProfileViewModel.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ProfileViewModel.kt index 0af0615..acc6348 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ProfileViewModel.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ProfileViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.budgeting.android.data.auth.TokenHolder import com.example.budgeting.android.data.local.TokenDataStore +import com.example.budgeting.android.data.model.BudgetResponse import com.example.budgeting.android.data.model.UserResponse import com.example.budgeting.android.data.model.UserUpdateRequest import com.example.budgeting.android.data.network.RetrofitClient @@ -15,6 +16,7 @@ import kotlinx.coroutines.launch data class ProfileUiState( val user: UserResponse? = null, + val budget: BudgetResponse? = null, val isLoading: Boolean = false, val isEditing: Boolean = false, val error: String? = null @@ -29,24 +31,37 @@ class ProfileViewModel(context: Context) : ViewModel() { val uiState: StateFlow = _uiState init { - loadUser() + loadUserAndBudget() } - fun loadUser() { + private fun loadUserAndBudget() { viewModelScope.launch { - _uiState.value = ProfileUiState(isLoading = true) + _uiState.value = _uiState.value.copy(isLoading = true, error = null) try { - val userId = tokenDataStore.getUserId() ?: throw Exception("User ID not found") + val userId = tokenDataStore.getUserId() + ?: throw Exception("User ID not found") + val user = userRepository.getUserById(userId) - _uiState.value = ProfileUiState(user = user) + + val budget = userRepository.getBudgetData(user.id) + + _uiState.value = _uiState.value.copy( + user = user, + budget = budget, + isLoading = false + ) } catch (e: Exception) { - _uiState.value = ProfileUiState(error = e.message) + _uiState.value = _uiState.value.copy( + error = e.message, + isLoading = false + ) } } } + fun setEditing(enabled: Boolean) { _uiState.value = _uiState.value.copy(isEditing = enabled) } @@ -57,7 +72,7 @@ class ProfileViewModel(context: Context) : ViewModel() { try { userRepository.updateUser(current.id, updated) - loadUser() // refresh + loadUserAndBudget() // refresh setEditing(false) } catch (e: Exception) { From 800470c2c08e7393bad10b1b441e746c6f72b939 Mon Sep 17 00:00:00 2001 From: Bolosh Date: Tue, 9 Dec 2025 03:43:31 +0200 Subject: [PATCH 06/11] categories features, and backend :/ --- API/routes/auth_routes.py | 2 +- API/routes/category_routes.py | 2 +- .../budgeting/android/data/model/Category.kt | 8 + .../android/data/model/CategoryBody.kt | 6 + .../budgeting/android/data/model/Expense.kt | 8 +- .../data/network/CategoryApiService.kt | 25 ++ .../android/data/network/RetrofitClient.kt | 14 + .../data/repository/CategoryRepository.kt | 46 +++ .../android/ui/component/ExpenseItem.kt | 2 +- .../android/ui/screens/CategoriesScreen.kt | 356 ++++++++++++++++++ .../android/ui/screens/ExpensesScreen.kt | 185 ++++++--- .../android/ui/screens/GroupDetailsScreen.kt | 4 +- .../android/ui/screens/MainScreen.kt | 8 +- .../ui/viewmodels/AnalyticsViewModel.kt | 9 +- .../ui/viewmodels/CategoryViewModel.kt | 86 +++++ .../ui/viewmodels/CategoryViewModelFactory.kt | 11 + .../android/ui/viewmodels/ExpenseViewModel.kt | 33 +- .../ui/viewmodels/GroupDetailsViewModel.kt | 2 +- 18 files changed, 724 insertions(+), 83 deletions(-) create mode 100644 Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/Category.kt create mode 100644 Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/CategoryBody.kt create mode 100644 Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/CategoryApiService.kt create mode 100644 Mobile/androidApp/src/main/java/com/example/budgeting/android/data/repository/CategoryRepository.kt create mode 100644 Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/CategoriesScreen.kt create mode 100644 Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/CategoryViewModel.kt create mode 100644 Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/CategoryViewModelFactory.kt diff --git a/API/routes/auth_routes.py b/API/routes/auth_routes.py index 5fa7a9f..157d36a 100644 --- a/API/routes/auth_routes.py +++ b/API/routes/auth_routes.py @@ -1,4 +1,4 @@ -from api.utils.helpers.jwt_utils import JwtUtils +from utils.helpers.jwt_utils import JwtUtils from dependencies.di import get_user_service from fastapi import APIRouter, Depends, HTTPException, Request, Response from schemas.user import UserCreate, UserLogin, UserPasswordReset diff --git a/API/routes/category_routes.py b/API/routes/category_routes.py index 5d8ade0..a393c45 100644 --- a/API/routes/category_routes.py +++ b/API/routes/category_routes.py @@ -32,7 +32,7 @@ def get_all_categories( def update_category(category_id: int, category_in: CategoryUpdate, requester_id: int = Depends(get_current_user_id), category_service: ICategoryService = Depends(get_category_service)): return category_service.update_category(category_id, category_in, requester_id) -@router.delete("/{category_id") +@router.delete("/{category_id}") def delete_category(category_id: int, requester_id: int = Depends(get_current_user_id), category_service: ICategoryService = Depends(get_category_service)): return category_service.delete_category(category_id, requester_id) diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/Category.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/Category.kt new file mode 100644 index 0000000..8589a6e --- /dev/null +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/Category.kt @@ -0,0 +1,8 @@ +package com.example.budgeting.android.data.model + +data class Category( + val id: Int?, + val title: String?, + val keywords: List?, + val user_id: Int? +) \ No newline at end of file diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/CategoryBody.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/CategoryBody.kt new file mode 100644 index 0000000..9cce0f4 --- /dev/null +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/CategoryBody.kt @@ -0,0 +1,6 @@ +package com.example.budgeting.android.data.model + +data class CategoryBody( + val title: String? = null, + val keywords: List? = null +) diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/Expense.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/Expense.kt index 92c78c1..8bb644c 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/Expense.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/Expense.kt @@ -23,10 +23,10 @@ data class Expense( @Json(name = "title") val title: String, - - @Json(name = "category") - val category: String, - + + @Json(name = "category_id") + val categoryId: Int? = null, + @Json(name = "amount") val amount: Double, diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/CategoryApiService.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/CategoryApiService.kt new file mode 100644 index 0000000..6231736 --- /dev/null +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/CategoryApiService.kt @@ -0,0 +1,25 @@ +package com.example.budgeting.android.data.network + +import com.example.budgeting.android.data.model.ApiResponse +import com.example.budgeting.android.data.model.Category +import com.example.budgeting.android.data.model.CategoryBody +import retrofit2.Response +import retrofit2.http.* + +interface CategoryApiService { + @GET("/categories") + suspend fun getCategories( + @Query("sort_by") sortBy: String? = null, + @Query("order") order: String? = null + ): Response>> + + @POST("/categories") + suspend fun addCategory(@Body category: CategoryBody): Response + + @PUT("/categories/{id}") + suspend fun updateCategory(@Path("id") id: Int, @Body category: CategoryBody): Response + + @DELETE("/categories/{id}") + suspend fun deleteCategory(@Path("id") id: Int): Response + +} \ No newline at end of file diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/RetrofitClient.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/RetrofitClient.kt index 3509fba..7757e63 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/RetrofitClient.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/RetrofitClient.kt @@ -75,4 +75,18 @@ object RetrofitClient { retrofit.create(UserApiService::class.java) } + + val categoryInstance: CategoryApiService = run { + val client = OkHttpClient.Builder() + .addInterceptor(TokenAuthInterceptor()) + .build() + + val retrofit = Retrofit.Builder() + .baseUrl(BASE_URL) + .client(client) + .addConverterFactory(moshiConverterFactory) + .build() + + retrofit.create(CategoryApiService::class.java) + } } diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/repository/CategoryRepository.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/repository/CategoryRepository.kt new file mode 100644 index 0000000..e9684f7 --- /dev/null +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/repository/CategoryRepository.kt @@ -0,0 +1,46 @@ +package com.example.budgeting.android.data.repository + +import com.example.budgeting.android.data.model.Category +import com.example.budgeting.android.data.model.CategoryBody +import com.example.budgeting.android.data.network.CategoryApiService + +class CategoryRepository( + val api: CategoryApiService +) { + + suspend fun getCategories(sortBy: String?, order: String?): List { + return api.getCategories(sortBy, order).body()?.data ?: throw Exception("Failed to fetch categories") + } + + suspend fun addCategory(category: CategoryBody) { + val response = api.addCategory(category) + if (!response.isSuccessful) { + throw Exception("Failed to update category") + } + } + + suspend fun updateCategory(id: Int, category: CategoryBody) { + val response = api.updateCategory(id, category) + if (!response.isSuccessful) { + throw Exception("Failed to update category") + } + } + + suspend fun deleteCategory(id: Int) { + val response = api.deleteCategory(id) + if (!response.isSuccessful) { + throw Exception("Failed to delete category") + } + } + + suspend fun getTitle(id: Int?): String { + val categories = api.getCategories(null, null).body()?.data ?: throw Exception("Failed to fetch categories") + return categories.find { it.id == id }?.title ?: throw Exception("Category not found") + } + + suspend fun getCategoryById(id: Int?): Category? { + val categories = api.getCategories(null, null).body()?.data ?: throw Exception("Failed to fetch categories") + return categories.find { it.id == id } + } + +} diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/component/ExpenseItem.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/component/ExpenseItem.kt index f1c22ea..194a669 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/component/ExpenseItem.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/component/ExpenseItem.kt @@ -48,7 +48,7 @@ fun ExpenseItem( ) Text( - text = "Category: ${expense.category}", + text = "Category: ${expense.title}", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/CategoriesScreen.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/CategoriesScreen.kt new file mode 100644 index 0000000..1d84a84 --- /dev/null +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/CategoriesScreen.kt @@ -0,0 +1,356 @@ +package com.example.budgeting.android.ui.screens + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.InputChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.budgeting.android.ui.viewmodels.CategoryViewModel +import com.example.budgeting.android.data.model.Category +import com.example.budgeting.android.data.model.CategoryBody +import com.example.budgeting.android.ui.viewmodels.CategoryViewModelFactory + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CategoriesScreen( + onBack: () -> Unit, + categoryViewModel: CategoryViewModel = viewModel( + factory = CategoryViewModelFactory(LocalContext.current) + ) +) { + val categories by categoryViewModel.categories.collectAsState() + val isLoading by categoryViewModel.isLoading.collectAsState() + val error by categoryViewModel.error.collectAsState() + + var showDialog by remember { mutableStateOf(false) } + var editingCategory by remember { mutableStateOf(null) } + var showDeleteDialog by remember { mutableStateOf(false) } + var categoryToDelete by remember { mutableStateOf(null) } + + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { Text("Categories") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + actions = { + IconButton(onClick = { + editingCategory = null + showDialog = true + }) { + Icon(Icons.Default.Add, contentDescription = "Add Category") + } + } + ) + } + ) { padding -> + Box( + modifier = Modifier + .padding(padding) + .fillMaxSize() + ) { + when { + isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + error != null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("Error: $error", color = MaterialTheme.colorScheme.error) + } + categories.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("No categories found") + } + else -> LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(8.dp) + ) { + items(categories) { category -> + CategoryItem( + category = category, + modifier = Modifier.padding(vertical = 4.dp), + onClick = { + editingCategory = category + showDialog = true + }, + onLongClick = { + categoryToDelete = category + showDeleteDialog = true + } + + ) + } + } + } + } + + if (showDeleteDialog && categoryToDelete != null) { + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + title = { Text("Delete Category") }, + text = { + Text("Are you sure you want to delete '${categoryToDelete!!.title}'?") + }, + confirmButton = { + TextButton(onClick = { + categoryViewModel.deleteCategory(categoryToDelete!!.id!!) + showDeleteDialog = false + categoryToDelete = null + }) { + Text("Delete", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { + showDeleteDialog = false + categoryToDelete = null + }) { + Text("Cancel") + } + } + ) + } + + } + + if (showDialog) { + AddEditCategoryDialog( + category = editingCategory, + onDismiss = { showDialog = false }, + onSave = { title, keywords -> + if (editingCategory != null) { + categoryViewModel.updateCategory( + editingCategory!!.id!!, + CategoryBody(title = title, keywords = keywords) + ) + } else { + categoryViewModel.addCategory( + CategoryBody(title = title, keywords = keywords) + ) + } + } + + ) + } +} + +// ========================= ADD/EDIT CATEGORY DIALOG ========================= +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddEditCategoryDialog( + category: Category?, + onDismiss: () -> Unit, + onSave: (String, List) -> Unit +) { + var title by remember { mutableStateOf(category?.title ?: "") } + + var keywordInput by remember { mutableStateOf("") } + var keywords by remember { + mutableStateOf>(category?.keywords?.toMutableList() ?: mutableListOf()) + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text(if (category == null) "Add Category" else "Edit Category") + }, + text = { + Column { + + // Title + OutlinedTextField( + value = title, + onValueChange = { title = it }, + label = { Text("Title") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(Modifier.height(12.dp)) + + // Keyword input + OutlinedTextField( + value = keywordInput, + onValueChange = { keywordInput = it }, + label = { Text("Add keyword") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + trailingIcon = { + TextButton( + enabled = keywordInput.isNotBlank(), + onClick = { + val keyword = keywordInput.trim() + if (keyword.isNotEmpty() && keyword !in keywords) { + keywords = (keywords + keyword).toMutableList() + } + keywordInput = "" + } + ) { + Text("Add") + } + } + ) + + // Keyword chips + if (keywords.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + keywords.forEach { keyword -> + AssistChip( + label = { Text(keyword) }, + onClick = { /* maybe do nothing */ }, + trailingIcon = { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Remove", + modifier = Modifier + .padding(start = 4.dp) + .clickable { + keywords = keywords.toMutableList().also { it.remove(keyword) } + } + ) + }, + colors = AssistChipDefaults.assistChipColors( + containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) + ) + ) + } + } + } + } + }, + confirmButton = { + TextButton( + enabled = title.isNotBlank(), + onClick = { + onSave(title, keywords) + onDismiss() + } + ) { + Text("Save") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} + + +@Composable +fun CategoryItem( + category: Category, + modifier: Modifier = Modifier, + onClick: () -> Unit, + onLongClick: () -> Unit +) { + Card( + modifier = modifier + .fillMaxWidth() + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick + ), + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + + // Title + Text( + text = category.title.orEmpty(), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + // Keywords + val keywords = category.keywords.orEmpty() + if (keywords.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + + FlowRow( + horizontalArrangement = Arrangement.Absolute.spacedBy(8.dp), + verticalArrangement = Arrangement.Absolute.spacedBy(6.dp) + ) { + keywords.forEach { keyword -> + AssistChip( + onClick = {}, + colors = AssistChipDefaults.assistChipColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ), + label = { Text(keyword) } + ) + + } + } + } + + Spacer(Modifier.height(8.dp)) + + Text( + text = "Tap to edit · Hold to delete", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ExpensesScreen.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ExpensesScreen.kt index 60a629c..c645112 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ExpensesScreen.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ExpensesScreen.kt @@ -1,7 +1,6 @@ package com.example.budgeting.android.ui.screens -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable +import android.util.Log import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.* @@ -10,16 +9,15 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Sort import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Category -import androidx.compose.material.icons.filled.FilterList -import androidx.compose.material.icons.filled.Sort import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.lifecycle.viewmodel.compose.viewModel import com.example.budgeting.android.ui.component.ExpenseItem @@ -29,6 +27,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp +import com.example.budgeting.android.data.model.Category +import com.example.budgeting.android.data.model.CategoryBody import com.example.budgeting.android.data.model.Expense import com.example.budgeting.android.data.model.SortOption import com.example.budgeting.android.ui.viewmodels.ExpenseMode @@ -36,6 +36,7 @@ import com.example.budgeting.android.ui.viewmodels.ExpenseMode @OptIn(ExperimentalMaterial3Api::class) @Composable fun ExpensesScreen( + onOpenCategories: () -> Unit, expenseViewModel: ExpenseViewModel = viewModel( factory = ExpenseViewModelFactory(LocalContext.current) ) @@ -61,7 +62,15 @@ fun ExpensesScreen( Scaffold( topBar = { CenterAlignedTopAppBar( - title = { Text("Expenses") } + title = { Text("Expenses") }, + actions = { + IconButton(onClick = onOpenCategories) { + Icon( + Icons.Default.Category, + contentDescription = "Manage Categories" + ) + } + } ) }, floatingActionButton = { @@ -124,41 +133,52 @@ fun ExpensesScreen( expenses.isEmpty() -> EmptyState() - else -> ExpensesList( - expenses = expenses, - currentUserId = currentUserId!!, - onClick = { expense -> - if(expense.user_id == currentUserId){ - selectedExpense = expense - showDialog = true - } - }, - onLongClick = { expense -> - if(expense.user_id == currentUserId){ - selectedExpense = expense - showDeleteDialog = true + + else ->{ + val userId = currentUserId ?: return@Box + ExpensesList( + expenses = expenses, + currentUserId = userId, + onClick = { expense -> + if(expense.user_id == currentUserId){ + selectedExpense = expense + showDialog = true + } + }, + onLongClick = { expense -> + if(expense.user_id == currentUserId){ + selectedExpense = expense + showDeleteDialog = true + } } - } - ) + ) + } } } } // ADD/EDIT DIALOG if (showDialog) { - AddEditExpenseDialog( - expense = selectedExpense, - onDismiss = { showDialog = false }, - onSave = { expense -> - if (selectedExpense != null) - expenseViewModel.updateExpense(expense.copy(id = selectedExpense!!.id)) - else - expenseViewModel.addExpense(expense) + val realCategories = + expenseViewModel.categories + .collectAsState() + .value + .filter { it.id != 0 } + + AddEditExpenseDialog( + expense = selectedExpense, + categories = realCategories, + onDismiss = { showDialog = false }, + onSave = { expense -> + if (selectedExpense != null) + expenseViewModel.updateExpense(expense.copy(id = selectedExpense!!.id)) + else + expenseViewModel.addExpense(expense) - selectedExpense = null - showDialog = false - } - ) + selectedExpense = null + showDialog = false + } + ) } // DELETE CONFIRMATION @@ -184,7 +204,7 @@ fun ExpensesScreen( fun FilterBar( search: String, onSearchChange: (String) -> Unit, - categories: List, + categories: List, selectedCategory: String, onCategorySelected: (String) -> Unit, sortOption: SortOption, @@ -279,7 +299,7 @@ fun ModeSelector( @Composable fun CategoryMenu( - categories: List, + categories: List, selected: String, onSelected: (String) -> Unit ) { @@ -287,14 +307,14 @@ fun CategoryMenu( DropdownMenuItem( text = { Text( - text = category, - color = if (category == selected) + text = category.title!!, + color = if (category.title == selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface ) }, - onClick = { onSelected(category) } + onClick = { onSelected(category.title!!) } ) } } @@ -309,54 +329,105 @@ fun SortMenu(onSelected: (SortOption) -> Unit) { } } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun AddEditExpenseDialog( expense: Expense?, + categories: List, onDismiss: () -> Unit, onSave: (Expense) -> Unit ) { var title by remember { mutableStateOf(expense?.title ?: "") } - var category by remember { mutableStateOf(expense?.category ?: "") } var amount by remember { mutableStateOf(expense?.amount?.toString() ?: "") } + var selectedCategoryId by remember { mutableStateOf(null) } + var expanded by remember { mutableStateOf(false) } + + LaunchedEffect(expense, categories) { + selectedCategoryId = + expense?.categoryId + ?: categories.firstOrNull()?.id + } + AlertDialog( onDismissRequest = onDismiss, title = { Text(if (expense == null) "Add Expense" else "Edit Expense") }, text = { Column { + OutlinedTextField( value = title, onValueChange = { title = it }, label = { Text("Title") }, modifier = Modifier.fillMaxWidth() ) - OutlinedTextField( - value = category, - onValueChange = { category = it }, - label = { Text("Category") }, - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp) - ) + + Spacer(Modifier.height(8.dp)) + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded }, + modifier = Modifier.fillMaxWidth() + ) { + val categoryName = categories + .find { it.id == selectedCategoryId } + ?.title ?: "" + + OutlinedTextField( + value = categoryName, + onValueChange = {}, + readOnly = true, + label = { Text("Category") }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded) + }, + modifier = Modifier.menuAnchor().fillMaxWidth() + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + categories.forEach { category -> + DropdownMenuItem( + text = { Text(category.title ?: "") }, + onClick = { + selectedCategoryId = category.id + expanded = false + } + ) + } + } + } + + Spacer(Modifier.height(8.dp)) + OutlinedTextField( value = amount, onValueChange = { amount = it }, label = { Text("Amount") }, - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number + ), + modifier = Modifier.fillMaxWidth() ) } }, confirmButton = { - TextButton(onClick = { - val parsedAmount = amount.toDoubleOrNull() ?: 0.0 - if (title.isNotBlank() && category.isNotBlank()) { - onSave(Expense(title = title, category = category, amount = parsedAmount)) + TextButton( + enabled = title.isNotBlank() && selectedCategoryId != null, + onClick = { + onSave( + Expense( + id = expense?.id, + title = title, + categoryId = selectedCategoryId!!, + amount = amount.toDoubleOrNull() ?: 0.0 + ) + ) + onDismiss() } - }) { + ) { Text("Save") } }, diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/GroupDetailsScreen.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/GroupDetailsScreen.kt index 4b033ec..63fabe9 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/GroupDetailsScreen.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/GroupDetailsScreen.kt @@ -499,7 +499,7 @@ private fun isExpenseAlreadyInGroup( return groupExpenses.any { groupExpense -> groupExpense.expense.title == expense.title && groupExpense.expense.amount == expense.amount && - groupExpense.expense.category == expense.category && + groupExpense.expense.categoryId == expense.categoryId && groupExpense.expense.user_id == expense.user_id } } @@ -719,7 +719,7 @@ private fun ExpensePickerDialog( color = MaterialTheme.colorScheme.onSurface ) Text( - text = expense.category, + text = expense.categoryId.toString(), // TODO pretty print the category id color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall ) diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/MainScreen.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/MainScreen.kt index 7bbdeb9..1b939f4 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/MainScreen.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/MainScreen.kt @@ -103,7 +103,7 @@ fun MainScreen( .fillMaxSize() ) { page -> when (page) { - 0 -> ExpensesScreen() + 0 -> ExpensesScreen(onOpenCategories = {navController.navigate("categories")}) 1 -> GroupsScreen(onOpenGroup = { id -> navController.navigate("groupDetails/$id") }) 2 -> ReceiptScreen() 3 -> AnalyticsScreen() @@ -120,5 +120,11 @@ fun MainScreen( onBack = { navController.popBackStack() } ) } + + composable("categories") { + CategoriesScreen( + onBack = { navController.popBackStack() } + ) + } } } diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/AnalyticsViewModel.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/AnalyticsViewModel.kt index 332b143..7d754dd 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/AnalyticsViewModel.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/AnalyticsViewModel.kt @@ -10,6 +10,7 @@ import com.example.budgeting.android.data.model.Expense import com.example.budgeting.android.data.model.MonthlyTotal import com.example.budgeting.android.data.network.RetrofitClient import com.example.budgeting.android.data.repository.ExpenseRepository +import com.example.budgeting.android.data.repository.CategoryRepository import com.example.budgeting.android.data.repository.GroupRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -27,6 +28,8 @@ class AnalyticsViewModel(context: Context) : ViewModel() { tokenStore ) + private val categoryRepository = CategoryRepository(RetrofitClient.categoryInstance) + // --- STATE --- private val _mode = MutableStateFlow(ExpenseMode.ALL) val mode: StateFlow = _mode.asStateFlow() @@ -142,11 +145,11 @@ class AnalyticsViewModel(context: Context) : ViewModel() { } // --- CATEGORY AMOUNT --- - _categoryAmounts.value = expenses.groupBy { it.category } + _categoryAmounts.value = expenses.groupBy { categoryRepository.getTitle(it.categoryId) } .map { (cat, list) -> CategoryTotal(category = cat, total = list.sumOf { it.amount }.toFloat()) } // --- CATEGORY COUNT --- - _categoryCounts.value = expenses.groupBy { it.category } + _categoryCounts.value = expenses.groupBy { categoryRepository.getTitle(it.categoryId) } .map { (cat, list) -> CategoryCount(category = cat, count = list.size) } // --- MONTHLY TREND --- @@ -154,7 +157,7 @@ class AnalyticsViewModel(context: Context) : ViewModel() { .map { (month, list) -> MonthlyTotal(month = month, total = list.sumOf { it.amount }.toFloat()) } // --- AVAILABLE CATEGORIES --- - _categories.value = expenses.map { it.category }.distinct().sorted() + _categories.value = expenses.map { categoryRepository.getTitle(it.categoryId) }.distinct().sorted() } catch (e: Exception) { _error.value = e.localizedMessage diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/CategoryViewModel.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/CategoryViewModel.kt new file mode 100644 index 0000000..0af1bd6 --- /dev/null +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/CategoryViewModel.kt @@ -0,0 +1,86 @@ +package com.example.budgeting.android.ui.viewmodels + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.budgeting.android.data.local.TokenDataStore +import com.example.budgeting.android.data.model.Category +import com.example.budgeting.android.data.model.CategoryBody +import com.example.budgeting.android.data.network.RetrofitClient +import com.example.budgeting.android.data.repository.CategoryRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class CategoryViewModel(context: Context): ViewModel() { + val repository = CategoryRepository(RetrofitClient.categoryInstance) + + private val _categories = MutableStateFlow>(emptyList()) + val categories: StateFlow> = _categories.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + init { + loadCategories(null, null) + } + + fun loadCategories(sortBy: String?, order: String?) { + viewModelScope.launch { + _isLoading.value = true + _error.value = null + + try { + val response = repository.getCategories(sortBy = sortBy, order = order) + _categories.value = response + _isLoading.value = false + } catch (e: Exception) { + _error.value = e.localizedMessage + } + } + } + + fun addCategory(category: CategoryBody) { + viewModelScope.launch { + _error.value = null + + try { + repository.addCategory(category) + loadCategories(null, null) // refresh + } catch (e: Exception) { + _error.value = e.localizedMessage + } + } + } + + fun updateCategory(id: Int, category: CategoryBody) { + viewModelScope.launch { + _error.value = null + + try { + repository.updateCategory(id, category) + loadCategories(null, null) // refresh + } catch (e: Exception) { + _error.value = e.localizedMessage + } + } + } + + fun deleteCategory(id: Int) { + viewModelScope.launch { + _error.value = null + + try { + repository.deleteCategory(id) + loadCategories(null, null) // refresh + } catch (e: Exception) { + _error.value = e.localizedMessage + } + } + } + +} \ No newline at end of file diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/CategoryViewModelFactory.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/CategoryViewModelFactory.kt new file mode 100644 index 0000000..1dd1c38 --- /dev/null +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/CategoryViewModelFactory.kt @@ -0,0 +1,11 @@ +package com.example.budgeting.android.ui.viewmodels + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider + +class CategoryViewModelFactory(private val context: Context): ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return CategoryViewModel(context) as T + } +} \ No newline at end of file diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ExpenseViewModel.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ExpenseViewModel.kt index 877c54b..dfbdc9e 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ExpenseViewModel.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ExpenseViewModel.kt @@ -4,10 +4,12 @@ import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.budgeting.android.data.local.TokenDataStore +import com.example.budgeting.android.data.model.Category import com.example.budgeting.android.data.model.Expense import com.example.budgeting.android.data.model.ExpenseFilters import com.example.budgeting.android.data.model.SortOption import com.example.budgeting.android.data.network.RetrofitClient +import com.example.budgeting.android.data.repository.CategoryRepository import com.example.budgeting.android.data.repository.ExpenseRepository import com.example.budgeting.android.data.repository.GroupRepository import kotlinx.coroutines.flow.MutableStateFlow @@ -27,6 +29,8 @@ class ExpenseViewModel(context: Context) : ViewModel() { private val repository = ExpenseRepository(RetrofitClient.expenseInstance, tokenStore) private val groupRepository = GroupRepository(RetrofitClient.groupInstance, RetrofitClient.expenseInstance, tokenStore) + private val categoryRepository = CategoryRepository(RetrofitClient.categoryInstance) + private val _expenses = MutableStateFlow>(emptyList()) val expenses: StateFlow> = _expenses.asStateFlow() @@ -48,8 +52,8 @@ class ExpenseViewModel(context: Context) : ViewModel() { private val _groupIds = MutableStateFlow>(emptyList()) val groupIds: StateFlow> = _groupIds.asStateFlow() - private val _categories = MutableStateFlow>(emptyList()) - val categories: StateFlow> = _categories + private val _categories = MutableStateFlow>(emptyList()) + val categories: StateFlow> = _categories private val _currentUserId = MutableStateFlow(null) val currentUserId: StateFlow = _currentUserId.asStateFlow() @@ -74,7 +78,7 @@ class ExpenseViewModel(context: Context) : ViewModel() { _error.value = null try { - _categories.value = listOf("All") + _categories.value = listOf(Category(0, "All", null, null)) val f = _filters.value val data = when (_mode.value) { @@ -133,17 +137,22 @@ class ExpenseViewModel(context: Context) : ViewModel() { } private fun updateCategories(expenses: List) { - val newCategories = expenses - .map { it.category } - .filter { it.isNotBlank() } - .distinct() - .sorted() - - // Merge with existing categories, keep All at top - val merged = listOf("All") + (_categories.value - "All" + newCategories).distinct() - _categories.value = merged + viewModelScope.launch { + val usedCategoryIds = expenses + .map { it.categoryId } + .distinct() + + val resolvedCategories = usedCategoryIds.mapNotNull { categoryId -> + categoryRepository.getCategoryById(categoryId) + } + + _categories.value = + listOf(Category(0, "All", null, null)) + + resolvedCategories.sortedBy { it.title } + } } + /** ---------------------------------------------------------- * MODES * ---------------------------------------------------------- */ diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/GroupDetailsViewModel.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/GroupDetailsViewModel.kt index 7759bc9..a081a58 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/GroupDetailsViewModel.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/GroupDetailsViewModel.kt @@ -175,7 +175,7 @@ class GroupDetailsViewModel(context: Context) : ViewModel() { val isDuplicate = existingExpenses.any { groupExpense -> groupExpense.expense.title == expense.title && groupExpense.expense.amount == expense.amount && - groupExpense.expense.category == expense.category && + groupExpense.expense.categoryId == expense.categoryId && groupExpense.expense.user_id == userId } From 7ae5ae29de643cd9a5fa0d87f3939e199e68f610 Mon Sep 17 00:00:00 2001 From: Bolosh Date: Tue, 9 Dec 2025 10:09:21 +0200 Subject: [PATCH 07/11] category title display fix --- .../budgeting/android/ui/component/ExpenseItem.kt | 3 ++- .../budgeting/android/ui/screens/ExpensesScreen.kt | 3 +++ .../android/ui/viewmodels/CategoryViewModel.kt | 14 ++++++++++++++ .../android/ui/viewmodels/ExpenseViewModel.kt | 4 ++++ 4 files changed, 23 insertions(+), 1 deletion(-) diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/component/ExpenseItem.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/component/ExpenseItem.kt index 194a669..6390e99 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/component/ExpenseItem.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/component/ExpenseItem.kt @@ -27,6 +27,7 @@ import com.example.budgeting.android.data.model.Expense fun ExpenseItem( expense: Expense, currentUserId: Int, + categoryTitle: String, modifier: Modifier = Modifier ) { Card( @@ -48,7 +49,7 @@ fun ExpenseItem( ) Text( - text = "Category: ${expense.title}", + text = "Category: $categoryTitle", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ExpensesScreen.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ExpensesScreen.kt index c645112..95f7f6f 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ExpensesScreen.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ExpensesScreen.kt @@ -139,6 +139,7 @@ fun ExpensesScreen( ExpensesList( expenses = expenses, currentUserId = userId, + vm = expenseViewModel, onClick = { expense -> if(expense.user_id == currentUserId){ selectedExpense = expense @@ -468,6 +469,7 @@ fun EmptyState() { fun ExpensesList( expenses: List, currentUserId: Int, + vm: ExpenseViewModel, onClick: (Expense) -> Unit, onLongClick: (Expense) -> Unit ) { @@ -477,6 +479,7 @@ fun ExpensesList( items(expenses) { expense -> ExpenseItem( expense = expense, + categoryTitle = vm.getCategoryTitle(expense.categoryId!!), currentUserId = currentUserId, modifier = Modifier .fillMaxWidth() diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/CategoryViewModel.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/CategoryViewModel.kt index 0af1bd6..a010370 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/CategoryViewModel.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/CategoryViewModel.kt @@ -83,4 +83,18 @@ class CategoryViewModel(context: Context): ViewModel() { } } + fun getTitle(id: Int?): String { + var title: String = "" + viewModelScope.launch { + _error.value = null + + try { + title = repository.getTitle(id) + } catch (e: Exception) { + _error.value = e.localizedMessage + } + } + return title + } + } \ No newline at end of file diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ExpenseViewModel.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ExpenseViewModel.kt index dfbdc9e..e50b8a7 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ExpenseViewModel.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ExpenseViewModel.kt @@ -253,4 +253,8 @@ class ExpenseViewModel(context: Context) : ViewModel() { } } } + + fun getCategoryTitle(categoryId: Int): String { + return _categories.value.find { it.id == categoryId }?.title ?: "Unknown" + } } From 9e139643cb21626efc9ac7f6e6430e983146372f Mon Sep 17 00:00:00 2001 From: Bolosh Date: Wed, 10 Dec 2025 02:25:41 +0200 Subject: [PATCH 08/11] merged onto frontend-mobile --- API/repositories/category_repository.py | 7 +- API/services/expense_service.py | 4 +- API/utils/helpers/constants.py | 10 +- .../budgeting/android/data/model/Expense.kt | 5 +- .../android/data/network/ExpenseApiService.kt | 2 +- .../android/data/network/RetrofitClient.kt | 3 - .../data/repository/ExpenseRepository.kt | 11 +- .../data/repository/GroupRepository.kt | 13 +- .../components/group/ExpensePickerDialog.kt | 2 +- .../android/ui/screens/GroupDetailsScreen.kt | 415 +----------------- .../budgeting/android/ui/utils/GroupUtils.kt | 2 +- .../android/ui/viewmodels/ExpenseViewModel.kt | 6 +- .../ui/viewmodels/GroupDetailsViewModel.kt | 7 +- 13 files changed, 35 insertions(+), 452 deletions(-) diff --git a/API/repositories/category_repository.py b/API/repositories/category_repository.py index 95b1260..5332028 100644 --- a/API/repositories/category_repository.py +++ b/API/repositories/category_repository.py @@ -13,9 +13,6 @@ def add(self, category: Category) -> int: ... @abstractmethod def get_all(self, sort_by: str, order: str) -> List[Category]: ... - @abstractmethod - def get_by_id(self, category_id: int) -> Category: ... - @abstractmethod def update(self, category_id: int, fields: dict) -> int: ... @@ -33,7 +30,9 @@ def add(self, category: Category) -> int: return category.id def get_by_title_or_keywords(self, user_id: int, title: str, keywords: list[str]) -> bool: - statement = (select(Category.id).where(Category.user_id == user_id,or_( + statement = (select(Category.id).where( + Category.user_id == user_id, + or_( Category.title == title, Category.keywords.op("&&")(cast(keywords, ARRAY(Text)))))) return self.db.scalar(statement) is not None diff --git a/API/services/expense_service.py b/API/services/expense_service.py index 7c7ffa1..413eee8 100644 --- a/API/services/expense_service.py +++ b/API/services/expense_service.py @@ -127,7 +127,7 @@ def create_expense(self, data: ExpenseCreate, user_id: int) -> APIResponse: if data.group_id is not None: self._validate_group(data.group_id) self._validate_category(data.category_id, user_id) - + id = self.repository.add(expense) return APIResponse( @@ -196,7 +196,7 @@ def update_expense(self, expense_id: int, data: ExpenseUpdate, requester_id: int """ self._validate_owner(expense_id, requester_id) self._validate_category(data.category_id, requester_id) - + fields = data.model_dump(exclude_unset=True) fields.pop("user_id", None) diff --git a/API/utils/helpers/constants.py b/API/utils/helpers/constants.py index eb2b66c..a1a1620 100644 --- a/API/utils/helpers/constants.py +++ b/API/utils/helpers/constants.py @@ -16,8 +16,8 @@ # Fields for group statistics -TOTAL_GROUP_SPEND = "total_group_spend" -MY_TOTAL_PAID = "my_total_paid" -MY_SHARE_OF_EXPENSES = "my_share_of_expenses" -NET_BALANCE_PAID_FOR_OTHERS = "net_balance_paid_for_others" -REST_OF_GROUP_EXPENSES = "rest_of_group_expenses" \ No newline at end of file +TOTAL_GROUP_SPEND="total_group_spend" +MY_TOTAL_PAID="my_total_paid" +MY_SHARE_OF_EXPENSES="my_share_of_expenses" +NET_BALANCE_PAID_FOR_OTHERS="net_balance_paid_for_others" +REST_OF_GROUP_EXPENSES="rest_of_group_expenses" \ No newline at end of file diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/Expense.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/Expense.kt index 796a477..e6634bb 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/Expense.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/model/Expense.kt @@ -26,10 +26,7 @@ data class Expense( @Json(name = "created_at") val created_at: String? = null -) { - val categoryTitle: String - get() = category?.title ?: "Uncategorized" -} +) data class ExpenseIdResponse( @Json(name = "id") diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/ExpenseApiService.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/ExpenseApiService.kt index 2ad0302..83e1e6d 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/ExpenseApiService.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/ExpenseApiService.kt @@ -56,7 +56,7 @@ interface ExpenseApiService { ): Response> @POST("/expenses/") - suspend fun addExpense(@Body expense: ExpenseCreateRequest): Response> + suspend fun addExpense(@Body expense: Expense): Response> @PUT("/expenses/{id}") suspend fun updateExpense( diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/RetrofitClient.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/RetrofitClient.kt index f3de8e6..ec9dcbf 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/RetrofitClient.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/RetrofitClient.kt @@ -8,9 +8,6 @@ import okhttp3.OkHttpClient import retrofit2.Retrofit import retrofit2.converter.moshi.MoshiConverterFactory -// Import ExpensePaymentApiService -import com.example.budgeting.android.data.network.ExpensePaymentApiService - object RetrofitClient { private val BASE_URL: String = BuildConfig.BASE_URL diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/repository/ExpenseRepository.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/repository/ExpenseRepository.kt index 0b9d257..8227650 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/repository/ExpenseRepository.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/repository/ExpenseRepository.kt @@ -78,16 +78,7 @@ class ExpenseRepository( } suspend fun addExpense(expense: Expense): Int { - // Convert Expense to ExpenseCreateRequest - // Backend requires category_id, not category string - val createRequest = com.example.budgeting.android.data.model.ExpenseCreateRequest( - title = expense.title, - amount = expense.amount, - category_id = expense.category_id ?: 1, // Default to 1 if not provided - group_id = expense.group_id, - description = expense.description - ) - return api.addExpense(createRequest).body()?.data?.id ?: throw Exception("Failed to add expense") + return api.addExpense(expense).body()?.data?.id ?: throw Exception("Failed to add expense") } suspend fun updateExpense(id: Int, expense: Expense): Int { diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/repository/GroupRepository.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/repository/GroupRepository.kt index 4fa56bf..8bbecfb 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/repository/GroupRepository.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/repository/GroupRepository.kt @@ -127,18 +127,9 @@ class GroupRepository( suspend fun getExpenseById(id: Int) = expenseApiService.getExpenseById(id).body()?.data ?: throw Exception("Failed to fetch expense") - suspend fun addExpenseToGroup(expense: Expense, description: String? = null): Int { - // Convert Expense to ExpenseCreateRequest - // For now, use category_id = 1 as default (backend requires category_id, not category string) + suspend fun addExpenseToGroup(expense: Expense): Int { // TODO: Implement proper category lookup by name - val createRequest = ExpenseCreateRequest( - title = expense.title, - amount = expense.amount, - category_id = 1, // Default category - should be looked up from category name - group_id = expense.group_id, - description = description - ) - return expenseApiService.addExpense(createRequest).body()?.data?.id ?: throw Exception("Failed to add expense") + return expenseApiService.addExpense(expense).body()?.data?.id ?: throw Exception("Failed to add expense") } suspend fun getGroupInviteQr(groupId: Int): Response { diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/ExpensePickerDialog.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/ExpensePickerDialog.kt index a31a339..3d46077 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/ExpensePickerDialog.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/ExpensePickerDialog.kt @@ -96,7 +96,7 @@ fun ExpensePickerDialog( color = MaterialTheme.colorScheme.onSurface ) Text( - text = expense.categoryTitle, + text = expense.categoryId.toString(), // TODO display category title not id color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall ) diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/GroupDetailsScreen.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/GroupDetailsScreen.kt index c5bb86b..17d5bb9 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/GroupDetailsScreen.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/GroupDetailsScreen.kt @@ -31,11 +31,11 @@ fun GroupDetailsScreen( BackHandler { onBack() } - - LaunchedEffect(groupId) { - vm.loadGroup(groupId) + + LaunchedEffect(groupId) { + vm.loadGroup(groupId) } - + val group by vm.group.collectAsState() val expenses by vm.expenses.collectAsState() val isLoading by vm.isLoading.collectAsState() @@ -61,11 +61,11 @@ fun GroupDetailsScreen( Scaffold( topBar = { CenterAlignedTopAppBar( - title = { + title = { Text( text = group?.name ?: "Group", color = MaterialTheme.colorScheme.onBackground - ) + ) }, navigationIcon = { IconButton(onClick = onBack) { @@ -183,7 +183,7 @@ fun GroupDetailsScreen( Triple(item, date, date?.let { DateUtils.formatDateForDisplay(it) }) } } - + LazyColumn( modifier = Modifier .weight(1f) @@ -192,15 +192,15 @@ fun GroupDetailsScreen( verticalArrangement = Arrangement.spacedBy(12.dp) ) { timelineWithDates.forEachIndexed { index, (item, itemDate, displayDate) -> - val showDateHeader = index == 0 || - itemDate != timelineWithDates.getOrNull(index - 1)?.second - + val showDateHeader = index == 0 || + itemDate != timelineWithDates.getOrNull(index - 1)?.second + if (showDateHeader && itemDate != null) { item(key = "date_${itemDate}_$index") { DateHeader(displayDate ?: "Unknown Date") } } - + when (item) { is TimelineItem.ExpenseItem -> { item(key = "expense_${item.expense.expense.id}") { @@ -267,396 +267,3 @@ fun GroupDetailsScreen( ) } } - - Button( - onClick = { - invitationCode?.let { code -> - onShareCode(code) - } ?: run { - Toast.makeText( - context, - "Invitation code unavailable", - Toast.LENGTH_SHORT - ).show() - } - }, - enabled = invitationCode != null, - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), - shape = RoundedCornerShape(12.dp) - ) { - Text("Share code") - } - } - } - } -} - -private fun shareGroupInvite(context: Context, groupName: String, invitationCode: String) { - val shareIntent = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_SUBJECT, "Join $groupName") - putExtra( - Intent.EXTRA_TEXT, - "Join \"$groupName\" using this invitation code: $invitationCode" - ) - } - try { - context.startActivity( - Intent.createChooser(shareIntent, "Share group invite") - ) - } catch (e: Exception) { - Toast.makeText( - context, - "Unable to open share options", - Toast.LENGTH_SHORT - ).show() - } -} - -@Composable -private fun DateHeader(date: String) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - contentAlignment = Alignment.Center - ) { - Surface( - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), - shape = RoundedCornerShape(12.dp), - modifier = Modifier.padding(horizontal = 16.dp) - ) { - Text( - text = date, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) - ) - } - } -} - - -private fun parseDateFromString(dateString: String?): LocalDate? { - if (dateString == null) return null - - return try { - try { - val zonedDateTime = java.time.ZonedDateTime.parse(dateString) - return zonedDateTime.toLocalDate() - } catch (e: Exception) { - } - - try { - val dateTime = java.time.LocalDateTime.parse(dateString.take(19)) - return dateTime.toLocalDate() - } catch (e: Exception) { - } - - try { - return LocalDate.parse(dateString.take(10)) - } catch (e: Exception) { - } - - try { - val cleaned = dateString.replace("T", " ").take(19) - val dateTime = java.time.LocalDateTime.parse(cleaned, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) - return dateTime.toLocalDate() - } catch (e: Exception) { - } - - null - } catch (e: Exception) { - null - } -} - -private fun formatDateForDisplay(date: LocalDate): String { - val today = LocalDate.now() - val yesterday = today.minusDays(1) - - return when { - date == today -> "Today" - date == yesterday -> "Yesterday" - date.year == today.year -> date.format(DateTimeFormatter.ofPattern("MMM d")) - else -> date.format(DateTimeFormatter.ofPattern("MMM d, yyyy")) - } -} - -private fun isExpenseAlreadyInGroup( - expense: Expense, - groupExpenses: List -): Boolean { - return groupExpenses.any { groupExpense -> - groupExpense.expense.title == expense.title && - groupExpense.expense.amount == expense.amount && - groupExpense.expense.categoryId == expense.categoryId && - groupExpense.expense.user_id == expense.user_id - } -} - -@Composable -private fun ExpenseBubble( - expense: Expense, - userName: String, - description: String? -) { - Column(modifier = Modifier.fillMaxWidth()) { - // User name row - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(bottom = 4.dp) - ) { - Icon( - Icons.Filled.Person, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(16.dp) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = userName, - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall - ) - } - - // Expense bubble - Box( - modifier = Modifier - .background( - MaterialTheme.colorScheme.surfaceVariant, - RoundedCornerShape(10.dp) - ) - .padding(horizontal = 12.dp, vertical = 8.dp) - ) { - Column { - Text( - text = "${expense.title} - $${String.format("%.2f", expense.amount)}", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.bodyMedium - ) - if (!description.isNullOrBlank()) { - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = description, - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall - ) - } - } - } - } -} - -@Composable -private fun BottomAddExpenseBar( - vm: GroupDetailsViewModel -) { - val context = LocalContext.current - val expenseViewModel: ExpenseViewModel = viewModel( - factory = ExpenseViewModelFactory(context) - ) - - LaunchedEffect(Unit) { - expenseViewModel.loadExpenses() - } - - val personalExpenses by expenseViewModel.expenses.collectAsState() - val groupExpenses by vm.expenses.collectAsState() - val personalExpensesOnly = remember(personalExpenses, groupExpenses) { - val existingExpenseIds = groupExpenses.map { it.expense.id }.toSet() - personalExpenses.filter { expense -> - expense.group_id == null && - !existingExpenseIds.contains(expense.id) && - !isExpenseAlreadyInGroup(expense, groupExpenses) - } - } - var showPicker by remember { mutableStateOf(false) } - var description by remember { mutableStateOf("") } - - BackHandler(enabled = showPicker) { - showPicker = false - } - - Surface(color = MaterialTheme.colorScheme.background) { - Row( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - TextField( - value = description, - onValueChange = { description = it }, - modifier = Modifier.weight(1f), - placeholder = { Text("Add description (optional)") }, - colors = TextFieldDefaults.colors( - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, - focusedIndicatorColor = MaterialTheme.colorScheme.primary, - unfocusedIndicatorColor = MaterialTheme.colorScheme.surfaceVariant - ), - shape = RoundedCornerShape(12.dp), - singleLine = true - ) - Button( - onClick = { showPicker = true }, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary - ), - shape = RoundedCornerShape(12.dp), - modifier = Modifier.height(52.dp) - ) { - Text("+") - } - } - } - - if (showPicker) { - ExpensePickerDialog( - expenses = personalExpensesOnly, - onDismiss = { showPicker = false }, - onConfirm = { selected -> - vm.addExpensesFromPersonal(selected, description, "You") - description = "" - showPicker = false - } - ) - } -} - -@Composable -private fun ExpensePickerDialog( - expenses: List, - onDismiss: () -> Unit, - onConfirm: (List) -> Unit -) { - Dialog(onDismissRequest = onDismiss) { - Surface( - color = MaterialTheme.colorScheme.background, - shape = RoundedCornerShape(16.dp), - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier - .padding(12.dp) - .fillMaxWidth() - .heightIn(max = 480.dp) - ) { - // Header - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - TextButton(onClick = onDismiss) { - Text("✕", color = MaterialTheme.colorScheme.onBackground) - } - Box( - modifier = Modifier.weight(1f), - contentAlignment = Alignment.Center - ) { - Text( - text = "Choose expenses", - color = MaterialTheme.colorScheme.onBackground, - style = MaterialTheme.typography.titleMedium - ) - } - Spacer(modifier = Modifier.width(32.dp)) - } - - // Expense list - val selected = remember { mutableStateListOf() } - - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - itemsIndexed(expenses) { index, expense -> - Surface( - shape = RoundedCornerShape(12.dp), - color = if (selected.contains(index)) { - MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) - } else { - MaterialTheme.colorScheme.surfaceVariant - }, - modifier = Modifier - .fillMaxWidth() - .clickable { - if (selected.contains(index)) { - selected.remove(index) - } else { - selected.add(index) - } - } - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = expense.title, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = expense.categoryId.toString(), // TODO pretty print the category id - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall - ) - } - Text( - text = "$${"%.2f".format(expense.amount)}", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.bodyMedium - ) - } - } - } - } - - // Action buttons - Row( - modifier = Modifier - .padding(12.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - OutlinedButton( - onClick = onDismiss, - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(12.dp) - ) { - Text("Cancel") - } - Button( - onClick = { - val items = selected.map { expenses[it] } - onConfirm(items) - }, - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(12.dp), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary - ) - ) { - Text("Add") - } - } - } - } - } -} diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/utils/GroupUtils.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/utils/GroupUtils.kt index 826718e..c005d4f 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/utils/GroupUtils.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/utils/GroupUtils.kt @@ -15,7 +15,7 @@ object GroupUtils { return groupExpenses.any { groupExpense -> groupExpense.expense.title == expense.title && groupExpense.expense.amount == expense.amount && - groupExpense.expense.categoryTitle == expense.categoryTitle && + groupExpense.expense.categoryId == expense.categoryId && groupExpense.expense.user_id == expense.user_id } } diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ExpenseViewModel.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ExpenseViewModel.kt index e50b8a7..17ca745 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ExpenseViewModel.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ExpenseViewModel.kt @@ -78,7 +78,7 @@ class ExpenseViewModel(context: Context) : ViewModel() { _error.value = null try { - _categories.value = listOf(Category(0, "All", null, null)) + _categories.value = listOf(Category(0, 0, "All", emptyList())) val f = _filters.value val data = when (_mode.value) { @@ -147,7 +147,7 @@ class ExpenseViewModel(context: Context) : ViewModel() { } _categories.value = - listOf(Category(0, "All", null, null)) + + listOf(Category(0, 0, "All", emptyList())) + resolvedCategories.sortedBy { it.title } } } @@ -170,7 +170,7 @@ class ExpenseViewModel(context: Context) : ViewModel() { val userId = tokenStore.getUserId() val response = groupRepository.getGroupsByUser(userId!!) if (response.isSuccessful && response.body() != null) { - _groupIds.value = response.body()!!.data!!.map { it.id!! } // store all group IDs + _groupIds.value = response.body()!!.map { it.id!! } // store all group IDs } else { _error.value = "Error loading user groups" } diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/GroupDetailsViewModel.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/GroupDetailsViewModel.kt index 6c2c1ad..f8f1a55 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/GroupDetailsViewModel.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/GroupDetailsViewModel.kt @@ -205,7 +205,7 @@ class GroupDetailsViewModel(context: Context) : ViewModel() { val isDuplicate = existingExpenses.any { groupExpense -> groupExpense.expense.title == expense.title && groupExpense.expense.amount == expense.amount && - groupExpense.expense.categoryTitle == expense.categoryTitle && + groupExpense.expense.categoryId == expense.categoryId && groupExpense.expense.user_id == userId } @@ -218,11 +218,12 @@ class GroupDetailsViewModel(context: Context) : ViewModel() { id = null, user_id = userId, group_id = groupId, - created_at = null + created_at = null, + description = description ) try { - val expenseId = groupRepository.addExpenseToGroup(expenseToCreate, description?.takeIf { it.isNotBlank() }) + val expenseId = groupRepository.addExpenseToGroup(expenseToCreate) addedExpenseIds.add(expenseId) // Automatically mark the expense creator as having paid From 92e2479b9e14f2b8d1659af23c9361afc3252da8 Mon Sep 17 00:00:00 2001 From: Ionut253 <48173899+Ionut253@users.noreply.github.com> Date: Sat, 13 Dec 2025 20:50:01 +0200 Subject: [PATCH 09/11] improved payments pop up logic --- .../ui/components/group/CreateGroupDialog.kt | 3 +- .../components/group/ExpensePaymentDialog.kt | 5 +- .../ui/components/group/GroupShareDialog.kt | 3 +- .../ui/components/group/JoinGroupDialog.kt | 3 +- .../android/ui/screens/GroupDetailsScreen.kt | 113 ++++++++++++++++-- 5 files changed, 113 insertions(+), 14 deletions(-) diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/CreateGroupDialog.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/CreateGroupDialog.kt index 98082a5..5505f10 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/CreateGroupDialog.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/CreateGroupDialog.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties @Composable fun CreateGroupDialog( @@ -25,7 +26,7 @@ fun CreateGroupDialog( var groupName by remember { mutableStateOf("") } var groupDescription by remember { mutableStateOf("") } - Dialog(onDismissRequest = onDismiss) { + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(dismissOnClickOutside = false)) { val configuration = LocalConfiguration.current val maxHeight = configuration.screenHeightDp.dp * 0.6f Surface( diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/ExpensePaymentDialog.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/ExpensePaymentDialog.kt index cac3710..e9197d9 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/ExpensePaymentDialog.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/ExpensePaymentDialog.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import com.example.budgeting.android.data.model.Expense import com.example.budgeting.android.data.model.ExpensePayment import com.example.budgeting.android.data.model.UserData @@ -31,7 +32,7 @@ fun ExpensePaymentDialog( expense: Expense, members: List, vm: GroupDetailsViewModel, - onDismiss: () -> Unit + onDismiss: () -> Unit ) { val coroutineScope = rememberCoroutineScope() var payments by remember { mutableStateOf>(emptyList()) } @@ -70,7 +71,7 @@ fun ExpensePaymentDialog( members.filter { it.id != expense.user_id } } - Dialog(onDismissRequest = onDismiss) { + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(dismissOnClickOutside = false)) { Surface( color = MaterialTheme.colorScheme.background, shape = RoundedCornerShape(16.dp), diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/GroupShareDialog.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/GroupShareDialog.kt index 3cb82f6..bfbf3c5 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/GroupShareDialog.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/GroupShareDialog.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import com.example.budgeting.android.ui.utils.GroupUtils @Composable @@ -34,7 +35,7 @@ fun GroupShareDialog( } } - Dialog(onDismissRequest = onDismiss) { + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(dismissOnClickOutside = false)) { Surface( color = MaterialTheme.colorScheme.background, shape = RoundedCornerShape(16.dp), diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/JoinGroupDialog.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/JoinGroupDialog.kt index a922b7e..31d3b33 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/JoinGroupDialog.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/JoinGroupDialog.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.core.content.ContextCompat import com.example.budgeting.android.ui.screens.QrCaptureActivity import com.journeyapps.barcodescanner.ScanContract @@ -80,7 +81,7 @@ fun JoinGroupDialog( } } - Dialog(onDismissRequest = onDismiss) { + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(dismissOnClickOutside = false)) { Surface( color = MaterialTheme.colorScheme.background, shape = RoundedCornerShape(16.dp) diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/GroupDetailsScreen.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/GroupDetailsScreen.kt index 17d5bb9..4476d97 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/GroupDetailsScreen.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/GroupDetailsScreen.kt @@ -6,12 +6,14 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.viewmodel.compose.viewModel import com.example.budgeting.android.ui.components.group.* import com.example.budgeting.android.ui.utils.DateUtils @@ -51,6 +53,9 @@ fun GroupDetailsScreen( var selectedExpense by remember { mutableStateOf(null) } var showPaymentDialog by remember { mutableStateOf(false) } + var paidUserIds by remember { mutableStateOf>(emptySet()) } + var isFetchingPayments by remember { mutableStateOf(false) } + LaunchedEffect(showShareDialog, group?.id) { val id = group?.id if (showShareDialog && id != null) { @@ -58,6 +63,19 @@ fun GroupDetailsScreen( } } + LaunchedEffect(selectedExpense) { + if (selectedExpense != null) { + isFetchingPayments = true + val payments = vm.getExpensePayments(selectedExpense!!.expense.id!!) + + paidUserIds = payments.mapNotNull { it.user_id }.toSet() + isFetchingPayments = false + } else { + paidUserIds = emptySet() + isFetchingPayments = false + } + } + Scaffold( topBar = { CenterAlignedTopAppBar( @@ -255,15 +273,92 @@ fun GroupDetailsScreen( } if (showPaymentDialog && selectedExpense != null) { - ExpensePaymentDialog( - expense = selectedExpense!!.expense, - members = members, - vm = vm, - onDismiss = { - showPaymentDialog = false - selectedExpense = null - vm.loadGroup(groupId) // Reload group to refresh expenses and payments + if (isFetchingPayments) { + AlertDialog( + onDismissRequest = {}, + properties = DialogProperties( + dismissOnClickOutside = false, + dismissOnBackPress = true + ), + confirmButton = {}, + text = { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + } + }, + containerColor = MaterialTheme.colorScheme.surface, + ) + } else { + val unpaidMembers = remember(members, paidUserIds) { + members.filter { member -> + !paidUserIds.contains(member.id) && + member.id != selectedExpense!!.expense.user_id + } } - ) + + if (unpaidMembers.isEmpty()) { + AlertDialog( + onDismissRequest = { + showPaymentDialog = false + selectedExpense = null + }, + properties = DialogProperties( + dismissOnClickOutside = false, + dismissOnBackPress = true + ), + icon = { + Icon( + Icons.Default.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(48.dp) + ) + }, + title = { + Text( + text = "All Settled", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface + ) + }, + text = { + Text( + text = "All users have paid for this expense.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + containerColor = MaterialTheme.colorScheme.surface, + confirmButton = { + Button( + onClick = { + showPaymentDialog = false + selectedExpense = null + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) + ) { + Text("Ok") + } + } + ) + } else { + ExpensePaymentDialog( + expense = selectedExpense!!.expense, + members = unpaidMembers, + vm = vm, + onDismiss = { + showPaymentDialog = false + selectedExpense = null + vm.loadGroup(groupId) + } + ) + } + } } } From 9a2f07929448c0c95dd3d9c1513ea0058c4018a0 Mon Sep 17 00:00:00 2001 From: Bolosh Date: Mon, 15 Dec 2025 01:51:16 +0200 Subject: [PATCH 10/11] made some consistent design updates --- .../android/ui/component/ExpenseItem.kt | 85 +++-- .../android/ui/screens/AnalyticsScreen.kt | 224 +++++++------- .../android/ui/screens/CategoriesScreen.kt | 156 ++++------ .../android/ui/screens/ExpensesScreen.kt | 290 +++++++++--------- .../android/ui/screens/GroupsScreen.kt | 9 +- .../android/ui/screens/ProfileScreen.kt | 17 +- .../android/ui/screens/ReceiptScreen.kt | 9 +- 7 files changed, 371 insertions(+), 419 deletions(-) diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/component/ExpenseItem.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/component/ExpenseItem.kt index 6390e99..0c2ceac 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/component/ExpenseItem.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/component/ExpenseItem.kt @@ -1,24 +1,13 @@ package com.example.budgeting.android.ui.component -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.MaterialTheme import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Lock -import androidx.compose.material3.Icon -import androidx.compose.material3.Text +import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.example.budgeting.android.data.model.Expense @@ -31,49 +20,55 @@ fun ExpenseItem( modifier: Modifier = Modifier ) { Card( - modifier = modifier - .fillMaxWidth() - .padding(8.dp), + modifier = modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) ) { - Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { - Column( - modifier = Modifier.padding(16.dp) + // Title + Lock indicator + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically ) { + Text( text = expense.title, style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - - Text( - text = "Category: $categoryTitle", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f) ) - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = "Amount: ${expense.amount} RON", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.primary - ) + if (expense.user_id != currentUserId) { + Icon( + imageVector = Icons.Default.Lock, + contentDescription = "Locked expense", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp) + ) + } } - if (expense.user_id != currentUserId) { - Icon( - imageVector = Icons.Default.Lock, - contentDescription = "Locked", - tint = Color.Gray, - modifier = Modifier - .align(Alignment.TopEnd) - .padding(12.dp) - ) - } + // Category + Text( + text = categoryTitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + // Amount + Text( + text = "${expense.amount} RON", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium + ) } } } - diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/AnalyticsScreen.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/AnalyticsScreen.kt index dad2c31..d53a5e8 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/AnalyticsScreen.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/AnalyticsScreen.kt @@ -2,6 +2,7 @@ package com.example.budgeting.android.ui.screens import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CalendarToday @@ -30,6 +31,8 @@ import java.time.LocalDate import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.rememberDatePickerState +import com.example.budgeting.android.data.model.* +import com.example.budgeting.android.ui.viewmodels.* @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -52,45 +55,63 @@ fun AnalyticsScreen() { LaunchedEffect(Unit) { viewModel.loadAnalytics() } - val scrollState = rememberScrollState() - Scaffold( - topBar = { CenterAlignedTopAppBar(title = { Text("Analytics") }) } + topBar = { + TopAppBar( + title = { + Text( + "Analytics", + style = MaterialTheme.typography.titleLarge + ) + } + ) + } ) { padding -> + Column( modifier = Modifier .padding(padding) .fillMaxSize() - .verticalScroll(scrollState) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) ) { Spacer(Modifier.height(8.dp)) ModeSelector(selected = mode, onSelected = { viewModel.setMode(it) }) - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(12.dp)) - // --- FILTER ROW WITH CATEGORY AND CALENDAR DATE PICKERS --- - AnalyticsFilterRow( - categories = categories, - selectedCategory = selectedCategory, - onCategorySelected = { viewModel.setCategory(it) }, - from = from, - to = to, - onFromSelected = { viewModel.setDateFrom(it) }, - onToSelected = { viewModel.setDateTo(it) } - ) + // ---------------- FILTER CARD ---------------- + Surface( + shape = RoundedCornerShape(16.dp), + tonalElevation = 2.dp, + modifier = Modifier.fillMaxWidth() + ) { + AnalyticsFilterRow( + categories = categories, + selectedCategory = selectedCategory, + onCategorySelected = { viewModel.setCategory(it) }, + from = from, + to = to, + onFromSelected = { viewModel.setDateFrom(it) }, + onToSelected = { viewModel.setDateTo(it) } + ) + } Spacer(Modifier.height(16.dp)) when { - isLoading -> Box( - Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { CircularProgressIndicator() } - error != null -> Text("Error: $error", color = MaterialTheme.colorScheme.error) - else -> AnalyticsContent(categoryCounts, categoryAmounts, monthlyTotals) + isLoading -> CenterLoading() + error != null -> CenterError(error!!) + else -> AnalyticsContent( + categoryCounts, + categoryAmounts, + monthlyTotals + ) } + + Spacer(Modifier.height(24.dp)) } } } @@ -110,67 +131,29 @@ fun AnalyticsFilterRow( var showFromDatePicker by remember { mutableStateOf(false) } var showToDatePicker by remember { mutableStateOf(false) } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - - // --- CATEGORY BUTTON WITH ICON --- - Box { - IconButton(onClick = { showCategoryMenu = true }) { - Icon( - Icons.Default.Category, - contentDescription = "Select Category", - tint = if (selectedCategory != "All") - MaterialTheme.colorScheme.primary - else - MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - DropdownMenu( - expanded = showCategoryMenu, - onDismissRequest = { showCategoryMenu = false } - ) { - DropdownMenuItem(text = { Text("All") }, onClick = { - onCategorySelected("All") - showCategoryMenu = false - }) - categories.forEach { cat -> - DropdownMenuItem(text = { Text(cat) }, onClick = { - onCategorySelected(cat) - showCategoryMenu = false - }) - } - } - } - - // --- DATE PICKERS WITH ICONS --- - Row { - // From Date - IconButton(onClick = { showFromDatePicker = true }) { - Icon(Icons.Default.CalendarToday, contentDescription = "Select From Date") - } + // DATE RANGE + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { TextButton(onClick = { showFromDatePicker = true }) { + Icon(Icons.Default.CalendarToday, contentDescription = null) + Spacer(Modifier.width(6.dp)) Text(from?.toString() ?: "From") } - Spacer(Modifier.width(8.dp)) - - // To Date - IconButton(onClick = { showToDatePicker = true }) { - Icon(Icons.Default.CalendarToday, contentDescription = "Select To Date") - } TextButton(onClick = { showToDatePicker = true }) { + Icon(Icons.Default.CalendarToday, contentDescription = null) + Spacer(Modifier.width(6.dp)) Text(to?.toString() ?: "To") } } } - // --- FROM DATE PICKER DIALOG --- if (showFromDatePicker) { val state = rememberDatePickerState( initialSelectedDateMillis = from?.toEpochDay()?.times(86400000L) @@ -179,21 +162,20 @@ fun AnalyticsFilterRow( onDismissRequest = { showFromDatePicker = false }, confirmButton = { TextButton(onClick = { - state.selectedDateMillis?.let { millis -> - onFromSelected(LocalDate.ofEpochDay(millis / 86400000L)) + state.selectedDateMillis?.let { + onFromSelected(LocalDate.ofEpochDay(it / 86400000L)) } showFromDatePicker = false }) { Text("OK") } }, dismissButton = { - TextButton(onClick = { showFromDatePicker = false }) { Text("Cancel") } + TextButton(onClick = { showFromDatePicker = false }) { + Text("Cancel") + } } - ) { - DatePicker(state = state) - } + ) { DatePicker(state = state) } } - // --- TO DATE PICKER DIALOG --- if (showToDatePicker) { val state = rememberDatePickerState( initialSelectedDateMillis = to?.toEpochDay()?.times(86400000L) @@ -202,24 +184,65 @@ fun AnalyticsFilterRow( onDismissRequest = { showToDatePicker = false }, confirmButton = { TextButton(onClick = { - state.selectedDateMillis?.let { millis -> - onToSelected(LocalDate.ofEpochDay(millis / 86400000L)) + state.selectedDateMillis?.let { + onToSelected(LocalDate.ofEpochDay(it / 86400000L)) } showToDatePicker = false }) { Text("OK") } }, dismissButton = { - TextButton(onClick = { showToDatePicker = false }) { Text("Cancel") } + TextButton(onClick = { showToDatePicker = false }) { + Text("Cancel") + } } - ) { - DatePicker(state = state) + ) { DatePicker(state = state) } + } +} + +@Composable +fun AnalyticsContent( + categoryCounts: List, + categoryAmounts: List, + monthlyTotals: List +) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + + AnalyticsCard("Expenses per category") { + CategoryCountBarChart(categoryCounts) + } + + AnalyticsCard("Total amount per category") { + CategoryAmountBarChart(categoryAmounts) + } + + AnalyticsCard("Monthly trend") { + MonthlyLineChart(monthlyTotals) } } } +@Composable +fun AnalyticsCard( + title: String, + content: @Composable () -> Unit +) { + Card( + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column(Modifier.padding(16.dp)) { + Text(title, style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(12.dp)) + content() + } + } +} + + // ----------------- REMAINING ANALYTICS CHARTS ----------------- @Composable -fun AnalyticsContent( +fun AnalyticsContentOLD( categoryCounts: List, categoryAmounts: List, monthlyTotals: List @@ -333,39 +356,6 @@ fun MonthlyLineChart(data: List) { ) } -// ----------------- CATEGORY DROPDOWN ----------------- -@Composable -fun CategoryDropdown( - categories: List, - selected: String, - onSelected: (String) -> Unit -) { - var expanded by remember { mutableStateOf(false) } - - Box { - TextButton(onClick = { expanded = true }) { - Text("Category: ${selected}") - } - - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false } - ) { - DropdownMenuItem(text = { Text("All") }, onClick = { - onSelected("All") - expanded = false - }) - - categories.forEach { cat -> - DropdownMenuItem(text = { Text(cat) }, onClick = { - onSelected(cat) - expanded = false - }) - } - } - } -} - // ----------------- DATE PICKER ----------------- @Composable fun DateRangePicker( diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/CategoriesScreen.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/CategoriesScreen.kt index 1d84a84..e04ce63 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/CategoriesScreen.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/CategoriesScreen.kt @@ -2,17 +2,7 @@ package com.example.budgeting.android.ui.screens import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape @@ -20,43 +10,19 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.AssistChip -import androidx.compose.material3.AssistChipDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CenterAlignedTopAppBar -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.InputChip -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel -import com.example.budgeting.android.ui.viewmodels.CategoryViewModel import com.example.budgeting.android.data.model.Category import com.example.budgeting.android.data.model.CategoryBody +import com.example.budgeting.android.ui.viewmodels.CategoryViewModel import com.example.budgeting.android.ui.viewmodels.CategoryViewModelFactory - @OptIn(ExperimentalMaterial3Api::class) @Composable fun CategoriesScreen( @@ -76,11 +42,19 @@ fun CategoriesScreen( Scaffold( topBar = { - CenterAlignedTopAppBar( - title = { Text("Categories") }, + TopAppBar( + title = { + Text( + text = "Categories", + style = MaterialTheme.typography.titleLarge + ) + }, navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) } }, actions = { @@ -94,6 +68,7 @@ fun CategoriesScreen( ) } ) { padding -> + Box( modifier = Modifier .padding(padding) @@ -104,19 +79,21 @@ fun CategoriesScreen( CircularProgressIndicator() } error != null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("Error: $error", color = MaterialTheme.colorScheme.error) - } - categories.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("No categories found") + Text( + text = error!!, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium + ) } + categories.isEmpty() -> EmptyCategoriesState() else -> LazyColumn( modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(8.dp) + contentPadding = PaddingValues(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { items(categories) { category -> CategoryItem( category = category, - modifier = Modifier.padding(vertical = 4.dp), onClick = { editingCategory = category showDialog = true @@ -125,7 +102,6 @@ fun CategoriesScreen( categoryToDelete = category showDeleteDialog = true } - ) } } @@ -135,9 +111,10 @@ fun CategoriesScreen( if (showDeleteDialog && categoryToDelete != null) { AlertDialog( onDismissRequest = { showDeleteDialog = false }, - title = { Text("Delete Category") }, + shape = RoundedCornerShape(20.dp), + title = { Text("Delete category") }, text = { - Text("Are you sure you want to delete '${categoryToDelete!!.title}'?") + Text("Are you sure you want to delete \"${categoryToDelete!!.title}\"?") }, confirmButton = { TextButton(onClick = { @@ -158,7 +135,6 @@ fun CategoriesScreen( } ) } - } if (showDialog) { @@ -177,12 +153,10 @@ fun CategoriesScreen( ) } } - ) } } -// ========================= ADD/EDIT CATEGORY DIALOG ========================= @OptIn(ExperimentalMaterial3Api::class) @Composable fun AddEditCategoryDialog( @@ -191,36 +165,37 @@ fun AddEditCategoryDialog( onSave: (String, List) -> Unit ) { var title by remember { mutableStateOf(category?.title ?: "") } - var keywordInput by remember { mutableStateOf("") } var keywords by remember { - mutableStateOf>(category?.keywords?.toMutableList() ?: mutableListOf()) + mutableStateOf(category?.keywords ?: mutableListOf()) } AlertDialog( onDismissRequest = onDismiss, + shape = RoundedCornerShape(20.dp), title = { - Text(if (category == null) "Add Category" else "Edit Category") + Text( + text = if (category == null) "Add category" else "Edit category", + style = MaterialTheme.typography.titleLarge + ) }, text = { - Column { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - // Title OutlinedTextField( value = title, onValueChange = { title = it }, label = { Text("Title") }, + placeholder = { Text("eg. Food") }, singleLine = true, modifier = Modifier.fillMaxWidth() ) - Spacer(Modifier.height(12.dp)) - - // Keyword input OutlinedTextField( value = keywordInput, onValueChange = { keywordInput = it }, label = { Text("Add keyword") }, + placeholder = { Text("eg. Grocery") }, singleLine = true, modifier = Modifier.fillMaxWidth(), trailingIcon = { @@ -239,32 +214,25 @@ fun AddEditCategoryDialog( } ) - // Keyword chips if (keywords.isNotEmpty()) { - Spacer(Modifier.height(8.dp)) - FlowRow( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp) ) { keywords.forEach { keyword -> AssistChip( + onClick = {}, label = { Text(keyword) }, - onClick = { /* maybe do nothing */ }, trailingIcon = { Icon( - imageVector = Icons.Default.Close, + Icons.Default.Close, contentDescription = "Remove", - modifier = Modifier - .padding(start = 4.dp) - .clickable { - keywords = keywords.toMutableList().also { it.remove(keyword) } - } + modifier = Modifier.clickable { + keywords = + keywords.toMutableList().also { it.remove(keyword) } + } ) - }, - colors = AssistChipDefaults.assistChipColors( - containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) - ) + } ) } } @@ -290,7 +258,6 @@ fun AddEditCategoryDialog( ) } - @Composable fun CategoryItem( category: Category, @@ -306,45 +273,40 @@ fun CategoryItem( onLongClick = onLongClick ), shape = RoundedCornerShape(16.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - // Title Text( text = category.title.orEmpty(), style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.SemiBold ) - // Keywords val keywords = category.keywords.orEmpty() if (keywords.isNotEmpty()) { - Spacer(Modifier.height(8.dp)) - FlowRow( - horizontalArrangement = Arrangement.Absolute.spacedBy(8.dp), - verticalArrangement = Arrangement.Absolute.spacedBy(6.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) ) { keywords.forEach { keyword -> AssistChip( onClick = {}, + label = { Text(keyword) }, colors = AssistChipDefaults.assistChipColors( containerColor = MaterialTheme.colorScheme.surfaceVariant - ), - label = { Text(keyword) } + ) ) - } } } - Spacer(Modifier.height(8.dp)) - Text( text = "Tap to edit · Hold to delete", style = MaterialTheme.typography.bodySmall, @@ -354,3 +316,13 @@ fun CategoryItem( } } +@Composable +fun EmptyCategoriesState() { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + "No categories yet", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ExpensesScreen.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ExpensesScreen.kt index 95f7f6f..ba505cb 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ExpensesScreen.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ExpensesScreen.kt @@ -1,6 +1,5 @@ package com.example.budgeting.android.ui.screens -import android.util.Log import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.* @@ -9,29 +8,22 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Sort import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Category import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput -import androidx.lifecycle.viewmodel.compose.viewModel -import com.example.budgeting.android.ui.component.ExpenseItem -import com.example.budgeting.android.ui.viewmodels.ExpenseViewModelFactory -import com.example.budgeting.android.ui.viewmodels.ExpenseViewModel import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp -import com.example.budgeting.android.data.model.Category -import com.example.budgeting.android.data.model.CategoryBody -import com.example.budgeting.android.data.model.Expense -import com.example.budgeting.android.data.model.SortOption -import com.example.budgeting.android.ui.viewmodels.ExpenseMode +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.budgeting.android.data.model.* +import com.example.budgeting.android.ui.component.ExpenseItem +import com.example.budgeting.android.ui.viewmodels.* @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -46,8 +38,8 @@ fun ExpensesScreen( val error by expenseViewModel.error.collectAsState() val mode by expenseViewModel.mode.collectAsState() val currentUserId by expenseViewModel.currentUserId.collectAsState() - val filters by expenseViewModel.filters.collectAsState() + val focusManager = LocalFocusManager.current var showDialog by remember { mutableStateOf(false) } @@ -61,13 +53,18 @@ fun ExpensesScreen( Scaffold( topBar = { - CenterAlignedTopAppBar( - title = { Text("Expenses") }, + TopAppBar( + title = { + Text( + text = "Expenses", + style = MaterialTheme.typography.titleLarge + ) + }, actions = { IconButton(onClick = onOpenCategories) { Icon( Icons.Default.Category, - contentDescription = "Manage Categories" + contentDescription = "Categories" ) } } @@ -79,8 +76,7 @@ fun ExpensesScreen( selectedExpense = null showDialog = true }, - shape = RoundedCornerShape(16.dp), - containerColor = MaterialTheme.colorScheme.primary + shape = RoundedCornerShape(16.dp) ) { Icon(Icons.Default.Add, contentDescription = "Add Expense") } @@ -94,61 +90,47 @@ fun ExpensesScreen( .pointerInput(Unit) { detectTapGestures { focusManager.clearFocus() } } ) { - // ------------------------------------------------------------------- - // MODE TOGGLE: PERSONAL | GROUP | ALL - // ------------------------------------------------------------------- - ModeSelector( - selected = mode, - onSelected = { expenseViewModel.setMode(it) } - ) - - Spacer(Modifier.height(4.dp)) + Spacer(Modifier.height(8.dp)) - // ------------------------------------------------------------------- - // FILTER BAR - // ------------------------------------------------------------------- - FilterBar( + FilterCard( search = filters.search, onSearchChange = { expenseViewModel.setSearchQuery(it) }, - categories = expenseViewModel.categories.collectAsState().value, selectedCategory = filters.category, onCategorySelected = { expenseViewModel.setCategoryFilter(it) }, - sortOption = filters.sortOption, onSortSelected = { expenseViewModel.setSortOption(it) } ) - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(12.dp)) - // ------------------------------------------------------------------- - // CONTENT AREA - // ------------------------------------------------------------------- - Box(modifier = Modifier.fillMaxSize()) { + ModeSelector( + selected = mode, + onSelected = { expenseViewModel.setMode(it) } + ) + Spacer(Modifier.height(12.dp)) + + Box(modifier = Modifier.fillMaxSize()) { when { isLoading -> CenterLoading() - error != null -> CenterError(error!!) - expenses.isEmpty() -> EmptyState() - - - else ->{ + else -> { val userId = currentUserId ?: return@Box ExpensesList( expenses = expenses, currentUserId = userId, vm = expenseViewModel, - onClick = { expense -> - if(expense.user_id == currentUserId){ - selectedExpense = expense + onClick = { + if (it.user_id == userId) { + selectedExpense = it showDialog = true } }, - onLongClick = { expense -> - if(expense.user_id == currentUserId){ - selectedExpense = expense + onLongClick = { + if (it.user_id == userId) { + selectedExpense = it showDeleteDialog = true } } @@ -158,31 +140,26 @@ fun ExpensesScreen( } } - // ADD/EDIT DIALOG if (showDialog) { val realCategories = - expenseViewModel.categories - .collectAsState() - .value - .filter { it.id != 0 } - - AddEditExpenseDialog( - expense = selectedExpense, - categories = realCategories, - onDismiss = { showDialog = false }, - onSave = { expense -> - if (selectedExpense != null) - expenseViewModel.updateExpense(expense.copy(id = selectedExpense!!.id)) - else - expenseViewModel.addExpense(expense) + expenseViewModel.categories.collectAsState().value.filter { it.id != 0 } + + AddEditExpenseDialog( + expense = selectedExpense, + categories = realCategories, + onDismiss = { showDialog = false }, + onSave = { expense -> + if (selectedExpense != null) + expenseViewModel.updateExpense(expense.copy(id = selectedExpense!!.id)) + else + expenseViewModel.addExpense(expense) - selectedExpense = null - showDialog = false - } - ) + selectedExpense = null + showDialog = false + } + ) } - // DELETE CONFIRMATION if (showDeleteDialog && selectedExpense != null) { DeleteExpenseDialog( expense = selectedExpense!!, @@ -200,9 +177,8 @@ fun ExpensesScreen( } } - @Composable -fun FilterBar( +fun FilterCard( search: String, onSearchChange: (String) -> Unit, categories: List, @@ -214,71 +190,73 @@ fun FilterBar( var showCategoryMenu by remember { mutableStateOf(false) } var showSortMenu by remember { mutableStateOf(false) } - Row( - Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically + Surface( + shape = RoundedCornerShape(16.dp), + tonalElevation = 2.dp, + modifier = Modifier.fillMaxWidth() ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { - OutlinedTextField( - value = search, - onValueChange = onSearchChange, - label = { Text("Search") }, - singleLine = true, - modifier = Modifier.weight(1f) - ) + OutlinedTextField( + value = search, + onValueChange = onSearchChange, + placeholder = { Text("Search expenses") }, + singleLine = true, + modifier = Modifier.weight(1f) + ) - Spacer(Modifier.width(8.dp)) + Spacer(Modifier.width(8.dp)) - // Category button - Box { - IconButton(onClick = { showCategoryMenu = true }) { - Icon( - Icons.Default.Category, - contentDescription = "Category", - tint = - if (selectedCategory != "All") + Box { + IconButton(onClick = { showCategoryMenu = true }) { + Icon( + Icons.Default.Category, + contentDescription = "Filter by category", + tint = if (selectedCategory != "All") MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - DropdownMenu( - expanded = showCategoryMenu, - onDismissRequest = { showCategoryMenu = false } - ) { - CategoryMenu( - categories = categories, - selected = selectedCategory, - onSelected = { - onCategorySelected(it) - showCategoryMenu = false - } - ) + ) + } + DropdownMenu( + expanded = showCategoryMenu, + onDismissRequest = { showCategoryMenu = false } + ) { + CategoryMenu( + categories = categories, + selected = selectedCategory, + onSelected = { + onCategorySelected(it) + showCategoryMenu = false + } + ) + } } - } - // Sort button - Box { - IconButton(onClick = { showSortMenu = true }) { - Icon( - Icons.AutoMirrored.Filled.Sort, - contentDescription = "Sort" - ) - } - DropdownMenu( - expanded = showSortMenu, - onDismissRequest = { showSortMenu = false } - ) { - SortMenu { option -> - onSortSelected(option) - showSortMenu = false + Box { + IconButton(onClick = { showSortMenu = true }) { + Icon( + Icons.AutoMirrored.Filled.Sort, + contentDescription = "Sort" + ) + } + DropdownMenu( + expanded = showSortMenu, + onDismissRequest = { showSortMenu = false } + ) { + SortMenu { + onSortSelected(it) + showSortMenu = false + } } } } } } - @Composable fun ModeSelector( selected: ExpenseMode, @@ -286,13 +264,17 @@ fun ModeSelector( ) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { ExpenseMode.entries.forEach { mode -> FilterChip( selected = selected == mode, onClick = { onSelected(mode) }, - label = { Text(mode.name.lowercase().replaceFirstChar { it.uppercase() }) } + label = { + Text( + mode.name.lowercase().replaceFirstChar { it.uppercase() } + ) + } ) } } @@ -306,16 +288,8 @@ fun CategoryMenu( ) { categories.forEach { category -> DropdownMenuItem( - text = { - Text( - text = category.title!!, - color = if (category.title == selected) - MaterialTheme.colorScheme.primary - else - MaterialTheme.colorScheme.onSurface - ) - }, - onClick = { onSelected(category.title!!) } + text = { Text(category.title ?: "") }, + onClick = { onSelected(category.title ?: "") } ) } } @@ -346,25 +320,32 @@ fun AddEditExpenseDialog( LaunchedEffect(expense, categories) { selectedCategoryId = - expense?.categoryId - ?: categories.firstOrNull()?.id + expense?.categoryId ?: categories.firstOrNull()?.id } AlertDialog( onDismissRequest = onDismiss, - title = { Text(if (expense == null) "Add Expense" else "Edit Expense") }, + shape = RoundedCornerShape(20.dp), + title = { + Text( + text = if (expense == null) "Add expense" else "Edit expense", + style = MaterialTheme.typography.titleLarge + ) + }, text = { - Column { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { OutlinedTextField( value = title, onValueChange = { title = it }, label = { Text("Title") }, + placeholder = { Text("eg. Grocery shopping")}, + singleLine = true, modifier = Modifier.fillMaxWidth() ) - Spacer(Modifier.height(8.dp)) - ExposedDropdownMenuBox( expanded = expanded, onExpandedChange = { expanded = !expanded }, @@ -372,17 +353,21 @@ fun AddEditExpenseDialog( ) { val categoryName = categories .find { it.id == selectedCategoryId } - ?.title ?: "" + ?.title + ?: "" OutlinedTextField( value = categoryName, onValueChange = {}, readOnly = true, label = { Text("Category") }, + singleLine = true, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, - modifier = Modifier.menuAnchor().fillMaxWidth() + modifier = Modifier + .menuAnchor() + .fillMaxWidth() ) ExposedDropdownMenu( @@ -401,15 +386,15 @@ fun AddEditExpenseDialog( } } - Spacer(Modifier.height(8.dp)) - OutlinedTextField( value = amount, onValueChange = { amount = it }, label = { Text("Amount") }, + placeholder = { Text("eg. 168.99") }, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number ), + singleLine = true, modifier = Modifier.fillMaxWidth() ) } @@ -450,7 +435,11 @@ fun CenterLoading() { @Composable fun CenterError(message: String) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("Error: $message", color = MaterialTheme.colorScheme.error) + Text( + message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium + ) } } @@ -458,7 +447,7 @@ fun CenterError(message: String) { fun EmptyState() { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text( - "No expenses found", + "No expenses yet", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -473,9 +462,7 @@ fun ExpensesList( onClick: (Expense) -> Unit, onLongClick: (Expense) -> Unit ) { - LazyColumn( - modifier = Modifier.fillMaxSize() - ) { + LazyColumn { items(expenses) { expense -> ExpenseItem( expense = expense, @@ -483,7 +470,7 @@ fun ExpensesList( currentUserId = currentUserId, modifier = Modifier .fillMaxWidth() - .padding(vertical = 4.dp) + .padding(vertical = 6.dp) .combinedClickable( onClick = { onClick(expense) }, onLongClick = { onLongClick(expense) } @@ -501,8 +488,8 @@ fun DeleteExpenseDialog( ) { AlertDialog( onDismissRequest = onDismiss, - title = { Text("Delete Expense") }, - text = { Text("Are you sure you want to delete '${expense.title}'?") }, + title = { Text("Delete expense") }, + text = { Text("Are you sure you want to delete \"${expense.title}\"?") }, confirmButton = { TextButton(onClick = onConfirm) { Text("Delete", color = MaterialTheme.colorScheme.error) @@ -515,4 +502,3 @@ fun DeleteExpenseDialog( } ) } - diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/GroupsScreen.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/GroupsScreen.kt index 45a708c..fc056c9 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/GroupsScreen.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/GroupsScreen.kt @@ -33,7 +33,14 @@ fun GroupsScreen(onOpenGroup: (Int) -> Unit) { Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { Column(modifier = Modifier.fillMaxSize()) { - CenterAlignedTopAppBar(title = { Text("Groups", color = MaterialTheme.colorScheme.onBackground) }) + TopAppBar( + title = { + Text( + text = "Groups", + style = MaterialTheme.typography.titleLarge + ) + } + ) Text( text = "My Groups", diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ProfileScreen.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ProfileScreen.kt index 276462b..0b958a1 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ProfileScreen.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ProfileScreen.kt @@ -54,15 +54,12 @@ fun ProfileScreen( onClick = { profileViewModel.logout() onLogout() - }, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary - ), - shape = RoundedCornerShape(16.dp), - modifier = Modifier.padding(end = 12.dp) + } ) { - Text("Logout") + Text( + text = "Logout", + color = MaterialTheme.colorScheme.error + ) } } ) @@ -110,7 +107,7 @@ fun ProfileScreen( Surface( shape = CircleShape, color = MaterialTheme.colorScheme.primaryContainer, - modifier = Modifier.size(80.dp) + modifier = Modifier.size(72.dp) ) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text( @@ -121,7 +118,7 @@ fun ProfileScreen( } } - Spacer(Modifier.width(20.dp)) + Spacer(Modifier.width(16.dp)) Column( modifier = Modifier.weight(1f) diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ReceiptScreen.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ReceiptScreen.kt index 04b8b29..e107c39 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ReceiptScreen.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ReceiptScreen.kt @@ -73,8 +73,13 @@ fun ReceiptScreen() { Scaffold( topBar = { - CenterAlignedTopAppBar( - title = { Text("Receipts", color = MaterialTheme.colorScheme.onBackground) } + TopAppBar( + title = { + Text( + text = "Groups", + style = MaterialTheme.typography.titleLarge + ) + } ) }, bottomBar = { From 3daa27c2380a658ffdb06db92ade800e9c6d8768 Mon Sep 17 00:00:00 2001 From: Bolosh Date: Mon, 15 Dec 2025 14:04:23 +0200 Subject: [PATCH 11/11] minr fixes --- .../budgeting/android/data/network/RetrofitClient.kt | 4 ++++ .../budgeting/android/data/network/UserApiService.kt | 2 +- .../example/budgeting/android/ui/screens/ExpensesScreen.kt | 5 +++-- .../example/budgeting/android/ui/screens/ProfileScreen.kt | 4 ++-- .../budgeting/android/ui/viewmodels/ExpenseViewModel.kt | 6 ++++++ Web/React/package-lock.json | 2 +- Web/React/package.json | 2 +- 7 files changed, 18 insertions(+), 7 deletions(-) diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/RetrofitClient.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/RetrofitClient.kt index ec9dcbf..786b3a9 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/RetrofitClient.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/RetrofitClient.kt @@ -11,6 +11,10 @@ import retrofit2.converter.moshi.MoshiConverterFactory object RetrofitClient { private val BASE_URL: String = BuildConfig.BASE_URL + init { + Log.d("RetrofitClient", "Base URL: $BASE_URL") + } + private val moshi = Moshi.Builder() .add(KotlinJsonAdapterFactory()) .build() diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/UserApiService.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/UserApiService.kt index 1b3bcda..1c1d0b7 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/UserApiService.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/data/network/UserApiService.kt @@ -13,7 +13,7 @@ interface UserApiService { suspend fun getUserById(@Path("id") id: Int): Response> @PUT("/users/{id}") - suspend fun updateUser(@Path("id") id: Int, @Body user: UserUpdateRequest): Response> + suspend fun updateUser(@Path("id") id: Int, @Body user: UserUpdateRequest): Response> @DELETE("/users/{id}") suspend fun deleteUser(@Path("id") id: Int): Response> diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ExpensesScreen.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ExpensesScreen.kt index ba505cb..f5bc2ac 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ExpensesScreen.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ExpensesScreen.kt @@ -39,6 +39,7 @@ fun ExpensesScreen( val mode by expenseViewModel.mode.collectAsState() val currentUserId by expenseViewModel.currentUserId.collectAsState() val filters by expenseViewModel.filters.collectAsState() + val categories by expenseViewModel.categories.collectAsState() val focusManager = LocalFocusManager.current @@ -141,8 +142,8 @@ fun ExpensesScreen( } if (showDialog) { - val realCategories = - expenseViewModel.categories.collectAsState().value.filter { it.id != 0 } + expenseViewModel.getCategoriesOfUser() + val realCategories = categories AddEditExpenseDialog( expense = selectedExpense, diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ProfileScreen.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ProfileScreen.kt index 0b958a1..96a9ec5 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ProfileScreen.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/screens/ProfileScreen.kt @@ -432,7 +432,7 @@ fun BudgetSummaryCard( ) Text( - text = "$${totalBudget}", + text = "${totalBudget}", style = MaterialTheme.typography.titleMedium ) } @@ -462,7 +462,7 @@ private fun BudgetStat( ) Text( - text = "$${amount.toInt()}", + text = "${amount.toInt()}", style = MaterialTheme.typography.titleLarge ) } diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ExpenseViewModel.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ExpenseViewModel.kt index 17ca745..51dcbd3 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ExpenseViewModel.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ExpenseViewModel.kt @@ -3,6 +3,7 @@ package com.example.budgeting.android.ui.viewmodels import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel import com.example.budgeting.android.data.local.TokenDataStore import com.example.budgeting.android.data.model.Category import com.example.budgeting.android.data.model.Expense @@ -152,6 +153,11 @@ class ExpenseViewModel(context: Context) : ViewModel() { } } + fun getCategoriesOfUser() { + viewModelScope.launch { + _categories.value = categoryRepository.getCategories(null, null) + } + } /** ---------------------------------------------------------- * MODES diff --git a/Web/React/package-lock.json b/Web/React/package-lock.json index cf6f85b..65b94f6 100644 --- a/Web/React/package-lock.json +++ b/Web/React/package-lock.json @@ -19,7 +19,7 @@ "@types/react-dom": "^18.0.10", "@vitejs/plugin-react": "^3.1.0", "typescript": "^4.9.3", - "vite": "^4.1.0" + "vite": "^4.5.14" } }, "node_modules/@babel/code-frame": { diff --git a/Web/React/package.json b/Web/React/package.json index 89df4d1..1ec8d44 100644 --- a/Web/React/package.json +++ b/Web/React/package.json @@ -20,6 +20,6 @@ "@types/react-dom": "^18.0.10", "@vitejs/plugin-react": "^3.1.0", "typescript": "^4.9.3", - "vite": "^4.1.0" + "vite": "^4.5.14" } }