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

View File

@ -5,6 +5,8 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<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
android:name=".MyApplication"
android:allowBackup="false"
@ -17,6 +19,7 @@
android:usesCleartextTraffic="true"
tools:targetApi="31">
<!-- Activities -->
<activity
android:name="com.google.android.gms.oss.licenses.OssLicensesMenuActivity"
android:theme="@style/Theme.Isolaatti.OSS" />
@ -48,10 +51,12 @@
<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=".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=".posting.posts.ui.PostInfoActivity" android:theme="@style/Theme.Isolaatti"/>
<activity android:name=".hashtags.ui.HashtagsPostsActivity" android:theme="@style/Theme.Isolaatti" />
<!-- End activities-->
<provider
android:authorities="com.isolaatti.provider"
android:name="androidx.core.content.FileProvider"
@ -60,6 +65,8 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths"/>
</provider>
<!-- Services -->
<service android:name=".push_notifications.FcmService"
android:exported="false">
<intent-filter>
@ -67,6 +74,13 @@
</intent-filter>
</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>

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

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.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<Resource<List<Image>>> = flow {
override fun getImagesOfUser(userId: Int, lastId: String?): Flow<Resource<List<RemoteImage>>> = 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<Image>): Flow<Resource<Boolean>> = flow {
override fun deleteImages(images: List<RemoteImage>): Flow<Resource<Boolean>> = 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<Resource<Image>> = flow {
override fun uploadImage(imageUri: Uri, postId: Long, squadId: String?): Flow<Resource<RemoteImage>> = 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())))

View File

@ -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<Image> {
override fun createFromParcel(parcel: Parcel): Image {
return Image(parcel)
}
override fun newArray(size: Int): Array<Image?> {
return arrayOfNulls(size)
}
}
}
/**
* Marker interface to identify images
*/
interface Image

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
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<Resource<List<Image>>>
fun deleteImages(images: List<Image>): Flow<Resource<Boolean>>
fun uploadImage(imageUri: Uri, postId: Long, squadId: String?): Flow<Resource<Image>>
fun getImagesOfUser(userId: Int, lastId: String? = null): Flow<Resource<List<RemoteImage>>>
fun deleteImages(images: List<RemoteImage>): Flow<Resource<Boolean>>
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.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<Image>) : FragmentStateAdapter(fragment) {
class PictureViewerViewPagerAdapter(fragment: Fragment, private val images: Array<RemoteImage>) : FragmentStateAdapter(fragment) {
override fun getItemCount(): Int {
return images.size
}

View File

@ -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<Image>, position: Int = 0) {
fun startActivityWithImages(context: Context, images: Array<RemoteImage>, position: Int = 0) {
if(images.isEmpty()) {
return
}

View File

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

View File

@ -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<Image>
lateinit var images: Array<RemoteImage>
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<Image>
images = requireActivity().intent.extras?.getSerializable(PictureViewerActivity.EXTRA_IMAGES) as Array<RemoteImage>
val position = requireActivity().intent.extras?.getInt(PictureViewerActivity.EXTRA_IMAGE_POSITiON) ?: 0
val adapter = PictureViewerViewPagerAdapter(this, images)
binding.viewpager.adapter = adapter

View File

@ -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<Uri>,
images: List<Image>,
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,
)
}
}
}
}
}
}

View File

@ -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<Uri>): Flow<Resource<PostingSteps>> = flow {
override fun makePost(createPostDto: CreatePostDto, images: List<LocalImage>): Flow<Resource<PostingSteps>> = 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) {

View File

@ -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<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 deletePost(postId: Long): Flow<Resource<PostDeletedDto>>
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.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<Image>
val images: List<RemoteImage>
) : 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<Image> ?: emptyList()
parcel.readParcelableArray(RemoteImage::class.java.classLoader)?.toList() as? List<RemoteImage> ?: 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) }
)
}

View File

@ -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<Uri>,
images: List<LocalImage>,
audioId: String?,
squadId: String?
): Flow<Resource<PostingSteps>> {

View File

@ -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<Boolean?> = MutableLiveData(null)
val postToEdit: MutableLiveData<EditPostDto> = MutableLiveData()
val liveContent: MutableLiveData<String> = MutableLiveData()
private val _photos: MutableStateFlow<List<Uri>> = MutableStateFlow(emptyList())
val photos: StateFlow<List<Uri>> get() = _photos
private val _photos: MutableStateFlow<List<Image>> = MutableStateFlow(emptyList())
val photos: StateFlow<List<Image>> get() = _photos
var audioToUpload: Uri? = null
private val _postingStep = MutableStateFlow(PostingSteps.Unspecified)
val postingStep: StateFlow<PostingSteps> 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<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
* 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<LocalImage>(),
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
}
}
}
}

View File

@ -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<Image>) :
class PostImagesViewPagerAdapter(private val images: List<RemoteImage>) :
RecyclerView.Adapter<PostImagesViewPagerAdapter.PostImagesViewPagerAdapterItemViewHolder>() {
class PostImagesViewPagerAdapterItemViewHolder(val imageView: ImageView) : ViewHolder(imageView)

View File

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

View File

@ -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<Resource<Boolean>> = flow {
override fun setProfileImage(image: RemoteImage): Flow<Resource<Boolean>> = flow {
try {
val result = profileApi.setProfileImage(image.id).awaitResponse()

View File

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

View File

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

View File

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

View File

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

View File

@ -220,6 +220,12 @@
<string name="invalid_arg">Invalid value passed</string>
<string name="posting">Posting</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">
<item>Spam</item>
<item>Explicit content</item>