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