Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Mobile/androidApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ android {
"BASE_URL",
// fallback url if .env file is not found : https://10.0.0.2:8000
//"\"${envProps.getProperty("BASE_URL") ?: "https://10.0.0.2:8000"}\""
"\"http://proiectcolectivlb-437779233.eu-west-1.elb.amazonaws.com\""
"\"https://backend.gitpushforce-ubb.com\""
)
}
buildFeatures {
Expand Down
2 changes: 2 additions & 0 deletions Mobile/androidApp/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
<uses-permission android:name="android.permission.CAMERA" />

<application
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:allowBackup="false"
android:supportsRtl="true"
android:theme="@style/AppTheme"
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.example.budgeting.android.data.model

import com.squareup.moshi.Json

data class GroupStatistics(
@Json(name = "total_group_spend")
val totalGroupSpend: Double,

@Json(name = "my_total_paid")
val myTotalPaid: Double,

@Json(name = "my_share_of_expenses")
val myShareOfExpenses: Double,

@Json(name = "net_balance_paid_for_others")
val netBalancePaidForOthers: Double,

@Json(name = "rest_of_group_expenses")
val restOfGroupExpenses: Double
)

Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ import retrofit2.Response
import retrofit2.http.*

interface CategoryApiService {
@GET("/categories")
@GET("/categories/")
suspend fun getCategories(
@Query("sort_by") sortBy: String? = null,
@Query("order") order: String? = null
): Response<ApiResponse<List<Category>>>

@POST("/categories")
@POST("/categories/")
suspend fun addCategory(@Body category: CategoryBody): Response<Unit>

@PUT("/categories/{id}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ 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 com.example.budgeting.android.data.model.GroupStatistics
import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.http.Body
Expand Down Expand Up @@ -49,7 +50,7 @@ interface GroupApiService {
suspend fun removeUserFromGroup(
@Path("group_id") groupId: Int,
@Path("user_id") userId: Int
): Response<ApiResponse<Unit>>
): Response<ApiResponse<Any>>

@GET("/groups/user/{user_id}")
suspend fun getGroupsByUser(@Path("user_id") userId: Int): Response<ApiResponse<List<Group>>>
Expand Down Expand Up @@ -79,4 +80,7 @@ interface GroupApiService {

@GET("/group_logs/{group_id}")
suspend fun getGroupLogs(@Path("group_id") groupId: Int): Response<ApiResponse<List<GroupLog>>>

@GET("/groups/{group_id}/statistics/user-summary")
suspend fun getGroupStatistics(@Path("group_id") groupId: Int): Response<ApiResponse<com.example.budgeting.android.data.model.GroupStatistics>>
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,9 @@ import java.util.concurrent.TimeUnit
object RetrofitClient {
private val BASE_URL: String = BuildConfig.BASE_URL

// init {
// Log.d("RetrofitClient", "Base URL: $BASE_URL")
// }

init {
Log.d("RetrofitClient", "Backend url $BASE_URL")
}
private val moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ 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 retrofit2.Response
import kotlinx.coroutines.delay
import okhttp3.ResponseBody
import android.util.Base64

Expand Down Expand Up @@ -206,12 +207,26 @@ class GroupRepository(
}

suspend fun joinGroupByInvitationCode(invitationCode: String): Response<Unit> {
val response = apiService.joinGroupByInvitationCode(invitationCode)
return if (response.isSuccessful) {
Response.success(Unit)
} else {
Response.error(response.code(), response.errorBody() ?: ResponseBody.create(null, ""))
var attempt = 0
val maxAttempts = 2
while (attempt < maxAttempts) {
val response = apiService.joinGroupByInvitationCode(invitationCode)
if (response.isSuccessful) {
return Response.success(Unit)
}

// If server error, retry once after a short delay
val code = response.code()
if (code in 500..599 && attempt + 1 < maxAttempts) {
attempt++
delay(300)
continue
}

return Response.error(response.code(), response.errorBody() ?: ResponseBody.create(null, ""))
}

return Response.error(500, ResponseBody.create(null, "Server error"))
}

suspend fun getGroupLogs(groupId: Int): Response<List<GroupLog>> {
Expand All @@ -222,4 +237,22 @@ class GroupRepository(
Response.error(response.code(), response.errorBody() ?: ResponseBody.create(null, ""))
}
}

suspend fun getGroupStatistics(groupId: Int): Response<com.example.budgeting.android.data.model.GroupStatistics> {
val response = apiService.getGroupStatistics(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 removeUserFromGroup(groupId: Int, userId: Int): Response<Unit> {
val response = apiService.removeUserFromGroup(groupId, userId)
return if (response.isSuccessful) {
Response.success(Unit)
} else {
Response.error(response.code(), response.errorBody() ?: ResponseBody.create(null, ""))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ 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.CategoryViewModel
import com.example.budgeting.android.ui.viewmodels.CategoryViewModelFactory
import com.example.budgeting.android.ui.viewmodels.ExpenseMode
import com.example.budgeting.android.ui.viewmodels.ExpenseViewModel
import com.example.budgeting.android.ui.viewmodels.ExpenseViewModelFactory
import com.example.budgeting.android.ui.viewmodels.GroupDetailsViewModel
Expand All @@ -31,17 +34,31 @@ fun BottomAddExpenseBar(
val expenseViewModel: ExpenseViewModel = viewModel(
factory = ExpenseViewModelFactory(context)
)
val categoryViewModel: CategoryViewModel = viewModel(
factory = CategoryViewModelFactory(context)
)

LaunchedEffect(Unit) {
expenseViewModel.loadExpenses()
expenseViewModel.setMode(ExpenseMode.PERSONAL)
expenseViewModel.loadExpenses()
}

val personalExpenses by expenseViewModel.expenses.collectAsState()
val currentUserId by expenseViewModel.currentUserId.collectAsState()
val groupExpenses by vm.expenses.collectAsState()
val personalExpensesOnly = remember(personalExpenses, groupExpenses) {
val categories by categoryViewModel.categories.collectAsState()

val categoryMap = remember(categories) {
categories.associate { it.id to it.title }
}
val personalExpensesOnly = remember(personalExpenses, groupExpenses, currentUserId) {
if (currentUserId == null) return@remember emptyList()

val existingExpenseIds = groupExpenses.map { it.expense.id }.toSet()
personalExpenses.filter { expense ->
expense.group_id == null &&
expense.user_id != null &&
expense.user_id == currentUserId &&
!existingExpenseIds.contains(expense.id) &&
!GroupUtils.isExpenseAlreadyInGroup(expense, groupExpenses)
}
Expand Down Expand Up @@ -93,6 +110,7 @@ fun BottomAddExpenseBar(
if (showPicker) {
ExpensePickerDialog(
expenses = personalExpensesOnly,
categoryMap = categoryMap,
onDismiss = { showPicker = false },
onConfirm = { selected ->
vm.addExpensesFromPersonal(selected, description, "You")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import com.example.budgeting.android.data.model.Expense
@Composable
fun ExpensePickerDialog(
expenses: List<Expense>,
categoryMap: Map<Int, String> = emptyMap(),
onDismiss: () -> Unit,
onConfirm: (List<Expense>) -> Unit
) {
Expand Down Expand Up @@ -96,7 +97,7 @@ fun ExpensePickerDialog(
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = expense.categoryId.toString(), // TODO display category title not id
text = expense.categoryId?.let { categoryMap[it] } ?: "Uncategorized",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall
)
Expand Down Expand Up @@ -143,5 +144,4 @@ fun ExpensePickerDialog(
}
}
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
Expand All @@ -15,7 +16,8 @@ import androidx.compose.ui.unit.dp
fun GroupMetaRow(
memberCount: Int,
onShareClick: () -> Unit,
invitationCodeAvailable: Boolean
invitationCodeAvailable: Boolean,
onOverviewClick: () -> Unit
) {
Row(
modifier = Modifier
Expand Down Expand Up @@ -45,17 +47,30 @@ fun GroupMetaRow(
)
}
}
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")
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedButton(
onClick = onOverviewClick,
modifier = Modifier.height(40.dp),
shape = RoundedCornerShape(10.dp),
contentPadding = ButtonDefaults.ContentPadding
) {
Text("Overview", style = MaterialTheme.typography.labelLarge)
}

FilledTonalButton(
onClick = onShareClick,
enabled = invitationCodeAvailable,
modifier = Modifier.height(40.dp),
shape = RoundedCornerShape(10.dp),
contentPadding = ButtonDefaults.ContentPadding
) {
Icon(
imageVector = Icons.Filled.Share,
contentDescription = "Share group",
tint = MaterialTheme.colorScheme.onPrimary
)
Spacer(modifier = Modifier.width(6.dp))
}
}
}
}
Expand Down
Loading
Loading