Skip to content

Commit

Permalink
Merge pull request #68 from akiomik/followers-list
Browse files Browse the repository at this point in the history
Followers list
  • Loading branch information
akiomik authored Mar 22, 2023
2 parents 24b1ba7 + e460b4b commit 3adf038
Show file tree
Hide file tree
Showing 11 changed files with 274 additions and 8 deletions.
10 changes: 10 additions & 0 deletions app/src/main/java/io/github/akiomik/seiun/api/AtpService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import io.github.akiomik.seiun.model.app.bsky.feed.SetVoteInput
import io.github.akiomik.seiun.model.app.bsky.feed.SetVoteOutput
import io.github.akiomik.seiun.model.app.bsky.feed.Timeline
import io.github.akiomik.seiun.model.app.bsky.graph.Follow
import io.github.akiomik.seiun.model.app.bsky.graph.Followers
import io.github.akiomik.seiun.model.app.bsky.graph.Follows
import io.github.akiomik.seiun.model.app.bsky.graph.MuteInput
import io.github.akiomik.seiun.model.app.bsky.graph.UnmuteInput
Expand Down Expand Up @@ -183,6 +184,15 @@ interface AtpService {
@Query("before") before: String? = null
): ApiResult<Follows, AtpError>

@DecodeErrorBody
@GET("app.bsky.graph.getFollowers")
suspend fun getFollowers(
@Header("Authorization") authorization: String,
@Query("user") user: String,
@Query("limit") limit: Int? = null,
@Query("before") before: String? = null
): ApiResult<Followers, AtpError>

@DecodeErrorBody
@POST("com.atproto.repo.createRecord")
suspend fun follow(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package io.github.akiomik.seiun.model.app.bsky.graph

import com.squareup.moshi.JsonClass
import io.github.akiomik.seiun.model.app.bsky.actor.RefWithInfo

@JsonClass(generateAdapter = true)
data class Followers(
val subject: RefWithInfo,
val followers: List<RefWithInfo>,
val cursor: String? = null
)
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import io.github.akiomik.seiun.model.app.bsky.actor.Ref
import io.github.akiomik.seiun.model.app.bsky.actor.UpdateProfileInput
import io.github.akiomik.seiun.model.app.bsky.actor.UpdateProfileOutput
import io.github.akiomik.seiun.model.app.bsky.graph.Follow
import io.github.akiomik.seiun.model.app.bsky.graph.Followers
import io.github.akiomik.seiun.model.app.bsky.graph.Follows
import io.github.akiomik.seiun.model.app.bsky.graph.MuteInput
import io.github.akiomik.seiun.model.app.bsky.graph.UnmuteInput
Expand Down Expand Up @@ -62,6 +63,18 @@ class UserRepository(private val authRepository: AuthRepository) : ApplicationRe
}
}

suspend fun getFollowers(did: String, before: String? = null): Followers {
Log.d(SeiunApplication.TAG, "Get followers: did = $did, before = $before")

return RequestHelper.executeWithRetry(authRepository) {
getAtpClient().getFollowers(
authorization = "Bearer ${it.accessJwt}",
user = did,
before = before
)
}
}

