Overview
GroupRepository handles all group-related data operations including creating groups, fetching group lists, updating group details, and deleting groups.
Interface: backend/GroupRepository.kt:12
Implementation: backend/SupabaseGroupRepository.kt:45
Interface Definition
interface GroupRepository {
fun listGroups(): Flow<DataResult<List<Group>>>
fun getGroup(groupId: String): Flow<Group>
suspend fun createGroup(name: String, icon: GroupIcon): Group
suspend fun updateGroup(groupId: String, name: String, icon: GroupIcon)
suspend fun deleteGroup(groupId: String)
suspend fun refreshGroups()
}
Implementation
SupabaseGroupRepository
@Singleton
class SupabaseGroupRepository @Inject constructor(
private val supabaseClient: SupabaseClient
) : GroupRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val _groups = MutableStateFlow<DataResult<List<Group>>>(DataResult.Loading)
init {
scope.launch { refreshGroups() }
}
}
The repository maintains an internal state flow that’s initialized to Loading and automatically refreshes on creation.
Methods
listGroups
Returns a reactive Flow of all groups for the current user.
fun listGroups(): Flow<DataResult<List<Group>>>
Returns: Flow<DataResult<List<Group>>> - Stream of group list with loading/error states
Example Usage:
@HiltViewModel
class GroupsViewModel @Inject constructor(
private val groupRepository: GroupRepository
) : ViewModel() {
val groups = groupRepository.listGroups()
.stateIn(viewModelScope, SharingStarted.Lazily, DataResult.Loading)
}
Implementation:
override fun listGroups(): Flow<DataResult<List<Group>>> = _groups
getGroup
Returns a Flow for a single group by ID.
fun getGroup(groupId: String): Flow<Group>
The unique identifier of the group to retrieve
Returns: Flow<Group> - Stream of the requested group (returns placeholder if not found)
Example Usage:
val group = groupRepository.getGroup("group-123")
.collectAsState(initial = Group(id = "group-123", name = "Loading..."))
Implementation:
override fun getGroup(groupId: String): Flow<Group> =
_groups.map { result ->
when (result) {
is DataResult.Success -> result.data.find { it.id == groupId }
?: Group(id = groupId, name = "Group")
else -> Group(id = groupId, name = "Group")
}
}
createGroup
Creates a new group with the current user as owner.
suspend fun createGroup(name: String, icon: GroupIcon): Group
The display name for the new group
The icon to display for the group (e.g., GroupIcon.Home, GroupIcon.Flight)
Returns: Group - The newly created group object
Throws: Exception if Supabase call fails
Example Usage:
viewModelScope.launch {
try {
val group = groupRepository.createGroup(
name = "Weekend Trip",
icon = GroupIcon.Flight
)
println("Created group: ${group.id}")
} catch (e: Exception) {
showError("Failed to create group: ${e.message}")
}
}
Supabase Implementation:
override suspend fun createGroup(name: String, icon: GroupIcon): Group {
val params = buildJsonObject {
put("p_name", name)
put("p_icon", icon.name)
}
val row = supabaseClient.postgrest
.rpc("create_group_with_owner", params)
.decodeSingle<GroupRow>()
val group = Group(
id = row.id,
name = row.name,
icon = iconFromName(row.icon),
memberCount = 1,
balanceCents = 0L,
createdBy = row.createdBy
)
// Update local state
_groups.update { result ->
val current = (result as? DataResult.Success)?.data ?: emptyList()
DataResult.Success(current + group)
}
return group
}
RPC Function: create_group_with_owner - Creates the group and adds the current user as a member
updateGroup
Updates an existing group’s name and icon.
suspend fun updateGroup(groupId: String, name: String, icon: GroupIcon)
The ID of the group to update
The new name for the group
The new icon for the group
Example Usage:
viewModelScope.launch {
groupRepository.updateGroup(
groupId = "group-123",
name = "Summer Vacation",
icon = GroupIcon.Beach
)
}
Supabase Implementation:
override suspend fun updateGroup(groupId: String, name: String, icon: GroupIcon) {
supabaseClient.from("groups").update({
set("name", name)
set("icon", icon.name)
}) {
filter { eq("id", groupId) }
}
// Update local state
_groups.update { result ->
val current = (result as? DataResult.Success)?.data ?: return@update result
DataResult.Success(current.map { g ->
if (g.id == groupId) g.copy(name = name, icon = icon) else g
})
}
}
deleteGroup
Deletes a group and all associated data (expenses, splits, members).
suspend fun deleteGroup(groupId: String)
The ID of the group to delete
Warning: This operation is irreversible and cascades to all related data.
Example Usage:
viewModelScope.launch {
try {
groupRepository.deleteGroup("group-123")
navigateBack()
} catch (e: Exception) {
showError("Failed to delete group: ${e.message}")
}
}
Supabase Implementation:
override suspend fun deleteGroup(groupId: String) {
val params = buildJsonObject { put("p_group_id", groupId) }
supabaseClient.postgrest.rpc("delete_group_cascade", params)
// Update local state
_groups.update { result ->
val current = (result as? DataResult.Success)?.data ?: return@update result
DataResult.Success(current.filter { g -> g.id != groupId })
}
}
RPC Function: delete_group_cascade - Deletes the group and all related records
refreshGroups
Forces a refresh of the groups list from Supabase.
suspend fun refreshGroups()
Example Usage:
viewModelScope.launch {
groupRepository.refreshGroups()
}
Supabase Implementation:
override suspend fun refreshGroups() {
try {
val rows = supabaseClient.postgrest
.rpc("get_my_groups_summary")
.decodeList<GroupSummaryRow>()
_groups.value = DataResult.Success(rows.map { row ->
Group(
id = row.id,
name = row.name,
icon = iconFromName(row.icon),
memberCount = row.memberCount.toInt(),
balanceCents = row.balanceCents,
createdBy = row.createdBy
)
})
} catch (e: Exception) {
_groups.value = DataResult.Error("Failed to load groups", e)
}
}
RPC Function: get_my_groups_summary - Returns groups with member counts and balance calculations
Data Models
Group
Source: models/Group.kt:7
@Serializable
data class Group(
val id: String,
val name: String,
val emoji: String = "",
val icon: GroupIcon = GroupIcon.Group,
val memberCount: Int = 0,
val balanceCents: Long = 0L,
val currency: String = "USD",
val createdBy: String = ""
) {
val isOwed: Boolean get() = balanceCents >= 0
val formattedBalance: String
val balanceLabel: String
}
Internal Models
@Serializable
private data class GroupSummaryRow(
val id: String = "",
val name: String = "",
val icon: String = "",
@SerialName("created_by") val createdBy: String = "",
@SerialName("created_at") val createdAt: String = "",
@SerialName("member_count") val memberCount: Long = 0L,
@SerialName("balance_cents") val balanceCents: Long = 0L
)
@Serializable
private data class GroupRow(
val id: String = "",
val name: String = "",
val icon: String = "",
@SerialName("created_by") val createdBy: String = "",
@SerialName("created_at") val createdAt: String = ""
)
Error Handling
Error States
The repository wraps all Supabase operations in try-catch blocks and updates the state flow:
try {
val data = fetchFromSupabase()
_groups.value = DataResult.Success(data)
} catch (e: Exception) {
_groups.value = DataResult.Error("Failed to load groups", e)
}
Common Errors
- Network errors: Connection timeout, no internet
- Authentication errors: Invalid session, expired token
- Database errors: Constraint violations, RLS policy failures
See Also