Skip to content

Feature/Show groups list #45

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
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
111 changes: 107 additions & 4 deletions app/src/main/java/br/com/mob1st/bet/features/groups/GroupTabScreen.kt
Original file line number Diff line number Diff line change
@@ -1,30 +1,133 @@
package br.com.mob1st.bet.features.groups

import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import arrow.optics.Fold.Companion.string
import br.com.mob1st.bet.R
import br.com.mob1st.bet.core.ui.compose.LocalLazyListState
import br.com.mob1st.bet.core.ui.compose.LocalSnackbarState
import br.com.mob1st.bet.core.ui.ds.molecule.AddButton
import br.com.mob1st.bet.core.ui.ds.molecule.RetrySnackbar
import br.com.mob1st.bet.core.ui.ds.organisms.FetchedCrossfade
import br.com.mob1st.bet.core.ui.ds.organisms.GroupRow
import br.com.mob1st.bet.core.ui.ds.page.DefaultErrorPage
import br.com.mob1st.bet.core.ui.ds.templates.InfoTemplate
import br.com.mob1st.bet.core.ui.state.AsyncState
import br.com.mob1st.bet.core.ui.state.SimpleMessage
import br.com.mob1st.bet.core.utils.extensions.ifNotEmpty

@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
fun GroupsTabScreen(
viewModel: GroupTabViewModel,
onNavigateToCreateGroups: () -> Unit,
onNavigateToGroupDetails: () -> Unit
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val listGroups = viewModel.groups

LaunchedEffect(listGroups) {
viewModel.getGroups()
}

Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center ,
verticalArrangement = Arrangement.Top ,
modifier = Modifier.fillMaxSize()
) {
ProvideTextStyle(MaterialTheme.typography.headlineMedium){
Text("Seus grupos")
Text(stringResource(id = R.string.group_title))
}
AddButton(onNavigateToCreateGroups)
GroupRow(groupName = "Primeiro grupo", currentMembersNumber = 1, maxMembers = 25, onNavigateToGroupDetails)
GroupRow(groupName = "Segundo grupo", currentMembersNumber = 3, maxMembers = 25, onNavigateToGroupDetails)

GroupsPage(
state = state,
onTryAgain = { viewModel.fromUi(GroupsUIEvent.TryAgain(it)) } ,
onDismiss = { viewModel.messageShown(it) }
)
}
}

@Composable
fun GroupsPage(
state: AsyncState<GroupsListData>,
onTryAgain: (SimpleMessage) -> Unit,
onDismiss: (SimpleMessage) -> Unit
) {
Box(
contentAlignment = Alignment.Center
) {
FetchedCrossfade(
state = state,
emptyError = {_, message -> DefaultErrorPage(message, onTryAgain)},
emptyLoading = { EmptyLoading() },
empty = { GroupsEmptyData() },
data = {
GroupsData(
state = it,
onTryAgain = onTryAgain,
onDismiss = onDismiss
)
}
)
}
}

@Composable
fun GroupsData(
state: AsyncState<GroupsListData>,
onTryAgain: (SimpleMessage) -> Unit,
onDismiss: (SimpleMessage) -> Unit
) {
state.messages.ifNotEmpty {
RetrySnackbar(
snackbarHostState = LocalSnackbarState.current,
message = stringResource(id = it.descriptionResId),
onDismiss = { onDismiss(it) },
onRetry = { onTryAgain(it) }
)
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = LocalLazyListState.current
) {
itemsIndexed(
state.data.groupList,
key = { _, item -> item.id },
) { _, item ->
GroupRow(groupName = item.name, currentMembersNumber = 4, maxMembers = 4) {}
}
}
}

@Composable
fun EmptyLoading() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}

