WIP: se agrega configurar foto de perfil y otro trabajo en progreso

This commit is contained in:
erik 2025-03-17 22:33:10 -06:00
parent b019c79bcb
commit 0e553e05e6
12 changed files with 503 additions and 43 deletions

View File

@ -21,8 +21,8 @@ interface ImagesApi {
@POST("images/create") @POST("images/create")
@Multipart @Multipart
fun postImage(@Part file: MultipartBody.Part, fun postImage(@Part file: MultipartBody.Part,
@Part postId: MultipartBody.Part, @Part postId: MultipartBody.Part? = null,
@Part setAsProfile: MultipartBody.Part? = null, @Query("setAsProfile") setAsProfile: Boolean = false,
@Part squadId: MultipartBody.Part? = null): Call<ImageDto> @Part squadId: MultipartBody.Part? = null): Call<ImageDto>
@POST("images/delete/delete_many") @POST("images/delete/delete_many")

View File

@ -1,8 +1,10 @@
package com.isolaatti.profile package com.isolaatti.profile
import android.content.ContentResolver
import com.isolaatti.auth.data.local.UserInfoDao import com.isolaatti.auth.data.local.UserInfoDao
import com.isolaatti.connectivity.RetrofitClient import com.isolaatti.connectivity.RetrofitClient
import com.isolaatti.database.AppDatabase import com.isolaatti.database.AppDatabase
import com.isolaatti.images.common.data.remote.ImagesApi
import com.isolaatti.profile.data.remote.ProfileApi import com.isolaatti.profile.data.remote.ProfileApi
import com.isolaatti.profile.data.repository.ProfileRepositoryImpl import com.isolaatti.profile.data.repository.ProfileRepositoryImpl
import com.isolaatti.profile.domain.ProfileRepository import com.isolaatti.profile.domain.ProfileRepository
@ -25,8 +27,8 @@ class Module {
} }
@Provides @Provides
fun provideProfileRepository(profileApi: ProfileApi, userInfoDao: UserInfoDao): ProfileRepository { fun provideProfileRepository(profileApi: ProfileApi, userInfoDao: UserInfoDao, imagesApi: ImagesApi, contentResolver: ContentResolver): ProfileRepository {
return ProfileRepositoryImpl(profileApi, userInfoDao) return ProfileRepositoryImpl(profileApi, userInfoDao, imagesApi, contentResolver)
} }

View File

