Compare commits

...

60 Commits

Author SHA1 Message Date
a32757c518 WIP 2024-11-10 23:48:00 -06:00
7f2e16c580 Merge remote-tracking branch 'origin/dev' into development 2024-11-09 01:22:34 -06:00
7c073b80c6 WIP:
1. edge to edge
2. se quitan imagenes y audios del perfil
3. se quita markdown de posts
4. se agrega pantalla de licencias
5. se agrega soporte para dar clic en hashtags
6. pantalla de hashtags ahora es una actividad
7. se comienza a implementar nuevo flujo de imagenes
2024-11-09 01:22:18 -06:00
b0d3e28352 WIP audio selector 2024-07-14 17:29:27 -06:00
a2c9091598 WIP 2024-07-14 13:21:34 -06:00
c00bd001ea se desactiva temporalmente botón "Go to hashtags" 2024-04-28 01:10:25 -06:00
ab76e3827a se agrega pantalla "browse profiles" y se realiza refactorización 2024-04-28 01:09:50 -06:00
50a70c6207 versionCode 6; versionName 0.6-vc6 2024-04-25 00:21:28 -06:00
1540d8ec2a agrego ProfileListingFragment: pantalla para mostrar lista de usuarios de varios origenes 2024-04-25 00:20:32 -06:00
a8c262048e versionCode 5; versionName 0.5-vc5 2024-04-22 22:44:23 -06:00
8a345fc59b se permite eliminar foto de perfil, y varios fixes 2024-04-22 22:42:54 -06:00
837b38ef97 solución temporal para abrir imagen de perfil desde menu de opciones de foto de perfil. Se agrega dialogo "dummy" de bloqueo de usuario 2024-04-21 23:32:59 -06:00
af8c785b01 se agrega funcionalidad a boton de "back" en PostInfoActivity 2024-04-21 23:31:30 -06:00
4f5b044f8f versionCode 4; versionName 0.4-vc4 2024-04-21 20:44:15 -06:00
ba9aa4c395 WIP quien dio like a post e historial de post 2024-04-21 14:17:41 -06:00
e12bbb2e94 bug fix fragment_feed.xml en landscape 2024-04-20 12:20:42 -06:00
8d15bce887 bug fix ImagesAdapter: se perdía imagenes marcadas cuando se hace scroll 2024-04-20 12:20:23 -06:00
be4e0ef294 strings typo fixes 2024-04-20 11:51:41 -06:00
3f67933bc8 allowBackup="false" 2024-04-20 11:45:53 -06:00
bf886c845d crear reportes 2024-04-20 11:45:12 -06:00
ce8ec23100 Merge remote-tracking branch 'origin/dev' into dev
# Conflicts:
#	app/src/main/java/com/isolaatti/posting/posts/ui/PostListingFragment.kt
#	app/src/main/res/values/strings.xml
2024-04-14 02:22:04 -06:00
72568725d4 WIP reportes 2024-04-14 02:20:35 -06:00
ade2027b17 envio user-agent de la app en todas las peticiones hechas por OkHttp 2024-04-09 00:46:22 -06:00
133483387f actualizacion AGP a 8.3.1 2024-04-07 23:02:29 -06:00
e50489abf1 fix PostsRecyclerViewAdapter: evito llamar a onLoadMore cuando se realiza binding de elemento pero debido a actualizaciones del ultimo view 2024-04-07 10:41:58 -06:00
12344011ba versionCode 3; versionName 0.3-vc3 2024-04-06 20:08:55 -06:00
ff1733de01 se muestra version en pantalla About 2024-04-06 20:08:34 -06:00
061814e7e8 se cambia de orden los iconos de buscar y notificaciones en la barra principal 2024-04-06 20:08:14 -06:00
94c7ec1b93 agrego navEditor.xml para conservar posiciones de editor de navegacion 2024-04-06 19:56:09 -06:00
3ef8961729 busqueda casi completa, refactorizacion, iconos nuevos en resultados, posts de hashtag 2024-04-06 19:55:25 -06:00
000937ded4 WIP busqueda: abre resultados de busqueda 2024-04-03 19:48:01 -06:00
49ccebb539 WIP busqueda, hastags y pantalla de explorar perfiles 2024-03-31 22:50:06 -06:00
8730bbe796 WIP 2 search 2024-03-29 00:10:55 -06:00
d815d717e2 WIP search 2024-03-24 21:06:08 -06:00
a26adc11f8 assert non nullable 2024-03-24 21:05:52 -06:00
530d8e7fa2 subo versionCode 2; versionName 0.2 2024-03-14 22:24:48 -06:00
236a2ce957 agrego ic_launcher-playstore.png 2024-03-14 22:23:43 -06:00
d8dfda8d04 WIP squads y search 2024-03-14 22:22:48 -06:00
34f2c51f78 notificaciones 2024-03-14 22:22:25 -06:00
9667eafa49 notificaciones 2024-03-14 22:21:55 -06:00
bd994a17e8 WIP: se agrega notificacion de nuevo seguidor y accion a notificacion de like 2024-03-05 23:17:57 -06:00
049d97aeb8 update agp 2024-03-05 23:16:04 -06:00
313b5b5ffe update agp 2024-03-05 22:04:03 -06:00
16f9d7f90d WIP push notifications likes 2024-03-05 22:03:47 -06:00
0aa7b1001f WIP reproduccion de audios en feeds y push notifications 2024-03-03 20:25:49 -06:00
c87e12caab WIP notificaciones push 2024-02-28 22:04:32 -06:00
896912d514 WIP eliminar audio 2024-02-17 22:06:10 -06:00
cd10aced57 reproduccion de audio en lista de audios y fixes varios 2024-02-17 21:37:25 -06:00
bfa0fdcecf WIP hashtags 2024-02-10 11:57:13 -06:00
c790a0064c WIP selector de audios 2024-02-05 20:54:53 -06:00
835c7304c2 WIP agregar audio a post, sube audio
se agrega crashlytics
2024-02-05 01:47:38 -06:00
5084fe337d WIP grabador de audio 2024-01-31 01:06:45 -06:00
3ff49d73ce WIP 2024-01-29 00:07:36 -06:00
96fd70556b WIP grabador de audio 2024-01-28 12:27:10 -06:00
b539f03ef6
Update README.md 2024-01-27 10:26:51 -06:00
77d092dec5 pantalla about 2024-01-27 01:25:42 -06:00
fb92a69fae guarda info basica de perfiles para uso offline. Por ahora, se utiliza la info del usuario actual para mostrarla en ajustes 2024-01-27 00:33:58 -06:00
381274a1b6 feature cambiar password y refactor 2024-01-26 22:59:19 -06:00
e33d1c279a fixes strings.xml 2024-01-26 22:01:25 -06:00
0140797e91 actualizo plugins 2024-01-26 22:01:07 -06:00
260 changed files with 8186 additions and 913 deletions

