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")
@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<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())
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<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 {
fun getImagesOfUser(userId: Int, lastId: String? = null): Flow<Resource<List<Image>>>
fun deleteImages(images: List<Image>): Flow<Resource<Boolean>>
fun uploadImage(name: String, imageUri: Uri, squadId: String?): Flow<Resource<Image>>
fun getDetachedDraftImages(): Flow<Resource<List<ImageDraftEntity>>>
fun getDraftImagesOfPost(postId: Long): Flow<Resource<List<ImageDraftEntity>>>
fun uploadImage(imageUri: Uri, postId: Long, squadId: String?): Flow<Resource<Image>>
}

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

View File

@ -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<Uri>,
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 = {})
}

View File

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

View File

@ -24,4 +24,7 @@ interface PostApi {
@GET("Posting/Post/{postId}/Versions")
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
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<Resource<FeedDto.PostDto>> = flow {
override fun makePost(createPostDto: CreatePostDto, images: List<Uri>): Flow<Resource<FeedDto.PostDto>> = 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<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())))
} catch(_: Exception) {
} catch(e: Exception) {
Log.d(LOG_TAG, e.toString())
emit(Resource.Error(Resource.Error.ErrorType.NetworkError))
}
}

View File

@ -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<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 deletePost(postId: Long): Flow<Resource<PostDeletedDto>>
fun loadPost(postId: Long): Flow<Resource<Post>>

View File

@ -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<Uri>,
audioId: String?,
squadId: String?
): Flow<Resource<FeedDto.PostDto>> {
return postsRepository.makePost(CreatePostDto(privacy, content, audioId, squadId))
): Flow<Resource<FeedDto.PostDto>> {
return postsRepository.makePost(CreatePostDto(privacy, content, audioId, squadId), images)
}
}

View File

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

View File

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

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"
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_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">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appBarLayout"
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
app:navigationIcon="@drawable/baseline_close_24"
app:navigationIconTint="@color/on_surface"
app:title="@string/new_post"
app:titleCentered="true">
</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"
app:boxBackgroundMode="none"
android:layout_height="wrap_content"
app:navigationIcon="@drawable/baseline_close_24"
app:navigationIconTint="@color/on_surface"
app:title="@string/new_post"
app:titleCentered="true">
</com.google.android.material.appbar.MaterialToolbar>
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>
</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"/>
<androidx.viewpager2.widget.ViewPager2
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>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</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>