Compare commits
60 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 | |||
| 77d092dec5 | |||
| fb92a69fae | |||
| 381274a1b6 | |||
| e33d1c279a | |||
| 0140797e91 |
28
.idea/assetWizardSettings.xml
generated
28
.idea/assetWizardSettings.xml
generated
@ -79,7 +79,7 @@
|
||||
<option name="values">
|
||||
<map>
|
||||
<entry key="color" value="000000" />
|
||||
<entry key="imagePath" value="C:\Users\erike\Downloads\path1-3.svg" />
|
||||
<entry key="imagePath" value="C:\Users\erike\isolaatti-icon.png" />
|
||||
<entry key="scalingPercent" value="74" />
|
||||
<entry key="trimmed" value="true" />
|
||||
</map>
|
||||
@ -181,6 +181,19 @@
|
||||
</PersistentState>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="image">
|
||||
<value>
|
||||
<PersistentState>
|
||||
<option name="values">
|
||||
<map>
|
||||
<entry key="color" value="000000" />
|
||||
<entry key="imagePath" value="C:\Users\erike\isolaatti-notification-icon.png" />
|
||||
<entry key="paddingPercent" value="-10" />
|
||||
</map>
|
||||
</option>
|
||||
</PersistentState>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="text">
|
||||
<value>
|
||||
<PersistentState>
|
||||
@ -205,6 +218,13 @@
|
||||
</entry>
|
||||
</map>
|
||||
</option>
|
||||
<option name="values">
|
||||
<map>
|
||||
<entry key="assetType" value="IMAGE" />
|
||||
<entry key="imageAsset" value="C:\Users\erike\isolaatti-notification-icon.png" />
|
||||
<entry key="outputName" value="ic_notification" />
|
||||
</map>
|
||||
</option>
|
||||
</PersistentState>
|
||||
</value>
|
||||
</entry>
|
||||
@ -308,7 +328,7 @@
|
||||
<PersistentState>
|
||||
<option name="values">
|
||||
<map>
|
||||
<entry key="url" value="file:/$USER_HOME$/AppData/Local/Android/Sdk/icons/material/materialicons/password/baseline_password_24.xml" />
|
||||
<entry key="url" value="file:/$USER_HOME$/AppData/Local/Android/Sdk/icons/material/materialicons/article/baseline_article_24.xml" />
|
||||
</map>
|
||||
</option>
|
||||
</PersistentState>
|
||||
@ -318,8 +338,8 @@
|
||||
</option>
|
||||
<option name="values">
|
||||
<map>
|
||||
<entry key="outputName" value="baseline_password_24" />
|
||||
<entry key="sourceFile" value="C:\Users\erike\Downloads\face-kiss-wink-heart-solid.svg" />
|
||||
<entry key="outputName" value="baseline_article_24" />
|
||||
<entry key="sourceFile" value="C:\Users\erike\Downloads\hashtag-solid.svg" />
|
||||
</map>
|
||||
</option>
|
||||
</PersistentState>
|
||||
|
||||
2
.idea/kotlinc.xml
generated
2
.idea/kotlinc.xml
generated
@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="version" value="1.8.20" />
|
||||
<option name="version" value="1.9.0" />
|
||||
</component>
|
||||
</project>
|
||||
203
.idea/navEditor.xml
generated
203
.idea/navEditor.xml
generated
@ -13,22 +13,41 @@
|
||||
<LayoutPositions>
|
||||
<option name="myPosition">
|
||||
<Point>
|
||||
<option name="x" value="130" />
|
||||
<option name="y" value="18" />
|
||||
<option name="x" value="40" />
|
||||
<option name="y" value="40" />
|
||||
</Point>
|
||||
</option>
|
||||
</LayoutPositions>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="audioRecorderMainFragment">
|
||||
</map>
|
||||
</option>
|
||||
</LayoutPositions>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="comment_thread_navigation.xml">
|
||||
<value>
|
||||
<LayoutPositions>
|
||||
<option name="myPositions">
|
||||
<map>
|
||||
<entry key="commentThreadFragment">
|
||||
<value>
|
||||
<LayoutPositions>
|
||||
<option name="myPosition">
|
||||
<Point>
|
||||
<option name="x" value="-74" />
|
||||
<option name="y" value="17" />
|
||||
<option name="x" value="-258" />
|
||||
<option name="y" value="8" />
|
||||
</Point>
|
||||
</option>
|
||||
<option name="myPositions">
|
||||
<map>
|
||||
<entry key="action_commentThreadFragment_self">
|
||||
<value>
|
||||
<LayoutPositions />
|
||||
</value>
|
||||
</entry>
|
||||
</map>
|
||||
</option>
|
||||
</LayoutPositions>
|
||||
</value>
|
||||
</entry>
|
||||
@ -71,11 +90,66 @@
|
||||
</LayoutPositions>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="hashtags_navigation.xml">
|
||||
<value>
|
||||
<LayoutPositions>
|
||||
<option name="myPositions">
|
||||
<map>
|
||||
<entry key="hashtagPostsFragment">
|
||||
<value>
|
||||
<LayoutPositions>
|
||||
<option name="myPosition">
|
||||
<Point>
|
||||
<option name="x" value="-3" />
|
||||
<option name="y" value="36" />
|
||||
</Point>
|
||||
</option>
|
||||
</LayoutPositions>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="hashtagsFragment">
|
||||
<value>
|
||||
<LayoutPositions>
|
||||
<option name="myPosition">
|
||||
<Point>
|
||||
<option name="x" value="-219" />
|
||||
<option name="y" value="36" />
|
||||
</Point>
|
||||
</option>
|
||||
<option name="myPositions">
|
||||
<map>
|
||||
<entry key="action_hashtagsFragment_to_hashtagPostsFragment">
|
||||
<value>
|
||||
<LayoutPositions />
|
||||
</value>
|
||||
</entry>
|
||||
</map>
|
||||
</option>
|
||||
</LayoutPositions>
|
||||
</value>
|
||||
</entry>
|
||||
</map>
|
||||
</option>
|
||||
</LayoutPositions>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="home_navigation.xml">
|
||||
<value>
|
||||
<LayoutPositions>
|
||||
<option name="myPositions">
|
||||
<map>
|
||||
<entry key="browseProfilesFragment">
|
||||
<value>
|
||||
<LayoutPositions>
|
||||
<option name="myPosition">
|
||||
<Point>
|
||||
<option name="x" value="45" />
|
||||
<option name="y" value="385" />
|
||||
</Point>
|
||||
</option>
|
||||
</LayoutPositions>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="feedFragment">
|
||||
<value>
|
||||
<LayoutPositions>
|
||||
@ -97,6 +171,39 @@
|
||||
</LayoutPositions>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="hashtagPostsFragment2">
|
||||
<value>
|
||||
<LayoutPositions>
|
||||
<option name="myPosition">
|
||||
<Point>
|
||||
<option name="x" value="267" />
|
||||
<option name="y" value="107" />
|
||||
</Point>
|
||||
</option>
|
||||
</LayoutPositions>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="hashtagsFragment">
|
||||
<value>
|
||||
<LayoutPositions>
|
||||
<option name="myPosition">
|
||||
<Point>
|
||||
<option name="x" value="36" />
|
||||
<option name="y" value="-76" />
|
||||
</Point>
|
||||
</option>
|
||||
<option name="myPositions">
|
||||
<map>
|
||||
<entry key="action_hashtagsFragment2_to_hashtagPostsFragment2">
|
||||
<value>
|
||||
<LayoutPositions />
|
||||
</value>
|
||||
</entry>
|
||||
</map>
|
||||
</option>
|
||||
</LayoutPositions>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="notificationsFragment">
|
||||
<value>
|
||||
<LayoutPositions>
|
||||
@ -118,18 +225,6 @@
|
||||
</LayoutPositions>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="postViewerActivity">
|
||||
<value>
|
||||
<LayoutPositions>
|
||||
<option name="myPosition">
|
||||
<Point>
|
||||
<option name="x" value="-520" />
|
||||
<option name="y" value="-373" />
|
||||
</Point>
|
||||
</option>
|
||||
</LayoutPositions>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="profileActivity">
|
||||
<value>
|
||||
<LayoutPositions>
|
||||
@ -142,15 +237,41 @@
|
||||
</LayoutPositions>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="profileListingFragment">
|
||||
<value>
|
||||
<LayoutPositions>
|
||||
<option name="myPosition">
|
||||
<Point>
|
||||
<option name="x" value="-277" />
|
||||
<option name="y" value="154" />
|
||||
</Point>
|
||||
</option>
|
||||
</LayoutPositions>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="searchFragment">
|
||||
<value>
|
||||
<LayoutPositions>
|
||||
<option name="myPosition">
|
||||
<Point>
|
||||
<option name="x" value="12" />
|
||||
<option name="y" value="12" />
|
||||
<option name="x" value="-278" />
|
||||
<option name="y" value="216" />
|
||||
</Point>
|
||||
</option>
|
||||
<option name="myPositions">
|
||||
<map>
|
||||
<entry key="action_searchFragment_to_hashtagPostsFragment">
|
||||
<value>
|
||||
<LayoutPositions />
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="action_searchFragment_to_hashtagsFragment">
|
||||
<value>
|
||||
<LayoutPositions />
|
||||
</value>
|
||||
</entry>
|
||||
</map>
|
||||
</option>
|
||||
</LayoutPositions>
|
||||
</value>
|
||||
</entry>
|
||||
@ -258,6 +379,50 @@
|
||||
</LayoutPositions>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="post_listing_navigation.xml">
|
||||
<value>
|
||||
<LayoutPositions>
|
||||
<option name="myPositions">
|
||||
<map>
|
||||
<entry key="postListingFragment">
|
||||
<value>
|
||||
<LayoutPositions>
|
||||
<option name="myPosition">
|
||||
<Point>
|
||||
<option name="x" value="40" />
|
||||
<option name="y" value="40" />
|
||||
</Point>
|
||||
</option>
|
||||
</LayoutPositions>
|
||||
</value>
|
||||
</entry>
|
||||
</map>
|
||||
</option>
|
||||
</LayoutPositions>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="profile_listing_navigation.xml">
|
||||
<value>
|
||||
<LayoutPositions>
|
||||
<option name="myPositions">
|
||||
<map>
|
||||
<entry key="profileListingFragment">
|
||||
<value>
|
||||
<LayoutPositions>
|
||||
<option name="myPosition">
|
||||
<Point>
|
||||
<option name="x" value="40" />
|
||||
<option name="y" value="40" />
|
||||
</Point>
|
||||
</option>
|
||||
</LayoutPositions>
|
||||
</value>
|
||||
</entry>
|
||||
</map>
|
||||
</option>
|
||||
</LayoutPositions>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="profile_navigation.xml">
|
||||
<value>
|
||||
<LayoutPositions>
|
||||
|
||||
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
|
||||
* Algunos modulos de Android Jetpack y bibliotecas de compatibilidad (revisar build.gradle)
|
||||
|
||||
## Compilar
|
||||
Para compilar la app es necesario utilizar Android Studio. Modifica el archivo local.properties para incluir las siguientes propiedades según tu implementación:
|
||||
```
|
||||
backend=https://isolaatti.com
|
||||
clientId=
|
||||
secret=
|
||||
terms=https://isolaatti.com/terminos_de_uso
|
||||
privacyPolicy=https://isolaatti.com/politica_de_privacidad
|
||||
sourceCodeUrl=https://github.com/Isolaatti-Software/isolaatti-android
|
||||
blogUrl=https://isolaattisoftware.com.mx/
|
||||
openSourceLicences=https://files.isolaatti.com/licencias.html
|
||||
```
|
||||
|
||||
|
||||
## Características planeadas
|
||||
* Grabar audios
|
||||
|
||||
@ -7,6 +7,9 @@ plugins {
|
||||
id 'org.jetbrains.kotlin.plugin.serialization' version '1.8.0'
|
||||
id 'com.google.android.libraries.mapsplatform.secrets-gradle-plugin'
|
||||
id 'androidx.navigation.safeargs.kotlin'
|
||||
id 'com.google.gms.google-services'
|
||||
id 'com.google.firebase.crashlytics'
|
||||
id 'com.google.android.gms.oss-licenses-plugin'
|
||||
}
|
||||
|
||||
android {
|
||||
@ -16,12 +19,20 @@ android {
|
||||
enabled = true
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.2"
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.isolaatti"
|
||||
minSdk 24
|
||||
targetSdk 34
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
versionCode 7
|
||||
versionName "0.7-vc7"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@ -45,11 +56,11 @@ android {
|
||||
dependencies {
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3'
|
||||
|
||||
implementation 'androidx.core:core-ktx:1.10.1'
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation "androidx.recyclerview:recyclerview:1.3.1"
|
||||
implementation "androidx.recyclerview:recyclerview:1.3.2"
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation 'androidx.core:core-ktx:1.10.1'
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1'
|
||||
@ -59,6 +70,7 @@ dependencies {
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||
implementation "androidx.datastore:datastore-preferences:1.0.0"
|
||||
|
||||
// Hilt
|
||||
implementation "com.google.dagger:hilt-android:2.47"
|
||||
@ -70,10 +82,10 @@ dependencies {
|
||||
|
||||
|
||||
// Material 3
|
||||
implementation "com.google.android.material:material:1.9.0"
|
||||
implementation "com.google.android.material:material:1.12.0"
|
||||
|
||||
// Navigation
|
||||
def nav_version = "2.6.0"
|
||||
def nav_version = "2.7.7"
|
||||
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
|
||||
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
|
||||
implementation "androidx.navigation:navigation-dynamic-features-fragment:$nav_version"
|
||||
@ -89,7 +101,7 @@ dependencies {
|
||||
final def markwon_version = '4.6.2'
|
||||
|
||||
// Customtabs
|
||||
implementation 'androidx.browser:browser:1.5.0'
|
||||
implementation 'androidx.browser:browser:1.8.0'
|
||||
|
||||
implementation 'io.coil-kt:coil:2.5.0'
|
||||
implementation 'io.coil-kt:coil-svg:2.5.0'
|
||||
@ -125,4 +137,27 @@ dependencies {
|
||||
// QR
|
||||
implementation 'com.github.androidmads:QRGenerator:1.0.1'
|
||||
|
||||
// Firebase
|
||||
implementation(platform("com.google.firebase:firebase-bom:32.7.3"))
|
||||
implementation("com.google.firebase:firebase-crashlytics")
|
||||
implementation("com.google.firebase:firebase-analytics")
|
||||
implementation("com.google.firebase:firebase-messaging")
|
||||
|
||||
// OSS screen
|
||||
implementation 'com.google.android.gms:play-services-oss-licenses:17.1.0'
|
||||
|
||||
def composeBom = platform('androidx.compose:compose-bom:2024.10.01')
|
||||
implementation composeBom
|
||||
androidTestImplementation composeBom
|
||||
implementation 'androidx.compose.material3:material3'
|
||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
|
||||
debugImplementation 'androidx.compose.ui:ui-test-manifest'
|
||||
|
||||
implementation 'androidx.activity:activity-compose:1.9.2'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.5'
|
||||
implementation 'androidx.compose.runtime:runtime-livedata'
|
||||
|
||||
implementation "io.coil-kt.coil3:coil-compose:3.0.1"
|
||||
}
|
||||
@ -1,12 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<application
|
||||
android:name=".MyApplication"
|
||||
android:allowBackup="true"
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
@ -15,6 +16,15 @@
|
||||
android:theme="@style/Theme.Isolaatti"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="31">
|
||||
|
||||
<activity
|
||||
android:name="com.google.android.gms.oss.licenses.OssLicensesMenuActivity"
|
||||
android:theme="@style/Theme.Isolaatti.OSS" />
|
||||
<activity
|
||||
android:name="com.google.android.gms.oss.licenses.OssLicensesActivity"
|
||||
android:theme="@style/Theme.Isolaatti.OSS" />
|
||||
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
@ -25,14 +35,15 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity android:name=".home.HomeActivity" android:theme="@style/Theme.Isolaatti" />
|
||||
<activity android:name=".home.ui.HomeActivity" android:theme="@style/Theme.Isolaatti" />
|
||||
<activity android:name=".login.LogInActivity" android:theme="@style/Theme.Isolaatti" />
|
||||
<activity android:name=".profile.ui.ProfileActivity"
|
||||
android:theme="@style/Theme.Isolaatti"
|
||||
android:parentActivityName=".MainActivity"/>
|
||||
android:parentActivityName=".home.ui.HomeActivity"/>
|
||||
<activity android:name=".settings.ui.SettingsActivity" android:theme="@style/Theme.Isolaatti"/>
|
||||
<activity android:name=".posting.posts.ui.CreatePostActivity" android:theme="@style/Theme.Isolaatti" android:windowSoftInputMode="adjustResize"/>
|
||||
<activity android:name=".posting.posts.viewer.ui.PostViewerActivity" android:theme="@style/Theme.Isolaatti"/>
|
||||
<activity android:name=".posting.posts.viewer.ui.PostViewerActivity" android:theme="@style/Theme.Isolaatti"
|
||||
android:parentActivityName=".home.ui.HomeActivity"/>
|
||||
<activity android:name=".drafts.ui.DraftsActivity" android:theme="@style/Theme.Isolaatti"/>
|
||||
<activity android:name=".about.AboutActivity" android:theme="@style/Theme.Isolaatti"/>
|
||||
<activity android:name=".images.picture_viewer.ui.PictureViewerActivity" android:theme="@style/Theme.Isolaatti"/>
|
||||
@ -40,6 +51,10 @@
|
||||
<activity android:name=".images.image_maker.ui.ImageMakerActivity" android:theme="@style/Theme.Isolaatti"/>
|
||||
<activity android:name=".images.image_chooser.ui.ImageChooserActivity" android:theme="@style/Theme.Isolaatti"/>
|
||||
<activity android:name=".profile.ui.EditProfileActivity" android:theme="@style/Theme.Isolaatti" />
|
||||
<activity android:name=".audio.recorder.ui.AudioRecorderActivity" android:theme="@style/Theme.Isolaatti" />
|
||||
<activity android:name=".audio.audio_selector.ui.AudioSelectorActivity" android:theme="@style/Theme.Isolaatti" />
|
||||
<activity android:name=".posting.posts.ui.PostInfoActivity" android:theme="@style/Theme.Isolaatti"/>
|
||||
<activity android:name=".hashtags.ui.HashtagsPostsActivity" android:theme="@style/Theme.Isolaatti" />
|
||||
<provider
|
||||
android:authorities="com.isolaatti.provider"
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
@ -48,6 +63,15 @@
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/provider_paths"/>
|
||||
</provider>
|
||||
<service android:name=".push_notifications.FcmService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
|
||||
</service>
|
||||
|
||||
</application>
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
|
||||
|
||||
</manifest>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 11 KiB |
@ -7,7 +7,7 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import com.isolaatti.auth.data.AuthRepositoryImpl
|
||||
import com.isolaatti.home.HomeActivity
|
||||
import com.isolaatti.home.ui.HomeActivity
|
||||
import com.isolaatti.login.LogInActivity
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
|
||||
@ -1,17 +1,42 @@
|
||||
package com.isolaatti
|
||||
|
||||
import android.app.Application
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import com.isolaatti.connectivity.ConnectivityCallbackImpl
|
||||
import com.isolaatti.push_notifications.PushNotificationsApi
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import retrofit2.awaitResponse
|
||||
import javax.inject.Inject
|
||||
|
||||
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
|
||||
|
||||
@HiltAndroidApp
|
||||
class MyApplication : Application() {
|
||||
|
||||
companion object {
|
||||
lateinit var myApp: MyApplication
|
||||
const val LOG_TAG = "MyApplication"
|
||||
|
||||
const val LIKES_NOTIFICATION_CHANNEL_ID = "like notification"
|
||||
}
|
||||
|
||||
@Inject
|
||||
lateinit var pushNotificationsApi: PushNotificationsApi
|
||||
|
||||
private val activityLifecycleCallbacks = ActivityLifecycleCallbacks()
|
||||
lateinit var connectivityCallbackImpl: ConnectivityCallbackImpl
|
||||
|
||||
@ -21,6 +46,28 @@ class MyApplication : Application() {
|
||||
registerActivityLifecycleCallbacks(activityLifecycleCallbacks)
|
||||
connectivityCallbackImpl = ConnectivityCallbackImpl()
|
||||
getSystemService(ConnectivityManager::class.java).registerDefaultNetworkCallback(connectivityCallbackImpl)
|
||||
|
||||
FirebaseMessaging.getInstance().token.addOnSuccessListener {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val response = pushNotificationsApi.registerDevice(it.toRequestBody()).awaitResponse()
|
||||
}
|
||||
}
|
||||
|
||||
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
createLikesNotificationChannel(notificationManager)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun createLikesNotificationChannel(notificationManager: NotificationManager) {
|
||||
val name = getString(R.string.likes_notification_channel)
|
||||
val description = getString(R.string.likes_notification_channel_description)
|
||||
val importance = NotificationManager.IMPORTANCE_DEFAULT
|
||||
val channel = NotificationChannel(LIKES_NOTIFICATION_CHANNEL_ID, name, importance).also {
|
||||
it.description = description
|
||||
}
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
override fun onTerminate() {
|
||||
|
||||
@ -1,7 +1,13 @@
|
||||
package com.isolaatti.about
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.core.net.toUri
|
||||
import com.google.android.gms.oss.licenses.OssLicensesMenuActivity
|
||||
import com.isolaatti.BuildConfig
|
||||
import com.isolaatti.R
|
||||
import com.isolaatti.databinding.ActivityAboutBinding
|
||||
|
||||
class AboutActivity : AppCompatActivity() {
|
||||
@ -17,5 +23,37 @@ class AboutActivity : AppCompatActivity() {
|
||||
binding.toolbar.setNavigationOnClickListener {
|
||||
finish()
|
||||
}
|
||||
|
||||
binding.blogButton.setOnClickListener {
|
||||
CustomTabsIntent.Builder()
|
||||
.build()
|
||||
.launchUrl(this, BuildConfig.blogUrl.toUri())
|
||||
}
|
||||
|
||||
binding.sourceCodeButton.setOnClickListener {
|
||||
CustomTabsIntent.Builder()
|
||||
.setSendToExternalDefaultHandlerEnabled(true)
|
||||
.build()
|
||||
.launchUrl(this, BuildConfig.sourceCodeUrl.toUri())
|
||||
}
|
||||
|
||||
binding.openSourceLicences.setOnClickListener {
|
||||
startActivity(Intent(this, OssLicensesMenuActivity::class.java))
|
||||
|
||||
}
|
||||
|
||||
binding.privacyPolicyButton.setOnClickListener {
|
||||
CustomTabsIntent.Builder()
|
||||
.build()
|
||||
.launchUrl(this, BuildConfig.privacyPolicy.toUri())
|
||||
}
|
||||
|
||||
binding.termsButon.setOnClickListener {
|
||||
CustomTabsIntent.Builder()
|
||||
.build()
|
||||
.launchUrl(this, BuildConfig.terms.toUri())
|
||||
}
|
||||
|
||||
binding.appVersion.text = getString(R.string.app_version, BuildConfig.VERSION_NAME)
|
||||
}
|
||||
}
|
||||
@ -4,7 +4,12 @@ import androidx.media3.common.Player
|
||||
import com.isolaatti.audio.common.data.AudiosApi
|
||||
import com.isolaatti.audio.common.data.AudiosRepositoryImpl
|
||||
import com.isolaatti.audio.common.domain.AudiosRepository
|
||||
import com.isolaatti.audio.drafts.data.AudioDraftsRepositoryImpl
|
||||
import com.isolaatti.audio.drafts.data.AudiosDraftsDao
|
||||
import com.isolaatti.audio.drafts.domain.repository.AudioDraftsRepository
|
||||
import com.isolaatti.connectivity.RetrofitClient
|
||||
import com.isolaatti.database.AppDatabase
|
||||
import com.isolaatti.settings.domain.UserIdSetting
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
@ -19,7 +24,17 @@ class Module {
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun provideAudiosRepository(audiosApi: AudiosApi): AudiosRepository {
|
||||
return AudiosRepositoryImpl(audiosApi)
|
||||
fun provideAudiosRepository(audiosApi: AudiosApi, audiosDraftsDao: AudiosDraftsDao, userIdSetting: UserIdSetting): AudiosRepository {
|
||||
return AudiosRepositoryImpl(audiosApi, audiosDraftsDao, userIdSetting)
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun provideAudioDraftsDao(database: AppDatabase): AudiosDraftsDao {
|
||||
return database.audioDrafts()
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun provideAudioDraftsRepository(audiosDraftsDao: AudiosDraftsDao): AudioDraftsRepository {
|
||||
return AudioDraftsRepositoryImpl(audiosDraftsDao)
|
||||
}
|
||||
}
|
||||
@ -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.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||
import coil.load
|
||||
import com.isolaatti.R
|
||||
import com.isolaatti.audio.common.domain.Audio
|
||||
import com.isolaatti.databinding.AudioListItemBinding
|
||||
|
||||
class AudiosAdapter(
|
||||
private val onClick: ((audio: Audio) -> Unit),
|
||||
private val onPlayClick: ((audio: Audio) -> Unit),
|
||||
private val onOptionsClick: ((audio: Audio, button: View) -> Boolean)
|
||||
) : RecyclerView.Adapter<AudiosAdapter.AudiosViewHolder>() {
|
||||
|
||||
enum class Payload {
|
||||
PlayStateChanged, IsLoadingChanged
|
||||
}
|
||||
|
||||
private var data: List<Audio> = listOf()
|
||||
private var currentPlaying: Audio? = null
|
||||
|
||||
fun setData(audios: List<Audio>) {
|
||||
data = audios
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun setIsPlaying(isPlaying: Boolean, audio: Audio) {
|
||||
if(audio == currentPlaying) {
|
||||
val index = data.indexOf(audio)
|
||||
if(index == -1) return
|
||||
|
||||
data[index].isPlaying = isPlaying
|
||||
|
||||
notifyItemChanged(index, Payload.PlayStateChanged)
|
||||
return
|
||||
}
|
||||
|
||||
val prevIndex = data.indexOf(currentPlaying)
|
||||
|
||||
if(prevIndex != -1) {
|
||||
data[prevIndex].isPlaying = false
|
||||
notifyItemChanged(prevIndex, Payload.PlayStateChanged)
|
||||
}
|
||||
val newIndex = data.indexOf(audio)
|
||||
if(newIndex != -1) {
|
||||
data[newIndex].isPlaying = isPlaying
|
||||
notifyItemChanged(newIndex, Payload.PlayStateChanged)
|
||||
}
|
||||
|
||||
currentPlaying = audio
|
||||
}
|
||||
|
||||
fun setIsLoadingAudio(isLoading: Boolean, audio: Audio) {
|
||||
if(audio == currentPlaying) {
|
||||
val index = data.indexOf(audio)
|
||||
if(index == -1) return
|
||||
|
||||
data[index].isLoading = isLoading
|
||||
|
||||
notifyItemChanged(index, Payload.IsLoadingChanged)
|
||||
return
|
||||
}
|
||||
val prevIndex = data.indexOf(currentPlaying)
|
||||
|
||||
if(prevIndex != -1) {
|
||||
data[prevIndex].isLoading = false
|
||||
notifyItemChanged(prevIndex, Payload.IsLoadingChanged)
|
||||
}
|
||||
val newIndex = data.indexOf(audio)
|
||||
if(newIndex != -1) {
|
||||
data[newIndex].isLoading = isLoading
|
||||
notifyItemChanged(newIndex, Payload.IsLoadingChanged)
|
||||
}
|
||||
currentPlaying = audio
|
||||
}
|
||||
|
||||
fun updateAudioProgress(total: Int, progress: Int, audio: Audio) {
|
||||
|
||||
}
|
||||
|
||||
inner class AudiosViewHolder(val binding: AudioListItemBinding) : ViewHolder(binding.root)
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AudiosViewHolder {
|
||||
@ -32,19 +93,62 @@ class AudiosAdapter(
|
||||
return data.size
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: AudiosViewHolder, position: Int) {
|
||||
override fun onBindViewHolder(
|
||||
holder: AudiosViewHolder,
|
||||
position: Int,
|
||||
payloads: MutableList<Any>
|
||||
) {
|
||||
val audio = data[position]
|
||||
if(payloads.isEmpty()) {
|
||||
|
||||
|
||||
holder.binding.audioName.text = audio.name
|
||||
holder.binding.audioAuthor.text = audio.userName
|
||||
holder.binding.thumbnail.load(audio.thumbnail)
|
||||
|
||||
holder.binding.root.setOnClickListener {
|
||||
onClick(audio)
|
||||
}
|
||||
|
||||
holder.binding.audioItemOptionsButton.setOnClickListener {
|
||||
onOptionsClick(audio, it)
|
||||
}
|
||||
|
||||
holder.binding.playButton.icon = ResourcesCompat.getDrawable(
|
||||
holder.itemView.resources,
|
||||
if(audio.isPlaying) R.drawable.baseline_pause_24 else R.drawable.baseline_play_arrow_24,
|
||||
null
|
||||
)
|
||||
|
||||
holder.binding.loading.visibility = if(audio.isLoading) View.VISIBLE else View.GONE
|
||||
|
||||
holder.binding.playButton.setOnClickListener {
|
||||
onPlayClick(audio)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// only updates play button
|
||||
if(payloads.contains(Payload.PlayStateChanged)) {
|
||||
holder.binding.playButton.icon = ResourcesCompat.getDrawable(
|
||||
holder.itemView.resources,
|
||||
if(audio.isPlaying) R.drawable.baseline_pause_24 else R.drawable.baseline_play_arrow_24,
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
if(payloads.contains(Payload.IsLoadingChanged)) {
|
||||
holder.binding.loading.visibility = if(audio.isLoading) View.VISIBLE else View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: AudiosViewHolder, position: Int) {}
|
||||
|
||||
fun removeAudio(audio: Audio) {
|
||||
val index = data.indexOf(audio)
|
||||
|
||||
if(index == -1) return
|
||||
// TODO data should be modified from outside
|
||||
data = data.toMutableList().apply {
|
||||
removeAt(index)
|
||||
}
|
||||
notifyItemRemoved(index)
|
||||
}
|
||||
}
|
||||
@ -17,7 +17,7 @@ import javax.inject.Inject
|
||||
@HiltViewModel
|
||||
class AudiosViewModel @Inject constructor(private val audiosRepository: AudiosRepository) : ViewModel() {
|
||||
val resource: MutableLiveData<Resource<List<Audio>>> = MutableLiveData()
|
||||
|
||||
val audioRemoved: MutableLiveData<Audio?> = MutableLiveData()
|
||||
|
||||
fun loadAudios(userId: Int) {
|
||||
viewModelScope.launch {
|
||||
@ -28,4 +28,14 @@ class AudiosViewModel @Inject constructor(private val audiosRepository: AudiosRe
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAudio(audio: Audio) {
|
||||
viewModelScope.launch {
|
||||
audiosRepository.deleteAudio(audio.id).onEach {
|
||||
if(it is Resource.Success) {
|
||||
audioRemoved.postValue(audio)
|
||||
}
|
||||
}.flowOn(Dispatchers.IO).launchIn(this)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -8,16 +8,21 @@ import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.isolaatti.R
|
||||
import com.isolaatti.audio.audios_list.presentation.AudiosAdapter
|
||||
import com.isolaatti.audio.audios_list.presentation.AudiosViewModel
|
||||
import com.isolaatti.audio.common.domain.Audio
|
||||
import com.isolaatti.audio.common.domain.Playable
|
||||
import com.isolaatti.audio.player.AudioPlayerConnector
|
||||
import com.isolaatti.common.ErrorMessageViewModel
|
||||
import com.isolaatti.databinding.FragmentAudiosBinding
|
||||
import com.isolaatti.utils.Resource
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlin.properties.Delegates
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AudiosFragment : Fragment() {
|
||||
@ -26,6 +31,9 @@ class AudiosFragment : Fragment() {
|
||||
private val errorViewModel: ErrorMessageViewModel by activityViewModels()
|
||||
private val arguments: AudiosFragmentArgs by navArgs()
|
||||
private lateinit var adapter: AudiosAdapter
|
||||
private var privilegedUserId by Delegates.notNull<Int>()
|
||||
|
||||
private lateinit var audioPlayerConnector: AudioPlayerConnector
|
||||
|
||||
private var loadedFirstTime = false
|
||||
override fun onCreateView(
|
||||
@ -38,24 +46,81 @@ class AudiosFragment : Fragment() {
|
||||
return viewBinding.root
|
||||
}
|
||||
|
||||
private fun onDeleteAudio(audio: Audio) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setMessage(R.string.delete_audio_message)
|
||||
.setTitle(R.string.delete_audio_title)
|
||||
.setPositiveButton(R.string.yes_continue) { _, _ ->
|
||||
viewModel.removeAudio(audio)
|
||||
}
|
||||
.setNegativeButton(R.string.no, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private val onOptionsClick: ((audio: Audio, button: View) -> Boolean) = { audio, button ->
|
||||
val popup = PopupMenu(requireContext(), button)
|
||||
popup.menuInflater.inflate(R.menu.audio_item_menu, popup.menu)
|
||||
|
||||
if(audio.userId != privilegedUserId) {
|
||||
popup.menu.removeItem(R.id.rename_item)
|
||||
popup.menu.removeItem(R.id.delete_item)
|
||||
popup.menu.removeItem(R.id.set_as_profile_audio)
|
||||
}
|
||||
|
||||
popup.setOnMenuItemClickListener {
|
||||
when(it.itemId) {
|
||||
R.id.delete_item -> {
|
||||
onDeleteAudio(audio)
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
popup.show()
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
private val onAudioClick: ((audio: Audio) -> Unit) = {
|
||||
// Play audio
|
||||
private val onAudioPlayClick: (audio: Audio) -> Unit = {
|
||||
audioPlayerConnector.playPauseAudio(it)
|
||||
}
|
||||
|
||||
private val audioPlayerConnectorListener: AudioPlayerConnector.Listener = object: AudioPlayerConnector.Listener {
|
||||
override fun onPlaying(isPlaying: Boolean, audio: Playable) {
|
||||
if(audio is Audio)
|
||||
adapter.setIsPlaying(isPlaying, audio)
|
||||
}
|
||||
|
||||
override fun isLoading(isLoading: Boolean, audio: Playable) {
|
||||
if(audio is Audio)
|
||||
adapter.setIsLoadingAudio(isLoading, audio)
|
||||
}
|
||||
|
||||
override fun progressChanged(second: Int, audio: Playable) {
|
||||
|
||||
}
|
||||
|
||||
override fun durationChanged(duration: Int, audio: Playable) {
|
||||
|
||||
}
|
||||
|
||||
override fun onEnded(audio: Playable) {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
adapter = AudiosAdapter(onClick = onAudioClick, onOptionsClick = onOptionsClick)
|
||||
audioPlayerConnector = AudioPlayerConnector(requireContext())
|
||||
|
||||
audioPlayerConnector.addListener(audioPlayerConnectorListener)
|
||||
|
||||
viewLifecycleOwner.lifecycle.addObserver(audioPlayerConnector)
|
||||
|
||||
adapter = AudiosAdapter(onPlayClick = onAudioPlayClick, onOptionsClick = onOptionsClick)
|
||||
|
||||
viewBinding.recyclerAudios.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false)
|
||||
viewBinding.recyclerAudios.adapter = adapter
|
||||
@ -63,7 +128,12 @@ class AudiosFragment : Fragment() {
|
||||
|
||||
setupObservers()
|
||||
if(arguments.source == SOURCE_PROFILE) {
|
||||
viewModel.loadAudios(arguments.sourceId.toInt())
|
||||
privilegedUserId = arguments.sourceId.toInt()
|
||||
viewModel.loadAudios(privilegedUserId)
|
||||
}
|
||||
|
||||
viewBinding.topAppBar.setNavigationOnClickListener {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
}
|
||||
|
||||
@ -85,6 +155,15 @@ class AudiosFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.audioRemoved.observe(viewLifecycleOwner) {
|
||||
if(it != null){
|
||||
adapter.removeAudio(it)
|
||||
viewModel.audioRemoved.value = null
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package com.isolaatti.audio.common.data
|
||||
|
||||
import java.io.Serializable
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
data class AudiosDto(val data: List<AudioDto>)
|
||||
@ -10,4 +11,4 @@ data class AudioDto(
|
||||
val userId: Int,
|
||||
val firestoreObjectPath: String,
|
||||
val userName: String
|
||||
)
|
||||
): Serializable
|
||||
@ -1,11 +1,28 @@
|
||||
package com.isolaatti.audio.common.data
|
||||
|
||||
import com.isolaatti.common.ResultDto
|
||||
import okhttp3.MultipartBody
|
||||
import retrofit2.Call
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Multipart
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Part
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface AudiosApi {
|
||||
companion object {
|
||||
const val AudioParam= "audioFile"
|
||||
const val NameParam = "name"
|
||||
const val DurationParam = "duration"
|
||||
}
|
||||
@GET("/api/Audios/OfUser/{userId}")
|
||||
fun getAudiosOfUser(@Path("userId") userId: Int, @Query("lastAudioId") lastAudioId: String?): Call<AudiosDto>
|
||||
|
||||
@POST("/api/Audios/Create")
|
||||
@Multipart
|
||||
fun uploadFile(@Part file: MultipartBody.Part, @Part name: MultipartBody.Part, @Part duration: MultipartBody.Part): Call<AudioDto>
|
||||
|
||||
@POST("/api/Audios/{audioId}/Delete")
|
||||
fun deleteAudio(@Path("audioId") audioId: String): Call<ResultDto<String>>
|
||||
}
|
||||
@ -1,32 +1,117 @@
|
||||
package com.isolaatti.audio.common.data
|
||||
|
||||
import android.util.Log
|
||||
import com.isolaatti.MyApplication
|
||||
import com.isolaatti.audio.common.domain.Audio
|
||||
import com.isolaatti.audio.common.domain.AudiosRepository
|
||||
import com.isolaatti.audio.drafts.data.AudiosDraftsDao
|
||||
import com.isolaatti.settings.domain.UserIdSetting
|
||||
import com.isolaatti.utils.Resource
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import retrofit2.awaitResponse
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
class AudiosRepositoryImpl @Inject constructor(private val audiosApi: AudiosApi) : AudiosRepository {
|
||||
override fun getAudiosOfUser(userId: Int, lastId: String?): Flow<Resource<List<Audio>>> = flow {
|
||||
emit(Resource.Loading())
|
||||
class AudiosRepositoryImpl @Inject constructor(
|
||||
private val audiosApi: AudiosApi,
|
||||
private val audiosDraftsDao: AudiosDraftsDao,
|
||||
private val userIdSetting: UserIdSetting
|
||||
) : AudiosRepository {
|
||||
companion object {
|
||||
const val LOG_TAG = "AudiosRepositoryImpl"
|
||||
}
|
||||
|
||||
private suspend fun _getAudiosOfUser(userId: Int, lastId: String?): Resource<List<Audio>> {
|
||||
try {
|
||||
val response = audiosApi.getAudiosOfUser(userId, lastId).awaitResponse()
|
||||
|
||||
if(response.isSuccessful) {
|
||||
val body = response.body()
|
||||
if(body == null) {
|
||||
emit(Resource.Error(Resource.Error.ErrorType.ServerError))
|
||||
return@flow
|
||||
}
|
||||
val body = response.body() ?: return Resource.Error(Resource.Error.ErrorType.ServerError)
|
||||
|
||||
emit(Resource.Success(body.data.map { Audio.fromDto(it) }))
|
||||
return Resource.Success(body.data.map { Audio.fromDto(it) })
|
||||
} else {
|
||||
return Resource.Error(Resource.Error.ErrorType.ServerError)
|
||||
}
|
||||
} catch(e: Exception) {
|
||||
Log.e("AudiosRepositoryImpl", e.message.toString())
|
||||
emit(Resource.Error(Resource.Error.ErrorType.NetworkError))
|
||||
return Resource.Error(Resource.Error.ErrorType.NetworkError)
|
||||
}
|
||||
}
|
||||
override fun getAudiosOfUser(userId: Int, lastId: String?): Flow<Resource<List<Audio>>> = flow {
|
||||
emit(Resource.Loading())
|
||||
emit(_getAudiosOfUser(userId, lastId))
|
||||
}
|
||||
|
||||
override fun getMyAudios(lastId: String?): Flow<Resource<List<Audio>>> = flow {
|
||||
emit(Resource.Loading())
|
||||
val userId = userIdSetting.getUserIdAsync()
|
||||
if(userId == null) {
|
||||
emit(Resource.Error(Resource.Error.ErrorType.OtherError))
|
||||
return@flow
|
||||
}
|
||||
|
||||
emit(_getAudiosOfUser(userId, lastId))
|
||||
}
|
||||
|
||||
override fun uploadAudio(draftId: Long): Flow<Resource<Audio>> = flow {
|
||||
val audioDraftEntity = audiosDraftsDao.getAudioDraftById(draftId)
|
||||
if(audioDraftEntity == null) {
|
||||
emit(Resource.Error(Resource.Error.ErrorType.NotFoundError, "draft not found"))
|
||||
return@flow
|
||||
}
|
||||
|
||||
val file = File(MyApplication.myApp.filesDir, audioDraftEntity.audioLocalPath)
|
||||
|
||||
if(!file.exists()) {
|
||||
// remove draft, file was removed from file system for some reason
|
||||
audiosDraftsDao.deleteDrafts(arrayOf(audioDraftEntity))
|
||||
emit(Resource.Error(Resource.Error.ErrorType.OtherError, "File not found"))
|
||||
return@flow
|
||||
}
|
||||
|
||||
try {
|
||||
val response = audiosApi.uploadFile(
|
||||
MultipartBody.Part.createFormData(
|
||||
AudiosApi.AudioParam, // api parameter name
|
||||
audioDraftEntity.audioLocalPath.split("/")[1],
|
||||
file.asRequestBody("audio/3gpp".toMediaType()) // actual file to be sent
|
||||
),
|
||||
MultipartBody.Part.createFormData(AudiosApi.NameParam, audioDraftEntity.name),
|
||||
MultipartBody.Part.createFormData(AudiosApi.DurationParam, "0")
|
||||
).awaitResponse()
|
||||
|
||||
if(response.isSuccessful) {
|
||||
val audioDto = response.body()
|
||||
if(audioDto != null) {
|
||||
Log.d(LOG_TAG, "emit audio dto")
|
||||
audiosDraftsDao.deleteDrafts(arrayOf(audioDraftEntity))
|
||||
emit(Resource.Success(Audio.fromDto(audioDto)))
|
||||
}
|
||||
|
||||
} else {
|
||||
emit(Resource.Error(Resource.Error.mapErrorCode(response.code())))
|
||||
}
|
||||
} catch(e: Exception) {
|
||||
Log.d(LOG_TAG, e.message.toString())
|
||||
}
|
||||
}
|
||||
|
||||
override fun deleteAudio(audioId: String): Flow<Resource<Boolean>> = flow {
|
||||
emit(Resource.Loading())
|
||||
try {
|
||||
val response = audiosApi.deleteAudio(audioId).awaitResponse()
|
||||
|
||||
if(response.isSuccessful) {
|
||||
emit(Resource.Success(response.isSuccessful))
|
||||
} else {
|
||||
emit(Resource.Error(Resource.Error.mapErrorCode(response.code())))
|
||||
}
|
||||
} catch(e: Exception) {
|
||||
Log.e(LOG_TAG, e.message.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,7 @@
|
||||
package com.isolaatti.audio.common.domain
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import com.isolaatti.audio.common.data.AudioDto
|
||||
import com.isolaatti.common.Ownable
|
||||
import com.isolaatti.connectivity.RetrofitClient.Companion.BASE_URL
|
||||
@ -13,17 +15,16 @@ data class Audio(
|
||||
val creationTime: ZonedDateTime,
|
||||
override val userId: Int,
|
||||
val userName: String
|
||||
): Ownable, Serializable {
|
||||
var playing: Boolean = false
|
||||
val downloadUrl: String get() {
|
||||
return "${BASE_URL}audios/$id.webm"
|
||||
): Ownable, Playable(), Serializable {
|
||||
|
||||
override val uri: Uri get() {
|
||||
return "${BASE_URL}audios/$id.webm".toUri()
|
||||
}
|
||||
|
||||
val thumbnail: String get() {
|
||||
override val thumbnail: String get() {
|
||||
return UrlGen.userProfileImage(userId)
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
fun fromDto(audioDto: AudioDto): Audio {
|
||||
return Audio(
|
||||
|
||||
@ -5,4 +5,9 @@ import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface AudiosRepository {
|
||||
fun getAudiosOfUser(userId: Int, lastId: String?): Flow<Resource<List<Audio>>>
|
||||
fun getMyAudios(lastId: String?): Flow<Resource<List<Audio>>>
|
||||
|
||||
fun uploadAudio(draftId: Long): Flow<Resource<Audio>>
|
||||
|
||||
fun deleteAudio(audioId: String): Flow<Resource<Boolean>>
|
||||
}
|
||||
@ -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.LifecycleOwner
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.drm.DrmSessionManagerProvider
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.exoplayer.source.MediaSource
|
||||
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
|
||||
import com.isolaatti.audio.common.domain.Audio
|
||||
import com.isolaatti.audio.common.domain.Playable
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@ -28,7 +35,7 @@ class AudioPlayerConnector(
|
||||
const val TAG = "AudioPlayerConnector"
|
||||
}
|
||||
private var player: Player? = null
|
||||
private var audio: Audio? = null
|
||||
private var audio: Playable? = null
|
||||
private var mediaItem: MediaItem? = null
|
||||
private var ended = false
|
||||
|
||||
@ -62,6 +69,9 @@ class AudioPlayerConnector(
|
||||
}
|
||||
|
||||
private val playerListener: Player.Listener = object: Player.Listener {
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
Log.e(TAG, error.message.toString())
|
||||
}
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
if(audio != null) {
|
||||
listeners.forEach { listener -> listener.onPlaying(isPlaying, audio!!)}
|
||||
@ -83,12 +93,23 @@ class AudioPlayerConnector(
|
||||
Player.STATE_ENDED -> {
|
||||
Log.d(TAG, "STATE_ENDED")
|
||||
audio?.let {
|
||||
listeners.forEach { listener -> listener.onPlaying(false, it)}
|
||||
listeners.forEach { listener ->
|
||||
listener.onPlaying(false, it)
|
||||
listener.onEnded(it)
|
||||
}
|
||||
}
|
||||
stopTimer()
|
||||
ended = true
|
||||
}
|
||||
Player.STATE_BUFFERING -> {}
|
||||
Player.STATE_BUFFERING -> {
|
||||
player?.totalBufferedDuration?.let {
|
||||
val seconds = (it / 1000).toInt()
|
||||
Log.d(TAG, "Duration $it")
|
||||
audio?.let {
|
||||
listeners.forEach { listener -> listener.durationChanged(seconds, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Player.STATE_IDLE -> {}
|
||||
Player.STATE_READY -> {
|
||||
Log.d(TAG, "STATE_READY")
|
||||
@ -107,7 +128,6 @@ class AudioPlayerConnector(
|
||||
|
||||
private fun initializePlayer() {
|
||||
player = ExoPlayer.Builder(context).build()
|
||||
player?.playWhenReady = true
|
||||
player?.addListener(playerListener)
|
||||
player?.prepare()
|
||||
}
|
||||
@ -125,23 +145,22 @@ class AudioPlayerConnector(
|
||||
}
|
||||
|
||||
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
|
||||
Log.d(TAG, event.toString())
|
||||
when(event) {
|
||||
Lifecycle.Event.ON_START -> {
|
||||
initializePlayer()
|
||||
}
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
if(player == null) {
|
||||
initializePlayer()
|
||||
}
|
||||
}
|
||||
Lifecycle.Event.ON_STOP, Lifecycle.Event.ON_DESTROY -> {
|
||||
Lifecycle.Event.ON_DESTROY -> {
|
||||
releasePlayer()
|
||||
listeners.clear()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
fun playPauseAudio(audio: Audio) {
|
||||
fun playPauseAudio(audio: Playable) {
|
||||
|
||||
// intention is to pause current audio
|
||||
if(audio == this.audio && player?.isPlaying == true) {
|
||||
@ -157,15 +176,31 @@ class AudioPlayerConnector(
|
||||
return
|
||||
}
|
||||
this.audio = audio
|
||||
mediaItem = MediaItem.fromUri(Uri.parse(audio.downloadUrl))
|
||||
mediaItem = MediaItem.fromUri(audio.uri)
|
||||
|
||||
player?.setMediaItem(mediaItem!!)
|
||||
player?.playWhenReady = true
|
||||
}
|
||||
|
||||
fun stopPlayback() {
|
||||
ended = true
|
||||
player?.pause()
|
||||
stopTimer()
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
fun onPlaying(isPlaying: Boolean, audio: Audio)
|
||||
fun isLoading(isLoading: Boolean, audio: Audio)
|
||||
fun progressChanged(second: Int, audio: Audio)
|
||||
fun durationChanged(duration: Int, audio: Audio)
|
||||
fun onPlaying(isPlaying: Boolean, audio: Playable)
|
||||
fun isLoading(isLoading: Boolean, audio: Playable)
|
||||
fun progressChanged(second: Int, audio: Playable)
|
||||
fun durationChanged(duration: Int, audio: Playable)
|
||||
fun onEnded(audio: Playable)
|
||||
}
|
||||
|
||||
open class DefaultListener() : Listener {
|
||||
override fun onPlaying(isPlaying: Boolean, audio: Playable) {}
|
||||
override fun isLoading(isLoading: Boolean, audio: Playable) {}
|
||||
override fun progressChanged(second: Int, audio: Playable) {}
|
||||
override fun durationChanged(duration: Int, audio: Playable) {}
|
||||
override fun onEnded(audio: Playable) {}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
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() {
|
||||
|
||||
}
|
||||
14
app/src/main/java/com/isolaatti/auth/AuthUtils.kt
Normal file
14
app/src/main/java/com/isolaatti/auth/AuthUtils.kt
Normal file
@ -0,0 +1,14 @@
|
||||
package com.isolaatti.auth
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import com.isolaatti.BuildConfig
|
||||
|
||||
const val RecoverPasswordRelativePath = "/recuperacion_cuenta"
|
||||
fun openForgotPassword(context: Context) {
|
||||
CustomTabsIntent.Builder()
|
||||
.setShowTitle(true)
|
||||
.build()
|
||||
.launchUrl(context, Uri.parse("${BuildConfig.backend}$RecoverPasswordRelativePath"))
|
||||
}
|
||||
@ -1,12 +1,13 @@
|
||||
package com.isolaatti.auth
|
||||
|
||||
import com.isolaatti.auth.data.AuthRepositoryImpl
|
||||
import com.isolaatti.settings.data.KeyValueDao
|
||||
import com.isolaatti.auth.data.UserInfoRepositoryImpl
|
||||
import com.isolaatti.auth.data.local.TokenStorage
|
||||
import com.isolaatti.auth.data.local.UserInfoDao
|
||||
import com.isolaatti.auth.data.remote.AuthApi
|
||||
import com.isolaatti.auth.domain.AuthRepository
|
||||
import com.isolaatti.auth.domain.UserInfoRepository
|
||||
import com.isolaatti.connectivity.RetrofitClient
|
||||
import com.isolaatti.database.AppDatabase
|
||||
import com.isolaatti.settings.domain.UserIdSetting
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
@ -25,4 +26,9 @@ class Module {
|
||||
fun provideAuthRepository(tokenStorage: TokenStorage, authApi: AuthApi, userIdSetting: UserIdSetting): AuthRepository {
|
||||
return AuthRepositoryImpl(tokenStorage, authApi, userIdSetting)
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun provideUserInfoRepository(userIdSetting: UserIdSetting, userInfoDao: UserInfoDao): UserInfoRepository {
|
||||
return UserInfoRepositoryImpl(userIdSetting, userInfoDao)
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,6 @@
|
||||
package com.isolaatti.auth.data
|
||||
|
||||
import com.isolaatti.BuildConfig
|
||||
import com.isolaatti.settings.data.KeyValueDao
|
||||
import com.isolaatti.settings.data.KeyValueEntity
|
||||
import com.isolaatti.auth.data.remote.AuthTokenDto
|
||||
import com.isolaatti.auth.data.local.TokenStorage
|
||||
import com.isolaatti.auth.data.remote.AuthApi
|
||||
@ -10,8 +8,12 @@ import com.isolaatti.auth.data.remote.Credential
|
||||
import com.isolaatti.auth.domain.AuthRepository
|
||||
import com.isolaatti.settings.domain.UserIdSetting
|
||||
import com.isolaatti.utils.Resource
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.last
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.awaitResponse
|
||||
import javax.inject.Inject
|
||||
|
||||
@ -51,8 +53,15 @@ class AuthRepositoryImpl @Inject constructor(
|
||||
return tokenStorage.token
|
||||
}
|
||||
|
||||
override fun getUserId(): Flow<Int?> = flow {
|
||||
emit(userIdSetting.getUserId())
|
||||
override fun getUserId(): Flow<Int?> {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val currentUserId = userIdSetting.getUserIdAsync()
|
||||
if(currentUserId == null) {
|
||||
validateSession()
|
||||
}
|
||||
}
|
||||
|
||||
return userIdSetting.getUserId()
|
||||
}
|
||||
|
||||
override suspend fun setToken(sessionDto: AuthTokenDto) {
|
||||
@ -60,4 +69,17 @@ class AuthRepositoryImpl @Inject constructor(
|
||||
userIdSetting.setUserId(sessionDto.userId)
|
||||
}
|
||||
|
||||
private suspend fun validateSession() {
|
||||
val token = tokenStorage.token?.token
|
||||
|
||||
if(token != null) {
|
||||
try {
|
||||
val response = authApi.validateTokenUrl(token).awaitResponse()
|
||||
if(response.isSuccessful) {
|
||||
response.body()?.userId?.let { userIdSetting.setUserId(it) }
|
||||
}
|
||||
} catch(_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
package com.isolaatti.auth.data
|
||||
|
||||
import com.isolaatti.auth.data.local.UserInfoDao
|
||||
import com.isolaatti.auth.domain.UserInfo
|
||||
import com.isolaatti.auth.domain.UserInfoRepository
|
||||
import com.isolaatti.settings.data.KeyValueDao
|
||||
import com.isolaatti.settings.domain.UserIdSetting
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.last
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
|
||||
class UserInfoRepositoryImpl @Inject constructor(private val userIdSetting: UserIdSetting, private val userInfoDao: UserInfoDao) : UserInfoRepository {
|
||||
override fun getCurrentUserInfo(): Flow<UserInfo> = flow {
|
||||
val currentUserId = userIdSetting.getUserIdAsync()
|
||||
if (currentUserId != null) {
|
||||
emitAll(userInfoDao.getUserInfo(currentUserId).map { UserInfo.fromEntity(it) })
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
package com.isolaatti.auth.data.local
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface UserInfoDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun setUserInfo(userInfoEntity: UserInfoEntity)
|
||||
|
||||
@Query("SELECT * FROM user_info WHERE id = :id LIMIT 1")
|
||||
fun getUserInfo(id: Int): Flow<UserInfoEntity>
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
package com.isolaatti.auth.data.local
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "user_info")
|
||||
data class UserInfoEntity(
|
||||
@PrimaryKey val id: Int,
|
||||
val username: String,
|
||||
val displayName: String,
|
||||
)
|
||||
@ -1,6 +1,7 @@
|
||||
package com.isolaatti.auth.domain
|
||||
|
||||
import com.isolaatti.auth.data.remote.AuthTokenDto
|
||||
import com.isolaatti.auth.data.remote.AuthTokenVerificationDto
|
||||
import com.isolaatti.utils.Resource
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
package com.isolaatti.auth.domain
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.isolaatti.MainActivity
|
||||
import com.isolaatti.auth.data.local.TokenStorage
|
||||
import com.isolaatti.settings.domain.AccountSettingsRepository
|
||||
import dagger.hilt.android.qualifiers.ActivityContext
|
||||
@ -8,10 +10,14 @@ import javax.inject.Inject
|
||||
|
||||
class SignOutUC @Inject constructor(
|
||||
@ActivityContext private val context: Context,
|
||||
private val tokenStorage: TokenStorage,
|
||||
private val accountSettingsRepository: AccountSettingsRepository
|
||||
private val tokenStorage: TokenStorage
|
||||
) {
|
||||
operator fun invoke() {
|
||||
tokenStorage.removeToken()
|
||||
val loginIntent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
}
|
||||
context.startActivity(loginIntent)
|
||||
|
||||
}
|
||||
}
|
||||
15
app/src/main/java/com/isolaatti/auth/domain/UserInfo.kt
Normal file
15
app/src/main/java/com/isolaatti/auth/domain/UserInfo.kt
Normal file
@ -0,0 +1,15 @@
|
||||
package com.isolaatti.auth.domain
|
||||
|
||||
import com.isolaatti.auth.data.local.UserInfoEntity
|
||||
import com.isolaatti.utils.UrlGen
|
||||
|
||||
data class UserInfo(val id: Int, val username: String, val displayName: String) {
|
||||
|
||||
val imageUrl get() = UrlGen.userProfileImage(id, false)
|
||||
|
||||
companion object {
|
||||
fun fromEntity(userInfoEntity: UserInfoEntity): UserInfo {
|
||||
return UserInfo(userInfoEntity.id, userInfoEntity.username, userInfoEntity.displayName)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
package com.isolaatti.auth.domain
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface UserInfoRepository {
|
||||
fun getCurrentUserInfo(): Flow<UserInfo>
|
||||
}
|
||||
@ -19,4 +19,17 @@ object CoilImageLoader {
|
||||
add(SvgDecoder.Factory())
|
||||
}.build()
|
||||
}
|
||||
|
||||
fun getImageLoader(context: Context): ImageLoader {
|
||||
return ImageLoader
|
||||
.Builder(context)
|
||||
.memoryCache {
|
||||
MemoryCache.Builder(context)
|
||||
.maxSizePercent(0.25)
|
||||
.build()
|
||||
}
|
||||
.components {
|
||||
add(SvgDecoder.Factory())
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
@ -6,7 +6,6 @@ import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
@ -14,9 +13,7 @@ import androidx.lifecycle.Observer
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.isolaatti.R
|
||||
import com.isolaatti.connectivity.ConnectivityCallbackImpl
|
||||
import com.isolaatti.connectivity.NetworkStatus
|
||||
import com.isolaatti.home.HomeActivity
|
||||
import com.isolaatti.login.LogInActivity
|
||||
import com.isolaatti.utils.Resource
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
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
|
||||
|
||||
import com.isolaatti.audio.common.domain.Audio
|
||||
|
||||
interface OnUserInteractedWithPostCallback : OnUserInteractedCallback {
|
||||
fun onLiked(postId: Long)
|
||||
fun onUnLiked(postId: Long)
|
||||
fun onComment(postId: Long)
|
||||
fun onOpenPost(postId: Long)
|
||||
fun onPlay(audio: Audio)
|
||||
fun onMoreInfo(postId: Long)
|
||||
fun onShare(postId: Long)
|
||||
fun hashtagClicked(hashtag: String)
|
||||
}
|
||||
4
app/src/main/java/com/isolaatti/common/ResultDto.kt
Normal file
4
app/src/main/java/com/isolaatti/common/ResultDto.kt
Normal file
@ -0,0 +1,4 @@
|
||||
package com.isolaatti.common
|
||||
|
||||
@JvmInline
|
||||
value class ResultDto<T>(val result: T)
|
||||
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_CHANGE_PHOTO = 7
|
||||
const val OPTION_PROFILE_PHOTO_REMOVE_PHOTO = 8
|
||||
const val OPTION_PROFILE_DESCRIPTION_SET_AUDIO = 9
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,6 +31,7 @@ data class Options(
|
||||
const val POST_OPTIONS = 1
|
||||
const val COMMENT_OPTIONS = 2
|
||||
const val PROFILE_PHOTO_OPTIONS = 3
|
||||
const val PROFILE_DESCRIPTION_OPTIONS = 4
|
||||
|
||||
val noOptions = Options(0, 0, listOf())
|
||||
|
||||
@ -93,5 +95,10 @@ data class Options(
|
||||
|
||||
return Options(R.string.profile_photo,PROFILE_PHOTO_OPTIONS, list)
|
||||
}
|
||||
|
||||
fun getProfileDescriptionOptions(): Options {
|
||||
val list = listOf(Option(R.string.set_audio_description, R.drawable.baseline_audio_file_24, Option.OPTION_PROFILE_DESCRIPTION_SET_AUDIO))
|
||||
return Options(R.string.description, PROFILE_DESCRIPTION_OPTIONS, list)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -9,11 +9,16 @@ import com.isolaatti.common.options_bottom_sheet.domain.OptionClicked
|
||||
import com.isolaatti.common.options_bottom_sheet.domain.Options
|
||||
import com.isolaatti.common.options_bottom_sheet.domain.Options.Companion.COMMENT_OPTIONS
|
||||
import com.isolaatti.common.options_bottom_sheet.domain.Options.Companion.POST_OPTIONS
|
||||
import com.isolaatti.common.options_bottom_sheet.domain.Options.Companion.PROFILE_DESCRIPTION_OPTIONS
|
||||
import com.isolaatti.common.options_bottom_sheet.domain.Options.Companion.PROFILE_PHOTO_OPTIONS
|
||||
import com.isolaatti.settings.domain.UserIdSetting
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.last
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@ -36,37 +41,36 @@ class BottomSheetPostOptionsViewModel @Inject constructor(private val userIdSett
|
||||
fun setOptions(options: Int, callerId: Int, payload: Ownable? = null) {
|
||||
viewModelScope.launch {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
_payload = payload
|
||||
_callerId = callerId
|
||||
when(options) {
|
||||
POST_OPTIONS -> {
|
||||
userIdSetting.getUserId()?.let { userId ->
|
||||
userIdSetting.getUserId().onEach { userId ->
|
||||
_options.postValue(
|
||||
Options.getPostsOptions(
|
||||
userOwned = userId == payload?.userId,
|
||||
savable = false,
|
||||
snapshotAble = false)
|
||||
)
|
||||
_callerId = callerId
|
||||
_payload = payload
|
||||
}
|
||||
}.flowOn(Dispatchers.IO).launchIn(this)
|
||||
}
|
||||
COMMENT_OPTIONS -> {
|
||||
userIdSetting.getUserId()?.let { userId ->
|
||||
userIdSetting.getUserId().onEach { userId ->
|
||||
_options.postValue(
|
||||
Options.getCommentOptions(
|
||||
userOwned = userId == payload?.userId,
|
||||
savable = false,
|
||||
snapshotAble = false)
|
||||
)
|
||||
_callerId = callerId
|
||||
_payload = payload
|
||||
}
|
||||
}.flowOn(Dispatchers.IO).launchIn(this)
|
||||
}
|
||||
PROFILE_PHOTO_OPTIONS -> {
|
||||
userIdSetting.getUserId()?.let { userId ->
|
||||
userIdSetting.getUserId().onEach { userId ->
|
||||
_options.postValue(Options.getProfilePhotoOptions(userOwned = userId == payload?.userId,))
|
||||
_callerId = callerId
|
||||
_payload = payload
|
||||
}.flowOn(Dispatchers.IO).launchIn(this)
|
||||
}
|
||||
PROFILE_DESCRIPTION_OPTIONS -> {
|
||||
_options.postValue(Options.getProfileDescriptionOptions())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,6 +26,12 @@ class RetrofitClient @Inject constructor(private val authenticationInterceptor:
|
||||
|
||||
private val okHttpClient get() = OkHttpClient.Builder()
|
||||
.addInterceptor(authenticationInterceptor)
|
||||
.addInterceptor { chain ->
|
||||
chain.proceed(chain.request()
|
||||
.newBuilder()
|
||||
.header("User-Agent", "Isolaatti Android ${BuildConfig.VERSION_NAME}")
|
||||
.build())
|
||||
}
|
||||
.connectTimeout(5, TimeUnit.MINUTES)
|
||||
.writeTimeout(5, TimeUnit.MINUTES)
|
||||
.readTimeout(5, TimeUnit.MINUTES)
|
||||
|
||||
@ -2,11 +2,22 @@ package com.isolaatti.database
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import com.isolaatti.audio.drafts.data.AudioDraftEntity
|
||||
import com.isolaatti.audio.drafts.data.AudiosDraftsDao
|
||||
import com.isolaatti.auth.data.local.UserInfoDao
|
||||
import com.isolaatti.auth.data.local.UserInfoEntity
|
||||
import com.isolaatti.images.common.data.dao.ImagesDraftsDao
|
||||
import com.isolaatti.images.common.data.entity.ImageDraftEntity
|
||||
import com.isolaatti.search.data.SearchDao
|
||||
import com.isolaatti.search.data.SearchHistoryEntity
|
||||
import com.isolaatti.settings.data.KeyValueDao
|
||||
import com.isolaatti.settings.data.KeyValueEntity
|
||||
|
||||
@Database(entities = [KeyValueEntity::class], version = 1)
|
||||
@Database(entities = [KeyValueEntity::class, UserInfoEntity::class, AudioDraftEntity::class, SearchHistoryEntity::class, ImageDraftEntity::class], version = 8)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun keyValueDao(): KeyValueDao
|
||||
|
||||
abstract fun userInfoDao(): UserInfoDao
|
||||
abstract fun audioDrafts(): AudiosDraftsDao
|
||||
abstract fun searchHistoryDao(): SearchDao
|
||||
abstract fun imagesDraftsDao(): ImagesDraftsDao
|
||||
}
|
||||
@ -15,6 +15,8 @@ class Module {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAppDatabase(@ApplicationContext applicationContext: Context): AppDatabase {
|
||||
return Room.databaseBuilder(applicationContext, AppDatabase::class.java, "database.db").build()
|
||||
return Room.databaseBuilder(applicationContext, AppDatabase::class.java, "database.db")
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@ -4,15 +4,16 @@ import androidx.fragment.app.Fragment
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import com.isolaatti.followers.ui.FollowersFragment
|
||||
import com.isolaatti.followers.ui.FollowingFragment
|
||||
import com.isolaatti.profile.profile_listing.ui.ProfileListingFragment
|
||||
|
||||
class FollowersViewPagerAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) {
|
||||
class FollowersViewPagerAdapter(fragment: Fragment, private val userId: Int) : FragmentStateAdapter(fragment) {
|
||||
override fun getItemCount(): Int = 2
|
||||
|
||||
override fun createFragment(position: Int): Fragment {
|
||||
if(position == 0) {
|
||||
return FollowersFragment()
|
||||
return ProfileListingFragment.getInstanceForFollowers(userId)
|
||||
}
|
||||
|
||||
return FollowingFragment()
|
||||
return ProfileListingFragment.getInstanceForFollowing(userId)
|
||||
}
|
||||
}
|
||||
@ -10,13 +10,13 @@ import androidx.fragment.app.viewModels
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.isolaatti.common.UserItemCallback
|
||||
import com.isolaatti.common.UserListRecyclerViewAdapter
|
||||
import com.isolaatti.databinding.FragmentFollowersBinding
|
||||
import com.isolaatti.databinding.FragmentUserListBinding
|
||||
import com.isolaatti.followers.presentation.FollowersViewModel
|
||||
import com.isolaatti.profile.domain.entity.ProfileListItem
|
||||
import com.isolaatti.profile.ui.ProfileActivity
|
||||
|
||||
class FollowersFragment : Fragment(), UserItemCallback {
|
||||
private lateinit var binding: FragmentFollowersBinding
|
||||
private lateinit var binding: FragmentUserListBinding
|
||||
private val viewModel: FollowersViewModel by viewModels({ requireParentFragment() })
|
||||
private lateinit var adapter: UserListRecyclerViewAdapter
|
||||
|
||||
@ -25,7 +25,7 @@ class FollowersFragment : Fragment(), UserItemCallback {
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = FragmentFollowersBinding.inflate(inflater)
|
||||
binding = FragmentUserListBinding.inflate(inflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
|
||||
@ -10,13 +10,13 @@ import androidx.fragment.app.viewModels
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.isolaatti.common.UserItemCallback
|
||||
import com.isolaatti.common.UserListRecyclerViewAdapter
|
||||
import com.isolaatti.databinding.FragmentFollowersBinding
|
||||
import com.isolaatti.databinding.FragmentUserListBinding
|
||||
import com.isolaatti.followers.presentation.FollowersViewModel
|
||||
import com.isolaatti.profile.domain.entity.ProfileListItem
|
||||
import com.isolaatti.profile.ui.ProfileActivity
|
||||
|
||||
class FollowingFragment : Fragment(), UserItemCallback {
|
||||
private lateinit var binding: FragmentFollowersBinding
|
||||
private lateinit var binding: FragmentUserListBinding
|
||||
private val viewModel: FollowersViewModel by viewModels({ requireParentFragment() })
|
||||
private lateinit var adapter: UserListRecyclerViewAdapter
|
||||
|
||||
@ -27,7 +27,7 @@ class FollowingFragment : Fragment(), UserItemCallback {
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = FragmentFollowersBinding.inflate(inflater)
|
||||
binding = FragmentUserListBinding.inflate(inflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
|
||||
@ -19,7 +19,6 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
class MainFollowersFragment : Fragment() {
|
||||
|
||||
private lateinit var binding: FragmentFollowersMainBinding
|
||||
private val viewModel: FollowersViewModel by viewModels()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
@ -33,12 +32,9 @@ class MainFollowersFragment : Fragment() {
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
val userId = arguments?.getInt(ARGUMENT_USER_ID) ?: 0
|
||||
|
||||
arguments?.getInt(ARGUMENT_USER_ID)?.let {
|
||||
viewModel.userId = it
|
||||
}
|
||||
|
||||
binding.viewPagerFollowersMain.adapter = FollowersViewPagerAdapter(this)
|
||||
binding.viewPagerFollowersMain.adapter = FollowersViewPagerAdapter(this, userId)
|
||||
TabLayoutMediator(binding.tabLayoutFollowers, binding.viewPagerFollowersMain) { tab, position ->
|
||||
when(position) {
|
||||
0 -> tab.text = getText(R.string.followers)
|
||||
|
||||
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.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.isolaatti.auth.domain.AuthRepository
|
||||
import com.isolaatti.posting.posts.domain.PostsRepository
|
||||
import com.isolaatti.posting.posts.presentation.PostListingViewModelBase
|
||||
import com.isolaatti.posting.posts.presentation.UpdateEvent
|
||||
import com.isolaatti.profile.data.remote.UserProfileDto
|
||||
import com.isolaatti.profile.domain.entity.UserProfile
|
||||
import com.isolaatti.profile.domain.use_case.GetProfile
|
||||
import com.isolaatti.utils.Resource
|
||||
@ -22,9 +22,8 @@ import javax.inject.Inject
|
||||
@HiltViewModel
|
||||
class FeedViewModel @Inject constructor(
|
||||
private val getProfileUseCase: GetProfile,
|
||||
private val authRepository: AuthRepository,
|
||||
private val postsRepository: PostsRepository
|
||||
) : PostListingViewModelBase() {
|
||||
private val authRepository: AuthRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val toRetry: MutableList<Runnable> = mutableListOf()
|
||||
|
||||
@ -39,41 +38,7 @@ class FeedViewModel @Inject constructor(
|
||||
toRetry.clear()
|
||||
}
|
||||
|
||||
override fun getFeed(refresh: Boolean) {
|
||||
viewModelScope.launch {
|
||||
if (refresh) {
|
||||
posts.value = null
|
||||
}
|
||||
postsRepository.getFeed(getLastId()).onEach { listResource ->
|
||||
when (listResource) {
|
||||
is Resource.Success -> {
|
||||
val eventType = if((postsList?.size ?: 0) > 0) UpdateEvent.UpdateType.PAGE_ADDED else UpdateEvent.UpdateType.REFRESH
|
||||
loadingPosts.postValue(false)
|
||||
posts.postValue(Pair(postsList?.apply {
|
||||
addAll(listResource.data ?: listOf())
|
||||
} ?: listResource.data,
|
||||
UpdateEvent(eventType, null)))
|
||||
|
||||
noMoreContent.postValue(listResource.data?.size == 0)
|
||||
}
|
||||
|
||||
is Resource.Loading -> {
|
||||
if(!refresh)
|
||||
loadingPosts.postValue(true)
|
||||
}
|
||||
|
||||
is Resource.Error -> {
|
||||
errorLoading.postValue(listResource.errorType)
|
||||
toRetry.add {
|
||||
getFeed(refresh)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
isLoadingFromScrolling = false
|
||||
}.flowOn(Dispatchers.IO).launchIn(this)
|
||||
}
|
||||
}
|
||||
|
||||
// User profile
|
||||
private val _userProfile: MutableLiveData<UserProfile> = MutableLiveData()
|
||||
@ -88,7 +53,6 @@ class FeedViewModel @Inject constructor(
|
||||
|
||||
when(profile) {
|
||||
is Resource.Error -> {
|
||||
errorLoading.postValue(profile.errorType)
|
||||
toRetry.add {
|
||||
getProfile()
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
import android.app.Application
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import com.isolaatti.MyApplication
|
||||
import com.isolaatti.connectivity.RetrofitClient
|
||||
import com.isolaatti.database.AppDatabase
|
||||
import com.isolaatti.images.common.data.dao.ImagesDraftsDao
|
||||
import com.isolaatti.images.common.data.remote.ImagesApi
|
||||
import com.isolaatti.images.common.data.repository.ImagesRepositoryImpl
|
||||
import com.isolaatti.images.common.domain.repository.ImagesRepository
|
||||
@ -28,7 +28,12 @@ class Module {
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun provideImagesRepository(imagesApi: ImagesApi, contentResolver: ContentResolver): ImagesRepository {
|
||||
return ImagesRepositoryImpl(imagesApi, contentResolver)
|
||||
fun provideImagesDraftDao(database: AppDatabase): ImagesDraftsDao {
|
||||
return database.imagesDraftsDao()
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun provideImagesRepository(imagesApi: ImagesApi, imagesDraftsDao: ImagesDraftsDao, contentResolver: ContentResolver): ImagesRepository {
|
||||
return ImagesRepositoryImpl(imagesApi, imagesDraftsDao, contentResolver)
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
val id: String,
|
||||
val userId: Int,
|
||||
val name: String,
|
||||
val squadId: String?,
|
||||
val username: String,
|
||||
val idOnFirebase: String
|
||||
|
||||
@ -6,6 +6,8 @@ import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.isolaatti.images.common.data.dao.ImagesDraftsDao
|
||||
import com.isolaatti.images.common.data.entity.ImageDraftEntity
|
||||
import com.isolaatti.images.common.data.remote.DeleteImagesDto
|
||||
import com.isolaatti.images.common.data.remote.ImagesApi
|
||||
import com.isolaatti.images.common.domain.entity.Image
|
||||
@ -20,7 +22,7 @@ import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
import javax.inject.Inject
|
||||
|
||||
class ImagesRepositoryImpl @Inject constructor(private val imagesApi: ImagesApi, private val contentResolver: ContentResolver) :
|
||||
class ImagesRepositoryImpl @Inject constructor(private val imagesApi: ImagesApi, private val imagesDraftsDao: ImagesDraftsDao, private val contentResolver: ContentResolver) :
|
||||
ImagesRepository {
|
||||
|
||||
companion object {
|
||||
@ -104,4 +106,12 @@ class ImagesRepositoryImpl @Inject constructor(private val imagesApi: ImagesApi,
|
||||
imageInputStream?.close()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getDetachedDraftImages(): Flow<Resource<List<ImageDraftEntity>>> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun getDraftImagesOfPost(postId: Long): Flow<Resource<List<ImageDraftEntity>>> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
}
|
||||
@ -9,7 +9,6 @@ import java.io.Serializable
|
||||
data class Image(
|
||||
val id: String,
|
||||
val userId: Int,
|
||||
val name: String,
|
||||
val username: String
|
||||
): Deletable(), Serializable {
|
||||
val imageUrl: String get() = UrlGen.imageUrl(id)
|
||||
@ -19,7 +18,7 @@ data class Image(
|
||||
val markdown: String get() = Generators.generateImage(imageUrl)
|
||||
|
||||
companion object {
|
||||
fun fromDto(imageDto: ImageDto) = Image(imageDto.id, imageDto.userId, imageDto.name, imageDto.username)
|
||||
fun fromDto(imageDto: ImageDto) = Image(imageDto.id, imageDto.userId, imageDto.username)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
@ -30,7 +29,6 @@ data class Image(
|
||||
|
||||
if (id != other.id) return false
|
||||
if (userId != other.userId) return false
|
||||
if (name != other.name) return false
|
||||
if (username != other.username) return false
|
||||
|
||||
return true
|
||||
@ -39,7 +37,6 @@ data class Image(
|
||||
override fun hashCode(): Int {
|
||||
var result = id.hashCode()
|
||||
result = 31 * result + userId
|
||||
result = 31 * result + name.hashCode()
|
||||
result = 31 * result + username.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package com.isolaatti.images.common.domain.repository
|
||||
|
||||
import android.net.Uri
|
||||
import com.isolaatti.images.common.data.entity.ImageDraftEntity
|
||||
import com.isolaatti.images.common.domain.entity.Image
|
||||
import com.isolaatti.utils.Resource
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@ -9,4 +10,6 @@ interface ImagesRepository {
|
||||
fun getImagesOfUser(userId: Int, lastId: String? = null): Flow<Resource<List<Image>>>
|
||||
fun deleteImages(images: List<Image>): Flow<Resource<Boolean>>
|
||||
fun uploadImage(name: String, imageUri: Uri, squadId: String?): Flow<Resource<Image>>
|
||||
fun getDetachedDraftImages(): Flow<Resource<List<ImageDraftEntity>>>
|
||||
fun getDraftImagesOfPost(postId: Long): Flow<Resource<List<ImageDraftEntity>>>
|
||||
}
|
||||
@ -5,9 +5,11 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.core.content.IntentCompat
|
||||
import com.isolaatti.images.common.data.entity.ImageDraftEntity
|
||||
import com.isolaatti.images.common.domain.entity.Image
|
||||
|
||||
class ImageChooserContract : ActivityResultContract<ImageChooserContract.Requester, Image?>() {
|
||||
class ImageChooserContract : ActivityResultContract<ImageChooserContract.Requester, ImageDraftEntity?>() {
|
||||
|
||||
enum class Requester {
|
||||
UserPost, SquadPost
|
||||
@ -18,13 +20,13 @@ class ImageChooserContract : ActivityResultContract<ImageChooserContract.Request
|
||||
}
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): Image? {
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): ImageDraftEntity? {
|
||||
if(resultCode != Activity.RESULT_OK) { return null }
|
||||
|
||||
return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
intent?.getSerializableExtra(ImageChooserActivity.OUTPUT_EXTRA_IMAGE) as Image?
|
||||
} else {
|
||||
intent?.getSerializableExtra(ImageChooserActivity.OUTPUT_EXTRA_IMAGE, Image::class.java)
|
||||
}
|
||||
if(intent == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return IntentCompat.getSerializableExtra(intent, ImageChooserActivity.OUTPUT_EXTRA_IMAGE, ImageDraftEntity::class.java)
|
||||
}
|
||||
}
|
||||
@ -38,7 +38,6 @@ class ImageChooserPreview : Fragment() {
|
||||
}
|
||||
|
||||
binding.image.load(viewModel.selectedImage?.imageUrl)
|
||||
binding.imageDescription.text = viewModel.selectedImage?.name
|
||||
|
||||
binding.chooseImageButton.setOnClickListener {
|
||||
showLoading(true)
|
||||
|
||||
@ -86,12 +86,12 @@ class ImagesAdapter(
|
||||
holder.imageItemBinding.root.setOnClickListener {
|
||||
holder.imageItemBinding.imageCheckbox.isChecked = !holder.imageItemBinding.imageCheckbox.isChecked
|
||||
}
|
||||
holder.imageItemBinding.imageCheckbox.isChecked = image.delete
|
||||
holder.imageItemBinding.imageCheckbox.setOnCheckedChangeListener { buttonView, isChecked ->
|
||||
image.delete = isChecked
|
||||
|
||||
onImageSelectedCountUpdate?.invoke(currentList.count { it.delete })
|
||||
}
|
||||
holder.imageItemBinding.imageCheckbox.isChecked = image.delete
|
||||
holder.imageItemBinding.root.setOnLongClickListener(null)
|
||||
} else {
|
||||
holder.imageItemBinding.imageCheckbox.visibility = View.GONE
|
||||
|
||||
@ -51,9 +51,6 @@ class ImageMakerActivity : IsolaattiBaseActivity() {
|
||||
binding.uploadPhotoFab.setOnClickListener {
|
||||
viewModel.uploadPicture()
|
||||
}
|
||||
binding.textImageName.editText?.doOnTextChanged { text, _, _, _ ->
|
||||
viewModel.name = text.toString()
|
||||
}
|
||||
binding.toolbar.setNavigationOnClickListener {
|
||||
showExitConfirmationDialog()
|
||||
}
|
||||
@ -66,12 +63,10 @@ class ImageMakerActivity : IsolaattiBaseActivity() {
|
||||
errorViewModel.error.value = it.errorType
|
||||
binding.progressBarLoading.visibility = View.GONE
|
||||
binding.uploadPhotoFab.visibility = View.VISIBLE
|
||||
binding.textImageName.isEnabled = true
|
||||
}
|
||||
is Resource.Loading -> {
|
||||
binding.progressBarLoading.visibility = View.VISIBLE
|
||||
binding.uploadPhotoFab.visibility = View.INVISIBLE
|
||||
binding.textImageName.isEnabled = false
|
||||
}
|
||||
is Resource.Success -> {
|
||||
binding.progressBarLoading.visibility = View.GONE
|
||||
|
||||
@ -20,7 +20,6 @@ class PictureViewerMainFragment : Fragment() {
|
||||
override fun onPageSelected(position: Int) {
|
||||
super.onPageSelected(position)
|
||||
binding.imageAuthor.text = images[position].username
|
||||
binding.imageDescription.text = images[position].name
|
||||
}
|
||||
}
|
||||
|
||||
@ -43,7 +42,6 @@ class PictureViewerMainFragment : Fragment() {
|
||||
binding.viewpager.adapter = adapter
|
||||
binding.viewpager.setCurrentItem(position, false)
|
||||
binding.viewpager.registerOnPageChangeCallback(onPageChangeCallback)
|
||||
binding.imageDescription.text = images[position].name
|
||||
binding.imageAuthor.text = images[position].username
|
||||
}
|
||||
|
||||
|
||||
@ -1,18 +1,16 @@
|
||||
package com.isolaatti.login
|
||||
|
||||
import android.app.Activity
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.core.widget.doOnTextChanged
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.isolaatti.BuildConfig
|
||||
import com.isolaatti.R
|
||||
import com.isolaatti.auth.openForgotPassword
|
||||
import com.isolaatti.databinding.ActivityLoginBinding
|
||||
import com.isolaatti.sign_up.ui.SignUpActivity
|
||||
import com.isolaatti.utils.Resource
|
||||
@ -92,7 +90,7 @@ class LogInActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
viewBinding.forgotPasswordBtn.setOnClickListener {
|
||||
openForgotPassword()
|
||||
openForgotPassword(this)
|
||||
}
|
||||
|
||||
viewBinding.signUpBtn.setOnClickListener {
|
||||
@ -101,17 +99,12 @@ class LogInActivity : AppCompatActivity() {
|
||||
|
||||
}
|
||||
|
||||
private fun openForgotPassword() {
|
||||
CustomTabsIntent.Builder()
|
||||
.setShowTitle(true)
|
||||
.build()
|
||||
.launchUrl(this, Uri.parse("${BuildConfig.backend}/recuperacion_cuenta"))
|
||||
}
|
||||
|
||||
|
||||
private fun showWrongPasswordErrorMessage() {
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setMessage(R.string.wrong_password)
|
||||
.setNeutralButton(R.string.forgot_password) {_,_ -> openForgotPassword()}
|
||||
.setNeutralButton(R.string.forgot_password) {_,_ -> openForgotPassword(this)}
|
||||
.setPositiveButton(R.string.dismiss, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
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