This commit is contained in:
erik-everardo 2025-01-26 01:54:04 -06:00
parent 031722920a
commit 77d1cc13b4
29 changed files with 768 additions and 289 deletions

View File

@ -58,6 +58,7 @@ dependencies {
implementation "androidx.recyclerview:recyclerview:1.3.2" implementation "androidx.recyclerview:recyclerview:1.3.2"
implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.core:core-ktx:1.12.0' 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.legacy:legacy-support-v4:1.0.0'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1'
implementation 'androidx.lifecycle:lifecycle-viewmodel-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 'androidx.compose.runtime:runtime-livedata'
implementation "io.coil-kt.coil3:coil-compose:3.0.1" 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")
} }

View File

@ -5,6 +5,8 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<application <application
android:name=".MyApplication" android:name=".MyApplication"
android:allowBackup="false" android:allowBackup="false"
@ -17,6 +19,7 @@
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
tools:targetApi="31"> tools:targetApi="31">
<!-- Activities -->
<activity <activity
android:name="com.google.android.gms.oss.licenses.OssLicensesMenuActivity" android:name="com.google.android.gms.oss.licenses.OssLicensesMenuActivity"
android:theme="@style/Theme.Isolaatti.OSS" /> android:theme="@style/Theme.Isolaatti.OSS" />
@ -48,10 +51,12 @@
<activity android:name=".about.AboutActivity" android:theme="@style/Theme.Isolaatti"/> <activity android:name=".about.AboutActivity" android:theme="@style/Theme.Isolaatti"/>
<activity android:name=".images.picture_viewer.ui.PictureViewerActivity" android:theme="@style/Theme.Isolaatti"/> <activity android:name=".images.picture_viewer.ui.PictureViewerActivity" android:theme="@style/Theme.Isolaatti"/>
<activity android:name=".sign_up.ui.SignUpActivity" android:theme="@style/Theme.Isolaatti"/> <activity android:name=".sign_up.ui.SignUpActivity" android:theme="@style/Theme.Isolaatti"/>
<activity android:name=".images.image_maker.ui.ImageMakerActivity" android:theme="@style/Theme.Isolaatti"/>
<activity android:name=".profile.ui.EditProfileActivity" android:theme="@style/Theme.Isolaatti" /> <activity android:name=".profile.ui.EditProfileActivity" android:theme="@style/Theme.Isolaatti" />
<activity android:name=".posting.posts.ui.PostInfoActivity" android:theme="@style/Theme.Isolaatti"/> <activity android:name=".posting.posts.ui.PostInfoActivity" android:theme="@style/Theme.Isolaatti"/>
<activity android:name=".hashtags.ui.HashtagsPostsActivity" android:theme="@style/Theme.Isolaatti" /> <activity android:name=".hashtags.ui.HashtagsPostsActivity" android:theme="@style/Theme.Isolaatti" />
<!-- End activities-->
<provider <provider
android:authorities="com.isolaatti.provider" android:authorities="com.isolaatti.provider"
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
@ -60,6 +65,8 @@
android:name="android.support.FILE_PROVIDER_PATHS" android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths"/> android:resource="@xml/provider_paths"/>
</provider> </provider>
<!-- Services -->
<service android:name=".push_notifications.FcmService" <service android:name=".push_notifications.FcmService"
android:exported="false"> android:exported="false">
<intent-filter> <intent-filter>
@ -67,6 +74,13 @@
</intent-filter> </intent-filter>
</service> </service>
<service android:name=".audio.player.MediaService"
android:foregroundServiceType="mediaPlayback"
android:exported="true">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService"/>
</intent-filter>
</service>
</application> </application>

View File

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

View File

@ -1,34 +1,185 @@
package com.isolaatti.audio.common.components package com.isolaatti.audio.common.components
import android.media.MediaRecorder import android.Manifest
import android.net.Uri 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.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.runtime.remember
import androidx.compose.ui.platform.LocalContext import androidx.compose.runtime.setValue
import androidx.compose.ui.tooling.preview.Preview 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 @Composable
fun AudioRecorder( fun AudioRecorder(
onAudioRecorded: (Uri) -> Unit, onDismiss: () -> Unit = {},
dismissible: Boolean, onPlay: () -> Unit = {},
onDismiss: () -> 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)
}
}
}
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))
}
}
}
} }
} }
} }
@Preview
@Composable
fun AudioRecorderPreview() {
}

View File

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

View File

