Compare commits

..

No commits in common. "dev" and "WIP-release-26.01.2024" have entirely different histories.

234 changed files with 853 additions and 7732 deletions

View File

@ -79,7 +79,7 @@
<option name="values">
<map>
<entry key="color" value="000000" />
<entry key="imagePath" value="C:\Users\erike\isolaatti-icon.png" />
<entry key="imagePath" value="C:\Users\erike\Downloads\path1-3.svg" />
<entry key="scalingPercent" value="74" />
<entry key="trimmed" value="true" />
</map>
@ -181,19 +181,6 @@
</PersistentState>
</value>
</entry>
<entry key="image">
<value>
<PersistentState>
<option name="values">
<map>
<entry key="color" value="000000" />
<entry key="imagePath" value="C:\Users\erike\isolaatti-notification-icon.png" />
<entry key="paddingPercent" value="-10" />
</map>
</option>
</PersistentState>
</value>
</entry>
<entry key="text">
<value>
<PersistentState>
@ -218,13 +205,6 @@
</entry>
</map>
</option>
<option name="values">
<map>
<entry key="assetType" value="IMAGE" />
<entry key="imageAsset" value="C:\Users\erike\isolaatti-notification-icon.png" />
<entry key="outputName" value="ic_notification" />
</map>
</option>
</PersistentState>
</value>
</entry>
@ -328,7 +308,7 @@
<PersistentState>
<option name="values">
<map>
<entry key="url" value="file:/$USER_HOME$/AppData/Local/Android/Sdk/icons/material/materialicons/article/baseline_article_24.xml" />
<entry key="url" value="file:/$USER_HOME$/AppData/Local/Android/Sdk/icons/material/materialicons/password/baseline_password_24.xml" />
</map>
</option>
</PersistentState>
@ -338,8 +318,8 @@
</option>
<option name="values">
<map>
<entry key="outputName" value="baseline_article_24" />
<entry key="sourceFile" value="C:\Users\erike\Downloads\hashtag-solid.svg" />
<entry key="outputName" value="baseline_password_24" />
<entry key="sourceFile" value="C:\Users\erike\Downloads\face-kiss-wink-heart-solid.svg" />
</map>
</option>
</PersistentState>

2
.idea/kotlinc.xml generated
View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.9.0" />
<option name="version" value="1.8.20" />
</component>
</project>

203
.idea/navEditor.xml generated
View File

@ -13,41 +13,22 @@
<LayoutPositions>
<option name="myPosition">
<Point>
<option name="x" value="40" />
<option name="y" value="40" />
<option name="x" value="130" />
<option name="y" value="18" />
</Point>
</option>
</LayoutPositions>
</value>
</entry>
</map>
</option>
</LayoutPositions>
</value>
</entry>
<entry key="comment_thread_navigation.xml">
<value>
<LayoutPositions>
<option name="myPositions">
<map>
<entry key="commentThreadFragment">
<entry key="audioRecorderMainFragment">
<value>
<LayoutPositions>
<option name="myPosition">
<Point>
<option name="x" value="-258" />
<option name="y" value="8" />
<option name="x" value="-74" />
<option name="y" value="17" />
</Point>
</option>
<option name="myPositions">
<map>
<entry key="action_commentThreadFragment_self">
<value>
<LayoutPositions />
</value>
</entry>
</map>
</option>
</LayoutPositions>
</value>
</entry>
@ -90,66 +71,11 @@
</LayoutPositions>
</value>
</entry>
<entry key="hashtags_navigation.xml">
<value>
<LayoutPositions>
<option name="myPositions">
<map>
<entry key="hashtagPostsFragment">
<value>
<LayoutPositions>
<option name="myPosition">
<Point>
<option name="x" value="-3" />
<option name="y" value="36" />
</Point>
</option>
</LayoutPositions>
</value>
</entry>
<entry key="hashtagsFragment">
<value>
<LayoutPositions>
<option name="myPosition">
<Point>
<option name="x" value="-219" />
<option name="y" value="36" />
</Point>
</option>
<option name="myPositions">
<map>
<entry key="action_hashtagsFragment_to_hashtagPostsFragment">
<value>
<LayoutPositions />
</value>
</entry>
</map>
</option>
</LayoutPositions>
</value>
</entry>
</map>
</option>
</LayoutPositions>
</value>
</entry>
<entry key="home_navigation.xml">
<value>
<LayoutPositions>
<option name="myPositions">
<map>
<entry key="browseProfilesFragment">
<value>
<LayoutPositions>
<option name="myPosition">
<Point>
<option name="x" value="45" />
<option name="y" value="385" />
</Point>
</option>
</LayoutPositions>
</value>
</entry>
<entry key="feedFragment">
<value>
<LayoutPositions>
@ -171,39 +97,6 @@
</LayoutPositions>
</value>
</entry>
<entry key="hashtagPostsFragment2">
<value>
<LayoutPositions>
<option name="myPosition">
<Point>
<option name="x" value="267" />
<option name="y" value="107" />
</Point>
</option>
</LayoutPositions>
</value>
</entry>
<entry key="hashtagsFragment">
<value>
<LayoutPositions>
<option name="myPosition">
<Point>
<option name="x" value="36" />
<option name="y" value="-76" />
</Point>
</option>
<option name="myPositions">
<map>
<entry key="action_hashtagsFragment2_to_hashtagPostsFragment2">
<value>
<LayoutPositions />
</value>
</entry>
</map>
</option>
</LayoutPositions>
</value>
</entry>
<entry key="notificationsFragment">
<value>
<LayoutPositions>
@ -225,6 +118,18 @@
</LayoutPositions>
</value>
</entry>
<entry key="postViewerActivity">
<value>
<LayoutPositions>
<option name="myPosition">
<Point>
<option name="x" value="-520" />
<option name="y" value="-373" />
</Point>
</option>
</LayoutPositions>
</value>
</entry>
<entry key="profileActivity">
<value>
<LayoutPositions>
@ -237,41 +142,15 @@
</LayoutPositions>
</value>
</entry>
<entry key="profileListingFragment">
<value>
<LayoutPositions>
<option name="myPosition">
<Point>
<option name="x" value="-277" />
<option name="y" value="154" />
</Point>
</option>
</LayoutPositions>
</value>
</entry>
<entry key="searchFragment">
<value>
<LayoutPositions>
<option name="myPosition">
<Point>
<option name="x" value="-278" />
<option name="y" value="216" />
<option name="x" value="12" />
<option name="y" value="12" />
</Point>
</option>
<option name="myPositions">
<map>
<entry key="action_searchFragment_to_hashtagPostsFragment">
<value>
<LayoutPositions />
</value>
</entry>
<entry key="action_searchFragment_to_hashtagsFragment">
<value>
<LayoutPositions />
</value>
</entry>
</map>
</option>
</LayoutPositions>
</value>
</entry>
@ -379,50 +258,6 @@
</LayoutPositions>
</value>
</entry>
<entry key="post_listing_navigation.xml">
<value>
<LayoutPositions>
<option name="myPositions">
<map>
<entry key="postListingFragment">
<value>
<LayoutPositions>
<option name="myPosition">
<Point>
<option name="x" value="40" />
<option name="y" value="40" />
</Point>
</option>
</LayoutPositions>
</value>
</entry>
</map>
</option>
</LayoutPositions>
</value>
</entry>
<entry key="profile_listing_navigation.xml">
<value>
<LayoutPositions>
<option name="myPositions">
<map>
<entry key="profileListingFragment">
<value>
<LayoutPositions>
<option name="myPosition">
<Point>
<option name="x" value="40" />
<option name="y" value="40" />
</Point>
</option>
</LayoutPositions>
</value>
</entry>
</map>
</option>
</LayoutPositions>
</value>
</entry>
<entry key="profile_navigation.xml">
<value>
<LayoutPositions>

View File

@ -23,19 +23,6 @@ Se conecta con el backend https://isolaatti.com
* Media 3 https://developer.android.com/jetpack/androidx/releases/media3?hl=es-419
* Algunos modulos de Android Jetpack y bibliotecas de compatibilidad (revisar build.gradle)
## Compilar
Para compilar la app es necesario utilizar Android Studio. Modifica el archivo local.properties para incluir las siguientes propiedades según tu implementación:
```
backend=https://isolaatti.com
clientId=
secret=
terms=https://isolaatti.com/terminos_de_uso
privacyPolicy=https://isolaatti.com/politica_de_privacidad
sourceCodeUrl=https://github.com/Isolaatti-Software/isolaatti-android
blogUrl=https://isolaattisoftware.com.mx/
openSourceLicences=https://files.isolaatti.com/licencias.html
```
## Características planeadas
* Grabar audios

View File

