diff --git a/app/src/main/java/io/github/akiomik/seiun/model/app/bsky/actor/ViewerState.kt b/app/src/main/java/io/github/akiomik/seiun/model/app/bsky/actor/ViewerState.kt index 05238ec..911b439 100644 --- a/app/src/main/java/io/github/akiomik/seiun/model/app/bsky/actor/ViewerState.kt +++ b/app/src/main/java/io/github/akiomik/seiun/model/app/bsky/actor/ViewerState.kt @@ -4,5 +4,7 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class ViewerState( - val muted: Boolean? = null + val muted: Boolean? = null, + val following: String? = null, + val followedBy: String? = null ) 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 55b6405..dd370d4 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.FollowsViewModel @Composable fun AppNavigation( @@ -109,9 +110,11 @@ fun AppNavigation( "user/{did}", arguments = listOf(navArgument("did") { type = NavType.StringType }) ) { + val did = it.arguments?.getString("did")!! UserScreen( - it.arguments?.getString("did")!!, - onProfileClick = { did -> navController.navigate("user/$did") } + did, + followsViewModel = FollowsViewModel(did), + onProfileClick = { navController.navigate("user/$it") } ) } composable("login") { diff --git a/app/src/main/java/io/github/akiomik/seiun/ui/user/LoadingIndicator.kt b/app/src/main/java/io/github/akiomik/seiun/ui/user/FeedLoadingIndicator.kt similarity index 97% rename from app/src/main/java/io/github/akiomik/seiun/ui/user/LoadingIndicator.kt rename to app/src/main/java/io/github/akiomik/seiun/ui/user/FeedLoadingIndicator.kt index caba465..35ead3c 100644 --- a/app/src/main/java/io/github/akiomik/seiun/ui/user/LoadingIndicator.kt +++ b/app/src/main/java/io/github/akiomik/seiun/ui/user/FeedLoadingIndicator.kt @@ -15,7 +15,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import io.github.akiomik.seiun.viewmodels.UserFeedViewModel @Composable -fun LoadingIndicator() { +fun FeedLoadingIndicator() { val viewModel: UserFeedViewModel = viewModel() val context = LocalContext.current diff --git a/app/src/main/java/io/github/akiomik/seiun/ui/user/FollowsListModal.kt b/app/src/main/java/io/github/akiomik/seiun/ui/user/FollowsListModal.kt new file mode 100644 index 0000000..d4fbe8c --- /dev/null +++ b/app/src/main/java/io/github/akiomik/seiun/ui/user/FollowsListModal.kt @@ -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.FollowsViewModel + +@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 FollowsListItem(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 FollowsListContent( + follows: List, + viewModel: FollowsViewModel, + onProfileClick: (String) -> Unit +) { + val seenAllFollows by viewModel.seenAllFollows.collectAsState() + + LazyColumn { + items(follows) { + FollowsListItem(user = it, onProfileClick = onProfileClick) + Divider(color = Color.Gray) + } + + if (follows.isEmpty()) { + item { Text(stringResource(R.string.follows_no_follows_yet)) } + } else if (!seenAllFollows) { + item { FollowsLoadingIndicator(viewModel) } + } + } +} + +// TODO: Implement as Screen +@Composable +fun FollowsListModal( + viewModel: FollowsViewModel, + onClose: () -> Unit, + onProfileClick: (String) -> Unit +) { + val state by viewModel.state.collectAsState() + val follows by viewModel.follows.collectAsState() + + FullScreenDialog(onClose = onClose, actions = {}) { + when (state) { + is FollowsViewModel.State.Loading -> { + CircularProgressIndicator() + } + is FollowsViewModel.State.Loaded -> { + FollowsListContent(follows, viewModel, onProfileClick) + } + is FollowsViewModel.State.Error -> { + Text((state as FollowsViewModel.State.Error).error.toString()) + } + } + } +} diff --git a/app/src/main/java/io/github/akiomik/seiun/ui/user/FollowsLoadingIndicator.kt b/app/src/main/java/io/github/akiomik/seiun/ui/user/FollowsLoadingIndicator.kt new file mode 100644 index 0000000..c5b35ac --- /dev/null +++ b/app/src/main/java/io/github/akiomik/seiun/ui/user/FollowsLoadingIndicator.kt @@ -0,0 +1,35 @@ +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 androidx.lifecycle.viewmodel.compose.viewModel +import io.github.akiomik.seiun.viewmodels.FollowsViewModel + +@Composable +fun FollowsLoadingIndicator(viewModel: FollowsViewModel) { + val context = LocalContext.current + + Box( + modifier = Modifier + .fillMaxSize() + .padding(8.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + + LaunchedEffect(key1 = true) { + viewModel.loadMoreFollows(onError = { + Toast.makeText(context, it.toString(), Toast.LENGTH_LONG).show() + }) + } +} diff --git a/app/src/main/java/io/github/akiomik/seiun/ui/user/UserFeed.kt b/app/src/main/java/io/github/akiomik/seiun/ui/user/UserFeed.kt index 67954c0..6e47366 100644 --- a/app/src/main/java/io/github/akiomik/seiun/ui/user/UserFeed.kt +++ b/app/src/main/java/io/github/akiomik/seiun/ui/user/UserFeed.kt @@ -63,7 +63,7 @@ fun UserFeedContent(onProfileClick: (String) -> Unit) { } else if (seenAllFeed) { item { NoMorePostsMessage() } } else { - item { LoadingIndicator() } + item { FeedLoadingIndicator() } } } 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 31829b5..25bc37c 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 @@ -2,6 +2,7 @@ package io.github.akiomik.seiun.ui.user import android.widget.Toast import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -42,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.FollowsViewModel import io.github.akiomik.seiun.viewmodels.UserFeedViewModel import me.onebone.toolbar.CollapsingToolbarScaffold import me.onebone.toolbar.ScrollStrategy @@ -96,7 +98,21 @@ private fun NameAndHandle(profile: ProfileDetail) { } @Composable -private fun StatRow(profile: ProfileDetail) { +private fun StatRow( + profile: ProfileDetail, + followsViewModel: FollowsViewModel, + onProfileClick: (String) -> Unit +) { + var showFollowsList by remember { mutableStateOf(false) } + + if (showFollowsList) { + FollowsListModal( + viewModel = followsViewModel, + onProfileClick = onProfileClick, + onClose = { showFollowsList = false } + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Text( @@ -105,7 +121,8 @@ private fun StatRow(profile: ProfileDetail) { ) Text( text = stringResource(R.string.follows), - style = MaterialTheme.typography.labelMedium + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.clickable { showFollowsList = true } ) } Row(verticalAlignment = Alignment.CenterVertically) { @@ -158,7 +175,11 @@ private fun FollowOrUnfollowButton(profile: ProfileDetail) { } @Composable -private fun Profile(profile: ProfileDetail) { +private fun Profile( + profile: ProfileDetail, + followsViewModel: FollowsViewModel, + onProfileClick: (String) -> Unit +) { val viewModel: AppViewModel = viewModel() val viewer by viewModel.profile.collectAsState() var showEditProfile by remember { mutableStateOf(false) } @@ -189,12 +210,20 @@ private fun Profile(profile: ProfileDetail) { Text(profile.description.orEmpty()) - StatRow(profile = profile) + StatRow( + profile = profile, + followsViewModel = followsViewModel, + onProfileClick = onProfileClick + ) } } @Composable -private fun UserModalContent(profile: ProfileDetail, onProfileClick: (String) -> Unit) { +private fun UserModalContent( + profile: ProfileDetail, + followsViewModel: FollowsViewModel, + onProfileClick: (String) -> Unit +) { val bannerHeight = 128.dp val avatarSize = 96.dp @@ -216,7 +245,7 @@ private fun UserModalContent(profile: ProfileDetail, onProfileClick: (String) -> .background(color = MaterialTheme.colorScheme.surfaceVariant) .fillMaxWidth() ) { - Profile(profile) + Profile(profile, followsViewModel, onProfileClick) } Divider() } @@ -230,7 +259,7 @@ private fun UserModalContent(profile: ProfileDetail, onProfileClick: (String) -> } @Composable -fun UserScreen(did: String, onProfileClick: (String) -> Unit) { +fun UserScreen(did: String, followsViewModel: FollowsViewModel, onProfileClick: (String) -> Unit) { val userFeedViewModel: UserFeedViewModel = viewModel() var profileRequested by remember { mutableStateOf(false) } val profile by userFeedViewModel.profile.collectAsState() @@ -249,7 +278,11 @@ fun UserScreen(did: String, onProfileClick: (String) -> Unit) { CircularProgressIndicator() } } else { - UserModalContent(profile = profile!!, onProfileClick = onProfileClick) + UserModalContent( + profile = profile!!, + followsViewModel = followsViewModel, + onProfileClick = onProfileClick + ) } } } 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 new file mode 100644 index 0000000..7f76185 --- /dev/null +++ b/app/src/main/java/io/github/akiomik/seiun/viewmodels/FollowsViewModel.kt @@ -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 FollowsViewModel(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 _follows = MutableStateFlow>(emptyList()) + val seenAllFollows = _seenAllFollows.asStateFlow() + val state = _state.asStateFlow() + val follows = _follows.asStateFlow() + + init { + wrapError( + run = { userRepository.getFollows(did) }, + onSuccess = { + Log.d(SeiunApplication.TAG, it.toString()) + _follows.value = it.follows + _cursor = it.cursor + _state.value = State.Loaded + }, + onError = { + Log.d(SeiunApplication.TAG, it.toString()) + _state.value = State.Error(it) + } + ) + } + + fun loadMoreFollows(onError: (Throwable) -> Unit = {}) { + wrapError(run = { + val data = userRepository.getFollows(did = did, before = _cursor) + + if (data.cursor != _cursor) { + if (data.follows.isNotEmpty()) { + _follows.value = _follows.value + data.follows + _cursor = data.cursor + Log.d(SeiunApplication.TAG, "Feed posts are updated") + } else { + Log.d(SeiunApplication.TAG, "No new feed posts") + _seenAllFollows.value = true + } + } + }, onError = onError) + } +} diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index ccb4bc6..c1c2826 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -72,4 +72,5 @@ 更新しました 投稿とアクティビティ 新しい通知が %1$d 件あります + まだフォローがいません diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bb4972d..c4e1b6c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -71,4 +71,5 @@ Updated Post and activities You\'ve got %1$d recent notifications + No follows yet