diff --git a/.idea/assetWizardSettings.xml b/.idea/assetWizardSettings.xml index c841460..1941e44 100644 --- a/.idea/assetWizardSettings.xml +++ b/.idea/assetWizardSettings.xml @@ -308,7 +308,7 @@ @@ -318,8 +318,8 @@ diff --git a/.idea/gradle.xml b/.idea/gradle.xml index ae388c2..0897082 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -4,16 +4,15 @@ diff --git a/app/build.gradle b/app/build.gradle index 4a67d14..cc0b240 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -55,6 +55,7 @@ dependencies { implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1' implementation 'com.google.code.gson:gson:2.10.1' implementation 'com.squareup.retrofit2:converter-gson:2.3.0' + implementation "androidx.preference:preference-ktx:1.2.1" testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' @@ -106,15 +107,19 @@ dependencies { exclude group: 'org.json', module: 'json' } + // Room Database def room_version = "2.5.2" - implementation "androidx.room:room-runtime:$room_version" annotationProcessor "androidx.room:room-compiler:$room_version" kapt("androidx.room:room-compiler:$room_version") implementation "androidx.room:room-ktx:2.5.2" - implementation "androidx.preference:preference-ktx:1.2.1" - + // Image viewer implementation 'com.github.MikeOrtiz:TouchImageView:3.5' + // Media 3 + implementation 'androidx.media3:media3-session:1.2.0' + implementation 'androidx.media3:media3-exoplayer:1.2.0' + implementation "androidx.media3:media3-ui:1.2.0" + } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 81c8d25..ed37e81 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools"> + { + initializePlayer() + } + Lifecycle.Event.ON_RESUME -> { + if(player == null) { + initializePlayer() + } + } + Lifecycle.Event.ON_STOP, Lifecycle.Event.ON_DESTROY -> { + releasePlayer() + } + else -> {} + } + } + + fun playAudio(audio: Audio) { + mediaItem = MediaItem.fromUri(Uri.parse(audio.downloadUrl)) + + player?.setMediaItem(mediaItem!!) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/audio/player/PlayerFactory.kt b/app/src/main/java/com/isolaatti/audio/player/PlayerFactory.kt new file mode 100644 index 0000000..c6f1462 --- /dev/null +++ b/app/src/main/java/com/isolaatti/audio/player/PlayerFactory.kt @@ -0,0 +1,7 @@ +package com.isolaatti.audio.player + +import androidx.media3.common.Player + +abstract class PlayerFactory { + abstract fun MakePlayer(): Player +} \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/audio/recorder/ui/AudioRecorderMainFragment.kt b/app/src/main/java/com/isolaatti/audio/recorder/ui/AudioRecorderMainFragment.kt index c46b94a..77e97bb 100644 --- a/app/src/main/java/com/isolaatti/audio/recorder/ui/AudioRecorderMainFragment.kt +++ b/app/src/main/java/com/isolaatti/audio/recorder/ui/AudioRecorderMainFragment.kt @@ -1,6 +1,8 @@ package com.isolaatti.audio.recorder.ui +import android.media.MediaRecorder import androidx.fragment.app.Fragment class AudioRecorderMainFragment : Fragment() { + } \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/images/common/domain/entity/Image.kt b/app/src/main/java/com/isolaatti/images/common/domain/entity/Image.kt index 404ba06..4bcb842 100644 --- a/app/src/main/java/com/isolaatti/images/common/domain/entity/Image.kt +++ b/app/src/main/java/com/isolaatti/images/common/domain/entity/Image.kt @@ -14,7 +14,31 @@ data class Image( val smallImageUrl : String get() = UrlGen.imageUrl(id, UrlGen.IMAGE_MODE_SMALL) val reducedImageUrl: String get() = UrlGen.imageUrl(id, UrlGen.IMAGE_MODE_REDUCED) + + companion object { fun fromDto(imageDto: ImageDto) = Image(imageDto.id, imageDto.userId, imageDto.name, imageDto.username) } + + 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 + if (userId != other.userId) return false + if (name != other.name) return false + if (username != other.username) return false + + return true + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + userId + result = 31 * result + name.hashCode() + result = 31 * result + username.hashCode() + return result + } } \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/images/image_list/presentation/ImageListViewModel.kt b/app/src/main/java/com/isolaatti/images/image_list/presentation/ImageListViewModel.kt index ecef985..f761c35 100644 --- a/app/src/main/java/com/isolaatti/images/image_list/presentation/ImageListViewModel.kt +++ b/app/src/main/java/com/isolaatti/images/image_list/presentation/ImageListViewModel.kt @@ -13,20 +13,59 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import javax.inject.Inject +import kotlin.properties.Delegates @HiltViewModel class ImageListViewModel @Inject constructor(private val imagesRepository: ImagesRepository) : ViewModel() { - val list: MutableLiveData>> = MutableLiveData() - fun loadNext(userId: Int) { + val liveList: MutableLiveData> = MutableLiveData() + val error: MutableLiveData = MutableLiveData() + val loading: MutableLiveData = MutableLiveData() + var noMoreContent = false + private var loadedFirstTime = false + var userId by Delegates.notNull() + + private val list: List get() { + return liveList.value ?: listOf() + } + + private val lastId: String? get() { + return list.lastOrNull()?.id + } + + fun loadNext() { viewModelScope.launch { - imagesRepository.getImagesOfUser(userId, null).onEach { - list.postValue(it) + imagesRepository.getImagesOfUser(userId, lastId).onEach { resource -> + when(resource) { + is Resource.Error -> { + error.postValue(resource.errorType) + } + is Resource.Loading -> { + if(!loadedFirstTime) { + loading.postValue(true) + } + } + is Resource.Success -> { + loading.postValue(false) + noMoreContent = resource.data?.isEmpty() == true + loadedFirstTime = true + if(noMoreContent) { + return@onEach + } + + liveList.postValue(list + (resource.data ?: listOf())) + } + } }.flowOn(Dispatchers.IO).launchIn(this) } } + fun refresh() { + liveList.value = listOf() + loadNext() + } + fun removeImages(images: List) { } diff --git a/app/src/main/java/com/isolaatti/images/image_list/presentation/ImagesAdapter.kt b/app/src/main/java/com/isolaatti/images/image_list/presentation/ImagesAdapter.kt index 89de517..1e24295 100644 --- a/app/src/main/java/com/isolaatti/images/image_list/presentation/ImagesAdapter.kt +++ b/app/src/main/java/com/isolaatti/images/image_list/presentation/ImagesAdapter.kt @@ -1,9 +1,12 @@ package com.isolaatti.images.image_list.presentation +import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.LinearLayout +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.Adapter import coil.load @@ -15,9 +18,22 @@ class ImagesAdapter( private val imageOnClick: ((images: List, position: Int) -> Unit), private val itemWidth: Int, private val onImageSelectedCountUpdate: ((count: Int) -> Unit)? = null, - private val onDeleteMode: ((enabled: Boolean) -> Unit)? = null) : Adapter(){ + private val onDeleteMode: ((enabled: Boolean) -> Unit)? = null, + private val onContentRequested: (() -> Unit)? = null) : ListAdapter(diffCallback){ + + companion object { + val diffCallback = object: DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Image, newItem: Image): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: Image, newItem: Image): Boolean { + return oldItem == newItem + } + + } + } - private var data: List = listOf() private var selectionState: Array = arrayOf() var deleteMode: Boolean = false @@ -31,14 +47,17 @@ class ImagesAdapter( inner class ImageViewHolder(val imageItemBinding: ImageItemBinding) : RecyclerView.ViewHolder(imageItemBinding.root) - fun setData(data: List) { - this.data = data - selectionState = Array(data.size) { false } - notifyDataSetChanged() + fun getSelectedImages(): List { + return currentList.filterIndexed { index, _ -> selectionState[index] } } - fun getSelectedImages(): List { - return data.filterIndexed { index, _ -> selectionState[index] } + override fun onCurrentListChanged( + previousList: MutableList, + currentList: MutableList + ) { + super.onCurrentListChanged(previousList, currentList) + noMoreContent = (currentList.size - previousList.size) == 0 + selectionState = Array(currentList.size) { false } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ImageViewHolder { @@ -49,12 +68,18 @@ class ImagesAdapter( return ImageViewHolder(binding) } - override fun getItemCount(): Int { - return data.size + private var requestedNewContent = false + private var noMoreContent = false + + /** + * Call this method when new content has been added on onLoadMore() callback + */ + fun newContentRequestFinished() { + requestedNewContent = false } override fun onBindViewHolder(holder: ImageViewHolder, position: Int) { - val image = data[position] + val image = getItem(position) holder.imageItemBinding.image.load(image.reducedImageUrl, imageLoader) @@ -76,7 +101,7 @@ class ImagesAdapter( holder.imageItemBinding.imageCheckbox.isChecked = false holder.imageItemBinding.imageOverlay.visibility = View.GONE holder.imageItemBinding.root.setOnClickListener { - imageOnClick(data, position) + imageOnClick(currentList, position) } holder.imageItemBinding.imageCheckbox.setOnCheckedChangeListener(null) holder.imageItemBinding.root.setOnLongClickListener { @@ -86,5 +111,16 @@ class ImagesAdapter( true } } + val totalItems = currentList.size + if(totalItems > 0 && !requestedNewContent && !noMoreContent) { + Log.d("ImagesAdapter", "Total items: $totalItems") + if(position == totalItems - 1) { + requestedNewContent = true + onContentRequested?.invoke() + } + } + } + + } \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/images/image_list/ui/ImagesFragment.kt b/app/src/main/java/com/isolaatti/images/image_list/ui/ImagesFragment.kt index ce8385f..2ca4de7 100644 --- a/app/src/main/java/com/isolaatti/images/image_list/ui/ImagesFragment.kt +++ b/app/src/main/java/com/isolaatti/images/image_list/ui/ImagesFragment.kt @@ -15,6 +15,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.widget.PopupMenu import androidx.core.content.FileProvider import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs @@ -22,6 +23,7 @@ import androidx.recyclerview.widget.GridLayoutManager import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.isolaatti.MyApplication import com.isolaatti.R +import com.isolaatti.common.ErrorMessageViewModel import com.isolaatti.databinding.FragmentImagesBinding import com.isolaatti.images.common.domain.entity.Image import com.isolaatti.images.image_list.presentation.ImageListViewModel @@ -38,6 +40,7 @@ class ImagesFragment : Fragment() { lateinit var viewBinding: FragmentImagesBinding lateinit var adapter: ImagesAdapter private val viewModel: ImageListViewModel by viewModels() + private val errorViewModel: ErrorMessageViewModel by activityViewModels() private val arguments: ImagesFragmentArgs by navArgs() private var cameraPhotoUri: Uri? = null @@ -84,7 +87,8 @@ class ImagesFragment : Fragment() { when(arguments.source) { SOURCE_SQUAD -> {} SOURCE_PROFILE -> { - viewModel.loadNext(arguments.sourceId.toInt()) + viewModel.userId = arguments.sourceId.toInt() + viewModel.loadNext() } } @@ -141,11 +145,7 @@ class ImagesFragment : Fragment() { } viewBinding.topAppBar.setOnMenuItemClickListener { when(it.itemId) { - R.id.delete_mode_item -> { - adapter.deleteMode = true - actionMode = requireActivity().startActionMode(contextBarCallback) - true - } + else -> false } } @@ -170,6 +170,9 @@ class ImagesFragment : Fragment() { popup.show() } + viewBinding.swipeToRefresh.setOnRefreshListener { + viewModel.refresh() + } } private fun setupAdapter() { @@ -183,6 +186,10 @@ class ImagesFragment : Fragment() { onDeleteMode = { adapter.deleteMode = it actionMode = requireActivity().startActionMode(contextBarCallback) + }, + onContentRequested = { + adapter.newContentRequestFinished() + viewModel.loadNext() }) viewBinding.recyclerView.layoutManager = GridLayoutManager(requireContext(), 3, GridLayoutManager.VERTICAL, false) @@ -191,19 +198,23 @@ class ImagesFragment : Fragment() { private fun setupObservers() { - viewModel.list.observe(viewLifecycleOwner) { resource -> - when(resource) { - is Resource.Error -> {} - is Resource.Loading -> { - viewBinding.progressBarLoading.visibility = View.VISIBLE - } - is Resource.Success -> { - resource.data?.let { - viewBinding.progressBarLoading.visibility = View.GONE - adapter.setData(it) - } - } + viewModel.liveList.observe(viewLifecycleOwner) { list -> + adapter.submitList(list) + } + + viewModel.loading.observe(viewLifecycleOwner) { + viewBinding.progressBarLoading.visibility = if(it) { + View.VISIBLE + } else { + View.GONE } + if(!it) { + viewBinding.swipeToRefresh.isRefreshing = false + } + } + + viewModel.error.observe(viewLifecycleOwner) { + errorViewModel.error.value = it } } diff --git a/app/src/main/java/com/isolaatti/profile/data/remote/ProfileApi.kt b/app/src/main/java/com/isolaatti/profile/data/remote/ProfileApi.kt index 7ff4901..0aca5f5 100644 --- a/app/src/main/java/com/isolaatti/profile/data/remote/ProfileApi.kt +++ b/app/src/main/java/com/isolaatti/profile/data/remote/ProfileApi.kt @@ -1,12 +1,13 @@ package com.isolaatti.profile.data.remote import retrofit2.Call +import retrofit2.http.GET import retrofit2.http.POST import retrofit2.http.Path interface ProfileApi { - @POST("Fetch/UserProfile/{userId}") + @GET("Fetch/UserProfile/{userId}") fun userProfile(@Path("userId") userId: Int): Call } \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/profile/data/remote/UserProfileDto.kt b/app/src/main/java/com/isolaatti/profile/data/remote/UserProfileDto.kt index 62b74a0..63d967c 100644 --- a/app/src/main/java/com/isolaatti/profile/data/remote/UserProfileDto.kt +++ b/app/src/main/java/com/isolaatti/profile/data/remote/UserProfileDto.kt @@ -1,5 +1,7 @@ package com.isolaatti.profile.data.remote +import com.isolaatti.audio.common.data.AudioDto + data class UserProfileDto( val id: Int, val name: String, @@ -13,5 +15,6 @@ data class UserProfileDto( val thisUserIsFollowingMe: Boolean, val profileImageId: String?, val descriptionText: String?, - val descriptionAudioId: String? + val descriptionAudioId: String?, + val audio: AudioDto? ) diff --git a/app/src/main/java/com/isolaatti/profile/domain/entity/UserProfile.kt b/app/src/main/java/com/isolaatti/profile/domain/entity/UserProfile.kt index 07c90a8..6e97894 100644 --- a/app/src/main/java/com/isolaatti/profile/domain/entity/UserProfile.kt +++ b/app/src/main/java/com/isolaatti/profile/domain/entity/UserProfile.kt @@ -1,5 +1,6 @@ package com.isolaatti.profile.domain.entity +import com.isolaatti.audio.common.domain.Audio import com.isolaatti.common.Ownable import com.isolaatti.profile.data.remote.UserProfileDto import com.isolaatti.utils.UrlGen @@ -17,7 +18,8 @@ data class UserProfile( val thisUserIsFollowingMe: Boolean, val profileImageId: String?, val descriptionText: String?, - val descriptionAudioId: String? + val descriptionAudioId: String?, + val descriptionAudio: Audio? ) : Ownable { val profileAvatarPictureUrl: String get() = UrlGen.userProfileImage(userId) @@ -37,7 +39,8 @@ data class UserProfile( thisUserIsFollowingMe = userProfileDto.thisUserIsFollowingMe, profileImageId = userProfileDto.profileImageId, descriptionText = userProfileDto.descriptionText, - descriptionAudioId = userProfileDto.descriptionAudioId + descriptionAudioId = userProfileDto.descriptionAudioId, + descriptionAudio = userProfileDto.audio?.let { Audio.fromDto(it) } ) } } diff --git a/app/src/main/java/com/isolaatti/profile/presentation/EditProfileContract.kt b/app/src/main/java/com/isolaatti/profile/presentation/EditProfileContract.kt new file mode 100644 index 0000000..3bd5027 --- /dev/null +++ b/app/src/main/java/com/isolaatti/profile/presentation/EditProfileContract.kt @@ -0,0 +1,4 @@ +package com.isolaatti.profile.presentation + +class EditProfileContract { +} \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/profile/presentation/EditProfileViewModel.kt b/app/src/main/java/com/isolaatti/profile/presentation/EditProfileViewModel.kt new file mode 100644 index 0000000..75320ae --- /dev/null +++ b/app/src/main/java/com/isolaatti/profile/presentation/EditProfileViewModel.kt @@ -0,0 +1,9 @@ +package com.isolaatti.profile.presentation + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class EditProfileViewModel @Inject constructor(): ViewModel() { +} \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/profile/ui/EditProfileActivity.kt b/app/src/main/java/com/isolaatti/profile/ui/EditProfileActivity.kt new file mode 100644 index 0000000..c1ef67c --- /dev/null +++ b/app/src/main/java/com/isolaatti/profile/ui/EditProfileActivity.kt @@ -0,0 +1,19 @@ +package com.isolaatti.profile.ui + +import android.os.Bundle +import com.isolaatti.common.IsolaattiBaseActivity +import com.isolaatti.databinding.ActivityEditProfileBinding +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class EditProfileActivity : IsolaattiBaseActivity() { + private lateinit var binding: ActivityEditProfileBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + binding = ActivityEditProfileBinding.inflate(layoutInflater) + + setContentView(binding.root) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/profile/ui/ProfileMainFragment.kt b/app/src/main/java/com/isolaatti/profile/ui/ProfileMainFragment.kt index ec605c2..54ea195 100644 --- a/app/src/main/java/com/isolaatti/profile/ui/ProfileMainFragment.kt +++ b/app/src/main/java/com/isolaatti/profile/ui/ProfileMainFragment.kt @@ -20,6 +20,8 @@ import coil.load import com.isolaatti.BuildConfig import com.isolaatti.R import com.isolaatti.audio.audios_list.ui.AudiosFragment +import com.isolaatti.audio.common.domain.Audio +import com.isolaatti.audio.player.AudioPlayerConnector import com.isolaatti.common.CoilImageLoader.imageLoader import com.isolaatti.common.Dialogs import com.isolaatti.common.ErrorMessageViewModel @@ -61,6 +63,10 @@ class ProfileMainFragment : Fragment() { lateinit var postsAdapter: PostsRecyclerViewAdapter + private var audioDescriptionAudio: Audio? = null + + private lateinit var audioPlayerConnector: AudioPlayerConnector + // collapsing bar private var title = "" private var scrollRange = -1 @@ -103,6 +109,9 @@ class ProfileMainFragment : Fragment() { fragment.show(parentFragmentManager, BottomSheetPostOptionsFragment.TAG) } + audioDescriptionAudio = profile.descriptionAudio + viewBinding.playButton.visibility = if(profile.descriptionAudio != null) View.VISIBLE else View.GONE + setupUiForUserType(profile.isUserItself) } @@ -247,6 +256,11 @@ class ProfileMainFragment : Fragment() { viewBinding.swipeToRefresh.setOnRefreshListener { viewModel.getFeed(true) } + viewBinding.playButton.setOnClickListener { + audioDescriptionAudio?.let { audio -> + audioPlayerConnector.playAudio(audio) + } + } } private fun setObservers() { @@ -309,6 +323,7 @@ class ProfileMainFragment : Fragment() { override fun onAttach(context: Context) { super.onAttach(context) + postListingRecyclerViewAdapterWiring = object: PostListingRecyclerViewAdapterWiring(viewModel) { override fun onComment(postId: Long) { val modalBottomSheet = BottomSheetPostComments.getInstance(postId) @@ -353,6 +368,9 @@ class ProfileMainFragment : Fragment() { setObservers() setupCollapsingBar() + audioPlayerConnector = AudioPlayerConnector(requireContext()) + + viewLifecycleOwner.lifecycle.addObserver(audioPlayerConnector) viewLifecycleOwner.lifecycleScope.launch { diff --git a/app/src/main/res/layout/activity_edit_profile.xml b/app/src/main/res/layout/activity_edit_profile.xml new file mode 100644 index 0000000..7fd71ed --- /dev/null +++ b/app/src/main/res/layout/activity_edit_profile.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_profile.xml b/app/src/main/res/layout/activity_profile.xml index 99ab099..f04ec22 100644 --- a/app/src/main/res/layout/activity_profile.xml +++ b/app/src/main/res/layout/activity_profile.xml @@ -12,5 +12,4 @@ android:layout_height="match_parent" app:defaultNavHost="true" app:navGraph="@navigation/profile_navigation" /> - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_discussions.xml b/app/src/main/res/layout/fragment_discussions.xml index 93f5d2f..7fc0b7a 100644 --- a/app/src/main/res/layout/fragment_discussions.xml +++ b/app/src/main/res/layout/fragment_discussions.xml @@ -67,16 +67,38 @@ app:layout_constraintTop_toBottomOf="@id/text_view_username" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent"> - + + + + + - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_images.xml b/app/src/main/res/layout/fragment_images.xml index e44cb8c..9f39046 100644 --- a/app/src/main/res/layout/fragment_images.xml +++ b/app/src/main/res/layout/fragment_images.xml @@ -17,12 +17,16 @@ app:navigationIconTint="@color/on_surface" app:titleCentered="true"/> - - + app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"> + + - - - \ No newline at end of file + xmlns:app="http://schemas.android.com/apk/res-auto"/> \ No newline at end of file