@ -7,9 +7,6 @@ plugins {
id 'org.jetbrains.kotlin.plugin.serialization' version '1.8.0'
id 'com.google.android.libraries.mapsplatform.secrets-gradle-plugin'
id 'androidx.navigation.safeargs.kotlin'
id 'com.google.gms.google-services'
id 'com.google.firebase.crashlytics'
id 'com.google.android.gms.oss-licenses-plugin'
}
android {
@ -19,20 +16,12 @@ android {
enabled = true
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.2"
}
defaultConfig {
applicationId "com.isolaatti"
minSdk 24
targetSdk 34
versionCode 7
versionName "0.7-vc7"
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
@ -56,11 +45,11 @@ android {
dependencies {
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3'
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation "androidx.recyclerview:recyclerview:1.3.2"
implementation "androidx.recyclerview:recyclerview:1.3.1"
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.core:core-ktx: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'
@ -70,7 +59,6 @@ dependencies {
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
implementation "androidx.datastore:datastore-preferences:1.0.0"
// Hilt
implementation "com.google.dagger:hilt-android:2.47"
@ -82,10 +70,10 @@ dependencies {
// Material 3
implementation "com.google.android.material:material:1.12.0"
implementation "com.google.android.material:material:1.9.0"
// Navigation
def nav_version = "2.7.7"
def nav_version = "2.6.0"
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
implementation "androidx.navigation:navigation-dynamic-features-fragment:$nav_version"
@ -101,7 +89,7 @@ dependencies {
final def markwon_version = '4.6.2'
// Customtabs
implementation 'androidx.browser:browser:1.8.0'
implementation 'androidx.browser:browser:1.7.0'
implementation 'io.coil-kt:coil:2.5.0'
implementation 'io.coil-kt:coil-svg:2.5.0'
@ -137,27 +125,4 @@ dependencies {
// QR
implementation 'com.github.androidmads:QRGenerator:1.0.1'
// Firebase
implementation(platform("com.google.firebase:firebase-bom:32.7.3"))
implementation("com.google.firebase:firebase-crashlytics")
implementation("com.google.firebase:firebase-analytics")
implementation("com.google.firebase:firebase-messaging")
// OSS screen
implementation 'com.google.android.gms:play-services-oss-licenses:17.1.0'
def composeBom = platform('androidx.compose:compose-bom:2024.10.01')
implementation composeBom
androidTestImplementation composeBom
implementation 'androidx.compose.material3:material3'
implementation 'androidx.compose.ui:ui-tooling-preview'
debugImplementation 'androidx.compose.ui:ui-tooling'
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
debugImplementation 'androidx.compose.ui:ui-test-manifest'
implementation 'androidx.activity:activity-compose:1.9.2'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.5'
implementation 'androidx.compose.runtime:runtime-livedata'
implementation "io.coil-kt.coil3:coil-compose:3.0.1"
}

View File

@ -1,13 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>
<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" />
<application
android:name=".MyApplication"
android:allowBackup="false"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
@ -16,15 +15,6 @@
android:theme="@style/Theme.Isolaatti"
android:usesCleartextTraffic="true"
tools:targetApi="31">
<activity
android:name="com.google.android.gms.oss.licenses.OssLicensesMenuActivity"
android:theme="@style/Theme.Isolaatti.OSS" />
<activity
android:name="com.google.android.gms.oss.licenses.OssLicensesActivity"
android:theme="@style/Theme.Isolaatti.OSS" />
<activity
android:name=".MainActivity"
android:exported="true"
@ -35,15 +25,14 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".home.ui.HomeActivity" android:theme="@style/Theme.Isolaatti" />
<activity android:name=".home.HomeActivity" android:theme="@style/Theme.Isolaatti" />
<activity android:name=".login.LogInActivity" android:theme="@style/Theme.Isolaatti" />
<activity android:name=".profile.ui.ProfileActivity"
android:theme="@style/Theme.Isolaatti"
android:parentActivityName=".home.ui.HomeActivity"/>
android:parentActivityName=".MainActivity"/>
<activity android:name=".settings.ui.SettingsActivity" android:theme="@style/Theme.Isolaatti"/>
<activity android:name=".posting.posts.ui.CreatePostActivity" android:theme="@style/Theme.Isolaatti" android:windowSoftInputMode="adjustResize"/>
<activity android:name=".posting.posts.viewer.ui.PostViewerActivity" android:theme="@style/Theme.Isolaatti"
android:parentActivityName=".home.ui.HomeActivity"/>
<activity android:name=".posting.posts.viewer.ui.PostViewerActivity" android:theme="@style/Theme.Isolaatti"/>
<activity android:name=".drafts.ui.DraftsActivity" android:theme="@style/Theme.Isolaatti"/>
<activity android:name=".about.AboutActivity" android:theme="@style/Theme.Isolaatti"/>
<activity android:name=".images.picture_viewer.ui.PictureViewerActivity" android:theme="@style/Theme.Isolaatti"/>
@ -51,10 +40,6 @@
<activity android:name=".images.image_maker.ui.ImageMakerActivity" android:theme="@style/Theme.Isolaatti"/>
<activity android:name=".images.image_chooser.ui.ImageChooserActivity" android:theme="@style/Theme.Isolaatti"/>
<activity android:name=".profile.ui.EditProfileActivity" android:theme="@style/Theme.Isolaatti" />
<activity android:name=".audio.recorder.ui.AudioRecorderActivity" android:theme="@style/Theme.Isolaatti" />
<activity android:name=".audio.audio_selector.ui.AudioSelectorActivity" 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" />
<provider
android:authorities="com.isolaatti.provider"
android:name="androidx.core.content.FileProvider"
@ -63,15 +48,6 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths"/>
</provider>
<service android:name=".push_notifications.FcmService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
</application>
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

@ -7,7 +7,7 @@ import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.isolaatti.auth.data.AuthRepositoryImpl
import com.isolaatti.home.ui.HomeActivity
import com.isolaatti.home.HomeActivity
import com.isolaatti.login.LogInActivity
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject

View File

@ -1,42 +1,17 @@
package com.isolaatti
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.net.ConnectivityManager
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import com.google.firebase.messaging.FirebaseMessaging
import com.isolaatti.connectivity.ConnectivityCallbackImpl
import com.isolaatti.push_notifications.PushNotificationsApi
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import okhttp3.RequestBody.Companion.toRequestBody
import retrofit2.awaitResponse
import javax.inject.Inject
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
@HiltAndroidApp
class MyApplication : Application() {
companion object {
lateinit var myApp: MyApplication
const val LOG_TAG = "MyApplication"
const val LIKES_NOTIFICATION_CHANNEL_ID = "like notification"
}
@Inject
lateinit var pushNotificationsApi: PushNotificationsApi
private val activityLifecycleCallbacks = ActivityLifecycleCallbacks()
lateinit var connectivityCallbackImpl: ConnectivityCallbackImpl
@ -46,28 +21,6 @@ class MyApplication : Application() {
registerActivityLifecycleCallbacks(activityLifecycleCallbacks)
connectivityCallbackImpl = ConnectivityCallbackImpl()
getSystemService(ConnectivityManager::class.java).registerDefaultNetworkCallback(connectivityCallbackImpl)
FirebaseMessaging.getInstance().token.addOnSuccessListener {
CoroutineScope(Dispatchers.IO).launch {
val response = pushNotificationsApi.registerDevice(it.toRequestBody()).awaitResponse()
}
}
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
createLikesNotificationChannel(notificationManager)
}
}
@RequiresApi(Build.VERSION_CODES.O)
private fun createLikesNotificationChannel(notificationManager: NotificationManager) {
val name = getString(R.string.likes_notification_channel)
val description = getString(R.string.likes_notification_channel_description)
val importance = NotificationManager.IMPORTANCE_DEFAULT
val channel = NotificationChannel(LIKES_NOTIFICATION_CHANNEL_ID, name, importance).also {
it.description = description
}
notificationManager.createNotificationChannel(channel)
}
override fun onTerminate() {

View File

@ -1,13 +1,10 @@
package com.isolaatti.about
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.net.toUri
import com.google.android.gms.oss.licenses.OssLicensesMenuActivity
import com.isolaatti.BuildConfig
import com.isolaatti.R
import com.isolaatti.databinding.ActivityAboutBinding
class AboutActivity : AppCompatActivity() {
@ -38,8 +35,9 @@ class AboutActivity : AppCompatActivity() {
}
binding.openSourceLicences.setOnClickListener {
startActivity(Intent(this, OssLicensesMenuActivity::class.java))
CustomTabsIntent.Builder()
.build()
.launchUrl(this, BuildConfig.openSourceLicences.toUri())
}
binding.privacyPolicyButton.setOnClickListener {
@ -53,7 +51,5 @@ class AboutActivity : AppCompatActivity() {
.build()
.launchUrl(this, BuildConfig.terms.toUri())
}
binding.appVersion.text = getString(R.string.app_version, BuildConfig.VERSION_NAME)
}
}

View File

@ -4,12 +4,7 @@ import androidx.media3.common.Player
import com.isolaatti.audio.common.data.AudiosApi
import com.isolaatti.audio.common.data.AudiosRepositoryImpl
import com.isolaatti.audio.common.domain.AudiosRepository
import com.isolaatti.audio.drafts.data.AudioDraftsRepositoryImpl
import com.isolaatti.audio.drafts.data.AudiosDraftsDao
import com.isolaatti.audio.drafts.domain.repository.AudioDraftsRepository
import com.isolaatti.connectivity.RetrofitClient
import com.isolaatti.database.AppDatabase
import com.isolaatti.settings.domain.UserIdSetting
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -24,17 +19,7 @@ class Module {
}
@Provides
fun provideAudiosRepository(audiosApi: AudiosApi, audiosDraftsDao: AudiosDraftsDao, userIdSetting: UserIdSetting): AudiosRepository {
return AudiosRepositoryImpl(audiosApi, audiosDraftsDao, userIdSetting)
}
@Provides
fun provideAudioDraftsDao(database: AppDatabase): AudiosDraftsDao {
return database.audioDrafts()
}
@Provides
fun provideAudioDraftsRepository(audiosDraftsDao: AudiosDraftsDao): AudioDraftsRepository {
return AudioDraftsRepositoryImpl(audiosDraftsDao)
fun provideAudiosRepository(audiosApi: AudiosApi): AudiosRepository {
return AudiosRepositoryImpl(audiosApi)
}
}

View File

@ -1,18 +0,0 @@
package com.isolaatti.audio.audio_selector.presentation
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.isolaatti.audio.audio_selector.ui.AudiosListSelectorFragment
class AudioSelectorFragmentAdapter(fragmentActivity: FragmentActivity) : FragmentStateAdapter(fragmentActivity) {
override fun getItemCount(): Int = 2
override fun createFragment(position: Int): Fragment {
return when(position) {
0 -> AudiosListSelectorFragment.getInstance()
1 -> AudiosListSelectorFragment.getInstanceForDrafts()
else -> Fragment()
}
}
}

View File

@ -1,66 +0,0 @@
package com.isolaatti.audio.audio_selector.presentation
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.AudiosRepository
import com.isolaatti.audio.common.domain.Playable
import com.isolaatti.audio.drafts.domain.AudioDraft
import com.isolaatti.audio.drafts.domain.repository.AudioDraftsRepository
import com.isolaatti.auth.domain.AuthRepository
import com.isolaatti.common.SortingEnum
import com.isolaatti.utils.Resource
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class AudioSelectorViewModel @Inject constructor(
private val audiosRepository: AudiosRepository,
private val audioDraftsRepository: AudioDraftsRepository
) : ViewModel() {
val audios: MutableLiveData<List<Audio>> = MutableLiveData()
val drafts: MutableLiveData<List<AudioDraft>> = MutableLiveData()
val sorting: MutableLiveData<SortingEnum> = MutableLiveData()
var squadId: String? = null
val error: MutableLiveData<Resource.Error.ErrorType?> = MutableLiveData(null)
val loading: MutableLiveData<Boolean> = MutableLiveData()
fun setSorting(sort: SortingEnum) {
sorting.value = sort
}
fun getAudioDrafts() {
viewModelScope.launch {
audioDraftsRepository.getAudioDrafts().onEach {
drafts.postValue(it)
}
}
}
fun getAudios() {
if(squadId == null) {
viewModelScope.launch {
audiosRepository.getMyAudios(audios.value?.lastOrNull()?.id).onEach { resource ->
when(resource) {
is Resource.Error -> {
loading.postValue(false)
error.postValue(resource.errorType)
}
is Resource.Loading -> {
loading.postValue(true)
}
is Resource.Success -> {
loading.postValue(false)
audios.postValue(resource.data ?: listOf())
}
}
}.flowOn(Dispatchers.IO).launchIn(this)
}
}
}
}

View File

@ -1,91 +0,0 @@
package com.isolaatti.audio.audio_selector.ui
import android.os.Bundle
import androidx.activity.viewModels
import androidx.appcompat.widget.PopupMenu
import androidx.navigation.findNavController
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.tabs.TabLayoutMediator
import com.isolaatti.R
import com.isolaatti.audio.audio_selector.presentation.AudioSelectorFragmentAdapter
import com.isolaatti.audio.audio_selector.presentation.AudioSelectorViewModel
import com.isolaatti.audio.recorder.ui.AudioRecorderContract
import com.isolaatti.common.IsolaattiBaseActivity
import com.isolaatti.common.SortingEnum
import com.isolaatti.databinding.ActivityAudioSelectorBinding
class AudioSelectorActivity : IsolaattiBaseActivity() {
companion object {
const val EXTRA_ID = "id"
const val EXTRA_FOR_SQUAD = "forSquad"
const val OUT_EXTRA_PLAYABLE = "playable"
}
private lateinit var binding: ActivityAudioSelectorBinding
private val viewModel: AudioSelectorViewModel by viewModels()
private val audioRecorderLauncher = registerForActivityResult(AudioRecorderContract()) {
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityAudioSelectorBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.viewpager.adapter = AudioSelectorFragmentAdapter(this)
TabLayoutMediator(binding.tabLayout, binding.viewpager) {tab, position ->
when(position) {
0 -> tab.text = getString(R.string.audios)
1 -> tab.text = getString(R.string.drafts)
}
}.attach()
binding.sort.setText(R.string.descending_by_name)
setupListeners()
}
private fun setupListeners() {
binding.toolbar.setNavigationOnClickListener {
finish()
}
binding.newAudio.setOnClickListener {
audioRecorderLauncher.launch(null)
}
binding.sort.setOnClickListener {
val popupMenu = PopupMenu(this, it)
popupMenu.inflate(R.menu.audios_sort_menu)
popupMenu.setOnMenuItemClickListener { menuItem ->
binding.sort.text = menuItem.title
when(menuItem.itemId) {
R.id.desc_by_name -> {
viewModel.setSorting(SortingEnum.DescendingByName)
true
}
R.id.asc_by_name -> {
viewModel.setSorting(SortingEnum.AscendingByName)
true
}
R.id.asc_by_creation_date -> {
viewModel.setSorting(SortingEnum.AscendingByCreationDate)
true
}
R.id.desc_by_creation_date -> {
viewModel.setSorting(SortingEnum.DescendingByCreationDate)
true
}
else -> false
}
}
popupMenu.show()
}
}
}

View File

@ -1,43 +0,0 @@
package com.isolaatti.audio.audio_selector.ui
import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.activity.result.contract.ActivityResultContract
import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.audio.common.domain.Playable
import java.io.Serializable
/**
* Contract to select audio. Use SelectorConfig class to specify the context to use to select audio.
* Output: if user selected an audio or draft, it will be returned as result. Check type as convenient. Null if user cancelled
*/
class AudioSelectorContract : ActivityResultContract<AudioSelectorContract.SelectorConfig, Playable?>() {
/**
* @param forSquad audios source will be a specified squad in the id param
* @param id squad id, should be non null if forSquad is true
*/
data class SelectorConfig(val forSquad: Boolean, val id: String?): Serializable
override fun createIntent(context: Context, input: SelectorConfig): Intent {
val intent = Intent(context, AudioSelectorActivity::class.java)
intent.apply {
putExtra(AudioSelectorActivity.EXTRA_FOR_SQUAD, input.forSquad)
if(input.forSquad) {
putExtra(AudioSelectorActivity.EXTRA_ID, input.id as String)
}
}
return intent
}
override fun parseResult(resultCode: Int, intent: Intent?): Playable? {
if(resultCode == Activity.RESULT_OK) {
return intent?.extras?.getSerializable(AudioSelectorActivity.OUT_EXTRA_PLAYABLE) as? Playable
}
return null
}
}

View File

@ -1,105 +0,0 @@
package com.isolaatti.audio.audio_selector.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import com.isolaatti.audio.audio_selector.presentation.AudioSelectorViewModel
import com.isolaatti.audio.audios_list.presentation.AudiosAdapter
import com.isolaatti.databinding.FragmentAudiosListSelectorBinding
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class AudiosListSelectorFragment : Fragment() {
private lateinit var binding: FragmentAudiosListSelectorBinding
private val viewModel: AudioSelectorViewModel by activityViewModels()
private var mode: Int = ARG_VAL_MODE_AUDIOS
private var squadId: String? = null
private lateinit var adapter: AudiosAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
arguments?.getInt(ARG_MODE)?.let { mode = it }
arguments?.getString(ARG_SQUAD_ID)?.let { squadId = it }
viewModel.squadId = squadId
when(mode) {
ARG_VAL_MODE_AUDIOS -> {
viewModel.getAudios()
}
ARG_VAL_MODE_DRAFTS -> {
viewModel.getAudioDrafts()
}
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentAudiosListSelectorBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
adapter = AudiosAdapter(
onPlayClick = {
},
onOptionsClick = {audio, button ->
false
}
)
binding.recycler.adapter = adapter
when(mode) {
ARG_VAL_MODE_AUDIOS -> {
viewModel.audios.observe(viewLifecycleOwner) {
adapter.setData(it)
}
}
ARG_VAL_MODE_DRAFTS -> {
viewModel.drafts.observe(viewLifecycleOwner) {
}
}
}
}
companion object {
private const val ARG_MODE = "mode"
private const val ARG_SQUAD_ID = "squadId"
private const val ARG_VAL_MODE_DRAFTS = 0
private const val ARG_VAL_MODE_AUDIOS = 1
fun getInstance(): AudiosListSelectorFragment {
return AudiosListSelectorFragment().apply {
arguments = Bundle().apply {
putInt(ARG_MODE, ARG_VAL_MODE_AUDIOS)
}
}
}
fun getInstanceForDrafts(): AudiosListSelectorFragment {
return AudiosListSelectorFragment().apply {
arguments = Bundle().apply {
putInt(ARG_MODE, ARG_VAL_MODE_DRAFTS)
}
}
}
fun getInstanceForSquad(squadId: String): AudiosListSelectorFragment {
return AudiosListSelectorFragment().apply {
arguments = Bundle().apply {
putString(ARG_SQUAD_ID, squadId)
}
}
}
}
}

View File

@ -3,85 +3,24 @@ package com.isolaatti.audio.audios_list.presentation
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.res.ResourcesCompat
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import coil.load
import com.isolaatti.R
import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.databinding.AudioListItemBinding
class AudiosAdapter(
private val onPlayClick: ((audio: Audio) -> Unit),
private val onClick: ((audio: Audio) -> Unit),
private val onOptionsClick: ((audio: Audio, button: View) -> Boolean)
) : RecyclerView.Adapter<AudiosAdapter.AudiosViewHolder>() {
enum class Payload {
PlayStateChanged, IsLoadingChanged
}
private var data: List<Audio> = listOf()
private var currentPlaying: Audio? = null
fun setData(audios: List<Audio>) {
data = audios
notifyDataSetChanged()
}
fun setIsPlaying(isPlaying: Boolean, audio: Audio) {
if(audio == currentPlaying) {
val index = data.indexOf(audio)
if(index == -1) return
data[index].isPlaying = isPlaying
notifyItemChanged(index, Payload.PlayStateChanged)
return
}
val prevIndex = data.indexOf(currentPlaying)
if(prevIndex != -1) {
data[prevIndex].isPlaying = false
notifyItemChanged(prevIndex, Payload.PlayStateChanged)
}
val newIndex = data.indexOf(audio)
if(newIndex != -1) {
data[newIndex].isPlaying = isPlaying
notifyItemChanged(newIndex, Payload.PlayStateChanged)
}
currentPlaying = audio
}
fun setIsLoadingAudio(isLoading: Boolean, audio: Audio) {
if(audio == currentPlaying) {
val index = data.indexOf(audio)
if(index == -1) return
data[index].isLoading = isLoading
notifyItemChanged(index, Payload.IsLoadingChanged)
return
}
val prevIndex = data.indexOf(currentPlaying)
if(prevIndex != -1) {
data[prevIndex].isLoading = false
notifyItemChanged(prevIndex, Payload.IsLoadingChanged)
}
val newIndex = data.indexOf(audio)
if(newIndex != -1) {
data[newIndex].isLoading = isLoading
notifyItemChanged(newIndex, Payload.IsLoadingChanged)
}
currentPlaying = audio
}
fun updateAudioProgress(total: Int, progress: Int, audio: Audio) {
}
inner class AudiosViewHolder(val binding: AudioListItemBinding) : ViewHolder(binding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AudiosViewHolder {
@ -93,62 +32,19 @@ class AudiosAdapter(
return data.size
}
override fun onBindViewHolder(
holder: AudiosViewHolder,
position: Int,
payloads: MutableList<Any>
) {
override fun onBindViewHolder(holder: AudiosViewHolder, position: Int) {
val audio = data[position]
if(payloads.isEmpty()) {
holder.binding.audioName.text = audio.name
holder.binding.audioAuthor.text = audio.userName
holder.binding.thumbnail.load(audio.thumbnail)
holder.binding.root.setOnClickListener {
onClick(audio)
}
holder.binding.audioItemOptionsButton.setOnClickListener {
onOptionsClick(audio, it)
}
holder.binding.playButton.icon = ResourcesCompat.getDrawable(
holder.itemView.resources,
if(audio.isPlaying) R.drawable.baseline_pause_24 else R.drawable.baseline_play_arrow_24,
null
)
holder.binding.loading.visibility = if(audio.isLoading) View.VISIBLE else View.GONE
holder.binding.playButton.setOnClickListener {
onPlayClick(audio)
}
return
}
// only updates play button
if(payloads.contains(Payload.PlayStateChanged)) {
holder.binding.playButton.icon = ResourcesCompat.getDrawable(
holder.itemView.resources,
if(audio.isPlaying) R.drawable.baseline_pause_24 else R.drawable.baseline_play_arrow_24,
null
)
}
if(payloads.contains(Payload.IsLoadingChanged)) {
holder.binding.loading.visibility = if(audio.isLoading) View.VISIBLE else View.GONE
}
}
override fun onBindViewHolder(holder: AudiosViewHolder, position: Int) {}
fun removeAudio(audio: Audio) {
val index = data.indexOf(audio)
if(index == -1) return
// TODO data should be modified from outside
data = data.toMutableList().apply {
removeAt(index)
}
notifyItemRemoved(index)
}
}

View File

@ -17,7 +17,7 @@ import javax.inject.Inject
@HiltViewModel
class AudiosViewModel @Inject constructor(private val audiosRepository: AudiosRepository) : ViewModel() {
val resource: MutableLiveData<Resource<List<Audio>>> = MutableLiveData()
val audioRemoved: MutableLiveData<Audio?> = MutableLiveData()
fun loadAudios(userId: Int) {
viewModelScope.launch {
@ -28,14 +28,4 @@ class AudiosViewModel @Inject constructor(private val audiosRepository: AudiosRe
}
}
fun removeAudio(audio: Audio) {
viewModelScope.launch {
audiosRepository.deleteAudio(audio.id).onEach {
if(it is Resource.Success) {
audioRemoved.postValue(audio)
}
}.flowOn(Dispatchers.IO).launchIn(this)
}
}
}

View File

@ -8,21 +8,16 @@ import androidx.appcompat.widget.PopupMenu
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
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.isolaatti.R
import com.isolaatti.audio.audios_list.presentation.AudiosAdapter
import com.isolaatti.audio.audios_list.presentation.AudiosViewModel
import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.audio.common.domain.Playable
import com.isolaatti.audio.player.AudioPlayerConnector
import com.isolaatti.common.ErrorMessageViewModel
import com.isolaatti.databinding.FragmentAudiosBinding
import com.isolaatti.utils.Resource
import dagger.hilt.android.AndroidEntryPoint
import kotlin.properties.Delegates
@AndroidEntryPoint
class AudiosFragment : Fragment() {
@ -31,9 +26,6 @@ class AudiosFragment : Fragment() {
private val errorViewModel: ErrorMessageViewModel by activityViewModels()
private val arguments: AudiosFragmentArgs by navArgs()
private lateinit var adapter: AudiosAdapter
private var privilegedUserId by Delegates.notNull<Int>()
private lateinit var audioPlayerConnector: AudioPlayerConnector
private var loadedFirstTime = false
override fun onCreateView(
@ -46,81 +38,24 @@ class AudiosFragment : Fragment() {
return viewBinding.root
}
private fun onDeleteAudio(audio: Audio) {
MaterialAlertDialogBuilder(requireContext())
.setMessage(R.string.delete_audio_message)
.setTitle(R.string.delete_audio_title)
.setPositiveButton(R.string.yes_continue) { _, _ ->
viewModel.removeAudio(audio)
}
.setNegativeButton(R.string.no, null)
.show()
}
private val onOptionsClick: ((audio: Audio, button: View) -> Boolean) = { audio, button ->
val popup = PopupMenu(requireContext(), button)
popup.menuInflater.inflate(R.menu.audio_item_menu, popup.menu)
if(audio.userId != privilegedUserId) {
popup.menu.removeItem(R.id.rename_item)
popup.menu.removeItem(R.id.delete_item)
popup.menu.removeItem(R.id.set_as_profile_audio)
}
popup.setOnMenuItemClickListener {
when(it.itemId) {
R.id.delete_item -> {
onDeleteAudio(audio)
true
}
else -> false
}
}
popup.show()
true
}
private val onAudioPlayClick: (audio: Audio) -> Unit = {
audioPlayerConnector.playPauseAudio(it)
}
private val audioPlayerConnectorListener: AudioPlayerConnector.Listener = object: AudioPlayerConnector.Listener {
override fun onPlaying(isPlaying: Boolean, audio: Playable) {
if(audio is Audio)
adapter.setIsPlaying(isPlaying, audio)
}
override fun isLoading(isLoading: Boolean, audio: Playable) {
if(audio is Audio)
adapter.setIsLoadingAudio(isLoading, audio)
}
override fun progressChanged(second: Int, audio: Playable) {
}
override fun durationChanged(duration: Int, audio: Playable) {
}
override fun onEnded(audio: Playable) {
}
private val onAudioClick: ((audio: Audio) -> Unit) = {
// Play audio
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
audioPlayerConnector = AudioPlayerConnector(requireContext())
audioPlayerConnector.addListener(audioPlayerConnectorListener)
viewLifecycleOwner.lifecycle.addObserver(audioPlayerConnector)
adapter = AudiosAdapter(onPlayClick = onAudioPlayClick, onOptionsClick = onOptionsClick)
adapter = AudiosAdapter(onClick = onAudioClick, onOptionsClick = onOptionsClick)
viewBinding.recyclerAudios.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false)
viewBinding.recyclerAudios.adapter = adapter
@ -128,12 +63,7 @@ class AudiosFragment : Fragment() {
setupObservers()
if(arguments.source == SOURCE_PROFILE) {
privilegedUserId = arguments.sourceId.toInt()
viewModel.loadAudios(privilegedUserId)
}
viewBinding.topAppBar.setNavigationOnClickListener {
findNavController().popBackStack()
viewModel.loadAudios(arguments.sourceId.toInt())
}
}
@ -155,15 +85,6 @@ class AudiosFragment : Fragment() {
}
}
}
viewModel.audioRemoved.observe(viewLifecycleOwner) {
if(it != null){
adapter.removeAudio(it)
viewModel.audioRemoved.value = null
}
}
}
companion object {

View File

@ -1,6 +1,5 @@
package com.isolaatti.audio.common.data
import java.io.Serializable
import java.time.ZonedDateTime
data class AudiosDto(val data: List<AudioDto>)
@ -11,4 +10,4 @@ data class AudioDto(
val userId: Int,
val firestoreObjectPath: String,
val userName: String
): Serializable
)

View File

@ -1,28 +1,11 @@
package com.isolaatti.audio.common.data
import com.isolaatti.common.ResultDto
import okhttp3.MultipartBody
import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
interface AudiosApi {
companion object {
const val AudioParam= "audioFile"
const val NameParam = "name"
const val DurationParam = "duration"
}
@GET("/api/Audios/OfUser/{userId}")
fun getAudiosOfUser(@Path("userId") userId: Int, @Query("lastAudioId") lastAudioId: String?): Call<AudiosDto>
@POST("/api/Audios/Create")
@Multipart
fun uploadFile(@Part file: MultipartBody.Part, @Part name: MultipartBody.Part, @Part duration: MultipartBody.Part): Call<AudioDto>
@POST("/api/Audios/{audioId}/Delete")
fun deleteAudio(@Path("audioId") audioId: String): Call<ResultDto<String>>
}

View File

@ -1,117 +1,32 @@
package com.isolaatti.audio.common.data
import android.util.Log
import com.isolaatti.MyApplication
import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.audio.common.domain.AudiosRepository
import com.isolaatti.audio.drafts.data.AudiosDraftsDao
import com.isolaatti.settings.domain.UserIdSetting
import com.isolaatti.utils.Resource
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import retrofit2.awaitResponse
import java.io.File
import javax.inject.Inject
class AudiosRepositoryImpl @Inject constructor(
private val audiosApi: AudiosApi,
private val audiosDraftsDao: AudiosDraftsDao,
private val userIdSetting: UserIdSetting
) : AudiosRepository {
companion object {
const val LOG_TAG = "AudiosRepositoryImpl"
}
private suspend fun _getAudiosOfUser(userId: Int, lastId: String?): Resource<List<Audio>> {
class AudiosRepositoryImpl @Inject constructor(private val audiosApi: AudiosApi) : AudiosRepository {
override fun getAudiosOfUser(userId: Int, lastId: String?): Flow<Resource<List<Audio>>> = flow {
emit(Resource.Loading())
try {
val response = audiosApi.getAudiosOfUser(userId, lastId).awaitResponse()
if(response.isSuccessful) {
val body = response.body() ?: return Resource.Error(Resource.Error.ErrorType.ServerError)
val body = response.body()
if(body == null) {
emit(Resource.Error(Resource.Error.ErrorType.ServerError))
return@flow
}
return Resource.Success(body.data.map { Audio.fromDto(it) })
} else {
return Resource.Error(Resource.Error.ErrorType.ServerError)
emit(Resource.Success(body.data.map { Audio.fromDto(it) }))
}
} catch(e: Exception) {
Log.e("AudiosRepositoryImpl", e.message.toString())
return Resource.Error(Resource.Error.ErrorType.NetworkError)
}
}
override fun getAudiosOfUser(userId: Int, lastId: String?): Flow<Resource<List<Audio>>> = flow {
emit(Resource.Loading())
emit(_getAudiosOfUser(userId, lastId))
}
override fun getMyAudios(lastId: String?): Flow<Resource<List<Audio>>> = flow {
emit(Resource.Loading())
val userId = userIdSetting.getUserIdAsync()
if(userId == null) {
emit(Resource.Error(Resource.Error.ErrorType.OtherError))
return@flow
}
emit(_getAudiosOfUser(userId, lastId))
}
override fun uploadAudio(draftId: Long): Flow<Resource<Audio>> = flow {
val audioDraftEntity = audiosDraftsDao.getAudioDraftById(draftId)
if(audioDraftEntity == null) {
emit(Resource.Error(Resource.Error.ErrorType.NotFoundError, "draft not found"))
return@flow
}
val file = File(MyApplication.myApp.filesDir, audioDraftEntity.audioLocalPath)
if(!file.exists()) {
// remove draft, file was removed from file system for some reason
audiosDraftsDao.deleteDrafts(arrayOf(audioDraftEntity))
emit(Resource.Error(Resource.Error.ErrorType.OtherError, "File not found"))
return@flow
}
try {
val response = audiosApi.uploadFile(
MultipartBody.Part.createFormData(
AudiosApi.AudioParam, // api parameter name
audioDraftEntity.audioLocalPath.split("/")[1],
file.asRequestBody("audio/3gpp".toMediaType()) // actual file to be sent
),
MultipartBody.Part.createFormData(AudiosApi.NameParam, audioDraftEntity.name),
MultipartBody.Part.createFormData(AudiosApi.DurationParam, "0")
).awaitResponse()
if(response.isSuccessful) {
val audioDto = response.body()
if(audioDto != null) {
Log.d(LOG_TAG, "emit audio dto")
audiosDraftsDao.deleteDrafts(arrayOf(audioDraftEntity))
emit(Resource.Success(Audio.fromDto(audioDto)))
}
} else {
emit(Resource.Error(Resource.Error.mapErrorCode(response.code())))
}
} catch(e: Exception) {
Log.d(LOG_TAG, e.message.toString())
}
}
override fun deleteAudio(audioId: String): Flow<Resource<Boolean>> = flow {
emit(Resource.Loading())
try {
val response = audiosApi.deleteAudio(audioId).awaitResponse()
if(response.isSuccessful) {
emit(Resource.Success(response.isSuccessful))
} else {
emit(Resource.Error(Resource.Error.mapErrorCode(response.code())))
}
} catch(e: Exception) {
Log.e(LOG_TAG, e.message.toString())
emit(Resource.Error(Resource.Error.ErrorType.NetworkError))
}
}
}

View File

@ -1,7 +1,5 @@
package com.isolaatti.audio.common.domain
import android.net.Uri
import androidx.core.net.toUri
import com.isolaatti.audio.common.data.AudioDto
import com.isolaatti.common.Ownable
import com.isolaatti.connectivity.RetrofitClient.Companion.BASE_URL
@ -15,16 +13,17 @@ data class Audio(
val creationTime: ZonedDateTime,
override val userId: Int,
val userName: String
): Ownable, Playable(), Serializable {
override val uri: Uri get() {
return "${BASE_URL}audios/$id.webm".toUri()
): Ownable, Serializable {
var playing: Boolean = false
val downloadUrl: String get() {
return "${BASE_URL}audios/$id.webm"
}
override val thumbnail: String get() {
val thumbnail: String get() {
return UrlGen.userProfileImage(userId)
}
companion object {
fun fromDto(audioDto: AudioDto): Audio {
return Audio(

View File

@ -5,9 +5,4 @@ import kotlinx.coroutines.flow.Flow
interface AudiosRepository {
fun getAudiosOfUser(userId: Int, lastId: String?): Flow<Resource<List<Audio>>>
fun getMyAudios(lastId: String?): Flow<Resource<List<Audio>>>
fun uploadAudio(draftId: Long): Flow<Resource<Audio>>
fun deleteAudio(audioId: String): Flow<Resource<Boolean>>
}

View File

@ -1,32 +0,0 @@
package com.isolaatti.audio.common.domain
import android.net.Uri
abstract class Playable {
var isPlaying: Boolean = false
abstract val uri: Uri
var isLoading: Boolean = false
var progress: Int = 0
var duration: Int = 0
/**
* Image url, null indicating no image should be shown
*/
abstract val thumbnail: String?
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Playable
if (uri != other.uri) return false
return thumbnail == other.thumbnail
}
override fun hashCode(): Int {
var result = uri.hashCode()
result = 31 * result + (thumbnail?.hashCode() ?: 0)
return result
}
}

View File

@ -1,9 +0,0 @@
package com.isolaatti.audio.common.domain
import com.isolaatti.utils.Resource
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class UploadAudioUC @Inject constructor(private val audiosRepository: AudiosRepository) {
operator fun invoke(draftId: Long): Flow<Resource<Audio>> = audiosRepository.uploadAudio(draftId)
}

View File

@ -1,12 +0,0 @@
package com.isolaatti.audio.drafts.data
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "audio_drafts")
data class AudioDraftEntity(
@PrimaryKey(autoGenerate = true) val id: Long,
val name: String,
val audioLocalPath: String,
val sizeInBytes: Long
)

View File

@ -1,53 +0,0 @@
package com.isolaatti.audio.drafts.data
import android.util.Log
import com.isolaatti.MyApplication
import com.isolaatti.audio.drafts.domain.AudioDraft
import com.isolaatti.audio.drafts.domain.repository.AudioDraftsRepository
import com.isolaatti.utils.Resource
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import java.io.File
class AudioDraftsRepositoryImpl(private val audiosDraftsDao: AudiosDraftsDao) : AudioDraftsRepository {
override fun saveAudioDraft(name: String, relativePath: String, size: Long): Flow<AudioDraft> = flow {
val entity = AudioDraftEntity(0, name, relativePath, size)
val insertedEntityId = audiosDraftsDao.insertAudioDraft(entity)
emit(AudioDraft.fromEntity(entity.copy(id = insertedEntityId)))
}
override fun getAudioDrafts(): Flow<List<AudioDraft>> = flow {
emit(audiosDraftsDao.getDrafts().map { AudioDraft.fromEntity(it) })
}
override fun deleteDrafts(draftIds: LongArray): Flow<Boolean> = flow {
val drafts = audiosDraftsDao.getAudioDraftsByIds(*draftIds)
audiosDraftsDao.deleteDrafts(drafts)
try {
for(draft in drafts) {
File(MyApplication.myApp.applicationContext.filesDir, draft.audioLocalPath).delete()
}
} catch(securityException: SecurityException) {
Log.e("AudioDraftsRepositoryImpl", "Could not delete file\n${securityException.message}")
}
emit(true)
}
override fun renameDraft(draftId: Long, name: String): Flow<Boolean> = flow {
val rowsAffected = audiosDraftsDao.renameDraft(draftId, name)
emit(rowsAffected > 0)
}
override fun getAudioDraftById(draftId: Long): Flow<Resource<AudioDraft>> = flow {
val audioDraft = audiosDraftsDao.getAudioDraftById(draftId)
if(audioDraft == null) {
emit(Resource.Error(Resource.Error.ErrorType.NotFoundError, "Audio draft not found"))
} else {
emit(Resource.Success(AudioDraft.fromEntity(audioDraft)))
}
}
}

View File

@ -1,28 +0,0 @@
package com.isolaatti.audio.drafts.data
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
@Dao
interface AudiosDraftsDao {
@Insert
fun insertAudioDraft(audioDraftEntity: AudioDraftEntity): Long
@Query("SELECT * FROM audio_drafts WHERE id = :draftId")
fun getAudioDraftById(draftId: Long): AudioDraftEntity?
@Query("SELECT * FROM audio_drafts WHERE id in (:draftId)")
fun getAudioDraftsByIds(vararg draftId: Long): Array<AudioDraftEntity>
@Query("SELECT * FROM audio_drafts ORDER BY id DESC")
fun getDrafts(): List<AudioDraftEntity>
@Delete
fun deleteDrafts(draft: Array<AudioDraftEntity>)
@Query("UPDATE audio_drafts SET name = :name WHERE id = :id")
fun renameDraft(id: Long, name: String): Int
}

View File

@ -1,26 +0,0 @@
package com.isolaatti.audio.drafts.domain
import android.net.Uri
import androidx.core.net.toUri
import com.isolaatti.MyApplication
import com.isolaatti.audio.common.domain.Playable
import com.isolaatti.audio.drafts.data.AudioDraftEntity
import java.io.File
import java.io.Serializable
data class AudioDraft(val id: Long, val name: String, val localStorageRelativePath: String, val size: Long) : Playable(), Serializable {
override val thumbnail: String?
get() = null
override val uri: Uri
get() {
val appFiles = MyApplication.myApp.applicationContext.filesDir
return File(appFiles, localStorageRelativePath).toUri()
}
companion object {
fun fromEntity(audioDraftEntity: AudioDraftEntity): AudioDraft {
return AudioDraft(audioDraftEntity.id, audioDraftEntity.name, audioDraftEntity.audioLocalPath, audioDraftEntity.sizeInBytes)
}
}
}

View File

@ -1,17 +0,0 @@
package com.isolaatti.audio.drafts.domain.repository
import com.isolaatti.audio.drafts.domain.AudioDraft
import com.isolaatti.utils.Resource
import kotlinx.coroutines.flow.Flow
interface AudioDraftsRepository {
fun saveAudioDraft(name: String, relativePath: String, size: Long): Flow<AudioDraft>
fun getAudioDrafts(): Flow<List<AudioDraft>>
fun deleteDrafts(draftIds: LongArray): Flow<Boolean>
fun renameDraft(draftId: Long, name: String): Flow<Boolean>
fun getAudioDraftById(draftId: Long): Flow<Resource<AudioDraft>>
}

View File

@ -1,12 +0,0 @@
package com.isolaatti.audio.drafts.domain.use_case
import com.isolaatti.audio.drafts.domain.AudioDraft
import com.isolaatti.audio.drafts.domain.repository.AudioDraftsRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class SaveAudioDraft @Inject constructor(private val audioDraftsRepository: AudioDraftsRepository) {
operator fun invoke(name: String, relativePath: String, size: Long): Flow<AudioDraft> {
return audioDraftsRepository.saveAudioDraft(name, relativePath, size)
}
}

View File

@ -1,55 +0,0 @@
package com.isolaatti.audio.drafts.presentation
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.res.ResourcesCompat
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.isolaatti.R
import com.isolaatti.audio.drafts.domain.AudioDraft
import com.isolaatti.databinding.AudioListItemBinding
class AudioDraftsAdapter(
private val onOptionsClicked: (item: AudioDraft, view: View) -> Unit = { _,_ -> },
private val onPlayClicked: (item: AudioDraft , view: View) -> Unit = { _,_ -> },
private val onItemClicked: (item: AudioDraft , view: View) -> Unit = { _,_ -> }
) : ListAdapter<AudioDraft, AudioDraftsAdapter.AudioDraftViewHolder>(diffCallback) {
inner class AudioDraftViewHolder(val audioListItemBinding: AudioListItemBinding) : ViewHolder(audioListItemBinding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AudioDraftViewHolder {
return AudioDraftViewHolder(
AudioListItemBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
override fun onBindViewHolder(holder: AudioDraftViewHolder, position: Int) {
val item = getItem(position)
holder.audioListItemBinding.apply {
audioName.text = item.name
audioItemOptionsButton.setOnClickListener { onOptionsClicked(item, it) }
playButton.icon = ResourcesCompat.getDrawable(
holder.itemView.resources,
if(item.isPlaying) R.drawable.baseline_pause_24 else R.drawable.baseline_play_arrow_24,
null
)
}
}
companion object {
val diffCallback = object: DiffUtil.ItemCallback<AudioDraft>() {
override fun areItemsTheSame(oldItem: AudioDraft, newItem: AudioDraft): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: AudioDraft, newItem: AudioDraft): Boolean {
return oldItem == newItem
}
}
}
}

View File

@ -1,6 +0,0 @@
package com.isolaatti.audio.drafts.presentation
import androidx.lifecycle.ViewModel
class AudioDraftsViewModel : ViewModel() {
}

View File

@ -1,49 +0,0 @@
package com.isolaatti.audio.drafts.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.LinearLayoutManager
import com.isolaatti.audio.drafts.domain.AudioDraft
import com.isolaatti.audio.drafts.presentation.AudioDraftsAdapter
import com.isolaatti.audio.drafts.presentation.AudioDraftsViewModel
import com.isolaatti.databinding.FragmentAudioDraftsBinding
class AudioDraftsFragment : Fragment() {
private lateinit var binding: FragmentAudioDraftsBinding
private val viewModel: AudioDraftsViewModel by viewModels()
private var adapter: AudioDraftsAdapter? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentAudioDraftsBinding.inflate(inflater)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
adapter = AudioDraftsAdapter(
onPlayClicked = { item: AudioDraft, view: View ->
},
onOptionsClicked = { item, button ->
}
)
binding.recycler.adapter = adapter
binding.recycler.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false)
}
}

View File

@ -8,16 +8,9 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.drm.DrmSessionManagerProvider
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.audio.common.domain.Playable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -35,7 +28,7 @@ class AudioPlayerConnector(
const val TAG = "AudioPlayerConnector"
}
private var player: Player? = null
private var audio: Playable? = null
private var audio: Audio? = null
private var mediaItem: MediaItem? = null
private var ended = false
@ -69,9 +62,6 @@ class AudioPlayerConnector(
}
private val playerListener: Player.Listener = object: Player.Listener {
override fun onPlayerError(error: PlaybackException) {
Log.e(TAG, error.message.toString())
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
if(audio != null) {
listeners.forEach { listener -> listener.onPlaying(isPlaying, audio!!)}
@ -93,23 +83,12 @@ class AudioPlayerConnector(
Player.STATE_ENDED -> {
Log.d(TAG, "STATE_ENDED")
audio?.let {
listeners.forEach { listener ->
listener.onPlaying(false, it)
listener.onEnded(it)
}
listeners.forEach { listener -> listener.onPlaying(false, it)}
}
stopTimer()
ended = true
}
Player.STATE_BUFFERING -> {
player?.totalBufferedDuration?.let {
val seconds = (it / 1000).toInt()
Log.d(TAG, "Duration $it")
audio?.let {
listeners.forEach { listener -> listener.durationChanged(seconds, it) }
}
}
}
Player.STATE_BUFFERING -> {}
Player.STATE_IDLE -> {}
Player.STATE_READY -> {
Log.d(TAG, "STATE_READY")
@ -128,6 +107,7 @@ class AudioPlayerConnector(
private fun initializePlayer() {
player = ExoPlayer.Builder(context).build()
player?.playWhenReady = true
player?.addListener(playerListener)
player?.prepare()
}
@ -145,22 +125,23 @@ class AudioPlayerConnector(
}
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
Log.d(TAG, event.toString())
when(event) {
Lifecycle.Event.ON_START -> {
initializePlayer()
}
Lifecycle.Event.ON_RESUME -> {
if(player == null) {
initializePlayer()
}
}
Lifecycle.Event.ON_DESTROY -> {
Lifecycle.Event.ON_STOP, Lifecycle.Event.ON_DESTROY -> {
releasePlayer()
listeners.clear()
}
else -> {}
}
}
fun playPauseAudio(audio: Playable) {
fun playPauseAudio(audio: Audio) {
// intention is to pause current audio
if(audio == this.audio && player?.isPlaying == true) {
@ -176,31 +157,15 @@ class AudioPlayerConnector(
return
}
this.audio = audio
mediaItem = MediaItem.fromUri(audio.uri)
mediaItem = MediaItem.fromUri(Uri.parse(audio.downloadUrl))
player?.setMediaItem(mediaItem!!)
player?.playWhenReady = true
}
fun stopPlayback() {
ended = true
player?.pause()
stopTimer()
}
interface Listener {
fun onPlaying(isPlaying: Boolean, audio: Playable)
fun isLoading(isLoading: Boolean, audio: Playable)
fun progressChanged(second: Int, audio: Playable)
fun durationChanged(duration: Int, audio: Playable)
fun onEnded(audio: Playable)
}
open class DefaultListener() : Listener {
override fun onPlaying(isPlaying: Boolean, audio: Playable) {}
override fun isLoading(isLoading: Boolean, audio: Playable) {}
override fun progressChanged(second: Int, audio: Playable) {}
override fun durationChanged(duration: Int, audio: Playable) {}
override fun onEnded(audio: Playable) {}
fun onPlaying(isPlaying: Boolean, audio: Audio)
fun isLoading(isLoading: Boolean, audio: Audio)
fun progressChanged(second: Int, audio: Audio)
fun durationChanged(duration: Int, audio: Audio)
}
}

View File

@ -1,54 +0,0 @@
package com.isolaatti.audio.recorder.presentation
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.isolaatti.audio.drafts.domain.AudioDraft
import com.isolaatti.audio.drafts.domain.use_case.SaveAudioDraft
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import java.util.UUID
import javax.inject.Inject
@HiltViewModel
class AudioRecorderViewModel @Inject constructor(
private val saveAudioDraftUC: SaveAudioDraft
) : ViewModel() {
var name: String = ""
set(value) {
field = value
validate()
}
var relativePath = ""
var size: Long = 0
private var audioRecorded = false
val audioDraft: MutableLiveData<AudioDraft> = MutableLiveData()
val canSave: MutableLiveData<Boolean> = MutableLiveData()
private fun validate() {
canSave.value = audioRecorded && name.isNotBlank()
}
fun onAudioRecorded() {
audioRecorded = true
validate()
}
fun onClearAudio() {
audioRecorded = false
validate()
}
fun saveAudioDraft() {
viewModelScope.launch {
saveAudioDraftUC(name, relativePath, size).onEach {
audioDraft.postValue(it)
}.flowOn(Dispatchers.IO).launchIn(this)
}
}
}

View File

@ -1,21 +0,0 @@
package com.isolaatti.audio.recorder.presentation
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.isolaatti.audio.drafts.ui.AudioDraftsFragment
import com.isolaatti.audio.recorder.ui.AudioRecorderHelperNotesFragment
class RecorderPagerAdapter(fragmentActivity: FragmentActivity) : FragmentStateAdapter(fragmentActivity) {
override fun getItemCount(): Int {
return 2
}
override fun createFragment(position: Int): Fragment {
return when (position) {
0 -> AudioRecorderHelperNotesFragment()
1 -> AudioDraftsFragment()
else -> Fragment()
}
}
}

View File

@ -0,0 +1,6 @@
package com.isolaatti.audio.recorder.ui
import androidx.fragment.app.Fragment
class AudioDraftsFragment : Fragment() {
}

View File

@ -1,337 +1,6 @@
package com.isolaatti.audio.recorder.ui
import android.Manifest
import android.content.Intent
import android.content.res.ColorStateList
import android.content.res.Resources.Theme
import android.media.AudioManager
import android.media.MediaRecorder
import android.net.Uri
import android.os.Bundle
import android.provider.Settings
import android.util.Log
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.PermissionChecker
import androidx.core.content.PermissionChecker.PERMISSION_GRANTED
import androidx.core.content.res.ResourcesCompat
import androidx.core.net.toUri
import androidx.core.view.get
import androidx.core.widget.doOnTextChanged
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.dialog.MaterialDialogs
import com.google.android.material.tabs.TabLayoutMediator
import com.isolaatti.R
import com.isolaatti.audio.common.domain.Playable
import com.isolaatti.audio.drafts.domain.AudioDraft
import com.isolaatti.audio.player.AudioPlayerConnector
import com.isolaatti.audio.recorder.presentation.AudioRecorderViewModel
import com.isolaatti.audio.recorder.presentation.RecorderPagerAdapter
import com.isolaatti.databinding.ActivityAudioRecorderBinding
import com.isolaatti.utils.Resource
import com.isolaatti.utils.clockFormat
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.io.File
import java.io.IOException
import java.util.UUID
@AndroidEntryPoint
class AudioRecorderActivity : AppCompatActivity() {
companion object {
const val LOG_TAG = "AudioRecorderActivity"
const val IN_EXTRA_DRAFT_ID = "in_draft_id"
const val OUT_EXTRA_DRAFT = "out_draft"
}
private lateinit var binding: ActivityAudioRecorderBinding
private var audioRecorder: MediaRecorder? = null
private var recordingPaused = false
private var audioPlayerConnector: AudioPlayerConnector? = null
private val viewModel: AudioRecorderViewModel by viewModels()
private lateinit var outputFile: String
private val requestAudioPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) {
if(it) {
startRecording()
} else {
showPermissionRationale()
}
}
private val audioPlayerListener = object: AudioPlayerConnector.DefaultListener() {
override fun onPlaying(isPlaying: Boolean, audio: Playable) {
if(isPlaying) {
binding.seekbar.isEnabled = true
binding.playPauseButton.icon = ResourcesCompat.getDrawable(resources, R.drawable.baseline_pause_24, theme)
} else {
binding.playPauseButton.icon = ResourcesCompat.getDrawable(resources, R.drawable.baseline_play_arrow_24, theme)
}
}
override fun onEnded(audio: Playable) {
binding.seekbar.isEnabled = false
binding.seekbar.progress = 0
}
override fun durationChanged(duration: Int, audio: Playable) {
super.durationChanged(duration, audio)
binding.seekbar.max = duration
totalTime = duration
}
override fun progressChanged(second: Int, audio: Playable) {
binding.seekbar.progress = second
setDisplayTime(second, true)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityAudioRecorderBinding.inflate(layoutInflater)
setContentView(binding.root)
// Path where results are stored
File("${filesDir.absolutePath}/audios/").let {
if(!it.isDirectory) {
it.mkdir()
}
}
binding.seekbar.isEnabled = false
outputFile = "${cacheDir.absolutePath}/audio_recorder.3gp"
setupListeners()
setupObservers()
audioPlayerConnector = AudioPlayerConnector(this)
audioPlayerConnector?.addListener(audioPlayerListener)
lifecycle.addObserver(audioPlayerConnector!!)
}
private fun checkRecordAudioPermission(): Boolean {
return when {
PermissionChecker.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PERMISSION_GRANTED -> true
ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.RECORD_AUDIO) -> {
showPermissionRationale()
false
}
else -> {
requestAudioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
false
}
}
}
private fun showPermissionRationale() {
MaterialAlertDialogBuilder(this)
.setTitle("Record audio permission")
.setMessage("We need permission to access your microphone so that you can record your audio. Go to settings.")
.setPositiveButton("Go to settings"){_, _ ->
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intent.data = Uri.fromParts("package", packageName, null)
startActivity(intent)
}
.setNegativeButton("No, thanks", null)
.show()
}
private fun setupListeners() {
binding.recordButton.setOnClickListener {
if(checkRecordAudioPermission()) {
Log.d(LOG_TAG, "Starts recording")
startRecording()
} else {
Log.d(LOG_TAG, "Failed to start recording: mic permission not granted")
}
}
binding.stopRecording.setOnClickListener {
stopRecording()
}
binding.cancelButton.setOnClickListener {
discardRecording()
}
binding.pauseRecording.setOnClickListener {
pauseRecording()
}
binding.playPauseButton.setOnClickListener {
playPauseRecording(object: Playable() {
override val uri: Uri
get() = outputFile.toUri()
override val thumbnail: String?
get() = null
})
}
binding.inputDraftName.editText?.doOnTextChanged { text, _, _, _ ->
viewModel.name = text.toString()
}
binding.toolbar.setOnMenuItemClickListener {
when(it.itemId) {
R.id.save_draft -> {
viewModel.relativePath = "/audios/${UUID.randomUUID()}.3gp"
File(outputFile).run {
viewModel.size = length()
copyTo(File(filesDir.absolutePath, viewModel.relativePath), overwrite = true)
}
viewModel.saveAudioDraft()
true
}
else -> false
}
}
binding.toolbar.setNavigationOnClickListener {
finish()
}
}
private fun setupObservers() {
viewModel.canSave.observe(this) {
binding.toolbar.menu.findItem(R.id.save_draft).isEnabled = it
}
// audio draft is saved!
viewModel.audioDraft.observe(this) {
val result = Intent().apply {
putExtra(OUT_EXTRA_DRAFT, it)
}
setResult(RESULT_OK, result)
finish()
}
}
// region timer
private var timer: Job? = null
private var timerValue = 0
private var totalTime = 0
private fun setDisplayTime(seconds: Int, showTotalTime: Boolean) {
binding.time.text = buildString {
append(seconds.clockFormat())
if(showTotalTime) {
append("/")
append(totalTime.clockFormat())
}
}
}
private fun startTimerRecorder() {
timer = CoroutineScope(Dispatchers.Main).launch {
setDisplayTime(timerValue, false)
delay(1000)
timerValue++
startTimerRecorder()
}
}
private fun stopTimer() {
timer?.cancel()
}
// end region
// region record functions
private fun startRecording() {
viewModel.onClearAudio()
recordingPaused = false
audioRecorder = MediaRecorder().apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.THREE_GPP)
setOutputFile(outputFile)
setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB)
try {
prepare()
start()
timerValue = 0
startTimerRecorder()
binding.viewAnimator.displayedChild = 1
} catch(e: IOException) {
Log.e(LOG_TAG, "prepare() failed\n${e.message}")
}
}
}
private fun stopRecording() {
audioRecorder?.apply {
stop()
release()
}
stopTimer()
audioRecorder = null
// shows third state: audio recorded
binding.viewAnimator.displayedChild = 2
recordingPaused = false
binding.pauseRecording.icon = ResourcesCompat.getDrawable(resources, R.drawable.baseline_pause_24, theme)
binding.pauseRecording.setIconTintResource(com.google.android.material.R.color.m3_icon_button_icon_color_selector)
viewModel.onAudioRecorded()
}
private fun pauseRecording() {
if(recordingPaused) {
binding.pauseRecording.icon = ResourcesCompat.getDrawable(resources, R.drawable.baseline_pause_24, theme)
binding.pauseRecording.setIconTintResource(com.google.android.material.R.color.m3_icon_button_icon_color_selector)
audioRecorder?.resume()
startTimerRecorder()
} else {
binding.pauseRecording.icon = ResourcesCompat.getDrawable(resources, R.drawable.baseline_circle_24, theme)
binding.pauseRecording.setIconTintResource(R.color.danger)
audioRecorder?.pause()
stopTimer()
}
recordingPaused = !recordingPaused
}
private fun discardRecording(){
File(outputFile).apply {
try {
delete()
} catch(e: SecurityException) {
Log.e(LOG_TAG, "Could not delete file\n${e.message}")
}
}
binding.viewAnimator.displayedChild = 0
viewModel.onClearAudio()
totalTime = 0
timerValue = 0
binding.time.text = ""
audioPlayerConnector?.stopPlayback()
}
// end region
private fun playPauseRecording(audioDraft: Playable) {
audioPlayerConnector?.playPauseAudio(audioDraft)
}
import androidx.activity.ComponentActivity
class AudioRecorderActivity : ComponentActivity() {
}

View File

@ -1,29 +0,0 @@
package com.isolaatti.audio.recorder.ui
import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.activity.result.contract.ActivityResultContract
import com.isolaatti.audio.drafts.domain.AudioDraft
class AudioRecorderContract : ActivityResultContract<Long?, AudioDraft?>() {
override fun createIntent(context: Context, input: Long?): Intent {
val intent = Intent(context, AudioRecorderActivity::class.java).apply {
if(input != null) {
putExtra(AudioRecorderActivity.IN_EXTRA_DRAFT_ID, input)
}
}
return intent
}
override fun parseResult(resultCode: Int, intent: Intent?): AudioDraft? {
return when(resultCode){
Activity.RESULT_OK -> {
intent?.getSerializableExtra(AudioRecorderActivity.OUT_EXTRA_DRAFT) as? AudioDraft
}
else -> null
}
}
}

View File

@ -1,25 +0,0 @@
package com.isolaatti.audio.recorder.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import androidx.fragment.app.Fragment
class AudioRecorderHelperNotesFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return LinearLayout(requireContext()).apply {
addView(TextView(requireContext()).apply {
text = "Allow the user to place some info here to be " +
"reading while recording. If this screen is launched from comments, show here the post"
})
}
}
}

View File

@ -0,0 +1,8 @@
package com.isolaatti.audio.recorder.ui
import android.media.MediaRecorder
import androidx.fragment.app.Fragment
class AudioRecorderMainFragment : Fragment() {
}

View File

@ -19,17 +19,4 @@ object CoilImageLoader {
add(SvgDecoder.Factory())
}.build()
}
fun getImageLoader(context: Context): ImageLoader {
return ImageLoader
.Builder(context)
.memoryCache {
MemoryCache.Builder(context)
.maxSizePercent(0.25)
.build()
}
.components {
add(SvgDecoder.Factory())
}.build()
}
}

View File

@ -6,6 +6,7 @@ import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
@ -13,7 +14,9 @@ import androidx.lifecycle.Observer
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.isolaatti.R
import com.isolaatti.connectivity.ConnectivityCallbackImpl
import com.isolaatti.connectivity.NetworkStatus
import com.isolaatti.home.HomeActivity
import com.isolaatti.login.LogInActivity
import com.isolaatti.utils.Resource
import dagger.hilt.android.AndroidEntryPoint

View File

@ -1,27 +0,0 @@
package com.isolaatti.common
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.colorResource
import com.isolaatti.R
@Composable
fun IsolaattiTheme(content: @Composable () -> Unit) {
val colorScheme = if(isSystemInDarkTheme()) {
darkColorScheme(
primary = colorResource(R.color.purple),
onSurface = colorResource(R.color.on_surface)
)
} else {
lightColorScheme(
primary = colorResource(R.color.purple),
onSurface = colorResource(R.color.on_surface)
)
}
MaterialTheme(colorScheme = colorScheme) {
content()
}
}

View File

@ -1,14 +1,8 @@
package com.isolaatti.common
import com.isolaatti.audio.common.domain.Audio
interface OnUserInteractedWithPostCallback : OnUserInteractedCallback {
fun onLiked(postId: Long)
fun onUnLiked(postId: Long)
fun onComment(postId: Long)
fun onOpenPost(postId: Long)
fun onPlay(audio: Audio)
fun onMoreInfo(postId: Long)
fun onShare(postId: Long)
fun hashtagClicked(hashtag: String)
}

View File

@ -1,8 +0,0 @@
package com.isolaatti.common
enum class SortingEnum {
AscendingByName,
DescendingByName,
AscendingByCreationDate,
DescendingByCreationDate
}

View File

@ -1,4 +0,0 @@
package com.isolaatti.common
val hashtagRegex = "#(\\w|-|_)+".toRegex()
val userTagRegex = "@(\\w|-|_)+".toRegex()

View File

@ -23,7 +23,6 @@ data class Options(
const val OPTION_PROFILE_PHOTO_VIEW_PHOTO = 6
const val OPTION_PROFILE_PHOTO_CHANGE_PHOTO = 7
const val OPTION_PROFILE_PHOTO_REMOVE_PHOTO = 8
const val OPTION_PROFILE_DESCRIPTION_SET_AUDIO = 9
}
}
@ -31,7 +30,6 @@ data class Options(
const val POST_OPTIONS = 1
const val COMMENT_OPTIONS = 2
const val PROFILE_PHOTO_OPTIONS = 3
const val PROFILE_DESCRIPTION_OPTIONS = 4
val noOptions = Options(0, 0, listOf())
@ -95,10 +93,5 @@ data class Options(
return Options(R.string.profile_photo,PROFILE_PHOTO_OPTIONS, list)
}
fun getProfileDescriptionOptions(): Options {
val list = listOf(Option(R.string.set_audio_description, R.drawable.baseline_audio_file_24, Option.OPTION_PROFILE_DESCRIPTION_SET_AUDIO))
return Options(R.string.description, PROFILE_DESCRIPTION_OPTIONS, list)
}
}
}

View File

@ -9,7 +9,6 @@ import com.isolaatti.common.options_bottom_sheet.domain.OptionClicked
import com.isolaatti.common.options_bottom_sheet.domain.Options
import com.isolaatti.common.options_bottom_sheet.domain.Options.Companion.COMMENT_OPTIONS
import com.isolaatti.common.options_bottom_sheet.domain.Options.Companion.POST_OPTIONS
import com.isolaatti.common.options_bottom_sheet.domain.Options.Companion.PROFILE_DESCRIPTION_OPTIONS
import com.isolaatti.common.options_bottom_sheet.domain.Options.Companion.PROFILE_PHOTO_OPTIONS
import com.isolaatti.settings.domain.UserIdSetting
import dagger.hilt.android.lifecycle.HiltViewModel
@ -41,8 +40,6 @@ class BottomSheetPostOptionsViewModel @Inject constructor(private val userIdSett
fun setOptions(options: Int, callerId: Int, payload: Ownable? = null) {
viewModelScope.launch {
CoroutineScope(Dispatchers.IO).launch {
_payload = payload
_callerId = callerId
when(options) {
POST_OPTIONS -> {
userIdSetting.getUserId().onEach { userId ->
@ -52,6 +49,8 @@ class BottomSheetPostOptionsViewModel @Inject constructor(private val userIdSett
savable = false,
snapshotAble = false)
)
_callerId = callerId
_payload = payload
}.flowOn(Dispatchers.IO).launchIn(this)
}
COMMENT_OPTIONS -> {
@ -62,16 +61,17 @@ class BottomSheetPostOptionsViewModel @Inject constructor(private val userIdSett
savable = false,
snapshotAble = false)
)
_callerId = callerId
_payload = payload
}.flowOn(Dispatchers.IO).launchIn(this)
}
PROFILE_PHOTO_OPTIONS -> {
userIdSetting.getUserId().onEach { userId ->
_options.postValue(Options.getProfilePhotoOptions(userOwned = userId == payload?.userId,))
_callerId = callerId
_payload = payload
}.flowOn(Dispatchers.IO).launchIn(this)
}
PROFILE_DESCRIPTION_OPTIONS -> {
_options.postValue(Options.getProfileDescriptionOptions())
}
}
}

View File

@ -26,12 +26,6 @@ class RetrofitClient @Inject constructor(private val authenticationInterceptor:
private val okHttpClient get() = OkHttpClient.Builder()
.addInterceptor(authenticationInterceptor)
.addInterceptor { chain ->
chain.proceed(chain.request()
.newBuilder()
.header("User-Agent", "Isolaatti Android ${BuildConfig.VERSION_NAME}")
.build())
}
.connectTimeout(5, TimeUnit.MINUTES)
.writeTimeout(5, TimeUnit.MINUTES)
.readTimeout(5, TimeUnit.MINUTES)

View File

@ -2,22 +2,14 @@ package com.isolaatti.database
import androidx.room.Database
import androidx.room.RoomDatabase
import com.isolaatti.audio.drafts.data.AudioDraftEntity
import com.isolaatti.audio.drafts.data.AudiosDraftsDao
import com.isolaatti.auth.data.local.UserInfoDao
import com.isolaatti.auth.data.local.UserInfoEntity
import com.isolaatti.images.common.data.dao.ImagesDraftsDao
import com.isolaatti.images.common.data.entity.ImageDraftEntity
import com.isolaatti.search.data.SearchDao
import com.isolaatti.search.data.SearchHistoryEntity
import com.isolaatti.settings.data.KeyValueDao
import com.isolaatti.settings.data.KeyValueEntity
@Database(entities = [KeyValueEntity::class, UserInfoEntity::class, AudioDraftEntity::class, SearchHistoryEntity::class, ImageDraftEntity::class], version = 8)
@Database(entities = [KeyValueEntity::class, UserInfoEntity::class], version = 2)
abstract class AppDatabase : RoomDatabase() {
abstract fun keyValueDao(): KeyValueDao
abstract fun userInfoDao(): UserInfoDao
abstract fun audioDrafts(): AudiosDraftsDao
abstract fun searchHistoryDao(): SearchDao
abstract fun imagesDraftsDao(): ImagesDraftsDao
}

View File

@ -4,16 +4,15 @@ import androidx.fragment.app.Fragment
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.isolaatti.followers.ui.FollowersFragment
import com.isolaatti.followers.ui.FollowingFragment
import com.isolaatti.profile.profile_listing.ui.ProfileListingFragment
class FollowersViewPagerAdapter(fragment: Fragment, private val userId: Int) : FragmentStateAdapter(fragment) {
class FollowersViewPagerAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) {
override fun getItemCount(): Int = 2
override fun createFragment(position: Int): Fragment {
if(position == 0) {
return ProfileListingFragment.getInstanceForFollowers(userId)
return FollowersFragment()
}
return ProfileListingFragment.getInstanceForFollowing(userId)
return FollowingFragment()
}
}

View File

@ -10,13 +10,13 @@ import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.LinearLayoutManager
import com.isolaatti.common.UserItemCallback
import com.isolaatti.common.UserListRecyclerViewAdapter
import com.isolaatti.databinding.FragmentUserListBinding
import com.isolaatti.databinding.FragmentFollowersBinding
import com.isolaatti.followers.presentation.FollowersViewModel
import com.isolaatti.profile.domain.entity.ProfileListItem
import com.isolaatti.profile.ui.ProfileActivity
class FollowersFragment : Fragment(), UserItemCallback {
private lateinit var binding: FragmentUserListBinding
private lateinit var binding: FragmentFollowersBinding
private val viewModel: FollowersViewModel by viewModels({ requireParentFragment() })
private lateinit var adapter: UserListRecyclerViewAdapter
@ -25,7 +25,7 @@ class FollowersFragment : Fragment(), UserItemCallback {
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentUserListBinding.inflate(inflater)
binding = FragmentFollowersBinding.inflate(inflater)
return binding.root
}

View File

@ -10,13 +10,13 @@ import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.LinearLayoutManager
import com.isolaatti.common.UserItemCallback
import com.isolaatti.common.UserListRecyclerViewAdapter
import com.isolaatti.databinding.FragmentUserListBinding
import com.isolaatti.databinding.FragmentFollowersBinding
import com.isolaatti.followers.presentation.FollowersViewModel
import com.isolaatti.profile.domain.entity.ProfileListItem
import com.isolaatti.profile.ui.ProfileActivity
class FollowingFragment : Fragment(), UserItemCallback {
private lateinit var binding: FragmentUserListBinding
private lateinit var binding: FragmentFollowersBinding
private val viewModel: FollowersViewModel by viewModels({ requireParentFragment() })
private lateinit var adapter: UserListRecyclerViewAdapter
@ -27,7 +27,7 @@ class FollowingFragment : Fragment(), UserItemCallback {
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentUserListBinding.inflate(inflater)
binding = FragmentFollowersBinding.inflate(inflater)
return binding.root
}

View File

@ -19,6 +19,7 @@ import dagger.hilt.android.AndroidEntryPoint
class MainFollowersFragment : Fragment() {
private lateinit var binding: FragmentFollowersMainBinding
private val viewModel: FollowersViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater,
@ -32,9 +33,12 @@ class MainFollowersFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val userId = arguments?.getInt(ARGUMENT_USER_ID) ?: 0
binding.viewPagerFollowersMain.adapter = FollowersViewPagerAdapter(this, userId)
arguments?.getInt(ARGUMENT_USER_ID)?.let {
viewModel.userId = it
}
binding.viewPagerFollowersMain.adapter = FollowersViewPagerAdapter(this)
TabLayoutMediator(binding.tabLayoutFollowers, binding.viewPagerFollowersMain) { tab, position ->
when(position) {
0 -> tab.text = getText(R.string.followers)

View File

@ -1,12 +0,0 @@
package com.isolaatti.hashtags.data
import com.isolaatti.common.ResultDto
import com.isolaatti.posting.posts.data.remote.FeedDto
import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
interface HashtagsApi {
}

View File

@ -1,35 +0,0 @@
package com.isolaatti.hashtags.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import com.isolaatti.databinding.FragmentHashtagsBinding
class HashtagsFragment : Fragment() {
private lateinit var binding: FragmentHashtagsBinding
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentHashtagsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupListeners()
}
private fun setupListeners() {
binding.toolbar.setNavigationOnClickListener {
findNavController().popBackStack()
}
}
}

View File

@ -1,46 +0,0 @@
package com.isolaatti.hashtags.ui
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.navigation.fragment.NavHostFragment
import com.isolaatti.R
import com.isolaatti.databinding.ActivityPostsHashtagBinding
import com.isolaatti.posting.posts.ui.PostListingFragment
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class HashtagsPostsActivity : AppCompatActivity() {
lateinit var binding: ActivityPostsHashtagBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityPostsHashtagBinding.inflate(layoutInflater)
setContentView(binding.root)
setupListeners()
val hashtag = intent.extras?.getString(EXTRA_HASHTAG)!!
binding.toolbar.title = "#$hashtag"
(supportFragmentManager.findFragmentById(R.id.post_list_fragment_container) as NavHostFragment)
.navController.setGraph(R.navigation.post_listing_navigation, Bundle().apply { putString(
PostListingFragment.ARG_HASHTAG, hashtag) })
}
private fun setupListeners() {
binding.toolbar.setNavigationOnClickListener {
onBackPressedDispatcher.onBackPressed()
}
}
companion object {
const val EXTRA_HASHTAG = "hashtag"
fun startActivity(context: Context, hashtag: String) {
val intent = Intent(context, HashtagsPostsActivity::class.java)
intent.putExtra(EXTRA_HASHTAG, hashtag)
context.startActivity(intent)
}
}
}

View File

@ -1,43 +1,49 @@
package com.isolaatti.posting.posts.ui
package com.isolaatti.home
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import coil.load
import com.google.android.material.button.MaterialButton
import com.google.android.material.card.MaterialCardView
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.audio.player.AudioPlayerConnector
import com.isolaatti.common.CoilImageLoader
import com.isolaatti.about.AboutActivity
import com.isolaatti.common.CoilImageLoader.imageLoader
import com.isolaatti.common.Dialogs
import com.isolaatti.common.ErrorMessageViewModel
import com.isolaatti.databinding.FragmentFeedBinding
import com.isolaatti.drafts.ui.DraftsActivity
import com.isolaatti.home.presentation.FeedViewModel
import com.isolaatti.images.picture_viewer.ui.PictureViewerActivity
import com.isolaatti.posting.posts.viewer.ui.PostViewerActivity
import com.isolaatti.posting.comments.ui.BottomSheetPostComments
import com.isolaatti.common.OnUserInteractedWithPostCallback
import com.isolaatti.common.Ownable
import com.isolaatti.common.options_bottom_sheet.domain.OptionClicked
import com.isolaatti.common.options_bottom_sheet.domain.Options
import com.isolaatti.common.options_bottom_sheet.presentation.BottomSheetPostOptionsViewModel
import com.isolaatti.common.options_bottom_sheet.ui.BottomSheetPostOptionsFragment
import com.isolaatti.databinding.FragmentPostListingBinding
import com.isolaatti.hashtags.ui.HashtagsPostsActivity
import com.isolaatti.home.ui.FeedFragment
import com.isolaatti.posting.comments.ui.BottomSheetPostComments
import com.isolaatti.posting.posts.domain.entity.Post
import com.isolaatti.posting.posts.presentation.CreatePostContract
import com.isolaatti.posting.posts.presentation.EditPostContract
import com.isolaatti.posting.posts.presentation.PostListingViewModel
import com.isolaatti.posting.posts.presentation.PostsRecyclerViewAdapter
import com.isolaatti.posting.posts.viewer.ui.PostViewerActivity
import com.isolaatti.profile.ui.ProfileActivity
import com.isolaatti.reports.data.ContentType
import com.isolaatti.reports.ui.NewReportBottomSheetDialogFragment
import com.isolaatti.settings.ui.SettingsActivity
import com.isolaatti.utils.UrlGen
import dagger.hilt.android.AndroidEntryPoint
import io.noties.markwon.AbstractMarkwonPlugin
import io.noties.markwon.Markwon
@ -45,96 +51,161 @@ import io.noties.markwon.MarkwonConfiguration
import io.noties.markwon.image.coil.CoilImagesPlugin
import io.noties.markwon.image.destination.ImageDestinationProcessorRelativeToAbsolute
import io.noties.markwon.linkify.LinkifyPlugin
import kotlinx.coroutines.launch
import org.w3c.dom.Text
@AndroidEntryPoint
class PostListingFragment : Fragment(), OnUserInteractedWithPostCallback {
class FeedFragment : Fragment(), OnUserInteractedWithPostCallback {
companion object {
const val ARG_HASHTAG = "hashtag"
const val LOG_TAG = "PostListingFragment"
fun newInstance() = FeedFragment()
const val CALLER_ID = 20
}
var hashtag: String? = null
private lateinit var viewBinding: FragmentPostListingBinding
private val errorViewModel: ErrorMessageViewModel by activityViewModels()
private val viewModel: PostListingViewModel by viewModels()
private val viewModel: FeedViewModel by activityViewModels()
val optionsViewModel: BottomSheetPostOptionsViewModel by activityViewModels()
private var currentUserId = 0
private lateinit var viewBinding: FragmentFeedBinding
private lateinit var adapter: PostsRecyclerViewAdapter
private lateinit var audioPlayerConnector: AudioPlayerConnector
private val audioPlayerConnectorListener = object: AudioPlayerConnector.Listener {
override fun onPlaying(isPlaying: Boolean, audio: Playable) {
if(audio is Audio)
adapter.setIsPlaying(isPlaying, audio)
// region launchers
private val createDiscussion = registerForActivityResult(CreatePostContract()) {
if(it != null) {
Toast.makeText(requireContext(), R.string.posted_successfully, Toast.LENGTH_SHORT).show()
}
}
override fun isLoading(isLoading: Boolean, audio: Playable) {
if(audio is Audio)
adapter.setIsLoading(isLoading, audio)
private val editDiscussion = registerForActivityResult(EditPostContract()) {
if(it != null) {
viewModel.onPostUpdate(it)
}
}
override fun progressChanged(second: Int, audio: Playable) {
if(audio is Audio)
adapter.setProgress(second, audio)
}
// endregion
override fun durationChanged(duration: Int, audio: Playable) {
if(audio is Audio)
adapter.setDuration(duration, audio)
}
// region observers
override fun onEnded(audio: Playable) {
if(audio is Audio)
adapter.setEnded(audio)
private val optionsObserver: Observer<OptionClicked?> = Observer { optionClicked ->
if(optionClicked?.callerId == CALLER_ID) {
// post id should come as payload
val post = optionClicked.payload as? Post ?: return@Observer
when(optionClicked.optionId) {
Options.Option.OPTION_DELETE -> {
Dialogs.buildDeletePostDialog(requireContext()) { delete ->
optionsViewModel.handle()
if(delete) {
viewModel.deletePost(post.id)
}
}.show()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.d(LOG_TAG, "onCreate ; hashtag: $hashtag")
hashtag = arguments?.getString(ARG_HASHTAG)
viewModel.getFeed(false, hashtag)
Options.Option.OPTION_EDIT -> {
optionsViewModel.handle()
editDiscussion.launch(post.id)
}
Options.Option.OPTION_REPORT -> {
optionsViewModel.handle()
}
}
}
}
// endregion
// region lifecycle
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
viewBinding = FragmentPostListingBinding.inflate(inflater, container, false)
): View? {
viewBinding = FragmentFeedBinding.inflate(inflater)
return viewBinding.root
}
// endregion
// region events
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewBinding.topAppBar.setNavigationOnClickListener {
viewBinding.drawerLayout?.openDrawer(viewBinding.homeDrawer)
}
viewBinding.homeDrawer.setNavigationItemSelectedListener {
when(it.itemId) {
R.id.settings_menu_item -> {
startActivity(Intent(requireActivity(), SettingsActivity::class.java))
true
}
R.id.about_menu_item -> {
startActivity(Intent(requireActivity(), AboutActivity::class.java))
true
}
else -> {true}
}
}
audioPlayerConnector = AudioPlayerConnector(requireContext())
audioPlayerConnector.addListener(audioPlayerConnectorListener)
viewLifecycleOwner.lifecycle.addObserver(audioPlayerConnector)
val markwon = Markwon.builder(requireContext())
.usePlugin(object: AbstractMarkwonPlugin() {
override fun configureConfiguration(builder: MarkwonConfiguration.Builder) {
builder
.imageDestinationProcessor(
ImageDestinationProcessorRelativeToAbsolute
.imageDestinationProcessor(ImageDestinationProcessorRelativeToAbsolute
.create(BuildConfig.backend))
}
})
.usePlugin(CoilImagesPlugin.create(requireContext(), CoilImageLoader.imageLoader))
.usePlugin(CoilImagesPlugin.create(requireContext(), imageLoader))
.usePlugin(LinkifyPlugin.create())
.build()
adapter = PostsRecyclerViewAdapter(this)
adapter = PostsRecyclerViewAdapter(markwon, this)
viewBinding.feedRecyclerView.adapter = adapter
viewBinding.feedRecyclerView.setItemViewCacheSize(7)
viewBinding.feedRecyclerView.layoutManager = LinearLayoutManager(requireContext())
viewBinding.swipeToRefresh.setOnRefreshListener {
viewModel.getFeed(refresh = true, hashtag)
viewModel.getFeed(refresh = true)
}
viewBinding.topAppBar.setOnMenuItemClickListener {
when(it.itemId) {
R.id.menu_item_new_discussion -> {
createDiscussion.launch(Unit)
true
}
else -> {
false
}
}
}
viewModel.userProfile.observe(viewLifecycleOwner) {
val header = viewBinding.homeDrawer.getHeaderView(0) as? ConstraintLayout
val image: ImageView? = header?.findViewById(R.id.profileImageView)
val textViewName: TextView? = header?.findViewById(R.id.textViewName)
val textViewEmail: TextView? = header?.findViewById(R.id.textViewEmail)
val textViewUsername: TextView? = header?.findViewById(R.id.textViewUsername)
image?.load(UrlGen.userProfileImage(it.userId), imageLoader)
val card: MaterialCardView? = header?.findViewById(R.id.drawer_header_card)
card?.setOnClickListener {
ProfileActivity.startActivity(requireContext(), currentUserId)
}
textViewName?.text = it.name
textViewEmail?.text = it.email
textViewUsername?.text = "@${it.uniqueUsername}"
currentUserId = it.userId
}
viewModel.posts.observe(viewLifecycleOwner){
if (it?.first != null) {
adapter.updateList(it.first!!, it.second)
@ -154,7 +225,18 @@ class PostListingFragment : Fragment(), OnUserInteractedWithPostCallback {
errorViewModel.error.postValue(it)
}
viewModel.getProfile()
optionsViewModel.optionClicked.observe(viewLifecycleOwner, optionsObserver)
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
errorViewModel.retry.collect {
viewModel.retry()
errorViewModel.handleRetry()
}
}
}
}
override fun onLiked(postId: Long) = viewModel.likePost(postId)
@ -162,7 +244,7 @@ class PostListingFragment : Fragment(), OnUserInteractedWithPostCallback {
override fun onUnLiked(postId: Long) = viewModel.unLikePost(postId)
override fun onOptions(post: Ownable) {
optionsViewModel.setOptions(Options.POST_OPTIONS, FeedFragment.CALLER_ID, post)
optionsViewModel.setOptions(Options.POST_OPTIONS, CALLER_ID, post)
val modalBottomSheet = BottomSheetPostOptionsFragment()
modalBottomSheet.show(requireActivity().supportFragmentManager, BottomSheetPostOptionsFragment.TAG)
}
@ -176,67 +258,13 @@ class PostListingFragment : Fragment(), OnUserInteractedWithPostCallback {
PostViewerActivity.startActivity(requireContext(), postId)
}
override fun onPlay(audio: Audio) {
audioPlayerConnector.playPauseAudio(audio)
}
override fun onMoreInfo(postId: Long) {
PostInfoActivity.startActivity(requireContext(), postId)
}
override fun onShare(postId: Long) {
val intent = Intent.createChooser(Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, "${BuildConfig.backend}/pub/${postId}")
type = "text/plain"
}, getString(R.string.share_post))
startActivity(intent)
}
override fun hashtagClicked(hashtag: String) {
HashtagsPostsActivity.startActivity(requireContext(), hashtag)
}
override fun onProfileClick(userId: Int) {
ProfileActivity.startActivity(requireContext(), userId)
}
override fun onLoadMore() {
viewModel.getFeed(false, hashtag)
viewModel.getFeed(false)
}
private val editDiscussion = registerForActivityResult(EditPostContract()) {
if(it != null) {
viewModel.onPostUpdate(it)
}
}
private val optionsObserver: Observer<OptionClicked?> = Observer { optionClicked ->
if(optionClicked?.callerId == FeedFragment.CALLER_ID) {
// post id should come as payload
val post = optionClicked.payload as? Post ?: return@Observer
when(optionClicked.optionId) {
Options.Option.OPTION_DELETE -> {
Dialogs.buildDeletePostDialog(requireContext()) { delete ->
optionsViewModel.handle()
if(delete) {
viewModel.deletePost(post.id)
}
}.show()
}
Options.Option.OPTION_EDIT -> {
optionsViewModel.handle()
editDiscussion.launch(post.id)
}
Options.Option.OPTION_REPORT -> {
optionsViewModel.handle()
NewReportBottomSheetDialogFragment.newInstance(ContentType.Post, post.id.toString())
.show(childFragmentManager, NewReportBottomSheetDialogFragment.LOG_TAG)
}
}
}
}
// endregion
}

View File

@ -0,0 +1,40 @@
package com.isolaatti.home
import android.os.Bundle
import android.view.Menu
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Observer
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.setupWithNavController
import com.isolaatti.R
import com.isolaatti.common.IsolaattiBaseActivity
import com.isolaatti.databinding.ActivityHomeBinding
import com.isolaatti.home.presentation.FeedViewModel
class HomeActivity : IsolaattiBaseActivity() {
private lateinit var viewBinding: ActivityHomeBinding
private val feedViewModel: FeedViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewBinding = ActivityHomeBinding.inflate(layoutInflater)
setContentView(viewBinding.root)
val navHostFragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
viewBinding.bottomNavigation?.setupWithNavController(navHostFragment.navController)
viewBinding.navigationRail?.setupWithNavController(navHostFragment.navController)
if(savedInstanceState == null) {
feedViewModel.getFeed(false)
}
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
return super.onCreateOptionsMenu(menu)
}
override fun onDestroy() {
super.onDestroy()
}
}

View File

@ -0,0 +1,7 @@
package com.isolaatti.home.notifications.presentation
import androidx.lifecycle.ViewModel
class NotificationsViewModel : ViewModel() {
// TODO: Implement the ViewModel
}

View File

@ -0,0 +1,33 @@
package com.isolaatti.home.notifications.ui
import androidx.lifecycle.ViewModelProvider
import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.isolaatti.R
import com.isolaatti.home.notifications.presentation.NotificationsViewModel
class NotificationsFragment : Fragment() {
companion object {
fun newInstance() = NotificationsFragment()
}
private lateinit var viewModel: NotificationsViewModel
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_notifications, container, false)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
viewModel = ViewModelProvider(this).get(NotificationsViewModel::class.java)
// TODO: Use the ViewModel
}
}

View File

@ -2,12 +2,12 @@ package com.isolaatti.home.presentation
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.isolaatti.auth.domain.AuthRepository
import com.isolaatti.posting.posts.domain.PostsRepository
import com.isolaatti.posting.posts.presentation.PostListingViewModelBase
import com.isolaatti.posting.posts.presentation.UpdateEvent
import com.isolaatti.profile.data.remote.UserProfileDto
import com.isolaatti.profile.domain.entity.UserProfile
import com.isolaatti.profile.domain.use_case.GetProfile
import com.isolaatti.utils.Resource
@ -22,8 +22,9 @@ import javax.inject.Inject
@HiltViewModel
class FeedViewModel @Inject constructor(
private val getProfileUseCase: GetProfile,
private val authRepository: AuthRepository
) : ViewModel() {
private val authRepository: AuthRepository,
private val postsRepository: PostsRepository
) : PostListingViewModelBase() {
private val toRetry: MutableList<Runnable> = mutableListOf()
@ -38,7 +39,41 @@ class FeedViewModel @Inject constructor(
toRetry.clear()
}
override fun getFeed(refresh: Boolean) {
viewModelScope.launch {
if (refresh) {
posts.value = null
}
postsRepository.getFeed(getLastId()).onEach { listResource ->
when (listResource) {
is Resource.Success -> {
val eventType = if((postsList?.size ?: 0) > 0) UpdateEvent.UpdateType.PAGE_ADDED else UpdateEvent.UpdateType.REFRESH
loadingPosts.postValue(false)
posts.postValue(Pair(postsList?.apply {
addAll(listResource.data ?: listOf())
} ?: listResource.data,
UpdateEvent(eventType, null)))
noMoreContent.postValue(listResource.data?.size == 0)
}
is Resource.Loading -> {
if(!refresh)
loadingPosts.postValue(true)
}
is Resource.Error -> {
errorLoading.postValue(listResource.errorType)
toRetry.add {
getFeed(refresh)
}
}
}
isLoadingFromScrolling = false
}.flowOn(Dispatchers.IO).launchIn(this)
}
}
// User profile
private val _userProfile: MutableLiveData<UserProfile> = MutableLiveData()
@ -53,6 +88,7 @@ class FeedViewModel @Inject constructor(
when(profile) {
is Resource.Error -> {
errorLoading.postValue(profile.errorType)
toRetry.add {
getProfile()
}

View File

@ -0,0 +1,22 @@
package com.isolaatti.home.search.presentation
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import com.isolaatti.databinding.FragmentSearchBinding
class SearchFragment : Fragment() {
lateinit var viewBinding: FragmentSearchBinding
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
viewBinding = FragmentSearchBinding.inflate(inflater)
return viewBinding.root
}
}

View File

@ -1,149 +0,0 @@
package com.isolaatti.home.ui
import android.content.Intent
import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.findNavController
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.fragment.findNavController
import coil.load
import com.google.android.material.card.MaterialCardView
import com.isolaatti.R
import com.isolaatti.about.AboutActivity
import com.isolaatti.common.CoilImageLoader.imageLoader
import com.isolaatti.common.ErrorMessageViewModel
import com.isolaatti.databinding.FragmentFeedBinding
import com.isolaatti.home.presentation.FeedViewModel
import com.isolaatti.posting.posts.presentation.CreatePostContract
import com.isolaatti.posting.posts.ui.PostListingFragment
import com.isolaatti.profile.ui.ProfileActivity
import com.isolaatti.settings.ui.SettingsActivity
import com.isolaatti.utils.UrlGen
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
@AndroidEntryPoint
class FeedFragment : Fragment() {
companion object {
fun newInstance() = FeedFragment()
const val CALLER_ID = 20
}
private val errorViewModel: ErrorMessageViewModel by activityViewModels()
private val viewModel: FeedViewModel by activityViewModels()
private var currentUserId = 0
private lateinit var viewBinding: FragmentFeedBinding
private val createDiscussion = registerForActivityResult(CreatePostContract()) {
if(it != null) {
Toast.makeText(requireContext(), R.string.posted_successfully, Toast.LENGTH_SHORT).show()
}
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
viewBinding = FragmentFeedBinding.inflate(inflater)
return viewBinding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewBinding.topAppBar.setNavigationOnClickListener {
viewBinding.drawerLayout?.openDrawer(viewBinding.homeDrawer)
}
viewBinding.homeDrawer.setNavigationItemSelectedListener {
when(it.itemId) {
R.id.settings_menu_item -> {
startActivity(Intent(requireActivity(), SettingsActivity::class.java))
true
}
R.id.about_menu_item -> {
startActivity(Intent(requireActivity(), AboutActivity::class.java))
true
}
else -> {true}
}
}
viewBinding.topAppBar.setOnMenuItemClickListener {
when(it.itemId) {
R.id.menu_item_new_discussion -> {
createDiscussion.launch(Unit)
true
}
else -> {
false
}
}
}
// show feed
(childFragmentManager.findFragmentById(R.id.post_list_fragment_container) as NavHostFragment).navController.setGraph(R.navigation.post_listing_navigation)
viewModel.userProfile.observe(viewLifecycleOwner) {
val header = viewBinding.homeDrawer.getHeaderView(0) as? ConstraintLayout
val image: ImageView? = header?.findViewById(R.id.profileImageView)
val textViewName: TextView? = header?.findViewById(R.id.textViewName)
val textViewEmail: TextView? = header?.findViewById(R.id.textViewEmail)
val textViewUsername: TextView? = header?.findViewById(R.id.textViewUsername)
image?.load(UrlGen.userProfileImage(it.userId), imageLoader)
val card: MaterialCardView? = header?.findViewById(R.id.drawer_header_card)
card?.setOnClickListener {
ProfileActivity.startActivity(requireContext(), currentUserId)
}
textViewName?.text = it.name
textViewEmail?.text = it.email
textViewUsername?.text = "@${it.uniqueUsername}"
currentUserId = it.userId
}
viewModel.getProfile()
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
errorViewModel.retry.collect {
viewModel.retry()
errorViewModel.handleRetry()
}
}
}
ViewCompat.setOnApplyWindowInsetsListener(viewBinding.root) {_, insets ->
val systemBarsInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars())
viewBinding.homeDrawer.getHeaderView(0).updatePadding(top = systemBarsInsets.top)
insets
}
}
}

View File

@ -1,80 +0,0 @@
package com.isolaatti.home.ui
import android.Manifest
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.view.Menu
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.core.content.ContextCompat
import androidx.datastore.preferences.core.edit
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.setupWithNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.isolaatti.R
import com.isolaatti.common.IsolaattiBaseActivity
import com.isolaatti.dataStore
import com.isolaatti.databinding.ActivityHomeBinding
import com.isolaatti.home.presentation.FeedViewModel
class HomeActivity : IsolaattiBaseActivity() {
private lateinit var viewBinding: ActivityHomeBinding
private val feedViewModel: FeedViewModel by viewModels()
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission(),
) { isGranted: Boolean ->
if (!isGranted) {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.push_notifications_dialog_title)
.setMessage(R.string.push_notifications_dialog_rejected_message)
.show()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
viewBinding = ActivityHomeBinding.inflate(layoutInflater)
setContentView(viewBinding.root)
val navHostFragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
viewBinding.bottomNavigation?.setupWithNavController(navHostFragment.navController)
viewBinding.navigationRail?.setupWithNavController(navHostFragment.navController)
askNotificationPermission()
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
return super.onCreateOptionsMenu(menu)
}
private fun askNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
) {
} else if (shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS)) {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.push_notifications_dialog_title)
.setMessage(R.string.push_notifications_dialog_message)
.setPositiveButton(R.string.accept) { _, _ ->
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
.setNegativeButton(R.string.no, null)
.show()
} else {
// Directly ask for the permission
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
} else {
}
}
}

View File

@ -1,10 +1,10 @@
package com.isolaatti.images
import android.app.Application
import android.content.ContentResolver
import android.content.Context
import com.isolaatti.MyApplication
import com.isolaatti.connectivity.RetrofitClient
import com.isolaatti.database.AppDatabase
import com.isolaatti.images.common.data.dao.ImagesDraftsDao
import com.isolaatti.images.common.data.remote.ImagesApi
import com.isolaatti.images.common.data.repository.ImagesRepositoryImpl
import com.isolaatti.images.common.domain.repository.ImagesRepository
@ -28,12 +28,7 @@ class Module {
}
@Provides
fun provideImagesDraftDao(database: AppDatabase): ImagesDraftsDao {
return database.imagesDraftsDao()
}
@Provides
fun provideImagesRepository(imagesApi: ImagesApi, imagesDraftsDao: ImagesDraftsDao, contentResolver: ContentResolver): ImagesRepository {
return ImagesRepositoryImpl(imagesApi, imagesDraftsDao, contentResolver)
fun provideImagesRepository(imagesApi: ImagesApi, contentResolver: ContentResolver): ImagesRepository {
return ImagesRepositoryImpl(imagesApi, contentResolver)
}
}

View File

@ -1,122 +0,0 @@
package com.isolaatti.images.common.components
import android.net.Uri
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Card
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.zIndex
import coil3.compose.AsyncImage
import com.isolaatti.R
import com.isolaatti.images.common.domain.entity.Image
@Composable
fun ImagesRow(
modifier: Modifier = Modifier,
images: List<Uri>,
deletable: Boolean,
addable: Boolean,
onClick: (index: Int) -> Unit,
onDeleteClick: (index: Int) -> Unit = {},
onTakePicture: () -> Unit = {},
onUploadPicture: () -> Unit = {}
) {
var showAddPhotoPopUp by remember { mutableStateOf(false) }
LazyRow(modifier, contentPadding = PaddingValues(horizontal = 8.dp)) {
if(addable) {
item {
Card(modifier = Modifier
.padding(horizontal = 4.dp)
.size(120.dp), onClick = {
showAddPhotoPopUp = true
}) {
DropdownMenu(expanded = showAddPhotoPopUp, onDismissRequest = { showAddPhotoPopUp = false }) {
DropdownMenuItem(
text = { Text(stringResource(R.string.take_a_photo )) },
onClick = {
showAddPhotoPopUp = false
onTakePicture()
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.upload_a_picture )) },
onClick = {
showAddPhotoPopUp = false
onUploadPicture()
}
)
}
Image(modifier = Modifier
.fillMaxSize()
.padding(16.dp),
painter = painterResource(id = R.drawable.baseline_add_a_photo_24),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface),
contentDescription = null)
}
}
}
items(count = images.size) { index ->
Card(modifier = Modifier
.padding(horizontal = 4.dp)
.size(120.dp)) {
Box(
modifier = Modifier.fillMaxSize(),
) {
FilledTonalIconButton(
onClick = { onDeleteClick(index) },
modifier = Modifier.zIndex(2f)
) {
Icon(imageVector = Icons.Default.Close, contentDescription = null)
}
AsyncImage(
model = images[index],
contentDescription = null,
modifier = Modifier.fillMaxSize().zIndex(1f),
contentScale = ContentScale.Crop
)
}
}
}
}
}
@Preview(device = Devices.PIXEL_5)
@Composable
fun ImagesRowPreview() {
ImagesRow(images = emptyList(), deletable = false, addable = true, onClick = {})
}

View File

@ -1,19 +0,0 @@
package com.isolaatti.images.common.data.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import com.isolaatti.images.common.data.entity.ImageDraftEntity
@Dao
interface ImagesDraftsDao {
@Query("SELECT * FROM image_drafts WHERE postId IS NULL")
fun getDetachedImages(): List<ImageDraftEntity>
@Insert
fun insertImageDraft(imageDraftEntity: ImageDraftEntity)
@Query("DELETE FROM image_drafts WHERE id = :id")
fun deleteById(id: Long)
}

View File

@ -1,13 +0,0 @@
package com.isolaatti.images.common.data.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.io.Serializable
@Entity(tableName = "image_drafts")
data class ImageDraftEntity(
@PrimaryKey(autoGenerate = true)
val id: Long,
val uri: String,
val postId: Long? = null
) : Serializable

View File

@ -3,6 +3,7 @@ package com.isolaatti.images.common.data.remote
data class ImageDto(
val id: String,
val userId: Int,
val name: String,
val squadId: String?,
val username: String,
val idOnFirebase: String

View File

@ -6,8 +6,6 @@ import android.graphics.BitmapFactory
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
@ -22,7 +20,7 @@ import java.io.ByteArrayOutputStream
import java.io.InputStream
import javax.inject.Inject
class ImagesRepositoryImpl @Inject constructor(private val imagesApi: ImagesApi, private val imagesDraftsDao: ImagesDraftsDao, private val contentResolver: ContentResolver) :
class ImagesRepositoryImpl @Inject constructor(private val imagesApi: ImagesApi, private val contentResolver: ContentResolver) :
ImagesRepository {
companion object {
@ -106,12 +104,4 @@ class ImagesRepositoryImpl @Inject constructor(private val imagesApi: ImagesApi,
imageInputStream?.close()
}
}
override fun getDetachedDraftImages(): Flow<Resource<List<ImageDraftEntity>>> {
TODO("Not yet implemented")
}
override fun getDraftImagesOfPost(postId: Long): Flow<Resource<List<ImageDraftEntity>>> {
TODO("Not yet implemented")
}
}

View File

@ -9,6 +9,7 @@ import java.io.Serializable
data class Image(
val id: String,
val userId: Int,
val name: String,
val username: String
): Deletable(), Serializable {
val imageUrl: String get() = UrlGen.imageUrl(id)
@ -18,7 +19,7 @@ data class Image(
val markdown: String get() = Generators.generateImage(imageUrl)
companion object {
fun fromDto(imageDto: ImageDto) = Image(imageDto.id, imageDto.userId, imageDto.username)
fun fromDto(imageDto: ImageDto) = Image(imageDto.id, imageDto.userId, imageDto.name, imageDto.username)
}
override fun equals(other: Any?): Boolean {
@ -29,6 +30,7 @@ data class 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
@ -37,6 +39,7 @@ data class Image(
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + userId
result = 31 * result + name.hashCode()
result = 31 * result + username.hashCode()
return result
}

View File

@ -1,7 +1,6 @@
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.utils.Resource
import kotlinx.coroutines.flow.Flow
@ -10,6 +9,4 @@ interface ImagesRepository {
fun getImagesOfUser(userId: Int, lastId: String? = null): Flow<Resource<List<Image>>>
fun deleteImages(images: List<Image>): Flow<Resource<Boolean>>
fun uploadImage(name: String, imageUri: Uri, squadId: String?): Flow<Resource<Image>>
fun getDetachedDraftImages(): Flow<Resource<List<ImageDraftEntity>>>
fun getDraftImagesOfPost(postId: Long): Flow<Resource<List<ImageDraftEntity>>>
}

View File

@ -5,11 +5,9 @@ import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.activity.result.contract.ActivityResultContract
import androidx.core.content.IntentCompat
import com.isolaatti.images.common.data.entity.ImageDraftEntity
import com.isolaatti.images.common.domain.entity.Image
class ImageChooserContract : ActivityResultContract<ImageChooserContract.Requester, ImageDraftEntity?>() {
class ImageChooserContract : ActivityResultContract<ImageChooserContract.Requester, Image?>() {
enum class Requester {
UserPost, SquadPost
@ -20,13 +18,13 @@ class ImageChooserContract : ActivityResultContract<ImageChooserContract.Request
}
}
override fun parseResult(resultCode: Int, intent: Intent?): ImageDraftEntity? {
override fun parseResult(resultCode: Int, intent: Intent?): Image? {
if(resultCode != Activity.RESULT_OK) { return null }
if(intent == null) {
return null
}
return IntentCompat.getSerializableExtra(intent, ImageChooserActivity.OUTPUT_EXTRA_IMAGE, ImageDraftEntity::class.java)
return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
intent?.getSerializableExtra(ImageChooserActivity.OUTPUT_EXTRA_IMAGE) as Image?
} else {
intent?.getSerializableExtra(ImageChooserActivity.OUTPUT_EXTRA_IMAGE, Image::class.java)
}
}
}

View File

@ -38,6 +38,7 @@ class ImageChooserPreview : Fragment() {
}
binding.image.load(viewModel.selectedImage?.imageUrl)
binding.imageDescription.text = viewModel.selectedImage?.name
binding.chooseImageButton.setOnClickListener {
showLoading(true)

View File

@ -86,12 +86,12 @@ class ImagesAdapter(
holder.imageItemBinding.root.setOnClickListener {
holder.imageItemBinding.imageCheckbox.isChecked = !holder.imageItemBinding.imageCheckbox.isChecked
}
holder.imageItemBinding.imageCheckbox.isChecked = image.delete
holder.imageItemBinding.imageCheckbox.setOnCheckedChangeListener { buttonView, isChecked ->
image.delete = isChecked
onImageSelectedCountUpdate?.invoke(currentList.count { it.delete })
}
holder.imageItemBinding.imageCheckbox.isChecked = image.delete
holder.imageItemBinding.root.setOnLongClickListener(null)
} else {
holder.imageItemBinding.imageCheckbox.visibility = View.GONE

View File

@ -51,6 +51,9 @@ class ImageMakerActivity : IsolaattiBaseActivity() {
binding.uploadPhotoFab.setOnClickListener {
viewModel.uploadPicture()
}
binding.textImageName.editText?.doOnTextChanged { text, _, _, _ ->
viewModel.name = text.toString()
}
binding.toolbar.setNavigationOnClickListener {
showExitConfirmationDialog()
}
@ -63,10 +66,12 @@ class ImageMakerActivity : IsolaattiBaseActivity() {
errorViewModel.error.value = it.errorType
binding.progressBarLoading.visibility = View.GONE
binding.uploadPhotoFab.visibility = View.VISIBLE
binding.textImageName.isEnabled = true
}
is Resource.Loading -> {
binding.progressBarLoading.visibility = View.VISIBLE
binding.uploadPhotoFab.visibility = View.INVISIBLE
binding.textImageName.isEnabled = false
}
is Resource.Success -> {
binding.progressBarLoading.visibility = View.GONE

View File

@ -20,6 +20,7 @@ class PictureViewerMainFragment : Fragment() {
override fun onPageSelected(position: Int) {
super.onPageSelected(position)
binding.imageAuthor.text = images[position].username
binding.imageDescription.text = images[position].name
}
}
@ -42,6 +43,7 @@ class PictureViewerMainFragment : Fragment() {
binding.viewpager.adapter = adapter
binding.viewpager.setCurrentItem(position, false)
binding.viewpager.registerOnPageChangeCallback(onPageChangeCallback)
binding.imageDescription.text = images[position].name
binding.imageAuthor.text = images[position].username
}

View File

@ -1,25 +0,0 @@
package com.isolaatti.markdown
import android.text.SpannableString
import android.text.Spanned
import android.text.style.ClickableSpan
import android.view.View
import android.widget.TextView
import io.noties.markwon.AbstractMarkwonPlugin
class HashtagMarkwonPlugin : AbstractMarkwonPlugin() {
override fun beforeSetText(textView: TextView, markdown: Spanned) {
val matches = "#(\\w|-|_)+".toRegex().findAll(markdown)
val spannable = SpannableString(markdown)
matches.forEach { match ->
val clickableSpan = object: ClickableSpan() {
override fun onClick(widget: View) {
TODO("Not yet implemented")
}
}
spannable.setSpan(clickableSpan, match.range.first, match.range.last, 1)
}
}
}

View File

@ -1,38 +0,0 @@
package com.isolaatti.markdown
import android.content.Context
import com.isolaatti.BuildConfig
import com.isolaatti.common.CoilImageLoader
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityComponent
import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import io.noties.markwon.AbstractMarkwonPlugin
import io.noties.markwon.Markwon
import io.noties.markwon.MarkwonConfiguration
import io.noties.markwon.image.coil.CoilImagesPlugin
import io.noties.markwon.image.destination.ImageDestinationProcessorRelativeToAbsolute
import io.noties.markwon.linkify.LinkifyPlugin
@Module
@InstallIn(SingletonComponent::class)
class Module {
@Provides
fun provideMarkwon(@ApplicationContext context: Context): Markwon {
return Markwon.builder(context)
.usePlugin(object: AbstractMarkwonPlugin() {
override fun configureConfiguration(builder: MarkwonConfiguration.Builder) {
builder
.imageDestinationProcessor(
ImageDestinationProcessorRelativeToAbsolute
.create(BuildConfig.backend))
}
})
.usePlugin(CoilImagesPlugin.create(context, CoilImageLoader.imageLoader))
.usePlugin(LinkifyPlugin.create())
.build()
}
}

View File

@ -1,15 +0,0 @@
package com.isolaatti.markdown
import io.noties.markwon.AbstractMarkwonPlugin
import io.noties.markwon.MarkwonConfiguration
import io.noties.markwon.image.destination.ImageDestinationProcessorRelativeToAbsolute
class RelativePathMarkwonPlugin : AbstractMarkwonPlugin() {
override fun configureConfiguration(builder: MarkwonConfiguration.Builder) {
builder
.imageDestinationProcessor(
ImageDestinationProcessorRelativeToAbsolute
.create("https://isolaatti.com/"))
}
}

View File

@ -1,25 +0,0 @@
package com.isolaatti.notifications
import com.isolaatti.connectivity.RetrofitClient
import com.isolaatti.notifications.data.NotificationsApi
import com.isolaatti.notifications.data.NotificationsRepositoryImpl
import com.isolaatti.notifications.domain.NotificationsRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
class Module {
@Provides
fun provideNotificationsApi(retrofitClient: RetrofitClient): NotificationsApi {
return retrofitClient.client.create(NotificationsApi::class.java)
}
@Provides
fun provideNotificationsRepository(notificationsApi: NotificationsApi): NotificationsRepository {
return NotificationsRepositoryImpl(notificationsApi)
}
}

View File

@ -1,15 +0,0 @@
package com.isolaatti.notifications.data
import retrofit2.Call
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Query
interface NotificationsApi {
@GET("/api/Notifications/list")
fun getNotifications(@Query("after") after: Long?): Call<NotificationsDto>
@POST("/api/Notifications/delete_many")
fun deleteNotifications(@Body ids: DeleteNotificationsDto): Call<Unit>
}

View File

@ -1,17 +0,0 @@
package com.isolaatti.notifications.data
import java.time.ZonedDateTime
data class NotificationsDto(
val result: List<NotificationDto>
)
data class NotificationDto(
val id: Long,
val date: ZonedDateTime,
val userId: Int,
val read: Boolean,
val data: Map<String, String>
)
data class DeleteNotificationsDto(val ids: List<Long>)

View File

@ -1,48 +0,0 @@
package com.isolaatti.notifications.data
import android.util.Log
import com.isolaatti.notifications.domain.Notification
import com.isolaatti.notifications.domain.NotificationsRepository
import com.isolaatti.utils.Resource
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import retrofit2.awaitResponse
class NotificationsRepositoryImpl(private val notificationsApi: NotificationsApi) : NotificationsRepository {
companion object {
const val LOG_TAG = "NotificationsRepositoryImpl"
}
override fun getNotifications(after: Long?): Flow<Resource<List<Notification>>> = flow {
try {
emit(Resource.Loading())
val response = notificationsApi.getNotifications(after).awaitResponse()
if(response.isSuccessful) {
emit(Resource.Success(response.body()!!.result.mapNotNull { Notification.fromDto(it) }))
} else {
Log.e(LOG_TAG, "getNotifications(): Request is not successful, response code is ${response.code()}")
emit(Resource.Error(Resource.Error.mapErrorCode(response.code())))
}
} catch(e: Exception) {
Log.e(LOG_TAG, e.message.toString())
emit(Resource.Error(Resource.Error.ErrorType.OtherError))
}
}
override fun deleteNotifications(vararg notification: Notification): Flow<Resource<Boolean>> = flow {
try {
emit(Resource.Loading())
val response = notificationsApi.deleteNotifications(DeleteNotificationsDto(notification.map { it.id })).awaitResponse()
if(response.isSuccessful) {
emit(Resource.Success(true))
} else {
Log.e(LOG_TAG, "deleteNotifications(): Request is not successful, response code is ${response.code()}")
emit(Resource.Error(Resource.Error.mapErrorCode(response.code())))
}
} catch(e: Exception) {
Log.e(LOG_TAG, e.message.toString())
emit(Resource.Error(Resource.Error.ErrorType.OtherError))
}
}
}

View File

@ -1,181 +0,0 @@
package com.isolaatti.notifications.domain
import com.isolaatti.notifications.data.NotificationDto
import java.time.ZonedDateTime
class GenericNotification(id: Long, date: ZonedDateTime, userId: Int, read: Boolean) : Notification(id, date, userId, read) {
var title: String? = null
var message: String? = null
override fun ingestPayload(data: Map<String, String>) {
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as GenericNotification
if (title != other.title) return false
if (message != other.message) return false
return true
}
override fun hashCode(): Int {
var result = title?.hashCode() ?: 0
result = 31 * result + (message?.hashCode() ?: 0)
return result
}
companion object {
const val TYPE = "generic"
}
}
class LikeNotification(id: Long, date: ZonedDateTime, userId: Int, read: Boolean) : Notification(id, date, userId, read) {
companion object {
const val TYPE = "like"
}
var likeId: String? = null
var postId: Long? = null
var authorId: Int? = null
var authorName: String? = null
override fun ingestPayload(data: Map<String, String>) {
likeId = data["likeId"]
postId = data["postId"]?.toLongOrNull()
authorId = data["authorId"]?.toIntOrNull()
authorName = data["authorName"]
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as LikeNotification
if (likeId != other.likeId) return false
if (postId != other.postId) return false
if (authorId != other.authorId) return false
if (authorName != other.authorName) return false
return true
}
override fun hashCode(): Int {
var result = likeId?.hashCode() ?: 0
result = 31 * result + (postId?.hashCode() ?: 0)
result = 31 * result + (authorId ?: 0)
result = 31 * result + (authorName?.hashCode() ?: 0)
return result
}
}
class FollowNotification(id: Long, date: ZonedDateTime, userId: Int, read: Boolean) : Notification(id, date, userId, read) {
companion object {
const val TYPE = "follower"
}
var followerName: String? = null
var followerUserId: Int? = null
override fun ingestPayload(data: Map<String, String>) {
followerName = data["followerName"]
followerUserId = data["followerUserId"]?.toIntOrNull()
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as FollowNotification
if (followerName != other.followerName) return false
if (followerUserId != other.followerUserId) return false
return true
}
override fun hashCode(): Int {
var result = followerName?.hashCode() ?: 0
result = 31 * result + (followerUserId ?: 0)
return result
}
}
abstract class Notification(
val id: Long,
val date: ZonedDateTime,
val userId: Int,
var read: Boolean
) {
abstract fun ingestPayload(data: Map<String, String>)
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Notification) return false
if (id != other.id) return false
if (date != other.date) return false
if (userId != other.userId) return false
if (read != other.read) return false
if (other != this) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + date.hashCode()
result = 31 * result + userId
result = 31 * result + read.hashCode()
return result
}
companion object {
fun fromDto(notificationDto: NotificationDto): Notification? {
val type = notificationDto.data["type"]
return when(type) {
GenericNotification.TYPE -> {
GenericNotification(
notificationDto.id,
notificationDto.date,
notificationDto.userId,
notificationDto.read
).apply {
ingestPayload(notificationDto.data)
}
}
LikeNotification.TYPE -> {
LikeNotification(
notificationDto.id,
notificationDto.date,
notificationDto.userId,
notificationDto.read
).apply {
ingestPayload(notificationDto.data)
}
}
FollowNotification.TYPE -> {
FollowNotification(
notificationDto.id,
notificationDto.date,
notificationDto.userId,
notificationDto.read
).apply {
ingestPayload(notificationDto.data)
}
}
else -> null
}
}
}
}

View File

@ -1,9 +0,0 @@
package com.isolaatti.notifications.domain
import com.isolaatti.utils.Resource
import kotlinx.coroutines.flow.Flow
interface NotificationsRepository {
fun getNotifications(after: Long?): Flow<Resource<List<Notification>>>
fun deleteNotifications(vararg notification: Notification): Flow<Resource<Boolean>>
}

View File

@ -1,85 +0,0 @@
package com.isolaatti.notifications.presentation
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import coil.load
import com.isolaatti.R
import com.isolaatti.databinding.NotificationItemBinding
import com.isolaatti.notifications.domain.FollowNotification
import com.isolaatti.notifications.domain.LikeNotification
import com.isolaatti.notifications.domain.Notification
import com.isolaatti.utils.UrlGen
class NotificationsAdapter(
private val onNotificationClick: (notification: Notification) -> Unit,
private val onItemOptionsClick: (button: View, notification: Notification) -> Unit
) : ListAdapter<Notification, NotificationsAdapter.NotificationViewHolder>(diffCallback) {
inner class NotificationViewHolder(val notificationItemBinding: NotificationItemBinding) : ViewHolder(notificationItemBinding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NotificationViewHolder {
return NotificationViewHolder(NotificationItemBinding.inflate(LayoutInflater.from(parent.context), parent, false))
}
override fun onBindViewHolder(holder: NotificationViewHolder, position: Int) {
val context = holder.notificationItemBinding.root.context
val notification = getItem(position)
holder.notificationItemBinding.root.setOnClickListener {
onNotificationClick(notification)
}
holder.notificationItemBinding.optionButton.setOnClickListener {
onItemOptionsClick(it, notification)
}
when(notification) {
is LikeNotification -> {
holder.notificationItemBinding.notificationTitle.text = context.getString(R.string.like_notification_title, notification.authorName)
holder.notificationItemBinding.notificationMessage.text = context.getString(R.string.like_notification_text)
val authorProfileImageUrl = notification.authorId?.let { UrlGen.userProfileImage(it, false) }
if(authorProfileImageUrl != null) {
holder.notificationItemBinding.notificationMainImage.load(authorProfileImageUrl){
fallback(R.drawable.baseline_person_24)
}
} else {
holder.notificationItemBinding.notificationMainImage.load(R.drawable.baseline_person_24)
}
holder.notificationItemBinding.notificationSecondaryImage.load(R.drawable.hands_clapping_solid)
}
is FollowNotification -> {
holder.notificationItemBinding.notificationTitle.text = context.getString(R.string.new_follower_notification_title, notification.followerName)
holder.notificationItemBinding.notificationMessage.text = context.getString(R.string.new_follower_notification_text)
val followerProfileImageUrl = notification.followerUserId?.let { UrlGen.userProfileImage(it, false) }
if(followerProfileImageUrl != null) {
holder.notificationItemBinding.notificationMainImage.load(followerProfileImageUrl) {
fallback(R.drawable.baseline_person_24)
}
} else {
holder.notificationItemBinding.notificationMainImage.load(R.drawable.baseline_person_24)
}
holder.notificationItemBinding.notificationSecondaryImage.load(R.drawable.baseline_star_24)
}
}
}
companion object {
val diffCallback = object: DiffUtil.ItemCallback<Notification>() {
override fun areItemsTheSame(oldItem: Notification, newItem: Notification): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: Notification, newItem: Notification): Boolean {
return oldItem == newItem
}
}
}
}

View File

@ -1,71 +0,0 @@
package com.isolaatti.notifications.presentation
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.isolaatti.notifications.domain.Notification
import com.isolaatti.notifications.domain.NotificationsRepository
import com.isolaatti.utils.Resource
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class NotificationsViewModel @Inject constructor(private val notificationsRepository: NotificationsRepository) : ViewModel() {
companion object {
const val LOG_TAG = "NotificationsViewModel"
}
val notifications: MutableLiveData<List<Notification>> = MutableLiveData()
val loading: MutableLiveData<Boolean> = MutableLiveData()
val error: MutableLiveData<Boolean> = MutableLiveData()
fun getData() {
viewModelScope.launch {
notificationsRepository.getNotifications(null).onEach {
when(it) {
is Resource.Error -> {
loading.postValue(false)
}
is Resource.Loading -> {
loading.postValue(true)
}
is Resource.Success -> {
loading.postValue(false)
notifications.postValue(it.data!!)
}
}
}.flowOn(Dispatchers.IO).launchIn(this)
}
}
private fun onDeleted(notification: Notification) {
val mutableList = notifications.value?.toMutableList()
val removed = mutableList?.remove(notification)
if(mutableList != null && removed == true) {
notifications.postValue(mutableList!!)
}
}
fun deleteNotification(notification: Notification) {
viewModelScope.launch {
notificationsRepository.deleteNotifications(notification).onEach {
when(it) {
is Resource.Error -> {
error.postValue(true)
}
is Resource.Loading -> {
error.postValue(false)
}
is Resource.Success -> {
error.postValue(false)
onDeleted(notification)
}
}
}.flowOn(Dispatchers.IO).launchIn(this)
}
}
}

View File

@ -1,125 +0,0 @@
package com.isolaatti.notifications.ui
import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.widget.PopupMenu
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.isolaatti.R
import com.isolaatti.databinding.FragmentNotificationsBinding
import com.isolaatti.notifications.domain.FollowNotification
import com.isolaatti.notifications.domain.LikeNotification
import com.isolaatti.notifications.domain.Notification
import com.isolaatti.notifications.presentation.NotificationsAdapter
import com.isolaatti.notifications.presentation.NotificationsViewModel
import com.isolaatti.posting.posts.viewer.ui.PostViewerActivity
import com.isolaatti.profile.ui.ProfileActivity
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
@AndroidEntryPoint
class NotificationsFragment : Fragment() {
companion object {
fun newInstance() = NotificationsFragment()
}
private lateinit var binding: FragmentNotificationsBinding
private val viewModel: NotificationsViewModel by viewModels()
private var adapter: NotificationsAdapter? = null
private fun showDeleteNotificationDialog(notification: Notification) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.delete_notification)
.setMessage(R.string.delete_notification_dialog_message)
.setPositiveButton(R.string.accept) { _, _ ->
viewModel.deleteNotification(notification)
}
.setNegativeButton(R.string.no, null)
.show()
}
private val onItemOptionsClick: (button: View, notification: Notification) -> Unit = { button, notification ->
val popupMenu = PopupMenu(requireContext(), button)
popupMenu.inflate(R.menu.notification_menu)
popupMenu.setOnMenuItemClickListener {
when(it.itemId) {
R.id.delete_notification -> {
showDeleteNotificationDialog(notification)
true
}
else -> false
}
}
popupMenu.show()
}
private val onNotificationClick: (notification: Notification) -> Unit = { notification ->
when(notification) {
is LikeNotification -> {
notification.postId?.also { postId ->
PostViewerActivity.startActivity(requireContext(), postId)
}
}
is FollowNotification -> {
notification.followerUserId?.also { followerUserId ->
ProfileActivity.startActivity(requireContext(), followerUserId)
}
}
}
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = FragmentNotificationsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
adapter = NotificationsAdapter(onNotificationClick, onItemOptionsClick)
binding.recycler.adapter = adapter
binding.recycler.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false)
viewModel.getData()
setupObservers()
setupListeners()
}
private fun setupListeners() {
binding.swipeToRefresh.setOnRefreshListener {
viewModel.getData()
}
}
private fun setupObservers() {
viewModel.notifications.observe(viewLifecycleOwner) {
adapter?.submitList(it)
}
viewModel.loading.observe(viewLifecycleOwner) {
binding.swipeToRefresh.isRefreshing = it
}
viewModel.error.observe(viewLifecycleOwner) {
if(it){
Toast.makeText(requireContext(), R.string.error_making_request, Toast.LENGTH_SHORT).show()
viewModel.error.value = false
}
}
}
}

View File

@ -12,12 +12,7 @@ import com.isolaatti.common.OnUserInteractedCallback
import com.isolaatti.utils.UrlGen
import io.noties.markwon.Markwon
class CommentsRecyclerViewAdapter(
private var list: List<Comment>,
private val markwon: Markwon,
private val callback: OnUserInteractedCallback,
private val onCommentClick: (Comment) -> Unit
) : RecyclerView.Adapter<CommentsRecyclerViewAdapter.CommentViewHolder>() {
class CommentsRecyclerViewAdapter(private var list: List<Comment>, private val markwon: Markwon, private val callback: OnUserInteractedCallback) : RecyclerView.Adapter<CommentsRecyclerViewAdapter.CommentViewHolder>() {
private var previousSize = 0
var blockInfiniteScroll = false

View File

@ -36,8 +36,6 @@ import com.isolaatti.images.image_chooser.ui.ImageChooserContract
import com.isolaatti.posting.link_creator.presentation.LinkCreatorViewModel
import com.isolaatti.posting.link_creator.ui.LinkCreatorFragment
import com.isolaatti.profile.ui.ProfileActivity
import com.isolaatti.reports.data.ContentType
import com.isolaatti.reports.ui.NewReportBottomSheetDialogFragment
import dagger.hilt.android.AndroidEntryPoint
import io.noties.markwon.AbstractMarkwonPlugin
import io.noties.markwon.Markwon
@ -76,8 +74,6 @@ class BottomSheetPostComments() : BottomSheetDialogFragment(), OnUserInteractedC
}
Options.Option.OPTION_REPORT -> {
optionsViewModel.handle()
NewReportBottomSheetDialogFragment.newInstance(ContentType.Comment, comment.id.toString())
.show(childFragmentManager, NewReportBottomSheetDialogFragment.LOG_TAG)
}
}
}
@ -123,10 +119,10 @@ class BottomSheetPostComments() : BottomSheetDialogFragment(), OnUserInteractedC
}
private val imageChooserLauncher = registerForActivityResult(ImageChooserContract()) { image ->
Log.d("BottomSheetPostComment", "${image?.markdown}")
if(image != null) {
viewBinding.newCommentTextField.editText?.setText("${viewBinding.newCommentTextField.editText?.text}\n\n${image.markdown}")
}
}
@ -230,9 +226,7 @@ class BottomSheetPostComments() : BottomSheetDialogFragment(), OnUserInteractedC
.usePlugin(LinkifyPlugin.create())
.build()
adapter = CommentsRecyclerViewAdapter(listOf(), markwon, this, onCommentClick = {
})
adapter = CommentsRecyclerViewAdapter(listOf(), markwon, this)
viewBinding.recyclerComments.adapter = adapter
viewBinding.recyclerComments.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false)

View File

@ -1,44 +0,0 @@
package com.isolaatti.posting.comments.ui
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.navigation.fragment.NavHostFragment
import com.isolaatti.R
import com.isolaatti.databinding.ActivityCommentThreadBinding
class CommentThreadActivity : AppCompatActivity() {
private lateinit var binding: ActivityCommentThreadBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityCommentThreadBinding.inflate(layoutInflater)
setContentView(binding.root)
val commentId = intent.extras?.getLong(EXTRA_COMMENT_ID)
if(commentId == null || commentId == 0L) {
Toast.makeText(this, R.string.invalid_arg, Toast.LENGTH_SHORT).show()
finish()
} else {
(supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment)
.navController.setGraph(R.navigation.comment_thread_navigation,
Bundle().apply {
putLong(CommentThreadFragment.ARG_COMMENT_ID, commentId)
})
}
}
companion object {
private const val EXTRA_COMMENT_ID = "commentId"
fun startActivity(context: Context, commentId: Long) {
val intent = Intent(context, CommentThreadActivity::class.java).apply {
putExtra(EXTRA_COMMENT_ID, commentId)
}
context.startActivity(intent)
}
}
}

View File

@ -1,9 +0,0 @@
package com.isolaatti.posting.comments.ui
import androidx.fragment.app.Fragment
class CommentThreadFragment : Fragment() {
companion object {
const val ARG_COMMENT_ID = "commentId"
}
}

View File

@ -2,7 +2,6 @@ package com.isolaatti.posting.posts.data.remote
import android.os.Parcel
import android.os.Parcelable
import com.isolaatti.audio.common.data.AudioDto
import java.io.Serializable
data class FeedDto(
@ -24,8 +23,7 @@ data class FeedDto(
var numberOfComments: Int,
val userName: String,
val squadName: String?,
var liked: Boolean,
var audio: AudioDto?
var liked: Boolean
): Parcelable {
constructor(parcel: Parcel) : this(
@ -34,8 +32,7 @@ data class FeedDto(
parcel.readInt(),
parcel.readString()!!,
parcel.readString(),
parcel.readByte() != 0.toByte(),
parcel.readParcelable(AudioDto::class.java.classLoader)
parcel.readByte() != 0.toByte()
)
data class Post(
@ -96,7 +93,6 @@ data class FeedDto(
parcel.writeString(userName)
parcel.writeString(squadName)
parcel.writeByte(if (liked) 1 else 0)
parcel.writeSerializable(audio)
}
override fun describeContents(): Int {

View File

@ -1,6 +1,5 @@
package com.isolaatti.posting.posts.data.remote
import com.isolaatti.common.ResultDto
import com.isolaatti.profile.data.remote.ProfileListItemDto
import retrofit2.Call
import retrofit2.http.GET
@ -15,13 +14,12 @@ interface FeedsApi {
@Query("olderFirst") olderFirst: Boolean,
@Query(value = "filterJson", encoded = false) filter: String): Call<FeedDto>
@GET("Fetch/Post/{postId}")
fun getPost(@Path("postId") postId: Long): Call<FeedDto>
@GET("Fetch/Post/{postId}/LikedBy")
fun getLikedBy(@Path("postId") postId: Long): Call<ResultDto<List<ProfileListItemDto>>>
fun getLikedBy(@Path("postId") postId: Long): Call<List<ProfileListItemDto>>
@GET("Feed")
fun getChronology(@Query("lastId") lastId: Long, @Query("length") length: Int): Call<FeedDto>
@GET("hashtags/hashtag/{hashtag}")
fun getHashtagPosts(@Path("hashtag") hashtag: String, @Query("after") afterPost: Long? = null): Call<FeedDto>
}

View File

@ -1,12 +1,10 @@
package com.isolaatti.posting.posts.data.remote
import com.isolaatti.common.ResultDto
import retrofit2.Call
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
interface PostApi {
@POST("Posting/Make")
@ -21,7 +19,4 @@ interface PostApi {
@GET("Fetch/Post/{postId}")
fun getPost(@Path("postId") postId: Long): Call<FeedDto.PostDto>
@GET("Posting/Post/{postId}/Versions")
fun getVersions(@Path("postId") postId: Long): Call<ResultDto<List<VersionDto>>>
}

Some files were not shown because too many files have changed in this diff Show More