This commit is contained in:
erik-everardo 2025-01-15 23:01:39 -06:00
parent 46e12db1fd
commit 727007d94f
22 changed files with 388 additions and 600 deletions

View File

@ -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() {
}

View File

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

View File

@ -62,7 +62,7 @@ class ImagesRepositoryImpl @Inject constructor(private val imagesApi: ImagesApi,
} }
} }
override fun uploadImage(name: String, imageUri: Uri, squadId: String?): Flow<Resource<Image>> = flow { override fun uploadImage(imageUri: Uri, postId: Long, squadId: String?): Flow<Resource<Image>> = flow {
emit(Resource.Loading()) emit(Resource.Loading())
var imageInputStream: InputStream? = null var imageInputStream: InputStream? = null
try { try {
@ -84,8 +84,8 @@ class ImagesRepositoryImpl @Inject constructor(private val imagesApi: ImagesApi,
val response = imagesApi.postImage( val response = imagesApi.postImage(
MultipartBody.Part.createFormData("file", name,outputStream.toByteArray().toRequestBody()), MultipartBody.Part.createFormData("file", "$postId-${System.currentTimeMillis()}",outputStream.toByteArray().toRequestBody()),
MultipartBody.Part.createFormData("name", name) MultipartBody.Part.createFormData("postId", postId.toString())
).awaitResponse() ).awaitResponse()
if(response.isSuccessful) { if(response.isSuccessful) {
val imageDto = response.body() val imageDto = response.body()
@ -106,12 +106,4 @@ class ImagesRepositoryImpl @Inject constructor(private val imagesApi: ImagesApi,
imageInputStream?.close() imageInputStream?.close()
} }
} }
override fun getDetachedDraftImages(): Flow<Resource<List<ImageDraftEntity>>> {
TODO("Not yet implemented")
}
override fun getDraftImagesOfPost(postId: Long): Flow<Resource<List<ImageDraftEntity>>> {
TODO("Not yet implemented")
}
} }

View File

@ -9,7 +9,5 @@ import kotlinx.coroutines.flow.Flow
interface ImagesRepository { interface ImagesRepository {
fun getImagesOfUser(userId: Int, lastId: String? = null): Flow<Resource<List<Image>>> fun getImagesOfUser(userId: Int, lastId: String? = null): Flow<Resource<List<Image>>>
fun deleteImages(images: List<Image>): Flow<Resource<Boolean>> fun deleteImages(images: List<Image>): Flow<Resource<Boolean>>
fun uploadImage(name: String, imageUri: Uri, squadId: String?): Flow<Resource<Image>> fun uploadImage(imageUri: Uri, postId: Long, squadId: String?): Flow<Resource<Image>>
fun getDetachedDraftImages(): Flow<Resource<List<ImageDraftEntity>>>
fun getDraftImagesOfPost(postId: Long): Flow<Resource<List<ImageDraftEntity>>>
} }

View File

@ -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<Resource<Image>> = 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)
}
}
}

View File

@ -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"
}
}

View File

@ -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<Uri, Image?>() {
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
}
}

View File

@ -1,6 +1,8 @@
package com.isolaatti.posting package com.isolaatti.posting
import android.content.ContentResolver
import com.isolaatti.connectivity.RetrofitClient 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.FeedsApi
import com.isolaatti.posting.posts.data.remote.PostApi import com.isolaatti.posting.posts.data.remote.PostApi
import com.isolaatti.posting.posts.data.repository.PostsRepositoryImpl import com.isolaatti.posting.posts.data.repository.PostsRepositoryImpl
@ -25,7 +27,7 @@ class Module {
} }
@Provides @Provides
fun providePostsRepository(feedsApi: FeedsApi, postApi: PostApi): PostsRepository { fun providePostsRepository(feedsApi: FeedsApi, postApi: PostApi, imagesApi: ImagesApi, contentResolver: ContentResolver): PostsRepository {
return PostsRepositoryImpl(feedsApi, postApi) return PostsRepositoryImpl(feedsApi, postApi, imagesApi, contentResolver)
} }
} }

View File