@ -7,10 +7,9 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import com.isolaatti.images.common.data.dao.ImagesDraftsDao 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.DeleteImagesDto
import com.isolaatti.images.common.data.remote.ImagesApi 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.images.common.domain.repository.ImagesRepository
import com.isolaatti.utils.Resource import com.isolaatti.utils.Resource
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -28,13 +27,13 @@ class ImagesRepositoryImpl @Inject constructor(private val imagesApi: ImagesApi,
companion object { companion object {
const val TAG = "ImagesRepositoryImpl" const val TAG = "ImagesRepositoryImpl"
} }
override fun getImagesOfUser(userId: Int, lastId: String?): Flow<Resource<List<Image>>> = flow { override fun getImagesOfUser(userId: Int, lastId: String?): Flow<Resource<List<RemoteImage>>> = flow {
emit(Resource.Loading()) emit(Resource.Loading())
try { try {
val response = imagesApi.getImagesOfUser(userId, lastId).awaitResponse() val response = imagesApi.getImagesOfUser(userId, lastId).awaitResponse()
if(response.isSuccessful) { if(response.isSuccessful) {
val imagesDto = response.body() val imagesDto = response.body()
val images = imagesDto?.data?.map { Image(it.id) } val images = imagesDto?.data?.map { RemoteImage(it.id) }
emit(Resource.Success(images)) emit(Resource.Success(images))
@ -46,7 +45,7 @@ class ImagesRepositoryImpl @Inject constructor(private val imagesApi: ImagesApi,
} }
} }
override fun deleteImages(images: List<Image>): Flow<Resource<Boolean>> = flow { override fun deleteImages(images: List<RemoteImage>): Flow<Resource<Boolean>> = flow {
emit(Resource.Loading()) emit(Resource.Loading())
val dto = DeleteImagesDto(images.map { it.id }) val dto = DeleteImagesDto(images.map { it.id })
try { try {
@ -62,7 +61,7 @@ class ImagesRepositoryImpl @Inject constructor(private val imagesApi: ImagesApi,
} }
} }
override fun uploadImage(imageUri: Uri, postId: Long, squadId: String?): Flow<Resource<Image>> = flow { override fun uploadImage(imageUri: Uri, postId: Long, squadId: String?): Flow<Resource<RemoteImage>> = flow {
emit(Resource.Loading()) emit(Resource.Loading())
var imageInputStream: InputStream? = null var imageInputStream: InputStream? = null
try { try {
@ -93,7 +92,7 @@ class ImagesRepositoryImpl @Inject constructor(private val imagesApi: ImagesApi,
emit(Resource.Error(Resource.Error.ErrorType.ServerError)) emit(Resource.Error(Resource.Error.ErrorType.ServerError))
return@flow return@flow
} }
val image = Image(imageDto.id) val image = RemoteImage(imageDto.id)
emit(Resource.Success(image)) emit(Resource.Success(image))
} else { } else {
emit(Resource.Error(Resource.Error.mapErrorCode(response.code()))) emit(Resource.Error(Resource.Error.mapErrorCode(response.code())))

View File

@ -1,56 +1,6 @@
package com.isolaatti.images.common.domain.entity package com.isolaatti.images.common.domain.entity
import android.os.Parcel /**
import android.os.Parcelable * Marker interface to identify images
import com.isolaatti.common.Deletable */
import com.isolaatti.images.common.data.remote.ImageDto interface Image
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<Image> {
override fun createFromParcel(parcel: Parcel): Image {
return Image(parcel)
}
override fun newArray(size: Int): Array<Image?> {
return arrayOfNulls(size)
}
}
}

View File

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

View File

@ -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<RemoteImage> {
override fun createFromParcel(parcel: Parcel): RemoteImage {
return RemoteImage(parcel)
}
override fun newArray(size: Int): Array<RemoteImage?> {
return arrayOfNulls(size)
}
}
}

View File

@ -1,13 +1,12 @@
package com.isolaatti.images.common.domain.repository package com.isolaatti.images.common.domain.repository
import android.net.Uri import android.net.Uri
import com.isolaatti.images.common.data.entity.ImageDraftEntity import com.isolaatti.images.common.domain.entity.RemoteImage
import com.isolaatti.images.common.domain.entity.Image
import com.isolaatti.utils.Resource import com.isolaatti.utils.Resource
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface ImagesRepository { interface ImagesRepository {
fun getImagesOfUser(userId: Int, lastId: String? = null): Flow<Resource<List<Image>>> fun getImagesOfUser(userId: Int, lastId: String? = null): Flow<Resource<List<RemoteImage>>>
fun deleteImages(images: List<Image>): Flow<Resource<Boolean>> fun deleteImages(images: List<RemoteImage>): Flow<Resource<Boolean>>
fun uploadImage(imageUri: Uri, postId: Long, squadId: String?): Flow<Resource<Image>> fun uploadImage(imageUri: Uri, postId: Long, squadId: String?): Flow<Resource<RemoteImage>>
} }

View File

@ -2,10 +2,10 @@ package com.isolaatti.images.picture_viewer.presentation
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.viewpager2.adapter.FragmentStateAdapter 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 import com.isolaatti.images.picture_viewer.ui.PictureViewerImageWrapperFragment
class PictureViewerViewPagerAdapter(fragment: Fragment, private val images: Array<Image>) : FragmentStateAdapter(fragment) { class PictureViewerViewPagerAdapter(fragment: Fragment, private val images: Array<RemoteImage>) : FragmentStateAdapter(fragment) {
override fun getItemCount(): Int { override fun getItemCount(): Int {
return images.size return images.size
} }

View File

@ -5,8 +5,7 @@ import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import com.isolaatti.databinding.ActivityPictureViewerBinding import com.isolaatti.databinding.ActivityPictureViewerBinding
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 PictureViewerActivity : AppCompatActivity() { class PictureViewerActivity : AppCompatActivity() {
private lateinit var binding: ActivityPictureViewerBinding private lateinit var binding: ActivityPictureViewerBinding
@ -25,7 +24,7 @@ class PictureViewerActivity : AppCompatActivity() {
const val EXTRA_IMAGES = "images" const val EXTRA_IMAGES = "images"
const val EXTRA_IMAGE_POSITiON = "position" const val EXTRA_IMAGE_POSITiON = "position"
fun startActivityWithImages(context: Context, images: Array<Image>, position: Int = 0) { fun startActivityWithImages(context: Context, images: Array<RemoteImage>, position: Int = 0) {
if(images.isEmpty()) { if(images.isEmpty()) {
return return
} }

View File

@ -8,14 +8,14 @@ import androidx.fragment.app.Fragment
import coil.load import coil.load
import com.isolaatti.common.CoilImageLoader.imageLoader import com.isolaatti.common.CoilImageLoader.imageLoader
import com.isolaatti.databinding.FragmentTouchImageViewWrapperBinding 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 import com.ortiz.touchview.OnTouchImageViewListener
class PictureViewerImageWrapperFragment : Fragment() { class PictureViewerImageWrapperFragment : Fragment() {
private lateinit var binding: FragmentTouchImageViewWrapperBinding private lateinit var binding: FragmentTouchImageViewWrapperBinding
private var image: Image? = null private var image: RemoteImage? = null
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
@ -24,7 +24,7 @@ class PictureViewerImageWrapperFragment : Fragment() {
): View { ): View {
binding = FragmentTouchImageViewWrapperBinding.inflate(inflater) binding = FragmentTouchImageViewWrapperBinding.inflate(inflater)
image = arguments?.getSerializable(ARGUMENT_IMAGE) as Image image = arguments?.getSerializable(ARGUMENT_IMAGE) as RemoteImage
return binding.root return binding.root
} }
@ -50,7 +50,7 @@ class PictureViewerImageWrapperFragment : Fragment() {
companion object { companion object {
const val ARGUMENT_IMAGE = "image" const val ARGUMENT_IMAGE = "image"
fun getInstance(image: Image): PictureViewerImageWrapperFragment { fun getInstance(image: RemoteImage): PictureViewerImageWrapperFragment {
val fragment = PictureViewerImageWrapperFragment() val fragment = PictureViewerImageWrapperFragment()
fragment.arguments = Bundle().apply { fragment.arguments = Bundle().apply {
putParcelable(ARGUMENT_IMAGE, image) putParcelable(ARGUMENT_IMAGE, image)

View File

@ -7,14 +7,14 @@ import android.view.ViewGroup
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2
import com.isolaatti.databinding.FragmentMainPictureViewerBinding 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 import com.isolaatti.images.picture_viewer.presentation.PictureViewerViewPagerAdapter
class PictureViewerMainFragment : Fragment() { class PictureViewerMainFragment : Fragment() {
private lateinit var binding: FragmentMainPictureViewerBinding private lateinit var binding: FragmentMainPictureViewerBinding
lateinit var images: Array<Image> lateinit var images: Array<RemoteImage>
private val onPageChangeCallback = object: ViewPager2.OnPageChangeCallback() { private val onPageChangeCallback = object: ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) { override fun onPageSelected(position: Int) {
@ -36,7 +36,7 @@ class PictureViewerMainFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
images = requireActivity().intent.extras?.getSerializable(PictureViewerActivity.EXTRA_IMAGES) as Array<Image> images = requireActivity().intent.extras?.getSerializable(PictureViewerActivity.EXTRA_IMAGES) as Array<RemoteImage>
val position = requireActivity().intent.extras?.getInt(PictureViewerActivity.EXTRA_IMAGE_POSITiON) ?: 0 val position = requireActivity().intent.extras?.getInt(PictureViewerActivity.EXTRA_IMAGE_POSITiON) ?: 0
val adapter = PictureViewerViewPagerAdapter(this, images) val adapter = PictureViewerViewPagerAdapter(this, images)
binding.viewpager.adapter = adapter binding.viewpager.adapter = adapter

View File

@ -1,6 +1,7 @@
package com.isolaatti.posting.posts.components package com.isolaatti.posting.posts.components
import android.net.Uri import android.net.Uri
import android.util.Log
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@ -33,11 +34,14 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.isolaatti.R import com.isolaatti.R
import com.isolaatti.images.common.domain.entity.Image
import com.isolaatti.images.common.domain.entity.LocalImage
import com.isolaatti.images.common.domain.entity.RemoteImage
@Composable @Composable
fun PostAttachments( fun PostAttachments(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
images: List<Uri>, images: List<Image>,
deletable: Boolean, deletable: Boolean,
imageAddable: Boolean, imageAddable: Boolean,
onImageClick: (index: Int) -> Unit, onImageClick: (index: Int) -> Unit,
@ -103,13 +107,26 @@ fun PostAttachments(
} }
} }
when(val image = images[index]) {
AsyncImage( is LocalImage -> {
model = images[index], AsyncImage(
contentDescription = null, model = image.uri,
modifier = Modifier.fillMaxSize().zIndex(1f), contentDescription = null,
contentScale = ContentScale.Crop 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,
)
}
}
}
} }
} }
} }

View File

@ -9,6 +9,7 @@ import android.util.Log
import com.google.gson.Gson import com.google.gson.Gson
import com.isolaatti.images.common.data.remote.ImageDto import com.isolaatti.images.common.data.remote.ImageDto
import com.isolaatti.images.common.data.remote.ImagesApi 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.CreatePostDto
import com.isolaatti.posting.posts.data.remote.DeletePostDto import com.isolaatti.posting.posts.data.remote.DeletePostDto
import com.isolaatti.posting.posts.data.remote.EditPostDto import com.isolaatti.posting.posts.data.remote.EditPostDto
@ -81,7 +82,7 @@ class PostsRepositoryImpl @Inject constructor(
} }
} }
override fun makePost(createPostDto: CreatePostDto, images: List<Uri>): Flow<Resource<PostingSteps>> = flow { override fun makePost(createPostDto: CreatePostDto, images: List<LocalImage>): Flow<Resource<PostingSteps>> = flow {
emit(Resource.Loading()) emit(Resource.Loading())
try { try {
val hasImages = images.isNotEmpty() val hasImages = images.isNotEmpty()
@ -103,10 +104,10 @@ class PostsRepositoryImpl @Inject constructor(
return@flow return@flow
} }
emit(Resource.Success(PostingSteps.UploadingPhotos)) emit(Resource.Success(PostingSteps.UploadingPhotos))
images.forEach { imageUri -> images.forEach { localImage ->
var imageInputStream: InputStream? = null var imageInputStream: InputStream? = null
try { try {
imageInputStream = contentResolver.openInputStream(imageUri) imageInputStream = contentResolver.openInputStream(localImage.uri)
val bitmap = BitmapFactory.decodeStream(imageInputStream) val bitmap = BitmapFactory.decodeStream(imageInputStream)
if(bitmap == null) { if(bitmap == null) {

View File

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

View File

@ -5,7 +5,7 @@ import android.os.Parcelable
import com.isolaatti.audio.common.domain.Audio import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.common.Ownable import com.isolaatti.common.Ownable
import com.isolaatti.common.hashtagRegex 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 import com.isolaatti.posting.posts.data.remote.FeedDto
data class Post( data class Post(
@ -22,7 +22,7 @@ data class Post(
val squadName: String?, val squadName: String?,
var liked: Boolean, var liked: Boolean,
val audio: Audio? = null, val audio: Audio? = null,
val images: List<Image> val images: List<RemoteImage>
) : Ownable, Parcelable { ) : Ownable, Parcelable {
constructor(parcel: Parcel) : this( constructor(parcel: Parcel) : this(
parcel.readLong(), parcel.readLong(),
@ -38,7 +38,7 @@ data class Post(
parcel.readString(), parcel.readString(),
parcel.readByte() != 0.toByte(), parcel.readByte() != 0.toByte(),
parcel.readSerializable() as? Audio, parcel.readSerializable() as? Audio,
parcel.readParcelableArray(Image::class.java.classLoader)?.toList() as? List<Image> ?: emptyList() parcel.readParcelableArray(RemoteImage::class.java.classLoader)?.toList() as? List<RemoteImage> ?: emptyList()
) { ) {
} }
@ -59,7 +59,7 @@ data class Post(
squadName = it.squadName, squadName = it.squadName,
liked = it.liked, liked = it.liked,
audio = it.audio?.let { audioDto -> Audio.fromDto(audioDto) }, 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() }.toMutableList()
} }
@ -78,7 +78,7 @@ data class Post(
userName = postDto.userName, userName = postDto.userName,
squadName = postDto.squadName, squadName = postDto.squadName,
liked = postDto.liked, liked = postDto.liked,
images = postDto.post.images.map { imageDto -> Image(imageDto.id) } images = postDto.post.images.map { imageDto -> RemoteImage(imageDto.id) }
) )
} }

View File

@ -1,6 +1,7 @@
package com.isolaatti.posting.posts.domain.use_case package com.isolaatti.posting.posts.domain.use_case
import android.net.Uri 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.CreatePostDto
import com.isolaatti.posting.posts.domain.PostingSteps import com.isolaatti.posting.posts.domain.PostingSteps
import com.isolaatti.posting.posts.domain.PostsRepository import com.isolaatti.posting.posts.domain.PostsRepository
@ -12,7 +13,7 @@ class MakePost @Inject constructor(private val postsRepository: PostsRepository)
operator fun invoke( operator fun invoke(
privacy: Int, privacy: Int,
content: String, content: String,
images: List<Uri>, images: List<LocalImage>,
audioId: String?, audioId: String?,
squadId: String? squadId: String?
): Flow<Resource<PostingSteps>> { ): Flow<Resource<PostingSteps>> {

View File

@ -2,23 +2,27 @@ package com.isolaatti.posting.posts.presentation
import android.net.Uri import android.net.Uri
import android.util.Log 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.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.isolaatti.audio.common.domain.Audio import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.audio.common.domain.Playable 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
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.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.EditPost
import com.isolaatti.posting.posts.domain.use_case.LoadSinglePost import com.isolaatti.posting.posts.domain.use_case.LoadSinglePost
import com.isolaatti.posting.posts.domain.use_case.MakePost import com.isolaatti.posting.posts.domain.use_case.MakePost
import com.isolaatti.utils.Resource import com.isolaatti.utils.Resource
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -44,8 +48,9 @@ class CreatePostViewModel @Inject constructor(
val sendingPost: MutableLiveData<Boolean?> = MutableLiveData(null) val sendingPost: MutableLiveData<Boolean?> = MutableLiveData(null)
val postToEdit: MutableLiveData<EditPostDto> = MutableLiveData() val postToEdit: MutableLiveData<EditPostDto> = MutableLiveData()
val liveContent: MutableLiveData<String> = MutableLiveData() val liveContent: MutableLiveData<String> = MutableLiveData()
private val _photos: MutableStateFlow<List<Uri>> = MutableStateFlow(emptyList()) private val _photos: MutableStateFlow<List<Image>> = MutableStateFlow(emptyList())
val photos: StateFlow<List<Uri>> get() = _photos val photos: StateFlow<List<Image>> get() = _photos
var audioToUpload: Uri? = null
private val _postingStep = MutableStateFlow(PostingSteps.Unspecified) private val _postingStep = MutableStateFlow(PostingSteps.Unspecified)
val postingStep: StateFlow<PostingSteps> get() = _postingStep val postingStep: StateFlow<PostingSteps> get() = _postingStep
@ -55,6 +60,15 @@ class CreatePostViewModel @Inject constructor(
private var audioDraft: Long? = null private var audioDraft: Long? = null
private var audioId: String? = null private var audioId: String? = null
// region Player state
val isPlaying: MutableStateFlow<Boolean> = MutableStateFlow(false)
var position: MutableStateFlow<Long> = MutableStateFlow(0L)
var duration: MutableStateFlow<Long> = MutableStateFlow(0L)
var isLoading: MutableStateFlow<Boolean> = MutableStateFlow(false)
var ended: Boolean = false
// endregion
/** /**
* postDiscussion() and editDiscussion() will check for audios pending to upload (drafts). It will * postDiscussion() and editDiscussion() will check for audios pending to upload (drafts). It will
* upload it (if any) and then send the request to post. * upload it (if any) and then send the request to post.
@ -66,7 +80,7 @@ class CreatePostViewModel @Inject constructor(
makePost( makePost(
privacy = EditPostDto.PRIVACY_ISOLAATTI, privacy = EditPostDto.PRIVACY_ISOLAATTI,
content = liveContent.value ?: "", content = liveContent.value ?: "",
images = photos.value, images = photos.value.filterIsInstance<LocalImage>(),
audioId = audioId, audioId = audioId,
squadId = null squadId = null
).onEach { ).onEach {
@ -140,6 +154,8 @@ class CreatePostViewModel @Inject constructor(
//postToEdit.postValue(EditPostDto(PRIVACY_ISOLAATTI, content = it.textContent, postId = it.id)) //postToEdit.postValue(EditPostDto(PRIVACY_ISOLAATTI, content = it.textContent, postId = it.id))
//it.audio?.let { audio -> audioAttachment.postValue(audio) } //it.audio?.let { audio -> audioAttachment.postValue(audio) }
_photos.value = it.images
liveContent.postValue(it.textContent) liveContent.postValue(it.textContent)
} }
} }
@ -158,13 +174,26 @@ class CreatePostViewModel @Inject constructor(
audioAttachment.value = null audioAttachment.value = null
} }
fun addPicture(uri: Uri) { fun addPicture(uri: LocalImage) {
_photos.value = listOf(uri) + _photos.value _photos.value = listOf(uri) + _photos.value
} }
fun removePicture(index: Int) { fun removePicture(index: Int) {
_photos.value = _photos.value.toMutableList().apply { val image = _photos.value.getOrNull(index)
removeAt(index) if(image == null) {
return
} }
when(image) {
is LocalImage -> {
_photos.value = _photos.value.toMutableList().apply {
removeAt(index)
}
}
is RemoteImage -> {
image.delete = true
}
}
} }
} }

View File

@ -1,16 +1,13 @@
package com.isolaatti.posting.posts.presentation package com.isolaatti.posting.posts.presentation
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import android.widget.RelativeLayout
import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.ViewHolder import androidx.recyclerview.widget.RecyclerView.ViewHolder
import coil.load 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<Image>) : class PostImagesViewPagerAdapter(private val images: List<RemoteImage>) :
RecyclerView.Adapter<PostImagesViewPagerAdapter.PostImagesViewPagerAdapterItemViewHolder>() { RecyclerView.Adapter<PostImagesViewPagerAdapter.PostImagesViewPagerAdapterItemViewHolder>() {
class PostImagesViewPagerAdapterItemViewHolder(val imageView: ImageView) : ViewHolder(imageView) class PostImagesViewPagerAdapterItemViewHolder(val imageView: ImageView) : ViewHolder(imageView)

View File

@ -1,7 +1,10 @@
package com.isolaatti.posting.posts.ui package com.isolaatti.posting.posts.ui
import android.app.Activity import android.app.Activity
import android.content.ComponentName
import android.media.MediaRecorder
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
@ -9,9 +12,6 @@ import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.compose.animation.AnimatedVisibility 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.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth 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.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.CircularProgressIndicator 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.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.MyApplication
import com.isolaatti.R import com.isolaatti.R
import com.isolaatti.audio.common.components.AudioRecorder import com.isolaatti.audio.common.components.AudioRecorder
import com.isolaatti.audio.player.MediaService
import com.isolaatti.common.IsolaattiBaseActivity import com.isolaatti.common.IsolaattiBaseActivity
import com.isolaatti.common.IsolaattiTheme 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.components.PostAttachments
import com.isolaatti.posting.posts.domain.PostingSteps import com.isolaatti.posting.posts.domain.PostingSteps
import com.isolaatti.posting.posts.presentation.CreatePostViewModel import com.isolaatti.posting.posts.presentation.CreatePostViewModel
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.delay
import kotlinx.coroutines.guava.await
import kotlinx.coroutines.launch
import java.io.File import java.io.File
import java.util.Calendar import java.util.Calendar
import kotlin.time.Duration.Companion.milliseconds
@AndroidEntryPoint @AndroidEntryPoint
class CreatePostActivity : IsolaattiBaseActivity() { class CreatePostActivity : IsolaattiBaseActivity() {
@ -87,7 +98,7 @@ class CreatePostActivity : IsolaattiBaseActivity() {
private val choosePictureLauncher = registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { private val choosePictureLauncher = registerForActivityResult(ActivityResultContracts.PickVisualMedia()) {
if(it != null) { if(it != null) {
viewModel.addPicture(it) viewModel.addPicture(LocalImage(it))
} }
} }
@ -100,9 +111,37 @@ class CreatePostActivity : IsolaattiBaseActivity() {
private val takePhotoLauncher = registerForActivityResult(ActivityResultContracts.TakePicture()) { private val takePhotoLauncher = registerForActivityResult(ActivityResultContracts.TakePicture()) {
if(it && cameraPhotoUri != null) { if(it && cameraPhotoUri != null) {
viewModel.addPicture(cameraPhotoUri!!) viewModel.addPicture(LocalImage(cameraPhotoUri!!))
}
}
private lateinit var mediaControllerFuture: ListenableFuture<MediaController>
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) @OptIn(ExperimentalMaterial3Api::class)
@ -110,162 +149,250 @@ class CreatePostActivity : IsolaattiBaseActivity() {
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContent { val sessionToken = SessionToken(this, ComponentName(this, MediaService::class.java))
val text by viewModel.liveContent.observeAsState("") mediaControllerFuture = MediaController.Builder(this, sessionToken).buildAsync()
val focusRequester = remember { FocusRequester() }
val pictures by viewModel.photos.collectAsStateWithLifecycle()
val editMode = remember { mediaRecorder = if (Build.VERSION.SDK_INT >= 31) MediaRecorder(this) else MediaRecorder()
mode == EXTRA_MODE_EDIT && postId != 0L
}
LaunchedEffect(Unit) { lifecycleScope.launch {
focusRequester.requestFocus() mediaController = mediaControllerFuture.await()
}
IsolaattiTheme { mediaController.addListener(playerListener)
Scaffold(
topBar = { setContent {
CenterAlignedTopAppBar( val text by viewModel.liveContent.observeAsState("")
title = { Text(stringResource(if(editMode) R.string.edit else R.string.new_post)) }, val focusRequester = remember { FocusRequester() }
navigationIcon = { val pictures by viewModel.photos.collectAsStateWithLifecycle()
IconButton(
onClick = { val editMode = remember {
exit() mode == EXTRA_MODE_EDIT && postId != 0L
} }
) {
Icon(Icons.Default.Close, null)
}
}, IsolaattiTheme {
actions = { Scaffold(
Button( topBar = {
enabled = text.isNotBlank() || pictures.isNotEmpty(), CenterAlignedTopAppBar(
onClick = { title = { Text(stringResource(if(editMode) R.string.edit else R.string.new_post)) },
if(editMode) { navigationIcon = {
viewModel.editDiscussion(postId) IconButton(
} else { onClick = {
viewModel.postDiscussion() 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, actions = {
onDismiss = { 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 = {
}, OutlinedTextField(
onImageDeleteClick = { value = text,
viewModel.removePicture(it) onValueChange = { textFieldValue ->
}, viewModel.liveContent.postValue(textFieldValue)
onTakePicture = { },
cameraPhotoUri = makePhotoUri() modifier = Modifier
takePhotoLauncher.launch(cameraPhotoUri!!) .padding(horizontal = 8.dp)
}, .fillMaxWidth()
onUploadPicture = { .focusRequester(focusRequester),
choosePictureLauncher.launch( placeholder = { Text(stringResource(R.string.what_do_you_want_to_talk_about_you_can_record_an_audio_if_you_want)) },
PickVisualMediaRequest( colors = OutlinedTextFieldDefaults
ActivityResultContracts.PickVisualMedia.ImageOnly) .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() { private fun exit() {
setResult(Activity.RESULT_CANCELED) setResult(Activity.RESULT_CANCELED)
finish() finish()
} }
override fun onStop() {
super.onStop()
MediaController.releaseFuture(mediaControllerFuture)
}
} }

View File

@ -3,7 +3,7 @@ package com.isolaatti.profile.data.repository
import android.util.Log import android.util.Log
import com.isolaatti.auth.data.local.UserInfoDao import com.isolaatti.auth.data.local.UserInfoDao
import com.isolaatti.auth.data.local.UserInfoEntity 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.ProfileApi
import com.isolaatti.profile.data.remote.UpdateProfileDto import com.isolaatti.profile.data.remote.UpdateProfileDto
import com.isolaatti.profile.domain.ProfileRepository import com.isolaatti.profile.domain.ProfileRepository
@ -37,7 +37,7 @@ class ProfileRepositoryImpl @Inject constructor(
} }
} }
override fun setProfileImage(image: Image): Flow<Resource<Boolean>> = flow { override fun setProfileImage(image: RemoteImage): Flow<Resource<Boolean>> = flow {
try { try {
val result = profileApi.setProfileImage(image.id).awaitResponse() val result = profileApi.setProfileImage(image.id).awaitResponse()

View File

@ -1,7 +1,6 @@
package com.isolaatti.profile.domain package com.isolaatti.profile.domain
import com.isolaatti.images.common.domain.entity.Image import com.isolaatti.images.common.domain.entity.RemoteImage
import com.isolaatti.profile.data.remote.UpdateProfileDto
import com.isolaatti.profile.domain.entity.UserProfile import com.isolaatti.profile.domain.entity.UserProfile
import com.isolaatti.utils.Resource import com.isolaatti.utils.Resource
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -9,7 +8,7 @@ import kotlinx.coroutines.flow.Flow
interface ProfileRepository { interface ProfileRepository {
fun getProfile(userId: Int): Flow<Resource<UserProfile>> fun getProfile(userId: Int): Flow<Resource<UserProfile>>
fun setProfileImage(image: Image): Flow<Resource<Boolean>> fun setProfileImage(image: RemoteImage): Flow<Resource<Boolean>>
fun updateProfile(newDisplayName: String, newDescription: String): Flow<Resource<Boolean>> fun updateProfile(newDisplayName: String, newDescription: String): Flow<Resource<Boolean>>

View File

@ -1,11 +1,11 @@
package com.isolaatti.profile.domain.use_case 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.profile.domain.ProfileRepository
import com.isolaatti.utils.Resource import com.isolaatti.utils.Resource
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import javax.inject.Inject import javax.inject.Inject
class SetProfileImage @Inject constructor(private val profileRepository: ProfileRepository) { class SetProfileImage @Inject constructor(private val profileRepository: ProfileRepository) {
operator fun invoke(image: Image): Flow<Resource<Boolean>> = profileRepository.setProfileImage(image) operator fun invoke(image: RemoteImage): Flow<Resource<Boolean>> = profileRepository.setProfileImage(image)
} }

View File

@ -4,7 +4,7 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.isolaatti.followers.domain.FollowingState 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.PostListingViewModelBase
import com.isolaatti.posting.posts.presentation.UpdateEvent import com.isolaatti.posting.posts.presentation.UpdateEvent
import com.isolaatti.profile.domain.entity.UserProfile 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 { viewModelScope.launch {
setProfileImageUC(image).onEach { setProfileImageUC(image).onEach {
_profile.postValue(_profile.value?.copy(profileImageId = image.id)) _profile.postValue(_profile.value?.copy(profileImageId = image.id))

View File

@ -8,7 +8,6 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.content.res.AppCompatResources
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
@ -23,7 +22,6 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.isolaatti.BuildConfig import com.isolaatti.BuildConfig
import com.isolaatti.R import com.isolaatti.R
import com.isolaatti.audio.common.domain.Audio import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.audio.common.domain.Playable
import com.isolaatti.common.CoilImageLoader.imageLoader import com.isolaatti.common.CoilImageLoader.imageLoader
import com.isolaatti.common.Dialogs import com.isolaatti.common.Dialogs
import com.isolaatti.common.ErrorMessageViewModel 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.databinding.FragmentDiscussionsBinding
import com.isolaatti.followers.domain.FollowingState import com.isolaatti.followers.domain.FollowingState
import com.isolaatti.hashtags.ui.HashtagsPostsActivity 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.images.picture_viewer.ui.PictureViewerActivity
import com.isolaatti.posting.comments.ui.BottomSheetPostComments import com.isolaatti.posting.comments.ui.BottomSheetPostComments
import com.isolaatti.posting.posts.domain.entity.Post import com.isolaatti.posting.posts.domain.entity.Post
@ -182,7 +180,7 @@ class ProfileMainFragment : Fragment() {
val profilePictureUrl = profile?.profilePictureUrl val profilePictureUrl = profile?.profilePictureUrl
if(profilePictureUrl != null) { if(profilePictureUrl != null) {
PictureViewerActivity.startActivityWithImages(requireContext(), arrayOf( PictureViewerActivity.startActivityWithImages(requireContext(), arrayOf(
Image(profile.profileImageId ?: "") RemoteImage(profile.profileImageId ?: "")
)) ))
} }
} }

View File

@ -1,8 +1,20 @@
package com.isolaatti.utils package com.isolaatti.utils
fun Int.clockFormat(): String { import java.lang.StringBuilder
val minutes = this / 60 import kotlin.time.Duration
val seconds = this % 60
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()
}
} }

View File

@ -220,6 +220,12 @@
<string name="invalid_arg">Invalid value passed</string> <string name="invalid_arg">Invalid value passed</string>
<string name="posting">Posting</string> <string name="posting">Posting</string>
<string name="uploading_photos">Uploading photos</string> <string name="uploading_photos">Uploading photos</string>
<string name="record_audio_permission_required">We need permission to use your microphone</string>
<string name="request_permission">Request permission</string>
<string name="record_audio_permission_required_rationale">In order to be able to record we need to access your mic.</string>
<string name="pause_audio">Pause audio</string>
<string name="play_audio">Play audio</string>
<string name="just_recorded_audio_title">Just recorded audio</string>
<string-array name="report_reasons"> <string-array name="report_reasons">
<item>Spam</item> <item>Spam</item>
<item>Explicit content</item> <item>Explicit content</item>