From 146737dc7a243a2c031dcfc00d8b605b06dca854 Mon Sep 17 00:00:00 2001 From: Akiomi Kamakura Date: Thu, 23 Mar 2023 08:36:29 +0900 Subject: [PATCH 1/2] Add UserRepository#getFollowers --- .../java/io/github/akiomik/seiun/api/AtpService.kt | 10 ++++++++++ .../akiomik/seiun/model/app/bsky/graph/Followers.kt | 11 +++++++++++ .../akiomik/seiun/repository/UserRepository.kt | 13 +++++++++++++ .../akiomik/seiun/ui/user/FollowersListModal.kt | 4 ++++ .../seiun/ui/user/FollowersLoadingIndicator.kt | 1 + .../akiomik/seiun/viewmodels/FollowersViewModel.kt | 4 ++++ 6 files changed, 43 insertions(+) create mode 100644 app/src/main/java/io/github/akiomik/seiun/model/app/bsky/graph/Followers.kt create mode 100644 app/src/main/java/io/github/akiomik/seiun/ui/user/FollowersListModal.kt create mode 100644 app/src/main/java/io/github/akiomik/seiun/ui/user/FollowersLoadingIndicator.kt create mode 100644 app/src/main/java/io/github/akiomik/seiun/viewmodels/FollowersViewModel.kt diff --git a/app/src/main/java/io/github/akiomik/seiun/api/AtpService.kt b/app/src/main/java/io/github/akiomik/seiun/api/AtpService.kt index d3a64c8..f3e1e2d 100644 --- a/app/src/main/java/io/github/akiomik/seiun/api/AtpService.kt +++ b/app/src/main/java/io/github/akiomik/seiun/api/AtpService.kt @@ -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 @@ -183,6 +184,15 @@ interface AtpService { @Query("before") before: String? = null ): ApiResult + @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 + @DecodeErrorBody @POST("com.atproto.repo.createRecord") suspend fun follow( diff --git a/app/src/main/java/io/github/akiomik/seiun/model/app/bsky/graph/Followers.kt b/app/src/main/java/io/github/akiomik/seiun/model/app/bsky/graph/Followers.kt new file mode 100644 index 0000000..2a2097e --- /dev/null +++ b/app/src/main/java/io/github/akiomik/seiun/model/app/bsky/graph/Followers.kt @@ -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, + val cursor: String? = null +) diff --git a/app/src/main/java/io/github/akiomik/seiun/repository/UserRepository.kt b/app/src/main/java/io/github/akiomik/seiun/repository/UserRepository.kt index 7b241ba..eee5600 100644 --- a/app/src/main/java/io/github/akiomik/seiun/repository/UserRepository.kt +++ b/app/src/main/java/io/github/akiomik/seiun/repository/UserRepository.kt @@ -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 @@ -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") diff --git a/app/src/main/java/io/github/akiomik/seiun/ui/user/FollowersListModal.kt b/app/src/main/java/io/github/akiomik/seiun/ui/user/FollowersListModal.kt new file mode 100644 index 0000000..3be0c71 --- /dev/null +++ b/app/src/main/java/io/github/akiomik/seiun/ui/user/FollowersListModal.kt @@ -0,0 +1,4 @@ +package io.github.akiomik.seiun.ui.user + +class FollowersListModal { +} diff --git a/app/src/main/java/io/github/akiomik/seiun/ui/user/FollowersLoadingIndicator.kt b/app/src/main/java/io/github/akiomik/seiun/ui/user/FollowersLoadingIndicator.kt new file mode 100644 index 0000000..26e5f53 --- /dev/null +++ b/app/src/main/java/io/github/akiomik/seiun/ui/user/FollowersLoadingIndicator.kt @@ -0,0 +1 @@ +package io.github.akiomik.seiun.ui.user diff --git a/app/src/main/java/io/github/akiomik/seiun/viewmodels/FollowersViewModel.kt b/app/src/main/java/io/github/akiomik/seiun/viewmodels/FollowersViewModel.kt new file mode 100644 index 0000000..28a59b9 --- /dev/null +++ b/app/src/main/java/io/github/akiomik/seiun/viewmodels/FollowersViewModel.kt @@ -0,0 +1,4 @@ +package io.github.akiomik.seiun.viewmodels + +class FollowersViewModel { +} From e460b4b0b0507a43b75716f7cf98426c99082d9e Mon Sep 17 00:00:00 2001 From: Akiomi Kamakura Date: Thu, 23 Mar 2023 08:48:00 +0900 Subject: [PATCH 2/2] Add followers list --- .../akiomik/seiun/ui/app/AppNavigation.kt | 2 + .../seiun/ui/user/FollowersListModal.kt | 111 +++++++++++++++++- .../ui/user/FollowersLoadingIndicator.kt | 33 ++++++ .../akiomik/seiun/ui/user/UserScreen.kt | 35 +++++- .../seiun/viewmodels/FollowersViewModel.kt | 56 ++++++++- .../seiun/viewmodels/FollowsViewModel.kt | 4 +- app/src/main/res/values-ja-rJP/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 8 files changed, 233 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/io/github/akiomik/seiun/ui/app/AppNavigation.kt b/app/src/main/java/io/github/akiomik/seiun/ui/app/AppNavigation.kt index dd370d4..ee6606e 100644 --- a/app/src/main/java/io/github/akiomik/seiun/ui/app/AppNavigation.kt +++ b/app/src/main/java/io/github/akiomik/seiun/ui/app/AppNavigation.kt @@ -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 @@ -114,6 +115,7 @@ fun AppNavigation( UserScreen( did, followsViewModel = FollowsViewModel(did), + followersViewModel = FollowersViewModel(did), onProfileClick = { navController.navigate("user/$it") } ) } diff --git a/app/src/main/java/io/github/akiomik/seiun/ui/user/FollowersListModal.kt b/app/src/main/java/io/github/akiomik/seiun/ui/user/FollowersListModal.kt index 3be0c71..a4d3c93 100644 --- a/app/src/main/java/io/github/akiomik/seiun/ui/user/FollowersListModal.kt +++ b/app/src/main/java/io/github/akiomik/seiun/ui/user/FollowersListModal.kt @@ -1,4 +1,113 @@ package io.github.akiomik.seiun.ui.user -class FollowersListModal { +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, + 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()) + } + } + } } diff --git a/app/src/main/java/io/github/akiomik/seiun/ui/user/FollowersLoadingIndicator.kt b/app/src/main/java/io/github/akiomik/seiun/ui/user/FollowersLoadingIndicator.kt index 26e5f53..0fdbccb 100644 --- a/app/src/main/java/io/github/akiomik/seiun/ui/user/FollowersLoadingIndicator.kt +++ b/app/src/main/java/io/github/akiomik/seiun/ui/user/FollowersLoadingIndicator.kt @@ -1 +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() + }) + } +} diff --git a/app/src/main/java/io/github/akiomik/seiun/ui/user/UserScreen.kt b/app/src/main/java/io/github/akiomik/seiun/ui/user/UserScreen.kt index 25bc37c..a635916 100644 --- a/app/src/main/java/io/github/akiomik/seiun/ui/user/UserScreen.kt +++ b/app/src/main/java/io/github/akiomik/seiun/ui/user/UserScreen.kt @@ -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 @@ -101,9 +102,11 @@ 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( @@ -111,21 +114,32 @@ private fun StatRow( 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 @@ -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() @@ -213,6 +228,7 @@ private fun Profile( StatRow( profile = profile, followsViewModel = followsViewModel, + followersViewModel = followersViewModel, onProfileClick = onProfileClick ) } @@ -222,6 +238,7 @@ private fun Profile( private fun UserModalContent( profile: ProfileDetail, followsViewModel: FollowsViewModel, + followersViewModel: FollowersViewModel, onProfileClick: (String) -> Unit ) { val bannerHeight = 128.dp @@ -245,7 +262,7 @@ private fun UserModalContent( .background(color = MaterialTheme.colorScheme.surfaceVariant) .fillMaxWidth() ) { - Profile(profile, followsViewModel, onProfileClick) + Profile(profile, followsViewModel, followersViewModel, onProfileClick) } Divider() } @@ -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() @@ -281,6 +303,7 @@ fun UserScreen(did: String, followsViewModel: FollowsViewModel, onProfileClick: UserModalContent( profile = profile!!, followsViewModel = followsViewModel, + followersViewModel = followersViewModel, onProfileClick = onProfileClick ) } diff --git a/app/src/main/java/io/github/akiomik/seiun/viewmodels/FollowersViewModel.kt b/app/src/main/java/io/github/akiomik/seiun/viewmodels/FollowersViewModel.kt index 28a59b9..63eb216 100644 --- a/app/src/main/java/io/github/akiomik/seiun/viewmodels/FollowersViewModel.kt +++ b/app/src/main/java/io/github/akiomik/seiun/viewmodels/FollowersViewModel.kt @@ -1,4 +1,58 @@ package io.github.akiomik.seiun.viewmodels -class FollowersViewModel { +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.Loading) + private val _followers = MutableStateFlow>(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) + } } diff --git a/app/src/main/java/io/github/akiomik/seiun/viewmodels/FollowsViewModel.kt b/app/src/main/java/io/github/akiomik/seiun/viewmodels/FollowsViewModel.kt index 7f76185..c7397d5 100644 --- a/app/src/main/java/io/github/akiomik/seiun/viewmodels/FollowsViewModel.kt +++ b/app/src/main/java/io/github/akiomik/seiun/viewmodels/FollowsViewModel.kt @@ -47,9 +47,9 @@ class FollowsViewModel(val did: String) : ApplicationViewModel() { if (data.follows.isNotEmpty()) { _follows.value = _follows.value + data.follows _cursor = data.cursor - Log.d(SeiunApplication.TAG, "Feed posts are updated") + Log.d(SeiunApplication.TAG, "Followers are updated") } else { - Log.d(SeiunApplication.TAG, "No new feed posts") + Log.d(SeiunApplication.TAG, "No new followers") _seenAllFollows.value = true } } diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index c1c2826..048b5ee 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -73,4 +73,5 @@ 投稿とアクティビティ 新しい通知が %1$d 件あります まだフォローがいません + まだフォロワーがいません diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c4e1b6c..5132275 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -72,4 +72,5 @@ Post and activities You\'ve got %1$d recent notifications No follows yet + No followers yet