View File

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

203
.idea/navEditor.xml generated
View File

@ -13,22 +13,41 @@
<LayoutPositions>
<option name="myPosition">
<Point>
<option name="x" value="130" />
<option name="y" value="18" />
<option name="x" value="40" />
<option name="y" value="40" />
</Point>
</option>
</LayoutPositions>
</value>
</entry>
<entry key="audioRecorderMainFragment">
</map>
</option>
</LayoutPositions>
</value>
</entry>
<entry key="comment_thread_navigation.xml">
<value>
<LayoutPositions>
<option name="myPositions">
<map>
<entry key="commentThreadFragment">
<value>
<LayoutPositions>
<option name="myPosition">
<Point>
<option name="x" value="-74" />
<option name="y" value="17" />
<option name="x" value="-258" />
<option name="y" value="8" />
</Point>
</option>
<option name="myPositions">
<map>
<entry key="action_commentThreadFragment_self">
<value>
<LayoutPositions />
</value>
</entry>
</map>
</option>
</LayoutPositions>
</value>
</entry>
@ -71,11 +90,66 @@
</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>
@ -97,6 +171,39 @@
</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>
@ -118,18 +225,6 @@
</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>
@ -142,15 +237,41 @@
</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="12" />
<option name="y" value="12" />
<option name="x" value="-278" />
<option name="y" value="216" />
</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>
@ -258,6 +379,50 @@
</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,6 +23,19 @@ 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,6 +7,9 @@ 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 {
@ -16,12 +19,20 @@ android {
enabled = true
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.2"
}
defaultConfig {
applicationId "com.isolaatti"
minSdk 24
targetSdk 34
versionCode 1
versionName "1.0"
versionCode 7
versionName "0.7-vc7"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
@ -45,11 +56,11 @@ android {
dependencies {
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3'
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation "androidx.recyclerview:recyclerview:1.3.1"
implementation "androidx.recyclerview:recyclerview:1.3.2"
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.core:core-ktx:1.12.0'
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'
@ -59,6 +70,7 @@ 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"
@ -70,10 +82,10 @@ dependencies {
// Material 3
implementation "com.google.android.material:material:1.9.0"
implementation "com.google.android.material:material:1.12.0"
// Navigation
def nav_version = "2.6.0"
def nav_version = "2.7.7"
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"
@ -89,7 +101,7 @@ dependencies {
final def markwon_version = '4.6.2'
// Customtabs
implementation 'androidx.browser:browser:1.5.0'
implementation 'androidx.browser:browser:1.8.0'
implementation 'io.coil-kt:coil:2.5.0'
implementation 'io.coil-kt:coil-svg:2.5.0'
@ -125,4 +137,27 @@ 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,12 +1,13 @@
<?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="true"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
@ -15,6 +16,15 @@
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"
@ -25,14 +35,15 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".home.HomeActivity" android:theme="@style/Theme.Isolaatti" />
<activity android:name=".home.ui.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=".MainActivity"/>
android:parentActivityName=".home.ui.HomeActivity"/>
<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"/>
<activity android:name=".posting.posts.viewer.ui.PostViewerActivity" android:theme="@style/Theme.Isolaatti"
android:parentActivityName=".home.ui.HomeActivity"/>
<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"/>
@ -40,6 +51,10 @@
<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"
@ -48,6 +63,15 @@
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: 7.9 KiB

After

Width:  |  Height:  |  Size: 11 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.HomeActivity
import com.isolaatti.home.ui.HomeActivity
import com.isolaatti.login.LogInActivity
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject

View File

@ -1,17 +1,42 @@
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
@ -21,6 +46,28 @@ 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,7 +1,13 @@
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() {
@ -17,5 +23,37 @@ class AboutActivity : AppCompatActivity() {
binding.toolbar.setNavigationOnClickListener {
finish()
}
binding.blogButton.setOnClickListener {
CustomTabsIntent.Builder()
.build()
.launchUrl(this, BuildConfig.blogUrl.toUri())
}
binding.sourceCodeButton.setOnClickListener {
CustomTabsIntent.Builder()
.setSendToExternalDefaultHandlerEnabled(true)
.build()
.launchUrl(this, BuildConfig.sourceCodeUrl.toUri())
}
binding.openSourceLicences.setOnClickListener {
startActivity(Intent(this, OssLicensesMenuActivity::class.java))
}
binding.privacyPolicyButton.setOnClickListener {
CustomTabsIntent.Builder()
.build()
.launchUrl(this, BuildConfig.privacyPolicy.toUri())
}
binding.termsButon.setOnClickListener {
CustomTabsIntent.Builder()
.build()
.launchUrl(this, BuildConfig.terms.toUri())
}
binding.appVersion.text = getString(R.string.app_version, BuildConfig.VERSION_NAME)
}
}

View File

@ -4,7 +4,12 @@ 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
@ -19,7 +24,17 @@ class Module {
}
@Provides
fun provideAudiosRepository(audiosApi: AudiosApi): AudiosRepository {
return AudiosRepositoryImpl(audiosApi)
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)
}
}

View File

@ -0,0 +1,18 @@
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

@ -0,0 +1,66 @@
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

@ -0,0 +1,91 @@
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

@ -0,0 +1,43 @@
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

@ -0,0 +1,105 @@
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,24 +3,85 @@ 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 onClick: ((audio: Audio) -> Unit),
private val onPlayClick: ((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 {
@ -32,19 +93,62 @@ class AudiosAdapter(
return data.size
}
override fun onBindViewHolder(holder: AudiosViewHolder, position: Int) {
override fun onBindViewHolder(
holder: AudiosViewHolder,
position: Int,
payloads: MutableList<Any>
) {
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.audioName.text = audio.name
holder.binding.audioAuthor.text = audio.userName
holder.binding.thumbnail.load(audio.thumbnail)
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
}
holder.binding.audioItemOptionsButton.setOnClickListener {
onOptionsClick(audio, it)
// 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,4 +28,14 @@ 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,16 +8,21 @@ 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() {
@ -26,6 +31,9 @@ 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(
@ -38,24 +46,81 @@ 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 onAudioClick: ((audio: Audio) -> Unit) = {
// Play audio
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) {
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
adapter = AudiosAdapter(onClick = onAudioClick, onOptionsClick = onOptionsClick)
audioPlayerConnector = AudioPlayerConnector(requireContext())
audioPlayerConnector.addListener(audioPlayerConnectorListener)
viewLifecycleOwner.lifecycle.addObserver(audioPlayerConnector)
adapter = AudiosAdapter(onPlayClick = onAudioPlayClick, onOptionsClick = onOptionsClick)
viewBinding.recyclerAudios.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false)
viewBinding.recyclerAudios.adapter = adapter
@ -63,7 +128,12 @@ class AudiosFragment : Fragment() {
setupObservers()
if(arguments.source == SOURCE_PROFILE) {
viewModel.loadAudios(arguments.sourceId.toInt())
privilegedUserId = arguments.sourceId.toInt()
viewModel.loadAudios(privilegedUserId)
}
viewBinding.topAppBar.setNavigationOnClickListener {
findNavController().popBackStack()
}
}
@ -85,8 +155,17 @@ class AudiosFragment : Fragment() {
}
}
}
viewModel.audioRemoved.observe(viewLifecycleOwner) {
if(it != null){
adapter.removeAudio(it)
viewModel.audioRemoved.value = null
}
}
}
companion object {
const val SOURCE_PROFILE = "source_profile"
const val SOURCE_SQUAD = "source_squads"

View File

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

View File

@ -1,11 +1,28 @@
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,32 +1,117 @@
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) : AudiosRepository {
override fun getAudiosOfUser(userId: Int, lastId: String?): Flow<Resource<List<Audio>>> = flow {
emit(Resource.Loading())
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>> {
try {
val response = audiosApi.getAudiosOfUser(userId, lastId).awaitResponse()
if(response.isSuccessful) {
val body = response.body()
if(body == null) {
emit(Resource.Error(Resource.Error.ErrorType.ServerError))
return@flow
}
val body = response.body() ?: return Resource.Error(Resource.Error.ErrorType.ServerError)
emit(Resource.Success(body.data.map { Audio.fromDto(it) }))
return Resource.Success(body.data.map { Audio.fromDto(it) })
} else {
return Resource.Error(Resource.Error.ErrorType.ServerError)
}
} catch(e: Exception) {
Log.e("AudiosRepositoryImpl", e.message.toString())
emit(Resource.Error(Resource.Error.ErrorType.NetworkError))
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())
}
}
}

View File

@ -1,5 +1,7 @@
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
@ -13,17 +15,16 @@ data class Audio(
val creationTime: ZonedDateTime,
override val userId: Int,
val userName: String
): Ownable, Serializable {
var playing: Boolean = false
val downloadUrl: String get() {
return "${BASE_URL}audios/$id.webm"
): Ownable, Playable(), Serializable {
override val uri: Uri get() {
return "${BASE_URL}audios/$id.webm".toUri()
}
val thumbnail: String get() {
override val thumbnail: String get() {
return UrlGen.userProfileImage(userId)
}
companion object {
fun fromDto(audioDto: AudioDto): Audio {
return Audio(

View File

@ -5,4 +5,9 @@ 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

@ -0,0 +1,32 @@
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

@ -0,0 +1,9 @@
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

@ -0,0 +1,12 @@
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

@ -0,0 +1,53 @@
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

@ -0,0 +1,28 @@
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

@ -0,0 +1,26 @@
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

@ -0,0 +1,17 @@
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

@ -0,0 +1,12 @@
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

@ -0,0 +1,55 @@
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

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

View File

@ -0,0 +1,49 @@
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,9 +8,16 @@ 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
@ -28,7 +35,7 @@ class AudioPlayerConnector(
const val TAG = "AudioPlayerConnector"
}
private var player: Player? = null
private var audio: Audio? = null
private var audio: Playable? = null
private var mediaItem: MediaItem? = null
private var ended = false
@ -62,6 +69,9 @@ 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!!)}
@ -83,12 +93,23 @@ class AudioPlayerConnector(
Player.STATE_ENDED -> {
Log.d(TAG, "STATE_ENDED")
audio?.let {
listeners.forEach { listener -> listener.onPlaying(false, it)}
listeners.forEach { listener ->
listener.onPlaying(false, it)
listener.onEnded(it)
}
}
stopTimer()
ended = true
}
Player.STATE_BUFFERING -> {}
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_IDLE -> {}
Player.STATE_READY -> {
Log.d(TAG, "STATE_READY")
@ -107,7 +128,6 @@ class AudioPlayerConnector(
private fun initializePlayer() {
player = ExoPlayer.Builder(context).build()
player?.playWhenReady = true
player?.addListener(playerListener)
player?.prepare()
}
@ -125,23 +145,22 @@ 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_STOP, Lifecycle.Event.ON_DESTROY -> {
Lifecycle.Event.ON_DESTROY -> {
releasePlayer()
listeners.clear()
}
else -> {}
}
}
fun playPauseAudio(audio: Audio) {
fun playPauseAudio(audio: Playable) {
// intention is to pause current audio
if(audio == this.audio && player?.isPlaying == true) {
@ -157,15 +176,31 @@ class AudioPlayerConnector(
return
}
this.audio = audio
mediaItem = MediaItem.fromUri(Uri.parse(audio.downloadUrl))
mediaItem = MediaItem.fromUri(audio.uri)
player?.setMediaItem(mediaItem!!)
player?.playWhenReady = true
}
fun stopPlayback() {
ended = true
player?.pause()
stopTimer()
}
interface Listener {
fun onPlaying(isPlaying: Boolean, audio: Audio)
fun isLoading(isLoading: Boolean, audio: Audio)
fun progressChanged(second: Int, audio: Audio)
fun durationChanged(duration: Int, audio: Audio)
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) {}
}
}

View File

@ -0,0 +1,54 @@
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

@ -0,0 +1,21 @@
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

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

View File

@ -1,6 +1,337 @@
package com.isolaatti.audio.recorder.ui
import androidx.activity.ComponentActivity
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)
}
class AudioRecorderActivity : ComponentActivity() {
}

View File

@ -0,0 +1,29 @@
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

@ -0,0 +1,25 @@
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

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

View File

@ -0,0 +1,14 @@
package com.isolaatti.auth
import android.content.Context
import android.net.Uri
import androidx.browser.customtabs.CustomTabsIntent
import com.isolaatti.BuildConfig
const val RecoverPasswordRelativePath = "/recuperacion_cuenta"
fun openForgotPassword(context: Context) {
CustomTabsIntent.Builder()
.setShowTitle(true)
.build()
.launchUrl(context, Uri.parse("${BuildConfig.backend}$RecoverPasswordRelativePath"))
}

View File

@ -1,12 +1,13 @@
package com.isolaatti.auth
import com.isolaatti.auth.data.AuthRepositoryImpl
import com.isolaatti.settings.data.KeyValueDao
import com.isolaatti.auth.data.UserInfoRepositoryImpl
import com.isolaatti.auth.data.local.TokenStorage
import com.isolaatti.auth.data.local.UserInfoDao
import com.isolaatti.auth.data.remote.AuthApi
import com.isolaatti.auth.domain.AuthRepository
import com.isolaatti.auth.domain.UserInfoRepository
import com.isolaatti.connectivity.RetrofitClient
import com.isolaatti.database.AppDatabase
import com.isolaatti.settings.domain.UserIdSetting
import dagger.Module
import dagger.Provides
@ -25,4 +26,9 @@ class Module {
fun provideAuthRepository(tokenStorage: TokenStorage, authApi: AuthApi, userIdSetting: UserIdSetting): AuthRepository {
return AuthRepositoryImpl(tokenStorage, authApi, userIdSetting)
}
@Provides
fun provideUserInfoRepository(userIdSetting: UserIdSetting, userInfoDao: UserInfoDao): UserInfoRepository {
return UserInfoRepositoryImpl(userIdSetting, userInfoDao)
}
}

View File

@ -1,8 +1,6 @@
package com.isolaatti.auth.data
import com.isolaatti.BuildConfig
import com.isolaatti.settings.data.KeyValueDao
import com.isolaatti.settings.data.KeyValueEntity
import com.isolaatti.auth.data.remote.AuthTokenDto
import com.isolaatti.auth.data.local.TokenStorage
import com.isolaatti.auth.data.remote.AuthApi
@ -10,8 +8,12 @@ import com.isolaatti.auth.data.remote.Credential
import com.isolaatti.auth.domain.AuthRepository
import com.isolaatti.settings.domain.UserIdSetting
import com.isolaatti.utils.Resource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.launch
import retrofit2.awaitResponse
import javax.inject.Inject
@ -51,8 +53,15 @@ class AuthRepositoryImpl @Inject constructor(
return tokenStorage.token
}
override fun getUserId(): Flow<Int?> = flow {
emit(userIdSetting.getUserId())
override fun getUserId(): Flow<Int?> {
CoroutineScope(Dispatchers.IO).launch {
val currentUserId = userIdSetting.getUserIdAsync()
if(currentUserId == null) {
validateSession()
}
}
return userIdSetting.getUserId()
}
override suspend fun setToken(sessionDto: AuthTokenDto) {
@ -60,4 +69,17 @@ class AuthRepositoryImpl @Inject constructor(
userIdSetting.setUserId(sessionDto.userId)
}
private suspend fun validateSession() {
val token = tokenStorage.token?.token
if(token != null) {
try {
val response = authApi.validateTokenUrl(token).awaitResponse()
if(response.isSuccessful) {
response.body()?.userId?.let { userIdSetting.setUserId(it) }
}
} catch(_: Exception) { }
}
}
}

View File

@ -0,0 +1,22 @@
package com.isolaatti.auth.data
import com.isolaatti.auth.data.local.UserInfoDao
import com.isolaatti.auth.domain.UserInfo
import com.isolaatti.auth.domain.UserInfoRepository
import com.isolaatti.settings.data.KeyValueDao
import com.isolaatti.settings.domain.UserIdSetting
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.flow.map
import javax.inject.Inject
class UserInfoRepositoryImpl @Inject constructor(private val userIdSetting: UserIdSetting, private val userInfoDao: UserInfoDao) : UserInfoRepository {
override fun getCurrentUserInfo(): Flow<UserInfo> = flow {
val currentUserId = userIdSetting.getUserIdAsync()
if (currentUserId != null) {
emitAll(userInfoDao.getUserInfo(currentUserId).map { UserInfo.fromEntity(it) })
}
}
}

View File

@ -0,0 +1,16 @@
package com.isolaatti.auth.data.local
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface UserInfoDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun setUserInfo(userInfoEntity: UserInfoEntity)
@Query("SELECT * FROM user_info WHERE id = :id LIMIT 1")
fun getUserInfo(id: Int): Flow<UserInfoEntity>
}

View File

@ -0,0 +1,11 @@
package com.isolaatti.auth.data.local
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "user_info")
data class UserInfoEntity(
@PrimaryKey val id: Int,
val username: String,
val displayName: String,
)

View File

@ -1,6 +1,7 @@
package com.isolaatti.auth.domain
import com.isolaatti.auth.data.remote.AuthTokenDto
import com.isolaatti.auth.data.remote.AuthTokenVerificationDto
import com.isolaatti.utils.Resource
import kotlinx.coroutines.flow.Flow

View File

@ -1,6 +1,8 @@
package com.isolaatti.auth.domain
import android.content.Context
import android.content.Intent
import com.isolaatti.MainActivity
import com.isolaatti.auth.data.local.TokenStorage
import com.isolaatti.settings.domain.AccountSettingsRepository
import dagger.hilt.android.qualifiers.ActivityContext
@ -8,10 +10,14 @@ import javax.inject.Inject
class SignOutUC @Inject constructor(
@ActivityContext private val context: Context,
private val tokenStorage: TokenStorage,
private val accountSettingsRepository: AccountSettingsRepository
private val tokenStorage: TokenStorage
) {
operator fun invoke() {
tokenStorage.removeToken()
val loginIntent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
context.startActivity(loginIntent)
}
}

View File

@ -0,0 +1,15 @@
package com.isolaatti.auth.domain
import com.isolaatti.auth.data.local.UserInfoEntity
import com.isolaatti.utils.UrlGen
data class UserInfo(val id: Int, val username: String, val displayName: String) {
val imageUrl get() = UrlGen.userProfileImage(id, false)
companion object {
fun fromEntity(userInfoEntity: UserInfoEntity): UserInfo {
return UserInfo(userInfoEntity.id, userInfoEntity.username, userInfoEntity.displayName)
}
}
}

View File

@ -0,0 +1,7 @@
package com.isolaatti.auth.domain
import kotlinx.coroutines.flow.Flow
interface UserInfoRepository {
fun getCurrentUserInfo(): Flow<UserInfo>
}

View File

@ -19,4 +19,17 @@ 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,7 +6,6 @@ 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
@ -14,9 +13,7 @@ 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

@ -0,0 +1,27 @@
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,8 +1,14 @@
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

@ -0,0 +1,4 @@
package com.isolaatti.common
@JvmInline
value class ResultDto<T>(val result: T)

View File

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

View File

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

View File

@ -23,6 +23,7 @@ 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
}
}
@ -30,6 +31,7 @@ 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())
@ -93,5 +95,10 @@ 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,11 +9,16 @@ 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
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -36,37 +41,36 @@ 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()?.let { userId ->
userIdSetting.getUserId().onEach { userId ->
_options.postValue(
Options.getPostsOptions(
userOwned = userId == payload?.userId,
savable = false,
snapshotAble = false)
userOwned = userId == payload?.userId,
savable = false,
snapshotAble = false)
)
_callerId = callerId
_payload = payload
}
}.flowOn(Dispatchers.IO).launchIn(this)
}
COMMENT_OPTIONS -> {
userIdSetting.getUserId()?.let { userId ->
userIdSetting.getUserId().onEach { userId ->
_options.postValue(
Options.getCommentOptions(
userOwned = userId == payload?.userId,
savable = false,
snapshotAble = false)
userOwned = userId == payload?.userId,
savable = false,
snapshotAble = false)
)
_callerId = callerId
_payload = payload
}
}.flowOn(Dispatchers.IO).launchIn(this)
}
PROFILE_PHOTO_OPTIONS -> {
userIdSetting.getUserId()?.let { userId ->
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,6 +26,12 @@ 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,11 +2,22 @@ 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], version = 1)
@Database(entities = [KeyValueEntity::class, UserInfoEntity::class, AudioDraftEntity::class, SearchHistoryEntity::class, ImageDraftEntity::class], version = 8)
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

@ -15,6 +15,8 @@ class Module {
@Provides
@Singleton
fun provideAppDatabase(@ApplicationContext applicationContext: Context): AppDatabase {
return Room.databaseBuilder(applicationContext, AppDatabase::class.java, "database.db").build()
return Room.databaseBuilder(applicationContext, AppDatabase::class.java, "database.db")
.fallbackToDestructiveMigration()
.build()
}
}

View File

@ -4,15 +4,16 @@ 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) : FragmentStateAdapter(fragment) {
class FollowersViewPagerAdapter(fragment: Fragment, private val userId: Int) : FragmentStateAdapter(fragment) {
override fun getItemCount(): Int = 2
override fun createFragment(position: Int): Fragment {
if(position == 0) {
return FollowersFragment()
return ProfileListingFragment.getInstanceForFollowers(userId)
}
return FollowingFragment()
return ProfileListingFragment.getInstanceForFollowing(userId)
}
}

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.FragmentFollowersBinding
import com.isolaatti.databinding.FragmentUserListBinding
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: FragmentFollowersBinding
private lateinit var binding: FragmentUserListBinding
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 = FragmentFollowersBinding.inflate(inflater)
binding = FragmentUserListBinding.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.FragmentFollowersBinding
import com.isolaatti.databinding.FragmentUserListBinding
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: FragmentFollowersBinding
private lateinit var binding: FragmentUserListBinding
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 = FragmentFollowersBinding.inflate(inflater)
binding = FragmentUserListBinding.inflate(inflater)
return binding.root
}

View File

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

View File

@ -0,0 +1,12 @@
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

@ -0,0 +1,35 @@
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

@ -0,0 +1,46 @@
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,40 +0,0 @@
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

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

View File

@ -1,33 +0,0 @@
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,9 +22,8 @@ import javax.inject.Inject
@HiltViewModel
class FeedViewModel @Inject constructor(
private val getProfileUseCase: GetProfile,
private val authRepository: AuthRepository,
private val postsRepository: PostsRepository
) : PostListingViewModelBase() {
private val authRepository: AuthRepository
) : ViewModel() {
private val toRetry: MutableList<Runnable> = mutableListOf()
@ -39,41 +38,7 @@ 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()
@ -88,7 +53,6 @@ class FeedViewModel @Inject constructor(
when(profile) {
is Resource.Error -> {
errorLoading.postValue(profile.errorType)
toRetry.add {
getProfile()
}

View File

@ -1,22 +0,0 @@
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

@ -0,0 +1,149 @@
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

@ -0,0 +1,80 @@
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,7 +28,12 @@ class Module {
}
@Provides
fun provideImagesRepository(imagesApi: ImagesApi, contentResolver: ContentResolver): ImagesRepository {
return ImagesRepositoryImpl(imagesApi, contentResolver)
fun provideImagesDraftDao(database: AppDatabase): ImagesDraftsDao {
return database.imagesDraftsDao()
}
@Provides
fun provideImagesRepository(imagesApi: ImagesApi, imagesDraftsDao: ImagesDraftsDao, contentResolver: ContentResolver): ImagesRepository {
return ImagesRepositoryImpl(imagesApi, imagesDraftsDao, contentResolver)
}
}

View File

@ -0,0 +1,122 @@
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

@ -0,0 +1,19 @@
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

@ -0,0 +1,13 @@
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,7 +3,6 @@ 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,6 +6,8 @@ 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
@ -20,7 +22,7 @@ import java.io.ByteArrayOutputStream
import java.io.InputStream
import javax.inject.Inject
class ImagesRepositoryImpl @Inject constructor(private val imagesApi: ImagesApi, private val contentResolver: ContentResolver) :
class ImagesRepositoryImpl @Inject constructor(private val imagesApi: ImagesApi, private val imagesDraftsDao: ImagesDraftsDao, private val contentResolver: ContentResolver) :
ImagesRepository {
companion object {
@ -104,4 +106,12 @@ 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,7 +9,6 @@ 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)
@ -19,7 +18,7 @@ data class Image(
val markdown: String get() = Generators.generateImage(imageUrl)
companion object {
fun fromDto(imageDto: ImageDto) = Image(imageDto.id, imageDto.userId, imageDto.name, imageDto.username)
fun fromDto(imageDto: ImageDto) = Image(imageDto.id, imageDto.userId, imageDto.username)
}
override fun equals(other: Any?): Boolean {
@ -30,7 +29,6 @@ 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
@ -39,7 +37,6 @@ 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,6 +1,7 @@
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
@ -9,4 +10,6 @@ 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,9 +5,11 @@ 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, Image?>() {
class ImageChooserContract : ActivityResultContract<ImageChooserContract.Requester, ImageDraftEntity?>() {
enum class Requester {
UserPost, SquadPost
@ -18,13 +20,13 @@ class ImageChooserContract : ActivityResultContract<ImageChooserContract.Request
}
}
override fun parseResult(resultCode: Int, intent: Intent?): Image? {
override fun parseResult(resultCode: Int, intent: Intent?): ImageDraftEntity? {
if(resultCode != Activity.RESULT_OK) { return null }
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)
if(intent == null) {
return null
}
return IntentCompat.getSerializableExtra(intent, ImageChooserActivity.OUTPUT_EXTRA_IMAGE, ImageDraftEntity::class.java)
}
}

View File

@ -38,7 +38,6 @@ 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,9 +51,6 @@ class ImageMakerActivity : IsolaattiBaseActivity() {
binding.uploadPhotoFab.setOnClickListener {
viewModel.uploadPicture()
}
binding.textImageName.editText?.doOnTextChanged { text, _, _, _ ->
viewModel.name = text.toString()
}
binding.toolbar.setNavigationOnClickListener {
showExitConfirmationDialog()
}
@ -66,12 +63,10 @@ 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,7 +20,6 @@ class PictureViewerMainFragment : Fragment() {
override fun onPageSelected(position: Int) {
super.onPageSelected(position)
binding.imageAuthor.text = images[position].username
binding.imageDescription.text = images[position].name
}
}
@ -43,7 +42,6 @@ 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,18 +1,16 @@
package com.isolaatti.login
import android.app.Activity
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.widget.doOnTextChanged
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.isolaatti.BuildConfig
import com.isolaatti.R
import com.isolaatti.auth.openForgotPassword
import com.isolaatti.databinding.ActivityLoginBinding
import com.isolaatti.sign_up.ui.SignUpActivity
import com.isolaatti.utils.Resource
@ -92,7 +90,7 @@ class LogInActivity : AppCompatActivity() {
}
viewBinding.forgotPasswordBtn.setOnClickListener {
openForgotPassword()
openForgotPassword(this)
}
viewBinding.signUpBtn.setOnClickListener {
@ -101,17 +99,12 @@ class LogInActivity : AppCompatActivity() {
}
private fun openForgotPassword() {
CustomTabsIntent.Builder()
.setShowTitle(true)
.build()
.launchUrl(this, Uri.parse("${BuildConfig.backend}/recuperacion_cuenta"))
}
private fun showWrongPasswordErrorMessage() {
MaterialAlertDialogBuilder(this)
.setMessage(R.string.wrong_password)
.setNeutralButton(R.string.forgot_password) {_,_ -> openForgotPassword()}
.setNeutralButton(R.string.forgot_password) {_,_ -> openForgotPassword(this)}
.setPositiveButton(R.string.dismiss, null)
.show()
}

View File

@ -0,0 +1,25 @@
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

@ -0,0 +1,38 @@
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

@ -0,0 +1,15 @@
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

@ -0,0 +1,25 @@
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

@ -0,0 +1,15 @@
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

@ -0,0 +1,17 @@
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

@ -0,0 +1,48 @@
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))
}
}
}

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