suspend fun follow(did: String, declRef: DeclRef): CreateRecordOutput {
Log.d(SeiunApplication.TAG, "Follow $did")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import io.github.akiomik.seiun.ui.registration.RegistrationScreen
import io.github.akiomik.seiun.ui.timeline.TimelineScreen
import io.github.akiomik.seiun.ui.user.UserScreen
import io.github.akiomik.seiun.viewmodels.AppViewModel
import io.github.akiomik.seiun.viewmodels.FollowersViewModel
import io.github.akiomik.seiun.viewmodels.FollowsViewModel

@Composable
Expand Down Expand Up @@ -114,6 +115,7 @@ fun AppNavigation(
UserScreen(
did,
followsViewModel = FollowsViewModel(did),
followersViewModel = FollowersViewModel(did),
onProfileClick = { navController.navigate("user/$it") }
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package io.github.akiomik.seiun.ui.user

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Divider
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import io.github.akiomik.seiun.R
import io.github.akiomik.seiun.model.app.bsky.actor.RefWithInfo
import io.github.akiomik.seiun.ui.dialog.FullScreenDialog
import io.github.akiomik.seiun.viewmodels.FollowersViewModel

@Composable
private fun Avatar(user: RefWithInfo, onClicked: (String) -> Unit) {
AsyncImage(
model = user.avatar,
contentDescription = null,
modifier = Modifier
.width(56.dp)
.height(56.dp)
.clip(CircleShape)
.clickable { onClicked(user.did) }
)
}

@Composable
private fun FollowersListItem(user: RefWithInfo, onProfileClick: (String) -> Unit) {
ListItem(
leadingContent = { Avatar(user = user, onClicked = onProfileClick) },
headlineContent = { Text(text = user.displayName ?: "@${user.handle}") },
supportingContent = {
Text(
text = "@${user.handle}",
color = Color.Gray,
style = MaterialTheme.typography.labelMedium
)
},
trailingContent = {
// TODO
// if (user.viewer?.following == null) {
// Button(onClick = { /*TODO*/ }) {
// Text(stringResource(R.string.follow))
// }
// } else {
// Button(onClick = { /*TODO*/ }) {
// Text(stringResource(R.string.unfollow))
// }
// }
}
)
}

@Composable
private fun FollowersListContent(
followers: List<RefWithInfo>,
viewModel: FollowersViewModel,
onProfileClick: (String) -> Unit
) {
val seenAllFollowers by viewModel.seenAllFollowers.collectAsState()

LazyColumn {
items(followers) {
FollowersListItem(user = it, onProfileClick = onProfileClick)
Divider(color = Color.Gray)
}

if (followers.isEmpty()) {
item { Text(stringResource(R.string.followers_no_followers_yet)) }
} else if (!seenAllFollowers) {
item { FollowersLoadingIndicator(viewModel) }
}
}
}

// TODO: Implement as Screen
@Composable
fun FollowersListModal(
viewModel: FollowersViewModel,
onClose: () -> Unit,
onProfileClick: (String) -> Unit
) {
val state by viewModel.state.collectAsState()
val followers by viewModel.followers.collectAsState()

FullScreenDialog(onClose = onClose, actions = {}) {
when (state) {
is FollowersViewModel.State.Loading -> {
CircularProgressIndicator()
}
is FollowersViewModel.State.Loaded -> {
FollowersListContent(followers, viewModel, onProfileClick)
}
is FollowersViewModel.State.Error -> {
Text((state as FollowersViewModel.State.Error).error.toString())
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package io.github.akiomik.seiun.ui.user

import android.widget.Toast
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import io.github.akiomik.seiun.viewmodels.FollowersViewModel

@Composable
fun FollowersLoadingIndicator(viewModel: FollowersViewModel) {
val context = LocalContext.current

Box(
modifier = Modifier
.fillMaxSize()
.padding(8.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}

LaunchedEffect(key1 = true) {
viewModel.loadMoreFollowers(onError = {
Toast.makeText(context, it.toString(), Toast.LENGTH_LONG).show()
})
}
}
35 changes: 29 additions & 6 deletions app/src/main/java/io/github/akiomik/seiun/ui/user/UserScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import io.github.akiomik.seiun.R
import io.github.akiomik.seiun.model.app.bsky.actor.ProfileDetail
import io.github.akiomik.seiun.ui.theme.Indigo800
import io.github.akiomik.seiun.viewmodels.AppViewModel
import io.github.akiomik.seiun.viewmodels.FollowersViewModel
import io.github.akiomik.seiun.viewmodels.FollowsViewModel
import io.github.akiomik.seiun.viewmodels.UserFeedViewModel
import me.onebone.toolbar.CollapsingToolbarScaffold
Expand Down Expand Up @@ -101,31 +102,44 @@ private fun NameAndHandle(profile: ProfileDetail) {
private fun StatRow(
profile: ProfileDetail,
followsViewModel: FollowsViewModel,
followersViewModel: FollowersViewModel,
onProfileClick: (String) -> Unit
) {
var showFollowsList by remember { mutableStateOf(false) }
var showFollowersList by remember { mutableStateOf(false) }

if (showFollowsList) {
FollowsListModal(
viewModel = followsViewModel,
onProfileClick = onProfileClick,
onClose = { showFollowsList = false }
)
} else if (showFollowersList) {
FollowersListModal(
viewModel = followersViewModel,
onProfileClick = onProfileClick,
onClose = { showFollowersList = false }
)
}

Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { showFollowsList = true }
) {
Text(
text = profile.followsCount.toString(),
fontWeight = FontWeight.Bold
)
Text(
text = stringResource(R.string.follows),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.clickable { showFollowsList = true }
style = MaterialTheme.typography.labelMedium
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { showFollowersList = true }
) {
Text(
text = profile.followersCount.toString(),
fontWeight = FontWeight.Bold
Expand Down Expand Up @@ -178,6 +192,7 @@ private fun FollowOrUnfollowButton(profile: ProfileDetail) {
private fun Profile(
profile: ProfileDetail,
followsViewModel: FollowsViewModel,
followersViewModel: FollowersViewModel,
onProfileClick: (String) -> Unit
) {
val viewModel: AppViewModel = viewModel()
Expand Down Expand Up @@ -213,6 +228,7 @@ private fun Profile(
StatRow(
profile = profile,
followsViewModel = followsViewModel,
followersViewModel = followersViewModel,
onProfileClick = onProfileClick
)
}
Expand All @@ -222,6 +238,7 @@ private fun Profile(
private fun UserModalContent(
profile: ProfileDetail,
followsViewModel: FollowsViewModel,
followersViewModel: FollowersViewModel,
onProfileClick: (String) -> Unit
) {
val bannerHeight = 128.dp
Expand All @@ -245,7 +262,7 @@ private fun UserModalContent(
.background(color = MaterialTheme.colorScheme.surfaceVariant)
.fillMaxWidth()
) {
Profile(profile, followsViewModel, onProfileClick)
Profile(profile, followsViewModel, followersViewModel, onProfileClick)
}
Divider()
}
Expand All @@ -259,7 +276,12 @@ private fun UserModalContent(
}

@Composable
fun UserScreen(did: String, followsViewModel: FollowsViewModel, onProfileClick: (String) -> Unit) {
fun UserScreen(
did: String,
followsViewModel: FollowsViewModel,
followersViewModel: FollowersViewModel,
onProfileClick: (String) -> Unit
) {
val userFeedViewModel: UserFeedViewModel = viewModel()
var profileRequested by remember { mutableStateOf(false) }
val profile by userFeedViewModel.profile.collectAsState()
Expand All @@ -281,6 +303,7 @@ fun UserScreen(did: String, followsViewModel: FollowsViewModel, onProfileClick:
UserModalContent(
profile = profile!!,
followsViewModel = followsViewModel,
followersViewModel = followersViewModel,
onProfileClick = onProfileClick
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package io.github.akiomik.seiun.viewmodels

import android.util.Log
import io.github.akiomik.seiun.SeiunApplication
import io.github.akiomik.seiun.model.app.bsky.actor.RefWithInfo
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow

class FollowersViewModel(val did: String) : ApplicationViewModel() {
sealed class State {
object Loading : State()
object Loaded : State()
data class Error(val error: Throwable) : State()
}

private val userRepository = SeiunApplication.instance!!.userRepository
private var _cursor: String? = null

private val _seenAllFollows = MutableStateFlow(false)
private val _state = MutableStateFlow<State>(State.Loading)
private val _followers = MutableStateFlow<List<RefWithInfo>>(emptyList())
val seenAllFollowers = _seenAllFollows.asStateFlow()
val state = _state.asStateFlow()
val followers = _followers.asStateFlow()

init {
wrapError(
run = { userRepository.getFollowers(did) },
onSuccess = {
Log.d(SeiunApplication.TAG, it.toString())
_followers.value = it.followers
_cursor = it.cursor
_state.value = State.Loaded
},
onError = {
Log.d(SeiunApplication.TAG, it.toString())
_state.value = State.Error(it)
}
)
}

fun loadMoreFollowers(onError: (Throwable) -> Unit = {}) {
wrapError(run = {
val data = userRepository.getFollowers(did = did, before = _cursor)

if (data.cursor != _cursor) {
if (data.followers.isNotEmpty()) {
_followers.value = _followers.value + data.followers
_cursor = data.cursor
Log.d(SeiunApplication.TAG, "Followers are updated")
} else {
Log.d(SeiunApplication.TAG, "No followers")
_seenAllFollows.value = true
}
}
}, onError = onError)
}
}
Loading

0 comments on commit 3adf038

Please sign in to comment.