@ -1,4 +1,4 @@
package com.isolaatti.images.common.components package com.isolaatti.posting.posts.components
import android.net.Uri import android.net.Uri
import androidx.compose.foundation.Image 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.layout.size
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.Devices
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.isolaatti.R import com.isolaatti.R
import com.isolaatti.images.common.domain.entity.Image
@Composable @Composable
fun ImagesRow( fun PostAttachments(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
images: List<Uri>, images: List<Uri>,
deletable: Boolean, deletable: Boolean,
addable: Boolean, imageAddable: Boolean,
onClick: (index: Int) -> Unit, onImageClick: (index: Int) -> Unit,
onDeleteClick: (index: Int) -> Unit = {}, onImageDeleteClick: (index: Int) -> Unit = {},
onTakePicture: () -> Unit = {}, onTakePicture: () -> Unit = {},
onUploadPicture: () -> Unit = {} onUploadPicture: () -> Unit = {},
) { ) {
var showAddPhotoPopUp by remember { mutableStateOf(false) } var showAddPhotoPopUp by remember { mutableStateOf(false) }
LazyRow(modifier, contentPadding = PaddingValues(horizontal = 8.dp)) { LazyRow(modifier, contentPadding = PaddingValues(horizontal = 8.dp)) {
if(addable) { if(imageAddable) {
item { item {
Card(modifier = Modifier Card(modifier = Modifier
.padding(horizontal = 4.dp) .padding(horizontal = 4.dp)
@ -93,7 +88,7 @@ fun ImagesRow(
.padding(horizontal = 4.dp) .padding(horizontal = 4.dp)
.size(120.dp), .size(120.dp),
onClick = { onClick = {
onClick(index) onImageClick(index)
}) { }) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@ -101,7 +96,7 @@ fun ImagesRow(
) { ) {
if(deletable) { if(deletable) {
FilledTonalIconButton( FilledTonalIconButton(
onClick = { onDeleteClick(index) }, onClick = { onImageDeleteClick(index) },
modifier = Modifier.zIndex(2f) modifier = Modifier.zIndex(2f)
) { ) {
Icon(imageVector = Icons.Default.Close, contentDescription = null) Icon(imageVector = Icons.Default.Close, contentDescription = null)
@ -124,5 +119,5 @@ fun ImagesRow(
@Preview(device = Devices.PIXEL_5) @Preview(device = Devices.PIXEL_5)
@Composable @Composable
fun ImagesRowPreview() { fun ImagesRowPreview() {
ImagesRow(images = emptyList(), deletable = true, addable = true, onClick = {}) PostAttachments(images = emptyList(), deletable = true, imageAddable = true, onImageClick = {})
} }

View File

@ -4,5 +4,6 @@ data class CreatePostDto(
val privacy: Int, val privacy: Int,
val content: String, val content: String,
val audioId: String?, val audioId: String?,
val squadId: String? val squadId: String?,
val isDraft: Boolean = false
) )

View File

@ -24,4 +24,7 @@ interface PostApi {
@GET("Posting/Post/{postId}/Versions") @GET("Posting/Post/{postId}/Versions")
fun getVersions(@Path("postId") postId: Long): Call<ResultDto<List<VersionDto>>> fun getVersions(@Path("postId") postId: Long): Call<ResultDto<List<VersionDto>>>
@POST("Posting/Post/{postId}/SetIsDraft")
fun setIsDraft(@Path("postId") postId: Long, @Query("isDraft") isDraft: Boolean): Call<FeedDto.PostDto>
} }

View File

@ -1,7 +1,16 @@
package com.isolaatti.posting.posts.data.repository 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 android.util.Log
import com.google.gson.Gson 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.CreatePostDto
import com.isolaatti.posting.posts.data.remote.DeletePostDto import com.isolaatti.posting.posts.data.remote.DeletePostDto
import com.isolaatti.posting.posts.data.remote.EditPostDto 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 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.InputStream
import javax.inject.Inject 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 { companion object {
const val LOG_TAG = "PostsRepositoryImpl" const val LOG_TAG = "PostsRepositoryImpl"
} }
@ -64,16 +82,91 @@ class PostsRepositoryImpl @Inject constructor(private val feedsApi: FeedsApi, pr
} }
} }
override fun makePost(createPostDto: CreatePostDto): Flow<Resource<FeedDto.PostDto>> = flow { override fun makePost(createPostDto: CreatePostDto, images: List<Uri>): Flow<Resource<FeedDto.PostDto>> = flow {
emit(Resource.Loading()) emit(Resource.Loading())
try { 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) { if(result.isSuccessful) {
emit(Resource.Success(result.body()))
return@flow if(hasImages) {
val successfulImageUploads: MutableList<ImageDto> = 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()))) 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)) emit(Resource.Error(Resource.Error.ErrorType.NetworkError))
} }
} }

View File

@ -1,5 +1,6 @@
package com.isolaatti.posting.posts.domain 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.CreatePostDto
import com.isolaatti.posting.posts.data.remote.EditPostDto import com.isolaatti.posting.posts.data.remote.EditPostDto
import com.isolaatti.posting.posts.data.remote.FeedDto 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<Resource<MutableList<Post>>> fun getProfilePosts(userId: Int, lastId: Long, olderFirst: Boolean, filter: FeedFilterDto?): Flow<Resource<MutableList<Post>>>
fun makePost(createPostDto: CreatePostDto): Flow<Resource<FeedDto.PostDto>> fun makePost(createPostDto: CreatePostDto, images: List<Uri>): Flow<Resource<FeedDto.PostDto>>
fun editPost(editPostDto: EditPostDto): Flow<Resource<FeedDto.PostDto>> fun editPost(editPostDto: EditPostDto): Flow<Resource<FeedDto.PostDto>>
fun deletePost(postId: Long): Flow<Resource<PostDeletedDto>> fun deletePost(postId: Long): Flow<Resource<PostDeletedDto>>
fun loadPost(postId: Long): Flow<Resource<Post>> fun loadPost(postId: Long): Flow<Resource<Post>>

View File

@ -1,5 +1,6 @@
package com.isolaatti.posting.posts.domain.use_case 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.CreatePostDto
import com.isolaatti.posting.posts.data.remote.FeedDto import com.isolaatti.posting.posts.data.remote.FeedDto
import com.isolaatti.posting.posts.domain.PostsRepository import com.isolaatti.posting.posts.domain.PostsRepository
@ -11,9 +12,10 @@ class MakePost @Inject constructor(private val postsRepository: PostsRepository)
operator fun invoke( operator fun invoke(
privacy: Int, privacy: Int,
content: String, content: String,
images: List<Uri>,
audioId: String?, audioId: String?,
squadId: String? squadId: String?
): Flow<Resource<FeedDto.PostDto>> { ): Flow<Resource<FeedDto.PostDto>> {
return postsRepository.makePost(CreatePostDto(privacy, content, audioId, squadId)) return postsRepository.makePost(CreatePostDto(privacy, content, audioId, squadId), images)
} }
} }

View File

@ -62,7 +62,13 @@ class CreatePostViewModel @Inject constructor(
private fun sendDiscussion() { private fun sendDiscussion() {
Log.d(LOG_TAG, "postDiscussion#send()") Log.d(LOG_TAG, "postDiscussion#send()")
viewModelScope.launch { 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) { when(it) {
is Resource.Success -> { is Resource.Success -> {
sendingPost.postValue(false) sendingPost.postValue(false)

View File

@ -1,22 +1,57 @@
package com.isolaatti.posting.posts.ui package com.isolaatti.posting.posts.ui
import android.app.Activity import android.app.Activity
import android.content.Intent import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.View import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.lifecycle.Lifecycle import androidx.compose.animation.AnimatedVisibility
import androidx.lifecycle.MediatorLiveData import androidx.compose.foundation.gestures.ScrollableState
import androidx.lifecycle.lifecycleScope import androidx.compose.foundation.gestures.rememberScrollableState
import androidx.lifecycle.repeatOnLifecycle import androidx.compose.foundation.gestures.scrollable
import com.google.android.material.tabs.TabLayoutMediator 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.R
import com.isolaatti.audio.common.components.AudioRecorder
import com.isolaatti.common.IsolaattiBaseActivity 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 com.isolaatti.posting.posts.presentation.CreatePostViewModel
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch import java.io.File
import java.util.Calendar
@AndroidEntryPoint @AndroidEntryPoint
class CreatePostActivity : IsolaattiBaseActivity() { class CreatePostActivity : IsolaattiBaseActivity() {
@ -36,94 +71,157 @@ class CreatePostActivity : IsolaattiBaseActivity() {
const val EXTRA_KEY_POST_POSTED = "post" const val EXTRA_KEY_POST_POSTED = "post"
} }
lateinit var binding: ActivityCreatePostBinding
val viewModel: CreatePostViewModel by viewModels() val viewModel: CreatePostViewModel by viewModels()
var mode: Int = EXTRA_MODE_CREATE var mode: Int = EXTRA_MODE_CREATE
var postId: Long = 0L 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?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) 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 { intent.extras?.let {
mode = it.getInt(EXTRA_KEY_MODE, EXTRA_MODE_CREATE) mode = it.getInt(EXTRA_KEY_MODE, EXTRA_MODE_CREATE)
postId = it.getLong(EXTRA_KEY_POST_ID) postId = it.getLong(EXTRA_KEY_POST_ID)
} }
binding = ActivityCreatePostBinding.inflate(layoutInflater)
if(mode == EXTRA_MODE_EDIT && postId != 0L) { if(mode == EXTRA_MODE_EDIT && postId != 0L) {
viewModel.loadDiscussion(postId) 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() { private fun exit() {

View File

@ -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()
}
}

View File

@ -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)
}
}
}

View File

@ -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 ->
}
}
}

View File

@ -5,66 +5,55 @@
android:layout_height="match_parent" android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"> xmlns:app="http://schemas.android.com/apk/res-auto">
<androidx.constraintlayout.widget.ConstraintLayout <com.google.android.material.appbar.AppBarLayout
android:id="@+id/appBarLayout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:fitsSystemWindows="true"> android:fitsSystemWindows="true">
<com.google.android.material.appbar.AppBarLayout <com.google.android.material.appbar.MaterialToolbar
android:id="@+id/appBarLayout" android:id="@+id/toolbar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent" app:navigationIcon="@drawable/baseline_close_24"
app:layout_constraintStart_toStartOf="parent" app:navigationIconTint="@color/on_surface"
app:layout_constraintEnd_toEndOf="parent"> app:title="@string/new_post"
<com.google.android.material.appbar.MaterialToolbar app:titleCentered="true">
android:id="@+id/toolbar" </com.google.android.material.appbar.MaterialToolbar>
</com.google.android.material.appbar.AppBarLayout>
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingBottom="100dp"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<com.google.android.material.textfield.TextInputLayout
style="?attr/textInputFilledStyle"
android:id="@+id/filledTextField"
android:layout_width="match_parent" android:layout_width="match_parent"
app:boxBackgroundMode="none"
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:navigationIcon="@drawable/baseline_close_24" app:hintEnabled="false">
app:navigationIconTint="@color/on_surface" <com.google.android.material.textfield.TextInputEditText
app:title="@string/new_post" android:layout_width="match_parent"
app:titleCentered="true"> android:layout_height="wrap_content"
</com.google.android.material.appbar.MaterialToolbar> android:hint="@string/what_do_you_want_to_talk_about_you_can_record_an_audio_if_you_want"/>
</com.google.android.material.textfield.TextInputLayout>
</com.google.android.material.appbar.AppBarLayout> <androidx.compose.ui.platform.ComposeView
android:id="@+id/compose_view"
android:layout_width="match_parent"
android:layout_height="160dp"/>
</LinearLayout>
<androidx.viewpager2.widget.ViewPager2 </androidx.core.widget.NestedScrollView>
android:id="@+id/pager"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/appBarLayout" />
<ProgressBar
android:id="@+id/progress_bar_loading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/postButton"
app:layout_constraintTop_toTopOf="@+id/postButton"
tools:visibility="visible" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/postButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:srcCompat="@drawable/baseline_send_24"
android:fitsSystemWindows="true"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -1,33 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingBottom="100dp"
android:layout_marginTop="?attr/actionBarSize"
android:clipToPadding="false">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<com.google.android.material.textfield.TextInputLayout
style="?attr/textInputFilledStyle"
android:id="@+id/filledTextField"
android:layout_width="match_parent"
app:boxBackgroundMode="none"
android:layout_height="wrap_content"
app:hintEnabled="false">
<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/what_do_you_want_to_talk_about_you_can_record_an_audio_if_you_want"/>
</com.google.android.material.textfield.TextInputLayout>
<androidx.compose.ui.platform.ComposeView
android:id="@+id/compose_view"
android:layout_width="match_parent"
android:layout_height="160dp"/>
</LinearLayout>
</androidx.core.widget.NestedScrollView>

View File

@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/textView"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>