Merge remote-tracking branch 'origin/WIP' into WIP

# Conflicts:
#	app/src/main/java/com/isolaatti/profile/presentation/ProfileViewModel.kt
#	app/src/main/java/com/isolaatti/profile/ui/ProfileMainFragment.kt
#	app/src/main/java/com/isolaatti/profile/ui/components/ProfileHeader.kt
#	app/src/main/res/values/strings.xml
This commit is contained in:
erik 2025-03-17 22:38:57 -06:00
commit 34326cc8d4
16 changed files with 298 additions and 82 deletions

View File

@ -159,7 +159,7 @@ dependencies {
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.5' implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.5'
implementation 'androidx.compose.runtime:runtime-livedata' implementation 'androidx.compose.runtime:runtime-livedata'
implementation("com.google.accompanist:accompanist-permissions:0.36.0") implementation("com.google.accompanist:accompanist-permissions:0.36.0")
implementation "androidx.work:work-runtime-ktx:2.10.0"
} }

View File

@ -20,6 +20,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -48,7 +49,7 @@ fun AudioPlayer(
Card(modifier = modifier) { Card(modifier = modifier) {
Row(modifier = Modifier.padding(16.dp)) { Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = {if(isPlaying) onPause() else onPlay()}) { IconButton(onClick = {if(isPlaying) onPause() else onPlay()}) {
if(isPlaying) { if(isPlaying) {
Icon(painterResource(R.drawable.baseline_pause_24), null) Icon(painterResource(R.drawable.baseline_pause_24), null)

View File

@ -19,8 +19,10 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -40,6 +42,35 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
class AudioRecorderState(
isRecording: Boolean = false,
recordIsStopped: Boolean = false,
recordIsPaused: Boolean = false,
isPlaying: Boolean = false,
isLoading: Boolean = false,
durationSeconds: Long = 0L,
position: Long = 0L,
) {
var isRecording by mutableStateOf(isRecording)
var recordIsStopped by mutableStateOf(recordIsStopped)
var recordIsPaused by mutableStateOf(recordIsPaused)
var isPlaying by mutableStateOf(isPlaying)
var isLoading by mutableStateOf(isLoading)
var durationSeconds by mutableStateOf(durationSeconds)
var position by mutableStateOf(position)
fun clearState() {
recordIsStopped = false
isRecording = false
recordIsPaused = false
isPlaying = false
durationSeconds = 0L
position = 0L
}
}
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class) @OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
fun AudioRecorder( fun AudioRecorder(
@ -49,13 +80,7 @@ fun AudioRecorder(
onStopRecording: () -> Unit, onStopRecording: () -> Unit,
onPauseRecording: () -> Unit, onPauseRecording: () -> Unit,
onStartRecording: (fromPaused: Boolean) -> Unit, onStartRecording: (fromPaused: Boolean) -> Unit,
isRecording: Boolean, state: AudioRecorderState = AudioRecorderState(),
recordIsStopped: Boolean,
recordIsPaused: Boolean,
isPlaying: Boolean,
isLoading: Boolean,
durationSeconds: Long,
position: Long,
onSeek: (position: Float) -> Unit = {}, onSeek: (position: Float) -> Unit = {},
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@ -68,9 +93,9 @@ fun AudioRecorder(
var duration by remember { mutableLongStateOf(0L) } var duration by remember { mutableLongStateOf(0L) }
LaunchedEffect(isRecording, recordIsPaused) { LaunchedEffect(state.isRecording, state.recordIsPaused) {
coroutineScope { coroutineScope {
while(isRecording && !recordIsPaused) { while(state.isRecording && !state.recordIsPaused) {
duration += 500 duration += 500
delay(500.milliseconds) delay(500.milliseconds)
} }
@ -94,7 +119,7 @@ fun AudioRecorder(
when { when {
audioRecordPermissionState.status.isGranted -> { audioRecordPermissionState.status.isGranted -> {
when { when {
!isRecording && !recordIsStopped-> { !state.isRecording && !state.recordIsStopped-> {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
@ -113,7 +138,7 @@ fun AudioRecorder(
} }
} }
isRecording && !recordIsStopped -> { state.isRecording && !state.recordIsStopped -> {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 8.dp) modifier = Modifier.padding(horizontal = 8.dp)
@ -121,7 +146,7 @@ fun AudioRecorder(
Text(duration.milliseconds.clockFormat(), modifier = Modifier.padding(horizontal = 4.dp)) Text(duration.milliseconds.clockFormat(), modifier = Modifier.padding(horizontal = 4.dp))
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
IconButton(onClick = { IconButton(onClick = {
if(recordIsPaused) { if(state.recordIsPaused) {
onStartRecording(true) onStartRecording(true)
} else { } else {
@ -129,7 +154,7 @@ fun AudioRecorder(
} }
}) { }) {
if(recordIsPaused) { if(state.recordIsPaused) {
Icon(painterResource(R.drawable.baseline_circle_24), null, tint = Color.Red) Icon(painterResource(R.drawable.baseline_circle_24), null, tint = Color.Red)
} else { } else {
Icon(painterResource(R.drawable.baseline_pause_24), null) Icon(painterResource(R.drawable.baseline_pause_24), null)
@ -146,10 +171,10 @@ fun AudioRecorder(
else -> { else -> {
AudioPlayer( AudioPlayer(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
positionMs = position, positionMs = state.position,
isLoading = isLoading, isLoading = state.isLoading,
isPlaying = isPlaying, isPlaying = state.isPlaying,
durationMs = durationSeconds, durationMs = state.durationSeconds,
onPlay = onPlay, onPlay = onPlay,
onPause = onPause, onPause = onPause,
onSeek = onSeek onSeek = onSeek

View File

@ -0,0 +1,10 @@
package com.isolaatti.audio.player
import com.isolaatti.audio.common.domain.Audio
data class AudioPlaybackState(
val currentAudio: Audio? = null,
var isPlaying: Boolean = false,
val duration: Long = 0L,
val progress: Long = 0L
)

View File

@ -15,6 +15,7 @@ import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -33,10 +34,14 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.isolaatti.R import com.isolaatti.R
import com.isolaatti.audio.common.components.AudioPlayer
import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.audio.player.AudioPlaybackState
import com.isolaatti.images.common.domain.entity.RemoteImage import com.isolaatti.images.common.domain.entity.RemoteImage
import com.isolaatti.posting.posts.domain.entity.Post import com.isolaatti.posting.posts.domain.entity.Post
import com.isolaatti.utils.UrlGen.userProfileImage import com.isolaatti.utils.UrlGen.userProfileImage
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PostComponent( fun PostComponent(
post: Post, post: Post,
@ -50,6 +55,7 @@ fun PostComponent(
onUsernameClick: () -> Unit = {}, onUsernameClick: () -> Unit = {},
onImageClick: (images: List<RemoteImage>, index: Int) -> Unit = {_, _ -> }, onImageClick: (images: List<RemoteImage>, index: Int) -> Unit = {_, _ -> },
onHashtagClick: (hashtag: String) -> Unit = {}, onHashtagClick: (hashtag: String) -> Unit = {},
onPlay: (audio: Audio, positionMs: Long) -> Unit = {_, _ -> },
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Card(modifier = modifier.padding(8.dp).clickable { onClick() }) { Card(modifier = modifier.padding(8.dp).clickable { onClick() }) {
@ -77,7 +83,22 @@ fun PostComponent(
} }
post.audio?.let { post.audio?.let {
// TODO audio player here AudioPlayer(
modifier = Modifier.fillMaxWidth().height(80.dp),
positionMs = 2000L,
isPlaying = false,
durationMs = 30_000L,
isLoading = false,
onPlay = {
},
onPause = {
},
onSeek = {
}
)
} }
if(post.textContent.isNotBlank()) { if(post.textContent.isNotBlank()) {
@ -167,5 +188,5 @@ fun PostComponent(
@Composable @Composable
@Preview(device = Devices.PIXEL_5) @Preview(device = Devices.PIXEL_5)
fun PostPreview() { fun PostPreview() {
PostComponent(Post(id = 0L, textContent = "Test", userId = 1, privacy = 2, date = "Date", images = emptyList(), liked = false, userName = "Username", numberOfLikes = 1, numberOfComments = 2)) PostComponent(Post(id = 0L, textContent = "Test", userId = 1, privacy = 2, date = "Date", images = emptyList(), liked = false, userName = "Username", numberOfLikes = 2, numberOfComments = 3))
} }

View File

@ -4,6 +4,7 @@ import android.os.Parcel
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import com.isolaatti.audio.common.domain.Audio import com.isolaatti.audio.common.domain.Audio
@ -35,6 +36,10 @@ class Post(
var numberOfLikes by mutableIntStateOf(numberOfLikes) var numberOfLikes by mutableIntStateOf(numberOfLikes)
var numberOfComments by mutableStateOf(numberOfComments) var numberOfComments by mutableStateOf(numberOfComments)
var playing by mutableStateOf(false)
var durationMs by mutableLongStateOf(0L)
var progressMs by mutableLongStateOf(0L)
constructor(parcel: Parcel) : this( constructor(parcel: Parcel) : this(
parcel.readLong(), parcel.readLong(),
parcel.readString()!!, parcel.readString()!!,

View File

@ -11,6 +11,7 @@ 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.components.AudioRecorderState
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.images.common.domain.entity.Image import com.isolaatti.images.common.domain.entity.Image
@ -61,19 +62,9 @@ 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 audioRecorderState = AudioRecorderState()
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 var ended: Boolean = false
// endregion
// region Recorder state
var isRecording: MutableStateFlow<Boolean> = MutableStateFlow(false)
var recordingIsPaused: MutableStateFlow<Boolean> = MutableStateFlow(false)
var recodingIsStopped: MutableStateFlow<Boolean> = MutableStateFlow(false)
// endregion
/** /**

View File

@ -4,6 +4,9 @@ import android.util.Log
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.components.AudioRecorderState
import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.audio.player.AudioPlaybackState
import com.isolaatti.posting.likes.domain.repository.LikesRepository import com.isolaatti.posting.likes.domain.repository.LikesRepository
import com.isolaatti.posting.posts.data.remote.FeedDto import com.isolaatti.posting.posts.data.remote.FeedDto
import com.isolaatti.posting.posts.domain.entity.Post import com.isolaatti.posting.posts.domain.entity.Post
@ -40,8 +43,14 @@ abstract class PostListingViewModelBase : ViewModel() {
val errorLoading: MutableLiveData<Resource.Error.ErrorType?> = MutableLiveData() val errorLoading: MutableLiveData<Resource.Error.ErrorType?> = MutableLiveData()
var isLoadingFromScrolling = false var isLoadingFromScrolling = false
val mediaControllerIsAvailable: MutableStateFlow<Boolean> = MutableStateFlow(false)
val audioState: MutableStateFlow<AudioPlaybackState> = MutableStateFlow(AudioPlaybackState())
fun getLastId(): Long = try { posts.value?.last()?.id ?: 0 } catch (e: NoSuchElementException) { 0 } fun getLastId(): Long = try { posts.value?.last()?.id ?: 0 } catch (e: NoSuchElementException) { 0 }
val audioRecorderState = AudioRecorderState()
abstract fun getFeed(refresh: Boolean, hashtag: String?) abstract fun getFeed(refresh: Boolean, hashtag: String?)
@ -115,4 +124,16 @@ abstract class PostListingViewModelBase : ViewModel() {
fun onPostAddedAtTheBeginning(post: Post) { fun onPostAddedAtTheBeginning(post: Post) {
posts.value = listOf(post) + posts.value posts.value = listOf(post) + posts.value
} }
fun setAudioToPlay(audio: Audio) {
audioState.value = AudioPlaybackState(currentAudio = audio)
}
fun setIsPlaying(isPlaying: Boolean) {
audioState.value = audioState.value.copy(isPlaying = isPlaying)
}
fun updateProgress(progress: Long, duration: Long) {
audioState.value = audioState.value.copy(progress = progress, duration = duration)
}
} }

View File

@ -124,21 +124,21 @@ class CreatePostActivity : IsolaattiBaseActivity() {
private val playerListener: Player.Listener = object: Player.Listener { private val playerListener: Player.Listener = object: Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) { override fun onIsPlayingChanged(isPlaying: Boolean) {
viewModel.isPlaying.value = isPlaying viewModel.audioRecorderState.isPlaying = isPlaying
} }
override fun onPlaybackStateChanged(playbackState: Int) { override fun onPlaybackStateChanged(playbackState: Int) {
when(playbackState) { when(playbackState) {
Player.STATE_READY -> { Player.STATE_READY -> {
viewModel.ended = false viewModel.ended = false
viewModel.isLoading.value = false viewModel.audioRecorderState.isLoading = false
viewModel.duration.value = this@CreatePostActivity.mediaController.duration viewModel.audioRecorderState.durationSeconds = this@CreatePostActivity.mediaController.duration
} }
Player.STATE_ENDED -> { Player.STATE_ENDED -> {
viewModel.ended = true viewModel.ended = true
} }
Player.STATE_BUFFERING -> { Player.STATE_BUFFERING -> {
viewModel.isLoading.value = true viewModel.audioRecorderState.isLoading = true
} }
Player.STATE_IDLE -> {} Player.STATE_IDLE -> {}
@ -291,19 +291,11 @@ class CreatePostActivity : IsolaattiBaseActivity() {
AnimatedVisibility(audioRecorderIsVisible) { 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 isRecording by viewModel.isRecording.collectAsState() LaunchedEffect(viewModel.audioRecorderState.isPlaying) {
val recordIsPaused by viewModel.recordingIsPaused.collectAsState() while(viewModel.audioRecorderState.isPlaying) {
val recordIsStopped by viewModel.recodingIsStopped.collectAsState()
LaunchedEffect(isPlaying) {
while(isPlaying) {
delay(500.milliseconds) delay(500.milliseconds)
viewModel.position.value = mediaController.currentPosition viewModel.audioRecorderState.position = mediaController.currentPosition
} }
} }
@ -327,12 +319,7 @@ class CreatePostActivity : IsolaattiBaseActivity() {
mediaRecorder.release() mediaRecorder.release()
// clear references // clear references
viewModel.recodingIsStopped.value = false viewModel.audioRecorderState.clearState()
viewModel.isRecording.value = false
viewModel.recordingIsPaused.value = false
viewModel.isPlaying.value = false
viewModel.duration.value = 0L
viewModel.position.value = 0L
viewModel.audioToUpload = null viewModel.audioToUpload = null
}, },
onPlay = { onPlay = {
@ -367,18 +354,19 @@ class CreatePostActivity : IsolaattiBaseActivity() {
start() start()
} }
} }
viewModel.recodingIsStopped.value = false
viewModel.isRecording.value = true viewModel.audioRecorderState.recordIsStopped = false
viewModel.recordingIsPaused.value = false viewModel.audioRecorderState.isRecording = true
viewModel.audioRecorderState.recordIsPaused = false
}, },
onPauseRecording = { onPauseRecording = {
mediaRecorder.pause() mediaRecorder.pause()
viewModel.recodingIsStopped.value = false viewModel.audioRecorderState.recordIsStopped = false
viewModel.isRecording.value = true viewModel.audioRecorderState.isRecording = true
viewModel.recordingIsPaused.value = true viewModel.audioRecorderState.recordIsPaused = true
}, },
onStopRecording = { onStopRecording = {
mediaRecorder.stop() mediaRecorder.stop()
@ -392,17 +380,11 @@ class CreatePostActivity : IsolaattiBaseActivity() {
.build() .build()
) )
viewModel.audioToUpload = audioUri viewModel.audioToUpload = audioUri
viewModel.recodingIsStopped.value = true viewModel.audioRecorderState.recordIsStopped = true
viewModel.isRecording.value = false viewModel.audioRecorderState.isRecording = false
viewModel.recordingIsPaused.value = false viewModel.audioRecorderState.recordIsPaused = false
}, },
isPlaying = isPlaying, state = viewModel.audioRecorderState,
position = position,
durationSeconds = duration,
isRecording = isRecording,
recordIsPaused = recordIsPaused,
recordIsStopped = recordIsStopped,
isLoading = isLoading,
onSeek = { ms -> onSeek = { ms ->
mediaController.seekTo(ms.toLong()) mediaController.seekTo(ms.toLong())
} }

View File

@ -26,6 +26,7 @@ import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import com.isolaatti.BuildConfig import com.isolaatti.BuildConfig
import com.isolaatti.R import com.isolaatti.R
import com.isolaatti.audio.common.components.AudioRecorderState
import com.isolaatti.audio.common.domain.Audio import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.common.Dialogs import com.isolaatti.common.Dialogs
import com.isolaatti.common.ErrorMessageViewModel import com.isolaatti.common.ErrorMessageViewModel
@ -63,6 +64,7 @@ class PostListingFragment : Fragment() {
private val viewModel: PostListingViewModel by viewModels() private val viewModel: PostListingViewModel by viewModels()
val optionsViewModel: BottomSheetPostOptionsViewModel by activityViewModels() val optionsViewModel: BottomSheetPostOptionsViewModel by activityViewModels()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Log.d(LOG_TAG, "onCreate ; hashtag: $hashtag") Log.d(LOG_TAG, "onCreate ; hashtag: $hashtag")

View File

@ -57,6 +57,8 @@ class ProfileViewModel @Inject constructor(
val uploadingProfilePicture: MutableStateFlow<Boolean> = MutableStateFlow(false) val uploadingProfilePicture: MutableStateFlow<Boolean> = MutableStateFlow(false)
val errorProfilePicture: MutableStateFlow<Resource.Error<RemoteImage>?> = MutableStateFlow(null) val errorProfilePicture: MutableStateFlow<Resource.Error<RemoteImage>?> = MutableStateFlow(null)
val settingUpAudio: MutableStateFlow<Boolean> = MutableStateFlow(false)
// runs the lists of "Runnable" one by one and clears list. After this is executed, // runs the lists of "Runnable" one by one and clears list. After this is executed,
// caller should report as handled // caller should report as handled

View File

@ -3,6 +3,8 @@ package com.isolaatti.profile.ui
import android.content.ComponentName import android.content.ComponentName
import android.content.Intent import android.content.Intent
import android.media.MediaRecorder import android.media.MediaRecorder
import android.os.Build
import android.media.MediaRecorder
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
@ -16,14 +18,18 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -31,6 +37,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
@ -43,6 +50,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
@ -70,7 +78,9 @@ import com.google.common.util.concurrent.ListenableFuture
import com.isolaatti.BuildConfig import com.isolaatti.BuildConfig
import com.isolaatti.MyApplication import com.isolaatti.MyApplication
import com.isolaatti.R import com.isolaatti.R
import com.isolaatti.audio.common.components.AudioPlayer
import com.isolaatti.audio.common.components.AudioRecorder import com.isolaatti.audio.common.components.AudioRecorder
import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.audio.player.MediaService import com.isolaatti.audio.player.MediaService
import com.isolaatti.common.Dialogs import com.isolaatti.common.Dialogs
import com.isolaatti.common.ErrorMessageViewModel import com.isolaatti.common.ErrorMessageViewModel
@ -100,8 +110,11 @@ import com.isolaatti.profile.ui.components.ProfilePictureState
import com.isolaatti.reports.data.ContentType import com.isolaatti.reports.data.ContentType
import com.isolaatti.reports.ui.NewReportBottomSheetDialogFragment import com.isolaatti.reports.ui.NewReportBottomSheetDialogFragment
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.guava.await import kotlinx.coroutines.guava.await
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.util.Calendar import java.util.Calendar
@ -159,9 +172,12 @@ class ProfileMainFragment : Fragment() {
private lateinit var mediaController: MediaController private lateinit var mediaController: MediaController
private lateinit var mediaRecorder: MediaRecorder private lateinit var mediaRecorder: MediaRecorder
private var mediaRecorder: MediaRecorder? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
viewModel.mediaControllerIsAvailable.value = false
val sessionToken = SessionToken(requireContext(), ComponentName(requireContext(), MediaService::class.java)) val sessionToken = SessionToken(requireContext(), ComponentName(requireContext(), MediaService::class.java))
mediaControllerFuture = MediaController.Builder(requireContext(), sessionToken).buildAsync() mediaControllerFuture = MediaController.Builder(requireContext(), sessionToken).buildAsync()
@ -169,6 +185,58 @@ class ProfileMainFragment : Fragment() {
getData() getData()
} }
private lateinit var recordOutputFile: File
// initialized in onViewCreated
private lateinit var directory: File
private fun startRecording(callback: () -> Unit) {
recordOutputFile = File(directory, "${System.currentTimeMillis()}.3gp")
mediaRecorder = if (Build.VERSION.SDK_INT >= 31) MediaRecorder(requireContext()) else MediaRecorder()
mediaRecorder?.run {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.THREE_GPP)
setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB)
if (Build.VERSION.SDK_INT >= 26) {
setOutputFile(recordOutputFile)
} else {
setOutputFile(recordOutputFile.path)
}
prepare()
start()
callback()
}
}
private fun pauseRecording() {
}
private fun stopRecording() {
}
private fun playRecordedAudio() {
}
private fun pauseRecordedAudio() {
}
private fun seekTo(ms: Float) {
}
private fun playAudio(descriptionAudio: Audio) {
}
private fun pauseAudio(descriptionAudio: Audio) {
}
@ -187,6 +255,14 @@ class ProfileMainFragment : Fragment() {
val lazyColumState = rememberLazyListState() val lazyColumState = rememberLazyListState()
val profile by viewModel.profile.observeAsState() val profile by viewModel.profile.observeAsState()
val followingState by viewModel.followingState.observeAsState() val followingState by viewModel.followingState.observeAsState()
val posts by viewModel.posts.collectAsState()
val loadingProfile by viewModel.loadingProfile.collectAsState()
val settingUpAudio by viewModel.settingUpAudio.collectAsState()
val audioPlaybackState by viewModel.audioState.collectAsState()
// use this to show or hide all audio related UI
val mediaControllerIsAvailable by viewModel.mediaControllerIsAvailable.collectAsState()
val isAtTop by remember { val isAtTop by remember {
derivedStateOf { derivedStateOf {
lazyColumState.firstVisibleItemIndex == 0 && lazyColumState.firstVisibleItemScrollOffset == 0 lazyColumState.firstVisibleItemIndex == 0 && lazyColumState.firstVisibleItemScrollOffset == 0
@ -254,8 +330,7 @@ class ProfileMainFragment : Fragment() {
} }
val posts by viewModel.posts.collectAsState()
val loadingProfile by viewModel.loadingProfile.collectAsState()
IsolaattiTheme { IsolaattiTheme {
Scaffold( Scaffold(
@ -415,8 +490,77 @@ class ProfileMainFragment : Fragment() {
onFollowChange = { follow -> onFollowChange = { follow ->
viewModel.followUser() viewModel.followUser()
}, },
profileAudioProgress = 0.4f, audioPlayer = {
showProfileAudioProgress = true, if(mediaControllerIsAvailable) {
when {
settingUpAudio -> {
AudioRecorder(
modifier = Modifier.fillMaxWidth(),
onDismiss = {
viewModel.settingUpAudio.value = false
},
onPlay = {
playRecordedAudio()
},
onPause = {
pauseRecordedAudio()
},
onStopRecording = {
stopRecording()
},
onPauseRecording = {
pauseRecording()
},
onStartRecording = {
startRecording {
}
},
state = viewModel.audioRecorderState,
onSeek = {
}
)
}
it.descriptionAudio != null -> {
AudioPlayer(
modifier = Modifier.fillMaxWidth().height(120.dp),
positionMs = 2000L,
isPlaying = false,
durationMs = 30_000L,
isLoading = false,
onPlay = {
playAudio(it.descriptionAudio)
},
onPause = {
pauseAudio(it.descriptionAudio)
},
onSeek = { ms ->
seekTo(ms)
}
)
}
it.isUserItself -> {
Card(Modifier.fillMaxWidth().height(120.dp).padding(16.dp)) {
Column(modifier = Modifier.fillMaxWidth().padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(stringResource(R.string.setup_your_profile_audio))
TextButton(
onClick = {
viewModel.settingUpAudio.value = true
}
) {
Text("Record audio")
}
}
}
}
}
}
},
onEditProfileClick = { onEditProfileClick = {
editProfile.launch(it) editProfile.launch(it)
}, },
@ -489,11 +633,26 @@ class ProfileMainFragment : Fragment() {
} }
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
mediaController = mediaControllerFuture.await() mediaController = mediaControllerFuture.await()
viewModel.mediaControllerIsAvailable.value = true
// check audios directory
directory = File(requireContext().filesDir, "recordings")
withContext(Dispatchers.IO) {
if(!directory.isDirectory) {
if(directory.isFile) {
directory.delete()
}
directory.mkdir()
}
}
} }
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {

View File

@ -69,8 +69,6 @@ fun ProfileHeader(
followerCount: Int, followerCount: Int,
followingCount: Int, followingCount: Int,
onImageClick: () -> Unit, onImageClick: () -> Unit,
showProfileAudioProgress: Boolean,
profileAudioProgress: Float,
onFollowersClick: () -> Unit, onFollowersClick: () -> Unit,
onEditProfileClick: () -> Unit, onEditProfileClick: () -> Unit,
onFollowChange: (Boolean) -> Unit, onFollowChange: (Boolean) -> Unit,
@ -190,8 +188,6 @@ fun ProfileHeaderPreview() {
isOwnUser = true, isOwnUser = true,
followingThisUser = true, followingThisUser = true,
onImageClick = {}, onImageClick = {},
profileAudioProgress = 0.6f,
showProfileAudioProgress = true,
onFollowersClick = {}, onFollowersClick = {},
onFollowChange = {}, onFollowChange = {},
onEditProfileClick = {}, onEditProfileClick = {},

View File

@ -234,6 +234,7 @@
<string name="change_profile_photo_question">Change profile picture?</string> <string name="change_profile_photo_question">Change profile picture?</string>
<string name="uploading_photo">Uploading photo</string> <string name="uploading_photo">Uploading photo</string>
<string name="you_have_now_audio">You have no audio description</string> <string name="you_have_now_audio">You have no audio description</string>
<string name="setup_your_profile_audio">Setup your profile 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>

View File

@ -14,8 +14,8 @@ buildscript {
} }
} }
plugins { plugins {
id 'com.android.application' version '8.7.3' apply false id 'com.android.application' version '8.8.2' apply false
id 'com.android.library' version '8.7.3' apply false id 'com.android.library' version '8.8.2' apply false
id 'org.jetbrains.kotlin.android' version '2.1.0' apply false id 'org.jetbrains.kotlin.android' version '2.1.0' apply false
id 'com.google.dagger.hilt.android' version '2.53.1' apply false id 'com.google.dagger.hilt.android' version '2.53.1' apply false
id 'com.google.gms.google-services' version '4.4.2' apply false id 'com.google.gms.google-services' version '4.4.2' apply false

View File

@ -1,6 +1,6 @@
#Thu Jul 27 21:16:21 CST 2023 #Thu Jul 27 21:16:21 CST 2023
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists