From b019c79bcb40fea1b26b7ccf0ca98767343c9e8c Mon Sep 17 00:00:00 2001 From: erik-everardo Date: Mon, 3 Feb 2025 22:20:16 -0600 Subject: [PATCH] WIP perfil en compose --- .../components/{Post.kt => PostComponent.kt} | 8 +- .../presentation/PostListingViewModelBase.kt | 3 +- .../posting/posts/ui/PostListingFragment.kt | 3 +- .../profile/domain/entity/UserProfile.kt | 3 + .../profile/presentation/ProfileViewModel.kt | 30 +- .../profile/ui/ProfileMainFragment.kt | 737 +++++++++--------- .../isolaatti/profile/ui/QrCodeFragment.kt | 12 + .../profile/ui/components/ProfileHeader.kt | 173 ++++ .../main/res/layout/fragment_discussions.xml | 68 +- app/src/main/res/values-night/colors.xml | 2 +- app/src/main/res/values/strings.xml | 4 + 11 files changed, 581 insertions(+), 462 deletions(-) rename app/src/main/java/com/isolaatti/posting/posts/components/{Post.kt => PostComponent.kt} (95%) create mode 100644 app/src/main/java/com/isolaatti/profile/ui/components/ProfileHeader.kt diff --git a/app/src/main/java/com/isolaatti/posting/posts/components/Post.kt b/app/src/main/java/com/isolaatti/posting/posts/components/PostComponent.kt similarity index 95% rename from app/src/main/java/com/isolaatti/posting/posts/components/Post.kt rename to app/src/main/java/com/isolaatti/posting/posts/components/PostComponent.kt index 4ec4cce..5ce2a5d 100644 --- a/app/src/main/java/com/isolaatti/posting/posts/components/Post.kt +++ b/app/src/main/java/com/isolaatti/posting/posts/components/PostComponent.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize @@ -34,13 +33,12 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.isolaatti.R -import com.isolaatti.images.common.domain.entity.Image import com.isolaatti.images.common.domain.entity.RemoteImage import com.isolaatti.posting.posts.domain.entity.Post import com.isolaatti.utils.UrlGen.userProfileImage @Composable -fun Post( +fun PostComponent( post: Post, onClick: () -> Unit = {}, onLike: () -> Unit = {}, @@ -54,7 +52,7 @@ fun Post( onHashtagClick: (hashtag: String) -> Unit = {}, modifier: Modifier = Modifier ) { - Card(modifier = modifier.padding(8.dp)) { + Card(modifier = modifier.padding(8.dp).clickable { onClick() }) { Row(modifier = Modifier.fillMaxWidth().padding(8.dp), verticalAlignment = Alignment.CenterVertically) { AsyncImage( model = userProfileImage(post.userId), @@ -169,5 +167,5 @@ fun Post( @Composable @Preview(device = Devices.PIXEL_5) fun PostPreview() { - Post(Post(id = 0L, textContent = "Test", userId = 1, privacy = 2, date = "Date", images = emptyList(), liked = false, userName = "Username", numberOfLikes = 1, numberOfComments = 2)) + PostComponent(Post(id = 0L, textContent = "Test", userId = 1, privacy = 2, date = "Date", images = emptyList(), liked = false, userName = "Username", numberOfLikes = 1, numberOfComments = 2)) } \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/posting/posts/presentation/PostListingViewModelBase.kt b/app/src/main/java/com/isolaatti/posting/posts/presentation/PostListingViewModelBase.kt index 367ff01..aff50e9 100644 --- a/app/src/main/java/com/isolaatti/posting/posts/presentation/PostListingViewModelBase.kt +++ b/app/src/main/java/com/isolaatti/posting/posts/presentation/PostListingViewModelBase.kt @@ -33,7 +33,6 @@ abstract class PostListingViewModelBase : ViewModel() { val posts: MutableStateFlow> = MutableStateFlow(emptyList()) - val loadingPosts = MutableLiveData(false) val noMoreContent = MutableLiveData(false) @@ -114,6 +113,6 @@ abstract class PostListingViewModelBase : ViewModel() { } fun onPostAddedAtTheBeginning(post: Post) { - + posts.value = listOf(post) + posts.value } } \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/posting/posts/ui/PostListingFragment.kt b/app/src/main/java/com/isolaatti/posting/posts/ui/PostListingFragment.kt index bec7596..74a10e3 100644 --- a/app/src/main/java/com/isolaatti/posting/posts/ui/PostListingFragment.kt +++ b/app/src/main/java/com/isolaatti/posting/posts/ui/PostListingFragment.kt @@ -40,6 +40,7 @@ import com.isolaatti.hashtags.ui.HashtagsPostsActivity import com.isolaatti.home.ui.FeedFragment import com.isolaatti.images.picture_viewer.ui.PictureViewerActivity import com.isolaatti.posting.comments.ui.BottomSheetPostComments +import com.isolaatti.posting.posts.components.PostComponent import com.isolaatti.posting.posts.domain.entity.Post import com.isolaatti.posting.posts.presentation.EditPostContract import com.isolaatti.posting.posts.presentation.PostListingViewModel @@ -85,7 +86,7 @@ class PostListingFragment : Fragment() { LazyColumn(flingBehavior = ScrollableDefaults.flingBehavior()) { items(count = posts.size, key = {posts[it].id}) { val post = posts[it] - com.isolaatti.posting.posts.components.Post( + PostComponent( modifier = Modifier.animateItem(), post = post, onClick = { diff --git a/app/src/main/java/com/isolaatti/profile/domain/entity/UserProfile.kt b/app/src/main/java/com/isolaatti/profile/domain/entity/UserProfile.kt index d0a4a42..851441c 100644 --- a/app/src/main/java/com/isolaatti/profile/domain/entity/UserProfile.kt +++ b/app/src/main/java/com/isolaatti/profile/domain/entity/UserProfile.kt @@ -2,6 +2,7 @@ package com.isolaatti.profile.domain.entity import com.isolaatti.audio.common.domain.Audio import com.isolaatti.common.Ownable +import com.isolaatti.images.common.domain.entity.RemoteImage import com.isolaatti.profile.data.remote.UserProfileDto import com.isolaatti.utils.UrlGen import java.io.Serializable @@ -26,6 +27,8 @@ data class UserProfile( val profileAvatarPictureUrl: String get() = UrlGen.userProfileImage(userId) val profilePictureUrl: String get() = UrlGen.userProfileImageFullQuality(userId) + + val profileImage: RemoteImage? get() = profileImageId?.let { imageId -> RemoteImage(imageId) } companion object { fun fromDto(userProfileDto: UserProfileDto): UserProfile { return UserProfile( diff --git a/app/src/main/java/com/isolaatti/profile/presentation/ProfileViewModel.kt b/app/src/main/java/com/isolaatti/profile/presentation/ProfileViewModel.kt index e8d77a6..c90401d 100644 --- a/app/src/main/java/com/isolaatti/profile/presentation/ProfileViewModel.kt +++ b/app/src/main/java/com/isolaatti/profile/presentation/ProfileViewModel.kt @@ -16,6 +16,7 @@ import com.isolaatti.profile.domain.use_case.SetProfileImage import com.isolaatti.utils.Resource import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -32,6 +33,7 @@ class ProfileViewModel @Inject constructor( ) : PostListingViewModelBase() { private val _profile = MutableLiveData() val profile: LiveData get() = _profile + val loadingProfile: MutableStateFlow = MutableStateFlow(true) var profileId: Int = 0 @@ -41,6 +43,8 @@ class ProfileViewModel @Inject constructor( val followingLoading: MutableLiveData = MutableLiveData() + val isRefreshing: MutableStateFlow = MutableStateFlow(false) + // runs the lists of "Runnable" one by one and clears list. After this is executed, // caller should report as handled @@ -57,13 +61,17 @@ class ProfileViewModel @Inject constructor( getProfileUseCase(profileId).onEach { when(it) { is Resource.Error -> { + loadingProfile.value = false errorLoading.postValue(it.errorType) toRetry.add { getProfile() } } - is Resource.Loading -> {} + is Resource.Loading -> { + loadingProfile.value = true + } is Resource.Success -> { + loadingProfile.value = false _profile.postValue(it.data!!) followingState.postValue( it.data.let { user-> getFollowingState(user.followingThisUser, user.thisUserIsFollowingMe) } @@ -118,20 +126,34 @@ class ProfileViewModel @Inject constructor( if(refresh) { posts.value = emptyList() } - Log.d(TAG, "getFeed") getProfilePostsUseCase(profileId, getLastId(), false, null).onEach { feedDtoResource -> when (feedDtoResource) { is Resource.Success -> { - loadingPosts.postValue(false) + if(refresh) { + isRefreshing.value = false + } else { + loadingPosts.postValue(false) + } + posts.value += feedDtoResource.data ?: emptyList() noMoreContent.postValue(feedDtoResource.data?.size == 0) } is Resource.Loading -> { - loadingPosts.postValue(true) + if(refresh) { + isRefreshing.value = true + } else { + loadingPosts.postValue(true) + } + } is Resource.Error -> { + if(refresh) { + isRefreshing.value = true + } else { + loadingPosts.postValue(true) + } errorLoading.postValue(feedDtoResource.errorType) toRetry.add { getFeed(refresh, hashtag) diff --git a/app/src/main/java/com/isolaatti/profile/ui/ProfileMainFragment.kt b/app/src/main/java/com/isolaatti/profile/ui/ProfileMainFragment.kt index 787b813..77b453b 100644 --- a/app/src/main/java/com/isolaatti/profile/ui/ProfileMainFragment.kt +++ b/app/src/main/java/com/isolaatti/profile/ui/ProfileMainFragment.kt @@ -1,6 +1,6 @@ package com.isolaatti.profile.ui -import android.content.Context +import android.content.ComponentName import android.content.Intent import android.os.Bundle import android.util.Log @@ -8,72 +8,89 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle -import androidx.lifecycle.Observer import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle -import androidx.navigation.fragment.findNavController -import androidx.recyclerview.widget.LinearLayoutManager -import coil3.load +import androidx.media3.session.MediaController +import androidx.media3.session.SessionToken +import androidx.navigation.findNavController import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.common.util.concurrent.ListenableFuture import com.isolaatti.BuildConfig import com.isolaatti.R -import com.isolaatti.audio.common.domain.Audio -import com.isolaatti.common.CoilImageLoader.imageLoader +import com.isolaatti.audio.player.MediaService import com.isolaatti.common.Dialogs import com.isolaatti.common.ErrorMessageViewModel -import com.isolaatti.common.Ownable -import com.isolaatti.common.options_bottom_sheet.domain.OptionClicked +import com.isolaatti.common.IsolaattiTheme import com.isolaatti.common.options_bottom_sheet.domain.Options import com.isolaatti.common.options_bottom_sheet.presentation.BottomSheetPostOptionsViewModel import com.isolaatti.common.options_bottom_sheet.ui.BottomSheetPostOptionsFragment -import com.isolaatti.databinding.FragmentDiscussionsBinding import com.isolaatti.followers.domain.FollowingState import com.isolaatti.hashtags.ui.HashtagsPostsActivity -import com.isolaatti.images.common.domain.entity.RemoteImage import com.isolaatti.images.picture_viewer.ui.PictureViewerActivity import com.isolaatti.posting.comments.ui.BottomSheetPostComments +import com.isolaatti.posting.posts.components.PostComponent import com.isolaatti.posting.posts.domain.entity.Post import com.isolaatti.posting.posts.presentation.CreatePostContract -import com.isolaatti.posting.posts.presentation.EditPostContract -import com.isolaatti.posting.posts.presentation.PostListingRecyclerViewAdapterWiring import com.isolaatti.posting.posts.ui.PostInfoActivity import com.isolaatti.posting.posts.viewer.ui.PostViewerActivity import com.isolaatti.profile.domain.entity.UserProfile import com.isolaatti.profile.presentation.EditProfileContract import com.isolaatti.profile.presentation.ProfileViewModel +import com.isolaatti.profile.ui.components.ProfileHeader import com.isolaatti.reports.data.ContentType import com.isolaatti.reports.ui.NewReportBottomSheetDialogFragment -import com.isolaatti.utils.UrlGen import dagger.hilt.android.AndroidEntryPoint -import io.noties.markwon.AbstractMarkwonPlugin -import io.noties.markwon.Markwon -import io.noties.markwon.MarkwonConfiguration -import io.noties.markwon.image.coil.CoilImagesPlugin -import io.noties.markwon.image.destination.ImageDestinationProcessorRelativeToAbsolute -import io.noties.markwon.linkify.LinkifyPlugin +import kotlinx.coroutines.guava.await import kotlinx.coroutines.launch @AndroidEntryPoint class ProfileMainFragment : Fragment() { - lateinit var viewBinding: FragmentDiscussionsBinding private val viewModel: ProfileViewModel by viewModels() val optionsViewModel: BottomSheetPostOptionsViewModel by activityViewModels() val errorViewModel: ErrorMessageViewModel by activityViewModels() private var userId: Int? = null - - private var audioDescriptionAudio: Audio? = null - - - // collapsing bar - private var title = "" - private var scrollRange = -1 - private var isShow = false - private val createDiscussion = registerForActivityResult(CreatePostContract()) { if(it == null) return@registerForActivityResult @@ -83,13 +100,6 @@ class ProfileMainFragment : Fragment() { viewModel.onPostAddedAtTheBeginning(it) } - - private val editDiscussion = registerForActivityResult(EditPostContract()) { - if(it != null) { - viewModel.onPostUpdate(it) - } - } - private val editProfile = registerForActivityResult(EditProfileContract()) { updatedProfile -> if(updatedProfile != null) { viewModel.setProfile(updatedProfile) @@ -97,121 +107,324 @@ class ProfileMainFragment : Fragment() { } - private val profileObserver = Observer { profile -> - viewBinding.profileImageView.load(UrlGen.userProfileImage(profile.userId, invalidateCache = true), imageLoader) - title = profile.name - viewBinding.textViewUsername.text = profile.name - viewBinding.textViewDescription.text = profile.descriptionText - if(profile.descriptionText.isNullOrBlank()) { - viewBinding.descriptionCard.visibility = View.GONE - } + private fun getData() { - viewBinding.goToFollowersBtn.text = getString( - R.string.go_to_followers_btn_text, - profile.numberOfFollowers.toString(), - profile.numberOfFollowing.toString() - ) - - - viewBinding.profileImageView.setOnClickListener { - optionsViewModel.setOptions(Options.PROFILE_PHOTO_OPTIONS, CALLER_ID, profile) - val fragment = BottomSheetPostOptionsFragment() - fragment.show(parentFragmentManager, BottomSheetPostOptionsFragment.TAG) - } - - audioDescriptionAudio = profile.descriptionAudio - viewBinding.playButtonContainer.visibility = if(profile.descriptionAudio != null) View.VISIBLE else View.GONE - - setupUiForUserType(profile.isUserItself) - } - -// private val postsObserver: Observer?, UpdateEvent>?> = Observer { -// if(it?.first != null) { -// postsAdapter.updateList(it.first!!, it.second) -// postsAdapter.newContentRequestFinished() -// } -// -// } - - private val followingStateObserver: Observer = Observer { - when(it) { - FollowingState.FollowingThisUser -> { - viewBinding.textViewFollowingState.setText(R.string.following_user) - viewBinding.followButton.setText(R.string.unfollow) - viewBinding.followButton.isChecked = true - } - FollowingState.MutuallyFollowing -> { - viewBinding.textViewFollowingState.setText(R.string.mutually_following) - viewBinding.followButton.setText(R.string.unfollow) - viewBinding.followButton.isChecked = true - } - FollowingState.ThisUserIsFollowingMe -> { - viewBinding.textViewFollowingState.setText(R.string.following_you) - viewBinding.followButton.setText(R.string.follow) - viewBinding.followButton.isChecked = false - } - FollowingState.NotMutuallyFollowing -> { - viewBinding.textViewFollowingState.text = "" - viewBinding.followButton.setText(R.string.follow) - viewBinding.followButton.isChecked = false - } + userId?.let { profileId -> + viewModel.profileId = profileId + viewModel.getProfile() + viewModel.getFeed(true, null) } } - private val optionsObserver: Observer = Observer { optionClicked -> - if(optionClicked?.callerId == CALLER_ID) { - Log.d("ProfileMainFragment", optionClicked.toString()) + private lateinit var mediaControllerFuture: ListenableFuture + private lateinit var mediaController: MediaController - when(optionClicked.optionsId) { - Options.PROFILE_PHOTO_OPTIONS -> { - val profile = optionClicked.payload as? UserProfile - when(optionClicked.optionId) { - Options.Option.OPTION_PROFILE_PHOTO_CHANGE_PHOTO -> { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val sessionToken = SessionToken(requireContext(), ComponentName(requireContext(), MediaService::class.java)) + mediaControllerFuture = MediaController.Builder(requireContext(), sessionToken).buildAsync() + + userId = (requireActivity()).intent.extras?.getInt(ProfileActivity.EXTRA_USER_ID) + getData() + } + + + @OptIn(ExperimentalMaterial3Api::class) + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + + + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val isRefreshing by viewModel.isRefreshing.collectAsState() + val lazyColumState = rememberLazyListState() + val profile by viewModel.profile.observeAsState() + val followingState by viewModel.followingState.observeAsState() + val isAtTop by remember { + derivedStateOf { + lazyColumState.firstVisibleItemIndex == 0 && lazyColumState.firstVisibleItemScrollOffset == 0 + } + } + var showAddPostButton by remember { mutableStateOf(false) } + + val nestedScrollConnection = remember { + object : NestedScrollConnection { + override fun onPreScroll( + available: Offset, + source: NestedScrollSource + ): Offset { + // Hide FAB + if (available.y < -1) { + showAddPostButton = false + } + + // Show FAB + if (available.y > 1) { + showAddPostButton = true + } + + return Offset.Zero + } + } + } + + LaunchedEffect(profile) { + showAddPostButton = profile?.isUserItself == true + } + + val followingText = when(followingState) { + FollowingState.FollowingThisUser -> { + stringResource(R.string.following_user) + } + FollowingState.MutuallyFollowing -> { + stringResource(R.string.mutually_following) + } + FollowingState.ThisUserIsFollowingMe -> { + stringResource(R.string.following_you) + } + FollowingState.NotMutuallyFollowing -> { + "" + } + + null -> "" + } + + + val posts by viewModel.posts.collectAsState() + val loadingProfile by viewModel.loadingProfile.collectAsState() + + val playingAudio by remember { mutableStateOf(true) } + IsolaattiTheme { + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton( + onClick = { + activity?.finish() + } + ) { + Icon(Icons.AutoMirrored.Default.ArrowBack, stringResource(R.string.back_button)) + } + }, + title = { + Text(if(isAtTop) stringResource(R.string.profile) else profile?.name ?: "") + }, + actions = { + IconButton( + onClick = { + profile?.let { findNavController().navigate(ProfileMainFragmentDirections.actionMainFragmentToQrCodeFragment(it)) } + } + ) { + Icon(painterResource(R.drawable.baseline_qr_code_24), stringResource(R.string.qr_code)) + } + } + ) + }, + floatingActionButton = { + AnimatedVisibility( + visible = showAddPostButton, + enter = slideInVertically(initialOffsetY = { it * 2 }), + exit = slideOutVertically(targetOffsetY = { it * 2 }) + ) { + FloatingActionButton( + onClick = { + createDiscussion.launch(Unit) + } + ) { + Icon(Icons.Default.Add, stringResource(R.string.add_post_button_desc)) + } + } } - Options.Option.OPTION_PROFILE_PHOTO_REMOVE_PHOTO -> { - showRemoveProfileImageDialog() - } - Options.Option.OPTION_PROFILE_PHOTO_VIEW_PHOTO -> { - val profilePictureUrl = profile?.profilePictureUrl - if(profilePictureUrl != null) { - PictureViewerActivity.startActivityWithImages(requireContext(), arrayOf( - RemoteImage(profile.profileImageId ?: "") - )) + ) { insets -> + + PullToRefreshBox( + isRefreshing, + onRefresh = { + viewModel.getProfile() + viewModel.getFeed(refresh = true, hashtag = null) + }, + modifier = Modifier + .padding(insets) + .consumeWindowInsets(insets) + ) { + LazyColumn( + state = lazyColumState, + modifier = Modifier + .fillMaxWidth() + .then(if(profile?.isUserItself == true) { Modifier.nestedScroll(nestedScrollConnection) } else Modifier) + ) { + item { + profile?.let { + ProfileHeader( + modifier = Modifier.padding(16.dp), + name = it.name, + description = it.descriptionText, + image = it.profileImage, + audio = it.descriptionAudio, + followingCount = it.numberOfFollowing, + followerCount = it.numberOfFollowers, + loading = loadingProfile, + onImageClick = { + optionsViewModel.setOptions(Options.PROFILE_PHOTO_OPTIONS, CALLER_ID, it) + val fragment = BottomSheetPostOptionsFragment() + fragment.show(parentFragmentManager, BottomSheetPostOptionsFragment.TAG) + }, + onFollowersClick = { + findNavController().navigate(ProfileMainFragmentDirections.actionDiscussionsFragmentToMainFollowersFragment(it.userId)) + }, + followingText = followingText, + followingThisUser = it.followingThisUser, + isOwnUser = it.isUserItself, + onFollowChange = { follow -> + viewModel.followUser() + }, + profileAudioProgress = 0.4f, + showProfileAudioProgress = true, + onEditProfileClick = { + editProfile.launch(it) + } + ) + } + } + + if(posts.isEmpty()) { + item { + Text(getString(R.string.you_will_see_what_user_posts_here, profile?.name ?: ""), modifier = Modifier.padding(24.dp)) + } + } + + items(count = posts.size, key = {posts[it].id}) { + val post = posts[it] + PostComponent( + modifier = Modifier.animateItem(), + post = post, + onClick = { + PostViewerActivity.startActivity(requireContext(), post.id) + }, + onComments = { + val modalBottomSheet = BottomSheetPostComments.getInstance(post.id) + modalBottomSheet.show(requireActivity().supportFragmentManager, BottomSheetPostComments.TAG) + }, + onOptions = { + optionsViewModel.setOptions(Options.POST_OPTIONS, CALLER_ID, post) + val modalBottomSheet = BottomSheetPostOptionsFragment() + modalBottomSheet.show(requireActivity().supportFragmentManager, BottomSheetPostOptionsFragment.TAG) + }, + onInfo = { + PostInfoActivity.startActivity(requireContext(), post.id) + }, + onLike = { + viewModel.likePost(post.id) + }, + onDislike = { + viewModel.unLikePost(post.id) + }, + onShare = { + val intent = Intent.createChooser(Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, "${BuildConfig.backend}/pub/${post.id}") + type = "text/plain" + }, getString(R.string.share_post)) + startActivity(intent) + }, + onImageClick = { images, index -> + PictureViewerActivity.startActivityWithImages(requireContext(), images.toTypedArray(), index) + }, + onUsernameClick = { + if(profile?.userId == post.userId) return@PostComponent + ProfileActivity.startActivity(requireContext(), post.userId) + }, + onHashtagClick = { hashtag -> + HashtagsPostsActivity.startActivity(requireContext(), hashtag) + } + ) + } } } } - optionsViewModel.handle() - } - Options.POST_OPTIONS -> { - // post id should come as payload - val post = optionClicked.payload as? Post ?: return@Observer - when(optionClicked.optionId) { - Options.Option.OPTION_DELETE -> { - Dialogs.buildDeletePostDialog(requireContext()) { delete -> - optionsViewModel.handle() - if(delete) { - viewModel.deletePost(post.id) - } - }.show() - - } - Options.Option.OPTION_EDIT -> { - optionsViewModel.handle() - editDiscussion.launch(post.id) - } - Options.Option.OPTION_REPORT -> { - optionsViewModel.handle() - NewReportBottomSheetDialogFragment.newInstance(ContentType.Post, post.id.toString()).show(childFragmentManager, NewReportBottomSheetDialogFragment.LOG_TAG) - } - } - } - Options.PROFILE_DESCRIPTION_OPTIONS -> { - optionsViewModel.handle() } } + } + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewLifecycleOwner.lifecycleScope.launch { + mediaController = mediaControllerFuture.await() + } + + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + errorViewModel.retry.collect { + viewModel.retry() + errorViewModel.handleRetry() + } + } + } + + optionsViewModel.optionClicked.observe(viewLifecycleOwner) { optionClicked -> + if(optionClicked?.callerId == CALLER_ID) { + Log.d("ProfileMainFragment", optionClicked.toString()) + + when(optionClicked.optionsId) { + Options.PROFILE_PHOTO_OPTIONS -> { + val profile = optionClicked.payload as? UserProfile + when(optionClicked.optionId) { + Options.Option.OPTION_PROFILE_PHOTO_CHANGE_PHOTO -> { + + } + Options.Option.OPTION_PROFILE_PHOTO_REMOVE_PHOTO -> { + showRemoveProfileImageDialog() + } + Options.Option.OPTION_PROFILE_PHOTO_VIEW_PHOTO -> { + profile?.profileImage?.let { + PictureViewerActivity.startActivityWithImages(requireContext(), arrayOf(it)) + } + } + } + optionsViewModel.handle() + } + Options.POST_OPTIONS -> { + // post id should come as payload + val post = optionClicked.payload as? Post ?: return@observe + when(optionClicked.optionId) { + Options.Option.OPTION_DELETE -> { + Dialogs.buildDeletePostDialog(requireContext()) { delete -> + optionsViewModel.handle() + if(delete) { + viewModel.deletePost(post.id) + } + }.show() + + } + Options.Option.OPTION_EDIT -> { + optionsViewModel.handle() + //editDiscussion.launch(post.id) + } + Options.Option.OPTION_REPORT -> { + NewReportBottomSheetDialogFragment + .newInstance(ContentType.Post, post.id.toString()) + .show(childFragmentManager, NewReportBottomSheetDialogFragment.LOG_TAG) + optionsViewModel.handle() + } + } + } + Options.PROFILE_DESCRIPTION_OPTIONS -> { + optionsViewModel.handle() + } + } + + + } } } @@ -226,254 +439,6 @@ class ProfileMainFragment : Fragment() { } .show() } - - private lateinit var postListingRecyclerViewAdapterWiring: PostListingRecyclerViewAdapterWiring - - // counter variable to prevent calling appBarLayout.totalScrollRange many times - private var i = 0 - private fun setupCollapsingBar() { - viewBinding.topAppBarLayout.addOnOffsetChangedListener { appBarLayout, verticalOffset -> - // prevent calling appBarLayout.totalScrollRange many times - if (i == 30) { - scrollRange = appBarLayout.totalScrollRange - i = 0 - } else { - i++ - } - - if (scrollRange + verticalOffset == 0) { - viewBinding.collapsingToolbarLayout.title = viewModel.profile.value?.name - isShow = true - } else if (isShow) { - viewBinding.collapsingToolbarLayout.title = " " - } - } - } - - private fun showBlockUserDialog() { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.block_profile) - .setMessage(getString(R.string.block_profile_dialog_message, viewModel.profile.value?.name ?: "")) - .setPositiveButton(R.string.yes_continue) {_, _ -> - // TODO implement - } - .setNegativeButton(R.string.no, null) - .show() - - } - - private fun bind() { - - if(userId == null) { - return - } - - - viewBinding.topAppBar.setNavigationOnClickListener { - requireActivity().finish() - } - - viewBinding.topAppBar.setOnMenuItemClickListener { - when(it.itemId) { - R.id.edit_profile -> { - viewModel.profile.value?.let { profile -> editProfile.launch(profile) } - - true - } - R.id.user_link_menu_item -> { - viewModel.profile.value?.let { profile -> - findNavController().navigate(ProfileMainFragmentDirections.actionMainFragmentToQrCodeFragment(profile)) - } - - true - } - R.id.report_profile_menu_item -> { - NewReportBottomSheetDialogFragment.newInstance(ContentType.Profile, viewModel.profileId.toString()).show(childFragmentManager, NewReportBottomSheetDialogFragment.LOG_TAG) - true - } - R.id.block_profile_menu_item -> { - showBlockUserDialog() - true - } - else -> false - } - } - - viewBinding.createPostButton.setOnClickListener { - createDiscussion.launch(Unit) - } - - viewBinding.goToFollowersBtn.setOnClickListener { - findNavController().navigate(ProfileMainFragmentDirections.actionDiscussionsFragmentToMainFollowersFragment(userId!!)) - } - -// viewBinding.feedRecyclerView.adapter = postsAdapter -// viewBinding.feedRecyclerView.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false) - - viewBinding.swipeToRefresh.setOnRefreshListener { - viewModel.getFeed(true, null) - } - viewBinding.playButton.setOnClickListener { - audioDescriptionAudio?.let { audio -> - - } - } - - viewBinding.followButton.setOnClickListener { - it.isEnabled = false - viewModel.followUser() - } - } - - private fun setObservers() { - viewModel.profile.observe(viewLifecycleOwner, profileObserver) - //viewModel.posts.observe(viewLifecycleOwner, postsObserver) - viewModel.followingState.observe(viewLifecycleOwner, followingStateObserver) - optionsViewModel.optionClicked.observe(viewLifecycleOwner, optionsObserver) - viewModel.loadingPosts.observe(viewLifecycleOwner) { - viewBinding.loadingIndicator.visibility = if(it) View.VISIBLE else View.GONE - if(!it) { - viewBinding.swipeToRefresh.isRefreshing = false - } - } - viewModel.errorLoading.observe(viewLifecycleOwner) { - errorViewModel.error.postValue(it) - } - viewModel.followingLoading.observe(viewLifecycleOwner) { - viewBinding.followButton.isEnabled = !it - } - } - - private fun getData() { - - userId?.let { profileId -> - viewModel.profileId = profileId - viewModel.getProfile() - viewModel.getFeed(true, null) - } - } - - private fun setupPostsAdapter() { - val markwon = Markwon.builder(requireContext()) - .usePlugin(object: AbstractMarkwonPlugin() { - override fun configureConfiguration(builder: MarkwonConfiguration.Builder) { - builder - .imageDestinationProcessor( - ImageDestinationProcessorRelativeToAbsolute - .create(BuildConfig.backend)) - } - }) - .usePlugin(LinkifyPlugin.create()) - .build() - - //postsAdapter = PostsRecyclerViewAdapter(postListingRecyclerViewAdapterWiring ) - - } - - private fun setupUiForUserType(isOwnProfile: Boolean) { - if(isOwnProfile) { - viewBinding.followButton.visibility = View.GONE - viewBinding.topAppBar.menu?.run { - removeItem(R.id.block_profile_menu_item) - removeItem(R.id.report_profile_menu_item) - } - viewBinding.descriptionCard.setOnClickListener { - optionsViewModel.setOptions(Options.PROFILE_DESCRIPTION_OPTIONS, CALLER_ID, null) - val fragment = BottomSheetPostOptionsFragment() - fragment.show(parentFragmentManager, BottomSheetPostOptionsFragment.TAG) - } - } else { - viewBinding.createPostButton.visibility = View.GONE - viewBinding.topAppBar.menu.removeItem(R.id.edit_profile) - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - userId = (requireActivity()).intent.extras?.getInt(ProfileActivity.EXTRA_USER_ID) - getData() - } - - override fun onAttach(context: Context) { - super.onAttach(context) - - postListingRecyclerViewAdapterWiring = object: PostListingRecyclerViewAdapterWiring(viewModel) { - override fun onComment(postId: Long) { - val modalBottomSheet = BottomSheetPostComments.getInstance(postId) - modalBottomSheet.show(requireActivity().supportFragmentManager, BottomSheetPostComments.TAG) - } - - override fun onOpenPost(postId: Long) { - PostViewerActivity.startActivity(requireContext(), postId) - } - - override fun onOptions(post: Ownable) { - optionsViewModel.setOptions(Options.POST_OPTIONS, CALLER_ID, post) - val modalBottomSheet = BottomSheetPostOptionsFragment() - modalBottomSheet.show(requireActivity().supportFragmentManager, BottomSheetPostOptionsFragment.TAG) - } - - override fun onProfileClick(userId: Int) { - //ProfileActivity.startActivity(requireContext(), userId) - } - - override fun onPlay(audio: Audio) { - - } - - override fun onLoadMore() { - viewModel.getFeed(false, null) - } - - override fun onMoreInfo(postId: Long) { - PostInfoActivity.startActivity(requireContext(), postId) - } - - override fun onShare(postId: Long) { - val intent = Intent.createChooser(Intent().apply { - action = Intent.ACTION_SEND - putExtra(Intent.EXTRA_TEXT, "${BuildConfig.backend}/pub/${postId}") - type = "text/plain" - }, getString(R.string.share_post)) - startActivity(intent) - } - - override fun hashtagClicked(hashtag: String) { - HashtagsPostsActivity.startActivity(requireContext(), hashtag) - } - } - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - viewBinding = FragmentDiscussionsBinding.inflate(inflater) - - return viewBinding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - setupPostsAdapter() - bind() - setObservers() - setupCollapsingBar() - - - viewLifecycleOwner.lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - errorViewModel.retry.collect { - viewModel.retry() - errorViewModel.handleRetry() - } - } - } - } - companion object { const val CALLER_ID = 30 const val LOG_TAG = "ProfileMainFragment" diff --git a/app/src/main/java/com/isolaatti/profile/ui/QrCodeFragment.kt b/app/src/main/java/com/isolaatti/profile/ui/QrCodeFragment.kt index 97c52d8..fc1465d 100644 --- a/app/src/main/java/com/isolaatti/profile/ui/QrCodeFragment.kt +++ b/app/src/main/java/com/isolaatti/profile/ui/QrCodeFragment.kt @@ -5,10 +5,15 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.WindowInsets import androidmads.library.qrgenearator.BuildConfig import androidmads.library.qrgenearator.QRGContents import androidmads.library.qrgenearator.QRGEncoder import androidx.core.content.res.ResourcesCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs @@ -49,6 +54,13 @@ class QrCodeFragment : Fragment() { binding.toolbar.setNavigationOnClickListener { findNavController().popBackStack() } + + ViewCompat.setOnApplyWindowInsetsListener(view) { _, insets -> + val topPadding = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + binding.toolbar.updatePadding(top = topPadding.top) + insets + } + } private fun generateUrl(): String { diff --git a/app/src/main/java/com/isolaatti/profile/ui/components/ProfileHeader.kt b/app/src/main/java/com/isolaatti/profile/ui/components/ProfileHeader.kt new file mode 100644 index 0000000..4fb65e9 --- /dev/null +++ b/app/src/main/java/com/isolaatti/profile/ui/components/ProfileHeader.kt @@ -0,0 +1,173 @@ +package com.isolaatti.profile.ui.components + +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.animateValue +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import com.isolaatti.R +import com.isolaatti.audio.common.domain.Audio +import com.isolaatti.images.common.domain.entity.RemoteImage + +@Composable +private fun ProfileHeaderLoading() { + val infiniteTransition = rememberInfiniteTransition() + val tonalElevation by infiniteTransition.animateValue( + initialValue = 6.dp, + targetValue = 30.dp, + typeConverter = Dp.VectorConverter, + animationSpec = infiniteRepeatable( + animation = tween(1000), + repeatMode = RepeatMode.Reverse + ), label = "tonal elevation for loading skeleton" + ) + Surface(modifier = Modifier.fillMaxSize(), tonalElevation = tonalElevation) {} +} + +@Composable +fun ProfileHeader( + name: String, + description: String?, + image: RemoteImage?, + audio: Audio?, + followingText: String?, + followerCount: Int, + followingCount: Int, + onImageClick: () -> Unit, + showProfileAudioProgress: Boolean, + profileAudioProgress: Float, + onFollowersClick: () -> Unit, + onEditProfileClick: () -> Unit, + onFollowChange: (Boolean) -> Unit, + loading: Boolean = false, + isOwnUser: Boolean, + followingThisUser: Boolean, + modifier: Modifier = Modifier +) { + Box(modifier) { + if(loading) { + ProfileHeaderLoading() + } else { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + + if(image != null) { + AsyncImage( + model = image.reducedImageUrl, + contentDescription = stringResource(R.string.profile_photo), + modifier = Modifier + .size(140.dp) + .padding(16.dp) + .clip(RoundedCornerShape(50)) + .clickable { + onImageClick() + } + ) + } else { + Image( + painterResource(R.drawable.avatar), + contentDescription = stringResource(R.string.profile_photo), + modifier = Modifier + .size(140.dp) + .padding(16.dp) + .clip(RoundedCornerShape(50)) + .clickable { + onImageClick() + } + ) + } + } + + if(!isOwnUser) { + followingText?.let { + Text(it) + } + } + + + Text(name, fontSize = 20.sp, modifier = Modifier.padding(top = 16.dp)) + description?.let { + Text(it, modifier = Modifier.padding(top = 8.dp)) + } + + TextButton(onClick = onFollowersClick, modifier = Modifier.padding(top = 4.dp)) { + Text(stringResource(R.string.go_to_followers_btn_text, followerCount, followingCount)) + } + + when { + isOwnUser -> { + OutlinedButton(onClick = onEditProfileClick) { + Icon(Icons.Default.Edit, null) + Text(stringResource(R.string.edit_profile)) + } + } + + followingThisUser -> { + OutlinedButton(onClick = { onFollowChange(false) }) { + Text(stringResource(R.string.unfollow)) + } + } + else -> { + Button(onClick = { onFollowChange(true) }) { + Text(stringResource(R.string.follow)) + } + } + } + } + } + + } +} + +@Composable +@Preview(device = Devices.PIXEL_3) +fun ProfileHeaderPreview() { + ProfileHeader( + name = "Erik", + description = "Hola", + image = null, + audio = null, + followingText = "Mutually following", + followerCount = 100, + followingCount = 100, + isOwnUser = true, + followingThisUser = true, + onImageClick = {}, + profileAudioProgress = 0.6f, + showProfileAudioProgress = true, + onFollowersClick = {}, + onFollowChange = {}, + onEditProfileClick = {} + ) +} \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_discussions.xml b/app/src/main/res/layout/fragment_discussions.xml index 5a7dfd2..fe35171 100644 --- a/app/src/main/res/layout/fragment_discussions.xml +++ b/app/src/main/res/layout/fragment_discussions.xml @@ -58,62 +58,7 @@ app:layout_constraintTop_toBottomOf="@id/text_view_following_state" tools:text="Erik Cavazos" /> - - - - - - - - - - - - - - + app:layout_constraintTop_toBottomOf="@id/text"/> @@ -175,17 +120,14 @@ - - - + + - #4d3b68 + #A77EE4 #3F0095 #1D1725 #FFFFFF diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7ae99d4..b70e16b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -226,6 +226,10 @@ Pause audio Play audio Just recorded audio + Back button + Profile + Add post button + When %s posts something you will see it here Spam Explicit content