diff --git a/app/build.gradle b/app/build.gradle index bcc6c0a..db2eaee 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -58,6 +58,7 @@ dependencies { implementation "androidx.recyclerview:recyclerview:1.3.2" implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.core:core-ktx:1.12.0' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.10.1' implementation 'androidx.legacy:legacy-support-v4:1.0.0' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1' @@ -156,4 +157,7 @@ dependencies { implementation 'androidx.compose.runtime:runtime-livedata' implementation "io.coil-kt.coil3:coil-compose:3.0.1" + implementation("io.coil-kt.coil3:coil-network-okhttp:3.0.1") + + implementation("com.google.accompanist:accompanist-permissions:0.36.0") } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b60f867..108b5ec 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,8 @@ + + + @@ -48,10 +51,12 @@ - + + + + + @@ -67,6 +74,13 @@ + + + + + diff --git a/app/src/main/java/com/isolaatti/audio/common/components/AudioPlayer.kt b/app/src/main/java/com/isolaatti/audio/common/components/AudioPlayer.kt new file mode 100644 index 0000000..48523f8 --- /dev/null +++ b/app/src/main/java/com/isolaatti/audio/common/components/AudioPlayer.kt @@ -0,0 +1,62 @@ +package com.isolaatti.audio.common.components + +import androidx.annotation.OptIn +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.media3.common.util.UnstableApi +import com.isolaatti.R +import com.isolaatti.utils.clockFormat +import kotlin.time.Duration.Companion.milliseconds + + +@OptIn(UnstableApi::class) +@ExperimentalMaterial3Api +@Composable +fun AudioPlayer( + modifier: Modifier, + positionMs: Long, + isPlaying: Boolean, + isLoading: Boolean, + durationMs: Long, + onPlay: () -> Unit = {}, + onPause: () -> Unit = {} +) { + val sliderValue = positionMs.coerceAtLeast(1).toFloat() / durationMs.coerceAtLeast(1).toFloat() + + + + Card(modifier = modifier) { + + Row(modifier = Modifier.padding(16.dp)) { + IconButton(onClick = {if(isPlaying) onPause() else onPlay()}) { + if(isPlaying) { + Icon(painterResource(R.drawable.baseline_pause_24), null) + } else { + Icon(Icons.Default.PlayArrow, null) + } + } + if(isLoading) { + LinearProgressIndicator(modifier = Modifier.padding(horizontal = 4.dp).weight(1f)) + } else { + Slider(value = sliderValue, onValueChange = {}, modifier = Modifier.padding(horizontal = 4.dp).weight(1f)) + } + + Text("${positionMs.milliseconds.clockFormat()}/${durationMs.milliseconds.clockFormat()}") + } + + } + +} \ No newline at end of file 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 index 912213b..09b56a3 100644 --- a/app/src/main/java/com/isolaatti/audio/common/components/AudioRecorder.kt +++ b/app/src/main/java/com/isolaatti/audio/common/components/AudioRecorder.kt @@ -1,34 +1,185 @@ package com.isolaatti.audio.common.components -import android.media.MediaRecorder -import android.net.Uri +import android.Manifest +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.compose.ui.zIndex +import com.isolaatti.R +import com.isolaatti.utils.clockFormat +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.shouldShowRationale +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.milliseconds +@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class) @Composable fun AudioRecorder( - onAudioRecorded: (Uri) -> Unit, - dismissible: Boolean, - onDismiss: () -> Unit, + onDismiss: () -> Unit = {}, + onPlay: () -> Unit = {}, + onPause: () -> Unit = {}, + onStopRecording: () -> Unit, + onPauseRecording: () -> Unit, + onStartRecording: (fromPaused: Boolean) -> Unit, + isRecording: Boolean, + recordIsStopped: Boolean, + recordIsPaused: Boolean, + isPlaying: Boolean, + isLoading: Boolean, + durationSeconds: Long, + position: Long, + modifier: Modifier = Modifier ) { - var context = LocalContext.current - var mediaRecorder: MediaRecorder? - DisposableEffect(Unit) { - mediaRecorder = MediaRecorder() - onDispose { - mediaRecorder?.release() + + val audioRecordPermissionState = rememberPermissionState(Manifest.permission.RECORD_AUDIO) + + var duration by remember { mutableLongStateOf(0L) } + + + LaunchedEffect(isPlaying) { + coroutineScope { + while(isPlaying) { + duration += 500 + delay(500.milliseconds) + } } } -} -@Preview -@Composable -fun AudioRecorderPreview() { + Card(modifier = modifier + .fillMaxWidth() + .height(120.dp)) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + FilledTonalIconButton( + onClick = onDismiss, + modifier = Modifier.zIndex(2f) + ) { + Icon(imageVector = Icons.Default.Close, contentDescription = null) + } + Text(stringResource(R.string.record_new_audio)) + } + + when { + audioRecordPermissionState.status.isGranted -> { + when { + !isRecording -> { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + IconButton( + onClick = { + onStartRecording(false) + } + ) { + Icon( + painterResource(R.drawable.baseline_circle_24), + null, + tint = Color.Red + ) + } + } + } + + isRecording -> { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 8.dp) + ) { + Text(duration.milliseconds.clockFormat(), modifier = Modifier.padding(horizontal = 4.dp)) + Spacer(Modifier.weight(1f)) + IconButton(onClick = { + if(recordIsPaused) { + onPauseRecording() + } else { + onStartRecording(true) + } + + }) { + if(recordIsPaused) { + Icon(painterResource(R.drawable.baseline_circle_24), null, tint = Color.Red) + } else { + Icon(painterResource(R.drawable.baseline_pause_24), null) + } + } + IconButton(onClick = { + onStopRecording() + }) { + Icon(painterResource(R.drawable.baseline_stop_24), null) + } + } + } + + recordIsStopped -> { + AudioPlayer( + modifier = Modifier.fillMaxWidth(), + positionMs = position, + isLoading = isLoading, + isPlaying = isPlaying, + durationMs = durationSeconds, + onPlay = onPlay, + onPause = onPause + ) + } + } + } + + audioRecordPermissionState.status.shouldShowRationale -> { + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.Start) { + Text(stringResource(R.string.record_audio_permission_required_rationale)) + Button( + onClick = { + audioRecordPermissionState.launchPermissionRequest() + } + ) { + Text(stringResource(R.string.request_permission)) + } + } + } + + else -> { + Column(modifier = Modifier.fillMaxWidth().padding(4.dp), horizontalAlignment = Alignment.Start) { + Text(stringResource(R.string.record_audio_permission_required)) + Button( + onClick = { + audioRecordPermissionState.launchPermissionRequest() + } + ) { + Text(stringResource(R.string.request_permission)) + } + } + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/audio/player/MediaService.kt b/app/src/main/java/com/isolaatti/audio/player/MediaService.kt new file mode 100644 index 0000000..64dbce5 --- /dev/null +++ b/app/src/main/java/com/isolaatti/audio/player/MediaService.kt @@ -0,0 +1,40 @@ +package com.isolaatti.audio.player + +import android.content.Intent +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSessionService + +class MediaService : MediaSessionService() { + + private var mediaSession: MediaSession? = null + + override fun onCreate() { + super.onCreate() + + val player = ExoPlayer.Builder(this).build() + mediaSession = MediaSession.Builder(this, player).build() + + } + + override fun onTaskRemoved(rootIntent: Intent?) { + val player = mediaSession?.player + if(player?.playWhenReady == true) { + player.pause() + } + stopSelf() + } + + override fun onDestroy() { + mediaSession?.run { + player.release() + release() + mediaSession = null + } + super.onDestroy() + } + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { + return mediaSession + } +} \ No newline at end of file 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 91346dc..1f5cdae 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 @@ -7,10 +7,9 @@ import android.net.Uri import android.os.Build import android.util.Log import com.isolaatti.images.common.data.dao.ImagesDraftsDao -import com.isolaatti.images.common.data.entity.ImageDraftEntity import com.isolaatti.images.common.data.remote.DeleteImagesDto import com.isolaatti.images.common.data.remote.ImagesApi -import com.isolaatti.images.common.domain.entity.Image +import com.isolaatti.images.common.domain.entity.RemoteImage import com.isolaatti.images.common.domain.repository.ImagesRepository import com.isolaatti.utils.Resource import kotlinx.coroutines.flow.Flow @@ -28,13 +27,13 @@ class ImagesRepositoryImpl @Inject constructor(private val imagesApi: ImagesApi, companion object { const val TAG = "ImagesRepositoryImpl" } - override fun getImagesOfUser(userId: Int, lastId: String?): Flow>> = flow { + override fun getImagesOfUser(userId: Int, lastId: String?): Flow>> = flow { emit(Resource.Loading()) try { val response = imagesApi.getImagesOfUser(userId, lastId).awaitResponse() if(response.isSuccessful) { val imagesDto = response.body() - val images = imagesDto?.data?.map { Image(it.id) } + val images = imagesDto?.data?.map { RemoteImage(it.id) } emit(Resource.Success(images)) @@ -46,7 +45,7 @@ class ImagesRepositoryImpl @Inject constructor(private val imagesApi: ImagesApi, } } - override fun deleteImages(images: List): Flow> = flow { + override fun deleteImages(images: List): Flow> = flow { emit(Resource.Loading()) val dto = DeleteImagesDto(images.map { it.id }) try { @@ -62,7 +61,7 @@ class ImagesRepositoryImpl @Inject constructor(private val imagesApi: ImagesApi, } } - override fun uploadImage(imageUri: Uri, postId: Long, squadId: String?): Flow> = flow { + override fun uploadImage(imageUri: Uri, postId: Long, squadId: String?): Flow> = flow { emit(Resource.Loading()) var imageInputStream: InputStream? = null try { @@ -93,7 +92,7 @@ class ImagesRepositoryImpl @Inject constructor(private val imagesApi: ImagesApi, emit(Resource.Error(Resource.Error.ErrorType.ServerError)) return@flow } - val image = Image(imageDto.id) + val image = RemoteImage(imageDto.id) emit(Resource.Success(image)) } else { emit(Resource.Error(Resource.Error.mapErrorCode(response.code()))) diff --git a/app/src/main/java/com/isolaatti/images/common/domain/entity/Image.kt b/app/src/main/java/com/isolaatti/images/common/domain/entity/Image.kt index 2ca6e2e..9c25f40 100644 --- a/app/src/main/java/com/isolaatti/images/common/domain/entity/Image.kt +++ b/app/src/main/java/com/isolaatti/images/common/domain/entity/Image.kt @@ -1,56 +1,6 @@ package com.isolaatti.images.common.domain.entity -import android.os.Parcel -import android.os.Parcelable -import com.isolaatti.common.Deletable -import com.isolaatti.images.common.data.remote.ImageDto -import com.isolaatti.markdown.Generators -import com.isolaatti.utils.UrlGen -import java.io.Serializable - -data class Image( - val id: String -): Deletable(), Parcelable { - val imageUrl: String get() = UrlGen.imageUrl(id) - val smallImageUrl : String get() = UrlGen.imageUrl(id, UrlGen.IMAGE_MODE_SMALL) - val reducedImageUrl: String get() = UrlGen.imageUrl(id, UrlGen.IMAGE_MODE_REDUCED) - - val markdown: String get() = Generators.generateImage(imageUrl) - - constructor(parcel: Parcel) : this(parcel.readString()!!) - - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as Image - - if (id != other.id) return false - - return true - } - - override fun hashCode(): Int { - var result = id.hashCode() - return result - } - - override fun writeToParcel(parcel: Parcel, flags: Int) { - parcel.writeString(id) - } - - override fun describeContents(): Int { - return 0 - } - - companion object CREATOR : Parcelable.Creator { - override fun createFromParcel(parcel: Parcel): Image { - return Image(parcel) - } - - override fun newArray(size: Int): Array { - return arrayOfNulls(size) - } - } -} \ No newline at end of file +/** + * Marker interface to identify images + */ +interface Image \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/images/common/domain/entity/LocalImage.kt b/app/src/main/java/com/isolaatti/images/common/domain/entity/LocalImage.kt new file mode 100644 index 0000000..db124b8 --- /dev/null +++ b/app/src/main/java/com/isolaatti/images/common/domain/entity/LocalImage.kt @@ -0,0 +1,11 @@ +package com.isolaatti.images.common.domain.entity + +import android.net.Uri + +/** + * An image that is stored in disk. This cannot be deleted by app, + * but only is to be uploaded to the server + */ +data class LocalImage( + val uri: Uri +) : Image diff --git a/app/src/main/java/com/isolaatti/images/common/domain/entity/RemoteImage.kt b/app/src/main/java/com/isolaatti/images/common/domain/entity/RemoteImage.kt new file mode 100644 index 0000000..1841bdc --- /dev/null +++ b/app/src/main/java/com/isolaatti/images/common/domain/entity/RemoteImage.kt @@ -0,0 +1,62 @@ +package com.isolaatti.images.common.domain.entity + +import android.os.Parcel +import android.os.Parcelable +import com.isolaatti.common.Deletable +import com.isolaatti.markdown.Generators +import com.isolaatti.utils.UrlGen + +/** + * Represents an image that can be accessed using a remote uri. + * + * @property imageUrl The full resolution image url + * @property smallImageUrl A thumbnail url + * @property reducedImageUrl A squared reduced version of the image url + * @property markdown Markdown to insert this image in full resolution + */ +data class RemoteImage( + val id: String +): Deletable(), Parcelable, Image { + val imageUrl: String get() = UrlGen.imageUrl(id) + val smallImageUrl : String get() = UrlGen.imageUrl(id, UrlGen.IMAGE_MODE_SMALL) + val reducedImageUrl: String get() = UrlGen.imageUrl(id, UrlGen.IMAGE_MODE_REDUCED) + + val markdown: String get() = Generators.generateImage(imageUrl) + + constructor(parcel: Parcel) : this(parcel.readString()!!) + + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as RemoteImage + + if (id != other.id) return false + + return true + } + + override fun hashCode(): Int { + var result = id.hashCode() + return result + } + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeString(id) + } + + override fun describeContents(): Int { + return 0 + } + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): RemoteImage { + return RemoteImage(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } +} \ 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 e6cd940..bbfb650 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 @@ -1,13 +1,12 @@ package com.isolaatti.images.common.domain.repository import android.net.Uri -import com.isolaatti.images.common.data.entity.ImageDraftEntity -import com.isolaatti.images.common.domain.entity.Image +import com.isolaatti.images.common.domain.entity.RemoteImage import com.isolaatti.utils.Resource import kotlinx.coroutines.flow.Flow interface ImagesRepository { - fun getImagesOfUser(userId: Int, lastId: String? = null): Flow>> - fun deleteImages(images: List): Flow> - fun uploadImage(imageUri: Uri, postId: Long, squadId: String?): Flow> + fun getImagesOfUser(userId: Int, lastId: String? = null): Flow>> + fun deleteImages(images: List): 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/picture_viewer/presentation/PictureViewerViewPagerAdapter.kt b/app/src/main/java/com/isolaatti/images/picture_viewer/presentation/PictureViewerViewPagerAdapter.kt index 5f7369d..ca4e4cb 100644 --- a/app/src/main/java/com/isolaatti/images/picture_viewer/presentation/PictureViewerViewPagerAdapter.kt +++ b/app/src/main/java/com/isolaatti/images/picture_viewer/presentation/PictureViewerViewPagerAdapter.kt @@ -2,10 +2,10 @@ package com.isolaatti.images.picture_viewer.presentation import androidx.fragment.app.Fragment import androidx.viewpager2.adapter.FragmentStateAdapter -import com.isolaatti.images.common.domain.entity.Image +import com.isolaatti.images.common.domain.entity.RemoteImage import com.isolaatti.images.picture_viewer.ui.PictureViewerImageWrapperFragment -class PictureViewerViewPagerAdapter(fragment: Fragment, private val images: Array) : FragmentStateAdapter(fragment) { +class PictureViewerViewPagerAdapter(fragment: Fragment, private val images: Array) : FragmentStateAdapter(fragment) { override fun getItemCount(): Int { return images.size } diff --git a/app/src/main/java/com/isolaatti/images/picture_viewer/ui/PictureViewerActivity.kt b/app/src/main/java/com/isolaatti/images/picture_viewer/ui/PictureViewerActivity.kt index 487dfac..255273f 100644 --- a/app/src/main/java/com/isolaatti/images/picture_viewer/ui/PictureViewerActivity.kt +++ b/app/src/main/java/com/isolaatti/images/picture_viewer/ui/PictureViewerActivity.kt @@ -5,8 +5,7 @@ import android.content.Intent import android.os.Bundle import androidx.appcompat.app.AppCompatActivity import com.isolaatti.databinding.ActivityPictureViewerBinding -import com.isolaatti.images.common.domain.entity.Image -import com.isolaatti.images.picture_viewer.presentation.PictureViewerViewPagerAdapter +import com.isolaatti.images.common.domain.entity.RemoteImage class PictureViewerActivity : AppCompatActivity() { private lateinit var binding: ActivityPictureViewerBinding @@ -25,7 +24,7 @@ class PictureViewerActivity : AppCompatActivity() { const val EXTRA_IMAGES = "images" const val EXTRA_IMAGE_POSITiON = "position" - fun startActivityWithImages(context: Context, images: Array, position: Int = 0) { + fun startActivityWithImages(context: Context, images: Array, position: Int = 0) { if(images.isEmpty()) { return } diff --git a/app/src/main/java/com/isolaatti/images/picture_viewer/ui/PictureViewerImageWrapperFragment.kt b/app/src/main/java/com/isolaatti/images/picture_viewer/ui/PictureViewerImageWrapperFragment.kt index e304c1d..678ffb6 100644 --- a/app/src/main/java/com/isolaatti/images/picture_viewer/ui/PictureViewerImageWrapperFragment.kt +++ b/app/src/main/java/com/isolaatti/images/picture_viewer/ui/PictureViewerImageWrapperFragment.kt @@ -8,14 +8,14 @@ import androidx.fragment.app.Fragment import coil.load import com.isolaatti.common.CoilImageLoader.imageLoader import com.isolaatti.databinding.FragmentTouchImageViewWrapperBinding -import com.isolaatti.images.common.domain.entity.Image +import com.isolaatti.images.common.domain.entity.RemoteImage import com.ortiz.touchview.OnTouchImageViewListener class PictureViewerImageWrapperFragment : Fragment() { private lateinit var binding: FragmentTouchImageViewWrapperBinding - private var image: Image? = null + private var image: RemoteImage? = null override fun onCreateView( inflater: LayoutInflater, @@ -24,7 +24,7 @@ class PictureViewerImageWrapperFragment : Fragment() { ): View { binding = FragmentTouchImageViewWrapperBinding.inflate(inflater) - image = arguments?.getSerializable(ARGUMENT_IMAGE) as Image + image = arguments?.getSerializable(ARGUMENT_IMAGE) as RemoteImage return binding.root } @@ -50,7 +50,7 @@ class PictureViewerImageWrapperFragment : Fragment() { companion object { const val ARGUMENT_IMAGE = "image" - fun getInstance(image: Image): PictureViewerImageWrapperFragment { + fun getInstance(image: RemoteImage): PictureViewerImageWrapperFragment { val fragment = PictureViewerImageWrapperFragment() fragment.arguments = Bundle().apply { putParcelable(ARGUMENT_IMAGE, image) diff --git a/app/src/main/java/com/isolaatti/images/picture_viewer/ui/PictureViewerMainFragment.kt b/app/src/main/java/com/isolaatti/images/picture_viewer/ui/PictureViewerMainFragment.kt index 1f96fe1..d41f878 100644 --- a/app/src/main/java/com/isolaatti/images/picture_viewer/ui/PictureViewerMainFragment.kt +++ b/app/src/main/java/com/isolaatti/images/picture_viewer/ui/PictureViewerMainFragment.kt @@ -7,14 +7,14 @@ import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.viewpager2.widget.ViewPager2 import com.isolaatti.databinding.FragmentMainPictureViewerBinding -import com.isolaatti.images.common.domain.entity.Image +import com.isolaatti.images.common.domain.entity.RemoteImage import com.isolaatti.images.picture_viewer.presentation.PictureViewerViewPagerAdapter class PictureViewerMainFragment : Fragment() { private lateinit var binding: FragmentMainPictureViewerBinding - lateinit var images: Array + lateinit var images: Array private val onPageChangeCallback = object: ViewPager2.OnPageChangeCallback() { override fun onPageSelected(position: Int) { @@ -36,7 +36,7 @@ class PictureViewerMainFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - images = requireActivity().intent.extras?.getSerializable(PictureViewerActivity.EXTRA_IMAGES) as Array + images = requireActivity().intent.extras?.getSerializable(PictureViewerActivity.EXTRA_IMAGES) as Array val position = requireActivity().intent.extras?.getInt(PictureViewerActivity.EXTRA_IMAGE_POSITiON) ?: 0 val adapter = PictureViewerViewPagerAdapter(this, images) binding.viewpager.adapter = adapter diff --git a/app/src/main/java/com/isolaatti/posting/posts/components/PostAttachments.kt b/app/src/main/java/com/isolaatti/posting/posts/components/PostAttachments.kt index 17b5bd5..8a0f76d 100644 --- a/app/src/main/java/com/isolaatti/posting/posts/components/PostAttachments.kt +++ b/app/src/main/java/com/isolaatti/posting/posts/components/PostAttachments.kt @@ -1,6 +1,7 @@ package com.isolaatti.posting.posts.components import android.net.Uri +import android.util.Log import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues @@ -33,11 +34,14 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import coil3.compose.AsyncImage import com.isolaatti.R +import com.isolaatti.images.common.domain.entity.Image +import com.isolaatti.images.common.domain.entity.LocalImage +import com.isolaatti.images.common.domain.entity.RemoteImage @Composable fun PostAttachments( modifier: Modifier = Modifier, - images: List, + images: List, deletable: Boolean, imageAddable: Boolean, onImageClick: (index: Int) -> Unit, @@ -103,13 +107,26 @@ fun PostAttachments( } } - - AsyncImage( - model = images[index], - contentDescription = null, - modifier = Modifier.fillMaxSize().zIndex(1f), - contentScale = ContentScale.Crop - ) + when(val image = images[index]) { + is LocalImage -> { + AsyncImage( + model = image.uri, + contentDescription = null, + modifier = Modifier.fillMaxSize().zIndex(1f), + contentScale = ContentScale.Crop + ) + } + is RemoteImage -> { + if(!image.delete) { + AsyncImage( + model = image.reducedImageUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize().zIndex(1f), + contentScale = ContentScale.Crop, + ) + } + } + } } } } 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 38c4cde..c489a14 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 @@ -9,6 +9,7 @@ import android.util.Log import com.google.gson.Gson import com.isolaatti.images.common.data.remote.ImageDto import com.isolaatti.images.common.data.remote.ImagesApi +import com.isolaatti.images.common.domain.entity.LocalImage import com.isolaatti.posting.posts.data.remote.CreatePostDto import com.isolaatti.posting.posts.data.remote.DeletePostDto import com.isolaatti.posting.posts.data.remote.EditPostDto @@ -81,7 +82,7 @@ class PostsRepositoryImpl @Inject constructor( } } - override fun makePost(createPostDto: CreatePostDto, images: List): Flow> = flow { + override fun makePost(createPostDto: CreatePostDto, images: List): Flow> = flow { emit(Resource.Loading()) try { val hasImages = images.isNotEmpty() @@ -103,10 +104,10 @@ class PostsRepositoryImpl @Inject constructor( return@flow } emit(Resource.Success(PostingSteps.UploadingPhotos)) - images.forEach { imageUri -> + images.forEach { localImage -> var imageInputStream: InputStream? = null try { - imageInputStream = contentResolver.openInputStream(imageUri) + imageInputStream = contentResolver.openInputStream(localImage.uri) val bitmap = BitmapFactory.decodeStream(imageInputStream) if(bitmap == null) { 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 e42058a..aa4ee86 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,6 +1,7 @@ package com.isolaatti.posting.posts.domain import android.net.Uri +import com.isolaatti.images.common.domain.entity.LocalImage import com.isolaatti.posting.posts.data.remote.CreatePostDto import com.isolaatti.posting.posts.data.remote.EditPostDto import com.isolaatti.posting.posts.data.remote.FeedDto @@ -18,7 +19,7 @@ interface PostsRepository { fun getProfilePosts(userId: Int, lastId: Long, olderFirst: Boolean, filter: FeedFilterDto?): Flow>> - fun makePost(createPostDto: CreatePostDto, images: List): 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/entity/Post.kt b/app/src/main/java/com/isolaatti/posting/posts/domain/entity/Post.kt index 428fb8c..ef997ca 100644 --- a/app/src/main/java/com/isolaatti/posting/posts/domain/entity/Post.kt +++ b/app/src/main/java/com/isolaatti/posting/posts/domain/entity/Post.kt @@ -5,7 +5,7 @@ import android.os.Parcelable import com.isolaatti.audio.common.domain.Audio import com.isolaatti.common.Ownable import com.isolaatti.common.hashtagRegex -import com.isolaatti.images.common.domain.entity.Image +import com.isolaatti.images.common.domain.entity.RemoteImage import com.isolaatti.posting.posts.data.remote.FeedDto data class Post( @@ -22,7 +22,7 @@ data class Post( val squadName: String?, var liked: Boolean, val audio: Audio? = null, - val images: List + val images: List ) : Ownable, Parcelable { constructor(parcel: Parcel) : this( parcel.readLong(), @@ -38,7 +38,7 @@ data class Post( parcel.readString(), parcel.readByte() != 0.toByte(), parcel.readSerializable() as? Audio, - parcel.readParcelableArray(Image::class.java.classLoader)?.toList() as? List ?: emptyList() + parcel.readParcelableArray(RemoteImage::class.java.classLoader)?.toList() as? List ?: emptyList() ) { } @@ -59,7 +59,7 @@ data class Post( squadName = it.squadName, liked = it.liked, audio = it.audio?.let { audioDto -> Audio.fromDto(audioDto) }, - images = it.post.images.map { imageDto -> Image(imageDto.id) } + images = it.post.images.map { imageDto -> RemoteImage(imageDto.id) } ) }.toMutableList() } @@ -78,7 +78,7 @@ data class Post( userName = postDto.userName, squadName = postDto.squadName, liked = postDto.liked, - images = postDto.post.images.map { imageDto -> Image(imageDto.id) } + images = postDto.post.images.map { imageDto -> RemoteImage(imageDto.id) } ) } 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 d88b7ca..a29440c 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,6 +1,7 @@ package com.isolaatti.posting.posts.domain.use_case import android.net.Uri +import com.isolaatti.images.common.domain.entity.LocalImage import com.isolaatti.posting.posts.data.remote.CreatePostDto import com.isolaatti.posting.posts.domain.PostingSteps import com.isolaatti.posting.posts.domain.PostsRepository @@ -12,7 +13,7 @@ class MakePost @Inject constructor(private val postsRepository: PostsRepository) operator fun invoke( privacy: Int, content: String, - images: List, + images: List, audioId: String?, squadId: String? ): Flow> { 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 e208158..c7a5640 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 @@ -2,23 +2,27 @@ package com.isolaatti.posting.posts.presentation import android.net.Uri import android.util.Log +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.isolaatti.audio.common.domain.Audio import com.isolaatti.audio.common.domain.Playable -import com.isolaatti.posting.posts.data.remote.CreatePostDto +import com.isolaatti.images.common.domain.entity.Image +import com.isolaatti.images.common.domain.entity.LocalImage +import com.isolaatti.images.common.domain.entity.RemoteImage import com.isolaatti.posting.posts.data.remote.EditPostDto -import com.isolaatti.posting.posts.data.remote.EditPostDto.Companion.PRIVACY_ISOLAATTI -import com.isolaatti.posting.posts.data.remote.FeedDto import com.isolaatti.posting.posts.domain.PostingSteps -import com.isolaatti.posting.posts.domain.entity.Post import com.isolaatti.posting.posts.domain.use_case.EditPost import com.isolaatti.posting.posts.domain.use_case.LoadSinglePost import com.isolaatti.posting.posts.domain.use_case.MakePost import com.isolaatti.utils.Resource import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -44,8 +48,9 @@ class CreatePostViewModel @Inject constructor( val sendingPost: MutableLiveData = MutableLiveData(null) val postToEdit: MutableLiveData = MutableLiveData() val liveContent: MutableLiveData = MutableLiveData() - private val _photos: MutableStateFlow> = MutableStateFlow(emptyList()) - val photos: StateFlow> get() = _photos + private val _photos: MutableStateFlow> = MutableStateFlow(emptyList()) + val photos: StateFlow> get() = _photos + var audioToUpload: Uri? = null private val _postingStep = MutableStateFlow(PostingSteps.Unspecified) val postingStep: StateFlow get() = _postingStep @@ -55,6 +60,15 @@ class CreatePostViewModel @Inject constructor( private var audioDraft: Long? = null private var audioId: String? = null + // region Player state + val isPlaying: MutableStateFlow = MutableStateFlow(false) + var position: MutableStateFlow = MutableStateFlow(0L) + var duration: MutableStateFlow = MutableStateFlow(0L) + var isLoading: MutableStateFlow = MutableStateFlow(false) + var ended: Boolean = false + // endregion + + /** * postDiscussion() and editDiscussion() will check for audios pending to upload (drafts). It will * upload it (if any) and then send the request to post. @@ -66,7 +80,7 @@ class CreatePostViewModel @Inject constructor( makePost( privacy = EditPostDto.PRIVACY_ISOLAATTI, content = liveContent.value ?: "", - images = photos.value, + images = photos.value.filterIsInstance(), audioId = audioId, squadId = null ).onEach { @@ -140,6 +154,8 @@ class CreatePostViewModel @Inject constructor( //postToEdit.postValue(EditPostDto(PRIVACY_ISOLAATTI, content = it.textContent, postId = it.id)) //it.audio?.let { audio -> audioAttachment.postValue(audio) } + _photos.value = it.images + liveContent.postValue(it.textContent) } } @@ -158,13 +174,26 @@ class CreatePostViewModel @Inject constructor( audioAttachment.value = null } - fun addPicture(uri: Uri) { + fun addPicture(uri: LocalImage) { _photos.value = listOf(uri) + _photos.value } fun removePicture(index: Int) { - _photos.value = _photos.value.toMutableList().apply { - removeAt(index) + val image = _photos.value.getOrNull(index) + if(image == null) { + return } + + when(image) { + is LocalImage -> { + _photos.value = _photos.value.toMutableList().apply { + removeAt(index) + } + } + is RemoteImage -> { + image.delete = true + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/posting/posts/presentation/PostImagesViewPagerAdapter.kt b/app/src/main/java/com/isolaatti/posting/posts/presentation/PostImagesViewPagerAdapter.kt index 763f04d..7bfb21c 100644 --- a/app/src/main/java/com/isolaatti/posting/posts/presentation/PostImagesViewPagerAdapter.kt +++ b/app/src/main/java/com/isolaatti/posting/posts/presentation/PostImagesViewPagerAdapter.kt @@ -1,16 +1,13 @@ package com.isolaatti.posting.posts.presentation -import android.view.View import android.view.ViewGroup import android.widget.ImageView -import android.widget.RelativeLayout -import androidx.core.view.updateLayoutParams import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.ViewHolder import coil.load -import com.isolaatti.images.common.domain.entity.Image +import com.isolaatti.images.common.domain.entity.RemoteImage -class PostImagesViewPagerAdapter(private val images: List) : +class PostImagesViewPagerAdapter(private val images: List) : RecyclerView.Adapter() { class PostImagesViewPagerAdapterItemViewHolder(val imageView: ImageView) : ViewHolder(imageView) 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 b4fd522..0dbf439 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,7 +1,10 @@ package com.isolaatti.posting.posts.ui import android.app.Activity +import android.content.ComponentName +import android.media.MediaRecorder import android.net.Uri +import android.os.Build import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge @@ -9,9 +12,6 @@ import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels 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.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -21,7 +21,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material3.AlertDialog -import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CircularProgressIndicator @@ -47,21 +46,33 @@ 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.compose.ui.window.DialogProperties import androidx.core.content.FileProvider +import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.Player +import androidx.media3.session.MediaController +import androidx.media3.session.SessionToken +import com.google.common.util.concurrent.ListenableFuture import com.isolaatti.MyApplication import com.isolaatti.R import com.isolaatti.audio.common.components.AudioRecorder +import com.isolaatti.audio.player.MediaService import com.isolaatti.common.IsolaattiBaseActivity import com.isolaatti.common.IsolaattiTheme +import com.isolaatti.images.common.domain.entity.LocalImage import com.isolaatti.posting.posts.components.PostAttachments import com.isolaatti.posting.posts.domain.PostingSteps import com.isolaatti.posting.posts.presentation.CreatePostViewModel import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.delay +import kotlinx.coroutines.guava.await +import kotlinx.coroutines.launch import java.io.File import java.util.Calendar +import kotlin.time.Duration.Companion.milliseconds @AndroidEntryPoint class CreatePostActivity : IsolaattiBaseActivity() { @@ -87,7 +98,7 @@ class CreatePostActivity : IsolaattiBaseActivity() { private val choosePictureLauncher = registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { if(it != null) { - viewModel.addPicture(it) + viewModel.addPicture(LocalImage(it)) } } @@ -100,9 +111,37 @@ class CreatePostActivity : IsolaattiBaseActivity() { private val takePhotoLauncher = registerForActivityResult(ActivityResultContracts.TakePicture()) { if(it && cameraPhotoUri != null) { - viewModel.addPicture(cameraPhotoUri!!) + viewModel.addPicture(LocalImage(cameraPhotoUri!!)) + } + } + + private lateinit var mediaControllerFuture: ListenableFuture + + private lateinit var mediaController: MediaController + private lateinit var mediaRecorder: MediaRecorder + + private val playerListener: Player.Listener = object: Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + viewModel.isPlaying.value = isPlaying } + override fun onPlaybackStateChanged(playbackState: Int) { + when(playbackState) { + Player.STATE_READY -> { + viewModel.ended = false + viewModel.isLoading.value = false + viewModel.duration.value = this@CreatePostActivity.mediaController.duration + } + Player.STATE_ENDED -> { + viewModel.ended = true + } + Player.STATE_BUFFERING -> { + viewModel.isLoading.value = true + } + + Player.STATE_IDLE -> {} + } + } } @OptIn(ExperimentalMaterial3Api::class) @@ -110,162 +149,250 @@ class CreatePostActivity : IsolaattiBaseActivity() { enableEdgeToEdge() super.onCreate(savedInstanceState) - setContent { - val text by viewModel.liveContent.observeAsState("") - val focusRequester = remember { FocusRequester() } - val pictures by viewModel.photos.collectAsStateWithLifecycle() + val sessionToken = SessionToken(this, ComponentName(this, MediaService::class.java)) + mediaControllerFuture = MediaController.Builder(this, sessionToken).buildAsync() - val editMode = remember { - mode == EXTRA_MODE_EDIT && postId != 0L - } + mediaRecorder = if (Build.VERSION.SDK_INT >= 31) MediaRecorder(this) else MediaRecorder() - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } + lifecycleScope.launch { + mediaController = mediaControllerFuture.await() - 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() + mediaController.addListener(playerListener) + + setContent { + val text by viewModel.liveContent.observeAsState("") + val focusRequester = remember { FocusRequester() } + val pictures by viewModel.photos.collectAsStateWithLifecycle() + + val editMode = remember { + mode == EXTRA_MODE_EDIT && postId != 0L + } + + + + 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) } - ) { - Text(stringResource(if(editMode) R.string.save else R.string.post)) - } - } - ) - } - ) { - val scrollState = rememberScrollState() - - val posting by viewModel.sendingPost.observeAsState() - - if(posting == true) { - val postingStep by viewModel.postingStep.collectAsState() - AlertDialog( - onDismissRequest = {}, - text = { - Row(verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator() - Text( - when(postingStep) { - PostingSteps.PostContent -> getString(R.string.posting) - PostingSteps.UploadingPhotos -> getString(R.string.uploading_photos) - PostingSteps.Finished -> "" - PostingSteps.Unspecified -> "" - }, - modifier = Modifier.padding(4.dp) - ) - } - }, - dismissButton = null, - title = { - Text(getString(R.string.posting)) - }, - confirmButton = {} - ) - } else if(posting == false){ - finish() - } - Column(modifier = Modifier.padding(it).verticalScroll(scrollState)) { - OutlinedTextField( - value = text, - onValueChange = { textFieldValue -> - viewModel.liveContent.postValue(textFieldValue) - }, - 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 = { + 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() + + val posting by viewModel.sendingPost.observeAsState() + var audioRecorderIsVisible by remember { mutableStateOf(false) } + + if(posting == true) { + val postingStep by viewModel.postingStep.collectAsState() + AlertDialog( + onDismissRequest = {}, + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator() + Text( + when(postingStep) { + PostingSteps.PostContent -> getString(R.string.posting) + PostingSteps.UploadingPhotos -> getString(R.string.uploading_photos) + PostingSteps.Finished -> "" + PostingSteps.Unspecified -> "" + }, + modifier = Modifier.padding(4.dp) + ) + } + }, + dismissButton = null, + title = { + Text(getString(R.string.posting)) + }, + confirmButton = {} + ) + } else if(posting == false){ + finish() + } + Column(modifier = Modifier.padding(it).verticalScroll(scrollState)) { - 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) + OutlinedTextField( + value = text, + onValueChange = { textFieldValue -> + viewModel.liveContent.postValue(textFieldValue) + }, + 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 = { + audioRecorderIsVisible = !audioRecorderIsVisible + } + ) { + Icon(painterResource(id = R.drawable.baseline_mic_24), null) + } + } + ) + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + AnimatedVisibility(audioRecorderIsVisible) { + + val isPlaying by viewModel.isPlaying.collectAsState() + val position by viewModel.position.collectAsState() + val duration by viewModel.duration.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + + val outputFile = File(this@CreatePostActivity.filesDir, "${System.currentTimeMillis()}.3gp") + + LaunchedEffect(isPlaying) { + while(isPlaying) { + delay(500.milliseconds) + viewModel.position.value = mediaController.currentPosition + } + } + + AudioRecorder( + modifier = Modifier.padding(16.dp), + onDismiss = { + audioRecorderIsVisible = false + }, + onPlay = { + if(mediaController.isCommandAvailable(Player.COMMAND_PREPARE)) { + mediaController.prepare() + } + + if(viewModel.ended) { + mediaController.seekTo(0L) + } + mediaController.playWhenReady = true + }, + onPause = { + mediaController.pause() + }, + onStartRecording = { + + mediaRecorder.run { + setAudioSource(MediaRecorder.AudioSource.MIC) + setOutputFormat(MediaRecorder.OutputFormat.THREE_GPP) + setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB) + if (Build.VERSION.SDK_INT >= 26) { + setOutputFile(outputFile) + } else { + setOutputFile(outputFile.path) + } + + prepare() + start() + } + }, + onPauseRecording = { + mediaRecorder.pause() + }, + onStopRecording = { + mediaRecorder.stop() + val audioUri = outputFile.toUri() + mediaController.setMediaItem( + MediaItem.Builder() + .setUri(audioUri) + .setMediaMetadata(MediaMetadata.Builder() + .setTitle(getString(R.string.just_recorded_audio_title)) + .build()) + .build() + ) + viewModel.audioToUpload = audioUri + }, + isPlaying = isPlaying, + position = position, + durationSeconds = duration, + isRecording = false, + recordIsPaused = false, + recordIsStopped = false, + isLoading = isLoading ) - }, - ) + } + + + 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) + } + + if(mode == EXTRA_MODE_EDIT && postId != 0L) { + viewModel.loadDiscussion(postId) } } - - - intent.extras?.let { - mode = it.getInt(EXTRA_KEY_MODE, EXTRA_MODE_CREATE) - postId = it.getLong(EXTRA_KEY_POST_ID) - } - - - - if(mode == EXTRA_MODE_EDIT && postId != 0L) { - viewModel.loadDiscussion(postId) - } - } private fun exit() { setResult(Activity.RESULT_CANCELED) finish() } + + override fun onStop() { + super.onStop() + + MediaController.releaseFuture(mediaControllerFuture) + } } \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/profile/data/repository/ProfileRepositoryImpl.kt b/app/src/main/java/com/isolaatti/profile/data/repository/ProfileRepositoryImpl.kt index 9d0d0eb..26ac711 100644 --- a/app/src/main/java/com/isolaatti/profile/data/repository/ProfileRepositoryImpl.kt +++ b/app/src/main/java/com/isolaatti/profile/data/repository/ProfileRepositoryImpl.kt @@ -3,7 +3,7 @@ package com.isolaatti.profile.data.repository import android.util.Log import com.isolaatti.auth.data.local.UserInfoDao import com.isolaatti.auth.data.local.UserInfoEntity -import com.isolaatti.images.common.domain.entity.Image +import com.isolaatti.images.common.domain.entity.RemoteImage import com.isolaatti.profile.data.remote.ProfileApi import com.isolaatti.profile.data.remote.UpdateProfileDto import com.isolaatti.profile.domain.ProfileRepository @@ -37,7 +37,7 @@ class ProfileRepositoryImpl @Inject constructor( } } - override fun setProfileImage(image: Image): Flow> = flow { + override fun setProfileImage(image: RemoteImage): Flow> = flow { try { val result = profileApi.setProfileImage(image.id).awaitResponse() diff --git a/app/src/main/java/com/isolaatti/profile/domain/ProfileRepository.kt b/app/src/main/java/com/isolaatti/profile/domain/ProfileRepository.kt index b15ea31..6012360 100644 --- a/app/src/main/java/com/isolaatti/profile/domain/ProfileRepository.kt +++ b/app/src/main/java/com/isolaatti/profile/domain/ProfileRepository.kt @@ -1,7 +1,6 @@ package com.isolaatti.profile.domain -import com.isolaatti.images.common.domain.entity.Image -import com.isolaatti.profile.data.remote.UpdateProfileDto +import com.isolaatti.images.common.domain.entity.RemoteImage import com.isolaatti.profile.domain.entity.UserProfile import com.isolaatti.utils.Resource import kotlinx.coroutines.flow.Flow @@ -9,7 +8,7 @@ import kotlinx.coroutines.flow.Flow interface ProfileRepository { fun getProfile(userId: Int): Flow> - fun setProfileImage(image: Image): Flow> + fun setProfileImage(image: RemoteImage): Flow> fun updateProfile(newDisplayName: String, newDescription: String): Flow> diff --git a/app/src/main/java/com/isolaatti/profile/domain/use_case/SetProfileImage.kt b/app/src/main/java/com/isolaatti/profile/domain/use_case/SetProfileImage.kt index c933a54..d12fbf6 100644 --- a/app/src/main/java/com/isolaatti/profile/domain/use_case/SetProfileImage.kt +++ b/app/src/main/java/com/isolaatti/profile/domain/use_case/SetProfileImage.kt @@ -1,11 +1,11 @@ package com.isolaatti.profile.domain.use_case -import com.isolaatti.images.common.domain.entity.Image +import com.isolaatti.images.common.domain.entity.RemoteImage import com.isolaatti.profile.domain.ProfileRepository import com.isolaatti.utils.Resource import kotlinx.coroutines.flow.Flow import javax.inject.Inject class SetProfileImage @Inject constructor(private val profileRepository: ProfileRepository) { - operator fun invoke(image: Image): Flow> = profileRepository.setProfileImage(image) + operator fun invoke(image: RemoteImage): Flow> = profileRepository.setProfileImage(image) } \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/profile/presentation/ProfileViewModel.kt b/app/src/main/java/com/isolaatti/profile/presentation/ProfileViewModel.kt index 7107c9b..a5dec74 100644 --- a/app/src/main/java/com/isolaatti/profile/presentation/ProfileViewModel.kt +++ b/app/src/main/java/com/isolaatti/profile/presentation/ProfileViewModel.kt @@ -4,7 +4,7 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import com.isolaatti.followers.domain.FollowingState -import com.isolaatti.images.common.domain.entity.Image +import com.isolaatti.images.common.domain.entity.RemoteImage import com.isolaatti.posting.posts.presentation.PostListingViewModelBase import com.isolaatti.posting.posts.presentation.UpdateEvent import com.isolaatti.profile.domain.entity.UserProfile @@ -83,7 +83,7 @@ class ProfileViewModel @Inject constructor( } } - fun setProfileImage(image: Image) { + fun setProfileImage(image: RemoteImage) { viewModelScope.launch { setProfileImageUC(image).onEach { _profile.postValue(_profile.value?.copy(profileImageId = image.id)) diff --git a/app/src/main/java/com/isolaatti/profile/ui/ProfileMainFragment.kt b/app/src/main/java/com/isolaatti/profile/ui/ProfileMainFragment.kt index 5a97052..ac65ca8 100644 --- a/app/src/main/java/com/isolaatti/profile/ui/ProfileMainFragment.kt +++ b/app/src/main/java/com/isolaatti/profile/ui/ProfileMainFragment.kt @@ -8,7 +8,6 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast -import androidx.appcompat.content.res.AppCompatResources import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels @@ -23,7 +22,6 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.isolaatti.BuildConfig import com.isolaatti.R import com.isolaatti.audio.common.domain.Audio -import com.isolaatti.audio.common.domain.Playable import com.isolaatti.common.CoilImageLoader.imageLoader import com.isolaatti.common.Dialogs import com.isolaatti.common.ErrorMessageViewModel @@ -35,7 +33,7 @@ import com.isolaatti.common.options_bottom_sheet.ui.BottomSheetPostOptionsFragme import com.isolaatti.databinding.FragmentDiscussionsBinding import com.isolaatti.followers.domain.FollowingState import com.isolaatti.hashtags.ui.HashtagsPostsActivity -import com.isolaatti.images.common.domain.entity.Image +import com.isolaatti.images.common.domain.entity.RemoteImage import com.isolaatti.images.picture_viewer.ui.PictureViewerActivity import com.isolaatti.posting.comments.ui.BottomSheetPostComments import com.isolaatti.posting.posts.domain.entity.Post @@ -182,7 +180,7 @@ class ProfileMainFragment : Fragment() { val profilePictureUrl = profile?.profilePictureUrl if(profilePictureUrl != null) { PictureViewerActivity.startActivityWithImages(requireContext(), arrayOf( - Image(profile.profileImageId ?: "") + RemoteImage(profile.profileImageId ?: "") )) } } diff --git a/app/src/main/java/com/isolaatti/utils/Extensions.kt b/app/src/main/java/com/isolaatti/utils/Extensions.kt index 5a3d25b..f93e981 100644 --- a/app/src/main/java/com/isolaatti/utils/Extensions.kt +++ b/app/src/main/java/com/isolaatti/utils/Extensions.kt @@ -1,8 +1,20 @@ package com.isolaatti.utils -fun Int.clockFormat(): String { - val minutes = this / 60 - val seconds = this % 60 +import java.lang.StringBuilder +import kotlin.time.Duration - return "${minutes}:${seconds.toString().padStart(2, '0')}" +fun Duration.clockFormat(): String { + return toComponents { hours, minutes, seconds, nanoseconds -> + val sb = StringBuilder().apply { + if(hours > 0) { + append(hours.toString().padStart(2, '0')) + append(":") + } + append(minutes.toString().padStart(2, '0')) + append(":") + append(seconds.toString().padStart(2, '0')) + } + + sb.toString() + } } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a47280b..7ae99d4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -220,6 +220,12 @@ Invalid value passed Posting Uploading photos + We need permission to use your microphone + Request permission + In order to be able to record we need to access your mic. + Pause audio + Play audio + Just recorded audio Spam Explicit content