@ -12,8 +12,6 @@ interface ProfileApi {
@GET("Fetch/UserProfile/{userId}") @GET("Fetch/UserProfile/{userId}")
fun userProfile(@Path("userId") userId: Int): Call<UserProfileDto> fun userProfile(@Path("userId") userId: Int): Call<UserProfileDto>
@POST("EditProfile/SetProfilePhoto")
fun setProfileImage(@Query("imageId") imageId: String): Call<Void>
@POST("EditProfile/UpdateProfile") @POST("EditProfile/UpdateProfile")
fun updateProfile(@Body updateProfileDto: UpdateProfileDto): Call<UserProfileDto> fun updateProfile(@Body updateProfileDto: UpdateProfileDto): Call<UserProfileDto>

View File

@ -1,8 +1,14 @@
package com.isolaatti.profile.data.repository package com.isolaatti.profile.data.repository
import android.content.ContentResolver
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Build
import android.util.Log import android.util.Log
import com.isolaatti.auth.data.local.UserInfoDao import com.isolaatti.auth.data.local.UserInfoDao
import com.isolaatti.auth.data.local.UserInfoEntity import com.isolaatti.auth.data.local.UserInfoEntity
import com.isolaatti.images.common.data.remote.ImagesApi
import com.isolaatti.images.common.domain.entity.LocalImage
import com.isolaatti.images.common.domain.entity.RemoteImage import com.isolaatti.images.common.domain.entity.RemoteImage
import com.isolaatti.profile.data.remote.ProfileApi import com.isolaatti.profile.data.remote.ProfileApi
import com.isolaatti.profile.data.remote.UpdateProfileDto import com.isolaatti.profile.data.remote.UpdateProfileDto
@ -11,12 +17,20 @@ import com.isolaatti.profile.domain.entity.UserProfile
import com.isolaatti.utils.Resource import com.isolaatti.utils.Resource
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
import retrofit2.awaitResponse import retrofit2.awaitResponse
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.InputStream
import javax.inject.Inject import javax.inject.Inject
class ProfileRepositoryImpl @Inject constructor( class ProfileRepositoryImpl @Inject constructor(
private val profileApi: ProfileApi, private val profileApi: ProfileApi,
private val userInfoDao: UserInfoDao) : ProfileRepository { private val userInfoDao: UserInfoDao,
private val imagesApi: ImagesApi,
private val contentResolver: ContentResolver
) : ProfileRepository {
override fun getProfile(userId: Int): Flow<Resource<UserProfile>> = flow { override fun getProfile(userId: Int): Flow<Resource<UserProfile>> = flow {
try { try {
val result = profileApi.userProfile(userId).awaitResponse() val result = profileApi.userProfile(userId).awaitResponse()
@ -37,16 +51,41 @@ class ProfileRepositoryImpl @Inject constructor(
} }
} }
override fun setProfileImage(image: RemoteImage): Flow<Resource<Boolean>> = flow { override fun setProfileImage(image: LocalImage): Flow<Resource<RemoteImage>> = flow {
emit(Resource.Loading())
try { try {
val result = profileApi.setProfileImage(image.id).awaitResponse() var imageInputStream: InputStream? = null
contentResolver.openInputStream(image.uri).use {
imageInputStream = it
val bitmap = BitmapFactory.decodeStream(imageInputStream)
val outputStream = ByteArrayOutputStream()
if(Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
bitmap.compress(Bitmap.CompressFormat.WEBP, 50, outputStream)
} else {
bitmap.compress(Bitmap.CompressFormat.WEBP_LOSSY, 50, outputStream)
}
val result = imagesApi
.postImage(
file = MultipartBody.Part.createFormData("file", "profile-${System.currentTimeMillis()}",outputStream.toByteArray().toRequestBody()),
setAsProfile = true
)
.awaitResponse()
if(result.isSuccessful) { if(result.isSuccessful) {
emit(Resource.Success(true)) val imageDto = result.body()
emit(Resource.Success(RemoteImage(imageDto!!.id)))
} else { } else {
emit(Resource.Error(Resource.Error.mapErrorCode(result.code()))) emit(Resource.Error(Resource.Error.mapErrorCode(result.code())))
} }
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e("ProfileRepositoryImpl", e.message.toString()) Log.e("ProfileRepositoryImpl", e.message.toString())
emit(Resource.Error(Resource.Error.ErrorType.NetworkError)) emit(Resource.Error(Resource.Error.ErrorType.NetworkError))

View File

@ -1,5 +1,7 @@
package com.isolaatti.profile.domain package com.isolaatti.profile.domain
import com.isolaatti.images.common.data.remote.ImageDto
import com.isolaatti.images.common.domain.entity.LocalImage
import com.isolaatti.images.common.domain.entity.RemoteImage import com.isolaatti.images.common.domain.entity.RemoteImage
import com.isolaatti.profile.domain.entity.UserProfile import com.isolaatti.profile.domain.entity.UserProfile
import com.isolaatti.utils.Resource import com.isolaatti.utils.Resource
@ -8,7 +10,7 @@ import kotlinx.coroutines.flow.Flow
interface ProfileRepository { interface ProfileRepository {
fun getProfile(userId: Int): Flow<Resource<UserProfile>> fun getProfile(userId: Int): Flow<Resource<UserProfile>>
fun setProfileImage(image: RemoteImage): Flow<Resource<Boolean>> fun setProfileImage(image: LocalImage): Flow<Resource<RemoteImage>>
fun updateProfile(newDisplayName: String, newDescription: String): Flow<Resource<Boolean>> fun updateProfile(newDisplayName: String, newDescription: String): Flow<Resource<Boolean>>

View File

@ -1,5 +1,6 @@
package com.isolaatti.profile.domain.use_case package com.isolaatti.profile.domain.use_case
import com.isolaatti.images.common.domain.entity.LocalImage
import com.isolaatti.images.common.domain.entity.RemoteImage import com.isolaatti.images.common.domain.entity.RemoteImage
import com.isolaatti.profile.domain.ProfileRepository import com.isolaatti.profile.domain.ProfileRepository
import com.isolaatti.utils.Resource import com.isolaatti.utils.Resource
@ -7,5 +8,5 @@ import kotlinx.coroutines.flow.Flow
import javax.inject.Inject import javax.inject.Inject
class SetProfileImage @Inject constructor(private val profileRepository: ProfileRepository) { class SetProfileImage @Inject constructor(private val profileRepository: ProfileRepository) {
operator fun invoke(image: RemoteImage): Flow<Resource<Boolean>> = profileRepository.setProfileImage(image) operator fun invoke(image: LocalImage): Flow<Resource<RemoteImage>> = profileRepository.setProfileImage(image)
} }

View File

@ -1,10 +1,16 @@
package com.isolaatti.profile.presentation package com.isolaatti.profile.presentation
import android.net.Uri
import android.util.Log import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.isolaatti.followers.domain.FollowingState import com.isolaatti.followers.domain.FollowingState
import com.isolaatti.images.common.domain.entity.LocalImage
import com.isolaatti.images.common.domain.entity.RemoteImage import com.isolaatti.images.common.domain.entity.RemoteImage
import com.isolaatti.posting.posts.presentation.PostListingViewModelBase import com.isolaatti.posting.posts.presentation.PostListingViewModelBase
import com.isolaatti.profile.domain.entity.UserProfile import com.isolaatti.profile.domain.entity.UserProfile
@ -45,6 +51,12 @@ class ProfileViewModel @Inject constructor(
val isRefreshing: MutableStateFlow<Boolean> = MutableStateFlow(false) val isRefreshing: MutableStateFlow<Boolean> = MutableStateFlow(false)
val showConfirmChangeProfilePictureBottomSheet: MutableStateFlow<Boolean> = MutableStateFlow(false)
val newProfileImage: MutableStateFlow<Uri?> = MutableStateFlow(null)
val uploadingProfilePicture: MutableStateFlow<Boolean> = MutableStateFlow(false)
val errorProfilePicture: MutableStateFlow<Resource.Error<RemoteImage>?> = MutableStateFlow(null)
// runs the lists of "Runnable" one by one and clears list. After this is executed, // runs the lists of "Runnable" one by one and clears list. After this is executed,
// caller should report as handled // caller should report as handled
@ -91,13 +103,31 @@ class ProfileViewModel @Inject constructor(
} }
} }
fun setProfileImage(image: RemoteImage) { fun setProfileImage() {
viewModelScope.launch { viewModelScope.launch {
setProfileImageUC(image).onEach { newProfileImage.value?.let {
_profile.postValue(_profile.value?.copy(profileImageId = image.id)) setProfileImageUC(LocalImage(it)).onEach { resource ->
when(resource) {
is Resource.Error<RemoteImage> -> {
errorProfilePicture.value = resource
uploadingProfilePicture.value = false
}
is Resource.Loading<RemoteImage> -> {
errorProfilePicture.value = null
uploadingProfilePicture.value = true
}
is Resource.Success<RemoteImage> -> {
errorProfilePicture.value = null
uploadingProfilePicture.value = false
showConfirmChangeProfilePictureBottomSheet.value = false
_profile.postValue(_profile.value?.copy(profileImageId = resource.data?.id))
}
}
}.flowOn(Dispatchers.IO).launchIn(this) }.flowOn(Dispatchers.IO).launchIn(this)
} }
} }
}
fun followUser() { fun followUser() {
val currentProfile = _profile.value ?: return val currentProfile = _profile.value ?: return
@ -181,4 +211,18 @@ class ProfileViewModel @Inject constructor(
}.flowOn(Dispatchers.IO).launchIn(this) }.flowOn(Dispatchers.IO).launchIn(this)
} }
} }
fun hideConfirmPictureSheet() {
viewModelScope.launch {
showConfirmChangeProfilePictureBottomSheet.value = false
newProfileImage.value = null
}
}
fun onProfilePictureSelected(uri: Uri) {
viewModelScope.launch {
showConfirmChangeProfilePictureBottomSheet.value = true
newProfileImage.value = uri
}
}
} }

View File

@ -2,12 +2,17 @@ package com.isolaatti.profile.ui
import android.content.ComponentName import android.content.ComponentName
import android.content.Intent import android.content.Intent
import android.media.MediaRecorder
import android.net.Uri
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
@ -23,10 +28,12 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
@ -34,6 +41,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
@ -45,6 +53,8 @@ import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
@ -54,10 +64,13 @@ import androidx.lifecycle.repeatOnLifecycle
import androidx.media3.session.MediaController import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken import androidx.media3.session.SessionToken
import androidx.navigation.findNavController import androidx.navigation.findNavController
import coil3.toUri
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.ListenableFuture
import com.isolaatti.BuildConfig import com.isolaatti.BuildConfig
import com.isolaatti.MyApplication
import com.isolaatti.R import com.isolaatti.R
import com.isolaatti.audio.common.components.AudioRecorder
import com.isolaatti.audio.player.MediaService import com.isolaatti.audio.player.MediaService
import com.isolaatti.common.Dialogs import com.isolaatti.common.Dialogs
import com.isolaatti.common.ErrorMessageViewModel import com.isolaatti.common.ErrorMessageViewModel
@ -67,22 +80,30 @@ import com.isolaatti.common.options_bottom_sheet.presentation.BottomSheetPostOpt
import com.isolaatti.common.options_bottom_sheet.ui.BottomSheetPostOptionsFragment import com.isolaatti.common.options_bottom_sheet.ui.BottomSheetPostOptionsFragment
import com.isolaatti.followers.domain.FollowingState import com.isolaatti.followers.domain.FollowingState
import com.isolaatti.hashtags.ui.HashtagsPostsActivity import com.isolaatti.hashtags.ui.HashtagsPostsActivity
import com.isolaatti.images.common.domain.entity.LocalImage
import com.isolaatti.images.picture_viewer.ui.PictureViewerActivity import com.isolaatti.images.picture_viewer.ui.PictureViewerActivity
import com.isolaatti.posting.comments.ui.BottomSheetPostComments import com.isolaatti.posting.comments.ui.BottomSheetPostComments
import com.isolaatti.posting.posts.components.PostComponent import com.isolaatti.posting.posts.components.PostComponent
import com.isolaatti.posting.posts.domain.entity.Post import com.isolaatti.posting.posts.domain.entity.Post
import com.isolaatti.posting.posts.presentation.CreatePostContract import com.isolaatti.posting.posts.presentation.CreatePostContract
import com.isolaatti.posting.posts.ui.CreatePostActivity
import com.isolaatti.posting.posts.ui.PostInfoActivity import com.isolaatti.posting.posts.ui.PostInfoActivity
import com.isolaatti.posting.posts.viewer.ui.PostViewerActivity import com.isolaatti.posting.posts.viewer.ui.PostViewerActivity
import com.isolaatti.profile.domain.entity.UserProfile import com.isolaatti.profile.domain.entity.UserProfile
import com.isolaatti.profile.presentation.EditProfileContract import com.isolaatti.profile.presentation.EditProfileContract
import com.isolaatti.profile.presentation.ProfileViewModel import com.isolaatti.profile.presentation.ProfileViewModel
import com.isolaatti.profile.ui.components.ConfirmChangeProfilePictureBottomSheet
import com.isolaatti.profile.ui.components.ProfileHeader import com.isolaatti.profile.ui.components.ProfileHeader
import com.isolaatti.profile.ui.components.ProfilePictureBottomSheet
import com.isolaatti.profile.ui.components.ProfilePictureBottomSheetActions
import com.isolaatti.profile.ui.components.ProfilePictureState
import com.isolaatti.reports.data.ContentType import com.isolaatti.reports.data.ContentType
import com.isolaatti.reports.ui.NewReportBottomSheetDialogFragment import com.isolaatti.reports.ui.NewReportBottomSheetDialogFragment
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.guava.await import kotlinx.coroutines.guava.await
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File
import java.util.Calendar
@AndroidEntryPoint @AndroidEntryPoint
class ProfileMainFragment : Fragment() { class ProfileMainFragment : Fragment() {
@ -91,6 +112,19 @@ class ProfileMainFragment : Fragment() {
val errorViewModel: ErrorMessageViewModel by activityViewModels() val errorViewModel: ErrorMessageViewModel by activityViewModels()
private var userId: Int? = null private var userId: Int? = null
private var cameraPhotoUri: Uri? = null
private fun makePhotoUri(): Uri {
val cacheFile = File(requireContext().filesDir, "temp_picture_${Calendar.getInstance().timeInMillis}")
return FileProvider.getUriForFile(requireContext(), "${MyApplication.myApp.packageName}.provider", cacheFile)
}
private val takePhotoLauncher = registerForActivityResult(ActivityResultContracts.TakePicture()) {
if(it && cameraPhotoUri != null) {
viewModel.onProfilePictureSelected(cameraPhotoUri!!)
}
}
private val createDiscussion = registerForActivityResult(CreatePostContract()) { private val createDiscussion = registerForActivityResult(CreatePostContract()) {
if(it == null) if(it == null)
return@registerForActivityResult return@registerForActivityResult
@ -106,7 +140,11 @@ class ProfileMainFragment : Fragment() {
} }
} }
private val choosePictureLauncher = registerForActivityResult(ActivityResultContracts.PickVisualMedia()) {
if(it != null) {
viewModel.onProfilePictureSelected(it)
}
}
private fun getData() { private fun getData() {
@ -119,6 +157,7 @@ class ProfileMainFragment : Fragment() {
private lateinit var mediaControllerFuture: ListenableFuture<MediaController> private lateinit var mediaControllerFuture: ListenableFuture<MediaController>
private lateinit var mediaController: MediaController private lateinit var mediaController: MediaController
private lateinit var mediaRecorder: MediaRecorder
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -131,6 +170,8 @@ class ProfileMainFragment : Fragment() {
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
@ -174,8 +215,25 @@ class ProfileMainFragment : Fragment() {
} }
} }
/* Profile picture bottom sheet */
var showProfilePictureBottomSheet by remember { mutableStateOf(false) }
val profilePictureSheetState = rememberModalBottomSheetState()
var profilePictureState by remember { mutableStateOf(ProfilePictureState()) }
val scope = rememberCoroutineScope()
val showConfirmChangeProfilePicture by viewModel.showConfirmChangeProfilePictureBottomSheet.collectAsState()
val newProfilePicture by viewModel.newProfileImage.collectAsState()
val uploadingProfilePicture by viewModel.uploadingProfilePicture.collectAsState()
var showAudioRecorder by remember { mutableStateOf(false) }
LaunchedEffect(profile) { LaunchedEffect(profile) {
showAddPostButton = profile?.isUserItself == true showAddPostButton = profile?.isUserItself == true
profilePictureState = ProfilePictureState(
showViewImage = profile?.profileImage != null,
showChangeProfileImage = profile?.isUserItself == true,
showRemoveImage = profile?.isUserItself == true
)
} }
val followingText = when(followingState) { val followingText = when(followingState) {
@ -199,7 +257,6 @@ class ProfileMainFragment : Fragment() {
val posts by viewModel.posts.collectAsState() val posts by viewModel.posts.collectAsState()
val loadingProfile by viewModel.loadingProfile.collectAsState() val loadingProfile by viewModel.loadingProfile.collectAsState()
val playingAudio by remember { mutableStateOf(true) }
IsolaattiTheme { IsolaattiTheme {
Scaffold( Scaffold(
topBar = { topBar = {
@ -245,6 +302,80 @@ class ProfileMainFragment : Fragment() {
} }
) { insets -> ) { insets ->
if(showProfilePictureBottomSheet) {
ProfilePictureBottomSheet(
sheetState = profilePictureSheetState,
onDismissRequest = {
showProfilePictureBottomSheet = false
},
onAction = { action ->
when(action) {
ProfilePictureBottomSheetActions.ViewImage -> {
profile?.profileImage?.let {
PictureViewerActivity.startActivityWithImages(requireContext(), arrayOf(it))
}
}
ProfilePictureBottomSheetActions.ChangeImage -> {
choosePictureLauncher
.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
}
ProfilePictureBottomSheetActions.RemoveImage -> {
showRemoveProfileImageDialog()
}
ProfilePictureBottomSheetActions.TakeAPicture -> {
cameraPhotoUri = makePhotoUri()
takePhotoLauncher.launch(cameraPhotoUri)
}
}
scope.launch { profilePictureSheetState.hide() }.invokeOnCompletion {
if (!profilePictureSheetState.isVisible) {
showProfilePictureBottomSheet = false
}
}
},
state = profilePictureState
)
}
if(showConfirmChangeProfilePicture) {
newProfilePicture?.let {
ConfirmChangeProfilePictureBottomSheet(
onDismiss = {
viewModel.hideConfirmPictureSheet()
},
oldImage = profile?.profileImage?.imageUrl?.toUri(),
newImage = it,
onAccept = {
viewModel.setProfileImage()
},
loading = uploadingProfilePicture
)
}
}
if(showAudioRecorder) {
ModalBottomSheet(onDismissRequest = { showAudioRecorder = false }) {
AudioRecorder(
onDismiss = {},
onPlay = TODO(),
onPause = TODO(),
onStopRecording = TODO(),
onPauseRecording = TODO(),
onStartRecording = TODO(),
isRecording = TODO(),
recordIsStopped = TODO(),
recordIsPaused = TODO(),
isPlaying = TODO(),
isLoading = TODO(),
durationSeconds = TODO(),
position = TODO(),
onSeek = TODO(),
modifier = TODO()
)
}
}
PullToRefreshBox( PullToRefreshBox(
isRefreshing, isRefreshing,
onRefresh = { onRefresh = {
@ -273,9 +404,7 @@ class ProfileMainFragment : Fragment() {
followerCount = it.numberOfFollowers, followerCount = it.numberOfFollowers,
loading = loadingProfile, loading = loadingProfile,
onImageClick = { onImageClick = {
optionsViewModel.setOptions(Options.PROFILE_PHOTO_OPTIONS, CALLER_ID, it) showProfilePictureBottomSheet = true
val fragment = BottomSheetPostOptionsFragment()
fragment.show(parentFragmentManager, BottomSheetPostOptionsFragment.TAG)
}, },
onFollowersClick = { onFollowersClick = {
findNavController().navigate(ProfileMainFragmentDirections.actionDiscussionsFragmentToMainFollowersFragment(it.userId)) findNavController().navigate(ProfileMainFragmentDirections.actionDiscussionsFragmentToMainFollowersFragment(it.userId))
@ -290,6 +419,11 @@ class ProfileMainFragment : Fragment() {
showProfileAudioProgress = true, showProfileAudioProgress = true,
onEditProfileClick = { onEditProfileClick = {
editProfile.launch(it) editProfile.launch(it)
},
onAddAudioDescription = {
mediaRecorder = if (Build.VERSION.SDK_INT >= 31) MediaRecorder(requireContext()) else MediaRecorder()
showAudioRecorder = true
} }
) )
} }
@ -376,23 +510,6 @@ class ProfileMainFragment : Fragment() {
Log.d("ProfileMainFragment", optionClicked.toString()) Log.d("ProfileMainFragment", optionClicked.toString())
when(optionClicked.optionsId) { 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 -> { Options.POST_OPTIONS -> {
// post id should come as payload // post id should come as payload
val post = optionClicked.payload as? Post ?: return@observe val post = optionClicked.payload as? Post ?: return@observe

View File

@ -0,0 +1,120 @@
package com.isolaatti.profile.ui.components
import android.net.Uri
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import com.isolaatti.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConfirmChangeProfilePictureBottomSheet(
onDismiss: () -> Unit,
oldImage: Uri?,
newImage: Uri,
onAccept: () -> Unit,
loading: Boolean
) {
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = stringResource(R.string.change_profile_photo_question),
fontSize = 20.sp,
textAlign = TextAlign.Center,
modifier = Modifier.padding(16.dp).fillMaxWidth()
)
if(!loading) {
Box(Modifier.padding(8.dp).fillMaxWidth()) {
if(oldImage != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
AsyncImage(
model = oldImage,
contentDescription = stringResource(R.string.old_picture),
modifier = Modifier.size(70.dp)
.clip(RoundedCornerShape(50))
)
Icon(Icons.Default.ArrowForward, contentDescription = null, modifier = Modifier.size(40.dp))
AsyncImage(
model = newImage,
contentDescription = stringResource(R.string.old_picture),
modifier = Modifier
.clip(RoundedCornerShape(50))
.size(70.dp)
)
}
} else {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
){
AsyncImage(
model = newImage,
contentDescription = stringResource(R.string.old_picture),
modifier = Modifier
.clip(RoundedCornerShape(50))
.size(70.dp)
)
}
}
}
Row(modifier = Modifier.padding(8.dp).fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
FilledTonalButton(
onClick = {
onDismiss()
}
) {
Text(stringResource(R.string.cancel))
}
Spacer(Modifier.width(8.dp))
Button(
onClick = {
onAccept()
}
) {
Text(stringResource(R.string.accept))
}
}
} else {
CircularProgressIndicator()
Text(stringResource(R.string.uploading_photo))
}
}
}
}

View File

@ -6,7 +6,9 @@ import androidx.compose.animation.core.animateValue
import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -18,8 +20,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@ -70,6 +74,7 @@ fun ProfileHeader(
onFollowersClick: () -> Unit, onFollowersClick: () -> Unit,
onEditProfileClick: () -> Unit, onEditProfileClick: () -> Unit,
onFollowChange: (Boolean) -> Unit, onFollowChange: (Boolean) -> Unit,
onAddAudioDescription: () -> Unit,
loading: Boolean = false, loading: Boolean = false,
isOwnUser: Boolean, isOwnUser: Boolean,
followingThisUser: Boolean, followingThisUser: Boolean,
@ -125,6 +130,27 @@ fun ProfileHeader(
Text(stringResource(R.string.go_to_followers_btn_text, followerCount, followingCount)) Text(stringResource(R.string.go_to_followers_btn_text, followerCount, followingCount))
} }
when {
isOwnUser && audio == null -> {
OutlinedCard(modifier = Modifier.padding(16.dp).fillMaxWidth()) {
Column(modifier = Modifier.padding(8.dp).fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
Text(stringResource(R.string.you_have_now_audio))
FilledTonalButton(
onClick = {
}
) {
Text(stringResource(R.string.set_audio_description))
}
}
}
}
audio != null -> {
}
}
when { when {
isOwnUser -> { isOwnUser -> {
OutlinedButton(onClick = onEditProfileClick) { OutlinedButton(onClick = onEditProfileClick) {
@ -168,6 +194,7 @@ fun ProfileHeaderPreview() {
showProfileAudioProgress = true, showProfileAudioProgress = true,
onFollowersClick = {}, onFollowersClick = {},
onFollowChange = {}, onFollowChange = {},
onEditProfileClick = {} onEditProfileClick = {},
onAddAudioDescription = {}
) )
} }

View File

@ -0,0 +1,106 @@
package com.isolaatti.profile.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.isolaatti.R
data class ProfilePictureState(
val showViewImage: Boolean = true,
val showChangeProfileImage: Boolean = true,
val showRemoveImage: Boolean = true
)
enum class ProfilePictureBottomSheetActions {
ViewImage, ChangeImage, RemoveImage, TakeAPicture
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfilePictureBottomSheet(
onDismissRequest: () -> Unit,
sheetState: SheetState = rememberModalBottomSheetState(),
state: ProfilePictureState,
onAction: (ProfilePictureBottomSheetActions) -> Unit
) {
ModalBottomSheet(onDismissRequest = onDismissRequest, sheetState = sheetState) {
Column {
Text(
text = stringResource(R.string.profile_photo),
fontSize = 20.sp,
textAlign = TextAlign.Center,
modifier = Modifier.padding(16.dp).fillMaxWidth()
)
if(state.showViewImage) {
TextButton(
onClick = { onAction(ProfilePictureBottomSheetActions.ViewImage) },
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.view_photo))
}
}
if(state.showChangeProfileImage) {
Box {
var expanded by remember { mutableStateOf(false) }
TextButton(
onClick = {
expanded = true
},
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.change_profile_photo))
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.upload_a_picture)) },
onClick = {
onAction(ProfilePictureBottomSheetActions.ChangeImage)
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.take_a_photo)) },
onClick = {
onAction(ProfilePictureBottomSheetActions.TakeAPicture)
}
)
}
}
}
if(state.showRemoveImage) {
TextButton(
onClick = { onAction(ProfilePictureBottomSheetActions.RemoveImage) },
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.remove_photo))
}
}
}
}
}

View File

@ -230,6 +230,10 @@
<string name="profile">Profile</string> <string name="profile">Profile</string>
<string name="add_post_button_desc">Add post button</string> <string name="add_post_button_desc">Add post button</string>
<string name="you_will_see_what_user_posts_here">When %s posts something you will see it here</string> <string name="you_will_see_what_user_posts_here">When %s posts something you will see it here</string>
<string name="old_picture">Old picture</string>
<string name="change_profile_photo_question">Change profile picture?</string>
<string name="uploading_photo">Uploading photo</string>
<string name="you_have_now_audio">You have no audio description</string>
<string-array name="report_reasons"> <string-array name="report_reasons">
<item>Spam</item> <item>Spam</item>
<item>Explicit content</item> <item>Explicit content</item>