@Composable
fun GroupsEmptyData() {
InfoTemplate(
icon = { Icon(imageVector = Icons.Default.Person, contentDescription = "groups tab") },
title = { stringResource(id = R.string.group_empty_list_title ) },
description = { stringResource(id = R.string.group_empty_list_description) }
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package br.com.mob1st.bet.features.groups

import br.com.mob1st.bet.core.ui.state.FetchedData
import br.com.mob1st.bet.core.ui.state.SimpleMessage
import br.com.mob1st.bet.core.ui.state.StateViewModel
import br.com.mob1st.bet.features.groups.domain.GetGroupsUseCase
import br.com.mob1st.bet.features.groups.domain.GroupEntry
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.koin.android.annotation.KoinViewModel

data class GroupsListData(
val groupList: List<GroupEntry> = emptyList()
) : FetchedData {
override fun hasData(): Boolean = groupList.isNotEmpty()
}

sealed class GroupsUIEvent {
data class TryAgain(val message: SimpleMessage) : GroupsUIEvent()
}

@KoinViewModel
class GroupTabViewModel(
private val getGroupsUseCase: GetGroupsUseCase
) : StateViewModel<GroupsListData, GroupsUIEvent>(GroupsListData(), loading = false) {
override fun fromUi(uiEvent: GroupsUIEvent) {
when (uiEvent) {
is GroupsUIEvent.TryAgain -> tryAgain(uiEvent.message)
}
}

private val _groups = MutableStateFlow<List<GroupEntry>>(emptyList())
val groups: StateFlow<List<GroupEntry>> = _groups

init {
getGroups()
}

private fun tryAgain(message: SimpleMessage) {
messageShown(message, loading = true)
getGroups()
}

fun getGroups() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tem algum motivo pra essa função ser pública?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sim, ele é usado no GroupTabScreen, pra quando eu adicionar um novo grupo a tela ja mostrar a lista atualizada, ai chama essa função de novo.

setAsync {
val listGroups = getGroupsUseCase()
logger.d("fetch ${listGroups.size} groups")
_groups.value = listGroups
it.data(data = it.data.copy(groupList = listGroups))
}
}
}


Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package br.com.mob1st.bet.features.groups.data

import br.com.mob1st.bet.core.firebase.awaitWithTimeout
import br.com.mob1st.bet.core.firebase.getStringNotNull
import br.com.mob1st.bet.features.competitions.data.competitions
import br.com.mob1st.bet.features.groups.domain.Group
import br.com.mob1st.bet.features.groups.domain.GroupEntry
import br.com.mob1st.bet.features.profile.data.memberships
import br.com.mob1st.bet.features.profile.data.users
import br.com.mob1st.bet.features.profile.domain.User
Expand Down Expand Up @@ -43,7 +45,6 @@ class GroupCollection(
"ref" to firestore.users.document(founder.id),
"name" to founder.name,
"image" to founder.imageUrl.orEmpty(),

"points" to 0L
)
)
Expand All @@ -61,9 +62,20 @@ class GroupCollection(
batch.commit().awaitWithTimeout()
}

suspend fun getByUserId(founder: User) : List<GroupEntry> {
val groups = firestore.memberships(founder.id).get().awaitWithTimeout()
return groups.map { doc ->
GroupEntry(
id = doc.id,
name = doc.getStringNotNull(GroupEntry::name.name),
imageUrl = doc.getString(GroupEntry::imageUrl.name)
)
}
}
}

val FirebaseFirestore.groups get() =
collection("groups")

fun FirebaseFirestore.members(groupId: String) =
groups.document(groupId).collection("members")
groups.document(groupId).collection("members")
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
package br.com.mob1st.bet.features.groups.data

/*
Tu pode ver que nos imports não tem nada referenciando o firebase, porque o GroupCollection isolou
ele do projeto
*/
import br.com.mob1st.bet.core.coroutines.DispatcherProvider
import br.com.mob1st.bet.core.utils.functions.suspendRunCatching
import br.com.mob1st.bet.features.competitions.domain.CompetitionEntry
import br.com.mob1st.bet.features.groups.domain.CreateGroupException
import br.com.mob1st.bet.features.groups.domain.GetGroupsListException
import br.com.mob1st.bet.features.groups.domain.Group
import br.com.mob1st.bet.features.groups.domain.GroupEntry
import br.com.mob1st.bet.features.groups.domain.GroupRepository
Expand All @@ -33,9 +30,9 @@ class GroupRepositoryImpl(
name = name,
competition = competitionEntry,
description = "",
// the founder is the first member in the group
memberCount = 1
)

