├── .gitignore ├── .idea ├── .gitignore ├── compiler.xml ├── deploymentTargetDropDown.xml ├── gradle.xml ├── inspectionProfiles │ └── Project_Default.xml ├── kotlinc.xml ├── migrations.xml ├── misc.xml └── vcs.xml ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ ├── main │ ├── AndroidManifest.xml │ ├── ic_launcher-playstore.png │ ├── ic_launcher_purple-playstore.png │ ├── ic_launcher_purpleeeeeee-playstore.png │ ├── ic_launcher_red-playstore.png │ ├── ic_launcher_white-playstore.png │ ├── java │ │ └── com │ │ │ └── ifeanyi │ │ │ └── read │ │ │ ├── ReadActivity.kt │ │ │ ├── app │ │ │ ├── MainActivity.kt │ │ │ ├── data │ │ │ │ ├── LibraryRepository.kt │ │ │ │ ├── SettingsRepository.kt │ │ │ │ ├── models │ │ │ │ │ ├── FileModel.kt │ │ │ │ │ ├── FolderModel.kt │ │ │ │ │ └── WhatsNewModel.kt │ │ │ │ └── source │ │ │ │ │ ├── FileDao.kt │ │ │ │ │ ├── FolderDao.kt │ │ │ │ │ └── WhatsNewDao.kt │ │ │ └── presentation │ │ │ │ ├── components │ │ │ │ ├── AppButton.kt │ │ │ │ ├── BottomNavigationBarComponent.kt │ │ │ │ ├── CustomSliderSheet.kt │ │ │ │ ├── GridTileComponent.kt │ │ │ │ ├── ListTileComponent.kt │ │ │ │ ├── LoaderComponent.kt │ │ │ │ ├── PlayerComponent.kt │ │ │ │ ├── SettingsItem.kt │ │ │ │ ├── TextFieldComponent.kt │ │ │ │ ├── VoiceSelectorSheet.kt │ │ │ │ └── WaveForm.kt │ │ │ │ ├── viewmodel │ │ │ │ ├── LibraryViewModel.kt │ │ │ │ └── SettingsViewModel.kt │ │ │ │ └── views │ │ │ │ ├── home │ │ │ │ ├── EnterTextScreen.kt │ │ │ │ ├── EnterUrlSheet.kt │ │ │ │ └── HomeScreen.kt │ │ │ │ ├── library │ │ │ │ ├── CreateFolderSheet.kt │ │ │ │ ├── DefaultDialog.kt │ │ │ │ ├── FolderScreen.kt │ │ │ │ ├── LibraryScreen.kt │ │ │ │ ├── MoveFilesSheet.kt │ │ │ │ ├── RenameSheet.kt │ │ │ │ ├── SelectingDialog.kt │ │ │ │ ├── SelectingTopBar.kt │ │ │ │ └── SortDialog.kt │ │ │ │ ├── setting │ │ │ │ ├── AboutAppScreen.kt │ │ │ │ ├── AppearanceScreen.kt │ │ │ │ ├── DisplayDialog.kt │ │ │ │ ├── SettingsScreen.kt │ │ │ │ ├── TextToSpeechScreen.kt │ │ │ │ ├── ThemeDialog.kt │ │ │ │ └── WhatsNewSheet.kt │ │ │ │ └── speech │ │ │ │ ├── GoToPageSheet.kt │ │ │ │ ├── HighlightedText.kt │ │ │ │ └── SpeechScreen.kt │ │ │ └── core │ │ │ ├── di │ │ │ └── AppModule.kt │ │ │ ├── enums │ │ │ ├── ActivityType.kt │ │ │ ├── AppTheme.kt │ │ │ └── DisplayStyle.kt │ │ │ ├── route │ │ │ ├── BottomRouter.kt │ │ │ ├── Router.kt │ │ │ └── Routes.kt │ │ │ ├── services │ │ │ ├── AnalyticService.kt │ │ │ ├── DatabaseService.kt │ │ │ ├── NotificationService.kt │ │ │ ├── PreferenceService.kt │ │ │ ├── SnackbarService.kt │ │ │ └── SpeechService.kt │ │ │ ├── theme │ │ │ ├── AppIcons.kt │ │ │ ├── Theme.kt │ │ │ └── Type.kt │ │ │ └── util │ │ │ ├── Constants.kt │ │ │ ├── Extentions.kt │ │ │ ├── RoomConverters.kt │ │ │ └── TextParser.kt │ └── res │ │ ├── drawable │ │ ├── ic_launcher_background.xml │ │ ├── ic_launcher_foreground.xml │ │ ├── ic_launcher_purple_background.xml │ │ ├── ic_launcher_red_background.xml │ │ ├── ic_launcher_white_background.xml │ │ ├── purple_logo.png │ │ ├── red_logo.png │ │ ├── round_forward_10_24.xml │ │ ├── round_pause_24.xml │ │ ├── round_play_arrow_24.xml │ │ ├── round_record_voice_over_24.xml │ │ ├── round_replay_10_24.xml │ │ └── white_logo.png │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher_purple.xml │ │ ├── ic_launcher_purple_round.xml │ │ ├── ic_launcher_red.xml │ │ ├── ic_launcher_red_round.xml │ │ ├── ic_launcher_white.xml │ │ └── ic_launcher_white_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher_purple.webp │ │ ├── ic_launcher_purple_foreground.webp │ │ ├── ic_launcher_purple_round.webp │ │ ├── ic_launcher_red.webp │ │ ├── ic_launcher_red_foreground.webp │ │ ├── ic_launcher_red_round.webp │ │ ├── ic_launcher_white.webp │ │ ├── ic_launcher_white_foreground.webp │ │ └── ic_launcher_white_round.webp │ │ ├── mipmap-mdpi │ │ ├── ic_launcher_purple.webp │ │ ├── ic_launcher_purple_foreground.webp │ │ ├── ic_launcher_purple_round.webp │ │ ├── ic_launcher_red.webp │ │ ├── ic_launcher_red_foreground.webp │ │ ├── ic_launcher_red_round.webp │ │ ├── ic_launcher_white.webp │ │ ├── ic_launcher_white_foreground.webp │ │ └── ic_launcher_white_round.webp │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher_purple.webp │ │ ├── ic_launcher_purple_foreground.webp │ │ ├── ic_launcher_purple_round.webp │ │ ├── ic_launcher_red.webp │ │ ├── ic_launcher_red_foreground.webp │ │ ├── ic_launcher_red_round.webp │ │ ├── ic_launcher_white.webp │ │ ├── ic_launcher_white_foreground.webp │ │ └── ic_launcher_white_round.webp │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher_purple.webp │ │ ├── ic_launcher_purple_foreground.webp │ │ ├── ic_launcher_purple_round.webp │ │ ├── ic_launcher_red.webp │ │ ├── ic_launcher_red_foreground.webp │ │ ├── ic_launcher_red_round.webp │ │ ├── ic_launcher_white.webp │ │ ├── ic_launcher_white_foreground.webp │ │ └── ic_launcher_white_round.webp │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher_purple.webp │ │ ├── ic_launcher_purple_foreground.webp │ │ ├── ic_launcher_purple_round.webp │ │ ├── ic_launcher_red.webp │ │ ├── ic_launcher_red_foreground.webp │ │ ├── ic_launcher_red_round.webp │ │ ├── ic_launcher_white.webp │ │ ├── ic_launcher_white_foreground.webp │ │ └── ic_launcher_white_round.webp │ │ ├── values │ │ ├── colors.xml │ │ ├── font_certs.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ └── xml │ │ ├── backup_rules.xml │ │ └── data_extraction_rules.xml │ └── test │ └── java │ └── android │ └── ifeanyi │ └── read │ └── ExampleUnitTest.kt ├── build.gradle.kts ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle.kts /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | app/src/main/java/com/ifeanyi/read/core/util/Secrets.kt 17 | /app/release 18 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/deploymentTargetDropDown.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 18 | 19 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41 | -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/migrations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.application") 3 | id("org.jetbrains.kotlin.android") 4 | id("kotlin-kapt") 5 | id("com.google.dagger.hilt.android") 6 | id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") 7 | } 8 | 9 | android { 10 | signingConfigs { 11 | create("release") { 12 | storeFile = file("/Users/ifeanyionuoha/read_keystore.jks") 13 | storePassword = "readkeystore" 14 | keyPassword = "readkeystore" 15 | keyAlias = "release" 16 | } 17 | } 18 | namespace = "com.ifeanyi.read" 19 | compileSdk = 34 20 | 21 | defaultConfig { 22 | applicationId = "com.ifeanyi.read" 23 | minSdk = 26 24 | targetSdk = 34 25 | versionCode = 13 26 | versionName = "1.1.3" 27 | 28 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 29 | vectorDrawables { 30 | useSupportLibrary = true 31 | } 32 | } 33 | 34 | buildTypes { 35 | release { 36 | isMinifyEnabled = false 37 | proguardFiles( 38 | getDefaultProguardFile("proguard-android-optimize.txt"), 39 | "proguard-rules.pro" 40 | ) 41 | signingConfig = signingConfigs.getByName("debug") 42 | } 43 | } 44 | compileOptions { 45 | sourceCompatibility = JavaVersion.VERSION_1_8 46 | targetCompatibility = JavaVersion.VERSION_1_8 47 | } 48 | kotlinOptions { 49 | jvmTarget = "1.8" 50 | } 51 | buildFeatures { 52 | compose = true 53 | buildConfig = true 54 | } 55 | composeOptions { 56 | kotlinCompilerExtensionVersion = "1.5.5" 57 | } 58 | packaging { 59 | resources { 60 | excludes.add("/META-INF/{AL2.0,LGPL2.1}") 61 | excludes.add("META-INF/DEPENDENCIES") 62 | excludes.add("META-INF/LICENSE.md") 63 | excludes.add("META-INF/LICENSE-notice.md") 64 | } 65 | } 66 | } 67 | 68 | dependencies { 69 | // Room Database 70 | implementation("androidx.room:room-runtime:2.6.1") 71 | // noinspection KaptUsageInsteadOfKsp 72 | kapt("androidx.room:room-compiler:2.6.1") 73 | implementation("androidx.room:room-ktx:2.6.1") 74 | // PDF to text 75 | implementation("com.tom-roush:pdfbox-android:2.0.27.0") 76 | // Image to text 77 | implementation("com.google.android.gms:play-services-mlkit-text-recognition:19.0.0") 78 | // URL to text 79 | implementation("org.jsoup:jsoup:1.14.3") 80 | // Font 81 | implementation("androidx.compose.ui:ui-text-google-fonts:1.6.8") 82 | // Navigation 83 | implementation("androidx.navigation:navigation-compose:2.7.7") 84 | // Coroutines 85 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0") 86 | // Hilt 87 | implementation("com.google.dagger:hilt-android:2.49") 88 | kapt("com.google.dagger:hilt-android-compiler:2.48") 89 | implementation("androidx.hilt:hilt-navigation-compose:1.2.0") 90 | // Material icons 91 | implementation("androidx.compose.material:material-icons-extended:1.6.8") 92 | // Preferences 93 | implementation("androidx.datastore:datastore-preferences:1.1.1") 94 | // GSON 95 | implementation("com.google.code.gson:gson:2.10.1") 96 | // Media notification 97 | implementation("androidx.media:media:1.7.0") 98 | // Mixpanel 99 | implementation("com.mixpanel.android:mixpanel-android:7.5.0") 100 | // Gemini 101 | implementation("com.google.ai.client.generativeai:generativeai:0.9.0") 102 | // Scanner 103 | implementation("com.google.android.gms:play-services-mlkit-document-scanner:16.0.0-beta1") 104 | 105 | implementation("androidx.core:core-ktx:1.13.1") 106 | implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3") 107 | implementation("androidx.activity:activity-compose:1.9.0") 108 | implementation(platform("androidx.compose:compose-bom:2023.03.00")) 109 | implementation("androidx.compose.ui:ui") 110 | implementation("androidx.compose.ui:ui-graphics") 111 | implementation("androidx.compose.ui:ui-tooling-preview") 112 | implementation("androidx.compose.material3:material3:1.2.1") 113 | testImplementation("junit:junit:4.13.2") 114 | androidTestImplementation("androidx.test.ext:junit:1.2.1") 115 | androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") 116 | androidTestImplementation(platform("androidx.compose:compose-bom:2023.03.00")) 117 | androidTestImplementation("androidx.compose.ui:ui-test-junit4") 118 | debugImplementation("androidx.compose.ui:ui-tooling") 119 | debugImplementation("androidx.compose.ui:ui-test-manifest") 120 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 20 | 21 | 24 | 25 | 26 | 27 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/ic_launcher_purple-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/ic_launcher_purple-playstore.png -------------------------------------------------------------------------------- /app/src/main/ic_launcher_purpleeeeeee-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/ic_launcher_purpleeeeeee-playstore.png -------------------------------------------------------------------------------- /app/src/main/ic_launcher_red-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/ic_launcher_red-playstore.png -------------------------------------------------------------------------------- /app/src/main/ic_launcher_white-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/ic_launcher_white-playstore.png -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/ReadActivity.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read 2 | 3 | import android.app.Application 4 | import dagger.hilt.android.HiltAndroidApp 5 | 6 | @HiltAndroidApp 7 | class ReadActivity: Application() -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/app/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.app 2 | 3 | import com.ifeanyi.read.app.presentation.components.BottomNavigationBarComponent 4 | import com.ifeanyi.read.app.presentation.components.PlayerComponent 5 | import com.ifeanyi.read.app.presentation.views.speech.SpeechScreen 6 | import com.ifeanyi.read.core.route.Router 7 | import com.ifeanyi.read.core.services.AnalyticService 8 | import com.ifeanyi.read.core.services.AppStateService 9 | import android.os.Bundle 10 | import androidx.activity.ComponentActivity 11 | import androidx.activity.compose.setContent 12 | import androidx.compose.foundation.layout.fillMaxSize 13 | import androidx.compose.material3.Text 14 | import androidx.compose.ui.Modifier 15 | import com.ifeanyi.read.core.theme.ReadTheme 16 | import android.os.Build 17 | import androidx.annotation.RequiresApi 18 | import androidx.compose.animation.AnimatedContent 19 | import androidx.compose.animation.AnimatedVisibility 20 | import androidx.compose.animation.core.tween 21 | import androidx.compose.animation.fadeIn 22 | import androidx.compose.animation.fadeOut 23 | import androidx.compose.animation.slideInVertically 24 | import androidx.compose.animation.slideOutVertically 25 | import androidx.compose.animation.togetherWith 26 | import androidx.compose.foundation.layout.Box 27 | import androidx.compose.foundation.layout.Column 28 | import androidx.compose.foundation.layout.padding 29 | import androidx.compose.material3.Snackbar 30 | import androidx.compose.runtime.collectAsState 31 | import androidx.compose.runtime.getValue 32 | import androidx.compose.runtime.mutableStateOf 33 | import androidx.compose.runtime.remember 34 | import androidx.compose.runtime.setValue 35 | import androidx.compose.ui.Alignment 36 | import androidx.compose.ui.unit.dp 37 | import androidx.navigation.compose.currentBackStackEntryAsState 38 | import androidx.navigation.compose.rememberNavController 39 | import com.ifeanyi.read.app.presentation.components.LoaderComponent 40 | import com.ifeanyi.read.core.enums.ActivityType 41 | import com.ifeanyi.read.core.services.notificationService 42 | import com.ifeanyi.read.core.util.changeIcon 43 | import dagger.hilt.android.AndroidEntryPoint 44 | 45 | @AndroidEntryPoint 46 | class MainActivity : ComponentActivity() { 47 | override fun onDestroy() { 48 | notificationService.destroy() 49 | super.onDestroy() 50 | } 51 | 52 | @RequiresApi(Build.VERSION_CODES.TIRAMISU) 53 | override fun onCreate(savedInstanceState: Bundle?) { 54 | super.onCreate(savedInstanceState) 55 | 56 | AnalyticService.init(this) 57 | notificationService.init(this) 58 | 59 | 60 | 61 | setContent { 62 | ReadTheme { 63 | Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { 64 | val controller = rememberNavController() 65 | val navBackStackEntry = controller.currentBackStackEntryAsState() 66 | val currentDestination = navBackStackEntry.value?.destination 67 | 68 | var expanded by remember { mutableStateOf(false) } 69 | 70 | Router( 71 | controller = controller, 72 | onIconChangeRed = { this@MainActivity.changeIcon(ActivityType.MainActivity) }, 73 | onIconChangePurple = { this@MainActivity.changeIcon(ActivityType.MainActivityPurple) }, 74 | onIconChangeWhite = { this@MainActivity.changeIcon(ActivityType.MainActivityWhite) } 75 | ) 76 | 77 | Column(modifier = Modifier.align(Alignment.BottomCenter)) { 78 | AnimatedContent( 79 | targetState = expanded, 80 | label = "Animated Player", 81 | transitionSpec = { 82 | slideInVertically( 83 | animationSpec = tween(300), 84 | initialOffsetY = { 0 }) togetherWith 85 | slideOutVertically(animationSpec = tween(300)) 86 | } 87 | ) { isExpanded -> 88 | if (isExpanded) { 89 | SpeechScreen { expanded = false } 90 | } 91 | } 92 | 93 | PlayerComponent(expanded = expanded) { expanded = true } 94 | 95 | BottomNavigationBarComponent(controller, currentDestination) 96 | } 97 | 98 | val snackBar = AppStateService.snackBar.collectAsState().value 99 | 100 | AnimatedVisibility( 101 | modifier = Modifier.align(Alignment.TopCenter), 102 | visible = snackBar.hasMessage, 103 | enter = fadeIn(), 104 | exit = fadeOut(), 105 | ) { 106 | Snackbar(modifier = Modifier.padding(15.dp)) { 107 | Text(text = snackBar.message) 108 | } 109 | } 110 | 111 | val loader = AppStateService.loader.collectAsState().value 112 | 113 | AnimatedVisibility( 114 | modifier = Modifier.align(Alignment.Center), 115 | visible = loader.isLoading, 116 | enter = fadeIn(), 117 | exit = fadeOut(), 118 | ) { 119 | LoaderComponent(text = loader.message) 120 | } 121 | } 122 | } 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/app/data/LibraryRepository.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.app.data 2 | 3 | import com.ifeanyi.read.app.data.models.FileModel 4 | import com.ifeanyi.read.app.data.models.FolderModel 5 | import com.ifeanyi.read.app.data.source.FileDao 6 | import com.ifeanyi.read.app.data.source.FolderDao 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.flow.conflate 9 | import kotlinx.coroutines.flow.flowOn 10 | import java.util.UUID 11 | import javax.inject.Inject 12 | 13 | class LibraryRepository @Inject constructor(private val fileDao: FileDao, private val folderDao: FolderDao) { 14 | suspend fun insertItem(item: FileModel) = fileDao.insert(item) 15 | suspend fun updateItem(item: FileModel) = fileDao.update(item) 16 | suspend fun deleteItem(item: FileModel) = fileDao.delete(item) 17 | fun getAllFiles() = fileDao.getAllFiles().flowOn(Dispatchers.IO).conflate() 18 | fun getFolderFiles(id: UUID) = fileDao.getFolderFiles(id).flowOn(Dispatchers.IO).conflate() 19 | fun getFolderFilesCount(id: UUID) = fileDao.getFolderFilesCount(id).flowOn(Dispatchers.IO).conflate() 20 | 21 | suspend fun insertItem(item: FolderModel) = folderDao.insert(item) 22 | suspend fun updateItem(item: FolderModel) = folderDao.update(item) 23 | suspend fun deleteItem(item: FolderModel) = folderDao.delete(item) 24 | fun getAllFolders() = folderDao.getAllFolders().flowOn(Dispatchers.IO).conflate() 25 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/app/data/SettingsRepository.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.app.data 2 | 3 | import com.ifeanyi.read.app.data.models.WhatsNewModel 4 | import com.ifeanyi.read.app.data.source.WhatsNewDao 5 | import kotlinx.coroutines.Dispatchers 6 | import kotlinx.coroutines.flow.conflate 7 | import kotlinx.coroutines.flow.flowOn 8 | import javax.inject.Inject 9 | 10 | class SettingsRepository @Inject constructor(private val whatsNewDao: WhatsNewDao) { 11 | suspend fun insertItem(item: WhatsNewModel) = whatsNewDao.insert(item) 12 | suspend fun getUpdate(id: String) = whatsNewDao.getUpdate(id) 13 | fun getAllUpdates() = whatsNewDao.getAllUpdates().flowOn(Dispatchers.IO).conflate() 14 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/app/data/models/FileModel.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.app.data.models 2 | 3 | import android.content.Context 4 | import com.ifeanyi.read.core.theme.AppIcons 5 | import androidx.compose.ui.graphics.vector.ImageVector 6 | import androidx.core.net.toUri 7 | import androidx.room.ColumnInfo 8 | import androidx.room.Entity 9 | import androidx.room.PrimaryKey 10 | import com.ifeanyi.read.core.util.formatted 11 | import java.io.File 12 | import java.time.Instant 13 | import java.util.Date 14 | import java.util.UUID 15 | 16 | enum class LibraryType { Pdf, Img, Scan, Txt, Url } 17 | 18 | @Entity(tableName = "file_table") 19 | data class FileModel( 20 | @PrimaryKey 21 | val id: UUID = UUID.randomUUID(), 22 | @ColumnInfo 23 | val name: String, 24 | @ColumnInfo 25 | val type: LibraryType, 26 | @ColumnInfo 27 | val date: Date = Date.from(Instant.now()), 28 | @ColumnInfo 29 | val path: String, 30 | @ColumnInfo 31 | var cache: String? = null, 32 | @ColumnInfo 33 | val wordRange: IntRange = IntRange(0, 0), 34 | @ColumnInfo 35 | val wordIndex: Int = 0, 36 | @ColumnInfo 37 | val progress: Int = 0, 38 | @ColumnInfo 39 | val currentPage: Int = 1, 40 | @ColumnInfo 41 | val totalPages: Int = 1, 42 | @ColumnInfo 43 | val parent: UUID? = null, 44 | ) { 45 | fun icon(): ImageVector { 46 | return when (type) { 47 | LibraryType.Pdf -> AppIcons.doc 48 | LibraryType.Img -> AppIcons.image 49 | LibraryType.Scan -> AppIcons.scan 50 | LibraryType.Txt -> AppIcons.text 51 | LibraryType.Url -> AppIcons.link 52 | } 53 | } 54 | 55 | val absProgress: Int 56 | get() { 57 | val value = (currentPage.toDouble() / totalPages.toDouble()) * 100 58 | if (totalPages > 1) { 59 | return value.toInt() 60 | } 61 | return progress 62 | } 63 | 64 | fun readCache(): String? { 65 | return try { 66 | if (this.cache == null) return null 67 | val file = File(this.cache!!) 68 | file.readText().formatted 69 | } catch (_: Exception) { 70 | null 71 | } 72 | } 73 | 74 | fun writeCache(context: Context, text: String) { 75 | try { 76 | val path = "${context.filesDir.path}/${Instant.now()}.txt" 77 | val outputFile = File(path) 78 | outputFile.writeText(text, Charsets.UTF_8) 79 | 80 | this.cache = outputFile.toUri().path 81 | } catch (_: Exception) { 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/app/data/models/FolderModel.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.app.data.models 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Entity 5 | import androidx.room.PrimaryKey 6 | import java.time.Instant 7 | import java.util.Date 8 | import java.util.UUID 9 | 10 | @Entity(tableName = "folder_table") 11 | data class FolderModel( 12 | @PrimaryKey 13 | val id: UUID = UUID.randomUUID(), 14 | @ColumnInfo 15 | val name: String, 16 | @ColumnInfo 17 | val date: Date = Date.from(Instant.now()), 18 | @ColumnInfo 19 | val parent: UUID? = null, 20 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/app/data/models/WhatsNewModel.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.app.data.models 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Entity 5 | import androidx.room.PrimaryKey 6 | 7 | @Entity(tableName = "whats_new") 8 | data class WhatsNewModel( 9 | @PrimaryKey 10 | val id: String, 11 | @ColumnInfo 12 | val features: List 13 | ) 14 | 15 | data class NewFeature( 16 | val id: Int, 17 | val title: String, 18 | val body: String, 19 | ) 20 | 21 | val latestUpdate = WhatsNewModel( 22 | id = "1.1.1", 23 | features = listOf( 24 | NewFeature( 25 | id = 0, 26 | title = "Scan page 📖", 27 | body = "Scan the pages of your favourite books with your camera and listen to them" 28 | ), 29 | ) 30 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/app/data/source/FileDao.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.app.data.source 2 | 3 | import com.ifeanyi.read.app.data.models.FileModel 4 | import androidx.room.Dao 5 | import androidx.room.Delete 6 | import androidx.room.Insert 7 | import androidx.room.OnConflictStrategy 8 | import androidx.room.Query 9 | import androidx.room.Update 10 | import kotlinx.coroutines.flow.Flow 11 | import java.util.UUID 12 | 13 | @Dao 14 | interface FileDao { 15 | @Query(value = "SELECT * FROM file_table WHERE parent IS null") 16 | fun getAllFiles() : Flow> 17 | 18 | @Query(value = "SELECT * FROM file_table WHERE parent IS :id") 19 | fun getFolderFiles(id: UUID) : Flow> 20 | 21 | @Query(value = "SELECT COUNT(*) FROM file_table WHERE parent IS :id") 22 | fun getFolderFilesCount(id: UUID) : Flow 23 | 24 | @Insert(entity = FileModel::class, onConflict = OnConflictStrategy.REPLACE) 25 | suspend fun insert(item: FileModel) 26 | 27 | @Update(entity = FileModel::class, onConflict = OnConflictStrategy.REPLACE) 28 | suspend fun update(item: FileModel) 29 | 30 | @Delete(entity = FileModel::class) 31 | suspend fun delete(item: FileModel) 32 | 33 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/app/data/source/FolderDao.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.app.data.source 2 | 3 | import com.ifeanyi.read.app.data.models.FolderModel 4 | import androidx.room.Dao 5 | import androidx.room.Delete 6 | import androidx.room.Insert 7 | import androidx.room.OnConflictStrategy 8 | import androidx.room.Query 9 | import androidx.room.Update 10 | import kotlinx.coroutines.flow.Flow 11 | 12 | @Dao 13 | interface FolderDao { 14 | @Query(value = "SELECT * FROM folder_table") 15 | fun getAllFolders() : Flow> 16 | 17 | @Insert(entity = FolderModel::class, onConflict = OnConflictStrategy.REPLACE) 18 | suspend fun insert(item: FolderModel) 19 | 20 | @Update(entity = FolderModel::class, onConflict = OnConflictStrategy.REPLACE) 21 | suspend fun update(item: FolderModel) 22 | 23 | @Delete(entity = FolderModel::class) 24 | suspend fun delete(item: FolderModel) 25 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/app/data/source/WhatsNewDao.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.app.data.source 2 | 3 | import com.ifeanyi.read.app.data.models.WhatsNewModel 4 | import androidx.room.Dao 5 | import androidx.room.Insert 6 | import androidx.room.OnConflictStrategy 7 | import androidx.room.Query 8 | import kotlinx.coroutines.flow.Flow 9 | 10 | @Dao 11 | interface WhatsNewDao { 12 | @Query(value = "SELECT * FROM whats_new") 13 | fun getAllUpdates() : Flow> 14 | 15 | @Query(value = "SELECT * FROM whats_new WHERE id IS :id") 16 | suspend fun getUpdate(id: String) : WhatsNewModel? 17 | 18 | @Insert(entity = WhatsNewModel::class, onConflict = OnConflictStrategy.REPLACE) 19 | suspend fun insert(item: WhatsNewModel) 20 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/app/presentation/components/AppButton.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.app.presentation.components 2 | 3 | import androidx.compose.foundation.layout.fillMaxWidth 4 | import androidx.compose.foundation.layout.height 5 | import androidx.compose.material3.ElevatedButton 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.text.font.FontWeight 11 | import androidx.compose.ui.unit.Dp 12 | import androidx.compose.ui.unit.dp 13 | 14 | @Composable 15 | fun AppButton( 16 | modifier: Modifier = Modifier, 17 | text: String, 18 | enabled: Boolean = true, 19 | height: Dp = 60.dp, 20 | onClick: () -> Unit 21 | ) { 22 | ElevatedButton( 23 | modifier = modifier 24 | .fillMaxWidth() 25 | .height(height), 26 | shape = MaterialTheme.shapes.small, 27 | onClick = onClick, 28 | enabled = enabled, 29 | ) { 30 | Text(text = text, fontWeight = FontWeight.SemiBold) 31 | } 32 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/app/presentation/components/BottomNavigationBarComponent.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.app.presentation.components 2 | 3 | import com.ifeanyi.read.core.route.bottomNavItems 4 | import com.ifeanyi.read.core.route.parentRoute 5 | import androidx.compose.material3.* 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.runtime.derivedStateOf 8 | import androidx.compose.runtime.remember 9 | import androidx.navigation.NavDestination 10 | import androidx.navigation.NavGraph.Companion.findStartDestination 11 | import androidx.navigation.NavHostController 12 | 13 | @Composable 14 | fun BottomNavigationBarComponent(controller: NavHostController, currentDestination: NavDestination?) { 15 | NavigationBar { 16 | 17 | val parentRoute = remember(controller.currentBackStackEntry) { 18 | derivedStateOf { 19 | controller.parentRoute 20 | } 21 | } 22 | 23 | bottomNavItems.forEach { screen -> 24 | val selected = parentRoute.value.name == screen.route 25 | NavigationBarItem( 26 | selected = selected, 27 | icon = if (selected) screen.icon else screen.inactiveIcon, 28 | label = { Text(text = screen.label) }, 29 | onClick = { 30 | if (selected && currentDestination?.route != parentRoute.value.name) { 31 | controller.popBackStack() 32 | }else { 33 | controller.navigate(screen.route) { 34 | popUpTo(controller.graph.findStartDestination().id) { 35 | saveState = true 36 | } 37 | launchSingleTop = true 38 | restoreState = true 39 | } 40 | } 41 | }, 42 | ) 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/app/presentation/components/CustomSliderSheet.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.app.presentation.components 2 | 3 | import androidx.compose.animation.core.animateFloatAsState 4 | import androidx.compose.animation.core.spring 5 | import androidx.compose.foundation.background 6 | import androidx.compose.foundation.gestures.detectHorizontalDragGestures 7 | import androidx.compose.foundation.layout.Arrangement 8 | import androidx.compose.foundation.layout.Box 9 | import androidx.compose.foundation.layout.Column 10 | import androidx.compose.foundation.layout.Row 11 | import androidx.compose.foundation.layout.fillMaxHeight 12 | import androidx.compose.foundation.layout.fillMaxWidth 13 | import androidx.compose.foundation.layout.height 14 | import androidx.compose.foundation.layout.padding 15 | import androidx.compose.material3.ExperimentalMaterial3Api 16 | import androidx.compose.material3.MaterialTheme 17 | import androidx.compose.material3.ModalBottomSheet 18 | import androidx.compose.material3.SheetState 19 | import androidx.compose.material3.Text 20 | import androidx.compose.runtime.Composable 21 | import androidx.compose.runtime.MutableState 22 | import androidx.compose.runtime.mutableFloatStateOf 23 | import androidx.compose.runtime.mutableStateOf 24 | import androidx.compose.runtime.remember 25 | import androidx.compose.ui.Modifier 26 | import androidx.compose.ui.draw.clip 27 | import androidx.compose.ui.input.pointer.pointerInput 28 | import androidx.compose.ui.layout.onGloballyPositioned 29 | import androidx.compose.ui.unit.IntSize 30 | import androidx.compose.ui.unit.dp 31 | import kotlin.math.max 32 | import kotlin.math.min 33 | 34 | @OptIn(ExperimentalMaterial3Api::class) 35 | @Composable 36 | fun CustomSliderSheet( 37 | showRateSheet: MutableState, 38 | modalSheetState: SheetState, 39 | initialProgress: Float, 40 | onDone: (result: Float) -> Unit 41 | ) { 42 | val progress = remember { mutableFloatStateOf(initialProgress) } 43 | val position = animateFloatAsState( 44 | targetValue = progress.floatValue, 45 | animationSpec = spring(), 46 | label = "" 47 | ) 48 | val size = remember { mutableStateOf(IntSize.Zero) } 49 | 50 | ModalBottomSheet( 51 | onDismissRequest = { showRateSheet.value = false }, 52 | sheetState = modalSheetState 53 | ) { 54 | Column( 55 | modifier = Modifier 56 | .fillMaxWidth() 57 | .fillMaxHeight(0.5f) 58 | .padding(20.dp), verticalArrangement = Arrangement.SpaceBetween 59 | ) { 60 | 61 | Text(text = "Speech Rate", style = MaterialTheme.typography.titleLarge) 62 | 63 | Column(verticalArrangement = Arrangement.spacedBy(15.dp)) { 64 | Row( 65 | modifier = Modifier.fillMaxWidth(), 66 | horizontalArrangement = Arrangement.SpaceBetween 67 | ) { 68 | Text(text = "slow") 69 | Text(text = "normal") 70 | Text(text = "fast") 71 | } 72 | 73 | Box(modifier = Modifier 74 | .fillMaxWidth() 75 | .onGloballyPositioned { 76 | size.value = it.size 77 | } 78 | .clip(shape = MaterialTheme.shapes.small) 79 | .pointerInput(Unit) { 80 | detectHorizontalDragGestures( 81 | onHorizontalDrag = { change, _ -> 82 | val pos = min(change.position.x, size.value.width.toFloat()) / size.value.width 83 | progress.floatValue = max(0.05f, pos) 84 | }, 85 | onDragEnd = { 86 | onDone(progress.floatValue * 2) 87 | } 88 | ) 89 | } 90 | ) { 91 | Box( 92 | modifier = Modifier 93 | .height(50.dp) 94 | .fillMaxWidth() 95 | .background(color = MaterialTheme.colorScheme.inversePrimary) 96 | ) 97 | 98 | Box( 99 | modifier = Modifier 100 | .height(50.dp) 101 | .fillMaxWidth(position.value) 102 | .background(color = MaterialTheme.colorScheme.primary) 103 | ) 104 | } 105 | } 106 | 107 | Box(modifier = Modifier.height(60.dp)) 108 | } 109 | } 110 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/app/presentation/components/GridTileComponent.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.app.presentation.components 2 | 3 | import com.ifeanyi.read.core.theme.AppIcons 4 | import androidx.compose.foundation.ExperimentalFoundationApi 5 | import androidx.compose.foundation.combinedClickable 6 | import androidx.compose.foundation.layout.Arrangement 7 | import androidx.compose.foundation.layout.Column 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.layout.size 11 | import androidx.compose.material3.Icon 12 | import androidx.compose.material3.MaterialTheme 13 | import androidx.compose.material3.Surface 14 | import androidx.compose.material3.Text 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.draw.clip 19 | import androidx.compose.ui.text.font.FontWeight 20 | import androidx.compose.ui.text.style.TextAlign 21 | import androidx.compose.ui.unit.Dp 22 | import androidx.compose.ui.unit.dp 23 | 24 | @OptIn(ExperimentalFoundationApi::class) 25 | @Composable 26 | fun GridTileComponent( 27 | modifier: Modifier = Modifier, 28 | asset: @Composable (() -> Unit?)? = null, 29 | title: String? = null, 30 | subtitle: String, 31 | tonalElevation: Dp = 1.dp, 32 | onClick: () -> Unit, 33 | onLongPress: (() -> Unit)? = null, 34 | ) { 35 | Surface( 36 | modifier = modifier 37 | .clip(shape = MaterialTheme.shapes.small) 38 | .combinedClickable( 39 | onClick = { onClick.invoke() }, 40 | onLongClick = { onLongPress?.invoke() } 41 | ), 42 | shape = MaterialTheme.shapes.small, 43 | tonalElevation = tonalElevation, 44 | ) { 45 | Column( 46 | modifier = Modifier.padding(horizontal = 10.dp, vertical = 15.dp).fillMaxWidth(), 47 | horizontalAlignment = Alignment.CenterHorizontally, 48 | verticalArrangement = Arrangement.spacedBy(5.dp) 49 | ) { 50 | if (asset?.invoke() == null) Icon( 51 | imageVector = AppIcons.flag, 52 | contentDescription = "Flag", 53 | modifier = Modifier.size(30.dp) 54 | ) 55 | if (title != null) Text( 56 | text = title, maxLines = 1, 57 | textAlign = TextAlign.Center, 58 | style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold) 59 | ) 60 | Text( 61 | text = subtitle, 62 | maxLines = 2, 63 | textAlign = TextAlign.Center, 64 | style = MaterialTheme.typography.bodySmall 65 | ) 66 | } 67 | } 68 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/app/presentation/components/ListTileComponent.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.app.presentation.components 2 | 3 | import androidx.compose.foundation.ExperimentalFoundationApi 4 | import androidx.compose.foundation.combinedClickable 5 | import androidx.compose.foundation.layout.Arrangement 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.Row 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.material3.MaterialTheme 11 | import androidx.compose.material3.Surface 12 | import androidx.compose.material3.Text 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.draw.clip 17 | import androidx.compose.ui.text.font.FontWeight 18 | import androidx.compose.ui.unit.dp 19 | 20 | @OptIn(ExperimentalFoundationApi::class) 21 | @Composable 22 | fun ListTileComponent( 23 | modifier: Modifier = Modifier, 24 | asset: @Composable (() -> Unit?)? = null, 25 | title: String, 26 | subtitle: String, 27 | onClick: () -> Unit, 28 | onLongPress: (() -> Unit)? = null, 29 | ) { 30 | Surface( 31 | modifier = modifier 32 | .clip(shape = MaterialTheme.shapes.small) 33 | .combinedClickable( 34 | onClick = { onClick.invoke() }, 35 | onLongClick = { onLongPress?.invoke() } 36 | ), 37 | shape = MaterialTheme.shapes.small, 38 | tonalElevation = 1.dp, 39 | ) { 40 | Row( 41 | modifier = Modifier.padding(horizontal = 10.dp, vertical = 15.dp), 42 | horizontalArrangement = Arrangement.spacedBy(15.dp), 43 | verticalAlignment = Alignment.CenterVertically 44 | ) { 45 | asset?.invoke() 46 | Column( 47 | modifier = Modifier.fillMaxWidth(), 48 | verticalArrangement = Arrangement.spacedBy(5.dp) 49 | ) { 50 | Text(text = title, style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold)) 51 | Text(text = subtitle, style = MaterialTheme.typography.bodySmall) 52 | } 53 | } 54 | } 55 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/app/presentation/components/LoaderComponent.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.app.presentation.components 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.material3.AlertDialog 7 | import androidx.compose.material3.CircularProgressIndicator 8 | import androidx.compose.material3.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Modifier 11 | 12 | @Composable 13 | fun LoaderComponent(text: String) { 14 | AlertDialog(onDismissRequest = {}, confirmButton = {}, title = { 15 | Row( 16 | Modifier.fillMaxWidth(), 17 | horizontalArrangement = Arrangement.Center 18 | ) { 19 | CircularProgressIndicator() 20 | } 21 | }, text = { 22 | Row( 23 | Modifier.fillMaxWidth(), 24 | horizontalArrangement = Arrangement.Center 25 | ) { 26 | Text(text = text) 27 | } 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/app/presentation/components/PlayerComponent.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.app.presentation.components 2 | 3 | import android.annotation.SuppressLint 4 | import com.ifeanyi.read.core.services.SpeechService 5 | import com.ifeanyi.read.core.theme.AppIcons 6 | import androidx.compose.animation.AnimatedVisibility 7 | import androidx.compose.animation.slideInVertically 8 | import androidx.compose.animation.slideOutVertically 9 | import androidx.compose.foundation.layout.Arrangement 10 | import androidx.compose.foundation.layout.Column 11 | import androidx.compose.foundation.layout.PaddingValues 12 | import androidx.compose.foundation.layout.Row 13 | import androidx.compose.foundation.layout.Spacer 14 | import androidx.compose.foundation.layout.fillMaxWidth 15 | import androidx.compose.foundation.layout.padding 16 | import androidx.compose.foundation.layout.size 17 | import androidx.compose.foundation.shape.RoundedCornerShape 18 | import androidx.compose.material3.ElevatedButton 19 | import androidx.compose.material3.Icon 20 | import androidx.compose.material3.IconButton 21 | import androidx.compose.material3.LinearProgressIndicator 22 | import androidx.compose.material3.MaterialTheme 23 | import androidx.compose.material3.Text 24 | import androidx.compose.runtime.Composable 25 | import androidx.compose.runtime.collectAsState 26 | import androidx.compose.ui.Alignment 27 | import androidx.compose.ui.Modifier 28 | import androidx.compose.ui.text.font.FontWeight 29 | import androidx.compose.ui.text.style.TextOverflow 30 | import androidx.compose.ui.unit.dp 31 | 32 | @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") 33 | @Composable 34 | fun PlayerComponent(expanded: Boolean, onClick: () -> Unit) { 35 | val state = SpeechService.state.collectAsState().value 36 | 37 | AnimatedVisibility( 38 | visible = state.canPlay, 39 | enter = slideInVertically { it }, 40 | exit = slideOutVertically { it }, 41 | ) { 42 | ElevatedButton( 43 | modifier = Modifier.padding(8.dp), 44 | shape = RoundedCornerShape(5.dp), 45 | contentPadding = PaddingValues(8.dp), 46 | onClick = { onClick.invoke() } 47 | ) { 48 | Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { 49 | Row( 50 | modifier = Modifier.fillMaxWidth(), 51 | verticalAlignment = Alignment.CenterVertically, 52 | horizontalArrangement = Arrangement.spacedBy(15.dp) 53 | ) { 54 | WaveForm(animating = !expanded && state.isPlaying) 55 | 56 | if (state.model != null) { 57 | Text( 58 | text = state.model.name, 59 | fontWeight = FontWeight.SemiBold, 60 | maxLines = 1, 61 | overflow = TextOverflow.Ellipsis, 62 | modifier = Modifier.weight(1f) 63 | ) 64 | } else Spacer(modifier = Modifier.weight(1f)) 65 | 66 | IconButton( 67 | modifier = Modifier.size(35.dp), 68 | onClick = { SpeechService.stop(true) }, 69 | ) { 70 | Icon( 71 | modifier = Modifier.size(35.dp), 72 | imageVector = AppIcons.stop, 73 | contentDescription = "Stop Button", 74 | tint = MaterialTheme.colorScheme.primary 75 | ) 76 | } 77 | 78 | IconButton( 79 | modifier = Modifier.size(40.dp), 80 | onClick = { 81 | if (state.isPlaying) SpeechService.pause() else SpeechService.play() 82 | }, 83 | ) { 84 | Icon( 85 | modifier = Modifier.size(40.dp), 86 | imageVector = if (state.isPlaying) AppIcons.pause else AppIcons.play, 87 | contentDescription = "Play/Pause Button", 88 | tint = MaterialTheme.colorScheme.primary 89 | ) 90 | } 91 | } 92 | LinearProgressIndicator( 93 | progress = { state.progress }, 94 | modifier = Modifier.fillMaxWidth(), 95 | ) 96 | } 97 | } 98 | } 99 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/app/presentation/components/SettingsItem.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.app.presentation.components 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.PaddingValues 7 | import androidx.compose.foundation.layout.Row 8 | import androidx.compose.foundation.layout.Spacer 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.material3.ButtonDefaults 12 | import androidx.compose.material3.Icon 13 | import androidx.compose.material3.MaterialTheme 14 | import androidx.compose.material3.Text 15 | import androidx.compose.material3.TextButton 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.ui.Alignment 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.graphics.Color 20 | import androidx.compose.ui.graphics.vector.ImageVector 21 | import androidx.compose.ui.unit.dp 22 | 23 | @Composable 24 | fun SettingsItem( 25 | title: String, 26 | icon: ImageVector, 27 | color: Color, 28 | trailing: (@Composable () -> Unit)? = null, 29 | onClick: () -> Unit, 30 | ) { 31 | TextButton( 32 | shape = MaterialTheme.shapes.small, 33 | contentPadding = PaddingValues(0.dp), 34 | colors = ButtonDefaults.textButtonColors( 35 | contentColor = MaterialTheme.colorScheme.onSurface, 36 | ), 37 | onClick = { onClick.invoke() }) { 38 | Row( 39 | modifier = Modifier 40 | .fillMaxWidth() 41 | .padding(vertical = 6.dp), 42 | horizontalArrangement = Arrangement.spacedBy(15.dp), 43 | verticalAlignment = Alignment.CenterVertically 44 | ) { 45 | Box(modifier = Modifier.background(color = color, shape = MaterialTheme.shapes.small)) { 46 | Icon( 47 | imageVector = icon, 48 | contentDescription = "Settings Item - $title", 49 | modifier = Modifier.padding(8.dp), 50 | tint = Color.White 51 | ) 52 | } 53 | Text(text = title, style = MaterialTheme.typography.bodyMedium) 54 | 55 | Spacer(modifier = Modifier.weight(1f)) 56 | 57 | if (trailing?.invoke() == null) Box { 58 | 59 | } 60 | } 61 | } 62 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/app/presentation/components/TextFieldComponent.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.app.presentation.components 2 | 3 | import androidx.compose.foundation.layout.fillMaxWidth 4 | import androidx.compose.foundation.text.KeyboardActions 5 | import androidx.compose.foundation.text.KeyboardOptions 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.TextField 8 | import androidx.compose.material3.TextFieldDefaults 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.MutableState 11 | import androidx.compose.runtime.remember 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.focus.FocusRequester 14 | import androidx.compose.ui.focus.focusRequester 15 | import androidx.compose.ui.graphics.Color 16 | import androidx.compose.ui.platform.LocalSoftwareKeyboardController 17 | import androidx.compose.ui.text.input.ImeAction 18 | 19 | @Composable 20 | fun TextFieldComponent( 21 | value: MutableState, 22 | modifier: Modifier = Modifier, 23 | onValueChange: ((String) -> Unit)? = null, 24 | supportingText: @Composable (() -> Unit)? = null, 25 | onImeAction: (() -> Unit)? = null, 26 | label: @Composable (() -> Unit)? = null, 27 | placeholder: @Composable (() -> Unit)? = null, 28 | leadingIcon: @Composable (() -> Unit)? = null, 29 | trailingIcon: @Composable (() -> Unit)? = null, 30 | maxLines: Int = 1, 31 | textLimit: Int? = null, 32 | keyboardOptions: KeyboardOptions? = null, 33 | keyboardActions: KeyboardActions? = null, 34 | ) { 35 | val keyboardController = LocalSoftwareKeyboardController.current 36 | val focus = remember { FocusRequester() } 37 | 38 | TextField( 39 | modifier = modifier 40 | .fillMaxWidth() 41 | .focusRequester(focus), 42 | value = value.value, 43 | textStyle = MaterialTheme.typography.bodyMedium, 44 | onValueChange = { 45 | value.value = it.take(textLimit ?: it.length) 46 | onValueChange?.invoke(it) 47 | }, 48 | supportingText = supportingText, 49 | keyboardOptions = keyboardOptions ?: KeyboardOptions.Default.copy( 50 | imeAction = ImeAction.Search 51 | ), 52 | keyboardActions = keyboardActions ?: KeyboardActions( 53 | onSearch = { 54 | onImeAction?.invoke() 55 | keyboardController?.hide() 56 | }, 57 | onGo = { 58 | onImeAction?.invoke() 59 | keyboardController?.hide() 60 | }, 61 | onDone = { 62 | focus.freeFocus() 63 | keyboardController?.hide() 64 | } 65 | ), 66 | label = label, 67 | placeholder = placeholder, 68 | leadingIcon = leadingIcon, 69 | trailingIcon = trailingIcon, 70 | maxLines = maxLines, 71 | shape = MaterialTheme.shapes.medium, 72 | colors = TextFieldDefaults.colors( 73 | focusedIndicatorColor = Color.Transparent, 74 | unfocusedIndicatorColor = Color.Transparent, 75 | ) 76 | ) 77 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/app/presentation/components/VoiceSelectorSheet.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.app.presentation.components 2 | 3 | import com.ifeanyi.read.core.services.SpeechService 4 | import com.ifeanyi.read.core.theme.AppIcons 5 | import com.ifeanyi.read.core.util.flagEmoji 6 | import android.speech.tts.Voice 7 | import androidx.compose.foundation.layout.Arrangement 8 | import androidx.compose.foundation.layout.Box 9 | import androidx.compose.foundation.layout.Column 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.lazy.grid.GridCells 12 | import androidx.compose.foundation.lazy.grid.LazyVerticalGrid 13 | import androidx.compose.foundation.lazy.grid.items 14 | import androidx.compose.material3.ExperimentalMaterial3Api 15 | import androidx.compose.material3.Icon 16 | import androidx.compose.material3.MaterialTheme 17 | import androidx.compose.material3.ModalBottomSheet 18 | import androidx.compose.material3.SheetState 19 | import androidx.compose.material3.Text 20 | import androidx.compose.runtime.Composable 21 | import androidx.compose.runtime.MutableState 22 | import androidx.compose.runtime.collectAsState 23 | import androidx.compose.ui.Alignment 24 | import androidx.compose.ui.Modifier 25 | import androidx.compose.ui.unit.dp 26 | 27 | @OptIn(ExperimentalMaterial3Api::class) 28 | @Composable 29 | fun VoiceSelectorSheet( 30 | showVoicesSheet: MutableState, 31 | modalSheetState: SheetState, 32 | initial: Voice?, 33 | onDone: (voice: Voice) -> Unit 34 | ) { 35 | val state = SpeechService.state.collectAsState().value 36 | 37 | ModalBottomSheet( 38 | onDismissRequest = { showVoicesSheet.value = false }, 39 | sheetState = modalSheetState 40 | ) { 41 | Column( 42 | modifier = Modifier.padding(20.dp), 43 | verticalArrangement = Arrangement.spacedBy(15.dp) 44 | ) { 45 | Text(text = "Select Voice", style = MaterialTheme.typography.titleLarge) 46 | 47 | LazyVerticalGrid( 48 | columns = GridCells.Fixed(3), 49 | verticalArrangement = Arrangement.spacedBy(10.dp), 50 | horizontalArrangement = Arrangement.spacedBy(10.dp), 51 | ) { 52 | items(state.voices) { voice -> 53 | Box { 54 | GridTileComponent( 55 | asset = { 56 | if (voice.locale.flagEmoji != null) Text( 57 | text = voice.locale.flagEmoji!!, 58 | style = MaterialTheme.typography.titleMedium 59 | ) else null 60 | }, 61 | tonalElevation = 2.dp, 62 | subtitle = voice.locale.displayName, 63 | onClick = { onDone.invoke(voice) }, 64 | ) 65 | if (voice.name == initial?.name) { 66 | Icon( 67 | imageVector = AppIcons.checkbox, 68 | contentDescription = "", 69 | modifier = Modifier.align( 70 | Alignment.TopEnd 71 | ) 72 | ) 73 | } 74 | } 75 | } 76 | } 77 | } 78 | } 79 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/app/presentation/components/WaveForm.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.app.presentation.components 2 | 3 | import androidx.compose.animation.core.RepeatMode 4 | import androidx.compose.animation.core.animateFloatAsState 5 | import androidx.compose.animation.core.infiniteRepeatable 6 | import androidx.compose.animation.core.tween 7 | import androidx.compose.foundation.background 8 | import androidx.compose.foundation.layout.Box 9 | import androidx.compose.foundation.layout.Row 10 | import androidx.compose.foundation.layout.height 11 | import androidx.compose.foundation.layout.padding 12 | import androidx.compose.foundation.layout.width 13 | import androidx.compose.material3.MaterialTheme 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.ui.Alignment 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.unit.dp 18 | import kotlin.random.Random 19 | 20 | @Composable 21 | fun WaveForm(animating: Boolean) { 22 | val random = Random(1) 23 | 24 | Row(modifier = Modifier.height(50.dp), verticalAlignment = Alignment.CenterVertically) { 25 | for (i in 1.. 6) { 26 | val animation = animateFloatAsState( 27 | targetValue = if (animating) 1.0f else 0.1f, 28 | animationSpec = if (animating) infiniteRepeatable(animation = tween(durationMillis = random.nextInt(400, 1000)), repeatMode = RepeatMode.Reverse) else tween(durationMillis = 300), 29 | label = "Height Animation" 30 | ) 31 | val height = if (i < 3 || i > 4) random.nextInt(10, 25) else random.nextInt(25, 50) 32 | Box( 33 | modifier = Modifier 34 | .padding(horizontal = 2.dp) 35 | .background(color = MaterialTheme.colorScheme.primary, shape = MaterialTheme.shapes.small) 36 | .height((height * animation.value).dp) 37 | .width(4.dp) 38 | 39 | ) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/app/presentation/viewmodel/LibraryViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.app.presentation.viewmodel 2 | 3 | import com.ifeanyi.read.app.data.LibraryRepository 4 | import com.ifeanyi.read.app.data.models.FileModel 5 | import com.ifeanyi.read.app.data.models.FolderModel 6 | import com.ifeanyi.read.app.presentation.views.library.SortType 7 | import android.net.Uri 8 | import androidx.compose.runtime.MutableIntState 9 | import androidx.compose.runtime.mutableIntStateOf 10 | import androidx.lifecycle.ViewModel 11 | import androidx.lifecycle.viewModelScope 12 | import dagger.hilt.android.lifecycle.HiltViewModel 13 | import kotlinx.coroutines.delay 14 | import kotlinx.coroutines.flow.MutableStateFlow 15 | import kotlinx.coroutines.flow.asStateFlow 16 | import kotlinx.coroutines.flow.distinctUntilChanged 17 | import kotlinx.coroutines.flow.update 18 | import kotlinx.coroutines.launch 19 | import java.io.File 20 | import java.util.UUID 21 | import javax.inject.Inject 22 | 23 | data class LibraryState( 24 | val files: List = emptyList(), 25 | val searchedFiles: List = emptyList(), 26 | val folders: List = emptyList(), 27 | val searchedFolders: List = emptyList(), 28 | val folderFiles: List = emptyList(), 29 | ) 30 | 31 | @HiltViewModel 32 | class LibraryViewModel @Inject constructor(private val libraryRepository: LibraryRepository) : 33 | ViewModel() { 34 | private val _state = MutableStateFlow(LibraryState()) 35 | val state = _state.asStateFlow() 36 | 37 | init { 38 | getAllFiles() 39 | getAllFolders() 40 | } 41 | 42 | private fun getAllFiles() = viewModelScope.launch { 43 | libraryRepository.getAllFiles().distinctUntilChanged().collect { items -> 44 | _state.update { it.copy(files = items) } 45 | } 46 | } 47 | 48 | fun insertItem(file: FileModel) = viewModelScope.launch { 49 | libraryRepository.insertItem(file) 50 | } 51 | 52 | fun updateItem(file: FileModel) = viewModelScope.launch { 53 | libraryRepository.updateItem(file) 54 | } 55 | 56 | fun deleteItem(file: FileModel) = viewModelScope.launch { 57 | val fileUri = Uri.parse(file.path) 58 | if (File(fileUri.path ?: "").exists()) { 59 | File(fileUri.path ?: "").delete() 60 | } 61 | 62 | if (file.cache != null) { 63 | File(file.cache ?: "").delete() 64 | } 65 | libraryRepository.deleteItem(file) 66 | } 67 | 68 | private fun getAllFolders() = viewModelScope.launch { 69 | libraryRepository.getAllFolders().distinctUntilChanged().collect { items -> 70 | _state.update { it.copy(folders = items) } 71 | } 72 | } 73 | 74 | fun insertItem(folder: FolderModel) = viewModelScope.launch { 75 | libraryRepository.insertItem(folder) 76 | } 77 | 78 | fun updateItem(folder: FolderModel) = viewModelScope.launch { 79 | libraryRepository.updateItem(folder) 80 | } 81 | 82 | fun deleteItem(folder: FolderModel) = viewModelScope.launch { 83 | libraryRepository.deleteItem(folder) 84 | 85 | getFolderFiles(folder.id) 86 | delay(500) 87 | 88 | if (_state.value.folderFiles.isNotEmpty()) { 89 | for (file in _state.value.folderFiles) { 90 | deleteItem(file) 91 | } 92 | } 93 | } 94 | 95 | fun moveToFolder(id: UUID, files: List) = viewModelScope.launch { 96 | for (file in files) { 97 | libraryRepository.updateItem(file.copy(parent = id)) 98 | } 99 | } 100 | 101 | fun getFolderFiles(id: UUID) = viewModelScope.launch { 102 | libraryRepository.getFolderFiles(id).distinctUntilChanged().collect { items -> 103 | _state.update { it.copy(folderFiles = items) } 104 | } 105 | } 106 | 107 | fun getFolderFilesCount(id: UUID): MutableIntState { 108 | val count = mutableIntStateOf(0) 109 | viewModelScope.launch { 110 | libraryRepository.getFolderFilesCount(id).distinctUntilChanged().collect { item -> 111 | count.intValue = item 112 | } 113 | } 114 | return count 115 | } 116 | 117 | fun search(query: String) = viewModelScope.launch { 118 | val value = _state.value 119 | _state.update { 120 | it.copy( 121 | searchedFiles = value.files.filter { file -> 122 | file.name.lowercase().contains(query.lowercase()) 123 | }, 124 | searchedFolders = value.folders.filter { folder -> 125 | folder.name.lowercase().contains(query.lowercase()) 126 | } 127 | ) 128 | } 129 | } 130 | 131 | fun sort(type: SortType) = viewModelScope.launch { 132 | val value = _state.value 133 | _state.update { 134 | it.copy( 135 | files = value.files.sortedBy { file -> 136 | when (type) { 137 | SortType.Date -> file.date 138 | SortType.Name -> file.name 139 | }.toString() 140 | }, 141 | folders = value.folders.sortedBy { folder -> 142 | when (type) { 143 | SortType.Date -> folder.date 144 | SortType.Name -> folder.name 145 | }.toString() 146 | }, 147 | ) 148 | } 149 | } 150 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/app/presentation/viewmodel/SettingsViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.app.presentation.viewmodel 2 | 3 | import com.ifeanyi.read.app.data.SettingsRepository 4 | import com.ifeanyi.read.app.data.models.WhatsNewModel 5 | import com.ifeanyi.read.app.data.models.latestUpdate 6 | import com.ifeanyi.read.core.enums.AppTheme 7 | import com.ifeanyi.read.core.enums.DisplayStyle 8 | import com.ifeanyi.read.core.services.PreferenceService 9 | import com.ifeanyi.read.core.util.Constants 10 | import android.speech.tts.Voice 11 | import androidx.compose.runtime.MutableState 12 | import androidx.compose.runtime.mutableStateOf 13 | import androidx.lifecycle.ViewModel 14 | import androidx.lifecycle.viewModelScope 15 | import dagger.hilt.android.lifecycle.HiltViewModel 16 | import kotlinx.coroutines.flow.MutableStateFlow 17 | import kotlinx.coroutines.flow.asStateFlow 18 | import kotlinx.coroutines.flow.distinctUntilChanged 19 | import kotlinx.coroutines.flow.update 20 | import kotlinx.coroutines.launch 21 | import javax.inject.Inject 22 | 23 | data class SettingsState( 24 | val theme: AppTheme = AppTheme.System, 25 | val displayStyle: DisplayStyle = DisplayStyle.Grid, 26 | val speechRate: Float = 1.0f, 27 | val voice: Voice? = null, 28 | val whatsNew: List = emptyList(), 29 | val showWhatsNew: MutableState = mutableStateOf(false) 30 | ) 31 | 32 | @HiltViewModel 33 | class SettingsViewModel @Inject constructor( 34 | private val settingsRepository: SettingsRepository, 35 | private val preferenceService: PreferenceService 36 | ) : ViewModel() { 37 | private val _state = MutableStateFlow(SettingsState()) 38 | val state = _state.asStateFlow() 39 | 40 | init { 41 | getTheme() 42 | checkWhatsNew() 43 | getAllUpdates() 44 | getDisplayStyle() 45 | getSpeechRate() 46 | getVoice() 47 | } 48 | 49 | private fun getTheme() = viewModelScope.launch { 50 | preferenceService.getTheme().distinctUntilChanged().collect { theme -> 51 | _state.update { it.copy(theme = theme) } 52 | } 53 | } 54 | 55 | fun setTheme(theme: AppTheme) = viewModelScope.launch { 56 | preferenceService.saveString(key = Constants.theme, value = theme.name) 57 | } 58 | 59 | private fun checkWhatsNew() = viewModelScope.launch { 60 | val latest = settingsRepository.getUpdate(latestUpdate.id) 61 | if (latest == null) { 62 | settingsRepository.insertItem(latestUpdate) 63 | _state.update { it.copy(showWhatsNew = mutableStateOf(true)) } 64 | } 65 | } 66 | 67 | private fun getAllUpdates() = viewModelScope.launch { 68 | settingsRepository.getAllUpdates().distinctUntilChanged().collect { whatsNew -> 69 | _state.update { it.copy(whatsNew = whatsNew.reversed()) } 70 | } 71 | } 72 | 73 | private fun getDisplayStyle() = viewModelScope.launch { 74 | preferenceService.getDisplayStyle().distinctUntilChanged().collect { style -> 75 | _state.update { it.copy(displayStyle = style) } 76 | } 77 | } 78 | 79 | fun setDisplayStyle(style: DisplayStyle) = viewModelScope.launch { 80 | preferenceService.saveString(key = Constants.displayStyle, value = style.name) 81 | } 82 | 83 | private fun getSpeechRate() = viewModelScope.launch { 84 | preferenceService.getString(Constants.speechRate).distinctUntilChanged().collect { rate -> 85 | val speechRate = (rate ?: "1.0").toDouble() 86 | _state.update { it.copy(speechRate = String.format("%.1f", speechRate).toFloat()) } 87 | } 88 | } 89 | 90 | fun setSpeechRate(rate: Float) = viewModelScope.launch { 91 | preferenceService.saveString(key = Constants.speechRate, value = "$rate") 92 | } 93 | 94 | private fun getVoice() = viewModelScope.launch { 95 | preferenceService.getVoice().distinctUntilChanged().collect { voice -> 96 | _state.update { it.copy(voice = voice) } 97 | } 98 | } 99 | 100 | fun setVoice(voice: Voice) = viewModelScope.launch { 101 | preferenceService.saveString( 102 | key = Constants.voice, 103 | value = "${voice.name}/${voice.locale.language}/${voice.locale.country}" 104 | ) 105 | } 106 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/app/presentation/views/home/EnterTextScreen.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.app.presentation.views.home 2 | 3 | import android.annotation.SuppressLint 4 | import com.ifeanyi.read.app.data.models.FileModel 5 | import com.ifeanyi.read.app.data.models.LibraryType 6 | import com.ifeanyi.read.app.presentation.components.AppButton 7 | import com.ifeanyi.read.app.presentation.components.TextFieldComponent 8 | import com.ifeanyi.read.app.presentation.viewmodel.LibraryViewModel 9 | import com.ifeanyi.read.core.services.AnalyticService 10 | import com.ifeanyi.read.core.services.SpeechService 11 | import androidx.compose.foundation.layout.Arrangement 12 | import androidx.compose.foundation.layout.PaddingValues 13 | import androidx.compose.foundation.layout.fillMaxWidth 14 | import androidx.compose.foundation.layout.height 15 | import androidx.compose.foundation.lazy.LazyColumn 16 | import androidx.compose.foundation.text.KeyboardOptions 17 | import androidx.compose.material3.ExperimentalMaterial3Api 18 | import androidx.compose.material3.MaterialTheme 19 | import androidx.compose.material3.Scaffold 20 | import androidx.compose.material3.Text 21 | import androidx.compose.material3.TopAppBar 22 | import androidx.compose.runtime.Composable 23 | import androidx.compose.runtime.mutableStateOf 24 | import androidx.compose.runtime.remember 25 | import androidx.compose.ui.Modifier 26 | import androidx.compose.ui.platform.LocalConfiguration 27 | import androidx.compose.ui.platform.LocalContext 28 | import androidx.compose.ui.text.input.ImeAction 29 | import androidx.compose.ui.text.style.TextAlign 30 | import androidx.compose.ui.unit.dp 31 | import androidx.core.net.toUri 32 | import androidx.hilt.navigation.compose.hiltViewModel 33 | import androidx.navigation.NavHostController 34 | import java.io.File 35 | import java.time.Instant 36 | 37 | @OptIn(ExperimentalMaterial3Api::class) 38 | @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") 39 | @Composable 40 | fun EnterTextScreen( 41 | controller: NavHostController, 42 | libraryVM: LibraryViewModel = hiltViewModel(), 43 | ) { 44 | val context = LocalContext.current 45 | val config = LocalConfiguration.current 46 | val textLimit = 4000 47 | val text = remember { mutableStateOf("") } 48 | 49 | Scaffold( 50 | topBar = { 51 | TopAppBar( 52 | title = { Text(text = "Enter Text", style = MaterialTheme.typography.titleLarge) }, 53 | ) 54 | } 55 | ) { padding -> 56 | LazyColumn( 57 | contentPadding = PaddingValues( 58 | top = padding.calculateTopPadding(), 59 | start = 20.dp, end = 20.dp, 60 | bottom = 200.dp 61 | ), 62 | verticalArrangement = Arrangement.spacedBy(15.dp) 63 | ) { 64 | item { 65 | TextFieldComponent( 66 | value = text, 67 | label = { Text("Paste or write something...") }, 68 | modifier = Modifier.height((config.screenHeightDp * 0.5).dp), 69 | maxLines = 30, 70 | textLimit = textLimit, 71 | supportingText = { 72 | Text( 73 | text = "${text.value.length}/$textLimit", 74 | modifier = Modifier.fillMaxWidth(), 75 | textAlign = TextAlign.End, 76 | ) 77 | }, 78 | keyboardOptions = KeyboardOptions.Default.copy( 79 | imeAction = ImeAction.Done 80 | ), 81 | ) 82 | } 83 | item { 84 | AppButton(text = "Continue", enabled = text.value.isNotEmpty()) { 85 | AnalyticService.track("enter_text") 86 | val path = "${context.filesDir.path}/${Instant.now()}.txt" 87 | val outputFile = File(path) 88 | outputFile.writeText(text.value, Charsets.UTF_8) 89 | 90 | val newUri = outputFile.toUri() 91 | val model = FileModel( 92 | name = outputFile.name, 93 | type = LibraryType.Txt, 94 | path = newUri.toString(), 95 | ) 96 | SpeechService.updateModel(model) { 97 | libraryVM.insertItem(it) 98 | } 99 | controller.popBackStack() 100 | } 101 | } 102 | } 103 | } 104 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/app/presentation/views/home/EnterUrlSheet.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.app.presentation.views.home 2 | 3 | import com.ifeanyi.read.app.data.models.FileModel 4 | import com.ifeanyi.read.app.data.models.LibraryType 5 | import com.ifeanyi.read.app.presentation.components.AppButton 6 | import com.ifeanyi.read.app.presentation.components.TextFieldComponent 7 | import com.ifeanyi.read.app.presentation.viewmodel.LibraryViewModel 8 | import com.ifeanyi.read.core.services.AnalyticService 9 | import com.ifeanyi.read.core.services.SpeechService 10 | import com.ifeanyi.read.core.util.trimUrl 11 | import androidx.compose.foundation.layout.Arrangement 12 | import androidx.compose.foundation.layout.Column 13 | import androidx.compose.foundation.layout.fillMaxHeight 14 | import androidx.compose.foundation.layout.padding 15 | import androidx.compose.material3.ExperimentalMaterial3Api 16 | import androidx.compose.material3.MaterialTheme 17 | import androidx.compose.material3.ModalBottomSheet 18 | import androidx.compose.material3.Text 19 | import androidx.compose.material3.rememberModalBottomSheetState 20 | import androidx.compose.runtime.Composable 21 | import androidx.compose.runtime.MutableState 22 | import androidx.compose.runtime.mutableStateOf 23 | import androidx.compose.runtime.remember 24 | import androidx.compose.runtime.rememberCoroutineScope 25 | import androidx.compose.ui.Modifier 26 | import androidx.compose.ui.platform.LocalFocusManager 27 | import androidx.compose.ui.unit.dp 28 | import kotlinx.coroutines.launch 29 | 30 | @Composable 31 | @OptIn(ExperimentalMaterial3Api::class) 32 | fun EnterUrlSheet( 33 | showUrlSheet: MutableState, 34 | libraryVM: LibraryViewModel, 35 | ) { 36 | val focusManager = LocalFocusManager.current 37 | 38 | val url = remember { mutableStateOf("") } 39 | val coroutineScope = rememberCoroutineScope() 40 | val modalSheetState = rememberModalBottomSheetState() 41 | 42 | fun onContinue() { 43 | focusManager.clearFocus() 44 | val model = FileModel( 45 | name = url.value.trimUrl, 46 | type = LibraryType.Url, 47 | path = url.value, 48 | ) 49 | AnalyticService.track("enter_url") 50 | coroutineScope.launch { 51 | SpeechService.updateModel(model) { 52 | libraryVM.insertItem(it) 53 | } 54 | modalSheetState.hide() 55 | }.invokeOnCompletion { 56 | showUrlSheet.value = false 57 | } 58 | } 59 | 60 | ModalBottomSheet( 61 | onDismissRequest = { showUrlSheet.value = false }, 62 | sheetState = modalSheetState 63 | ) { 64 | Column( 65 | modifier = Modifier 66 | .fillMaxHeight(0.5f) 67 | .padding(20.dp), 68 | verticalArrangement = Arrangement.spacedBy(20.dp), 69 | ) { 70 | Text(text = "Enter link", style = MaterialTheme.typography.titleLarge) 71 | 72 | TextFieldComponent( 73 | value = url, 74 | label = { Text("Link") }, 75 | onImeAction = { onContinue() } 76 | ) 77 | 78 | AppButton( 79 | text = "Continue", 80 | enabled = url.value.isNotEmpty() 81 | ) { onContinue() } 82 | } 83 | } 84 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/app/presentation/views/library/CreateFolderSheet.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.app.presentation.views.library 2 | 3 | import com.ifeanyi.read.app.data.models.FolderModel 4 | import com.ifeanyi.read.app.presentation.components.AppButton 5 | import com.ifeanyi.read.app.presentation.components.TextFieldComponent 6 | import com.ifeanyi.read.app.presentation.viewmodel.LibraryViewModel 7 | import com.ifeanyi.read.core.services.AnalyticService 8 | import androidx.compose.foundation.layout.Arrangement 9 | import androidx.compose.foundation.layout.Column 10 | import androidx.compose.foundation.layout.fillMaxHeight 11 | import androidx.compose.foundation.layout.padding 12 | import androidx.compose.foundation.text.KeyboardActions 13 | import androidx.compose.foundation.text.KeyboardOptions 14 | import androidx.compose.material3.ExperimentalMaterial3Api 15 | import androidx.compose.material3.MaterialTheme 16 | import androidx.compose.material3.ModalBottomSheet 17 | import androidx.compose.material3.Text 18 | import androidx.compose.material3.rememberModalBottomSheetState 19 | import androidx.compose.runtime.Composable 20 | import androidx.compose.runtime.MutableState 21 | import androidx.compose.runtime.mutableStateOf 22 | import androidx.compose.runtime.remember 23 | import androidx.compose.runtime.rememberCoroutineScope 24 | import androidx.compose.ui.Modifier 25 | import androidx.compose.ui.text.input.ImeAction 26 | import androidx.compose.ui.unit.dp 27 | import kotlinx.coroutines.launch 28 | 29 | @Composable 30 | @OptIn(ExperimentalMaterial3Api::class) 31 | fun CreateFolderSheet( 32 | createFolder: MutableState, 33 | libraryVM: LibraryViewModel 34 | ) { 35 | val name = remember { mutableStateOf("") } 36 | val coroutineScope = rememberCoroutineScope() 37 | val modalSheetState = rememberModalBottomSheetState() 38 | 39 | fun onContinue() { 40 | val folder = FolderModel(name = name.value) 41 | AnalyticService.track("create_folder") 42 | coroutineScope.launch { 43 | libraryVM.insertItem(folder) 44 | modalSheetState.hide() 45 | }.invokeOnCompletion { 46 | createFolder.value = false 47 | } 48 | } 49 | 50 | ModalBottomSheet( 51 | onDismissRequest = { createFolder.value = false }, 52 | sheetState = modalSheetState 53 | ) { 54 | Column( 55 | modifier = Modifier 56 | .fillMaxHeight(0.5f) 57 | .padding(20.dp), 58 | verticalArrangement = Arrangement.spacedBy(20.dp), 59 | ) { 60 | Text(text = "Create Folder", style = MaterialTheme.typography.titleLarge) 61 | 62 | TextFieldComponent( 63 | value = name, 64 | label = { Text("Name") }, 65 | onImeAction = { onContinue() }, 66 | keyboardOptions = KeyboardOptions.Default.copy( 67 | imeAction = ImeAction.Go 68 | ), 69 | keyboardActions = KeyboardActions( 70 | onGo = { onContinue() } 71 | ) 72 | ) 73 | 74 | AppButton( 75 | text = "Continue", 76 | enabled = name.value.isNotEmpty() 77 | ) { onContinue() } 78 | } 79 | } 80 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/app/presentation/views/library/DefaultDialog.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.app.presentation.views.library 2 | 3 | import com.ifeanyi.read.app.presentation.viewmodel.SettingsViewModel 4 | import com.ifeanyi.read.core.enums.DisplayStyle 5 | import com.ifeanyi.read.core.theme.AppIcons 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.PaddingValues 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.layout.wrapContentSize 11 | import androidx.compose.material3.DropdownMenu 12 | import androidx.compose.material3.DropdownMenuItem 13 | import androidx.compose.material3.HorizontalDivider 14 | import androidx.compose.material3.Icon 15 | import androidx.compose.material3.Text 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.runtime.MutableState 18 | import androidx.compose.ui.Alignment 19 | import androidx.compose.ui.Modifier 20 | import androidx.compose.ui.unit.dp 21 | 22 | @Composable 23 | fun DefaultDialog( 24 | padding: PaddingValues, 25 | createFolder: MutableState, 26 | showMore: MutableState, 27 | showSort: MutableState, 28 | isSelecting: MutableState, 29 | settingsVM: SettingsViewModel 30 | ) { 31 | Box( 32 | modifier = Modifier 33 | .fillMaxWidth() 34 | .padding( 35 | top = padding.calculateTopPadding(), 36 | start = 20.dp, end = 20.dp, 37 | ) 38 | .wrapContentSize(Alignment.TopEnd) 39 | ) { 40 | DropdownMenu( 41 | expanded = showMore.value, 42 | onDismissRequest = { showMore.value = !showMore.value }, 43 | modifier = Modifier.fillMaxWidth(0.6f) 44 | ) { 45 | DropdownMenuItem( 46 | text = { Text(text = "New Folder") }, 47 | onClick = { 48 | createFolder.value = true 49 | showMore.value = false 50 | }, 51 | trailingIcon = { 52 | Icon(imageVector = AppIcons.newFolder, contentDescription = "") 53 | } 54 | ) 55 | DropdownMenuItem( 56 | text = { Text(text = "Sort") }, 57 | onClick = { 58 | showMore.value = false 59 | showSort.value = true 60 | }, 61 | trailingIcon = { 62 | Icon( 63 | imageVector = AppIcons.sort, 64 | contentDescription = "" 65 | ) 66 | } 67 | ) 68 | DropdownMenuItem( 69 | text = { Text(text = "Select Multiple") }, 70 | onClick = { 71 | showMore.value = false 72 | isSelecting.value = true 73 | }, 74 | trailingIcon = { 75 | Icon(imageVector = AppIcons.checklist, contentDescription = "") 76 | } 77 | ) 78 | HorizontalDivider() 79 | DropdownMenuItem( 80 | text = { Text(text = "List View") }, 81 | onClick = { 82 | showMore.value = false 83 | settingsVM.setDisplayStyle(DisplayStyle.List) 84 | }, 85 | trailingIcon = { 86 | Icon( 87 | imageVector = AppIcons.listView, 88 | contentDescription = "" 89 | ) 90 | } 91 | ) 92 | DropdownMenuItem( 93 | text = { Text(text = "Grid View") }, 94 | onClick = { 95 | showMore.value = false 96 | settingsVM.setDisplayStyle(DisplayStyle.Grid) 97 | }, 98 | trailingIcon = { 99 | Icon(imageVector = AppIcons.gridView, contentDescription = "") 100 | } 101 | ) 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/app/presentation/views/library/MoveFilesSheet.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.app.presentation.views.library 2 | 3 | 4 | import com.ifeanyi.read.app.data.models.FileModel 5 | import com.ifeanyi.read.app.data.models.FolderModel 6 | import com.ifeanyi.read.app.presentation.components.GridTileComponent 7 | import com.ifeanyi.read.app.presentation.viewmodel.LibraryViewModel 8 | import com.ifeanyi.read.core.services.AnalyticService 9 | import com.ifeanyi.read.core.theme.AppIcons 10 | import com.ifeanyi.read.core.util.dwdm 11 | import androidx.compose.foundation.layout.Arrangement 12 | import androidx.compose.foundation.layout.Box 13 | import androidx.compose.foundation.layout.Column 14 | import androidx.compose.foundation.layout.PaddingValues 15 | import androidx.compose.foundation.layout.fillMaxHeight 16 | import androidx.compose.foundation.layout.padding 17 | import androidx.compose.foundation.layout.size 18 | import androidx.compose.foundation.lazy.grid.GridCells 19 | import androidx.compose.foundation.lazy.grid.LazyVerticalGrid 20 | import androidx.compose.foundation.lazy.grid.items 21 | import androidx.compose.material3.ExperimentalMaterial3Api 22 | import androidx.compose.material3.Icon 23 | import androidx.compose.material3.MaterialTheme 24 | import androidx.compose.material3.ModalBottomSheet 25 | import androidx.compose.material3.Text 26 | import androidx.compose.material3.rememberModalBottomSheetState 27 | import androidx.compose.runtime.Composable 28 | import androidx.compose.runtime.MutableState 29 | import androidx.compose.runtime.collectAsState 30 | import androidx.compose.runtime.rememberCoroutineScope 31 | import androidx.compose.runtime.snapshots.SnapshotStateList 32 | import androidx.compose.ui.Modifier 33 | import androidx.compose.ui.unit.dp 34 | import kotlinx.coroutines.launch 35 | import java.util.Locale 36 | 37 | @Composable 38 | @OptIn(ExperimentalMaterial3Api::class) 39 | fun MoveFilesSheet( 40 | moveFiles: MutableState, 41 | selectedFiles: SnapshotStateList, 42 | libraryVM: LibraryViewModel, 43 | onDone: () -> Unit, 44 | ) { 45 | val state = libraryVM.state.collectAsState().value 46 | 47 | val locale = Locale.getDefault() 48 | val coroutineScope = rememberCoroutineScope() 49 | val modalSheetState = rememberModalBottomSheetState() 50 | 51 | fun onSelect(folder: FolderModel) { 52 | coroutineScope.launch { 53 | AnalyticService.track("move_files") 54 | libraryVM.moveToFolder(id = folder.id, files = selectedFiles) 55 | modalSheetState.hide() 56 | }.invokeOnCompletion { 57 | onDone.invoke() 58 | } 59 | } 60 | 61 | ModalBottomSheet( 62 | onDismissRequest = { moveFiles.value = false }, 63 | sheetState = modalSheetState 64 | ) { 65 | Column( 66 | modifier = Modifier 67 | .fillMaxHeight(0.8f) 68 | .padding(20.dp), 69 | verticalArrangement = Arrangement.spacedBy(20.dp), 70 | ) { 71 | Text(text = "Select Folder", style = MaterialTheme.typography.titleLarge) 72 | 73 | LazyVerticalGrid( 74 | columns = GridCells.Fixed(3), 75 | verticalArrangement = Arrangement.spacedBy(10.dp), 76 | horizontalArrangement = Arrangement.spacedBy(10.dp), 77 | contentPadding = PaddingValues(bottom = 200.dp) 78 | ) { 79 | items(state.folders) { folder -> 80 | Box { 81 | GridTileComponent( 82 | asset = { 83 | Icon( 84 | imageVector = AppIcons.folder, 85 | contentDescription = "Icon", 86 | modifier = Modifier.size(30.dp), 87 | ) 88 | }, 89 | title = folder.name, 90 | subtitle = folder.date.dwdm(locale), 91 | tonalElevation = 2.dp, 92 | onClick = { onSelect(folder) } 93 | ) 94 | } 95 | } 96 | } 97 | } 98 | } 99 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/app/presentation/views/library/RenameSheet.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.app.presentation.views.library 2 | 3 | 4 | import com.ifeanyi.read.app.data.models.FileModel 5 | import com.ifeanyi.read.app.data.models.FolderModel 6 | import com.ifeanyi.read.app.presentation.components.AppButton 7 | import com.ifeanyi.read.app.presentation.components.TextFieldComponent 8 | import com.ifeanyi.read.app.presentation.viewmodel.LibraryViewModel 9 | import androidx.compose.foundation.layout.Arrangement 10 | import androidx.compose.foundation.layout.Column 11 | import androidx.compose.foundation.layout.fillMaxHeight 12 | import androidx.compose.foundation.layout.padding 13 | import androidx.compose.foundation.text.KeyboardActions 14 | import androidx.compose.foundation.text.KeyboardOptions 15 | import androidx.compose.material3.ExperimentalMaterial3Api 16 | import androidx.compose.material3.MaterialTheme 17 | import androidx.compose.material3.ModalBottomSheet 18 | import androidx.compose.material3.Text 19 | import androidx.compose.material3.rememberModalBottomSheetState 20 | import androidx.compose.runtime.Composable 21 | import androidx.compose.runtime.LaunchedEffect 22 | import androidx.compose.runtime.MutableState 23 | import androidx.compose.runtime.mutableStateOf 24 | import androidx.compose.runtime.remember 25 | import androidx.compose.runtime.rememberCoroutineScope 26 | import androidx.compose.ui.Modifier 27 | import androidx.compose.ui.text.input.ImeAction 28 | import androidx.compose.ui.unit.dp 29 | import kotlinx.coroutines.launch 30 | 31 | @Composable 32 | @OptIn(ExperimentalMaterial3Api::class) 33 | fun RenameSheet( 34 | renameItem: MutableState, 35 | file: FileModel? = null, 36 | folder: FolderModel? =null, 37 | libraryVM: LibraryViewModel, 38 | onDone: () -> Unit, 39 | ) { 40 | val name = remember { mutableStateOf("") } 41 | val coroutineScope = rememberCoroutineScope() 42 | val modalSheetState = rememberModalBottomSheetState() 43 | 44 | fun onContinue() { 45 | coroutineScope.launch { 46 | if (file != null) { 47 | libraryVM.updateItem(file.copy(name = name.value)) 48 | } else if (folder != null) { 49 | libraryVM.updateItem(folder.copy(name = name.value)) 50 | } 51 | modalSheetState.hide() 52 | }.invokeOnCompletion { 53 | onDone.invoke() 54 | } 55 | } 56 | 57 | LaunchedEffect(key1 = Unit) { 58 | name.value = file?.name ?: folder?.name ?: "" 59 | } 60 | 61 | ModalBottomSheet( 62 | onDismissRequest = { renameItem.value = false }, 63 | sheetState = modalSheetState 64 | ) { 65 | Column( 66 | modifier = Modifier 67 | .fillMaxHeight(0.5f) 68 | .padding(20.dp), 69 | verticalArrangement = Arrangement.spacedBy(20.dp), 70 | ) { 71 | Text(text = "Rename", style = MaterialTheme.typography.titleLarge) 72 | 73 | TextFieldComponent( 74 | value = name, 75 | label = { Text("Name") }, 76 | onImeAction = { onContinue() }, 77 | keyboardOptions = KeyboardOptions.Default.copy( 78 | imeAction = ImeAction.Go 79 | ), 80 | keyboardActions = KeyboardActions( 81 | onGo = { onContinue() } 82 | ) 83 | ) 84 | 85 | AppButton( 86 | text = "Continue", 87 | enabled = name.value.isNotEmpty() 88 | ) { onContinue() } 89 | } 90 | } 91 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/app/presentation/views/library/SelectingDialog.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.app.presentation.views.library 2 | 3 | import com.ifeanyi.read.app.data.models.FileModel 4 | import com.ifeanyi.read.app.data.models.FolderModel 5 | import com.ifeanyi.read.app.presentation.viewmodel.LibraryViewModel 6 | import com.ifeanyi.read.core.theme.AppIcons 7 | import androidx.compose.foundation.layout.Box 8 | import androidx.compose.foundation.layout.PaddingValues 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.layout.wrapContentSize 12 | import androidx.compose.material3.DropdownMenu 13 | import androidx.compose.material3.DropdownMenuItem 14 | import androidx.compose.material3.Icon 15 | import androidx.compose.material3.Text 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.runtime.MutableState 18 | import androidx.compose.runtime.remember 19 | import androidx.compose.runtime.snapshots.SnapshotStateList 20 | import androidx.compose.ui.Alignment 21 | import androidx.compose.ui.Modifier 22 | import androidx.compose.ui.unit.dp 23 | 24 | @Composable 25 | fun SelectingDialog( 26 | padding: PaddingValues, 27 | isSelecting: MutableState, 28 | moveFiles: MutableState, 29 | renameItem: MutableState, 30 | showSelectOptions: MutableState, 31 | selectedFiles: SnapshotStateList, 32 | selectedFolders: SnapshotStateList, 33 | libraryVM: LibraryViewModel, 34 | ) { 35 | val allCount = remember(selectedFiles.size, selectedFolders.size) { 36 | selectedFiles.size + selectedFolders.size 37 | } 38 | 39 | Box( 40 | modifier = Modifier 41 | .fillMaxWidth() 42 | .padding( 43 | top = padding.calculateTopPadding(), 44 | start = 20.dp, end = 20.dp, 45 | ) 46 | .wrapContentSize(Alignment.TopEnd) 47 | ) { 48 | DropdownMenu( 49 | expanded = showSelectOptions.value, 50 | onDismissRequest = { 51 | showSelectOptions.value = false 52 | }, 53 | modifier = Modifier.fillMaxWidth(0.6f) 54 | ) { 55 | DropdownMenuItem( 56 | text = { Text(text = "Delete") }, 57 | onClick = { 58 | for (file in selectedFiles) { 59 | libraryVM.deleteItem(file) 60 | } 61 | for (folder in selectedFolders) { 62 | libraryVM.deleteItem(folder) 63 | } 64 | selectedFiles.clear() 65 | selectedFolders.clear() 66 | showSelectOptions.value = false 67 | isSelecting.value = false 68 | }, 69 | trailingIcon = { 70 | Icon( 71 | imageVector = AppIcons.delete, 72 | contentDescription = "" 73 | ) 74 | }, 75 | ) 76 | 77 | if (selectedFiles.isNotEmpty()) { 78 | DropdownMenuItem( 79 | text = { Text(text = "Move to folder") }, 80 | onClick = { 81 | showSelectOptions.value = false 82 | moveFiles.value = true 83 | }, 84 | trailingIcon = { 85 | Icon( 86 | imageVector = AppIcons.folderMove, 87 | contentDescription = "" 88 | ) 89 | }, 90 | ) 91 | } 92 | 93 | if (allCount == 1) { 94 | DropdownMenuItem( 95 | text = { Text(text = "Rename") }, 96 | onClick = { 97 | showSelectOptions.value = false 98 | renameItem.value = true 99 | }, 100 | trailingIcon = { 101 | Icon( 102 | imageVector = AppIcons.rename, 103 | contentDescription = "" 104 | ) 105 | }, 106 | ) 107 | } 108 | } 109 | } 110 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/app/presentation/views/library/SelectingTopBar.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.app.presentation.views.library 2 | 3 | import com.ifeanyi.read.app.data.models.FileModel 4 | import com.ifeanyi.read.app.data.models.FolderModel 5 | import com.ifeanyi.read.core.theme.AppIcons 6 | import androidx.compose.foundation.layout.Spacer 7 | import androidx.compose.foundation.layout.width 8 | import androidx.compose.material3.ExperimentalMaterial3Api 9 | import androidx.compose.material3.Icon 10 | import androidx.compose.material3.IconButton 11 | import androidx.compose.material3.Text 12 | import androidx.compose.material3.TextButton 13 | import androidx.compose.material3.TopAppBar 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.runtime.MutableState 16 | import androidx.compose.runtime.remember 17 | import androidx.compose.runtime.snapshots.SnapshotStateList 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.unit.dp 20 | 21 | @Composable 22 | @OptIn(ExperimentalMaterial3Api::class) 23 | fun SelectingTopBar( 24 | isSelecting: MutableState, 25 | showSelectOptions: MutableState, 26 | selectedFiles: SnapshotStateList, 27 | selectedFolders: SnapshotStateList, 28 | files: List, 29 | folders: List, 30 | ) { 31 | 32 | val allCount = remember(selectedFiles.size, selectedFolders.size) { 33 | selectedFiles.size + selectedFolders.size 34 | } 35 | val allSelected = remember(allCount) { 36 | allCount > 0 && selectedFiles.size == files.size && selectedFolders.size == folders.size 37 | } 38 | 39 | TopAppBar( 40 | title = { Text(text = "$allCount Selected") }, 41 | actions = { 42 | TextButton(onClick = { 43 | selectedFiles.clear() 44 | selectedFolders.clear() 45 | if (!allSelected) { 46 | selectedFiles.addAll(files) 47 | selectedFolders.addAll(folders) 48 | } 49 | }) { 50 | if (allSelected) Icon( 51 | imageVector = AppIcons.checkbox, 52 | contentDescription = "" 53 | ) else Icon( 54 | imageVector = AppIcons.checkboxOutline, 55 | contentDescription = "" 56 | ) 57 | Spacer(modifier = Modifier.width(10.dp)) 58 | Text(text = "All") 59 | } 60 | TextButton(onClick = { 61 | selectedFiles.clear() 62 | selectedFolders.clear() 63 | isSelecting.value = false 64 | }) { 65 | Text(text = "Cancel") 66 | } 67 | IconButton( 68 | onClick = { showSelectOptions.value = true }, 69 | enabled = allCount > 0 70 | ) { 71 | Icon(imageVector = AppIcons.more, contentDescription = "") 72 | } 73 | } 74 | ) 75 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/app/presentation/views/library/SortDialog.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.app.presentation.views.library 2 | 3 | import com.ifeanyi.read.app.presentation.viewmodel.LibraryViewModel 4 | import com.ifeanyi.read.core.theme.AppIcons 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.PaddingValues 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.foundation.layout.wrapContentSize 10 | import androidx.compose.material3.DropdownMenu 11 | import androidx.compose.material3.DropdownMenuItem 12 | import androidx.compose.material3.Icon 13 | import androidx.compose.material3.Text 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.runtime.MutableState 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.unit.dp 19 | 20 | enum class SortType { Date, Name } 21 | 22 | @Composable 23 | fun SortDialog( 24 | padding: PaddingValues, 25 | showSort: MutableState, 26 | showMore: MutableState, 27 | libraryVM: LibraryViewModel, 28 | ) { 29 | Box( 30 | modifier = Modifier 31 | .fillMaxWidth() 32 | .padding( 33 | top = padding.calculateTopPadding(), 34 | start = 20.dp, end = 20.dp, 35 | ) 36 | .wrapContentSize(Alignment.TopEnd) 37 | ) { 38 | DropdownMenu( 39 | expanded = showSort.value, 40 | onDismissRequest = { 41 | showSort.value = false 42 | }, 43 | modifier = Modifier.fillMaxWidth(0.6f) 44 | ) { 45 | DropdownMenuItem( 46 | text = { Text(text = "Sort") }, 47 | onClick = { 48 | showSort.value = false 49 | showMore.value = true 50 | }, 51 | leadingIcon = { 52 | Icon( 53 | imageVector = AppIcons.keyLeft, 54 | contentDescription = "" 55 | ) 56 | }, 57 | trailingIcon = { 58 | Icon( 59 | imageVector = AppIcons.sort, 60 | contentDescription = "" 61 | ) 62 | } 63 | ) 64 | DropdownMenuItem( 65 | text = { Text(text = "Name") }, 66 | onClick = { 67 | showSort.value = false 68 | libraryVM.sort(SortType.Name) 69 | }, 70 | ) 71 | DropdownMenuItem( 72 | text = { Text(text = "Date") }, 73 | onClick = { 74 | showSort.value = false 75 | libraryVM.sort(SortType.Date) 76 | }, 77 | ) 78 | } 79 | } 80 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/app/presentation/views/setting/AboutAppScreen.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.app.presentation.views.setting 2 | 3 | import com.ifeanyi.read.app.presentation.components.SettingsItem 4 | import com.ifeanyi.read.core.theme.AppIcons 5 | import com.ifeanyi.read.core.util.Constants 6 | import androidx.compose.foundation.layout.Arrangement 7 | import androidx.compose.foundation.layout.Column 8 | import androidx.compose.foundation.layout.PaddingValues 9 | import androidx.compose.foundation.layout.Row 10 | import androidx.compose.foundation.layout.fillMaxWidth 11 | import androidx.compose.foundation.layout.padding 12 | import androidx.compose.foundation.lazy.LazyColumn 13 | import androidx.compose.material3.ExperimentalMaterial3Api 14 | import androidx.compose.material3.MaterialTheme 15 | import androidx.compose.material3.Scaffold 16 | import androidx.compose.material3.Surface 17 | import androidx.compose.material3.Text 18 | import androidx.compose.material3.TopAppBar 19 | import androidx.compose.runtime.Composable 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.graphics.Color 22 | import androidx.compose.ui.platform.LocalUriHandler 23 | import androidx.compose.ui.text.style.TextAlign 24 | import androidx.compose.ui.unit.dp 25 | import com.ifeanyi.read.core.util.appVersion 26 | 27 | @OptIn(ExperimentalMaterial3Api::class) 28 | @Composable 29 | fun AboutAppScreen() { 30 | val uriHandler = LocalUriHandler.current 31 | 32 | Scaffold( 33 | topBar = { 34 | TopAppBar(title = { Text(text = "About App") }) 35 | } 36 | ) { padding -> 37 | LazyColumn( 38 | contentPadding = PaddingValues( 39 | top = padding.calculateTopPadding(), 40 | start = 20.dp, end = 20.dp, 41 | bottom = 200.dp 42 | ), 43 | verticalArrangement = Arrangement.spacedBy(15.dp) 44 | ) { 45 | item { 46 | Surface(tonalElevation = 1.dp, shape = MaterialTheme.shapes.small) { 47 | Column(modifier = Modifier.padding(horizontal = 15.dp, vertical = 10.dp)) { 48 | SettingsItem( 49 | title = "Privacy Policy", 50 | icon = AppIcons.shield, 51 | color = Color(0XFFBF5AF2) 52 | ) { 53 | uriHandler.openUri(uri = Constants.privacyLink) 54 | } 55 | SettingsItem( 56 | title = "Terms of Service", 57 | icon = AppIcons.doc, 58 | color = Color(0xFF63D2FF) 59 | ) { 60 | uriHandler.openUri(uri = Constants.termsLink) 61 | } 62 | } 63 | } 64 | } 65 | item { 66 | Row( 67 | modifier = Modifier.fillMaxWidth(), 68 | horizontalArrangement = Arrangement.Center 69 | ) { 70 | Text( 71 | text = "VER $appVersion", 72 | textAlign = TextAlign.Center, 73 | style = MaterialTheme.typography.bodySmall 74 | ) 75 | } 76 | } 77 | } 78 | } 79 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/app/presentation/views/setting/DisplayDialog.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.app.presentation.views.setting 2 | 3 | import com.ifeanyi.read.app.presentation.viewmodel.SettingsViewModel 4 | import com.ifeanyi.read.core.enums.DisplayStyle 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.PaddingValues 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.foundation.layout.wrapContentSize 10 | import androidx.compose.material3.DropdownMenu 11 | import androidx.compose.material3.DropdownMenuItem 12 | import androidx.compose.material3.Text 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.runtime.MutableState 15 | import androidx.compose.ui.Alignment 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.unit.dp 18 | 19 | @Composable 20 | fun DisplayDialog( 21 | padding: PaddingValues, 22 | showDisplay: MutableState, 23 | settingsVM: SettingsViewModel, 24 | ) { 25 | Box( 26 | modifier = Modifier 27 | .fillMaxWidth() 28 | .padding( 29 | top = padding.calculateTopPadding() + 60.dp, 30 | start = 20.dp, end = 20.dp, 31 | ) 32 | .wrapContentSize(Alignment.TopEnd) 33 | ) { 34 | DropdownMenu( 35 | expanded = showDisplay.value, 36 | onDismissRequest = { 37 | showDisplay.value = false 38 | }, 39 | modifier = Modifier.fillMaxWidth(0.6f) 40 | ) { 41 | DisplayStyle.entries.map { 42 | DropdownMenuItem( 43 | text = { Text(text = it.name) }, 44 | onClick = { 45 | showDisplay.value = false 46 | settingsVM.setDisplayStyle(it) 47 | }, 48 | ) 49 | } 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/app/presentation/views/setting/TextToSpeechScreen.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.app.presentation.views.setting 2 | 3 | import com.ifeanyi.read.app.presentation.components.CustomSliderSheet 4 | import com.ifeanyi.read.app.presentation.components.SettingsItem 5 | import com.ifeanyi.read.app.presentation.components.VoiceSelectorSheet 6 | import com.ifeanyi.read.app.presentation.viewmodel.SettingsViewModel 7 | import com.ifeanyi.read.core.services.SpeechService 8 | import com.ifeanyi.read.core.theme.AppIcons 9 | import androidx.compose.foundation.layout.Arrangement 10 | import androidx.compose.foundation.layout.Column 11 | import androidx.compose.foundation.layout.PaddingValues 12 | import androidx.compose.foundation.layout.padding 13 | import androidx.compose.foundation.lazy.LazyColumn 14 | import androidx.compose.material3.ExperimentalMaterial3Api 15 | import androidx.compose.material3.MaterialTheme 16 | import androidx.compose.material3.Scaffold 17 | import androidx.compose.material3.Surface 18 | import androidx.compose.material3.Text 19 | import androidx.compose.material3.TopAppBar 20 | import androidx.compose.material3.rememberModalBottomSheetState 21 | import androidx.compose.runtime.Composable 22 | import androidx.compose.runtime.collectAsState 23 | import androidx.compose.runtime.mutableStateOf 24 | import androidx.compose.runtime.remember 25 | import androidx.compose.runtime.rememberCoroutineScope 26 | import androidx.compose.ui.Modifier 27 | import androidx.compose.ui.graphics.Color 28 | import androidx.compose.ui.text.style.TextOverflow 29 | import androidx.compose.ui.unit.dp 30 | import androidx.hilt.navigation.compose.hiltViewModel 31 | import kotlinx.coroutines.launch 32 | 33 | @OptIn(ExperimentalMaterial3Api::class) 34 | @Composable 35 | fun TextToSpeechScreen(settingsVM: SettingsViewModel = hiltViewModel()) { 36 | val state = settingsVM.state.collectAsState().value 37 | 38 | val coroutineScope = rememberCoroutineScope() 39 | val modalSheetState = rememberModalBottomSheetState() 40 | 41 | val showVoicesSheet = remember { mutableStateOf(false) } 42 | val showRateSheet = remember { mutableStateOf(false) } 43 | 44 | Scaffold( 45 | topBar = { 46 | TopAppBar(title = { Text(text = "Text To Speech") }) 47 | } 48 | ) { padding -> 49 | if (showVoicesSheet.value) { 50 | VoiceSelectorSheet( 51 | showVoicesSheet = showVoicesSheet, 52 | modalSheetState = modalSheetState, 53 | initial = state.voice, 54 | ) { voice -> 55 | coroutineScope.launch { 56 | settingsVM.setVoice(voice) 57 | modalSheetState.hide() 58 | }.invokeOnCompletion { 59 | showVoicesSheet.value = false 60 | if (SpeechService.state.value.model != null) { 61 | SpeechService.stopAndPlay() 62 | } 63 | } 64 | } 65 | } 66 | 67 | if (showRateSheet.value) { 68 | CustomSliderSheet( 69 | showRateSheet = showRateSheet, 70 | modalSheetState = modalSheetState, 71 | initialProgress = state.speechRate / 2 72 | ) { rate -> 73 | coroutineScope.launch { 74 | settingsVM.setSpeechRate(rate) 75 | modalSheetState.hide() 76 | }.invokeOnCompletion { 77 | showRateSheet.value = false 78 | if (SpeechService.state.value.model != null) { 79 | SpeechService.stopAndPlay() 80 | } 81 | } 82 | } 83 | } 84 | 85 | LazyColumn( 86 | contentPadding = PaddingValues( 87 | top = padding.calculateTopPadding(), 88 | start = 20.dp, end = 20.dp, 89 | bottom = 200.dp 90 | ), 91 | verticalArrangement = Arrangement.spacedBy(15.dp) 92 | ) { 93 | item { 94 | Surface(tonalElevation = 1.dp, shape = MaterialTheme.shapes.small) { 95 | Column(modifier = Modifier.padding(horizontal = 15.dp, vertical = 10.dp)) { 96 | SettingsItem( 97 | title = "Speaker Voice", 98 | icon = AppIcons.speaker, 99 | color = Color(0xFFFF9E08), 100 | trailing = { 101 | Text( 102 | text = state.voice?.locale?.displayCountry ?: "", 103 | maxLines = 1, 104 | overflow = TextOverflow.Ellipsis 105 | ) 106 | } 107 | ) { 108 | showVoicesSheet.value = true 109 | } 110 | SettingsItem( 111 | title = "Speech Rate", 112 | icon = AppIcons.speechRate, 113 | color = Color(0xFF0F85FF), 114 | trailing = { Text(text = "${state.speechRate}") } 115 | ) { 116 | showRateSheet.value = true 117 | } 118 | } 119 | } 120 | } 121 | } 122 | } 123 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/app/presentation/views/setting/ThemeDialog.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.app.presentation.views.setting 2 | 3 | import com.ifeanyi.read.app.presentation.viewmodel.SettingsViewModel 4 | import com.ifeanyi.read.core.enums.AppTheme 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.PaddingValues 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.foundation.layout.wrapContentSize 10 | import androidx.compose.material3.DropdownMenu 11 | import androidx.compose.material3.DropdownMenuItem 12 | import androidx.compose.material3.Text 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.runtime.MutableState 15 | import androidx.compose.ui.Alignment 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.unit.dp 18 | 19 | @Composable 20 | fun ThemeDialog( 21 | padding: PaddingValues, 22 | showTheme: MutableState, 23 | settingsVM: SettingsViewModel, 24 | ) { 25 | Box( 26 | modifier = Modifier 27 | .fillMaxWidth() 28 | .padding( 29 | top = padding.calculateTopPadding(), 30 | start = 20.dp, end = 20.dp, 31 | ) 32 | .wrapContentSize(Alignment.TopEnd) 33 | ) { 34 | DropdownMenu( 35 | expanded = showTheme.value, 36 | onDismissRequest = { 37 | showTheme.value = false 38 | }, 39 | modifier = Modifier.fillMaxWidth(0.6f) 40 | ) { 41 | AppTheme.entries.map { 42 | DropdownMenuItem( 43 | text = { Text(text = it.name) }, 44 | onClick = { 45 | showTheme.value = false 46 | settingsVM.setTheme(it) 47 | }, 48 | ) 49 | } 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/app/presentation/views/setting/WhatsNewSheet.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.app.presentation.views.setting 2 | 3 | import com.ifeanyi.read.app.presentation.viewmodel.SettingsViewModel 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.lazy.LazyColumn 9 | import androidx.compose.foundation.lazy.items 10 | import androidx.compose.material3.ExperimentalMaterial3Api 11 | import androidx.compose.material3.MaterialTheme 12 | import androidx.compose.material3.ModalBottomSheet 13 | import androidx.compose.material3.Surface 14 | import androidx.compose.material3.Text 15 | import androidx.compose.material3.rememberModalBottomSheetState 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.runtime.MutableState 18 | import androidx.compose.runtime.collectAsState 19 | import androidx.compose.ui.Modifier 20 | import androidx.compose.ui.text.font.FontWeight 21 | import androidx.compose.ui.unit.dp 22 | import androidx.hilt.navigation.compose.hiltViewModel 23 | 24 | @OptIn(ExperimentalMaterial3Api::class) 25 | @Composable 26 | fun WhatsNewSheet( 27 | showWhatsNewSheet: MutableState, 28 | settingsVM: SettingsViewModel = hiltViewModel() 29 | ) { 30 | val state = settingsVM.state.collectAsState().value 31 | val modalSheetState = rememberModalBottomSheetState() 32 | 33 | ModalBottomSheet( 34 | onDismissRequest = { showWhatsNewSheet.value = false }, 35 | sheetState = modalSheetState 36 | ) { 37 | Column( 38 | modifier = Modifier.padding(20.dp), 39 | verticalArrangement = Arrangement.spacedBy(15.dp) 40 | ) { 41 | Text(text = "What's New", style = MaterialTheme.typography.titleLarge) 42 | 43 | LazyColumn( 44 | verticalArrangement = Arrangement.spacedBy(15.dp), 45 | ) { 46 | items(state.whatsNew) { whatsNew -> 47 | Surface(tonalElevation = 2.dp, shape = MaterialTheme.shapes.small) { 48 | Column( 49 | modifier = Modifier 50 | .fillMaxWidth() 51 | .padding(15.dp), 52 | verticalArrangement = Arrangement.spacedBy(15.dp) 53 | ) { 54 | Text( 55 | text = "🚀 Version ${whatsNew.id}", 56 | style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold) 57 | ) 58 | whatsNew.features.map { 59 | Column(verticalArrangement = Arrangement.spacedBy(5.dp)) { 60 | Text( 61 | text = it.title, 62 | style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold) 63 | ) 64 | Text(text = it.body, style = MaterialTheme.typography.bodySmall) 65 | } 66 | } 67 | } 68 | } 69 | } 70 | } 71 | } 72 | } 73 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/app/presentation/views/speech/GoToPageSheet.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.app.presentation.views.speech 2 | 3 | import com.ifeanyi.read.app.presentation.components.AppButton 4 | import com.ifeanyi.read.app.presentation.components.TextFieldComponent 5 | import com.ifeanyi.read.core.services.AppStateService 6 | import com.ifeanyi.read.core.services.SpeechService 7 | import androidx.compose.foundation.layout.Arrangement 8 | import androidx.compose.foundation.layout.Column 9 | import androidx.compose.foundation.layout.fillMaxHeight 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.text.KeyboardOptions 12 | import androidx.compose.material3.ExperimentalMaterial3Api 13 | import androidx.compose.material3.MaterialTheme 14 | import androidx.compose.material3.ModalBottomSheet 15 | import androidx.compose.material3.SheetState 16 | import androidx.compose.material3.Text 17 | import androidx.compose.runtime.Composable 18 | import androidx.compose.runtime.MutableState 19 | import androidx.compose.runtime.mutableStateOf 20 | import androidx.compose.runtime.remember 21 | import androidx.compose.runtime.rememberCoroutineScope 22 | import androidx.compose.ui.Modifier 23 | import androidx.compose.ui.platform.LocalFocusManager 24 | import androidx.compose.ui.text.input.KeyboardType 25 | import androidx.compose.ui.unit.dp 26 | import kotlinx.coroutines.launch 27 | import java.lang.NumberFormatException 28 | 29 | @Composable 30 | @OptIn(ExperimentalMaterial3Api::class) 31 | fun GoToPageSheet( 32 | showPageSheet: MutableState, 33 | modalSheetState: SheetState, 34 | ) { 35 | val focusManager = LocalFocusManager.current 36 | 37 | val text = remember { mutableStateOf("") } 38 | val coroutineScope = rememberCoroutineScope() 39 | 40 | fun onContinue() { 41 | focusManager.clearFocus() 42 | 43 | coroutineScope.launch { 44 | try { 45 | SpeechService.goToPage(text.value.toInt()) 46 | } catch (exc: NumberFormatException) { 47 | AppStateService.displayMessage("Enter a valid page number") 48 | } 49 | modalSheetState.hide() 50 | }.invokeOnCompletion { 51 | showPageSheet.value = false 52 | } 53 | } 54 | 55 | ModalBottomSheet( 56 | onDismissRequest = { showPageSheet.value = false }, 57 | sheetState = modalSheetState 58 | ) { 59 | Column( 60 | modifier = Modifier 61 | .fillMaxHeight(0.5f) 62 | .padding(20.dp), 63 | verticalArrangement = Arrangement.spacedBy(20.dp), 64 | ) { 65 | Text(text = "Go To Page", style = MaterialTheme.typography.titleLarge) 66 | 67 | TextFieldComponent( 68 | value = text, 69 | label = { Text("Page") }, 70 | onImeAction = { onContinue() }, 71 | keyboardOptions = KeyboardOptions( 72 | keyboardType = KeyboardType.Number 73 | ) 74 | ) 75 | 76 | AppButton( 77 | text = "Continue", 78 | enabled = text.value.isNotEmpty() 79 | ) { onContinue() } 80 | } 81 | } 82 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/app/presentation/views/speech/HighlightedText.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.app.presentation.views.speech 2 | 3 | import androidx.compose.material3.MaterialTheme 4 | import androidx.compose.material3.Text 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.mutableStateOf 7 | import androidx.compose.runtime.remember 8 | import androidx.compose.ui.text.buildAnnotatedString 9 | import androidx.compose.ui.text.font.FontWeight 10 | import androidx.compose.ui.text.withStyle 11 | 12 | @Composable 13 | fun HighlightedText(text: String, range: IntRange) { 14 | val displayedText = remember { mutableStateOf(buildAnnotatedString { }) } 15 | 16 | val wordStyle = MaterialTheme.typography.bodyMedium.toSpanStyle().copy( 17 | fontWeight = FontWeight.Bold, 18 | background = MaterialTheme.colorScheme.tertiaryContainer, 19 | ) 20 | 21 | val sentenceStyle = MaterialTheme.typography.bodyMedium.toSpanStyle() 22 | 23 | val spokenStyle = MaterialTheme.typography.bodyMedium.toSpanStyle().copy( 24 | color = MaterialTheme.colorScheme.outline 25 | ) 26 | 27 | 28 | displayedText.value = buildAnnotatedString { 29 | withStyle(sentenceStyle) { 30 | append(text) 31 | } 32 | 33 | addStyle(spokenStyle, start = 0, end = range.first) 34 | addStyle(wordStyle, start = range.first, end = range.last) 35 | } 36 | 37 | Text(text = displayedText.value) 38 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/core/di/AppModule.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.core.di 2 | 3 | import android.content.Context 4 | import com.ifeanyi.read.app.data.LibraryRepository 5 | import com.ifeanyi.read.app.data.SettingsRepository 6 | import com.ifeanyi.read.app.data.source.FileDao 7 | import com.ifeanyi.read.app.data.source.FolderDao 8 | import com.ifeanyi.read.app.data.source.WhatsNewDao 9 | import com.ifeanyi.read.core.services.DatabaseService 10 | import com.ifeanyi.read.core.services.PreferenceService 11 | import androidx.room.Room 12 | import dagger.Module 13 | import dagger.Provides 14 | import dagger.hilt.InstallIn 15 | import dagger.hilt.android.qualifiers.ApplicationContext 16 | import dagger.hilt.components.SingletonComponent 17 | import javax.inject.Singleton 18 | 19 | @InstallIn(SingletonComponent::class) 20 | @Module 21 | object AppModule { 22 | @Provides 23 | @Singleton 24 | fun providePreferenceService(@ApplicationContext context: Context): PreferenceService { 25 | return PreferenceService(context = context) 26 | } 27 | 28 | @Singleton 29 | @Provides 30 | fun provideFileDao(databaseService: DatabaseService): FileDao = databaseService.file() 31 | 32 | @Singleton 33 | @Provides 34 | fun provideFolderDao(databaseService: DatabaseService): FolderDao = databaseService.folder() 35 | 36 | @Singleton 37 | @Provides 38 | fun provideWhatsNewDao(databaseService: DatabaseService): WhatsNewDao = databaseService.whatsNew() 39 | 40 | @Singleton 41 | @Provides 42 | fun provideAppDatabase(@ApplicationContext context: Context): DatabaseService = 43 | Room.databaseBuilder( 44 | context, 45 | DatabaseService::class.java, 46 | name = "read_db" 47 | ).fallbackToDestructiveMigration().build() 48 | 49 | @Singleton 50 | @Provides 51 | fun provideLibraryRepository(fileDao: FileDao, folderDao: FolderDao): LibraryRepository = 52 | LibraryRepository(fileDao, folderDao) 53 | 54 | @Singleton 55 | @Provides 56 | fun provideSettingsRepository(whatsNewDao: WhatsNewDao): SettingsRepository = 57 | SettingsRepository(whatsNewDao) 58 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/core/enums/ActivityType.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.core.enums 2 | 3 | enum class ActivityType { MainActivity, MainActivityPurple, MainActivityWhite } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/core/enums/AppTheme.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.core.enums 2 | 3 | enum class AppTheme { System, Light, Dark } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/core/enums/DisplayStyle.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.core.enums 2 | 3 | enum class DisplayStyle { List, Grid } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/core/route/BottomRouter.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.core.route 2 | 3 | import androidx.compose.material.icons.Icons 4 | import androidx.compose.material.icons.automirrored.filled.LibraryBooks 5 | import androidx.compose.material.icons.automirrored.outlined.LibraryBooks 6 | import androidx.compose.material.icons.filled.Home 7 | import androidx.compose.material.icons.filled.Settings 8 | import androidx.compose.material.icons.outlined.Home 9 | import androidx.compose.material.icons.outlined.Settings 10 | import androidx.compose.material3.Icon 11 | import androidx.compose.runtime.Composable 12 | 13 | sealed class BottomRouter( 14 | val route: String, 15 | val label: String, 16 | val icon: @Composable () -> Unit, 17 | val inactiveIcon: @Composable () -> Unit 18 | ) { 19 | 20 | data object Home : BottomRouter( 21 | route = Routes.HomeScreen.name, 22 | label = "Home", 23 | icon = { 24 | Icon(imageVector = Icons.Default.Home, contentDescription = "Home Tab") 25 | }, 26 | inactiveIcon = { 27 | Icon(imageVector = Icons.Outlined.Home, contentDescription = "Home Tab") 28 | } 29 | ) 30 | 31 | data object Library : BottomRouter( 32 | route = Routes.LibraryScreen.name, 33 | label = "Library", 34 | icon = { 35 | Icon(imageVector = Icons.AutoMirrored.Filled.LibraryBooks, contentDescription = "Library Tab") 36 | }, 37 | inactiveIcon = { 38 | Icon(imageVector = Icons.AutoMirrored.Outlined.LibraryBooks, contentDescription = "Library Tab") 39 | } 40 | ) 41 | 42 | data object Settings : BottomRouter( 43 | route = Routes.SettingsScreen.name, 44 | label = "Settings", 45 | icon = { 46 | Icon(imageVector = Icons.Default.Settings, contentDescription = "Settings Tab") 47 | }, 48 | inactiveIcon = { 49 | Icon(imageVector = Icons.Outlined.Settings, contentDescription = "Settings Tab") 50 | } 51 | ) 52 | } 53 | 54 | val bottomNavItems = listOf( 55 | BottomRouter.Home, 56 | BottomRouter.Library, 57 | BottomRouter.Settings 58 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/core/route/Router.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.core.route 2 | 3 | import com.ifeanyi.read.app.presentation.views.home.HomeScreen 4 | import com.ifeanyi.read.app.presentation.views.home.EnterTextScreen 5 | import com.ifeanyi.read.app.presentation.views.library.FolderScreen 6 | import com.ifeanyi.read.app.presentation.views.library.LibraryScreen 7 | import com.ifeanyi.read.app.presentation.views.setting.AboutAppScreen 8 | import com.ifeanyi.read.app.presentation.views.setting.AppearanceScreen 9 | import com.ifeanyi.read.app.presentation.views.setting.SettingsScreen 10 | import com.ifeanyi.read.app.presentation.views.setting.TextToSpeechScreen 11 | import android.os.Build 12 | import androidx.annotation.RequiresApi 13 | import androidx.compose.runtime.Composable 14 | import androidx.navigation.NavHostController 15 | import androidx.navigation.compose.NavHost 16 | import androidx.navigation.compose.composable 17 | import java.util.UUID 18 | 19 | @RequiresApi(Build.VERSION_CODES.TIRAMISU) 20 | @Composable 21 | fun Router( 22 | controller: NavHostController, 23 | onIconChangeRed: () -> Unit, 24 | onIconChangePurple: () -> Unit, 25 | onIconChangeWhite: () -> Unit 26 | ) { 27 | 28 | NavHost(navController = controller, startDestination = Routes.HomeScreen.name) { 29 | composable(Routes.HomeScreen.name) { 30 | HomeScreen(controller = controller) 31 | } 32 | composable(Routes.EnterTextScreen.name) { 33 | EnterTextScreen(controller = controller) 34 | } 35 | composable(Routes.LibraryScreen.name) { 36 | LibraryScreen(controller = controller) 37 | } 38 | val folderRoute = "${Routes.FolderScreen.name}/{id}/{name}" 39 | composable(folderRoute) { 40 | val id = UUID.fromString(it.arguments?.getString("id")) 41 | val name = it.arguments?.getString("name") 42 | FolderScreen(id = id, name = name ?: "Folder") 43 | } 44 | composable(Routes.SettingsScreen.name) { 45 | SettingsScreen(controller = controller) 46 | } 47 | composable(Routes.AboutAppScreen.name) { 48 | AboutAppScreen() 49 | } 50 | composable(Routes.TextToSpeechScreen.name) { 51 | TextToSpeechScreen() 52 | } 53 | composable(Routes.AppearanceScreen.name) { 54 | AppearanceScreen( 55 | onIconChangeRed = onIconChangeRed, 56 | onIconChangePurple = onIconChangePurple, 57 | onIconChangeWhite = onIconChangeWhite 58 | ) 59 | } 60 | } 61 | } 62 | 63 | val NavHostController.parentRoute: Routes 64 | get() = this.currentBackStackEntry.let { 65 | val route = it?.destination?.route?.split("/")?.first() ?: Routes.HomeScreen.name 66 | 67 | val parentRoute = when (Routes.valueOf(route)) { 68 | Routes.HomeScreen -> Routes.HomeScreen 69 | Routes.EnterTextScreen -> Routes.HomeScreen 70 | Routes.LibraryScreen -> Routes.LibraryScreen 71 | Routes.FolderScreen -> Routes.LibraryScreen 72 | Routes.SettingsScreen -> Routes.SettingsScreen 73 | Routes.AboutAppScreen -> Routes.SettingsScreen 74 | Routes.TextToSpeechScreen -> Routes.SettingsScreen 75 | Routes.AppearanceScreen -> Routes.SettingsScreen 76 | } 77 | return parentRoute 78 | } 79 | -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/core/route/Routes.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.core.route 2 | 3 | enum class Routes { 4 | HomeScreen, 5 | EnterTextScreen, 6 | LibraryScreen, 7 | FolderScreen, 8 | SettingsScreen, 9 | AboutAppScreen, 10 | TextToSpeechScreen, 11 | AppearanceScreen 12 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/core/services/AnalyticService.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.core.services 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.Context 5 | import com.ifeanyi.read.BuildConfig 6 | import com.mixpanel.android.mpmetrics.MixpanelAPI 7 | 8 | object AnalyticService { 9 | @SuppressLint("StaticFieldLeak") 10 | private var _mixpanel: MixpanelAPI? = null 11 | 12 | fun init(context: Context) { 13 | _mixpanel = MixpanelAPI.getInstance(context, BuildConfig.MIX_PANEL_KEY, true) 14 | } 15 | 16 | fun track(event: String) { 17 | if (BuildConfig.DEBUG) return 18 | _mixpanel?.track(event) 19 | } 20 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/core/services/DatabaseService.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.core.services 2 | 3 | import com.ifeanyi.read.app.data.models.FileModel 4 | import com.ifeanyi.read.app.data.models.FolderModel 5 | import com.ifeanyi.read.app.data.models.WhatsNewModel 6 | import com.ifeanyi.read.app.data.source.FileDao 7 | import com.ifeanyi.read.app.data.source.FolderDao 8 | import com.ifeanyi.read.app.data.source.WhatsNewDao 9 | import com.ifeanyi.read.core.util.RoomConverters 10 | import androidx.room.Database 11 | import androidx.room.RoomDatabase 12 | import androidx.room.TypeConverters 13 | 14 | @Database( 15 | entities = [FileModel::class, FolderModel::class, WhatsNewModel::class], 16 | version = 2, 17 | exportSchema = false 18 | ) 19 | @TypeConverters(RoomConverters::class) 20 | abstract class DatabaseService : RoomDatabase() { 21 | abstract fun file(): FileDao 22 | abstract fun folder(): FolderDao 23 | abstract fun whatsNew(): WhatsNewDao 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/core/services/NotificationService.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.core.services 2 | 3 | import android.Manifest 4 | import android.app.NotificationChannel 5 | import android.app.NotificationManager 6 | import android.app.Service 7 | import android.content.Context 8 | import android.content.Intent 9 | import android.content.pm.PackageManager 10 | import com.ifeanyi.read.R 11 | import android.os.Bundle 12 | import android.os.IBinder 13 | import android.support.v4.media.MediaMetadataCompat 14 | import android.support.v4.media.session.MediaSessionCompat 15 | import android.support.v4.media.session.PlaybackStateCompat 16 | import androidx.core.app.ActivityCompat 17 | import androidx.core.app.NotificationCompat 18 | import androidx.core.app.NotificationManagerCompat 19 | import androidx.media.app.NotificationCompat as MediaNotification 20 | 21 | const val ACTION_FORWARD = "ACTION_FORWARD" 22 | const val ACTION_REWIND = "ACTION_REWIND" 23 | const val CHANNEL_ID = "TTS_NOTIFICATION_CHANNEL" 24 | 25 | val notificationService = NotificationService.getInstance 26 | 27 | class NotificationService: Service() { 28 | companion object { 29 | private var instance: NotificationService? = null 30 | 31 | val getInstance: NotificationService 32 | get() { 33 | return instance ?: synchronized(this) { 34 | instance ?: NotificationService().also { instance = it } 35 | } 36 | } 37 | } 38 | 39 | private lateinit var _appContext: Context 40 | private lateinit var _session: MediaSessionCompat 41 | 42 | private var _setup: Boolean = false 43 | 44 | fun init(context: Context) { 45 | _appContext = context 46 | _session = MediaSessionCompat(_appContext, "MediaSession") 47 | val serviceIntent = Intent(context, NotificationService::class.java) 48 | context.startService(serviceIntent) 49 | } 50 | 51 | fun destroy() { 52 | val notificationManagerCompat = NotificationManagerCompat.from(_appContext) 53 | notificationManagerCompat.cancel(0) 54 | _session.release() 55 | val serviceIntent = Intent(_appContext, NotificationService::class.java) 56 | _appContext.stopService(serviceIntent) 57 | } 58 | 59 | fun showMediaStyleNotification() { 60 | val state = SpeechService.state.value 61 | if (state.model == null) return 62 | 63 | if (ActivityCompat.checkSelfPermission( 64 | _appContext, 65 | Manifest.permission.POST_NOTIFICATIONS 66 | ) != PackageManager.PERMISSION_GRANTED 67 | ) { 68 | println("NOT GRANTED") 69 | return 70 | } 71 | 72 | val callback = object : MediaSessionCompat.Callback() { 73 | override fun onCustomAction(action: String?, extras: Bundle?) { 74 | when (action) { 75 | ACTION_FORWARD -> { 76 | SpeechService.forward() 77 | } 78 | 79 | ACTION_REWIND -> { 80 | SpeechService.rewind() 81 | } 82 | } 83 | } 84 | 85 | override fun onPlay() { 86 | SpeechService.play() 87 | } 88 | 89 | override fun onPause() { 90 | SpeechService.pause() 91 | } 92 | } 93 | 94 | val playbackStateBuilder = PlaybackStateCompat.Builder() 95 | .setActions(PlaybackStateCompat.ACTION_PLAY_PAUSE) 96 | .setState( 97 | if (state.isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED, 98 | (state.progress * 100).toLong(), 99 | 1f 100 | ) 101 | .addCustomAction( 102 | ACTION_REWIND, 103 | "Rewind", 104 | R.drawable.round_replay_10_24 105 | ) 106 | .addCustomAction( 107 | ACTION_FORWARD, 108 | "Forward", 109 | R.drawable.round_forward_10_24 110 | ) 111 | .build() 112 | 113 | val file = state.model 114 | val metadata = MediaMetadataCompat.Builder() 115 | .putString(MediaMetadataCompat.METADATA_KEY_TITLE, file.name) 116 | .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, file.type.name.lowercase()) 117 | .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, 100) 118 | .build() 119 | 120 | _session.setPlaybackState(playbackStateBuilder) 121 | _session.setCallback(callback) 122 | _session.setMetadata(metadata) 123 | 124 | if (_setup) return 125 | 126 | val builder = NotificationCompat.Builder(_appContext, CHANNEL_ID) 127 | .setSmallIcon(R.drawable.round_record_voice_over_24) 128 | .setStyle(MediaNotification.MediaStyle().setMediaSession(_session.sessionToken)) 129 | .setOngoing(true) 130 | .setAutoCancel(false) 131 | .setShowWhen(false) 132 | 133 | val notification = builder.build() 134 | val notificationManagerCompat = NotificationManagerCompat.from(_appContext) 135 | 136 | createNotificationChannel(notificationManagerCompat) 137 | 138 | notificationManagerCompat.notify(0, notification) 139 | 140 | _setup = true 141 | } 142 | 143 | private fun createNotificationChannel(notificationManagerCompat: NotificationManagerCompat) { 144 | val channelName = "Text To Speech Notification Channel" 145 | val channelDescription = "Channel for TTS notifications" 146 | val importance = NotificationManager.IMPORTANCE_HIGH 147 | val notificationChannel = NotificationChannel(CHANNEL_ID, channelName, importance).apply { 148 | description = channelDescription 149 | } 150 | 151 | notificationManagerCompat.createNotificationChannel(notificationChannel) 152 | } 153 | 154 | override fun onBind(intent: Intent?): IBinder? { 155 | return null 156 | } 157 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/core/services/PreferenceService.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.core.services 2 | 3 | import android.content.Context 4 | import com.ifeanyi.read.core.enums.AppTheme 5 | import com.ifeanyi.read.core.enums.DisplayStyle 6 | import com.ifeanyi.read.core.util.Constants 7 | import android.speech.tts.Voice 8 | import androidx.datastore.core.DataStore 9 | import androidx.datastore.preferences.core.Preferences 10 | import androidx.datastore.preferences.core.edit 11 | import androidx.datastore.preferences.core.stringPreferencesKey 12 | import androidx.datastore.preferences.preferencesDataStore 13 | import kotlinx.coroutines.flow.Flow 14 | import kotlinx.coroutines.flow.map 15 | import java.util.Locale 16 | 17 | class PreferenceService(private val context: Context) { 18 | companion object { 19 | private val Context.dataStore: DataStore by preferencesDataStore(name = "store") 20 | } 21 | 22 | fun getString(key: String): Flow { 23 | return context.dataStore.data.map { pref -> 24 | pref[stringPreferencesKey(key)] 25 | } 26 | } 27 | 28 | suspend fun saveString(key: String, value: String) { 29 | context.dataStore.edit { preferences -> 30 | preferences[stringPreferencesKey(key)] = value 31 | } 32 | } 33 | 34 | fun getVoice(): Flow { 35 | return context.dataStore.data.map { pref -> 36 | val items = pref[stringPreferencesKey(Constants.voice)]?.split("/") ?: listOf("en-us-x-tpf-local", "en", "US") 37 | Voice(items[0], Locale(items[1], items[2]), 400, 200, false, emptySet()) 38 | } 39 | } 40 | 41 | fun getTheme(): Flow { 42 | return context.dataStore.data.map { pref -> 43 | AppTheme.valueOf(pref[stringPreferencesKey(Constants.theme)] ?: "System") 44 | } 45 | } 46 | 47 | fun getDisplayStyle(): Flow { 48 | return context.dataStore.data.map { pref -> 49 | DisplayStyle.valueOf(pref[stringPreferencesKey(Constants.displayStyle)] ?: "Grid") 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/core/services/SnackbarService.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.core.services 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import kotlinx.coroutines.delay 6 | import kotlinx.coroutines.flow.MutableStateFlow 7 | import kotlinx.coroutines.flow.asStateFlow 8 | import kotlinx.coroutines.flow.update 9 | import kotlinx.coroutines.launch 10 | 11 | data class SnackBarState(val hasMessage: Boolean = false, val message: String = "") 12 | data class LoadingState(val isLoading: Boolean = false, val message: String = "") 13 | object AppStateService: ViewModel() { 14 | private val _snackBar = MutableStateFlow(SnackBarState()) 15 | private val _loader = MutableStateFlow(LoadingState()) 16 | val snackBar = _snackBar.asStateFlow() 17 | val loader = _loader.asStateFlow() 18 | 19 | fun displayMessage(message: String) = viewModelScope.launch { 20 | _snackBar.update { it.copy(hasMessage = true, message = message) } 21 | delay(4000L) 22 | _snackBar.update { it.copy(hasMessage = false, message = "") } 23 | } 24 | 25 | fun displayLoader(message: String = "loading ...") = viewModelScope.launch { 26 | _loader.update { it.copy(isLoading = true, message = message) } 27 | } 28 | 29 | fun removeLoader() = viewModelScope.launch { 30 | _loader.update { it.copy(isLoading = false, message = "") } 31 | } 32 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/core/theme/AppIcons.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.core.theme 2 | 3 | import androidx.compose.material.icons.Icons 4 | import androidx.compose.material.icons.automirrored.outlined.DriveFileMove 5 | import androidx.compose.material.icons.automirrored.outlined.InsertDriveFile 6 | import androidx.compose.material.icons.automirrored.outlined.Sort 7 | import androidx.compose.material.icons.automirrored.outlined.ViewList 8 | import androidx.compose.material.icons.automirrored.outlined.VolumeUp 9 | import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowLeft 10 | import androidx.compose.material.icons.outlined.Checklist 11 | import androidx.compose.material.icons.outlined.ColorLens 12 | import androidx.compose.material.icons.outlined.CreateNewFolder 13 | import androidx.compose.material.icons.outlined.DarkMode 14 | import androidx.compose.material.icons.outlined.Delete 15 | import androidx.compose.material.icons.outlined.DocumentScanner 16 | import androidx.compose.material.icons.outlined.DriveFileRenameOutline 17 | import androidx.compose.material.icons.outlined.Folder 18 | import androidx.compose.material.icons.outlined.GridView 19 | import androidx.compose.material.icons.outlined.Image 20 | import androidx.compose.material.icons.outlined.Info 21 | import androidx.compose.material.icons.outlined.LightMode 22 | import androidx.compose.material.icons.outlined.NewReleases 23 | import androidx.compose.material.icons.outlined.QuestionMark 24 | import androidx.compose.material.icons.outlined.RecordVoiceOver 25 | import androidx.compose.material.icons.outlined.Share 26 | import androidx.compose.material.icons.outlined.Shield 27 | import androidx.compose.material.icons.outlined.StarBorder 28 | import androidx.compose.material.icons.outlined.TextFields 29 | import androidx.compose.material.icons.rounded.CheckBox 30 | import androidx.compose.material.icons.rounded.CheckBoxOutlineBlank 31 | import androidx.compose.material.icons.rounded.FastForward 32 | import androidx.compose.material.icons.rounded.FastRewind 33 | import androidx.compose.material.icons.rounded.FlagCircle 34 | import androidx.compose.material.icons.rounded.Link 35 | import androidx.compose.material.icons.rounded.MoreVert 36 | import androidx.compose.material.icons.rounded.Pause 37 | import androidx.compose.material.icons.rounded.PlayArrow 38 | import androidx.compose.material.icons.rounded.Speed 39 | import androidx.compose.material.icons.rounded.Stop 40 | 41 | object AppIcons { 42 | val doc = Icons.AutoMirrored.Outlined.InsertDriveFile 43 | val image = Icons.Outlined.Image 44 | val scan = Icons.Outlined.DocumentScanner 45 | val text = Icons.Outlined.TextFields 46 | val link = Icons.Rounded.Link 47 | val speaker = Icons.Outlined.RecordVoiceOver 48 | val pause = Icons.Rounded.Pause 49 | val stop = Icons.Rounded.Stop 50 | val play = Icons.Rounded.PlayArrow 51 | val forward = Icons.Rounded.FastForward 52 | val rewind = Icons.Rounded.FastRewind 53 | val speechRate = Icons.Rounded.Speed 54 | val more = Icons.Rounded.MoreVert 55 | val newFolder = Icons.Outlined.CreateNewFolder 56 | val sort = Icons.AutoMirrored.Outlined.Sort 57 | val checklist = Icons.Outlined.Checklist 58 | val listView = Icons.AutoMirrored.Outlined.ViewList 59 | val gridView = Icons.Outlined.GridView 60 | val checkbox = Icons.Rounded.CheckBox 61 | val checkboxOutline = Icons.Rounded.CheckBoxOutlineBlank 62 | val folder = Icons.Outlined.Folder 63 | val delete = Icons.Outlined.Delete 64 | val folderMove = Icons.AutoMirrored.Outlined.DriveFileMove 65 | val rename = Icons.Outlined.DriveFileRenameOutline 66 | val keyLeft = Icons.AutoMirrored.Rounded.KeyboardArrowLeft 67 | val flag = Icons.Rounded.FlagCircle 68 | val theme = Icons.Outlined.ColorLens 69 | val dark = Icons.Outlined.DarkMode 70 | val light = Icons.Outlined.LightMode 71 | val star = Icons.Outlined.StarBorder 72 | val question = Icons.Outlined.QuestionMark 73 | val share = Icons.Outlined.Share 74 | val shield = Icons.Outlined.Shield 75 | val newRelease = Icons.Outlined.NewReleases 76 | val about = Icons.Outlined.Info 77 | val waveform = Icons.AutoMirrored.Outlined.VolumeUp 78 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/core/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.core.theme 2 | 3 | import android.app.Activity 4 | import com.ifeanyi.read.app.presentation.viewmodel.SettingsViewModel 5 | import com.ifeanyi.read.core.enums.AppTheme 6 | import androidx.compose.foundation.isSystemInDarkTheme 7 | import androidx.compose.material3.MaterialTheme 8 | import androidx.compose.material3.darkColorScheme 9 | import androidx.compose.material3.lightColorScheme 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.runtime.SideEffect 12 | import androidx.compose.runtime.collectAsState 13 | import androidx.compose.ui.graphics.Color 14 | import androidx.compose.ui.graphics.toArgb 15 | import androidx.compose.ui.platform.LocalView 16 | import androidx.core.view.WindowCompat 17 | import androidx.hilt.navigation.compose.hiltViewModel 18 | 19 | private val DarkColorScheme = darkColorScheme( 20 | primary = Color(0xff748bac), 21 | onPrimary = Color(0xfff8f9fc), 22 | primaryContainer = Color(0xff1b2e4b), 23 | onPrimaryContainer = Color(0xffe4e7eb), 24 | secondary = Color(0xff539eaf), 25 | onSecondary = Color(0xfff5fbfc), 26 | secondaryContainer = Color(0xff004e5d), 27 | onSecondaryContainer = Color(0xffdfecee), 28 | tertiary = Color(0xff219ab5), 29 | onTertiary = Color(0xfff1fbfd), 30 | tertiaryContainer = Color(0xff0f5b6a), 31 | onTertiaryContainer = Color(0xffe2eef0), 32 | error = Color(0xffcf6679), 33 | onError = Color(0xff140c0d), 34 | errorContainer = Color(0xffb1384e), 35 | onErrorContainer = Color(0xfffbe8ec), 36 | background = Color(0xff161718), 37 | onBackground = Color(0xffececec), 38 | surface = Color(0xff161718), 39 | onSurface = Color(0xffececec), 40 | surfaceVariant = Color(0xff383b3e), 41 | onSurfaceVariant = Color(0xffdfe0e0), 42 | outline = Color(0xff797979), 43 | outlineVariant = Color(0xff2d2d2d), 44 | scrim = Color(0xff000000), 45 | inverseSurface = Color(0xfff7f9fa), 46 | inversePrimary = Color(0xff404a58), 47 | surfaceTint = Color(0xff748bac), 48 | ) 49 | 50 | private val LightColorScheme = lightColorScheme( 51 | primary = Color(0xff223a5e), 52 | onPrimary = Color(0xffffffff), 53 | primaryContainer = Color(0xff97baea), 54 | onPrimaryContainer = Color(0xff0d1013), 55 | secondary = Color(0xff144955), 56 | onSecondary = Color(0xffffffff), 57 | secondaryContainer = Color(0xffa9edff), 58 | onSecondaryContainer = Color(0xff0e1414), 59 | tertiary = Color(0xff208399), 60 | onTertiary = Color(0xffffffff), 61 | tertiaryContainer = Color(0xffccf3ff), 62 | onTertiaryContainer = Color(0xff111414), 63 | error = Color(0xffb00020), 64 | onError = Color(0xffffffff), 65 | errorContainer = Color(0xfffcd8df), 66 | onErrorContainer = Color(0xff141213), 67 | background = Color(0xfff8f9fa), 68 | onBackground = Color(0xff090909), 69 | surface = Color(0xfff8f9fa), 70 | onSurface = Color(0xff090909), 71 | surfaceVariant = Color(0xffe2e4e6), 72 | onSurfaceVariant = Color(0xff111112), 73 | outline = Color(0xff7c7c7c), 74 | outlineVariant = Color(0xffc8c8c8), 75 | scrim = Color(0xff000000), 76 | inverseSurface = Color(0xff111213), 77 | inversePrimary = Color(0xffaabbd5), 78 | surfaceTint = Color(0xff223a5e), 79 | ) 80 | 81 | @Composable 82 | fun ReadTheme( 83 | darkTheme: Boolean = isSystemInDarkTheme(), 84 | content: @Composable () -> Unit 85 | ) { 86 | val state = hiltViewModel().state.collectAsState().value 87 | 88 | val colorScheme = when (state.theme) { 89 | AppTheme.Light -> LightColorScheme 90 | AppTheme.Dark -> DarkColorScheme 91 | AppTheme.System -> { 92 | if (darkTheme) DarkColorScheme else LightColorScheme 93 | } 94 | } 95 | 96 | val view = LocalView.current 97 | if (!view.isInEditMode) { 98 | SideEffect { 99 | val window = (view.context as Activity).window 100 | window.statusBarColor = colorScheme.background.toArgb() 101 | WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = 102 | (!darkTheme && state.theme != AppTheme.Dark) || state.theme == AppTheme.Light 103 | } 104 | } 105 | 106 | MaterialTheme( 107 | colorScheme = colorScheme, 108 | typography = Typography, 109 | content = content, 110 | ) 111 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/core/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.core.theme 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.text.googlefonts.Font 8 | import androidx.compose.ui.text.googlefonts.GoogleFont 9 | import androidx.compose.ui.unit.sp 10 | import com.ifeanyi.read.R 11 | 12 | val provider = GoogleFont.Provider( 13 | providerAuthority = "com.google.android.gms.fonts", 14 | providerPackage = "com.google.android.gms", 15 | certificates = R.array.com_google_android_gms_fonts_certs 16 | ) 17 | 18 | val font = GoogleFont("Sora") 19 | 20 | val regular = FontFamily(Font(googleFont = font, fontProvider = provider)) 21 | val semiBold = FontFamily(Font(googleFont = font, fontProvider = provider)) 22 | val bold = FontFamily(Font(googleFont = font, fontProvider = provider)) 23 | 24 | val Typography = Typography( 25 | titleLarge = TextStyle( 26 | fontFamily = bold, 27 | fontWeight = FontWeight.Bold, 28 | fontSize = 28.sp, 29 | lineHeight = 28.sp, 30 | letterSpacing = 0.5.sp 31 | ), 32 | titleMedium = TextStyle( 33 | fontFamily = semiBold, 34 | fontWeight = FontWeight.SemiBold, 35 | fontSize = 25.sp, 36 | lineHeight = 25.sp, 37 | letterSpacing = 0.5.sp 38 | ), 39 | titleSmall = TextStyle( 40 | fontFamily = semiBold, 41 | fontWeight = FontWeight.SemiBold, 42 | fontSize = 22.sp, 43 | lineHeight = 22.sp, 44 | letterSpacing = 0.5.sp 45 | ), 46 | bodyLarge = TextStyle( 47 | fontFamily = regular, 48 | fontWeight = FontWeight.Normal, 49 | fontSize = 18.sp, 50 | lineHeight = 18.sp, 51 | letterSpacing = 0.5.sp 52 | ), 53 | bodyMedium = TextStyle( 54 | fontFamily = regular, 55 | fontWeight = FontWeight.Normal, 56 | fontSize = 16.sp, 57 | lineHeight = 16.sp, 58 | letterSpacing = 0.5.sp 59 | ), 60 | bodySmall = TextStyle( 61 | fontFamily = regular, 62 | fontWeight = FontWeight.Normal, 63 | fontSize = 14.sp, 64 | lineHeight = 14.sp, 65 | letterSpacing = 0.5.sp 66 | ), 67 | labelLarge = TextStyle( 68 | fontFamily = regular, 69 | fontWeight = FontWeight.Normal, 70 | fontSize = 14.sp, 71 | lineHeight = 14.sp, 72 | letterSpacing = 0.5.sp 73 | ), 74 | labelMedium = TextStyle( 75 | fontFamily = regular, 76 | fontWeight = FontWeight.Normal, 77 | fontSize = 12.sp, 78 | lineHeight = 12.sp, 79 | letterSpacing = 0.5.sp 80 | ), 81 | labelSmall = TextStyle( 82 | fontFamily = regular, 83 | fontWeight = FontWeight.Normal, 84 | fontSize = 10.sp, 85 | lineHeight = 10.sp, 86 | letterSpacing = 0.5.sp 87 | ) 88 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/core/util/Constants.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.core.util 2 | 3 | object Constants { 4 | const val theme = "theme" 5 | const val displayStyle = "displayStyle" 6 | const val speechRate = "speechRate" 7 | const val voice = "voice" 8 | const val storeLink = "https://play.google.com/store/apps/details?id=com.ifeanyi.read" 9 | const val privacyLink = "https://read-web.web.app/#/privacy" 10 | const val termsLink = "https://read-web.web.app/#/terms" 11 | const val describeImagePrompt = 12 | "provide a detailed description of this image as if narrating it to someone who cannot see. Include details about the objects, colors, shapes, spatial relationships, and any relevant contextual information." 13 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/core/util/Extentions.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.core.util 2 | 3 | import android.app.Activity 4 | import android.content.ComponentName 5 | import android.content.Context 6 | import android.content.Intent 7 | import android.content.pm.PackageManager 8 | import android.net.Uri 9 | import android.provider.OpenableColumns 10 | import com.ifeanyi.read.BuildConfig 11 | import com.ifeanyi.read.core.enums.ActivityType 12 | import java.net.URI 13 | import java.text.SimpleDateFormat 14 | import java.time.LocalDateTime 15 | import java.util.Date 16 | import java.util.Locale 17 | 18 | 19 | val Locale.flagEmoji: String? 20 | get() { 21 | return try { 22 | val firstLetter = Character.codePointAt(country, 0) - 0x41 + 0x1F1E6 23 | val secondLetter = Character.codePointAt(country, 1) - 0x41 + 0x1F1E6 24 | String(Character.toChars(firstLetter)) + String(Character.toChars(secondLetter)) 25 | } catch (exception: Exception) { 26 | null 27 | } 28 | } 29 | val appVersion: String 30 | get() { 31 | return "${BuildConfig.VERSION_NAME}+${BuildConfig.VERSION_CODE}" 32 | } 33 | 34 | fun Context.share(text: String) { 35 | val sendIntent = Intent(Intent.ACTION_SEND).apply { 36 | type = "text/plain" 37 | putExtra(Intent.EXTRA_TEXT, text) 38 | } 39 | val shareIntent = Intent.createChooser(sendIntent, null) 40 | this.startActivity(shareIntent, null) 41 | } 42 | 43 | fun Context.mailTo(to: String, subject: String) { 44 | val selectorIntent = Intent(Intent.ACTION_SENDTO) 45 | selectorIntent.setData(Uri.parse("mailto:")) 46 | 47 | val sendIntent = Intent(Intent.ACTION_SEND).apply { 48 | putExtra(Intent.EXTRA_EMAIL, arrayOf(to)) 49 | putExtra(Intent.EXTRA_SUBJECT, subject) 50 | selector = selectorIntent 51 | } 52 | val shareIntent = Intent.createChooser(sendIntent, null) 53 | this.startActivity(shareIntent) 54 | } 55 | 56 | fun Uri.getName(context: Context): String { 57 | val returnCursor = context.contentResolver.query(this, null, null, null, null) 58 | ?: return LocalDateTime.now().toString() 59 | val nameIndex = returnCursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) 60 | returnCursor.moveToFirst() 61 | val fileName = returnCursor.getString(nameIndex) 62 | returnCursor.close() 63 | return fileName.split(".").first() 64 | } 65 | 66 | val String.trimUrl: String 67 | get() { 68 | return try { 69 | val host = URI(this).host 70 | val domain = if (host.startsWith("www.")) host.substring(4) else host 71 | return domain.split(".").first() 72 | } catch (ex: Exception) { 73 | "" 74 | } 75 | } 76 | 77 | val String.formatted: String 78 | get() { 79 | val first = this.replace(Regex("[*]"), "") 80 | return first.replace(Regex("[\\s+]"), " ") 81 | } 82 | 83 | fun Date.dwdm(locale: Locale): String { 84 | return SimpleDateFormat("E, d MMM", locale).format(this) 85 | } 86 | 87 | fun Activity.changeIcon(activityType: ActivityType) { 88 | ActivityType.entries.forEach { 89 | packageManager.setComponentEnabledSetting( 90 | ComponentName( 91 | this, 92 | "$packageName.app.${it.name}" 93 | ), 94 | if (it == activityType) PackageManager.COMPONENT_ENABLED_STATE_ENABLED 95 | else PackageManager.COMPONENT_ENABLED_STATE_DISABLED, 96 | PackageManager.DONT_KILL_APP 97 | ) 98 | } 99 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ifeanyi/read/core/util/RoomConverters.kt: -------------------------------------------------------------------------------- 1 | package com.ifeanyi.read.core.util 2 | 3 | import com.ifeanyi.read.app.data.models.NewFeature 4 | import androidx.room.TypeConverter 5 | import com.google.gson.Gson 6 | import com.google.gson.reflect.TypeToken 7 | import java.util.Date 8 | 9 | object RoomConverters { 10 | private val gson = Gson() 11 | 12 | @TypeConverter 13 | fun featuresFromJson(json: String): List { 14 | val type = object : TypeToken>() {}.type 15 | return gson.fromJson(json, type) 16 | } 17 | 18 | @TypeConverter 19 | fun featuresToJson(features: List): String { 20 | return gson.toJson(features) 21 | } 22 | 23 | @TypeConverter 24 | fun rangeFromJson(json: String): IntRange { 25 | val type = object : TypeToken() {}.type 26 | return gson.fromJson(json, type) 27 | } 28 | 29 | @TypeConverter 30 | fun rangeToJson(range: IntRange): String { 31 | return gson.toJson(range) 32 | } 33 | 34 | @TypeConverter 35 | fun dateFromTimeStamp(timestamp: Long?): Date? { 36 | return if (timestamp == null) null else Date(timestamp) 37 | } 38 | 39 | @TypeConverter 40 | fun dateToTimestamp(date: Date?): Long? { 41 | return date?.time 42 | } 43 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 10 | 12 | 14 | 16 | 18 | 20 | 22 | 24 | 26 | 28 | 30 | 32 | 34 | 36 | 38 | 40 | 42 | 44 | 46 | 48 | 50 | 52 | 54 | 56 | 58 | 60 | 62 | 64 | 66 | 68 | 70 | 72 | 74 | 75 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_purple_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 10 | 12 | 14 | 16 | 18 | 20 | 22 | 24 | 26 | 28 | 30 | 32 | 34 | 36 | 38 | 40 | 42 | 44 | 46 | 48 | 50 | 52 | 54 | 56 | 58 | 60 | 62 | 64 | 66 | 68 | 70 | 72 | 74 | 75 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_red_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 10 | 12 | 14 | 16 | 18 | 20 | 22 | 24 | 26 | 28 | 30 | 32 | 34 | 36 | 38 | 40 | 42 | 44 | 46 | 48 | 50 | 52 | 54 | 56 | 58 | 60 | 62 | 64 | 66 | 68 | 70 | 72 | 74 | 75 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_white_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 10 | 12 | 14 | 16 | 18 | 20 | 22 | 24 | 26 | 28 | 30 | 32 | 34 | 36 | 38 | 40 | 42 | 44 | 46 | 48 | 50 | 52 | 54 | 56 | 58 | 60 | 62 | 64 | 66 | 68 | 70 | 72 | 74 | 75 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/purple_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/drawable/purple_logo.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/red_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/drawable/red_logo.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/round_forward_10_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/round_pause_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/round_play_arrow_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/round_record_voice_over_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/round_replay_10_24.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/white_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/drawable/white_logo.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_purple.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_purple_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_red.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_red_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_white.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_white_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_purple.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-hdpi/ic_launcher_purple.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_purple_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-hdpi/ic_launcher_purple_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_purple_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-hdpi/ic_launcher_purple_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_red.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-hdpi/ic_launcher_red.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_red_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-hdpi/ic_launcher_red_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_red_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-hdpi/ic_launcher_red_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_white.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-hdpi/ic_launcher_white.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_white_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-hdpi/ic_launcher_white_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_white_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-hdpi/ic_launcher_white_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_purple.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-mdpi/ic_launcher_purple.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_purple_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-mdpi/ic_launcher_purple_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_purple_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-mdpi/ic_launcher_purple_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_red.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-mdpi/ic_launcher_red.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_red_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-mdpi/ic_launcher_red_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_red_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-mdpi/ic_launcher_red_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_white.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-mdpi/ic_launcher_white.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_white_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-mdpi/ic_launcher_white_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_white_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-mdpi/ic_launcher_white_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_purple.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-xhdpi/ic_launcher_purple.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_purple_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-xhdpi/ic_launcher_purple_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_purple_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-xhdpi/ic_launcher_purple_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_red.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-xhdpi/ic_launcher_red.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_red_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-xhdpi/ic_launcher_red_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_red_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-xhdpi/ic_launcher_red_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_white.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-xhdpi/ic_launcher_white.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_white_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-xhdpi/ic_launcher_white_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_white_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-xhdpi/ic_launcher_white_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_purple.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-xxhdpi/ic_launcher_purple.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_purple_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-xxhdpi/ic_launcher_purple_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_purple_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-xxhdpi/ic_launcher_purple_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_red.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-xxhdpi/ic_launcher_red.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_red_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-xxhdpi/ic_launcher_red_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_red_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-xxhdpi/ic_launcher_red_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_white.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-xxhdpi/ic_launcher_white.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_white_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-xxhdpi/ic_launcher_white_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_white_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-xxhdpi/ic_launcher_white_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_purple.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-xxxhdpi/ic_launcher_purple.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_purple_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-xxxhdpi/ic_launcher_purple_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_purple_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-xxxhdpi/ic_launcher_purple_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_red.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-xxxhdpi/ic_launcher_red.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_red_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-xxxhdpi/ic_launcher_red_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_red_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-xxxhdpi/ic_launcher_red_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_white.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-xxxhdpi/ic_launcher_white.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_white_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-xxxhdpi/ic_launcher_white_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_white_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/o-ifeanyi/read_kotlin/cb173da3f95827138be88113206fd92abaf5a707/app/src/main/res/mipmap-xxxhdpi/ic_launcher_white_round.webp -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/font_certs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | @array/com_google_android_gms_fonts_certs_dev 6 | @array/com_google_android_gms_fonts_certs_prod 7 | 8 | 9 | 10 | MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTAeFw0wODA0MTUyMzM2NTZaFw0zNTA5MDEyMzM2NTZaMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBANbOLggKv+IxTdGNs8/TGFy0PTP6DHThvbbR24kT9ixcOd9W+EaBPWW+wPPKQmsHxajtWjmQwWfna8mZuSeJS48LIgAZlKkpFeVyxW0qMBujb8X8ETrWy550NaFtI6t9+u7hZeTfHwqNvacKhp1RbE6dBRGWynwMVX8XW8N1+UjFaq6GCJukT4qmpN2afb8sCjUigq0GuMwYXrFVee74bQgLHWGJwPmvmLHC69EH6kWr22ijx4OKXlSIx2xT1AsSHee70w5iDBiK4aph27yH3TxkXy9V89TDdexAcKk/cVHYNnDBapcavl7y0RiQ4biu8ymM8Ga/nmzhRKya6G0cGw8CAQOjgfwwgfkwHQYDVR0OBBYEFI0cxb6VTEM8YYY6FbBMvAPyT+CyMIHJBgNVHSMEgcEwgb6AFI0cxb6VTEM8YYY6FbBMvAPyT+CyoYGapIGXMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbYIJANWFuGx90071MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBABnTDPEF+3iSP0wNfdIjIz1AlnrPzgAIHVvXxunW7SBrDhEglQZBbKJEk5kT0mtKoOD1JMrSu1xuTKEBahWRbqHsXclaXjoBADb0kkjVEJu/Lh5hgYZnOjvlba8Ld7HCKePCVePoTJBdI4fvugnL8TsgK05aIskyY0hKI9L8KfqfGTl1lzOv2KoWD0KWwtAWPoGChZxmQ+nBli+gwYMzM1vAkP+aayLe0a1EQimlOalO762r0GXO0ks+UeXde2Z4e+8S/pf7pITEI/tP+MxJTALw9QUWEv9lKTk+jkbqxbsh8nfBUapfKqYn0eidpwq2AzVp3juYl7//fKnaPhJD9gs= 11 | 12 | 13 | 14 | 15 | MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBAKtWLgDYO6IIrgqWbxJOKdoR8qtW0I9Y4sypEwPpt1TTcvZApxsdyxMJZ2JORland2qSGT2y5b+3JKkedxiLDmpHpDsz2WCbdxgxRczfey5YZnTJ4VZbH0xqWVW/8lGmPav5xVwnIiJS6HXk+BVKZF+JcWjAsb/GEuq/eFdpuzSqeYTcfi6idkyugwfYwXFU1+5fZKUaRKYCwkkFQVfcAs1fXA5V+++FGfvjJ/CxURaSxaBvGdGDhfXE28LWuT9ozCl5xw4Yq5OGazvV24mZVSoOO0yZ31j7kYvtwYK6NeADwbSxDdJEqO4k//0zOHKrUiGYXtqw/A0LFFtqoZKFjnkCAQOjgdkwgdYwHQYDVR0OBBYEFMd9jMIhF1Ylmn/Tgt9r45jk14alMIGmBgNVHSMEgZ4wgZuAFMd9jMIhF1Ylmn/Tgt9r45jk14aloXikdjB0MQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEUMBIGA1UEChMLR29vZ2xlIEluYy4xEDAOBgNVBAsTB0FuZHJvaWQxEDAOBgNVBAMTB0FuZHJvaWSCCQDC4IdGZEowjTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBAUAA4IBAQBt0lLO74UwLDYKqs6Tm8/yzKkEu116FmH4rkaymUIE0P9KaMftGlMexFlaYjzmB2OxZyl6euNXEsQH8gjwyxCUKRJNexBiGcCEyj6z+a1fuHHvkiaai+KL8W1EyNmgjmyy8AW7P+LLlkR+ho5zEHatRbM/YAnqGcFh5iZBqpknHf1SKMXFh4dd239FJ1jWYfbMDMy3NS5CTMQ2XFI1MvcyUTdZPErjQfTbQe3aDQsQcafEQPD+nqActifKZ0Np0IS9L9kR/wbNvyz6ENwPiTrjV2KRkEjH78ZMcUQXg0L3BYHJ3lc69Vs5Ddf9uUGGMYldX3WfMBEmh/9iFBDAaTCK 16 | 17 | 18 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Read 3 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |