Compare commits
55 Commits
WIP-releas
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| a32757c518 | |||
| 7f2e16c580 | |||
| 7c073b80c6 | |||
| b0d3e28352 | |||
| a2c9091598 | |||
| c00bd001ea | |||
| ab76e3827a | |||
| 50a70c6207 | |||
| 1540d8ec2a | |||
| a8c262048e | |||
| 8a345fc59b | |||
| 837b38ef97 | |||
| af8c785b01 | |||
| 4f5b044f8f | |||
| ba9aa4c395 | |||
| e12bbb2e94 | |||
| 8d15bce887 | |||
| be4e0ef294 | |||
| 3f67933bc8 | |||
| bf886c845d | |||
| ce8ec23100 | |||
| 72568725d4 | |||
| ade2027b17 | |||
| 133483387f | |||
| e50489abf1 | |||
| 12344011ba | |||
| ff1733de01 | |||
| 061814e7e8 | |||
| 94c7ec1b93 | |||
| 3ef8961729 | |||
| 000937ded4 | |||
| 49ccebb539 | |||
| 8730bbe796 | |||
| d815d717e2 | |||
| a26adc11f8 | |||
| 530d8e7fa2 | |||
| 236a2ce957 | |||
| d8dfda8d04 | |||
| 34f2c51f78 | |||
| 9667eafa49 | |||
| bd994a17e8 | |||
| 049d97aeb8 | |||
| 313b5b5ffe | |||
| 16f9d7f90d | |||
| 0aa7b1001f | |||
| c87e12caab | |||
| 896912d514 | |||
| cd10aced57 | |||
| bfa0fdcecf | |||
| c790a0064c | |||
| 835c7304c2 | |||
| 5084fe337d | |||
| 3ff49d73ce | |||
| 96fd70556b | |||
| b539f03ef6 |
28
.idea/assetWizardSettings.xml
generated
28
.idea/assetWizardSettings.xml
generated
@ -79,7 +79,7 @@
|
|||||||
<option name="values">
|
<option name="values">
|
||||||
<map>
|
<map>
|
||||||
<entry key="color" value="000000" />
|
<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="scalingPercent" value="74" />
|
||||||
<entry key="trimmed" value="true" />
|
<entry key="trimmed" value="true" />
|
||||||
</map>
|
</map>
|
||||||
@ -181,6 +181,19 @@
|
|||||||
</PersistentState>
|
</PersistentState>
|
||||||
</value>
|
</value>
|
||||||
</entry>
|
</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">
|
<entry key="text">
|
||||||
<value>
|
<value>
|
||||||
<PersistentState>
|
<PersistentState>
|
||||||
@ -205,6 +218,13 @@
|
|||||||
</entry>
|
</entry>
|
||||||
</map>
|
</map>
|
||||||
</option>
|
</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>
|
</PersistentState>
|
||||||
</value>
|
</value>
|
||||||
</entry>
|
</entry>
|
||||||
@ -308,7 +328,7 @@
|
|||||||
<PersistentState>
|
<PersistentState>
|
||||||
<option name="values">
|
<option name="values">
|
||||||
<map>
|
<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>
|
</map>
|
||||||
</option>
|
</option>
|
||||||
</PersistentState>
|
</PersistentState>
|
||||||
@ -318,8 +338,8 @@
|
|||||||
</option>
|
</option>
|
||||||
<option name="values">
|
<option name="values">
|
||||||
<map>
|
<map>
|
||||||
<entry key="outputName" value="baseline_password_24" />
|
<entry key="outputName" value="baseline_article_24" />
|
||||||
<entry key="sourceFile" value="C:\Users\erike\Downloads\face-kiss-wink-heart-solid.svg" />
|
<entry key="sourceFile" value="C:\Users\erike\Downloads\hashtag-solid.svg" />
|
||||||
</map>
|
</map>
|
||||||
</option>
|
</option>
|
||||||
</PersistentState>
|
</PersistentState>
|
||||||
|
|||||||
2
.idea/kotlinc.xml
generated
2
.idea/kotlinc.xml
generated
@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="KotlinJpsPluginSettings">
|
<component name="KotlinJpsPluginSettings">
|
||||||
<option name="version" value="1.8.20" />
|
<option name="version" value="1.9.0" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
203
.idea/navEditor.xml
generated
203
.idea/navEditor.xml
generated
@ -13,22 +13,41 @@
|
|||||||
<LayoutPositions>
|
<LayoutPositions>
|
||||||
<option name="myPosition">
|
<option name="myPosition">
|
||||||
<Point>
|
<Point>
|
||||||
<option name="x" value="130" />
|
<option name="x" value="40" />
|
||||||
<option name="y" value="18" />
|
<option name="y" value="40" />
|
||||||
</Point>
|
</Point>
|
||||||
</option>
|
</option>
|
||||||
</LayoutPositions>
|
</LayoutPositions>
|
||||||
</value>
|
</value>
|
||||||
</entry>
|
</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>
|
<value>
|
||||||
<LayoutPositions>
|
<LayoutPositions>
|
||||||
<option name="myPosition">
|
<option name="myPosition">
|
||||||
<Point>
|
<Point>
|
||||||
<option name="x" value="-74" />
|
<option name="x" value="-258" />
|
||||||
<option name="y" value="17" />
|
<option name="y" value="8" />
|
||||||
</Point>
|
</Point>
|
||||||
</option>
|
</option>
|
||||||
|
<option name="myPositions">
|
||||||
|
<map>
|
||||||
|
<entry key="action_commentThreadFragment_self">
|
||||||
|
<value>
|
||||||
|
<LayoutPositions />
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
</LayoutPositions>
|
</LayoutPositions>
|
||||||
</value>
|
</value>
|
||||||
</entry>
|
</entry>
|
||||||
@ -71,11 +90,66 @@
|
|||||||
</LayoutPositions>
|
</LayoutPositions>
|
||||||
</value>
|
</value>
|
||||||
</entry>
|
</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">
|
<entry key="home_navigation.xml">
|
||||||
<value>
|
<value>
|
||||||
<LayoutPositions>
|
<LayoutPositions>
|
||||||
<option name="myPositions">
|
<option name="myPositions">
|
||||||
<map>
|
<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">
|
<entry key="feedFragment">
|
||||||
<value>
|
<value>
|
||||||
<LayoutPositions>
|
<LayoutPositions>
|
||||||
@ -97,6 +171,39 @@
|
|||||||
</LayoutPositions>
|
</LayoutPositions>
|
||||||
</value>
|
</value>
|
||||||
</entry>
|
</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">
|
<entry key="notificationsFragment">
|
||||||
<value>
|
<value>
|
||||||
<LayoutPositions>
|
<LayoutPositions>
|
||||||
@ -118,18 +225,6 @@
|
|||||||
</LayoutPositions>
|
</LayoutPositions>
|
||||||
</value>
|
</value>
|
||||||
</entry>
|
</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">
|
<entry key="profileActivity">
|
||||||
<value>
|
<value>
|
||||||
<LayoutPositions>
|
<LayoutPositions>
|
||||||
@ -142,15 +237,41 @@
|
|||||||
</LayoutPositions>
|
</LayoutPositions>
|
||||||
</value>
|
</value>
|
||||||
</entry>
|
</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">
|
<entry key="searchFragment">
|
||||||
<value>
|
<value>
|
||||||
<LayoutPositions>
|
<LayoutPositions>
|
||||||
<option name="myPosition">
|
<option name="myPosition">
|
||||||
<Point>
|
<Point>
|
||||||
<option name="x" value="12" />
|
<option name="x" value="-278" />
|
||||||
<option name="y" value="12" />
|
<option name="y" value="216" />
|
||||||
</Point>
|
</Point>
|
||||||
</option>
|
</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>
|
</LayoutPositions>
|
||||||
</value>
|
</value>
|
||||||
</entry>
|
</entry>
|
||||||
@ -258,6 +379,50 @@
|
|||||||
</LayoutPositions>
|
</LayoutPositions>
|
||||||
</value>
|
</value>
|
||||||
</entry>
|
</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">
|
<entry key="profile_navigation.xml">
|
||||||
<value>
|
<value>
|
||||||
<LayoutPositions>
|
<LayoutPositions>
|
||||||
|
|||||||
13
README.md
13
README.md
@ -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
|
* 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)
|
* 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
|
## Características planeadas
|
||||||
* Grabar audios
|
* Grabar audios
|
||||||
|
|||||||
@ -7,6 +7,9 @@ plugins {
|
|||||||
id 'org.jetbrains.kotlin.plugin.serialization' version '1.8.0'
|
id 'org.jetbrains.kotlin.plugin.serialization' version '1.8.0'
|
||||||
id 'com.google.android.libraries.mapsplatform.secrets-gradle-plugin'
|
id 'com.google.android.libraries.mapsplatform.secrets-gradle-plugin'
|
||||||
id 'androidx.navigation.safeargs.kotlin'
|
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 {
|
android {
|
||||||
@ -16,12 +19,20 @@ android {
|
|||||||
enabled = true
|
enabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose true
|
||||||
|
}
|
||||||
|
|
||||||
|
composeOptions {
|
||||||
|
kotlinCompilerExtensionVersion = "1.5.2"
|
||||||
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "com.isolaatti"
|
applicationId "com.isolaatti"
|
||||||
minSdk 24
|
minSdk 24
|
||||||
targetSdk 34
|
targetSdk 34
|
||||||
versionCode 1
|
versionCode 7
|
||||||
versionName "1.0"
|
versionName "0.7-vc7"
|
||||||
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@ -45,11 +56,11 @@ android {
|
|||||||
dependencies {
|
dependencies {
|
||||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3'
|
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.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.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.legacy:legacy-support-v4:1.0.0'
|
||||||
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1'
|
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1'
|
||||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1'
|
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1'
|
||||||
@ -59,6 +70,7 @@ dependencies {
|
|||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||||
|
implementation "androidx.datastore:datastore-preferences:1.0.0"
|
||||||
|
|
||||||
// Hilt
|
// Hilt
|
||||||
implementation "com.google.dagger:hilt-android:2.47"
|
implementation "com.google.dagger:hilt-android:2.47"
|
||||||
@ -70,10 +82,10 @@ dependencies {
|
|||||||
|
|
||||||
|
|
||||||
// Material 3
|
// Material 3
|
||||||
implementation "com.google.android.material:material:1.9.0"
|
implementation "com.google.android.material:material:1.12.0"
|
||||||
|
|
||||||
// Navigation
|
// 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-fragment-ktx:$nav_version"
|
||||||
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
|
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
|
||||||
implementation "androidx.navigation:navigation-dynamic-features-fragment:$nav_version"
|
implementation "androidx.navigation:navigation-dynamic-features-fragment:$nav_version"
|
||||||
@ -89,7 +101,7 @@ dependencies {
|
|||||||
final def markwon_version = '4.6.2'
|
final def markwon_version = '4.6.2'
|
||||||
|
|
||||||
// Customtabs
|
// Customtabs
|
||||||
implementation 'androidx.browser:browser:1.7.0'
|
implementation 'androidx.browser:browser:1.8.0'
|
||||||
|
|
||||||
implementation 'io.coil-kt:coil:2.5.0'
|
implementation 'io.coil-kt:coil:2.5.0'
|
||||||
implementation 'io.coil-kt:coil-svg:2.5.0'
|
implementation 'io.coil-kt:coil-svg:2.5.0'
|
||||||
@ -125,4 +137,27 @@ dependencies {
|
|||||||
// QR
|
// QR
|
||||||
implementation 'com.github.androidmads:QRGenerator:1.0.1'
|
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"
|
||||||
}
|
}
|
||||||
@ -1,12 +1,13 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
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.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<application
|
<application
|
||||||
android:name=".MyApplication"
|
android:name=".MyApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="false"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
@ -15,6 +16,15 @@
|
|||||||
android:theme="@style/Theme.Isolaatti"
|
android:theme="@style/Theme.Isolaatti"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
tools:targetApi="31">
|
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
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@ -25,14 +35,15 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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=".login.LogInActivity" android:theme="@style/Theme.Isolaatti" />
|
||||||
<activity android:name=".profile.ui.ProfileActivity"
|
<activity android:name=".profile.ui.ProfileActivity"
|
||||||
android:theme="@style/Theme.Isolaatti"
|
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=".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.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=".drafts.ui.DraftsActivity" android:theme="@style/Theme.Isolaatti"/>
|
||||||
<activity android:name=".about.AboutActivity" 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"/>
|
<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_maker.ui.ImageMakerActivity" android:theme="@style/Theme.Isolaatti"/>
|
||||||
<activity android:name=".images.image_chooser.ui.ImageChooserActivity" 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=".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
|
<provider
|
||||||
android:authorities="com.isolaatti.provider"
|
android:authorities="com.isolaatti.provider"
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
@ -48,6 +63,15 @@
|
|||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
android:resource="@xml/provider_paths"/>
|
android:resource="@xml/provider_paths"/>
|
||||||
</provider>
|
</provider>
|
||||||
|
<service android:name=".push_notifications.FcmService"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
</service>
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 11 KiB |
@ -7,7 +7,7 @@ import androidx.activity.ComponentActivity
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import com.isolaatti.auth.data.AuthRepositoryImpl
|
import com.isolaatti.auth.data.AuthRepositoryImpl
|
||||||
import com.isolaatti.home.HomeActivity
|
import com.isolaatti.home.ui.HomeActivity
|
||||||
import com.isolaatti.login.LogInActivity
|
import com.isolaatti.login.LogInActivity
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|||||||
@ -1,17 +1,42 @@
|
|||||||
package com.isolaatti
|
package com.isolaatti
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.content.Context
|
||||||
import android.net.ConnectivityManager
|
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.connectivity.ConnectivityCallbackImpl
|
||||||
|
import com.isolaatti.push_notifications.PushNotificationsApi
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
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
|
@HiltAndroidApp
|
||||||
class MyApplication : Application() {
|
class MyApplication : Application() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
lateinit var myApp: MyApplication
|
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()
|
private val activityLifecycleCallbacks = ActivityLifecycleCallbacks()
|
||||||
lateinit var connectivityCallbackImpl: ConnectivityCallbackImpl
|
lateinit var connectivityCallbackImpl: ConnectivityCallbackImpl
|
||||||
|
|
||||||
@ -21,6 +46,28 @@ class MyApplication : Application() {
|
|||||||
registerActivityLifecycleCallbacks(activityLifecycleCallbacks)
|
registerActivityLifecycleCallbacks(activityLifecycleCallbacks)
|
||||||
connectivityCallbackImpl = ConnectivityCallbackImpl()
|
connectivityCallbackImpl = ConnectivityCallbackImpl()
|
||||||
getSystemService(ConnectivityManager::class.java).registerDefaultNetworkCallback(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() {
|
override fun onTerminate() {
|
||||||
|
|||||||
@ -1,10 +1,13 @@
|
|||||||
package com.isolaatti.about
|
package com.isolaatti.about
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.browser.customtabs.CustomTabsIntent
|
import androidx.browser.customtabs.CustomTabsIntent
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
|
import com.google.android.gms.oss.licenses.OssLicensesMenuActivity
|
||||||
import com.isolaatti.BuildConfig
|
import com.isolaatti.BuildConfig
|
||||||
|
import com.isolaatti.R
|
||||||
import com.isolaatti.databinding.ActivityAboutBinding
|
import com.isolaatti.databinding.ActivityAboutBinding
|
||||||
|
|
||||||
class AboutActivity : AppCompatActivity() {
|
class AboutActivity : AppCompatActivity() {
|
||||||
@ -35,9 +38,8 @@ class AboutActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
binding.openSourceLicences.setOnClickListener {
|
binding.openSourceLicences.setOnClickListener {
|
||||||
CustomTabsIntent.Builder()
|
startActivity(Intent(this, OssLicensesMenuActivity::class.java))
|
||||||
.build()
|
|
||||||
.launchUrl(this, BuildConfig.openSourceLicences.toUri())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.privacyPolicyButton.setOnClickListener {
|
binding.privacyPolicyButton.setOnClickListener {
|
||||||
@ -51,5 +53,7 @@ class AboutActivity : AppCompatActivity() {
|
|||||||
.build()
|
.build()
|
||||||
.launchUrl(this, BuildConfig.terms.toUri())
|
.launchUrl(this, BuildConfig.terms.toUri())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
binding.appVersion.text = getString(R.string.app_version, BuildConfig.VERSION_NAME)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -4,7 +4,12 @@ import androidx.media3.common.Player
|
|||||||
import com.isolaatti.audio.common.data.AudiosApi
|
import com.isolaatti.audio.common.data.AudiosApi
|
||||||
import com.isolaatti.audio.common.data.AudiosRepositoryImpl
|
import com.isolaatti.audio.common.data.AudiosRepositoryImpl
|
||||||
import com.isolaatti.audio.common.domain.AudiosRepository
|
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.connectivity.RetrofitClient
|
||||||
|
import com.isolaatti.database.AppDatabase
|
||||||
|
import com.isolaatti.settings.domain.UserIdSetting
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
@ -19,7 +24,17 @@ class Module {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
fun provideAudiosRepository(audiosApi: AudiosApi): AudiosRepository {
|
fun provideAudiosRepository(audiosApi: AudiosApi, audiosDraftsDao: AudiosDraftsDao, userIdSetting: UserIdSetting): AudiosRepository {
|
||||||
return AudiosRepositoryImpl(audiosApi)
|
return AudiosRepositoryImpl(audiosApi, audiosDraftsDao, userIdSetting)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideAudioDraftsDao(database: AppDatabase): AudiosDraftsDao {
|
||||||
|
return database.audioDrafts()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideAudioDraftsRepository(audiosDraftsDao: AudiosDraftsDao): AudioDraftsRepository {
|
||||||
|
return AudioDraftsRepositoryImpl(audiosDraftsDao)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,24 +3,85 @@ package com.isolaatti.audio.audios_list.presentation
|
|||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.content.res.ResourcesCompat
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||||
import coil.load
|
import coil.load
|
||||||
|
import com.isolaatti.R
|
||||||
import com.isolaatti.audio.common.domain.Audio
|
import com.isolaatti.audio.common.domain.Audio
|
||||||
import com.isolaatti.databinding.AudioListItemBinding
|
import com.isolaatti.databinding.AudioListItemBinding
|
||||||
|
|
||||||
class AudiosAdapter(
|
class AudiosAdapter(
|
||||||
private val onClick: ((audio: Audio) -> Unit),
|
private val onPlayClick: ((audio: Audio) -> Unit),
|
||||||
private val onOptionsClick: ((audio: Audio, button: View) -> Boolean)
|
private val onOptionsClick: ((audio: Audio, button: View) -> Boolean)
|
||||||
) : RecyclerView.Adapter<AudiosAdapter.AudiosViewHolder>() {
|
) : RecyclerView.Adapter<AudiosAdapter.AudiosViewHolder>() {
|
||||||
|
|
||||||
|
enum class Payload {
|
||||||
|
PlayStateChanged, IsLoadingChanged
|
||||||
|
}
|
||||||
|
|
||||||
private var data: List<Audio> = listOf()
|
private var data: List<Audio> = listOf()
|
||||||
|
private var currentPlaying: Audio? = null
|
||||||
|
|
||||||
fun setData(audios: List<Audio>) {
|
fun setData(audios: List<Audio>) {
|
||||||
data = audios
|
data = audios
|
||||||
notifyDataSetChanged()
|
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)
|
inner class AudiosViewHolder(val binding: AudioListItemBinding) : ViewHolder(binding.root)
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AudiosViewHolder {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AudiosViewHolder {
|
||||||
@ -32,19 +93,62 @@ class AudiosAdapter(
|
|||||||
return data.size
|
return data.size
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: AudiosViewHolder, position: Int) {
|
override fun onBindViewHolder(
|
||||||
|
holder: AudiosViewHolder,
|
||||||
|
position: Int,
|
||||||
|
payloads: MutableList<Any>
|
||||||
|
) {
|
||||||
val audio = data[position]
|
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 {
|
holder.binding.audioName.text = audio.name
|
||||||
onClick(audio)
|
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 {
|
// only updates play button
|
||||||
onOptionsClick(audio, it)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -17,7 +17,7 @@ import javax.inject.Inject
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AudiosViewModel @Inject constructor(private val audiosRepository: AudiosRepository) : ViewModel() {
|
class AudiosViewModel @Inject constructor(private val audiosRepository: AudiosRepository) : ViewModel() {
|
||||||
val resource: MutableLiveData<Resource<List<Audio>>> = MutableLiveData()
|
val resource: MutableLiveData<Resource<List<Audio>>> = MutableLiveData()
|
||||||
|
val audioRemoved: MutableLiveData<Audio?> = MutableLiveData()
|
||||||
|
|
||||||
fun loadAudios(userId: Int) {
|
fun loadAudios(userId: Int) {
|
||||||
viewModelScope.launch {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -8,16 +8,21 @@ import androidx.appcompat.widget.PopupMenu
|
|||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.navigation.fragment.findNavController
|
||||||
import androidx.navigation.fragment.navArgs
|
import androidx.navigation.fragment.navArgs
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import com.isolaatti.R
|
import com.isolaatti.R
|
||||||
import com.isolaatti.audio.audios_list.presentation.AudiosAdapter
|
import com.isolaatti.audio.audios_list.presentation.AudiosAdapter
|
||||||
import com.isolaatti.audio.audios_list.presentation.AudiosViewModel
|
import com.isolaatti.audio.audios_list.presentation.AudiosViewModel
|
||||||
import com.isolaatti.audio.common.domain.Audio
|
import com.isolaatti.audio.common.domain.Audio
|
||||||
|
import com.isolaatti.audio.common.domain.Playable
|
||||||
|
import com.isolaatti.audio.player.AudioPlayerConnector
|
||||||
import com.isolaatti.common.ErrorMessageViewModel
|
import com.isolaatti.common.ErrorMessageViewModel
|
||||||
import com.isolaatti.databinding.FragmentAudiosBinding
|
import com.isolaatti.databinding.FragmentAudiosBinding
|
||||||
import com.isolaatti.utils.Resource
|
import com.isolaatti.utils.Resource
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlin.properties.Delegates
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class AudiosFragment : Fragment() {
|
class AudiosFragment : Fragment() {
|
||||||
@ -26,6 +31,9 @@ class AudiosFragment : Fragment() {
|
|||||||
private val errorViewModel: ErrorMessageViewModel by activityViewModels()
|
private val errorViewModel: ErrorMessageViewModel by activityViewModels()
|
||||||
private val arguments: AudiosFragmentArgs by navArgs()
|
private val arguments: AudiosFragmentArgs by navArgs()
|
||||||
private lateinit var adapter: AudiosAdapter
|
private lateinit var adapter: AudiosAdapter
|
||||||
|
private var privilegedUserId by Delegates.notNull<Int>()
|
||||||
|
|
||||||
|
private lateinit var audioPlayerConnector: AudioPlayerConnector
|
||||||
|
|
||||||
private var loadedFirstTime = false
|
private var loadedFirstTime = false
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
@ -38,24 +46,81 @@ class AudiosFragment : Fragment() {
|
|||||||
return viewBinding.root
|
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 ->
|
private val onOptionsClick: ((audio: Audio, button: View) -> Boolean) = { audio, button ->
|
||||||
val popup = PopupMenu(requireContext(), button)
|
val popup = PopupMenu(requireContext(), button)
|
||||||
popup.menuInflater.inflate(R.menu.audio_item_menu, popup.menu)
|
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()
|
popup.show()
|
||||||
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
private val onAudioClick: ((audio: Audio) -> Unit) = {
|
private val onAudioPlayClick: (audio: Audio) -> Unit = {
|
||||||
// Play audio
|
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?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
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.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false)
|
||||||
viewBinding.recyclerAudios.adapter = adapter
|
viewBinding.recyclerAudios.adapter = adapter
|
||||||
@ -63,7 +128,12 @@ class AudiosFragment : Fragment() {
|
|||||||
|
|
||||||
setupObservers()
|
setupObservers()
|
||||||
if(arguments.source == SOURCE_PROFILE) {
|
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 {
|
companion object {
|
||||||
const val SOURCE_PROFILE = "source_profile"
|
const val SOURCE_PROFILE = "source_profile"
|
||||||
const val SOURCE_SQUAD = "source_squads"
|
const val SOURCE_SQUAD = "source_squads"
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package com.isolaatti.audio.common.data
|
package com.isolaatti.audio.common.data
|
||||||
|
|
||||||
|
import java.io.Serializable
|
||||||
import java.time.ZonedDateTime
|
import java.time.ZonedDateTime
|
||||||
|
|
||||||
data class AudiosDto(val data: List<AudioDto>)
|
data class AudiosDto(val data: List<AudioDto>)
|
||||||
@ -10,4 +11,4 @@ data class AudioDto(
|
|||||||
val userId: Int,
|
val userId: Int,
|
||||||
val firestoreObjectPath: String,
|
val firestoreObjectPath: String,
|
||||||
val userName: String
|
val userName: String
|
||||||
)
|
): Serializable
|
||||||
@ -1,11 +1,28 @@
|
|||||||
package com.isolaatti.audio.common.data
|
package com.isolaatti.audio.common.data
|
||||||
|
|
||||||
|
import com.isolaatti.common.ResultDto
|
||||||
|
import okhttp3.MultipartBody
|
||||||
import retrofit2.Call
|
import retrofit2.Call
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Multipart
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Part
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
|
|
||||||
interface AudiosApi {
|
interface AudiosApi {
|
||||||
|
companion object {
|
||||||
|
const val AudioParam= "audioFile"
|
||||||
|
const val NameParam = "name"
|
||||||
|
const val DurationParam = "duration"
|
||||||
|
}
|
||||||
@GET("/api/Audios/OfUser/{userId}")
|
@GET("/api/Audios/OfUser/{userId}")
|
||||||
fun getAudiosOfUser(@Path("userId") userId: Int, @Query("lastAudioId") lastAudioId: String?): Call<AudiosDto>
|
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>>
|
||||||
}
|
}
|
||||||
@ -1,32 +1,117 @@
|
|||||||
package com.isolaatti.audio.common.data
|
package com.isolaatti.audio.common.data
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.isolaatti.MyApplication
|
||||||
import com.isolaatti.audio.common.domain.Audio
|
import com.isolaatti.audio.common.domain.Audio
|
||||||
import com.isolaatti.audio.common.domain.AudiosRepository
|
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 com.isolaatti.utils.Resource
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
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 retrofit2.awaitResponse
|
||||||
|
import java.io.File
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class AudiosRepositoryImpl @Inject constructor(private val audiosApi: AudiosApi) : AudiosRepository {
|
class AudiosRepositoryImpl @Inject constructor(
|
||||||
override fun getAudiosOfUser(userId: Int, lastId: String?): Flow<Resource<List<Audio>>> = flow {
|
private val audiosApi: AudiosApi,
|
||||||
emit(Resource.Loading())
|
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 {
|
try {
|
||||||
val response = audiosApi.getAudiosOfUser(userId, lastId).awaitResponse()
|
val response = audiosApi.getAudiosOfUser(userId, lastId).awaitResponse()
|
||||||
|
|
||||||
if(response.isSuccessful) {
|
if(response.isSuccessful) {
|
||||||
val body = response.body()
|
val body = response.body() ?: return Resource.Error(Resource.Error.ErrorType.ServerError)
|
||||||
if(body == null) {
|
|
||||||
emit(Resource.Error(Resource.Error.ErrorType.ServerError))
|
|
||||||
return@flow
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
} catch(e: Exception) {
|
||||||
Log.e("AudiosRepositoryImpl", e.message.toString())
|
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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,5 +1,7 @@
|
|||||||
package com.isolaatti.audio.common.domain
|
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.audio.common.data.AudioDto
|
||||||
import com.isolaatti.common.Ownable
|
import com.isolaatti.common.Ownable
|
||||||
import com.isolaatti.connectivity.RetrofitClient.Companion.BASE_URL
|
import com.isolaatti.connectivity.RetrofitClient.Companion.BASE_URL
|
||||||
@ -13,17 +15,16 @@ data class Audio(
|
|||||||
val creationTime: ZonedDateTime,
|
val creationTime: ZonedDateTime,
|
||||||
override val userId: Int,
|
override val userId: Int,
|
||||||
val userName: String
|
val userName: String
|
||||||
): Ownable, Serializable {
|
): Ownable, Playable(), Serializable {
|
||||||
var playing: Boolean = false
|
|
||||||
val downloadUrl: String get() {
|
override val uri: Uri get() {
|
||||||
return "${BASE_URL}audios/$id.webm"
|
return "${BASE_URL}audios/$id.webm".toUri()
|
||||||
}
|
}
|
||||||
|
|
||||||
val thumbnail: String get() {
|
override val thumbnail: String get() {
|
||||||
return UrlGen.userProfileImage(userId)
|
return UrlGen.userProfileImage(userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromDto(audioDto: AudioDto): Audio {
|
fun fromDto(audioDto: AudioDto): Audio {
|
||||||
return Audio(
|
return Audio(
|
||||||
|
|||||||
@ -5,4 +5,9 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
|
|
||||||
interface AudiosRepository {
|
interface AudiosRepository {
|
||||||
fun getAudiosOfUser(userId: Int, lastId: String?): Flow<Resource<List<Audio>>>
|
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>>
|
||||||
}
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
)
|
||||||
@ -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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>>
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
package com.isolaatti.audio.drafts.presentation
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
|
||||||
|
class AudioDraftsViewModel : ViewModel() {
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@ -8,9 +8,16 @@ import androidx.lifecycle.Lifecycle
|
|||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.PlaybackException
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.datasource.DefaultDataSource
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
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.Audio
|
||||||
|
import com.isolaatti.audio.common.domain.Playable
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@ -28,7 +35,7 @@ class AudioPlayerConnector(
|
|||||||
const val TAG = "AudioPlayerConnector"
|
const val TAG = "AudioPlayerConnector"
|
||||||
}
|
}
|
||||||
private var player: Player? = null
|
private var player: Player? = null
|
||||||
private var audio: Audio? = null
|
private var audio: Playable? = null
|
||||||
private var mediaItem: MediaItem? = null
|
private var mediaItem: MediaItem? = null
|
||||||
private var ended = false
|
private var ended = false
|
||||||
|
|
||||||
@ -62,6 +69,9 @@ class AudioPlayerConnector(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val playerListener: Player.Listener = object: Player.Listener {
|
private val playerListener: Player.Listener = object: Player.Listener {
|
||||||
|
override fun onPlayerError(error: PlaybackException) {
|
||||||
|
Log.e(TAG, error.message.toString())
|
||||||
|
}
|
||||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||||
if(audio != null) {
|
if(audio != null) {
|
||||||
listeners.forEach { listener -> listener.onPlaying(isPlaying, audio!!)}
|
listeners.forEach { listener -> listener.onPlaying(isPlaying, audio!!)}
|
||||||
@ -83,12 +93,23 @@ class AudioPlayerConnector(
|
|||||||
Player.STATE_ENDED -> {
|
Player.STATE_ENDED -> {
|
||||||
Log.d(TAG, "STATE_ENDED")
|
Log.d(TAG, "STATE_ENDED")
|
||||||
audio?.let {
|
audio?.let {
|
||||||
listeners.forEach { listener -> listener.onPlaying(false, it)}
|
listeners.forEach { listener ->
|
||||||
|
listener.onPlaying(false, it)
|
||||||
|
listener.onEnded(it)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
stopTimer()
|
stopTimer()
|
||||||
ended = true
|
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_IDLE -> {}
|
||||||
Player.STATE_READY -> {
|
Player.STATE_READY -> {
|
||||||
Log.d(TAG, "STATE_READY")
|
Log.d(TAG, "STATE_READY")
|
||||||
@ -107,7 +128,6 @@ class AudioPlayerConnector(
|
|||||||
|
|
||||||
private fun initializePlayer() {
|
private fun initializePlayer() {
|
||||||
player = ExoPlayer.Builder(context).build()
|
player = ExoPlayer.Builder(context).build()
|
||||||
player?.playWhenReady = true
|
|
||||||
player?.addListener(playerListener)
|
player?.addListener(playerListener)
|
||||||
player?.prepare()
|
player?.prepare()
|
||||||
}
|
}
|
||||||
@ -125,23 +145,22 @@ class AudioPlayerConnector(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
|
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
|
||||||
|
Log.d(TAG, event.toString())
|
||||||
when(event) {
|
when(event) {
|
||||||
Lifecycle.Event.ON_START -> {
|
|
||||||
initializePlayer()
|
|
||||||
}
|
|
||||||
Lifecycle.Event.ON_RESUME -> {
|
Lifecycle.Event.ON_RESUME -> {
|
||||||
if(player == null) {
|
if(player == null) {
|
||||||
initializePlayer()
|
initializePlayer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Lifecycle.Event.ON_STOP, Lifecycle.Event.ON_DESTROY -> {
|
Lifecycle.Event.ON_DESTROY -> {
|
||||||
releasePlayer()
|
releasePlayer()
|
||||||
|
listeners.clear()
|
||||||
}
|
}
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun playPauseAudio(audio: Audio) {
|
fun playPauseAudio(audio: Playable) {
|
||||||
|
|
||||||
// intention is to pause current audio
|
// intention is to pause current audio
|
||||||
if(audio == this.audio && player?.isPlaying == true) {
|
if(audio == this.audio && player?.isPlaying == true) {
|
||||||
@ -157,15 +176,31 @@ class AudioPlayerConnector(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.audio = audio
|
this.audio = audio
|
||||||
mediaItem = MediaItem.fromUri(Uri.parse(audio.downloadUrl))
|
mediaItem = MediaItem.fromUri(audio.uri)
|
||||||
|
|
||||||
player?.setMediaItem(mediaItem!!)
|
player?.setMediaItem(mediaItem!!)
|
||||||
|
player?.playWhenReady = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopPlayback() {
|
||||||
|
ended = true
|
||||||
|
player?.pause()
|
||||||
|
stopTimer()
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Listener {
|
interface Listener {
|
||||||
fun onPlaying(isPlaying: Boolean, audio: Audio)
|
fun onPlaying(isPlaying: Boolean, audio: Playable)
|
||||||
fun isLoading(isLoading: Boolean, audio: Audio)
|
fun isLoading(isLoading: Boolean, audio: Playable)
|
||||||
fun progressChanged(second: Int, audio: Audio)
|
fun progressChanged(second: Int, audio: Playable)
|
||||||
fun durationChanged(duration: Int, audio: Audio)
|
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) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +0,0 @@
|
|||||||
package com.isolaatti.audio.recorder.ui
|
|
||||||
|
|
||||||
import androidx.fragment.app.Fragment
|
|
||||||
|
|
||||||
class AudioDraftsFragment : Fragment() {
|
|
||||||
}
|
|
||||||
@ -1,6 +1,337 @@
|
|||||||
package com.isolaatti.audio.recorder.ui
|
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() {
|
|
||||||
}
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,8 +0,0 @@
|
|||||||
package com.isolaatti.audio.recorder.ui
|
|
||||||
|
|
||||||
import android.media.MediaRecorder
|
|
||||||
import androidx.fragment.app.Fragment
|
|
||||||
|
|
||||||
class AudioRecorderMainFragment : Fragment() {
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -19,4 +19,17 @@ object CoilImageLoader {
|
|||||||
add(SvgDecoder.Factory())
|
add(SvgDecoder.Factory())
|
||||||
}.build()
|
}.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getImageLoader(context: Context): ImageLoader {
|
||||||
|
return ImageLoader
|
||||||
|
.Builder(context)
|
||||||
|
.memoryCache {
|
||||||
|
MemoryCache.Builder(context)
|
||||||
|
.maxSizePercent(0.25)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
.components {
|
||||||
|
add(SvgDecoder.Factory())
|
||||||
|
}.build()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -6,7 +6,6 @@ import android.content.Intent
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
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.dialog.MaterialAlertDialogBuilder
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import com.isolaatti.R
|
import com.isolaatti.R
|
||||||
import com.isolaatti.connectivity.ConnectivityCallbackImpl
|
|
||||||
import com.isolaatti.connectivity.NetworkStatus
|
import com.isolaatti.connectivity.NetworkStatus
|
||||||
import com.isolaatti.home.HomeActivity
|
|
||||||
import com.isolaatti.login.LogInActivity
|
import com.isolaatti.login.LogInActivity
|
||||||
import com.isolaatti.utils.Resource
|
import com.isolaatti.utils.Resource
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
|||||||
27
app/src/main/java/com/isolaatti/common/IsolaattiTheme.kt
Normal file
27
app/src/main/java/com/isolaatti/common/IsolaattiTheme.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,8 +1,14 @@
|
|||||||
package com.isolaatti.common
|
package com.isolaatti.common
|
||||||
|
|
||||||
|
import com.isolaatti.audio.common.domain.Audio
|
||||||
|
|
||||||
interface OnUserInteractedWithPostCallback : OnUserInteractedCallback {
|
interface OnUserInteractedWithPostCallback : OnUserInteractedCallback {
|
||||||
fun onLiked(postId: Long)
|
fun onLiked(postId: Long)
|
||||||
fun onUnLiked(postId: Long)
|
fun onUnLiked(postId: Long)
|
||||||
fun onComment(postId: Long)
|
fun onComment(postId: Long)
|
||||||
fun onOpenPost(postId: Long)
|
fun onOpenPost(postId: Long)
|
||||||
|
fun onPlay(audio: Audio)
|
||||||
|
fun onMoreInfo(postId: Long)
|
||||||
|
fun onShare(postId: Long)
|
||||||
|
fun hashtagClicked(hashtag: String)
|
||||||
}
|
}
|
||||||
8
app/src/main/java/com/isolaatti/common/SortingEnum.kt
Normal file
8
app/src/main/java/com/isolaatti/common/SortingEnum.kt
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
package com.isolaatti.common
|
||||||
|
|
||||||
|
enum class SortingEnum {
|
||||||
|
AscendingByName,
|
||||||
|
DescendingByName,
|
||||||
|
AscendingByCreationDate,
|
||||||
|
DescendingByCreationDate
|
||||||
|
}
|
||||||
4
app/src/main/java/com/isolaatti/common/TaggingRegexes.kt
Normal file
4
app/src/main/java/com/isolaatti/common/TaggingRegexes.kt
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
package com.isolaatti.common
|
||||||
|
|
||||||
|
val hashtagRegex = "#(\\w|-|_)+".toRegex()
|
||||||
|
val userTagRegex = "@(\\w|-|_)+".toRegex()
|
||||||
@ -23,6 +23,7 @@ data class Options(
|
|||||||
const val OPTION_PROFILE_PHOTO_VIEW_PHOTO = 6
|
const val OPTION_PROFILE_PHOTO_VIEW_PHOTO = 6
|
||||||
const val OPTION_PROFILE_PHOTO_CHANGE_PHOTO = 7
|
const val OPTION_PROFILE_PHOTO_CHANGE_PHOTO = 7
|
||||||
const val OPTION_PROFILE_PHOTO_REMOVE_PHOTO = 8
|
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 POST_OPTIONS = 1
|
||||||
const val COMMENT_OPTIONS = 2
|
const val COMMENT_OPTIONS = 2
|
||||||
const val PROFILE_PHOTO_OPTIONS = 3
|
const val PROFILE_PHOTO_OPTIONS = 3
|
||||||
|
const val PROFILE_DESCRIPTION_OPTIONS = 4
|
||||||
|
|
||||||
val noOptions = Options(0, 0, listOf())
|
val noOptions = Options(0, 0, listOf())
|
||||||
|
|
||||||
@ -93,5 +95,10 @@ data class Options(
|
|||||||
|
|
||||||
return Options(R.string.profile_photo,PROFILE_PHOTO_OPTIONS, list)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -9,6 +9,7 @@ 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
|
||||||
import com.isolaatti.common.options_bottom_sheet.domain.Options.Companion.COMMENT_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.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.common.options_bottom_sheet.domain.Options.Companion.PROFILE_PHOTO_OPTIONS
|
||||||
import com.isolaatti.settings.domain.UserIdSetting
|
import com.isolaatti.settings.domain.UserIdSetting
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@ -40,6 +41,8 @@ class BottomSheetPostOptionsViewModel @Inject constructor(private val userIdSett
|
|||||||
fun setOptions(options: Int, callerId: Int, payload: Ownable? = null) {
|
fun setOptions(options: Int, callerId: Int, payload: Ownable? = null) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
_payload = payload
|
||||||
|
_callerId = callerId
|
||||||
when(options) {
|
when(options) {
|
||||||
POST_OPTIONS -> {
|
POST_OPTIONS -> {
|
||||||
userIdSetting.getUserId().onEach { userId ->
|
userIdSetting.getUserId().onEach { userId ->
|
||||||
@ -49,8 +52,6 @@ class BottomSheetPostOptionsViewModel @Inject constructor(private val userIdSett
|
|||||||
savable = false,
|
savable = false,
|
||||||
snapshotAble = false)
|
snapshotAble = false)
|
||||||
)
|
)
|
||||||
_callerId = callerId
|
|
||||||
_payload = payload
|
|
||||||
}.flowOn(Dispatchers.IO).launchIn(this)
|
}.flowOn(Dispatchers.IO).launchIn(this)
|
||||||
}
|
}
|
||||||
COMMENT_OPTIONS -> {
|
COMMENT_OPTIONS -> {
|
||||||
@ -61,17 +62,16 @@ class BottomSheetPostOptionsViewModel @Inject constructor(private val userIdSett
|
|||||||
savable = false,
|
savable = false,
|
||||||
snapshotAble = false)
|
snapshotAble = false)
|
||||||
)
|
)
|
||||||
_callerId = callerId
|
|
||||||
_payload = payload
|
|
||||||
}.flowOn(Dispatchers.IO).launchIn(this)
|
}.flowOn(Dispatchers.IO).launchIn(this)
|
||||||
}
|
}
|
||||||
PROFILE_PHOTO_OPTIONS -> {
|
PROFILE_PHOTO_OPTIONS -> {
|
||||||
userIdSetting.getUserId().onEach { userId ->
|
userIdSetting.getUserId().onEach { userId ->
|
||||||
_options.postValue(Options.getProfilePhotoOptions(userOwned = userId == payload?.userId,))
|
_options.postValue(Options.getProfilePhotoOptions(userOwned = userId == payload?.userId,))
|
||||||
_callerId = callerId
|
|
||||||
_payload = payload
|
|
||||||
}.flowOn(Dispatchers.IO).launchIn(this)
|
}.flowOn(Dispatchers.IO).launchIn(this)
|
||||||
}
|
}
|
||||||
|
PROFILE_DESCRIPTION_OPTIONS -> {
|
||||||
|
_options.postValue(Options.getProfileDescriptionOptions())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -26,6 +26,12 @@ class RetrofitClient @Inject constructor(private val authenticationInterceptor:
|
|||||||
|
|
||||||
private val okHttpClient get() = OkHttpClient.Builder()
|
private val okHttpClient get() = OkHttpClient.Builder()
|
||||||
.addInterceptor(authenticationInterceptor)
|
.addInterceptor(authenticationInterceptor)
|
||||||
|
.addInterceptor { chain ->
|
||||||
|
chain.proceed(chain.request()
|
||||||
|
.newBuilder()
|
||||||
|
.header("User-Agent", "Isolaatti Android ${BuildConfig.VERSION_NAME}")
|
||||||
|
.build())
|
||||||
|
}
|
||||||
.connectTimeout(5, TimeUnit.MINUTES)
|
.connectTimeout(5, TimeUnit.MINUTES)
|
||||||
.writeTimeout(5, TimeUnit.MINUTES)
|
.writeTimeout(5, TimeUnit.MINUTES)
|
||||||
.readTimeout(5, TimeUnit.MINUTES)
|
.readTimeout(5, TimeUnit.MINUTES)
|
||||||
|
|||||||
@ -2,14 +2,22 @@ package com.isolaatti.database
|
|||||||
|
|
||||||
import androidx.room.Database
|
import androidx.room.Database
|
||||||
import androidx.room.RoomDatabase
|
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.UserInfoDao
|
||||||
import com.isolaatti.auth.data.local.UserInfoEntity
|
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.KeyValueDao
|
||||||
import com.isolaatti.settings.data.KeyValueEntity
|
import com.isolaatti.settings.data.KeyValueEntity
|
||||||
|
|
||||||
@Database(entities = [KeyValueEntity::class, UserInfoEntity::class], version = 2)
|
@Database(entities = [KeyValueEntity::class, UserInfoEntity::class, AudioDraftEntity::class, SearchHistoryEntity::class, ImageDraftEntity::class], version = 8)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
abstract fun keyValueDao(): KeyValueDao
|
abstract fun keyValueDao(): KeyValueDao
|
||||||
abstract fun userInfoDao(): UserInfoDao
|
abstract fun userInfoDao(): UserInfoDao
|
||||||
|
abstract fun audioDrafts(): AudiosDraftsDao
|
||||||
|
abstract fun searchHistoryDao(): SearchDao
|
||||||
|
abstract fun imagesDraftsDao(): ImagesDraftsDao
|
||||||
}
|
}
|
||||||
@ -4,15 +4,16 @@ import androidx.fragment.app.Fragment
|
|||||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||||
import com.isolaatti.followers.ui.FollowersFragment
|
import com.isolaatti.followers.ui.FollowersFragment
|
||||||
import com.isolaatti.followers.ui.FollowingFragment
|
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 getItemCount(): Int = 2
|
||||||
|
|
||||||
override fun createFragment(position: Int): Fragment {
|
override fun createFragment(position: Int): Fragment {
|
||||||
if(position == 0) {
|
if(position == 0) {
|
||||||
return FollowersFragment()
|
return ProfileListingFragment.getInstanceForFollowers(userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
return FollowingFragment()
|
return ProfileListingFragment.getInstanceForFollowing(userId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -10,13 +10,13 @@ import androidx.fragment.app.viewModels
|
|||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import com.isolaatti.common.UserItemCallback
|
import com.isolaatti.common.UserItemCallback
|
||||||
import com.isolaatti.common.UserListRecyclerViewAdapter
|
import com.isolaatti.common.UserListRecyclerViewAdapter
|
||||||
import com.isolaatti.databinding.FragmentFollowersBinding
|
import com.isolaatti.databinding.FragmentUserListBinding
|
||||||
import com.isolaatti.followers.presentation.FollowersViewModel
|
import com.isolaatti.followers.presentation.FollowersViewModel
|
||||||
import com.isolaatti.profile.domain.entity.ProfileListItem
|
import com.isolaatti.profile.domain.entity.ProfileListItem
|
||||||
import com.isolaatti.profile.ui.ProfileActivity
|
import com.isolaatti.profile.ui.ProfileActivity
|
||||||
|
|
||||||
class FollowersFragment : Fragment(), UserItemCallback {
|
class FollowersFragment : Fragment(), UserItemCallback {
|
||||||
private lateinit var binding: FragmentFollowersBinding
|
private lateinit var binding: FragmentUserListBinding
|
||||||
private val viewModel: FollowersViewModel by viewModels({ requireParentFragment() })
|
private val viewModel: FollowersViewModel by viewModels({ requireParentFragment() })
|
||||||
private lateinit var adapter: UserListRecyclerViewAdapter
|
private lateinit var adapter: UserListRecyclerViewAdapter
|
||||||
|
|
||||||
@ -25,7 +25,7 @@ class FollowersFragment : Fragment(), UserItemCallback {
|
|||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
savedInstanceState: Bundle?
|
savedInstanceState: Bundle?
|
||||||
): View {
|
): View {
|
||||||
binding = FragmentFollowersBinding.inflate(inflater)
|
binding = FragmentUserListBinding.inflate(inflater)
|
||||||
return binding.root
|
return binding.root
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,13 +10,13 @@ import androidx.fragment.app.viewModels
|
|||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import com.isolaatti.common.UserItemCallback
|
import com.isolaatti.common.UserItemCallback
|
||||||
import com.isolaatti.common.UserListRecyclerViewAdapter
|
import com.isolaatti.common.UserListRecyclerViewAdapter
|
||||||
import com.isolaatti.databinding.FragmentFollowersBinding
|
import com.isolaatti.databinding.FragmentUserListBinding
|
||||||
import com.isolaatti.followers.presentation.FollowersViewModel
|
import com.isolaatti.followers.presentation.FollowersViewModel
|
||||||
import com.isolaatti.profile.domain.entity.ProfileListItem
|
import com.isolaatti.profile.domain.entity.ProfileListItem
|
||||||
import com.isolaatti.profile.ui.ProfileActivity
|
import com.isolaatti.profile.ui.ProfileActivity
|
||||||
|
|
||||||
class FollowingFragment : Fragment(), UserItemCallback {
|
class FollowingFragment : Fragment(), UserItemCallback {
|
||||||
private lateinit var binding: FragmentFollowersBinding
|
private lateinit var binding: FragmentUserListBinding
|
||||||
private val viewModel: FollowersViewModel by viewModels({ requireParentFragment() })
|
private val viewModel: FollowersViewModel by viewModels({ requireParentFragment() })
|
||||||
private lateinit var adapter: UserListRecyclerViewAdapter
|
private lateinit var adapter: UserListRecyclerViewAdapter
|
||||||
|
|
||||||
@ -27,7 +27,7 @@ class FollowingFragment : Fragment(), UserItemCallback {
|
|||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
savedInstanceState: Bundle?
|
savedInstanceState: Bundle?
|
||||||
): View {
|
): View {
|
||||||
binding = FragmentFollowersBinding.inflate(inflater)
|
binding = FragmentUserListBinding.inflate(inflater)
|
||||||
return binding.root
|
return binding.root
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -19,7 +19,6 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||||||
class MainFollowersFragment : Fragment() {
|
class MainFollowersFragment : Fragment() {
|
||||||
|
|
||||||
private lateinit var binding: FragmentFollowersMainBinding
|
private lateinit var binding: FragmentFollowersMainBinding
|
||||||
private val viewModel: FollowersViewModel by viewModels()
|
|
||||||
|
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
@ -33,12 +32,9 @@ class MainFollowersFragment : Fragment() {
|
|||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
val userId = arguments?.getInt(ARGUMENT_USER_ID) ?: 0
|
||||||
|
|
||||||
arguments?.getInt(ARGUMENT_USER_ID)?.let {
|
binding.viewPagerFollowersMain.adapter = FollowersViewPagerAdapter(this, userId)
|
||||||
viewModel.userId = it
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.viewPagerFollowersMain.adapter = FollowersViewPagerAdapter(this)
|
|
||||||
TabLayoutMediator(binding.tabLayoutFollowers, binding.viewPagerFollowersMain) { tab, position ->
|
TabLayoutMediator(binding.tabLayoutFollowers, binding.viewPagerFollowersMain) { tab, position ->
|
||||||
when(position) {
|
when(position) {
|
||||||
0 -> tab.text = getText(R.string.followers)
|
0 -> tab.text = getText(R.string.followers)
|
||||||
|
|||||||
12
app/src/main/java/com/isolaatti/hashtags/data/HashtagsApi.kt
Normal file
12
app/src/main/java/com/isolaatti/hashtags/data/HashtagsApi.kt
Normal 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 {
|
||||||
|
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
package com.isolaatti.home.notifications.presentation
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
|
|
||||||
class NotificationsViewModel : ViewModel() {
|
|
||||||
// TODO: Implement the ViewModel
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -2,12 +2,12 @@ package com.isolaatti.home.presentation
|
|||||||
|
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.isolaatti.auth.domain.AuthRepository
|
import com.isolaatti.auth.domain.AuthRepository
|
||||||
import com.isolaatti.posting.posts.domain.PostsRepository
|
import com.isolaatti.posting.posts.domain.PostsRepository
|
||||||
import com.isolaatti.posting.posts.presentation.PostListingViewModelBase
|
import com.isolaatti.posting.posts.presentation.PostListingViewModelBase
|
||||||
import com.isolaatti.posting.posts.presentation.UpdateEvent
|
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.entity.UserProfile
|
||||||
import com.isolaatti.profile.domain.use_case.GetProfile
|
import com.isolaatti.profile.domain.use_case.GetProfile
|
||||||
import com.isolaatti.utils.Resource
|
import com.isolaatti.utils.Resource
|
||||||
@ -22,9 +22,8 @@ import javax.inject.Inject
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class FeedViewModel @Inject constructor(
|
class FeedViewModel @Inject constructor(
|
||||||
private val getProfileUseCase: GetProfile,
|
private val getProfileUseCase: GetProfile,
|
||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository
|
||||||
private val postsRepository: PostsRepository
|
) : ViewModel() {
|
||||||
) : PostListingViewModelBase() {
|
|
||||||
|
|
||||||
private val toRetry: MutableList<Runnable> = mutableListOf()
|
private val toRetry: MutableList<Runnable> = mutableListOf()
|
||||||
|
|
||||||
@ -39,41 +38,7 @@ class FeedViewModel @Inject constructor(
|
|||||||
toRetry.clear()
|
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
|
// User profile
|
||||||
private val _userProfile: MutableLiveData<UserProfile> = MutableLiveData()
|
private val _userProfile: MutableLiveData<UserProfile> = MutableLiveData()
|
||||||
@ -88,7 +53,6 @@ class FeedViewModel @Inject constructor(
|
|||||||
|
|
||||||
when(profile) {
|
when(profile) {
|
||||||
is Resource.Error -> {
|
is Resource.Error -> {
|
||||||
errorLoading.postValue(profile.errorType)
|
|
||||||
toRetry.add {
|
toRetry.add {
|
||||||
getProfile()
|
getProfile()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
149
app/src/main/java/com/isolaatti/home/ui/FeedFragment.kt
Normal file
149
app/src/main/java/com/isolaatti/home/ui/FeedFragment.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
80
app/src/main/java/com/isolaatti/home/ui/HomeActivity.kt
Normal file
80
app/src/main/java/com/isolaatti/home/ui/HomeActivity.kt
Normal 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 {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -1,10 +1,10 @@
|
|||||||
package com.isolaatti.images
|
package com.isolaatti.images
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.isolaatti.MyApplication
|
|
||||||
import com.isolaatti.connectivity.RetrofitClient
|
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.remote.ImagesApi
|
||||||
import com.isolaatti.images.common.data.repository.ImagesRepositoryImpl
|
import com.isolaatti.images.common.data.repository.ImagesRepositoryImpl
|
||||||
import com.isolaatti.images.common.domain.repository.ImagesRepository
|
import com.isolaatti.images.common.domain.repository.ImagesRepository
|
||||||
@ -28,7 +28,12 @@ class Module {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
fun provideImagesRepository(imagesApi: ImagesApi, contentResolver: ContentResolver): ImagesRepository {
|
fun provideImagesDraftDao(database: AppDatabase): ImagesDraftsDao {
|
||||||
return ImagesRepositoryImpl(imagesApi, contentResolver)
|
return database.imagesDraftsDao()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideImagesRepository(imagesApi: ImagesApi, imagesDraftsDao: ImagesDraftsDao, contentResolver: ContentResolver): ImagesRepository {
|
||||||
|
return ImagesRepositoryImpl(imagesApi, imagesDraftsDao, contentResolver)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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 = {})
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
@ -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
|
||||||
@ -3,7 +3,6 @@ package com.isolaatti.images.common.data.remote
|
|||||||
data class ImageDto(
|
data class ImageDto(
|
||||||
val id: String,
|
val id: String,
|
||||||
val userId: Int,
|
val userId: Int,
|
||||||
val name: String,
|
|
||||||
val squadId: String?,
|
val squadId: String?,
|
||||||
val username: String,
|
val username: String,
|
||||||
val idOnFirebase: String
|
val idOnFirebase: String
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import android.graphics.BitmapFactory
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
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.DeleteImagesDto
|
||||||
import com.isolaatti.images.common.data.remote.ImagesApi
|
import com.isolaatti.images.common.data.remote.ImagesApi
|
||||||
import com.isolaatti.images.common.domain.entity.Image
|
import com.isolaatti.images.common.domain.entity.Image
|
||||||
@ -20,7 +22,7 @@ import java.io.ByteArrayOutputStream
|
|||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import javax.inject.Inject
|
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 {
|
ImagesRepository {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@ -104,4 +106,12 @@ class ImagesRepositoryImpl @Inject constructor(private val imagesApi: ImagesApi,
|
|||||||
imageInputStream?.close()
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -9,7 +9,6 @@ import java.io.Serializable
|
|||||||
data class Image(
|
data class Image(
|
||||||
val id: String,
|
val id: String,
|
||||||
val userId: Int,
|
val userId: Int,
|
||||||
val name: String,
|
|
||||||
val username: String
|
val username: String
|
||||||
): Deletable(), Serializable {
|
): Deletable(), Serializable {
|
||||||
val imageUrl: String get() = UrlGen.imageUrl(id)
|
val imageUrl: String get() = UrlGen.imageUrl(id)
|
||||||
@ -19,7 +18,7 @@ data class Image(
|
|||||||
val markdown: String get() = Generators.generateImage(imageUrl)
|
val markdown: String get() = Generators.generateImage(imageUrl)
|
||||||
|
|
||||||
companion object {
|
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 {
|
override fun equals(other: Any?): Boolean {
|
||||||
@ -30,7 +29,6 @@ data class Image(
|
|||||||
|
|
||||||
if (id != other.id) return false
|
if (id != other.id) return false
|
||||||
if (userId != other.userId) return false
|
if (userId != other.userId) return false
|
||||||
if (name != other.name) return false
|
|
||||||
if (username != other.username) return false
|
if (username != other.username) return false
|
||||||
|
|
||||||
return true
|
return true
|
||||||
@ -39,7 +37,6 @@ data class Image(
|
|||||||
override fun hashCode(): Int {
|
override fun hashCode(): Int {
|
||||||
var result = id.hashCode()
|
var result = id.hashCode()
|
||||||
result = 31 * result + userId
|
result = 31 * result + userId
|
||||||
result = 31 * result + name.hashCode()
|
|
||||||
result = 31 * result + username.hashCode()
|
result = 31 * result + username.hashCode()
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package com.isolaatti.images.common.domain.repository
|
package com.isolaatti.images.common.domain.repository
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import com.isolaatti.images.common.data.entity.ImageDraftEntity
|
||||||
import com.isolaatti.images.common.domain.entity.Image
|
import com.isolaatti.images.common.domain.entity.Image
|
||||||
import com.isolaatti.utils.Resource
|
import com.isolaatti.utils.Resource
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@ -9,4 +10,6 @@ interface ImagesRepository {
|
|||||||
fun getImagesOfUser(userId: Int, lastId: String? = null): Flow<Resource<List<Image>>>
|
fun getImagesOfUser(userId: Int, lastId: String? = null): Flow<Resource<List<Image>>>
|
||||||
fun deleteImages(images: List<Image>): Flow<Resource<Boolean>>
|
fun deleteImages(images: List<Image>): Flow<Resource<Boolean>>
|
||||||
fun uploadImage(name: String, imageUri: Uri, squadId: String?): Flow<Resource<Image>>
|
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>>>
|
||||||
}
|
}
|
||||||
@ -5,9 +5,11 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.activity.result.contract.ActivityResultContract
|
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
|
import com.isolaatti.images.common.domain.entity.Image
|
||||||
|
|
||||||
class ImageChooserContract : ActivityResultContract<ImageChooserContract.Requester, Image?>() {
|
class ImageChooserContract : ActivityResultContract<ImageChooserContract.Requester, ImageDraftEntity?>() {
|
||||||
|
|
||||||
enum class Requester {
|
enum class Requester {
|
||||||
UserPost, SquadPost
|
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 }
|
if(resultCode != Activity.RESULT_OK) { return null }
|
||||||
|
|
||||||
return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
if(intent == null) {
|
||||||
intent?.getSerializableExtra(ImageChooserActivity.OUTPUT_EXTRA_IMAGE) as Image?
|
return null
|
||||||
} else {
|
|
||||||
intent?.getSerializableExtra(ImageChooserActivity.OUTPUT_EXTRA_IMAGE, Image::class.java)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return IntentCompat.getSerializableExtra(intent, ImageChooserActivity.OUTPUT_EXTRA_IMAGE, ImageDraftEntity::class.java)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -38,7 +38,6 @@ class ImageChooserPreview : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
binding.image.load(viewModel.selectedImage?.imageUrl)
|
binding.image.load(viewModel.selectedImage?.imageUrl)
|
||||||
binding.imageDescription.text = viewModel.selectedImage?.name
|
|
||||||
|
|
||||||
binding.chooseImageButton.setOnClickListener {
|
binding.chooseImageButton.setOnClickListener {
|
||||||
showLoading(true)
|
showLoading(true)
|
||||||
|
|||||||
@ -86,12 +86,12 @@ class ImagesAdapter(
|
|||||||
holder.imageItemBinding.root.setOnClickListener {
|
holder.imageItemBinding.root.setOnClickListener {
|
||||||
holder.imageItemBinding.imageCheckbox.isChecked = !holder.imageItemBinding.imageCheckbox.isChecked
|
holder.imageItemBinding.imageCheckbox.isChecked = !holder.imageItemBinding.imageCheckbox.isChecked
|
||||||
}
|
}
|
||||||
holder.imageItemBinding.imageCheckbox.isChecked = image.delete
|
|
||||||
holder.imageItemBinding.imageCheckbox.setOnCheckedChangeListener { buttonView, isChecked ->
|
holder.imageItemBinding.imageCheckbox.setOnCheckedChangeListener { buttonView, isChecked ->
|
||||||
image.delete = isChecked
|
image.delete = isChecked
|
||||||
|
|
||||||
onImageSelectedCountUpdate?.invoke(currentList.count { it.delete })
|
onImageSelectedCountUpdate?.invoke(currentList.count { it.delete })
|
||||||
}
|
}
|
||||||
|
holder.imageItemBinding.imageCheckbox.isChecked = image.delete
|
||||||
holder.imageItemBinding.root.setOnLongClickListener(null)
|
holder.imageItemBinding.root.setOnLongClickListener(null)
|
||||||
} else {
|
} else {
|
||||||
holder.imageItemBinding.imageCheckbox.visibility = View.GONE
|
holder.imageItemBinding.imageCheckbox.visibility = View.GONE
|
||||||
|
|||||||
@ -51,9 +51,6 @@ class ImageMakerActivity : IsolaattiBaseActivity() {
|
|||||||
binding.uploadPhotoFab.setOnClickListener {
|
binding.uploadPhotoFab.setOnClickListener {
|
||||||
viewModel.uploadPicture()
|
viewModel.uploadPicture()
|
||||||
}
|
}
|
||||||
binding.textImageName.editText?.doOnTextChanged { text, _, _, _ ->
|
|
||||||
viewModel.name = text.toString()
|
|
||||||
}
|
|
||||||
binding.toolbar.setNavigationOnClickListener {
|
binding.toolbar.setNavigationOnClickListener {
|
||||||
showExitConfirmationDialog()
|
showExitConfirmationDialog()
|
||||||
}
|
}
|
||||||
@ -66,12 +63,10 @@ class ImageMakerActivity : IsolaattiBaseActivity() {
|
|||||||
errorViewModel.error.value = it.errorType
|
errorViewModel.error.value = it.errorType
|
||||||
binding.progressBarLoading.visibility = View.GONE
|
binding.progressBarLoading.visibility = View.GONE
|
||||||
binding.uploadPhotoFab.visibility = View.VISIBLE
|
binding.uploadPhotoFab.visibility = View.VISIBLE
|
||||||
binding.textImageName.isEnabled = true
|
|
||||||
}
|
}
|
||||||
is Resource.Loading -> {
|
is Resource.Loading -> {
|
||||||
binding.progressBarLoading.visibility = View.VISIBLE
|
binding.progressBarLoading.visibility = View.VISIBLE
|
||||||
binding.uploadPhotoFab.visibility = View.INVISIBLE
|
binding.uploadPhotoFab.visibility = View.INVISIBLE
|
||||||
binding.textImageName.isEnabled = false
|
|
||||||
}
|
}
|
||||||
is Resource.Success -> {
|
is Resource.Success -> {
|
||||||
binding.progressBarLoading.visibility = View.GONE
|
binding.progressBarLoading.visibility = View.GONE
|
||||||
|
|||||||
@ -20,7 +20,6 @@ class PictureViewerMainFragment : Fragment() {
|
|||||||
override fun onPageSelected(position: Int) {
|
override fun onPageSelected(position: Int) {
|
||||||
super.onPageSelected(position)
|
super.onPageSelected(position)
|
||||||
binding.imageAuthor.text = images[position].username
|
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.adapter = adapter
|
||||||
binding.viewpager.setCurrentItem(position, false)
|
binding.viewpager.setCurrentItem(position, false)
|
||||||
binding.viewpager.registerOnPageChangeCallback(onPageChangeCallback)
|
binding.viewpager.registerOnPageChangeCallback(onPageChangeCallback)
|
||||||
binding.imageDescription.text = images[position].name
|
|
||||||
binding.imageAuthor.text = images[position].username
|
binding.imageAuthor.text = images[position].username
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/src/main/java/com/isolaatti/markdown/Module.kt
Normal file
38
app/src/main/java/com/isolaatti/markdown/Module.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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/"))
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/src/main/java/com/isolaatti/notifications/Module.kt
Normal file
25
app/src/main/java/com/isolaatti/notifications/Module.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
}
|
||||||
@ -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>)
|
||||||
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,181 @@
|
|||||||
|
package com.isolaatti.notifications.domain
|
||||||
|
|
||||||
|
import com.isolaatti.notifications.data.NotificationDto
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
|
||||||
|
|
||||||
|
class GenericNotification(id: Long, date: ZonedDateTime, userId: Int, read: Boolean) : Notification(id, date, userId, read) {
|
||||||
|
|
||||||
|
var title: String? = null
|
||||||
|
var message: String? = null
|
||||||
|
|
||||||
|
override fun ingestPayload(data: Map<String, String>) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|
||||||
|
other as GenericNotification
|
||||||
|
|
||||||
|
if (title != other.title) return false
|
||||||
|
if (message != other.message) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = title?.hashCode() ?: 0
|
||||||
|
result = 31 * result + (message?.hashCode() ?: 0)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TYPE = "generic"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LikeNotification(id: Long, date: ZonedDateTime, userId: Int, read: Boolean) : Notification(id, date, userId, read) {
|
||||||
|
companion object {
|
||||||
|
const val TYPE = "like"
|
||||||
|
}
|
||||||
|
|
||||||
|
var likeId: String? = null
|
||||||
|
var postId: Long? = null
|
||||||
|
var authorId: Int? = null
|
||||||
|
var authorName: String? = null
|
||||||
|
override fun ingestPayload(data: Map<String, String>) {
|
||||||
|
likeId = data["likeId"]
|
||||||
|
postId = data["postId"]?.toLongOrNull()
|
||||||
|
authorId = data["authorId"]?.toIntOrNull()
|
||||||
|
authorName = data["authorName"]
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|
||||||
|
other as LikeNotification
|
||||||
|
|
||||||
|
if (likeId != other.likeId) return false
|
||||||
|
if (postId != other.postId) return false
|
||||||
|
if (authorId != other.authorId) return false
|
||||||
|
if (authorName != other.authorName) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = likeId?.hashCode() ?: 0
|
||||||
|
result = 31 * result + (postId?.hashCode() ?: 0)
|
||||||
|
result = 31 * result + (authorId ?: 0)
|
||||||
|
result = 31 * result + (authorName?.hashCode() ?: 0)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
class FollowNotification(id: Long, date: ZonedDateTime, userId: Int, read: Boolean) : Notification(id, date, userId, read) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TYPE = "follower"
|
||||||
|
}
|
||||||
|
|
||||||
|
var followerName: String? = null
|
||||||
|
var followerUserId: Int? = null
|
||||||
|
|
||||||
|
override fun ingestPayload(data: Map<String, String>) {
|
||||||
|
followerName = data["followerName"]
|
||||||
|
followerUserId = data["followerUserId"]?.toIntOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|
||||||
|
other as FollowNotification
|
||||||
|
|
||||||
|
if (followerName != other.followerName) return false
|
||||||
|
if (followerUserId != other.followerUserId) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = followerName?.hashCode() ?: 0
|
||||||
|
result = 31 * result + (followerUserId ?: 0)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
abstract class Notification(
|
||||||
|
val id: Long,
|
||||||
|
val date: ZonedDateTime,
|
||||||
|
val userId: Int,
|
||||||
|
var read: Boolean
|
||||||
|
) {
|
||||||
|
|
||||||
|
abstract fun ingestPayload(data: Map<String, String>)
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (other !is Notification) return false
|
||||||
|
|
||||||
|
if (id != other.id) return false
|
||||||
|
if (date != other.date) return false
|
||||||
|
if (userId != other.userId) return false
|
||||||
|
if (read != other.read) return false
|
||||||
|
if (other != this) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = id.hashCode()
|
||||||
|
result = 31 * result + date.hashCode()
|
||||||
|
result = 31 * result + userId
|
||||||
|
result = 31 * result + read.hashCode()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromDto(notificationDto: NotificationDto): Notification? {
|
||||||
|
val type = notificationDto.data["type"]
|
||||||
|
return when(type) {
|
||||||
|
GenericNotification.TYPE -> {
|
||||||
|
|
||||||
|
GenericNotification(
|
||||||
|
notificationDto.id,
|
||||||
|
notificationDto.date,
|
||||||
|
notificationDto.userId,
|
||||||
|
notificationDto.read
|
||||||
|
).apply {
|
||||||
|
ingestPayload(notificationDto.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LikeNotification.TYPE -> {
|
||||||
|
LikeNotification(
|
||||||
|
notificationDto.id,
|
||||||
|
notificationDto.date,
|
||||||
|
notificationDto.userId,
|
||||||
|
notificationDto.read
|
||||||
|
).apply {
|
||||||
|
ingestPayload(notificationDto.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FollowNotification.TYPE -> {
|
||||||
|
FollowNotification(
|
||||||
|
notificationDto.id,
|
||||||
|
notificationDto.date,
|
||||||
|
notificationDto.userId,
|
||||||
|
notificationDto.read
|
||||||
|
).apply {
|
||||||
|
ingestPayload(notificationDto.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
package com.isolaatti.notifications.domain
|
||||||
|
|
||||||
|
import com.isolaatti.utils.Resource
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
interface NotificationsRepository {
|
||||||
|
fun getNotifications(after: Long?): Flow<Resource<List<Notification>>>
|
||||||
|
fun deleteNotifications(vararg notification: Notification): Flow<Resource<Boolean>>
|
||||||
|
}
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
package com.isolaatti.notifications.presentation
|
||||||
|
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
|
import androidx.recyclerview.widget.ListAdapter
|
||||||
|
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||||
|
import coil.load
|
||||||
|
import com.isolaatti.R
|
||||||
|
import com.isolaatti.databinding.NotificationItemBinding
|
||||||
|
import com.isolaatti.notifications.domain.FollowNotification
|
||||||
|
import com.isolaatti.notifications.domain.LikeNotification
|
||||||
|
import com.isolaatti.notifications.domain.Notification
|
||||||
|
import com.isolaatti.utils.UrlGen
|
||||||
|
|
||||||
|
class NotificationsAdapter(
|
||||||
|
private val onNotificationClick: (notification: Notification) -> Unit,
|
||||||
|
private val onItemOptionsClick: (button: View, notification: Notification) -> Unit
|
||||||
|
) : ListAdapter<Notification, NotificationsAdapter.NotificationViewHolder>(diffCallback) {
|
||||||
|
inner class NotificationViewHolder(val notificationItemBinding: NotificationItemBinding) : ViewHolder(notificationItemBinding.root)
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NotificationViewHolder {
|
||||||
|
return NotificationViewHolder(NotificationItemBinding.inflate(LayoutInflater.from(parent.context), parent, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: NotificationViewHolder, position: Int) {
|
||||||
|
val context = holder.notificationItemBinding.root.context
|
||||||
|
val notification = getItem(position)
|
||||||
|
holder.notificationItemBinding.root.setOnClickListener {
|
||||||
|
onNotificationClick(notification)
|
||||||
|
}
|
||||||
|
holder.notificationItemBinding.optionButton.setOnClickListener {
|
||||||
|
onItemOptionsClick(it, notification)
|
||||||
|
}
|
||||||
|
when(notification) {
|
||||||
|
is LikeNotification -> {
|
||||||
|
holder.notificationItemBinding.notificationTitle.text = context.getString(R.string.like_notification_title, notification.authorName)
|
||||||
|
holder.notificationItemBinding.notificationMessage.text = context.getString(R.string.like_notification_text)
|
||||||
|
val authorProfileImageUrl = notification.authorId?.let { UrlGen.userProfileImage(it, false) }
|
||||||
|
|
||||||
|
if(authorProfileImageUrl != null) {
|
||||||
|
holder.notificationItemBinding.notificationMainImage.load(authorProfileImageUrl){
|
||||||
|
fallback(R.drawable.baseline_person_24)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
holder.notificationItemBinding.notificationMainImage.load(R.drawable.baseline_person_24)
|
||||||
|
}
|
||||||
|
|
||||||
|
holder.notificationItemBinding.notificationSecondaryImage.load(R.drawable.hands_clapping_solid)
|
||||||
|
}
|
||||||
|
|
||||||
|
is FollowNotification -> {
|
||||||
|
holder.notificationItemBinding.notificationTitle.text = context.getString(R.string.new_follower_notification_title, notification.followerName)
|
||||||
|
holder.notificationItemBinding.notificationMessage.text = context.getString(R.string.new_follower_notification_text)
|
||||||
|
|
||||||
|
val followerProfileImageUrl = notification.followerUserId?.let { UrlGen.userProfileImage(it, false) }
|
||||||
|
if(followerProfileImageUrl != null) {
|
||||||
|
holder.notificationItemBinding.notificationMainImage.load(followerProfileImageUrl) {
|
||||||
|
fallback(R.drawable.baseline_person_24)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
holder.notificationItemBinding.notificationMainImage.load(R.drawable.baseline_person_24)
|
||||||
|
}
|
||||||
|
|
||||||
|
holder.notificationItemBinding.notificationSecondaryImage.load(R.drawable.baseline_star_24)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val diffCallback = object: DiffUtil.ItemCallback<Notification>() {
|
||||||
|
override fun areItemsTheSame(oldItem: Notification, newItem: Notification): Boolean {
|
||||||
|
return oldItem.id == newItem.id
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun areContentsTheSame(oldItem: Notification, newItem: Notification): Boolean {
|
||||||
|
return oldItem == newItem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,71 @@
|
|||||||
|
package com.isolaatti.notifications.presentation
|
||||||
|
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.isolaatti.notifications.domain.Notification
|
||||||
|
import com.isolaatti.notifications.domain.NotificationsRepository
|
||||||
|
import com.isolaatti.utils.Resource
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class NotificationsViewModel @Inject constructor(private val notificationsRepository: NotificationsRepository) : ViewModel() {
|
||||||
|
companion object {
|
||||||
|
const val LOG_TAG = "NotificationsViewModel"
|
||||||
|
}
|
||||||
|
val notifications: MutableLiveData<List<Notification>> = MutableLiveData()
|
||||||
|
val loading: MutableLiveData<Boolean> = MutableLiveData()
|
||||||
|
|
||||||
|
val error: MutableLiveData<Boolean> = MutableLiveData()
|
||||||
|
fun getData() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
notificationsRepository.getNotifications(null).onEach {
|
||||||
|
when(it) {
|
||||||
|
is Resource.Error -> {
|
||||||
|
loading.postValue(false)
|
||||||
|
}
|
||||||
|
is Resource.Loading -> {
|
||||||
|
loading.postValue(true)
|
||||||
|
}
|
||||||
|
is Resource.Success -> {
|
||||||
|
loading.postValue(false)
|
||||||
|
notifications.postValue(it.data!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.flowOn(Dispatchers.IO).launchIn(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onDeleted(notification: Notification) {
|
||||||
|
val mutableList = notifications.value?.toMutableList()
|
||||||
|
val removed = mutableList?.remove(notification)
|
||||||
|
if(mutableList != null && removed == true) {
|
||||||
|
notifications.postValue(mutableList!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteNotification(notification: Notification) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
notificationsRepository.deleteNotifications(notification).onEach {
|
||||||
|
when(it) {
|
||||||
|
is Resource.Error -> {
|
||||||
|
error.postValue(true)
|
||||||
|
}
|
||||||
|
is Resource.Loading -> {
|
||||||
|
error.postValue(false)
|
||||||
|
}
|
||||||
|
is Resource.Success -> {
|
||||||
|
error.postValue(false)
|
||||||
|
onDeleted(notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.flowOn(Dispatchers.IO).launchIn(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,125 @@
|
|||||||
|
package com.isolaatti.notifications.ui
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.appcompat.widget.PopupMenu
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import com.isolaatti.R
|
||||||
|
import com.isolaatti.databinding.FragmentNotificationsBinding
|
||||||
|
import com.isolaatti.notifications.domain.FollowNotification
|
||||||
|
import com.isolaatti.notifications.domain.LikeNotification
|
||||||
|
import com.isolaatti.notifications.domain.Notification
|
||||||
|
import com.isolaatti.notifications.presentation.NotificationsAdapter
|
||||||
|
import com.isolaatti.notifications.presentation.NotificationsViewModel
|
||||||
|
import com.isolaatti.posting.posts.viewer.ui.PostViewerActivity
|
||||||
|
import com.isolaatti.profile.ui.ProfileActivity
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class NotificationsFragment : Fragment() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun newInstance() = NotificationsFragment()
|
||||||
|
}
|
||||||
|
|
||||||
|
private lateinit var binding: FragmentNotificationsBinding
|
||||||
|
private val viewModel: NotificationsViewModel by viewModels()
|
||||||
|
private var adapter: NotificationsAdapter? = null
|
||||||
|
|
||||||
|
private fun showDeleteNotificationDialog(notification: Notification) {
|
||||||
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setTitle(R.string.delete_notification)
|
||||||
|
.setMessage(R.string.delete_notification_dialog_message)
|
||||||
|
.setPositiveButton(R.string.accept) { _, _ ->
|
||||||
|
viewModel.deleteNotification(notification)
|
||||||
|
}
|
||||||
|
.setNegativeButton(R.string.no, null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val onItemOptionsClick: (button: View, notification: Notification) -> Unit = { button, notification ->
|
||||||
|
val popupMenu = PopupMenu(requireContext(), button)
|
||||||
|
|
||||||
|
popupMenu.inflate(R.menu.notification_menu)
|
||||||
|
|
||||||
|
popupMenu.setOnMenuItemClickListener {
|
||||||
|
when(it.itemId) {
|
||||||
|
R.id.delete_notification -> {
|
||||||
|
showDeleteNotificationDialog(notification)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
popupMenu.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val onNotificationClick: (notification: Notification) -> Unit = { notification ->
|
||||||
|
when(notification) {
|
||||||
|
is LikeNotification -> {
|
||||||
|
notification.postId?.also { postId ->
|
||||||
|
PostViewerActivity.startActivity(requireContext(), postId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is FollowNotification -> {
|
||||||
|
notification.followerUserId?.also { followerUserId ->
|
||||||
|
ProfileActivity.startActivity(requireContext(), followerUserId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater, container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View? {
|
||||||
|
binding = FragmentNotificationsBinding.inflate(inflater, container, false)
|
||||||
|
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
|
|
||||||
|
adapter = NotificationsAdapter(onNotificationClick, onItemOptionsClick)
|
||||||
|
binding.recycler.adapter = adapter
|
||||||
|
binding.recycler.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false)
|
||||||
|
|
||||||
|
viewModel.getData()
|
||||||
|
|
||||||
|
setupObservers()
|
||||||
|
setupListeners()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupListeners() {
|
||||||
|
binding.swipeToRefresh.setOnRefreshListener {
|
||||||
|
viewModel.getData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupObservers() {
|
||||||
|
viewModel.notifications.observe(viewLifecycleOwner) {
|
||||||
|
adapter?.submitList(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.loading.observe(viewLifecycleOwner) {
|
||||||
|
binding.swipeToRefresh.isRefreshing = it
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.error.observe(viewLifecycleOwner) {
|
||||||
|
if(it){
|
||||||
|
Toast.makeText(requireContext(), R.string.error_making_request, Toast.LENGTH_SHORT).show()
|
||||||
|
viewModel.error.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,7 +12,12 @@ import com.isolaatti.common.OnUserInteractedCallback
|
|||||||
import com.isolaatti.utils.UrlGen
|
import com.isolaatti.utils.UrlGen
|
||||||
import io.noties.markwon.Markwon
|
import io.noties.markwon.Markwon
|
||||||
|
|
||||||
class CommentsRecyclerViewAdapter(private var list: List<Comment>, private val markwon: Markwon, private val callback: OnUserInteractedCallback) : RecyclerView.Adapter<CommentsRecyclerViewAdapter.CommentViewHolder>() {
|
class CommentsRecyclerViewAdapter(
|
||||||
|
private var list: List<Comment>,
|
||||||
|
private val markwon: Markwon,
|
||||||
|
private val callback: OnUserInteractedCallback,
|
||||||
|
private val onCommentClick: (Comment) -> Unit
|
||||||
|
) : RecyclerView.Adapter<CommentsRecyclerViewAdapter.CommentViewHolder>() {
|
||||||
|
|
||||||
private var previousSize = 0
|
private var previousSize = 0
|
||||||
var blockInfiniteScroll = false
|
var blockInfiniteScroll = false
|
||||||
|
|||||||
@ -36,6 +36,8 @@ import com.isolaatti.images.image_chooser.ui.ImageChooserContract
|
|||||||
import com.isolaatti.posting.link_creator.presentation.LinkCreatorViewModel
|
import com.isolaatti.posting.link_creator.presentation.LinkCreatorViewModel
|
||||||
import com.isolaatti.posting.link_creator.ui.LinkCreatorFragment
|
import com.isolaatti.posting.link_creator.ui.LinkCreatorFragment
|
||||||
import com.isolaatti.profile.ui.ProfileActivity
|
import com.isolaatti.profile.ui.ProfileActivity
|
||||||
|
import com.isolaatti.reports.data.ContentType
|
||||||
|
import com.isolaatti.reports.ui.NewReportBottomSheetDialogFragment
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import io.noties.markwon.AbstractMarkwonPlugin
|
import io.noties.markwon.AbstractMarkwonPlugin
|
||||||
import io.noties.markwon.Markwon
|
import io.noties.markwon.Markwon
|
||||||
@ -74,6 +76,8 @@ class BottomSheetPostComments() : BottomSheetDialogFragment(), OnUserInteractedC
|
|||||||
}
|
}
|
||||||
Options.Option.OPTION_REPORT -> {
|
Options.Option.OPTION_REPORT -> {
|
||||||
optionsViewModel.handle()
|
optionsViewModel.handle()
|
||||||
|
NewReportBottomSheetDialogFragment.newInstance(ContentType.Comment, comment.id.toString())
|
||||||
|
.show(childFragmentManager, NewReportBottomSheetDialogFragment.LOG_TAG)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -119,10 +123,10 @@ class BottomSheetPostComments() : BottomSheetDialogFragment(), OnUserInteractedC
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val imageChooserLauncher = registerForActivityResult(ImageChooserContract()) { image ->
|
private val imageChooserLauncher = registerForActivityResult(ImageChooserContract()) { image ->
|
||||||
Log.d("BottomSheetPostComment", "${image?.markdown}")
|
|
||||||
|
|
||||||
if(image != null) {
|
if(image != null) {
|
||||||
viewBinding.newCommentTextField.editText?.setText("${viewBinding.newCommentTextField.editText?.text}\n\n${image.markdown}")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -226,7 +230,9 @@ class BottomSheetPostComments() : BottomSheetDialogFragment(), OnUserInteractedC
|
|||||||
.usePlugin(LinkifyPlugin.create())
|
.usePlugin(LinkifyPlugin.create())
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
adapter = CommentsRecyclerViewAdapter(listOf(), markwon, this)
|
adapter = CommentsRecyclerViewAdapter(listOf(), markwon, this, onCommentClick = {
|
||||||
|
|
||||||
|
})
|
||||||
viewBinding.recyclerComments.adapter = adapter
|
viewBinding.recyclerComments.adapter = adapter
|
||||||
viewBinding.recyclerComments.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false)
|
viewBinding.recyclerComments.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false)
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,44 @@
|
|||||||
|
package com.isolaatti.posting.comments.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.navigation.fragment.NavHostFragment
|
||||||
|
import com.isolaatti.R
|
||||||
|
import com.isolaatti.databinding.ActivityCommentThreadBinding
|
||||||
|
|
||||||
|
class CommentThreadActivity : AppCompatActivity() {
|
||||||
|
private lateinit var binding: ActivityCommentThreadBinding
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
binding = ActivityCommentThreadBinding.inflate(layoutInflater)
|
||||||
|
setContentView(binding.root)
|
||||||
|
|
||||||
|
val commentId = intent.extras?.getLong(EXTRA_COMMENT_ID)
|
||||||
|
|
||||||
|
if(commentId == null || commentId == 0L) {
|
||||||
|
Toast.makeText(this, R.string.invalid_arg, Toast.LENGTH_SHORT).show()
|
||||||
|
finish()
|
||||||
|
} else {
|
||||||
|
(supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment)
|
||||||
|
.navController.setGraph(R.navigation.comment_thread_navigation,
|
||||||
|
Bundle().apply {
|
||||||
|
putLong(CommentThreadFragment.ARG_COMMENT_ID, commentId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val EXTRA_COMMENT_ID = "commentId"
|
||||||
|
fun startActivity(context: Context, commentId: Long) {
|
||||||
|
val intent = Intent(context, CommentThreadActivity::class.java).apply {
|
||||||
|
putExtra(EXTRA_COMMENT_ID, commentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
package com.isolaatti.posting.comments.ui
|
||||||
|
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
|
||||||
|
class CommentThreadFragment : Fragment() {
|
||||||
|
companion object {
|
||||||
|
const val ARG_COMMENT_ID = "commentId"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ package com.isolaatti.posting.posts.data.remote
|
|||||||
|
|
||||||
import android.os.Parcel
|
import android.os.Parcel
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
|
import com.isolaatti.audio.common.data.AudioDto
|
||||||
import java.io.Serializable
|
import java.io.Serializable
|
||||||
|
|
||||||
data class FeedDto(
|
data class FeedDto(
|
||||||
@ -23,7 +24,8 @@ data class FeedDto(
|
|||||||
var numberOfComments: Int,
|
var numberOfComments: Int,
|
||||||
val userName: String,
|
val userName: String,
|
||||||
val squadName: String?,
|
val squadName: String?,
|
||||||
var liked: Boolean
|
var liked: Boolean,
|
||||||
|
var audio: AudioDto?
|
||||||
): Parcelable {
|
): Parcelable {
|
||||||
|
|
||||||
constructor(parcel: Parcel) : this(
|
constructor(parcel: Parcel) : this(
|
||||||
@ -32,7 +34,8 @@ data class FeedDto(
|
|||||||
parcel.readInt(),
|
parcel.readInt(),
|
||||||
parcel.readString()!!,
|
parcel.readString()!!,
|
||||||
parcel.readString(),
|
parcel.readString(),
|
||||||
parcel.readByte() != 0.toByte()
|
parcel.readByte() != 0.toByte(),
|
||||||
|
parcel.readParcelable(AudioDto::class.java.classLoader)
|
||||||
)
|
)
|
||||||
|
|
||||||
data class Post(
|
data class Post(
|
||||||
@ -93,6 +96,7 @@ data class FeedDto(
|
|||||||
parcel.writeString(userName)
|
parcel.writeString(userName)
|
||||||
parcel.writeString(squadName)
|
parcel.writeString(squadName)
|
||||||
parcel.writeByte(if (liked) 1 else 0)
|
parcel.writeByte(if (liked) 1 else 0)
|
||||||
|
parcel.writeSerializable(audio)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun describeContents(): Int {
|
override fun describeContents(): Int {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package com.isolaatti.posting.posts.data.remote
|
package com.isolaatti.posting.posts.data.remote
|
||||||
|
|
||||||
|
import com.isolaatti.common.ResultDto
|
||||||
import com.isolaatti.profile.data.remote.ProfileListItemDto
|
import com.isolaatti.profile.data.remote.ProfileListItemDto
|
||||||
import retrofit2.Call
|
import retrofit2.Call
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
@ -14,12 +15,13 @@ interface FeedsApi {
|
|||||||
@Query("olderFirst") olderFirst: Boolean,
|
@Query("olderFirst") olderFirst: Boolean,
|
||||||
@Query(value = "filterJson", encoded = false) filter: String): Call<FeedDto>
|
@Query(value = "filterJson", encoded = false) filter: String): Call<FeedDto>
|
||||||
|
|
||||||
@GET("Fetch/Post/{postId}")
|
|
||||||
fun getPost(@Path("postId") postId: Long): Call<FeedDto>
|
|
||||||
|
|
||||||
@GET("Fetch/Post/{postId}/LikedBy")
|
@GET("Fetch/Post/{postId}/LikedBy")
|
||||||
fun getLikedBy(@Path("postId") postId: Long): Call<List<ProfileListItemDto>>
|
fun getLikedBy(@Path("postId") postId: Long): Call<ResultDto<List<ProfileListItemDto>>>
|
||||||
|
|
||||||
@GET("Feed")
|
@GET("Feed")
|
||||||
fun getChronology(@Query("lastId") lastId: Long, @Query("length") length: Int): Call<FeedDto>
|
fun getChronology(@Query("lastId") lastId: Long, @Query("length") length: Int): Call<FeedDto>
|
||||||
|
|
||||||
|
@GET("hashtags/hashtag/{hashtag}")
|
||||||
|
fun getHashtagPosts(@Path("hashtag") hashtag: String, @Query("after") afterPost: Long? = null): Call<FeedDto>
|
||||||
}
|
}
|
||||||
@ -1,10 +1,12 @@
|
|||||||
package com.isolaatti.posting.posts.data.remote
|
package com.isolaatti.posting.posts.data.remote
|
||||||
|
|
||||||
|
import com.isolaatti.common.ResultDto
|
||||||
import retrofit2.Call
|
import retrofit2.Call
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
|
import retrofit2.http.Query
|
||||||
|
|
||||||
interface PostApi {
|
interface PostApi {
|
||||||
@POST("Posting/Make")
|
@POST("Posting/Make")
|
||||||
@ -19,4 +21,7 @@ interface PostApi {
|
|||||||
@GET("Fetch/Post/{postId}")
|
@GET("Fetch/Post/{postId}")
|
||||||
fun getPost(@Path("postId") postId: Long): Call<FeedDto.PostDto>
|
fun getPost(@Path("postId") postId: Long): Call<FeedDto.PostDto>
|
||||||
|
|
||||||
|
@GET("Posting/Post/{postId}/Versions")
|
||||||
|
fun getVersions(@Path("postId") postId: Long): Call<ResultDto<List<VersionDto>>>
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
package com.isolaatti.posting.posts.data.remote
|
||||||
|
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
|
||||||
|
data class VersionDto(
|
||||||
|
val id: Long,
|
||||||
|
val postId: Long,
|
||||||
|
val textContent: String,
|
||||||
|
val dateTime: ZonedDateTime,
|
||||||
|
val audioId: String
|
||||||
|
)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user