val entry = group.toEntry()
suspendRunCatching {
groupCollection.create(founder, group).let { entry }
Expand All @@ -47,4 +44,14 @@ class GroupRepositoryImpl(
)
}
}

override suspend fun getGroups(
founder: User
): List<GroupEntry> = withContext(io) {
suspendRunCatching {
groupCollection.getByUserId(founder)
}.getOrElse {
throw GetGroupsListException(it)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,10 @@ class CreateGroupUseCase(
): GroupEntry {
val user = userRepository.get()

if (user.authType == Anonymous) {
throw NotAuthorizedForItException()
}

if (user.membershipCount >= MEMBERSHIP_LIMIT) {
throw MembershipLimitException(user.membershipCount)
}

val competitionEntry = competitionRepository.getDefaultCompetition().toEntry()
val groupEntry = groupRepository.create(
founder = user,
Expand All @@ -47,6 +44,7 @@ class CreateGroupUseCase(
competitionEntry = competitionEntry
)
)

return groupEntry
}

Expand All @@ -58,7 +56,8 @@ class CreateGroupUseCase(

class MembershipLimitException(
private val currentCount: Int
) : Exception("a user can't have more then $MEMBERSHIP_LIMIT memberships. Your current memberships is $currentCount"), Debuggable {
) : Exception("a user can't have more then $MEMBERSHIP_LIMIT memberships. Your current memberships is $currentCount"),
Debuggable {
override fun logProperties(): Map<String, Any> {
return mapOf(
"currentMemberships" to currentCount,
Expand All @@ -67,4 +66,5 @@ class MembershipLimitException(
}
}

class NotAuthorizedForItException : Exception("The user have to be logged in with some provider to be able to create and join groups")
class NotAuthorizedForItException :
Exception("The user have to be logged in with some provider to be able to create and join groups")
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package br.com.mob1st.bet.features.groups.domain

import br.com.mob1st.bet.features.profile.domain.UserRepository
import org.koin.core.annotation.Factory

@Factory
class GetGroupsUseCase(
private val repository: GroupRepository,
private val userRepository: UserRepository
) {

suspend operator fun invoke(): List<GroupEntry> {
return repository.getGroups(userRepository.get())
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ interface GroupRepository {
competitionEntry: CompetitionEntry
) : GroupEntry

suspend fun getGroups(founder: User) : List<GroupEntry>
}

class CreateGroupException(
Expand All @@ -32,5 +33,12 @@ class CreateGroupException(
override fun logProperties(): Map<String, Any> {
return (groupEntry to competitionEntry).toLogMap()
}
}

class GetGroupsListException(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

faz sim. se essa request pro firebase falhar em produção vai ser bem fácil ver ela no crashlytics.
A única coisa que vc nao precisava enviar é o userId no logo, pq eu fiz uma magia pra sempre colocar o userId em todas as exceptions que logamos.

cause: Throwable
) : Exception("Unable to get groups from user", cause), Debuggable {
override fun logProperties(): Map<String, Any?> {
return mapOf()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,7 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import br.com.mob1st.bet.core.ui.ds.molecule.AddButton
import br.com.mob1st.bet.features.groups.presentation.createGroup.CreateGroupViewModel
import br.com.mob1st.bet.features.groups.presentation.createGroup.GroupsUIEvent
import org.koin.androidx.compose.koinViewModel

@OptIn(ExperimentalLifecycleComposeApi::class, ExperimentalMaterial3Api::class)
Expand All @@ -33,7 +30,6 @@ fun CreateGroupScreen(
onCreateGroupAction: () -> Unit
) {
val viewModel = koinViewModel<CreateGroupViewModel>()
val state by viewModel.uiState.collectAsStateWithLifecycle()

var groupName by remember {
mutableStateOf("")
Expand All @@ -57,7 +53,7 @@ fun CreateGroupScreen(
)
AddButton(
onAction = {
viewModel.fromUi(GroupsUIEvent.CreateGroup(groupName))
viewModel.fromUi(CreateGroupUiEvent.CreateGroup(groupName))
onCreateGroupAction()
})
}
Expand Down
Loading