From 727007d94fa199dd62fb6db8f1a768ec6f71efd5 Mon Sep 17 00:00:00 2001 From: erik-everardo Date: Wed, 15 Jan 2025 23:01:39 -0600 Subject: [PATCH] WIP --- .../audio/common/components/AudioRecorder.kt | 34 +++ .../images/common/data/remote/ImagesApi.kt | 2 +- .../data/repository/ImagesRepositoryImpl.kt | 14 +- .../domain/repository/ImagesRepository.kt | 4 +- .../presentation/ImageMakerViewModel.kt | 33 --- .../image_maker/ui/ImageMakerActivity.kt | 84 ------ .../image_maker/ui/ImageMakerContract.kt | 29 -- .../main/java/com/isolaatti/posting/Module.kt | 6 +- .../posts/components/PostAttachments.kt} | 25 +- .../posts/data/remote/CreatePostDto.kt | 3 +- .../posting/posts/data/remote/PostApi.kt | 3 + .../data/repository/PostsRepositoryImpl.kt | 105 +++++++- .../posting/posts/domain/PostsRepository.kt | 3 +- .../posting/posts/domain/use_case/MakePost.kt | 6 +- .../posts/presentation/CreatePostViewModel.kt | 8 +- .../posting/posts/ui/CreatePostActivity.kt | 254 ++++++++++++------ .../ui/CreatePostFragmentStateAdapter.kt | 15 -- .../posts/ui/MarkdownPreviewFragment.kt | 60 ----- .../posting/posts/ui/PostEditingFragment.kt | 157 ----------- .../main/res/layout/activity_create_post.xml | 95 +++---- .../res/layout/fragment_markdown_editing.xml | 33 --- .../res/layout/fragment_markdown_preview.xml | 15 -- 22 files changed, 388 insertions(+), 600 deletions(-) create mode 100644 app/src/main/java/com/isolaatti/audio/common/components/AudioRecorder.kt delete mode 100644 app/src/main/java/com/isolaatti/images/image_maker/presentation/ImageMakerViewModel.kt delete mode 100644 app/src/main/java/com/isolaatti/images/image_maker/ui/ImageMakerActivity.kt delete mode 100644 app/src/main/java/com/isolaatti/images/image_maker/ui/ImageMakerContract.kt rename app/src/main/java/com/isolaatti/{images/common/components/ImagesRow.kt => posting/posts/components/PostAttachments.kt} (86%) delete mode 100644 app/src/main/java/com/isolaatti/posting/posts/ui/CreatePostFragmentStateAdapter.kt delete mode 100644 app/src/main/java/com/isolaatti/posting/posts/ui/MarkdownPreviewFragment.kt delete mode 100644 app/src/main/java/com/isolaatti/posting/posts/ui/PostEditingFragment.kt delete mode 100644 app/src/main/res/layout/fragment_markdown_editing.xml delete mode 100644 app/src/main/res/layout/fragment_markdown_preview.xml diff --git a/app/src/main/java/com/isolaatti/audio/common/components/AudioRecorder.kt b/app/src/main/java/com/isolaatti/audio/common/components/AudioRecorder.kt new file mode 100644 index 0000000..912213b --- /dev/null +++ b/app/src/main/java/com/isolaatti/audio/common/components/AudioRecorder.kt @@ -0,0 +1,34 @@ +package com.isolaatti.audio.common.components + +import android.media.MediaRecorder +import android.net.Uri +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview + +@Composable +fun AudioRecorder( + onAudioRecorded: (Uri) -> Unit, + dismissible: Boolean, + onDismiss: () -> Unit, +) { + + var context = LocalContext.current + var mediaRecorder: MediaRecorder? + + DisposableEffect(Unit) { + mediaRecorder = MediaRecorder() + + onDispose { + mediaRecorder?.release() + } + } +} + +@Preview +@Composable +fun AudioRecorderPreview() { + +} \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/images/common/data/remote/ImagesApi.kt b/app/src/main/java/com/isolaatti/images/common/data/remote/ImagesApi.kt index 899edc0..8ef1362 100644 --- a/app/src/main/java/com/isolaatti/images/common/data/remote/ImagesApi.kt +++ b/app/src/main/java/com/isolaatti/images/common/data/remote/ImagesApi.kt @@ -21,7 +21,7 @@ interface ImagesApi { @POST("images/create") @Multipart fun postImage(@Part file: MultipartBody.Part, - @Part name: MultipartBody.Part, + @Part postId: MultipartBody.Part, @Part setAsProfile: MultipartBody.Part? = null, @Part squadId: MultipartBody.Part? = null): Call diff --git a/app/src/main/java/com/isolaatti/images/common/data/repository/ImagesRepositoryImpl.kt b/app/src/main/java/com/isolaatti/images/common/data/repository/ImagesRepositoryImpl.kt index c232ff9..0460563 100644 --- a/app/src/main/java/com/isolaatti/images/common/data/repository/ImagesRepositoryImpl.kt +++ b/app/src/main/java/com/isolaatti/images/common/data/repository/ImagesRepositoryImpl.kt @@ -62,7 +62,7 @@ class ImagesRepositoryImpl @Inject constructor(private val imagesApi: ImagesApi, } } - override fun uploadImage(name: String, imageUri: Uri, squadId: String?): Flow> = flow { + override fun uploadImage(imageUri: Uri, postId: Long, squadId: String?): Flow> = flow { emit(Resource.Loading()) var imageInputStream: InputStream? = null try { @@ -84,8 +84,8 @@ class ImagesRepositoryImpl @Inject constructor(private val imagesApi: ImagesApi, val response = imagesApi.postImage( - MultipartBody.Part.createFormData("file", name,outputStream.toByteArray().toRequestBody()), - MultipartBody.Part.createFormData("name", name) + MultipartBody.Part.createFormData("file", "$postId-${System.currentTimeMillis()}",outputStream.toByteArray().toRequestBody()), + MultipartBody.Part.createFormData("postId", postId.toString()) ).awaitResponse() if(response.isSuccessful) { val imageDto = response.body() @@ -106,12 +106,4 @@ class ImagesRepositoryImpl @Inject constructor(private val imagesApi: ImagesApi, imageInputStream?.close() } } - - override fun getDetachedDraftImages(): Flow>> { - TODO("Not yet implemented") - } - - override fun getDraftImagesOfPost(postId: Long): Flow>> { - TODO("Not yet implemented") - } } \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/images/common/domain/repository/ImagesRepository.kt b/app/src/main/java/com/isolaatti/images/common/domain/repository/ImagesRepository.kt index 9cfeb07..e6cd940 100644 --- a/app/src/main/java/com/isolaatti/images/common/domain/repository/ImagesRepository.kt +++ b/app/src/main/java/com/isolaatti/images/common/domain/repository/ImagesRepository.kt @@ -9,7 +9,5 @@ import kotlinx.coroutines.flow.Flow interface ImagesRepository { fun getImagesOfUser(userId: Int, lastId: String? = null): Flow>> fun deleteImages(images: List): Flow> - fun uploadImage(name: String, imageUri: Uri, squadId: String?): Flow> - fun getDetachedDraftImages(): Flow>> - fun getDraftImagesOfPost(postId: Long): Flow>> + fun uploadImage(imageUri: Uri, postId: Long, squadId: String?): Flow> } \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/images/image_maker/presentation/ImageMakerViewModel.kt b/app/src/main/java/com/isolaatti/images/image_maker/presentation/ImageMakerViewModel.kt deleted file mode 100644 index ed35efe..0000000 --- a/app/src/main/java/com/isolaatti/images/image_maker/presentation/ImageMakerViewModel.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.isolaatti.images.image_maker.presentation - -import android.net.Uri -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.isolaatti.images.common.domain.entity.Image -import com.isolaatti.images.common.domain.repository.ImagesRepository -import com.isolaatti.utils.Resource -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -import javax.inject.Inject - -@HiltViewModel -class ImageMakerViewModel @Inject constructor(private val imagesRepository: ImagesRepository) : ViewModel() { - var imageUri: Uri? = null - var name: String? = null - val image: MutableLiveData> = MutableLiveData() - fun uploadPicture() { - if(imageUri == null || name == null) { - return - } - viewModelScope.launch { - imagesRepository.uploadImage(name!!.trim(), imageUri!!, null).onEach { - image.postValue(it) - }.flowOn(Dispatchers.IO).launchIn(this) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/images/image_maker/ui/ImageMakerActivity.kt b/app/src/main/java/com/isolaatti/images/image_maker/ui/ImageMakerActivity.kt deleted file mode 100644 index 64ba1a6..0000000 --- a/app/src/main/java/com/isolaatti/images/image_maker/ui/ImageMakerActivity.kt +++ /dev/null @@ -1,84 +0,0 @@ -package com.isolaatti.images.image_maker.ui - -import android.app.Activity -import android.content.Intent -import android.os.Bundle -import android.view.View -import androidx.activity.OnBackPressedCallback -import androidx.activity.viewModels -import androidx.core.widget.doOnTextChanged -import coil.load -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.isolaatti.R -import com.isolaatti.common.IsolaattiBaseActivity -import com.isolaatti.databinding.ActivityImageMakerBinding -import com.isolaatti.images.image_maker.presentation.ImageMakerViewModel -import com.isolaatti.utils.Resource -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class ImageMakerActivity : IsolaattiBaseActivity() { - private lateinit var binding: ActivityImageMakerBinding - private val viewModel: ImageMakerViewModel by viewModels() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - binding = ActivityImageMakerBinding.inflate(layoutInflater) - setContentView(binding.root) - viewModel.imageUri = intent.data - binding.imagePreview.load(intent.data) - onBackPressedDispatcher.addCallback(onBackPressedCallback) - setupListeners() - setupObservers() - } - - private val onBackPressedCallback = object: OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - showExitConfirmationDialog() - } - } - - private fun showExitConfirmationDialog() { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.discard_image) - .setPositiveButton(R.string.yes_discard_image) {_, _ -> - finish() - }.setNegativeButton(R.string.no, null) - .show() - } - - private fun setupListeners() { - binding.uploadPhotoFab.setOnClickListener { - viewModel.uploadPicture() - } - binding.toolbar.setNavigationOnClickListener { - showExitConfirmationDialog() - } - } - - private fun setupObservers() { - viewModel.image.observe(this) { - when(it) { - is Resource.Error -> { - errorViewModel.error.value = it.errorType - binding.progressBarLoading.visibility = View.GONE - binding.uploadPhotoFab.visibility = View.VISIBLE - } - is Resource.Loading -> { - binding.progressBarLoading.visibility = View.VISIBLE - binding.uploadPhotoFab.visibility = View.INVISIBLE - } - is Resource.Success -> { - binding.progressBarLoading.visibility = View.GONE - binding.uploadPhotoFab.visibility = View.VISIBLE - setResult(Activity.RESULT_OK, Intent().putExtra(EXTRA_IMAGE, it.data)) - finish() - } - } - } - } - - companion object { - const val EXTRA_IMAGE = "image" - } -} \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/images/image_maker/ui/ImageMakerContract.kt b/app/src/main/java/com/isolaatti/images/image_maker/ui/ImageMakerContract.kt deleted file mode 100644 index c543c55..0000000 --- a/app/src/main/java/com/isolaatti/images/image_maker/ui/ImageMakerContract.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.isolaatti.images.image_maker.ui - -import android.app.Activity -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.os.Build -import androidx.activity.result.contract.ActivityResultContract -import com.isolaatti.images.common.domain.entity.Image - -class ImageMakerContract : ActivityResultContract() { - override fun createIntent(context: Context, input: Uri): Intent { - val intent = Intent(context, ImageMakerActivity::class.java) - intent.data = input - - return intent - } - - override fun parseResult(resultCode: Int, intent: Intent?): Image? { - if(resultCode == Activity.RESULT_OK) { - return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { - intent?.getSerializableExtra(ImageMakerActivity.EXTRA_IMAGE) as Image? - } else { - intent?.getSerializableExtra(ImageMakerActivity.EXTRA_IMAGE, Image::class.java) - } - } - return null - } -} \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/posting/Module.kt b/app/src/main/java/com/isolaatti/posting/Module.kt index 2f0cb0d..a7b3a69 100644 --- a/app/src/main/java/com/isolaatti/posting/Module.kt +++ b/app/src/main/java/com/isolaatti/posting/Module.kt @@ -1,6 +1,8 @@ package com.isolaatti.posting +import android.content.ContentResolver import com.isolaatti.connectivity.RetrofitClient +import com.isolaatti.images.common.data.remote.ImagesApi import com.isolaatti.posting.posts.data.remote.FeedsApi import com.isolaatti.posting.posts.data.remote.PostApi import com.isolaatti.posting.posts.data.repository.PostsRepositoryImpl @@ -25,7 +27,7 @@ class Module { } @Provides - fun providePostsRepository(feedsApi: FeedsApi, postApi: PostApi): PostsRepository { - return PostsRepositoryImpl(feedsApi, postApi) + fun providePostsRepository(feedsApi: FeedsApi, postApi: PostApi, imagesApi: ImagesApi, contentResolver: ContentResolver): PostsRepository { + return PostsRepositoryImpl(feedsApi, postApi, imagesApi, contentResolver) } } \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/images/common/components/ImagesRow.kt b/app/src/main/java/com/isolaatti/posting/posts/components/PostAttachments.kt similarity index 86% rename from app/src/main/java/com/isolaatti/images/common/components/ImagesRow.kt rename to app/src/main/java/com/isolaatti/posting/posts/components/PostAttachments.kt index cbf808a..17b5bd5 100644 --- a/app/src/main/java/com/isolaatti/images/common/components/ImagesRow.kt +++ b/app/src/main/java/com/isolaatti/posting/posts/components/PostAttachments.kt @@ -1,4 +1,4 @@ -package com.isolaatti.images.common.components +package com.isolaatti.posting.posts.components import android.net.Uri import androidx.compose.foundation.Image @@ -9,15 +9,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Card import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -33,26 +30,24 @@ 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.window.Popup import androidx.compose.ui.zIndex import coil3.compose.AsyncImage import com.isolaatti.R -import com.isolaatti.images.common.domain.entity.Image @Composable -fun ImagesRow( +fun PostAttachments( modifier: Modifier = Modifier, images: List, deletable: Boolean, - addable: Boolean, - onClick: (index: Int) -> Unit, - onDeleteClick: (index: Int) -> Unit = {}, + imageAddable: Boolean, + onImageClick: (index: Int) -> Unit, + onImageDeleteClick: (index: Int) -> Unit = {}, onTakePicture: () -> Unit = {}, - onUploadPicture: () -> Unit = {} + onUploadPicture: () -> Unit = {}, ) { var showAddPhotoPopUp by remember { mutableStateOf(false) } LazyRow(modifier, contentPadding = PaddingValues(horizontal = 8.dp)) { - if(addable) { + if(imageAddable) { item { Card(modifier = Modifier .padding(horizontal = 4.dp) @@ -93,7 +88,7 @@ fun ImagesRow( .padding(horizontal = 4.dp) .size(120.dp), onClick = { - onClick(index) + onImageClick(index) }) { Box( modifier = Modifier.fillMaxSize(), @@ -101,7 +96,7 @@ fun ImagesRow( ) { if(deletable) { FilledTonalIconButton( - onClick = { onDeleteClick(index) }, + onClick = { onImageDeleteClick(index) }, modifier = Modifier.zIndex(2f) ) { Icon(imageVector = Icons.Default.Close, contentDescription = null) @@ -124,5 +119,5 @@ fun ImagesRow( @Preview(device = Devices.PIXEL_5) @Composable fun ImagesRowPreview() { - ImagesRow(images = emptyList(), deletable = true, addable = true, onClick = {}) + PostAttachments(images = emptyList(), deletable = true, imageAddable = true, onImageClick = {}) } \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/posting/posts/data/remote/CreatePostDto.kt b/app/src/main/java/com/isolaatti/posting/posts/data/remote/CreatePostDto.kt index 05fdf0c..4386938 100644 --- a/app/src/main/java/com/isolaatti/posting/posts/data/remote/CreatePostDto.kt +++ b/app/src/main/java/com/isolaatti/posting/posts/data/remote/CreatePostDto.kt @@ -4,5 +4,6 @@ data class CreatePostDto( val privacy: Int, val content: String, val audioId: String?, - val squadId: String? + val squadId: String?, + val isDraft: Boolean = false ) \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/posting/posts/data/remote/PostApi.kt b/app/src/main/java/com/isolaatti/posting/posts/data/remote/PostApi.kt index 7676500..8071e08 100644 --- a/app/src/main/java/com/isolaatti/posting/posts/data/remote/PostApi.kt +++ b/app/src/main/java/com/isolaatti/posting/posts/data/remote/PostApi.kt @@ -24,4 +24,7 @@ interface PostApi { @GET("Posting/Post/{postId}/Versions") fun getVersions(@Path("postId") postId: Long): Call>> + @POST("Posting/Post/{postId}/SetIsDraft") + fun setIsDraft(@Path("postId") postId: Long, @Query("isDraft") isDraft: Boolean): Call + } \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/posting/posts/data/repository/PostsRepositoryImpl.kt b/app/src/main/java/com/isolaatti/posting/posts/data/repository/PostsRepositoryImpl.kt index 4e8bd30..90d8ae6 100644 --- a/app/src/main/java/com/isolaatti/posting/posts/data/repository/PostsRepositoryImpl.kt +++ b/app/src/main/java/com/isolaatti/posting/posts/data/repository/PostsRepositoryImpl.kt @@ -1,7 +1,16 @@ package com.isolaatti.posting.posts.data.repository +import android.content.ContentResolver +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.Build import android.util.Log import com.google.gson.Gson +import com.isolaatti.images.common.data.remote.DeleteImagesDto +import com.isolaatti.images.common.data.remote.ImageDto +import com.isolaatti.images.common.data.remote.ImagesApi +import com.isolaatti.images.common.domain.entity.Image import com.isolaatti.posting.posts.data.remote.CreatePostDto import com.isolaatti.posting.posts.data.remote.DeletePostDto import com.isolaatti.posting.posts.data.remote.EditPostDto @@ -17,10 +26,19 @@ import com.isolaatti.profile.domain.entity.ProfileListItem import com.isolaatti.utils.Resource import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.toRequestBody import retrofit2.awaitResponse +import java.io.ByteArrayOutputStream +import java.io.InputStream import javax.inject.Inject -class PostsRepositoryImpl @Inject constructor(private val feedsApi: FeedsApi, private val postApi: PostApi) : PostsRepository { +class PostsRepositoryImpl @Inject constructor( + private val feedsApi: FeedsApi, + private val postApi: PostApi, + private val imagesApi: ImagesApi, + private val contentResolver: ContentResolver +) : PostsRepository { companion object { const val LOG_TAG = "PostsRepositoryImpl" } @@ -64,16 +82,91 @@ class PostsRepositoryImpl @Inject constructor(private val feedsApi: FeedsApi, pr } } - override fun makePost(createPostDto: CreatePostDto): Flow> = flow { + override fun makePost(createPostDto: CreatePostDto, images: List): Flow> = flow { emit(Resource.Loading()) try { - val result = postApi.makePost(createPostDto).awaitResponse() + val hasImages = images.isNotEmpty() + val result = postApi.makePost( + if(hasImages) { + createPostDto.copy(isDraft = true) + } else { + createPostDto + } + ).awaitResponse() if(result.isSuccessful) { - emit(Resource.Success(result.body())) - return@flow + + if(hasImages) { + val successfulImageUploads: MutableList = mutableListOf() + + val postId = result.body()?.post?.id + if(postId == null) { + emit(Resource.Error()) + return@flow + } + images.forEach { imageUri -> + var imageInputStream: InputStream? = null + try { + imageInputStream = contentResolver.openInputStream(imageUri) + val bitmap = BitmapFactory.decodeStream(imageInputStream) + + if(bitmap == null) { + Log.e(LOG_TAG, "Error resolving image with content resolver") + return@forEach + } + + 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 response = imagesApi.postImage( + MultipartBody.Part.createFormData("file", "$postId-${System.currentTimeMillis()}",outputStream.toByteArray().toRequestBody()), + MultipartBody.Part.createFormData("postId", postId.toString()) + ).awaitResponse() + + if(response.isSuccessful) { + response.body()?.let { successfulImageUploads.add(it) } + + } + + } catch(e: Exception) { + Log.e("ImagesRepository", e.toString()) + + } finally { + imageInputStream?.close() + } + } + + // undo post and emit error + if(successfulImageUploads.size != images.size) { + postApi.deletePost(DeletePostDto(postId)).awaitResponse() + emit(Resource.Error(Resource.Error.ErrorType.ServerError, "Some images were not processed correctly")) + } else { + postApi.setIsDraft(postId, false) + val response = postApi.getPost(postId).awaitResponse() + + if(response.isSuccessful) { + emit(Resource.Success(response.body())) + } else { + val msg = "Post posted but could not retrieve updated post data from server" + Log.e(LOG_TAG, msg) + emit(Resource.Error(Resource.Error.mapErrorCode(response.code()), msg)) + } + + } + } else { + emit(Resource.Success(result.body())) + return@flow + } + } emit(Resource.Error(Resource.Error.mapErrorCode(result.code()))) - } catch(_: Exception) { + } catch(e: Exception) { + Log.d(LOG_TAG, e.toString()) emit(Resource.Error(Resource.Error.ErrorType.NetworkError)) } } diff --git a/app/src/main/java/com/isolaatti/posting/posts/domain/PostsRepository.kt b/app/src/main/java/com/isolaatti/posting/posts/domain/PostsRepository.kt index 988a30c..ff8f4d2 100644 --- a/app/src/main/java/com/isolaatti/posting/posts/domain/PostsRepository.kt +++ b/app/src/main/java/com/isolaatti/posting/posts/domain/PostsRepository.kt @@ -1,5 +1,6 @@ package com.isolaatti.posting.posts.domain +import android.net.Uri import com.isolaatti.posting.posts.data.remote.CreatePostDto import com.isolaatti.posting.posts.data.remote.EditPostDto import com.isolaatti.posting.posts.data.remote.FeedDto @@ -17,7 +18,7 @@ interface PostsRepository { fun getProfilePosts(userId: Int, lastId: Long, olderFirst: Boolean, filter: FeedFilterDto?): Flow>> - fun makePost(createPostDto: CreatePostDto): Flow> + fun makePost(createPostDto: CreatePostDto, images: List): Flow> fun editPost(editPostDto: EditPostDto): Flow> fun deletePost(postId: Long): Flow> fun loadPost(postId: Long): Flow> diff --git a/app/src/main/java/com/isolaatti/posting/posts/domain/use_case/MakePost.kt b/app/src/main/java/com/isolaatti/posting/posts/domain/use_case/MakePost.kt index 6c6c67a..487b5ce 100644 --- a/app/src/main/java/com/isolaatti/posting/posts/domain/use_case/MakePost.kt +++ b/app/src/main/java/com/isolaatti/posting/posts/domain/use_case/MakePost.kt @@ -1,5 +1,6 @@ package com.isolaatti.posting.posts.domain.use_case +import android.net.Uri import com.isolaatti.posting.posts.data.remote.CreatePostDto import com.isolaatti.posting.posts.data.remote.FeedDto import com.isolaatti.posting.posts.domain.PostsRepository @@ -11,9 +12,10 @@ class MakePost @Inject constructor(private val postsRepository: PostsRepository) operator fun invoke( privacy: Int, content: String, + images: List, audioId: String?, squadId: String? - ): Flow> { - return postsRepository.makePost(CreatePostDto(privacy, content, audioId, squadId)) + ): Flow> { + return postsRepository.makePost(CreatePostDto(privacy, content, audioId, squadId), images) } } \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/posting/posts/presentation/CreatePostViewModel.kt b/app/src/main/java/com/isolaatti/posting/posts/presentation/CreatePostViewModel.kt index f602ccd..fd78a89 100644 --- a/app/src/main/java/com/isolaatti/posting/posts/presentation/CreatePostViewModel.kt +++ b/app/src/main/java/com/isolaatti/posting/posts/presentation/CreatePostViewModel.kt @@ -62,7 +62,13 @@ class CreatePostViewModel @Inject constructor( private fun sendDiscussion() { Log.d(LOG_TAG, "postDiscussion#send()") viewModelScope.launch { - makePost(EditPostDto.PRIVACY_ISOLAATTI, content, audioId, null).onEach { + makePost( + privacy = EditPostDto.PRIVACY_ISOLAATTI, + content = content, + images = photos.value, + audioId = audioId, + squadId = null + ).onEach { when(it) { is Resource.Success -> { sendingPost.postValue(false) diff --git a/app/src/main/java/com/isolaatti/posting/posts/ui/CreatePostActivity.kt b/app/src/main/java/com/isolaatti/posting/posts/ui/CreatePostActivity.kt index bcbc2cb..bc71fab 100644 --- a/app/src/main/java/com/isolaatti/posting/posts/ui/CreatePostActivity.kt +++ b/app/src/main/java/com/isolaatti/posting/posts/ui/CreatePostActivity.kt @@ -1,22 +1,57 @@ package com.isolaatti.posting.posts.ui import android.app.Activity -import android.content.Intent +import android.net.Uri import android.os.Bundle -import android.view.View +import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.MediatorLiveData -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import com.google.android.material.tabs.TabLayoutMediator +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.foundation.gestures.rememberScrollableState +import androidx.compose.foundation.gestures.scrollable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Button +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.LaunchedEffect +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.content.FileProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.isolaatti.MyApplication import com.isolaatti.R +import com.isolaatti.audio.common.components.AudioRecorder import com.isolaatti.common.IsolaattiBaseActivity -import com.isolaatti.databinding.ActivityCreatePostBinding +import com.isolaatti.common.IsolaattiTheme +import com.isolaatti.posting.posts.components.PostAttachments import com.isolaatti.posting.posts.presentation.CreatePostViewModel import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.launch +import java.io.File +import java.util.Calendar @AndroidEntryPoint class CreatePostActivity : IsolaattiBaseActivity() { @@ -36,94 +71,157 @@ class CreatePostActivity : IsolaattiBaseActivity() { const val EXTRA_KEY_POST_POSTED = "post" } - lateinit var binding: ActivityCreatePostBinding val viewModel: CreatePostViewModel by viewModels() var mode: Int = EXTRA_MODE_CREATE var postId: Long = 0L + private val choosePictureLauncher = registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { + if(it != null) { + viewModel.addPicture(it) + } + } + + private fun makePhotoUri(): Uri { + val cacheFile = File(filesDir, "temp_picture_${Calendar.getInstance().timeInMillis}") + return FileProvider.getUriForFile(this, "${MyApplication.myApp.packageName}.provider", cacheFile) + } + + private var cameraPhotoUri: Uri? = null + + private val takePhotoLauncher = registerForActivityResult(ActivityResultContracts.TakePicture()) { + if(it && cameraPhotoUri != null) { + viewModel.addPicture(cameraPhotoUri!!) + } + + } + + @OptIn(ExperimentalMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) + setContent { + var text by remember { mutableStateOf("") } + val focusRequester = remember { FocusRequester() } + val pictures by viewModel.photos.collectAsStateWithLifecycle() + + val editMode = remember { + mode == EXTRA_MODE_EDIT && postId != 0L + } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + IsolaattiTheme { + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { Text(stringResource(if(editMode) R.string.edit else R.string.new_post)) }, + navigationIcon = { + IconButton( + onClick = { + exit() + } + ) { + Icon(Icons.Default.Close, null) + } + }, + actions = { + Button( + enabled = text.isNotBlank() || pictures.isNotEmpty(), + onClick = { + if(editMode) { + viewModel.editDiscussion(postId) + } else { + viewModel.postDiscussion() + } + + } + ) { + Text(stringResource(if(editMode) R.string.save else R.string.post)) + } + } + ) + } + ) { + val scrollState = rememberScrollState() + Column(modifier = Modifier.padding(it).verticalScroll(scrollState)) { + OutlinedTextField( + value = text, + onValueChange = { + text = it + }, + modifier = Modifier + .padding(horizontal = 8.dp) + .fillMaxWidth() + .focusRequester(focusRequester), + placeholder = { Text(stringResource(R.string.what_do_you_want_to_talk_about_you_can_record_an_audio_if_you_want)) }, + colors = OutlinedTextFieldDefaults + .colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent + ), + trailingIcon = { + IconButton(onClick = {}) { + Icon(painterResource(id = R.drawable.baseline_mic_24), null) + } + } + ) + + AnimatedVisibility(false) { + AudioRecorder( + onAudioRecorded = { + + }, + dismissible = true, + onDismiss = { + + } + ) + } + + + PostAttachments( + modifier = Modifier.padding(16.dp), + images = pictures, + deletable = true, + imageAddable = true, + onImageClick = { + + }, + onImageDeleteClick = { + viewModel.removePicture(it) + }, + onTakePicture = { + cameraPhotoUri = makePhotoUri() + takePhotoLauncher.launch(cameraPhotoUri!!) + }, + onUploadPicture = { + choosePictureLauncher.launch( + PickVisualMediaRequest( + ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + }, + ) + } + } + } + + } + + intent.extras?.let { mode = it.getInt(EXTRA_KEY_MODE, EXTRA_MODE_CREATE) postId = it.getLong(EXTRA_KEY_POST_ID) } - binding = ActivityCreatePostBinding.inflate(layoutInflater) if(mode == EXTRA_MODE_EDIT && postId != 0L) { viewModel.loadDiscussion(postId) } - - - setupUI() - setListeners() - setObservers() - setContentView(binding.root) - - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - errorViewModel.retry.collect { - if(!it) { - return@collect - } - - if(mode == EXTRA_MODE_EDIT && postId != 0L) { - viewModel.editDiscussion(postId) - } else { - viewModel.postDiscussion() - } - } - } - } - - } - - private fun setupUI() { - binding.toolbar.setTitle(if(mode == EXTRA_MODE_EDIT && postId != 0L) R.string.edit else R.string.new_post) - - binding.pager.adapter = CreatePostFragmentStateAdapter(this) - - } - - private fun setListeners() { - binding.toolbar.setNavigationOnClickListener { - exit() - } - - - binding.postButton.setOnClickListener { - if(mode == EXTRA_MODE_EDIT && postId != 0L) { - viewModel.editDiscussion(postId) - } else { - viewModel.postDiscussion() - } - } - - } - - private fun setObservers() { - viewModel.validation.observe(this@CreatePostActivity) { - binding.postButton.isEnabled = it - } - - viewModel.error.observe(this@CreatePostActivity) { - errorViewModel.error.postValue(it) - } - - viewModel.posted.observe(this@CreatePostActivity) { - setResult(Activity.RESULT_OK, Intent().apply{ - putExtra(EXTRA_KEY_POST_POSTED, it) - }) - finish() - } - - viewModel.sendingPost.observe(this@CreatePostActivity) { - binding.progressBarLoading.visibility = if(it) View.VISIBLE else View.GONE - } } private fun exit() { diff --git a/app/src/main/java/com/isolaatti/posting/posts/ui/CreatePostFragmentStateAdapter.kt b/app/src/main/java/com/isolaatti/posting/posts/ui/CreatePostFragmentStateAdapter.kt deleted file mode 100644 index 62e44d8..0000000 --- a/app/src/main/java/com/isolaatti/posting/posts/ui/CreatePostFragmentStateAdapter.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.isolaatti.posting.posts.ui - -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentActivity -import androidx.viewpager2.adapter.FragmentStateAdapter - -class CreatePostFragmentStateAdapter(fragmentActivity: FragmentActivity) : FragmentStateAdapter(fragmentActivity) { - override fun getItemCount(): Int { - return 1 - } - - override fun createFragment(position: Int): Fragment { - return PostEditingFragment() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/posting/posts/ui/MarkdownPreviewFragment.kt b/app/src/main/java/com/isolaatti/posting/posts/ui/MarkdownPreviewFragment.kt deleted file mode 100644 index ff0a73e..0000000 --- a/app/src/main/java/com/isolaatti/posting/posts/ui/MarkdownPreviewFragment.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.isolaatti.posting.posts.ui - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.widget.doOnTextChanged -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import com.isolaatti.common.CoilImageLoader -import com.isolaatti.databinding.FragmentMarkdownEditingBinding -import com.isolaatti.databinding.FragmentMarkdownPreviewBinding -import com.isolaatti.markdown.HashtagMarkwonPlugin -import com.isolaatti.markdown.RelativePathMarkwonPlugin -import com.isolaatti.posting.posts.presentation.CreatePostViewModel -import dagger.hilt.EntryPoint -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 - -class MarkdownPreviewFragment : Fragment() { - private lateinit var binding: FragmentMarkdownPreviewBinding - private val viewModel: CreatePostViewModel by activityViewModels() - private var markwon: Markwon? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - markwon = Markwon.builder(requireContext()) - .usePlugin(RelativePathMarkwonPlugin()) - .usePlugin(CoilImagesPlugin.create(requireContext(), CoilImageLoader.imageLoader)) - .usePlugin(LinkifyPlugin.create()) - .usePlugin(HashtagMarkwonPlugin()) - .build() - } - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - binding = FragmentMarkdownPreviewBinding.inflate(layoutInflater) - return binding.root - } - - override fun onDestroy() { - super.onDestroy() - - markwon = null - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - viewModel.liveContent.observe(viewLifecycleOwner) { - markwon?.setMarkdown(binding.textView, it) - } - - } -} \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/posting/posts/ui/PostEditingFragment.kt b/app/src/main/java/com/isolaatti/posting/posts/ui/PostEditingFragment.kt deleted file mode 100644 index ca16fdd..0000000 --- a/app/src/main/java/com/isolaatti/posting/posts/ui/PostEditingFragment.kt +++ /dev/null @@ -1,157 +0,0 @@ -package com.isolaatti.posting.posts.ui - -import android.net.Uri -import android.os.Bundle -import android.util.Log -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.activity.result.PickVisualMediaRequest -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.content.res.AppCompatResources -import androidx.appcompat.widget.PopupMenu -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.compose.ui.unit.dp -import androidx.core.content.FileProvider -import androidx.core.widget.doOnTextChanged -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.LifecycleOwner -import com.isolaatti.MyApplication -import com.isolaatti.R -import com.isolaatti.audio.common.domain.Audio -import com.isolaatti.audio.common.domain.Playable -import com.isolaatti.common.IsolaattiTheme -import com.isolaatti.databinding.FragmentMarkdownEditingBinding -import com.isolaatti.images.common.components.ImagesRow -import com.isolaatti.posting.link_creator.presentation.LinkCreatorViewModel -import com.isolaatti.posting.link_creator.ui.LinkCreatorFragment -import com.isolaatti.posting.posts.presentation.CreatePostViewModel -import java.io.File -import java.util.Calendar - -class PostEditingFragment : Fragment(){ - companion object { - const val LOG_TAG = "PostEditingFragment" - } - - - private lateinit var binding: FragmentMarkdownEditingBinding - private val viewModel: CreatePostViewModel by activityViewModels() - private val linkCreatorViewModel: LinkCreatorViewModel by viewModels() - - - private val choosePictureLauncher = registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { - if(it != null) { - viewModel.addPicture(it) - } - } - - private fun makePhotoUri(): Uri { - val cacheFile = File(requireContext().filesDir, "temp_picture_${Calendar.getInstance().timeInMillis}") - return FileProvider.getUriForFile(requireContext(), "${MyApplication.myApp.packageName}.provider", cacheFile) - } - - private var cameraPhotoUri: Uri? = null - - private val takePhotoLauncher = registerForActivityResult(ActivityResultContracts.TakePicture()) { - if(it && cameraPhotoUri != null) { - viewModel.addPicture(cameraPhotoUri!!) - } - - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - - binding = FragmentMarkdownEditingBinding.inflate(layoutInflater) - - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - viewLifecycleOwner.lifecycle.addObserver(object: LifecycleEventObserver { - override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { - Log.d(LOG_TAG, event.toString()) - } - }) - - setupListeners() - setupObservers() - - binding.composeView.apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - val pictures by viewModel.photos.collectAsState() - IsolaattiTheme { - ImagesRow( - modifier = Modifier.padding(16.dp), - images = pictures, - deletable = true, - addable = true, - onClick = { - - }, - onDeleteClick = { - viewModel.removePicture(it) - }, - onTakePicture = { - cameraPhotoUri = makePhotoUri() - takePhotoLauncher.launch(cameraPhotoUri) - }, - onUploadPicture = { - choosePictureLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) - } - ) - } - } - } - } - - override fun onDestroyView() { - super.onDestroyView() - } - - private fun setupListeners() { - binding.filledTextField.editText?.setText(viewModel.content) - binding.filledTextField.requestFocus() - binding.filledTextField.editText?.doOnTextChanged { text, _, _, _ -> - // make better validation :) - viewModel.validation.postValue(!text.isNullOrEmpty()) - viewModel.content = text.toString() - } - } - - private fun setupObservers(){ - viewModel.postToEdit.observe(viewLifecycleOwner) { - binding.filledTextField.editText?.setText(it.content) - } - linkCreatorViewModel.inserted.observe(viewLifecycleOwner) { - if(it) { - viewModel.content += " ${linkCreatorViewModel.markdown}" - binding.filledTextField.editText?.setText(viewModel.content) - linkCreatorViewModel.inserted.value = false - } - } - - viewModel.audioAttachment.observe(viewLifecycleOwner) { playable -> - - } - } -} \ No newline at end of file diff --git a/app/src/main/res/layout/activity_create_post.xml b/app/src/main/res/layout/activity_create_post.xml index 742fbb8..0465683 100644 --- a/app/src/main/res/layout/activity_create_post.xml +++ b/app/src/main/res/layout/activity_create_post.xml @@ -5,66 +5,55 @@ android:layout_height="match_parent" xmlns:app="http://schemas.android.com/apk/res-auto"> - - - + + + + + + + + - + app:hintEnabled="false"> + + - + - - - - - - - - + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_markdown_editing.xml b/app/src/main/res/layout/fragment_markdown_editing.xml deleted file mode 100644 index 0131d7f..0000000 --- a/app/src/main/res/layout/fragment_markdown_editing.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_markdown_preview.xml b/app/src/main/res/layout/fragment_markdown_preview.xml deleted file mode 100644 index 5749c84..0000000 --- a/app/src/main/res/layout/fragment_markdown_preview.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - \ No newline at end of file