diff --git a/Mobile/androidApp/build.gradle.kts b/Mobile/androidApp/build.gradle.kts index 36b802e..25b0807 100644 --- a/Mobile/androidApp/build.gradle.kts +++ b/Mobile/androidApp/build.gradle.kts @@ -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 { diff --git a/Mobile/androidApp/src/main/AndroidManifest.xml b/Mobile/androidApp/src/main/AndroidManifest.xml index a1a3c43..7a0ec62 100644 --- a/Mobile/androidApp/src/main/AndroidManifest.xml +++ b/Mobile/androidApp/src/main/AndroidManifest.xml @@ -9,6 +9,8 @@ >> - @POST("/categories") + @POST("/categories/") suspend fun addCategory(@Body category: CategoryBody): Response @PUT("/categories/{id}") 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 9048459..47a41f6 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 @@ -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 @@ -49,7 +50,7 @@ interface GroupApiService { suspend fun removeUserFromGroup( @Path("group_id") groupId: Int, @Path("user_id") userId: Int - ): Response> + ): Response> @GET("/groups/user/{user_id}") suspend fun getGroupsByUser(@Path("user_id") userId: Int): Response>> @@ -79,4 +80,7 @@ interface GroupApiService { @GET("/group_logs/{group_id}") suspend fun getGroupLogs(@Path("group_id") groupId: Int): Response>> + + @GET("/groups/{group_id}/statistics/user-summary") + suspend fun getGroupStatistics(@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 08dd0fa..aa244dc 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 @@ -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() 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 8bbecfb..b57bd0d 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 @@ -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 @@ -206,12 +207,26 @@ class GroupRepository( } 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, "")) + 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> { @@ -222,4 +237,22 @@ class GroupRepository( Response.error(response.code(), response.errorBody() ?: ResponseBody.create(null, "")) } } + + suspend fun getGroupStatistics(groupId: Int): Response { + 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 { + val response = apiService.removeUserFromGroup(groupId, userId) + return if (response.isSuccessful) { + Response.success(Unit) + } else { + Response.error(response.code(), response.errorBody() ?: ResponseBody.create(null, "")) + } + } } 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 index 2830db1..e8cf508 100644 --- 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 @@ -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 @@ -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) } @@ -93,6 +110,7 @@ fun BottomAddExpenseBar( if (showPicker) { ExpensePickerDialog( expenses = personalExpensesOnly, + categoryMap = categoryMap, onDismiss = { showPicker = false }, onConfirm = { selected -> vm.addExpensesFromPersonal(selected, description, "You") 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 3d46077..5b52709 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 @@ -18,6 +18,7 @@ import com.example.budgeting.android.data.model.Expense @Composable fun ExpensePickerDialog( expenses: List, + categoryMap: Map = emptyMap(), onDismiss: () -> Unit, onConfirm: (List) -> Unit ) { @@ -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 ) @@ -143,5 +144,4 @@ fun ExpensePickerDialog( } } } -} - +} \ No newline at end of file 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 index a4d0c9d..360a4c0 100644 --- 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 @@ -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 @@ -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 @@ -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)) + } } } } diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/GroupPieChart.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/GroupPieChart.kt new file mode 100644 index 0000000..2e1e96e --- /dev/null +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/components/group/GroupPieChart.kt @@ -0,0 +1,287 @@ +package com.example.budgeting.android.ui.components.group + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.example.budgeting.android.data.model.Expense +import com.example.budgeting.android.data.model.GroupStatistics + +data class CategoryAmount( + val categoryName: String, + val amount: Double, + val color: Color +) + +@Composable +fun GroupPieChart( + expenses: List, + categoryNameMap: Map, + modifier: Modifier = Modifier +) { + val categoryTotals = expenses + .groupBy { expense -> + categoryNameMap[expense.categoryId] ?: "Uncategorized" + } + .map { (category, expenseList) -> + CategoryAmount( + categoryName = category, + amount = expenseList.sumOf { kotlin.math.abs(it.amount) }, + color = getColorForCategory(category) + ) + } + .sortedByDescending { it.amount } + + if (categoryTotals.isEmpty()) { + Box( + modifier = modifier + .fillMaxWidth() + .then(Modifier.height(200.dp)), + contentAlignment = Alignment.Center + ) { + Text( + text = "No expenses to display", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + return + } + + val totalAmount = categoryTotals.sumOf { it.amount } + + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Pie Chart + Box( + modifier = Modifier + .width(200.dp) + .height(200.dp) + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + PieChartCanvas( + data = categoryTotals, + modifier = Modifier.fillMaxSize() + ) + } + + // Legend + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + categoryTotals.forEach { categoryAmount -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + modifier = Modifier + .width(12.dp) + .height(12.dp) + .clip(CircleShape) + .background(categoryAmount.color) + ) + Text( + text = categoryAmount.categoryName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface + ) + } + Text( + text = String.format("%.2f", categoryAmount.amount), + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } + + // Total + Text( + text = "Total: ${String.format("%.2f", totalAmount)}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 8.dp) + ) + } +} + +@Composable +fun GroupStatisticsPieChart( + statistics: GroupStatistics, + modifier: Modifier = Modifier +) { + // Map statistics to pie slices + val rawItems = listOf( + CategoryAmount("Your share", statistics.myShareOfExpenses, getColorForCategory("Your share")), + CategoryAmount("You paid", statistics.myTotalPaid, getColorForCategory("You paid")), + CategoryAmount("Net balance for others", kotlin.math.abs(statistics.netBalancePaidForOthers), getColorForCategory("Net balance for others")), + CategoryAmount("Rest of group", statistics.restOfGroupExpenses, getColorForCategory("Rest of group")) + ).filter { it.amount > 0 } + + if (rawItems.isEmpty()) { + Box( + modifier = modifier + .fillMaxWidth() + .then(Modifier.height(200.dp)), + contentAlignment = Alignment.Center + ) { + Text( + text = "No statistics to display", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + return + } + + val totalAmount = rawItems.sumOf { it.amount } + + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .width(220.dp) + .height(220.dp) + .padding(12.dp), + contentAlignment = Alignment.Center + ) { + PieChartCanvas( + data = rawItems, + modifier = Modifier.fillMaxSize() + ) + } + + // Legend + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + rawItems.forEach { categoryAmount -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + modifier = Modifier + .width(12.dp) + .height(12.dp) + .clip(CircleShape) + .background(categoryAmount.color) + ) + Text( + text = categoryAmount.categoryName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface + ) + } + Text( + text = String.format("%.2f", categoryAmount.amount), + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } + + Text( + text = "Total: ${String.format("%.2f", totalAmount)}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 8.dp) + ) + } +} + +@Composable +private fun PieChartCanvas( + data: List, + modifier: Modifier = Modifier +) { + val total = data.sumOf { it.amount } + if (total == 0.0) return + + Canvas(modifier = modifier) { + val canvasSize = size.minDimension + val radius = canvasSize / 2f + val center = Offset(size.width / 2f, size.height / 2f) + val innerRadius = radius * 0.5f // Donut chart style + + var startAngle = -90f // Start from top + + data.forEach { item -> + val sweepAngle = (item.amount / total * 360f).toFloat() + + // Draw outer arc + drawArc( + color = item.color, + startAngle = startAngle, + sweepAngle = sweepAngle, + useCenter = false, + topLeft = Offset(center.x - radius, center.y - radius), + size = Size(radius * 2f, radius * 2f), + style = Stroke(width = radius - innerRadius) + ) + + startAngle += sweepAngle + } + } +} + +private fun getColorForCategory(categoryName: String): Color { + val colors = listOf( + Color(0xFF7C3AED), + Color(0xFF6B29D9), + Color(0xFF059669), + Color(0xFFDC2626), + Color(0xFFF59E0B), + Color(0xFF0EA5E9), + Color(0xFFEF4444), + Color(0xFF8B5CF6), + Color(0xFF10B981), + Color(0xFFF97316) + ) + + val index = categoryName.hashCode().absoluteValue % colors.size + return colors[index] +} + +private val Int.absoluteValue: Int + get() = if (this < 0) -this else this + 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 4476d97..b0730b3 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 @@ -4,20 +4,26 @@ import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.draw.clip import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ExitToApp 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.text.font.FontWeight 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 import com.example.budgeting.android.ui.viewmodels.* +import java.text.NumberFormat +import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -43,19 +49,26 @@ fun GroupDetailsScreen( val isLoading by vm.isLoading.collectAsState() val error by vm.error.collectAsState() val members by vm.members.collectAsState() + val extraUsers by vm.extraUsers.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() + val statistics by vm.statistics.collectAsState() + val categories by vm.categories.collectAsState() + val categoryNameMap = remember(categories) { categories.associate { it.id to (it.title ?: "") } } var showShareDialog by remember { mutableStateOf(false) } + var showOverviewDialog by remember { mutableStateOf(false) } var selectedExpense by remember { mutableStateOf(null) } var showPaymentDialog by remember { mutableStateOf(false) } var paidUserIds by remember { mutableStateOf>(emptySet()) } var isFetchingPayments by remember { mutableStateOf(false) } + var showLeaveDialog by remember { mutableStateOf(false) } + LaunchedEffect(showShareDialog, group?.id) { val id = group?.id if (showShareDialog && id != null) { @@ -67,12 +80,14 @@ fun GroupDetailsScreen( if (selectedExpense != null) { isFetchingPayments = true val payments = vm.getExpensePayments(selectedExpense!!.expense.id!!) - + paidUserIds = payments.mapNotNull { it.user_id }.toSet() isFetchingPayments = false + showPaymentDialog = true } else { paidUserIds = emptySet() isFetchingPayments = false + showPaymentDialog = false } } @@ -92,6 +107,15 @@ fun GroupDetailsScreen( contentDescription = "Back" ) } + }, + actions = { + IconButton(onClick = { showLeaveDialog = true }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ExitToApp, + contentDescription = "Leave Group", + tint = MaterialTheme.colorScheme.error + ) + } } ) }, @@ -121,7 +145,8 @@ fun GroupDetailsScreen( GroupMetaRow( memberCount = members.size, onShareClick = { showShareDialog = true }, - invitationCodeAvailable = !currentGroup.invitationCode.isNullOrBlank() + invitationCodeAvailable = !currentGroup.invitationCode.isNullOrBlank(), + onOverviewClick = { showOverviewDialog = true } ) } @@ -161,14 +186,12 @@ fun GroupDetailsScreen( } else -> { // Filter logs: exclude creator and current user's own JOIN messages - val creatorId = members.minByOrNull { it.id }?.id - val filteredLogs = remember(logs, currentUserId, creatorId) { + val filteredLogs = remember(logs, currentUserId) { 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 + true } } @@ -230,7 +253,6 @@ fun GroupDetailsScreen( // 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 @@ -241,7 +263,10 @@ fun GroupDetailsScreen( item(key = "log_${item.log.id}") { GroupLogBubble( log = item.log, - members = members + members = remember(members, extraUsers) { + val extraList = extraUsers.values.toList() + (members + extraList).distinctBy { it.id } + } ) } } @@ -272,6 +297,54 @@ fun GroupDetailsScreen( ) } + if (showOverviewDialog && statistics != null) { + AlertDialog( + onDismissRequest = {}, + properties = DialogProperties( + dismissOnClickOutside = false, + dismissOnBackPress = false + ), + confirmButton = { + Button(onClick = { showOverviewDialog = false }) { + Text("Close") + } + }, + title = { + Text( + text = "Overview", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface + ) + }, + text = { + if (statistics != null) { + GroupStatisticsPieChart( + statistics = statistics!!, + modifier = Modifier.fillMaxWidth() + ) + } else if (expenses.isNotEmpty() && categoryNameMap.isNotEmpty()) { + // Fallback: category-based pie chart + GroupPieChart( + expenses = expenses.map { it.expense }, + categoryNameMap = categoryNameMap, + modifier = Modifier + .fillMaxWidth() + .height(360.dp) + ) + } else { + Column { + Text( + text = "No expense data available", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + }, + containerColor = MaterialTheme.colorScheme.surface + ) + } + if (showPaymentDialog && selectedExpense != null) { if (isFetchingPayments) { AlertDialog( @@ -361,4 +434,34 @@ fun GroupDetailsScreen( } } } + if (showLeaveDialog) { + AlertDialog( + onDismissRequest = { showLeaveDialog = false }, + title = { Text("Leave Group") }, + text = { Text("Are you sure you want to leave this group?") }, + confirmButton = { + TextButton( + onClick = { + showLeaveDialog = false + vm.leaveGroup(onSuccess = onBack) + }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Text("Leave") + } + }, + dismissButton = { + TextButton(onClick = { showLeaveDialog = false }) { + Text("Cancel") + } + } + ) + } +} + +private fun formatAmount(value: Double): String { + val nf = NumberFormat.getCurrencyInstance(Locale.getDefault()) + return nf.format(value) } 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 fc056c9..290e0cd 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 @@ -159,7 +159,10 @@ fun GroupsScreen(onOpenGroup: (Int) -> Unit) { if (showCreateDialog) { CreateGroupDialog( - onDismiss = { showCreateDialog = false }, + onDismiss = { + showCreateDialog = false + vm.clearError() + }, isLoading = isLoading, error = error, onCreate = { name, description -> @@ -178,7 +181,10 @@ fun GroupsScreen(onOpenGroup: (Int) -> Unit) { if (showJoinDialog) { JoinGroupDialog( - onDismiss = { showJoinDialog = false }, + onDismiss = { + showJoinDialog = false + vm.clearError() + }, isLoading = isLoading, error = error, onJoinByCode = { code -> 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 5fc0ba0..f17a851 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 @@ -108,9 +108,12 @@ class ExpenseViewModel(context: Context) : ViewModel() { if (data.size < limit) endReached = true offset += data.size + // Deduplicate expenses by id to avoid showing the same expense multiple times + val deduped = data.filter { it.id != null }.distinctBy { it.id } + data.filter { it.id == null } + _expenses.value = - if (append) _expenses.value + data - else data + if (append) (_expenses.value + deduped).distinctBy { it.id } + else deduped updateCategories(_expenses.value) @@ -156,11 +159,12 @@ class ExpenseViewModel(context: Context) : ViewModel() { ) } - return all + // Remove duplicates that may come from multiple group queries + return all.distinctBy { it.id } } private suspend fun loadAllExpenses(): List { - return loadPersonalExpenses() + loadGroupExpenses() + return (loadPersonalExpenses() + loadGroupExpenses()).distinctBy { it.id } } private fun updateCategories(expenses: List) { 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 f8f1a55..d138e55 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 @@ -10,10 +10,13 @@ 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 com.example.budgeting.android.data.repository.CategoryRepository +import com.example.budgeting.android.data.model.Category +import com.example.budgeting.android.data.network.RetrofitClient +import com.example.budgeting.android.data.repository.UserRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -37,6 +40,11 @@ class GroupDetailsViewModel(context: Context) : ViewModel() { RetrofitClient.expensePaymentInstance, tokenDataStore ) + private val userRepository = UserRepository( + RetrofitClient.userInstance, + tokenDataStore + ) + private val categoryRepository = CategoryRepository(RetrofitClient.categoryInstance) private val _group = MutableStateFlow(null) val group: StateFlow = _group.asStateFlow() @@ -65,8 +73,17 @@ class GroupDetailsViewModel(context: Context) : ViewModel() { private val _logs = MutableStateFlow>(emptyList()) val logs: StateFlow> = _logs.asStateFlow() + private val _extraUsers = MutableStateFlow>(emptyMap()) + val extraUsers: StateFlow> = _extraUsers.asStateFlow() + private val _currentUserId = MutableStateFlow(null) val currentUserId: StateFlow = _currentUserId.asStateFlow() + + private val _statistics = MutableStateFlow(null) + val statistics: StateFlow = _statistics.asStateFlow() + + private val _categories = MutableStateFlow>(emptyList()) + val categories: StateFlow> = _categories.asStateFlow() init { viewModelScope.launch { @@ -113,30 +130,79 @@ class GroupDetailsViewModel(context: Context) : ViewModel() { sortBy = "created_at", order = "asc" ) - - if (expensesResponse.isSuccessful) { - val expenses = expensesResponse.body() ?: emptyList() - val groupExpenses = mapExpensesToGroupExpenses(expenses, membersList) - _expenses.value = groupExpenses + + val expensesList = if (expensesResponse.isSuccessful) { + expensesResponse.body() ?: emptyList() } else { _error.value = "Error: ${expensesResponse.code()} - ${expensesResponse.message()}" - _expenses.value = emptyList() + emptyList() } // Load group logs (join/leave events) - try { + val logsList = try { val logsResponse = groupRepository.getGroupLogs(groupId) if (logsResponse.isSuccessful && logsResponse.body() != null) { - _logs.value = logsResponse.body()!! + logsResponse.body()!! } else { val errorMsg = "Failed to load group logs: ${logsResponse.code()}" if (_error.value == null) { _error.value = errorMsg } - _logs.value = emptyList() + emptyList() + } + } catch (e: Exception) { + emptyList() + } + + val memberIds = membersList.map { it.id }.toSet() + val referencedUserIds = mutableSetOf() + expensesList.mapNotNullTo(referencedUserIds) { it.user_id } + logsList.mapTo(referencedUserIds) { it.user_id } + val missingUserIds = referencedUserIds.filter { !memberIds.contains(it) } + + val fetchedUsers = mutableMapOf() + if (missingUserIds.isNotEmpty()) { + for (uid in missingUserIds) { + try { + val userResp = userRepository.getUserById(uid) + val userData = UserData( + id = userResp.id, + firstName = userResp.first_name, + lastName = userResp.last_name, + email = userResp.email, + phoneNumber = userResp.phone_number, + budget = userResp.budget + ) + fetchedUsers[uid] = userData + } catch (e: Exception) { + } + } + } + + if (fetchedUsers.isNotEmpty()) { + _extraUsers.value = _extraUsers.value + fetchedUsers + } + + val mergedMembers = (membersList + _extraUsers.value.values).distinctBy { it.id } + + val groupExpenses = mapExpensesToGroupExpenses(expensesList, mergedMembers) + _expenses.value = groupExpenses + _logs.value = logsList + + // Load group statistics + try { + val statsResponse = groupRepository.getGroupStatistics(groupId) + if (statsResponse.isSuccessful && statsResponse.body() != null) { + _statistics.value = statsResponse.body()!! } } catch (e: Exception) { - _logs.value = emptyList() + } + + // Load categories for pie chart + try { + _categories.value = categoryRepository.getCategories(null, null) + } catch (e: Exception) { + _categories.value = emptyList() } } catch (e: Exception) { @@ -369,4 +435,31 @@ class GroupDetailsViewModel(context: Context) : ViewModel() { false } } -} + + fun leaveGroup(onSuccess: () -> Unit) { + val groupId = _group.value?.id + val userId = _currentUserId.value + + if (groupId == null || userId == null) { + _error.value = "Cannot leave group: Data missing" + return + } + + viewModelScope.launch { + _isLoading.value = true + try { + ensureTokenLoaded() + val response = groupRepository.removeUserFromGroup(groupId, userId) + if (response.isSuccessful) { + onSuccess() + } else { + _error.value = "Failed to leave group: ${response.code()}" + } + } catch (e: Exception) { + _error.value = "Error: ${e.message}" + } finally { + _isLoading.value = false + } + } + } +} \ No newline at end of file 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 096eab0..8857625 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 @@ -371,5 +371,9 @@ class GroupsViewModel(context: Context) : ViewModel() { } } } + + fun clearError() { + _error.value = null + } } diff --git a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ReceiptViewModel.kt b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ReceiptViewModel.kt index c9b6733..d1d62fd 100644 --- a/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ReceiptViewModel.kt +++ b/Mobile/androidApp/src/main/java/com/example/budgeting/android/ui/viewmodels/ReceiptViewModel.kt @@ -72,8 +72,6 @@ class ReceiptViewModel(application: Application) : AndroidViewModel(application) val part = MultipartBody.Part.createFormData("image", file.name, reqBody) val response = receiptApi.processReceipt(part) - Log.d("ReceiptViewModel", "Response: $response") - Log.d("ReceiptViewModel", "Response: ${response.toString()}") if (response.isSuccessful && response.body() != null) { _scannedItems.value = response.body()!!.data!!.items diff --git a/Mobile/androidApp/src/main/res/drawable/ic_launcher_monochrome.xml b/Mobile/androidApp/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000..d50a3b0 --- /dev/null +++ b/Mobile/androidApp/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Mobile/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Mobile/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..081998b --- /dev/null +++ b/Mobile/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Mobile/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/Mobile/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..081998b --- /dev/null +++ b/Mobile/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Mobile/androidApp/src/main/res/mipmap-hdpi/ic_launcher.webp b/Mobile/androidApp/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..b02cb99 Binary files /dev/null and b/Mobile/androidApp/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/Mobile/androidApp/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/Mobile/androidApp/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..b02d05f Binary files /dev/null and b/Mobile/androidApp/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/Mobile/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/Mobile/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b4b94b4 Binary files /dev/null and b/Mobile/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/Mobile/androidApp/src/main/res/mipmap-mdpi/ic_launcher.webp b/Mobile/androidApp/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..e34c6c2 Binary files /dev/null and b/Mobile/androidApp/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/Mobile/androidApp/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/Mobile/androidApp/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..df432d6 Binary files /dev/null and b/Mobile/androidApp/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/Mobile/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/Mobile/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9d3bdc6 Binary files /dev/null and b/Mobile/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/Mobile/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.webp b/Mobile/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..f9dd60f Binary files /dev/null and b/Mobile/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/Mobile/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/Mobile/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..ad8389a Binary files /dev/null and b/Mobile/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/Mobile/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/Mobile/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1a6f8da Binary files /dev/null and b/Mobile/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/Mobile/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/Mobile/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..f7a7b6b Binary files /dev/null and b/Mobile/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/Mobile/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/Mobile/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..4f38186 Binary files /dev/null and b/Mobile/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/Mobile/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/Mobile/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..5555ce7 Binary files /dev/null and b/Mobile/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/Mobile/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/Mobile/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..9e7445b Binary files /dev/null and b/Mobile/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/Mobile/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/Mobile/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..91e8230 Binary files /dev/null and b/Mobile/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/Mobile/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/Mobile/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..f80aca2 Binary files /dev/null and b/Mobile/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/Mobile/androidApp/src/main/res/values/ic_launcher_background.xml b/Mobile/androidApp/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..d68acd9 --- /dev/null +++ b/Mobile/androidApp/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #173655 + \ No newline at end of file diff --git a/Mobile/androidApp/src/main/res/values/strings.xml b/Mobile/androidApp/src/main/res/values/strings.xml index 8dd30f5..27a5298 100644 --- a/Mobile/androidApp/src/main/res/values/strings.xml +++ b/Mobile/androidApp/src/main/res/values/strings.xml @@ -1,5 +1,5 @@ - BugetingApp + Expense Manager Username Login \ No newline at end of file