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