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 3 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
109 changes: 106 additions & 3 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 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(key1 = true) {
listGroups.collect() {
viewModel.getGroups()
}
}

Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center ,
verticalArrangement = Arrangement.Top ,
modifier = Modifier.fillMaxSize()
) {
ProvideTextStyle(MaterialTheme.typography.headlineMedium){
Text("Seus grupos")
}
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 = { Text("Sem grupos") },
Copy link
Member

Choose a reason for hiding this comment

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

eu acho uma boa já colocar esses textos nos strings resources, porque assim evitamos que por algum vacilo a gente esqueça de remover um deles

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Foi

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Foi

description = { Text("Tente criar ou entrar em algum grupo.") }
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
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.GroupEntry
import br.com.mob1st.bet.features.groups.domain.GroupRepository
import br.com.mob1st.bet.features.profile.domain.UserRepository
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 repository: GroupRepository,
private val userRepository: UserRepository
Copy link
Member

Choose a reason for hiding this comment

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

lá vem textão:
na medida em que as regras de negócio na tela de grupo forem crescendo, talvez mais repositórios sejam necessários.
não é uma boa implementar regras de negócio no ViewModel, pq ele é uma classe que serve pra outras finalidades, como gerenciar o estado da UI.

Portanto, talvez seja melhor criar uma class UseCase, tipo GetGroupsUseCase, e fazer com que esse usecase use os repositórios pra implementar as regras de negócio.
A vantagem disso é que tiramos as regras de negócio de uma classe que interage com uma biblioteca externa, que é caso do ViewModel.

é um detalhe besta em um primeiro momento, mas essas pequenas decisões tem um peso grande conforme o projeto vai crescendo.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Pode crer, faz sentido. Como essa tela não ia ter useCase por enquanto acabei fazendo tudo no viewModel mesmo, mas vou me atentar mais nisso nas próximas.

) : 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 = repository.getGroups(userRepository.get())
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 getGroupsByUserId(founder: User) : List<GroupEntry> {
Copy link
Member

Choose a reason for hiding this comment

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

alerta de dica irrelevante:
como esse já é o GroupCollection, acho que da pra tirar o Groups do nome desse método, assim evita duplicação de palavras

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Foi

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.getGroupsByUserId(founder)
}.getOrElse {
throw GetGroupsListException(founder.id, it)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,14 @@ class CreateGroupUseCase(
): GroupEntry {
val user = userRepository.get()

if (user.authType == Anonymous) {
throw NotAuthorizedForItException()
}
// if (user.authType == Anonymous) {
Copy link
Member

Choose a reason for hiding this comment

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

pode deletar o código

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Foi

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Foi

// 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 +48,7 @@ class CreateGroupUseCase(
competitionEntry = competitionEntry
)
)

return groupEntry
}

Expand All @@ -58,7 +60,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 +70,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
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,14 @@ class CreateGroupException(
override fun logProperties(): Map<String, Any> {
return (groupEntry to competitionEntry).toLogMap()
}
}

//Faz sentido essa Exception que eu criei?
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.

private val id: String,
cause: Throwable
) : Exception("Unable to get groups from user id $id", cause), Debuggable {
override fun logProperties(): Map<String, Any?> {
return mapOf("UserId" to id)
}
}
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