├── .gitignore ├── .idea ├── .name ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── compiler.xml ├── inspectionProfiles │ └── Project_Default.xml ├── jarRepositories.xml └── misc.xml ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── debug │ └── output.json ├── proguard-rules.pro └── src │ ├── main │ ├── AndroidManifest.xml │ ├── ic_launcher-playstore.png │ ├── ic_launcher-web.png │ ├── java │ │ └── com │ │ │ └── ryccoatika │ │ │ └── imagetotext │ │ │ ├── AppNavigation.kt │ │ │ ├── MyApplication.kt │ │ │ ├── di │ │ │ ├── AppBindsModule.kt │ │ │ └── AppModule.kt │ │ │ ├── main │ │ │ ├── MainActivity.kt │ │ │ ├── MainViewModel.kt │ │ │ └── MainViewState.kt │ │ │ └── utils │ │ │ └── ComposeFileProviderImpl.kt │ └── res │ │ ├── drawable │ │ ├── background_splash.xml │ │ ├── decoration.png │ │ ├── ic_launcher_foreground.xml │ │ └── image_to_text.png │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── values-v31 │ │ └── styles.xml │ │ ├── values │ │ ├── colors.xml │ │ ├── ic_launcher_background.xml │ │ ├── strings.xml │ │ └── styles.xml │ │ └── xml │ │ └── filepaths.xml │ └── qaDebug │ └── res │ └── values │ └── strings.xml ├── build.gradle.kts ├── core ├── build.gradle.kts └── src │ └── main │ └── java │ └── com │ └── ryccoatika │ └── imagetotext │ └── core │ ├── data │ ├── TextRecognitionRepositoryImpl.kt │ ├── TextScannedRepositoryImpl.kt │ └── local │ │ ├── AppPreferences.kt │ │ ├── LocalDataSource.kt │ │ ├── entity │ │ └── TextScannedEntity.kt │ │ └── room │ │ ├── AppDatabase.kt │ │ ├── TextScannedDao.kt │ │ └── typeconverter │ │ ├── BitmapTypeConverter.kt │ │ └── TextRecognizedTypeConverter.kt │ ├── di │ ├── DatabaseModule.kt │ └── RepositoryBindsModule.kt │ └── utils │ └── DataMapper.kt ├── design ├── app_icon.png ├── banner.png ├── screenshot_1.png ├── screenshot_2.png ├── screenshot_3.png ├── screenshot_4.png └── screenshot_5.png ├── domain ├── build.gradle.kts └── src │ └── main │ ├── AndroidManifest.xml │ └── java │ └── com │ └── ryccoatika │ └── imagetotext │ └── domain │ ├── exceptions │ ├── ImageBroken.kt │ ├── TextScanFailure.kt │ └── TextScanNotFound.kt │ ├── model │ ├── InvokeStatus.kt │ ├── RecognationLanguageModel.kt │ ├── TextRecognized.kt │ └── TextScanned.kt │ ├── repository │ ├── TextRecognitionRepository.kt │ └── TextScannedRepository.kt │ ├── usecase │ ├── GetIsUserFirstTime.kt │ ├── GetTextFromImage.kt │ ├── GetTextScanned.kt │ ├── ObserveTextScanned.kt │ ├── RemoveTextScanned.kt │ ├── SaveTextScanned.kt │ └── SetUserFirstTime.kt │ └── utils │ ├── AppCoroutineDispatchers.kt │ ├── CombineExts.kt │ ├── ComposeFileProvider.kt │ ├── Interactor.kt │ ├── ObservableLoadingCounter.kt │ └── UiMessageManager.kt ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── release └── debug.keystore ├── settings.gradle.kts └── ui ├── build.gradle.kts └── src └── main ├── java └── com │ └── ryccoatika │ └── imagetotext │ └── ui │ ├── common │ ├── theme │ │ ├── Spacing.kt │ │ └── Theme.kt │ ├── ui │ │ ├── AppSearchTextInput.kt │ │ ├── AppTextInput.kt │ │ ├── AppTopBar.kt │ │ ├── DotIndicator.kt │ │ ├── FabImagePicker.kt │ │ ├── ScannedTextCard.kt │ │ ├── TextHighlightBlock.kt │ │ └── TextHighlightBlockSelected.kt │ └── utils │ │ ├── FlowWithLifecycle.kt │ │ └── StringUtils.kt │ ├── convertresult │ ├── ImageConvertResult.kt │ ├── ImageConvertResultViewModel.kt │ └── ImageConvertResultViewState.kt │ ├── home │ ├── Home.kt │ ├── HomeViewModel.kt │ └── HomeViewState.kt │ ├── imagepreview │ ├── ImagePreview.kt │ ├── ImagePreviewViewModel.kt │ └── ImagePreviewViewState.kt │ ├── intro │ └── Intro.kt │ └── utils │ └── ReviewHelper.kt └── res ├── drawable ├── decoration_empty.png ├── intro_1.png ├── intro_2.png └── intro_3.png └── values └── strings.xml /.gitignore: -------------------------------------------------------------------------------- 1 | <<<<<<< HEAD 2 | # Built application files 3 | *.apk 4 | *.aar 5 | *.ap_ 6 | *.aab 7 | 8 | # Files for the ART/Dalvik VM 9 | *.dex 10 | 11 | # Java class files 12 | *.class 13 | 14 | # Generated files 15 | bin/ 16 | gen/ 17 | out/ 18 | # Uncomment the following line in case you need and you don't have the release build type files in your app 19 | # release/ 20 | 21 | # Gradle files 22 | .gradle/ 23 | build/ 24 | 25 | # Local configuration file (sdk path, etc) 26 | local.properties 27 | 28 | # Proguard folder generated by Eclipse 29 | proguard/ 30 | 31 | # Log Files 32 | *.log 33 | 34 | # Android Studio Navigation editor temp files 35 | .navigation/ 36 | 37 | # Android Studio captures folder 38 | captures/ 39 | 40 | # IntelliJ 41 | *.iml 42 | .idea/workspace.xml 43 | .idea/tasks.xml 44 | .idea/gradle.xml 45 | .idea/assetWizardSettings.xml 46 | .idea/dictionaries 47 | .idea/libraries 48 | # Android Studio 3 in .gitignore file. 49 | .idea/caches 50 | .idea/modules.xml 51 | # Comment next line if keeping position of elements in Navigation Editor is relevant for you 52 | .idea/navEditor.xml 53 | 54 | # Keystore files 55 | # Uncomment the following lines if you do not want to check your keystore files in. 56 | *.jks 57 | *.keystore 58 | 59 | # External native build folder generated in Android Studio 2.2 and later 60 | .externalNativeBuild 61 | .cxx/ 62 | 63 | # Google Services (e.g. APIs or Firebase) 64 | google-services.json 65 | 66 | # Freeline 67 | freeline.py 68 | freeline/ 69 | freeline_project_description.json 70 | 71 | # fastlane 72 | fastlane/report.xml 73 | fastlane/Preview.html 74 | fastlane/screenshots 75 | fastlane/test_output 76 | fastlane/readme.md 77 | 78 | # Version control 79 | vcs.xml 80 | 81 | # lint 82 | lint/intermediates/ 83 | lint/generated/ 84 | lint/outputs/ 85 | lint/tmp/ 86 | # lint/reports/ 87 | ======= 88 | *.iml 89 | .gradle 90 | /local.properties 91 | /.idea/caches 92 | /.idea/libraries 93 | /.idea/modules.xml 94 | /.idea/workspace.xml 95 | /.idea/navEditor.xml 96 | /.idea/assetWizardSettings.xml 97 | .DS_Store 98 | /build 99 | /captures 100 | .externalNativeBuild 101 | .cxx 102 | >>>>>>> bdb9943... init commit 103 | -------------------------------------------------------------------------------- /.idea/.name: -------------------------------------------------------------------------------- 1 | Image To Text -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 37 | -------------------------------------------------------------------------------- /.idea/jarRepositories.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 14 | 15 | 19 | 20 | 24 | 25 | 29 | 30 | 34 | 35 | 39 | 40 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 10 | 11 | 12 | 13 | 14 | 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [Image To Text](https://play.google.com/store/apps/details?id=com.ryccoatika.imagetotext) 2 | 3 | 4 | 5 | Scan text from images and photos for easy copy and paste. 6 | 7 | * Convert an Image To Text 8 | * Recognizes all latin characters 9 | * Only camera and storage permission needed 10 | * You could crop the image 11 | * Works Offline 12 | * Quick scan 13 | * Edit scanned text 14 | * Copy scanned text into clipboard for use in other apps 15 | * Free and without ads 16 | 17 | Things you should pay attention to: 18 | 19 | * For best results you need to take a clear image 20 | * This app can't recognise peculiar handwritings 21 | 22 | # Screenshots 23 | 24 | 25 | 26 | # Download 27 | 28 | 29 | Get it on Google Play 30 | 31 | # Build 32 | 33 | To build the app, you will need to get a `google-services.json` file from Firebase 34 | 35 | # Developer 36 | 37 | * [Rycco Atika](https://ryccoatika.github.io) 38 | * [Linkedin](https://www.linkedin.com/in/ryccoatika) 39 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | # Built application files 2 | *.apk 3 | *.aar 4 | *.ap_ 5 | *.aab 6 | 7 | # Files for the ART/Dalvik VM 8 | *.dex 9 | 10 | # Java class files 11 | *.class 12 | 13 | # Generated files 14 | bin/ 15 | gen/ 16 | out/ 17 | # Uncomment the following line in case you need and you don't have the release build type files in your app 18 | # release/ 19 | 20 | # Gradle files 21 | .gradle/ 22 | build/ 23 | 24 | # Local configuration file (sdk path, etc) 25 | local.properties 26 | 27 | # Proguard folder generated by Eclipse 28 | proguard/ 29 | 30 | # Log Files 31 | *.log 32 | 33 | # Android Studio Navigation editor temp files 34 | .navigation/ 35 | 36 | # Android Studio captures folder 37 | captures/ 38 | 39 | # IntelliJ 40 | *.iml 41 | .idea/workspace.xml 42 | .idea/tasks.xml 43 | .idea/gradle.xml 44 | .idea/assetWizardSettings.xml 45 | .idea/dictionaries 46 | .idea/libraries 47 | # Android Studio 3 in .gitignore file. 48 | .idea/caches 49 | .idea/modules.xml 50 | # Comment next line if keeping position of elements in Navigation Editor is relevant for you 51 | .idea/navEditor.xml 52 | 53 | # Keystore files 54 | # Uncomment the following lines if you do not want to check your keystore files in. 55 | #*.jks 56 | #*.keystore 57 | 58 | # External native build folder generated in Android Studio 2.2 and later 59 | .externalNativeBuild 60 | .cxx/ 61 | 62 | # Google Services (e.g. APIs or Firebase) 63 | # google-services.json 64 | 65 | # Freeline 66 | freeline.py 67 | freeline/ 68 | freeline_project_description.json 69 | 70 | # fastlane 71 | fastlane/report.xml 72 | fastlane/Preview.html 73 | fastlane/screenshots 74 | fastlane/test_output 75 | fastlane/readme.md 76 | 77 | # Version control 78 | vcs.xml 79 | 80 | # lint 81 | lint/intermediates/ 82 | lint/generated/ 83 | lint/outputs/ 84 | lint/tmp/ 85 | # lint/reports/ -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.application") 3 | id("com.google.gms.google-services") 4 | id("com.google.firebase.crashlytics") 5 | id("com.google.dagger.hilt.android") 6 | kotlin("android") 7 | kotlin("kapt") 8 | } 9 | 10 | hilt { 11 | enableAggregatingTask = true 12 | } 13 | 14 | val appVersionCode: Int = project.properties["VERSION_CODE"] as? Int? ?: 10 15 | println("APK version code: $appVersionCode") 16 | 17 | val isKeystoreReleaseExists = rootProject.file("release/release.jks").exists() 18 | 19 | android { 20 | namespace = "com.ryccoatika.imagetotext" 21 | signingConfigs { 22 | getByName("debug") { 23 | storeFile = rootProject.file("release/debug.keystore") 24 | storePassword = "android" 25 | keyAlias = "androiddebugkey" 26 | keyPassword = "android" 27 | } 28 | 29 | create("release") { 30 | if (isKeystoreReleaseExists) { 31 | storeFile = rootProject.file("release/release.jks") 32 | storePassword = project.properties["RELEASE_KEYSTORE_PWD"] as? String 33 | keyAlias = "imagetotext" 34 | keyPassword = project.properties["RELEASE_KEY_PWD"] as? String 35 | } 36 | } 37 | } 38 | compileSdk = 33 39 | 40 | defaultConfig { 41 | applicationId = "com.ryccoatika.imagetotext" 42 | minSdk = 23 43 | targetSdk = 33 44 | versionCode = appVersionCode 45 | versionName = "2.0.0" 46 | 47 | testInstrumentationRunner = "androidx.intro_1.runner.AndroidJUnitRunner" 48 | } 49 | buildTypes { 50 | getByName("release") { 51 | isMinifyEnabled = true 52 | isShrinkResources = true 53 | proguardFiles( 54 | getDefaultProguardFile("proguard-android-optimize.txt"), 55 | "proguard-rules.pro" 56 | ) 57 | } 58 | getByName("debug") { 59 | applicationIdSuffix = ".debug" 60 | proguardFiles( 61 | getDefaultProguardFile("proguard-android-optimize.txt"), 62 | "proguard-rules.pro" 63 | ) 64 | } 65 | } 66 | 67 | flavorDimensions += "mode" 68 | productFlavors { 69 | create("qa") { 70 | signingConfig = signingConfigs.getByName("debug") 71 | 72 | dimension = "mode" 73 | versionNameSuffix = "-qa" 74 | } 75 | 76 | create("standard") { 77 | signingConfig = if (isKeystoreReleaseExists) { 78 | signingConfigs.getByName("release") 79 | } else { 80 | signingConfigs.getByName("debug") 81 | } 82 | 83 | dimension = "mode" 84 | } 85 | } 86 | 87 | compileOptions { 88 | sourceCompatibility(JavaVersion.VERSION_11) 89 | targetCompatibility(JavaVersion.VERSION_11) 90 | } 91 | 92 | buildFeatures { 93 | compose = true 94 | } 95 | 96 | composeOptions { 97 | kotlinCompilerExtensionVersion = libs.versions.composecompiler.get() 98 | } 99 | } 100 | 101 | // Disable variant standard debug 102 | androidComponents { 103 | beforeVariants { variantBuilder -> 104 | val isQa = variantBuilder.productFlavors.any { it.first.contains("qa") || it.second.contains("qa") } 105 | val isDebug = variantBuilder.buildType == "debug" 106 | if (!isQa && isDebug) { 107 | variantBuilder.enable = false 108 | } 109 | } 110 | } 111 | 112 | dependencies { 113 | implementation(project(":core")) 114 | implementation(project(":domain")) 115 | implementation(project(":ui")) 116 | 117 | implementation(libs.accompanist.navigation.animation) 118 | implementation(libs.accompanist.navigation.material) 119 | implementation(libs.accompanist.systemuicontroller) 120 | 121 | implementation(libs.activity.compose) 122 | 123 | debugImplementation(libs.compose.ui.tooling) 124 | implementation(libs.compose.ui.tooling.preview) 125 | 126 | implementation(libs.compose.material) 127 | 128 | implementation(libs.splashscreen) 129 | 130 | implementation(libs.hilt.android) 131 | kapt(libs.hilt.android.compiler) 132 | 133 | implementation(libs.google.firebase.core) 134 | implementation(libs.google.firebase.analytics) 135 | 136 | debugImplementation(libs.leakcanary) 137 | } 138 | -------------------------------------------------------------------------------- /app/debug/output.json: -------------------------------------------------------------------------------- 1 | [{"outputType":{"type":"APK"},"apkData":{"type":"MAIN","splits":[],"versionCode":1,"versionName":"1.0","enabled":true,"outputFile":"app-debug.apk","fullName":"debug","baseName":"debug"},"path":"app-debug.apk","properties":{}}] -------------------------------------------------------------------------------- /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.kts. 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 22 | 23 | # https://github.com/ArthurHub/Android-Image-Cropper 24 | #noinspection ShrinkerUnresolvedReference 25 | -keep class androidx.appcompat.widget.** { *; } 26 | 27 | # Please add these rules to your existing keep rules in order to suppress warnings. 28 | # This is generated automatically by the Android Gradle plugin. 29 | -dontwarn org.bouncycastle.jsse.BCSSLParameters 30 | -dontwarn org.bouncycastle.jsse.BCSSLSocket 31 | -dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider 32 | -dontwarn org.conscrypt.Conscrypt$Version 33 | -dontwarn org.conscrypt.Conscrypt 34 | -dontwarn org.conscrypt.ConscryptHostnameVerifier 35 | -dontwarn org.openjsse.javax.net.ssl.SSLParameters 36 | -dontwarn org.openjsse.javax.net.ssl.SSLSocket 37 | -dontwarn org.openjsse.net.ssl.OpenJSSE 38 | 39 | -keep class com.ryccoatika.imagetotext.domain.model.** { *; } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 21 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 46 | 48 | 49 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryccoatika/Image-To-Text/2a5c376837d332a99718a1cc32effb39b8d8cf53/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/ic_launcher-web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryccoatika/Image-To-Text/2a5c376837d332a99718a1cc32effb39b8d8cf53/app/src/main/ic_launcher-web.png -------------------------------------------------------------------------------- /app/src/main/java/com/ryccoatika/imagetotext/AppNavigation.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext 2 | 3 | import android.net.Uri 4 | import androidx.compose.animation.ExperimentalAnimationApi 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Modifier 7 | import androidx.navigation.* 8 | import com.google.accompanist.navigation.animation.AnimatedNavHost 9 | import com.google.accompanist.navigation.animation.composable 10 | import com.ryccoatika.imagetotext.ui.convertresult.ImageConvertResult 11 | import com.ryccoatika.imagetotext.ui.home.Home 12 | import com.ryccoatika.imagetotext.ui.intro.Intro 13 | import com.ryccoatika.imagetotext.ui.imagepreview.ImagePreview 14 | 15 | internal sealed class Screen(val route: String) { 16 | object Splash : Screen("splash") 17 | object Home : Screen("home") 18 | } 19 | 20 | internal sealed class LeafScreen( 21 | private val route: String 22 | ) { 23 | fun createRoute(root: Screen) = "${root.route}/$route" 24 | 25 | object SplashScreen : LeafScreen("splash") 26 | object IntroScreen : LeafScreen("intro") 27 | object HomeScreen : LeafScreen("home") 28 | 29 | object ImageConvertResultScreen : LeafScreen("imageconvertresult/{id}") { 30 | fun createRoute( 31 | root: Screen, 32 | id: Long 33 | ): String = "${root.route}/imageconvertresult/$id" 34 | } 35 | 36 | object ImagePreview : LeafScreen("languagemodelselector/{uri}") { 37 | fun createRoute( 38 | root: Screen, 39 | uri: Uri? 40 | ): String = "${root.route}/languagemodelselector/${uri?.let { Uri.encode(it.toString()) }}" 41 | } 42 | } 43 | 44 | @OptIn(ExperimentalAnimationApi::class) 45 | @Composable 46 | internal fun AppNavigation( 47 | navController: NavHostController, 48 | modifier: Modifier = Modifier 49 | ) { 50 | AnimatedNavHost( 51 | navController = navController, 52 | startDestination = Screen.Home.route, 53 | modifier = modifier 54 | ) { 55 | addSplashTopLevel(navController) 56 | addHomeTopLevel(navController) 57 | } 58 | } 59 | 60 | private fun NavGraphBuilder.addSplashTopLevel( 61 | navController: NavController 62 | ) { 63 | navigation( 64 | route = Screen.Splash.route, 65 | startDestination = LeafScreen.SplashScreen.createRoute(Screen.Splash) 66 | ) { 67 | addSplashScreen(Screen.Splash) 68 | addIntroScreen(Screen.Splash, navController) 69 | } 70 | } 71 | 72 | private fun NavGraphBuilder.addHomeTopLevel( 73 | navController: NavController 74 | ) { 75 | navigation( 76 | route = Screen.Home.route, 77 | startDestination = LeafScreen.HomeScreen.createRoute(Screen.Home) 78 | ) { 79 | addHomeScreen(Screen.Home, navController) 80 | addImagePreview(Screen.Home, navController) 81 | addImageConvertResultScreen(Screen.Home, navController) 82 | } 83 | } 84 | 85 | @OptIn(ExperimentalAnimationApi::class) 86 | private fun NavGraphBuilder.addSplashScreen( 87 | root: Screen 88 | ) { 89 | composable( 90 | route = LeafScreen.SplashScreen.createRoute(root), 91 | ) {} 92 | } 93 | 94 | @OptIn(ExperimentalAnimationApi::class) 95 | private fun NavGraphBuilder.addIntroScreen( 96 | root: Screen, 97 | navController: NavController 98 | ) { 99 | composable( 100 | route = LeafScreen.IntroScreen.createRoute(root), 101 | ) { 102 | Intro( 103 | openHomeScreen = { 104 | navController.navigate(Screen.Home.route) { 105 | launchSingleTop = true 106 | 107 | popUpTo(navController.graph.id) { 108 | inclusive = true 109 | } 110 | } 111 | } 112 | ) 113 | } 114 | } 115 | 116 | @OptIn(ExperimentalAnimationApi::class) 117 | private fun NavGraphBuilder.addHomeScreen( 118 | root: Screen, 119 | navController: NavController 120 | ) { 121 | composable( 122 | route = LeafScreen.HomeScreen.createRoute(root), 123 | ) { 124 | Home( 125 | openImageResultScreen = { 126 | navController.navigate(LeafScreen.ImageConvertResultScreen.createRoute(root, it)) { 127 | launchSingleTop = true 128 | } 129 | }, 130 | openImagePreviewScreen = { 131 | navController.navigate(LeafScreen.ImagePreview.createRoute(root, it)) { 132 | launchSingleTop = true 133 | } 134 | }, 135 | ) 136 | } 137 | } 138 | 139 | @OptIn(ExperimentalAnimationApi::class) 140 | private fun NavGraphBuilder.addImageConvertResultScreen( 141 | root: Screen, 142 | navController: NavController 143 | ) { 144 | composable( 145 | route = LeafScreen.ImageConvertResultScreen.createRoute(root), 146 | arguments = listOf( 147 | navArgument("id") { 148 | type = NavType.LongType 149 | } 150 | ) 151 | ) { 152 | ImageConvertResult( 153 | navigateBack = navController::navigateUp 154 | ) 155 | } 156 | } 157 | 158 | @OptIn(ExperimentalAnimationApi::class) 159 | private fun NavGraphBuilder.addImagePreview( 160 | root: Screen, 161 | navController: NavController 162 | ) { 163 | composable( 164 | route = LeafScreen.ImagePreview.createRoute(root), 165 | arguments = listOf( 166 | navArgument("uri") { 167 | type = NavType.StringType 168 | } 169 | ) 170 | ) { 171 | ImagePreview( 172 | openImageResultScreen = { 173 | navController.navigate(LeafScreen.ImageConvertResultScreen.createRoute(root, it)) { 174 | launchSingleTop = true 175 | 176 | 177 | popUpTo(LeafScreen.ImagePreview.createRoute(root)) { 178 | inclusive = true 179 | } 180 | } 181 | }, 182 | navigateUp = navController::navigateUp 183 | ) 184 | } 185 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ryccoatika/imagetotext/MyApplication.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext 2 | 3 | import android.app.Application 4 | import dagger.hilt.android.HiltAndroidApp 5 | 6 | @HiltAndroidApp 7 | class MyApplication : Application() -------------------------------------------------------------------------------- /app/src/main/java/com/ryccoatika/imagetotext/di/AppBindsModule.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.di 2 | 3 | import com.ryccoatika.imagetotext.domain.utils.ComposeFileProvider 4 | import com.ryccoatika.imagetotext.utils.ComposeFileProviderImpl 5 | import dagger.Binds 6 | import dagger.Module 7 | import dagger.hilt.InstallIn 8 | import dagger.hilt.components.SingletonComponent 9 | 10 | @InstallIn(SingletonComponent::class) 11 | @Module 12 | abstract class AppBindsModule { 13 | @Binds 14 | abstract fun provideComposeFileProvider(composeFileProviderImpl: ComposeFileProviderImpl): ComposeFileProvider 15 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ryccoatika/imagetotext/di/AppModule.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.di 2 | 3 | import com.ryccoatika.imagetotext.domain.utils.AppCoroutineDispatchers 4 | import dagger.Module 5 | import dagger.Provides 6 | import dagger.hilt.InstallIn 7 | import dagger.hilt.components.SingletonComponent 8 | import kotlinx.coroutines.Dispatchers 9 | import javax.inject.Singleton 10 | 11 | @InstallIn(SingletonComponent::class) 12 | @Module 13 | object AppModule { 14 | @Provides 15 | @Singleton 16 | fun provideAppCoroutineDispatchers(): AppCoroutineDispatchers = AppCoroutineDispatchers( 17 | io = Dispatchers.IO, 18 | computation = Dispatchers.Default, 19 | main = Dispatchers.Main 20 | ) 21 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ryccoatika/imagetotext/main/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.main 2 | 3 | import android.content.Intent 4 | import android.net.Uri 5 | import android.os.Build 6 | import android.os.Bundle 7 | import androidx.activity.ComponentActivity 8 | import androidx.activity.compose.setContent 9 | import androidx.compose.animation.ExperimentalAnimationApi 10 | import androidx.compose.foundation.layout.fillMaxSize 11 | import androidx.compose.runtime.* 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.graphics.Color 14 | import androidx.core.splashscreen.SplashScreen 15 | import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen 16 | import androidx.core.util.Consumer 17 | import androidx.core.view.WindowCompat 18 | import androidx.lifecycle.ViewModelProvider 19 | import androidx.navigation.NavController 20 | import androidx.navigation.NavHostController 21 | import com.google.accompanist.navigation.animation.rememberAnimatedNavController 22 | import com.google.accompanist.systemuicontroller.rememberSystemUiController 23 | import com.ryccoatika.imagetotext.AppNavigation 24 | import com.ryccoatika.imagetotext.LeafScreen 25 | import com.ryccoatika.imagetotext.Screen 26 | import com.ryccoatika.imagetotext.ui.common.theme.AppTheme 27 | import com.ryccoatika.imagetotext.ui.common.utils.rememberStateWithLifecycle 28 | import dagger.hilt.android.AndroidEntryPoint 29 | 30 | @AndroidEntryPoint 31 | class MainActivity : ComponentActivity() { 32 | private lateinit var viewModel: MainViewModel 33 | private var splashScreen: SplashScreen? = null 34 | 35 | private var intentListener: Consumer? = null 36 | 37 | @OptIn(ExperimentalAnimationApi::class) 38 | override fun onCreate(savedInstanceState: Bundle?) { 39 | super.onCreate(savedInstanceState) 40 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { 41 | splashScreen = installSplashScreen() 42 | splashScreen?.setKeepOnScreenCondition { true } 43 | } 44 | 45 | WindowCompat.setDecorFitsSystemWindows(window, false) 46 | 47 | viewModel = ViewModelProvider(this)[MainViewModel::class.java] 48 | 49 | setContent { 50 | val navHostController = rememberAnimatedNavController() 51 | MainContent(navHostController) 52 | DisposableEffect(Unit) { 53 | handleIntentAction(intent, navHostController) 54 | val listener = Consumer { intent -> 55 | handleIntentAction(intent, navHostController) 56 | } 57 | intentListener = listener 58 | onDispose { intentListener = null } 59 | } 60 | } 61 | } 62 | 63 | @Composable 64 | private fun MainContent( 65 | navHostController: NavHostController 66 | ) { 67 | val systemUiController = rememberSystemUiController() 68 | val state by rememberStateWithLifecycle(viewModel.state) 69 | 70 | SideEffect { 71 | systemUiController.setSystemBarsColor( 72 | color = Color.Black.copy(alpha = 0.1f) 73 | ) 74 | } 75 | 76 | LaunchedEffect(state) { 77 | state.isFirstTime?.let { 78 | // if (isFirstTime) { 79 | // navHostController.navigate(LeafScreen.IntroScreen.createRoute(Screen.Splash)) { 80 | // launchSingleTop = true 81 | // 82 | // popUpTo(navHostController.graph.id) { 83 | // inclusive = true 84 | // } 85 | // } 86 | // } else { 87 | // navHostController.navigate(Screen.Home.route) { 88 | // launchSingleTop = true 89 | // 90 | // popUpTo(navHostController.graph.id) { 91 | // inclusive = true 92 | // } 93 | // } 94 | // } 95 | splashScreen?.setKeepOnScreenCondition { false } 96 | } 97 | } 98 | 99 | AppTheme( 100 | darkTheme = false 101 | ) { 102 | AppNavigation( 103 | navController = navHostController, 104 | modifier = Modifier.fillMaxSize() 105 | ) 106 | } 107 | } 108 | 109 | private fun handleIntentAction(intent: Intent?, navController: NavController) { 110 | if (intent == null) return 111 | if (intent.action == Intent.ACTION_SEND) { 112 | @Suppress("DEPRECATION") 113 | val uri: Uri? = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { 114 | intent.getParcelableExtra(Intent.EXTRA_STREAM) 115 | } else { 116 | intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) 117 | } 118 | uri?.let { 119 | navController.navigate(Screen.Home.route) { 120 | launchSingleTop = true 121 | 122 | popUpTo(navController.graph.id) { 123 | inclusive = true 124 | } 125 | } 126 | navController.navigate( 127 | LeafScreen.ImagePreview.createRoute( 128 | Screen.Home, 129 | it 130 | ) 131 | ) { 132 | launchSingleTop = true 133 | } 134 | } 135 | } 136 | } 137 | 138 | override fun onNewIntent(intent: Intent?) { 139 | super.onNewIntent(intent) 140 | 141 | intentListener?.accept(intent) 142 | } 143 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ryccoatika/imagetotext/main/MainViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.main 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.ryccoatika.imagetotext.domain.usecase.GetIsUserFirstTime 6 | import com.ryccoatika.imagetotext.domain.usecase.SetUserFirstTime 7 | import dagger.hilt.android.lifecycle.HiltViewModel 8 | import kotlinx.coroutines.flow.* 9 | import javax.inject.Inject 10 | 11 | @HiltViewModel 12 | class MainViewModel @Inject constructor( 13 | getIsUserFirstTime: GetIsUserFirstTime, 14 | setUserFirstTime: SetUserFirstTime 15 | ) : ViewModel() { 16 | val state: StateFlow = getIsUserFirstTime(Unit) 17 | .onEach { isUserFirstTime -> 18 | if (isUserFirstTime) { 19 | setUserFirstTime.executeSync(SetUserFirstTime.Params(false)) 20 | } 21 | } 22 | .map { isUserFirstTime -> 23 | MainViewState(isUserFirstTime) 24 | }.stateIn( 25 | scope = viewModelScope, 26 | started = SharingStarted.WhileSubscribed(5000), 27 | initialValue = MainViewState.Empty 28 | ) 29 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ryccoatika/imagetotext/main/MainViewState.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.main 2 | 3 | data class MainViewState( 4 | val isFirstTime: Boolean? = null 5 | ) { 6 | companion object { 7 | val Empty = MainViewState() 8 | } 9 | } -------------------------------------------------------------------------------- /app/src/main/java/com/ryccoatika/imagetotext/utils/ComposeFileProviderImpl.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.utils 2 | 3 | import android.content.Context 4 | import android.net.Uri 5 | import androidx.core.content.FileProvider 6 | import com.ryccoatika.imagetotext.R 7 | import com.ryccoatika.imagetotext.domain.utils.ComposeFileProvider 8 | import java.io.File 9 | import javax.inject.Inject 10 | import javax.inject.Singleton 11 | 12 | @Singleton 13 | class ComposeFileProviderImpl @Inject constructor() : ComposeFileProvider, FileProvider(R.xml.filepaths) { 14 | override fun getImageUri(context: Context): Uri { 15 | val directory = File(context.cacheDir, "images") 16 | directory.mkdirs() 17 | val file = File.createTempFile( 18 | "image_to_text_", 19 | ".jpg", 20 | directory 21 | ) 22 | val authority = context.packageName + ".fileprovider" 23 | return getUriForFile( 24 | context, 25 | authority, 26 | file 27 | ) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/background_splash.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 10 | 11 | 12 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/decoration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryccoatika/Image-To-Text/2a5c376837d332a99718a1cc32effb39b8d8cf53/app/src/main/res/drawable/decoration.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 14 | 20 | 23 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/image_to_text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryccoatika/Image-To-Text/2a5c376837d332a99718a1cc32effb39b8d8cf53/app/src/main/res/drawable/image_to_text.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryccoatika/Image-To-Text/2a5c376837d332a99718a1cc32effb39b8d8cf53/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryccoatika/Image-To-Text/2a5c376837d332a99718a1cc32effb39b8d8cf53/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryccoatika/Image-To-Text/2a5c376837d332a99718a1cc32effb39b8d8cf53/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryccoatika/Image-To-Text/2a5c376837d332a99718a1cc32effb39b8d8cf53/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryccoatika/Image-To-Text/2a5c376837d332a99718a1cc32effb39b8d8cf53/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryccoatika/Image-To-Text/2a5c376837d332a99718a1cc32effb39b8d8cf53/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryccoatika/Image-To-Text/2a5c376837d332a99718a1cc32effb39b8d8cf53/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryccoatika/Image-To-Text/2a5c376837d332a99718a1cc32effb39b8d8cf53/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryccoatika/Image-To-Text/2a5c376837d332a99718a1cc32effb39b8d8cf53/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryccoatika/Image-To-Text/2a5c376837d332a99718a1cc32effb39b8d8cf53/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/values-v31/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #465BD8 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | @color/primary 4 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Image To Text 4 | com.ryccoatika.imagetotext.fileprovider 5 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/xml/filepaths.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /app/src/qaDebug/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Image To Text - Debug 4 | com.ryccoatika.imagetotext.debug.fileprovider 5 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | 3 | buildscript { 4 | repositories { 5 | google() 6 | mavenCentral() 7 | } 8 | dependencies { 9 | classpath("com.android.tools.build:gradle:7.3.1") 10 | classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.20") 11 | classpath("com.google.gms:google-services:4.3.15") 12 | classpath("com.google.firebase:firebase-crashlytics-gradle:2.9.2") 13 | classpath("com.google.dagger:hilt-android-gradle-plugin:2.44.2") 14 | // NOTE: Do not place your application dependencies here; they belong 15 | // in the individual module build.gradle files 16 | } 17 | } 18 | 19 | allprojects { 20 | repositories { 21 | google() 22 | mavenCentral() 23 | maven("https://jitpack.io") 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /core/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.library") 3 | kotlin("android") 4 | kotlin("kapt") 5 | } 6 | 7 | kapt { 8 | useBuildCache = true 9 | } 10 | 11 | android { 12 | namespace = "com.ryccoatika.imagetotext.core" 13 | compileSdk = 33 14 | 15 | defaultConfig { 16 | minSdk = 23 17 | } 18 | 19 | compileOptions { 20 | sourceCompatibility = JavaVersion.VERSION_11 21 | targetCompatibility = JavaVersion.VERSION_11 22 | } 23 | } 24 | 25 | dependencies { 26 | implementation(project(":domain")) 27 | implementation(libs.room) 28 | implementation(libs.room.runtime) 29 | kapt(libs.room.compiler) 30 | 31 | implementation(libs.coroutines.core) 32 | 33 | implementation(libs.hilt.android) 34 | kapt(libs.hilt.android.compiler) 35 | 36 | implementation(libs.datastore.preferences) 37 | 38 | implementation(libs.google.mlkit.textrecognition) 39 | implementation(libs.google.mlkit.textrecognition.chinese) 40 | implementation(libs.google.mlkit.textrecognition.devanagari) 41 | implementation(libs.google.mlkit.textrecognition.japanese) 42 | implementation(libs.google.mlkit.textrecognition.korean) 43 | implementation(libs.gson) 44 | } -------------------------------------------------------------------------------- /core/src/main/java/com/ryccoatika/imagetotext/core/data/TextRecognitionRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.core.data 2 | 3 | import com.google.mlkit.vision.common.InputImage 4 | import com.google.mlkit.vision.text.TextRecognition 5 | import com.google.mlkit.vision.text.chinese.ChineseTextRecognizerOptions 6 | import com.google.mlkit.vision.text.devanagari.DevanagariTextRecognizerOptions 7 | import com.google.mlkit.vision.text.japanese.JapaneseTextRecognizerOptions 8 | import com.google.mlkit.vision.text.korean.KoreanTextRecognizerOptions 9 | import com.google.mlkit.vision.text.latin.TextRecognizerOptions 10 | import com.ryccoatika.imagetotext.core.utils.toTextRecognizedDomain 11 | import com.ryccoatika.imagetotext.domain.exceptions.TextScanFailure 12 | import com.ryccoatika.imagetotext.domain.exceptions.TextScanNotFound 13 | import com.ryccoatika.imagetotext.domain.model.RecognationLanguageModel 14 | import com.ryccoatika.imagetotext.domain.model.TextRecognized 15 | import com.ryccoatika.imagetotext.domain.repository.TextRecognitionRepository 16 | import javax.inject.Inject 17 | import kotlin.coroutines.resume 18 | import kotlin.coroutines.resumeWithException 19 | import kotlin.coroutines.suspendCoroutine 20 | 21 | class TextRecognitionRepositoryImpl @Inject constructor() : TextRecognitionRepository { 22 | override suspend fun convertImageToText( 23 | inputImage: InputImage, 24 | languageModel: RecognationLanguageModel 25 | ): TextRecognized = suspendCoroutine { continuation -> 26 | val recognizer = when (languageModel) { 27 | RecognationLanguageModel.LATIN -> TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS) 28 | RecognationLanguageModel.CHINESE -> TextRecognition.getClient( 29 | ChineseTextRecognizerOptions.Builder().build() 30 | ) 31 | RecognationLanguageModel.DEVANAGARI -> TextRecognition.getClient( 32 | DevanagariTextRecognizerOptions.Builder().build() 33 | ) 34 | RecognationLanguageModel.JAPANESE -> TextRecognition.getClient( 35 | JapaneseTextRecognizerOptions.Builder().build() 36 | ) 37 | RecognationLanguageModel.KOREAN -> TextRecognition.getClient( 38 | KoreanTextRecognizerOptions.Builder().build() 39 | ) 40 | else -> { 41 | continuation.resumeWithException(TextScanFailure()) 42 | return@suspendCoroutine 43 | } 44 | } 45 | 46 | recognizer.process(inputImage) 47 | .addOnCompleteListener { task -> 48 | if (task.isSuccessful) { 49 | if (task.result.text.isEmpty() || task.result.textBlocks.isEmpty()) { 50 | continuation.resumeWithException(TextScanNotFound()) 51 | } else { 52 | continuation.resume(task.result.toTextRecognizedDomain()) 53 | } 54 | } else { 55 | continuation.resumeWithException(TextScanFailure()) 56 | } 57 | } 58 | } 59 | } -------------------------------------------------------------------------------- /core/src/main/java/com/ryccoatika/imagetotext/core/data/TextScannedRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.core.data 2 | 3 | import android.graphics.Bitmap 4 | import com.ryccoatika.imagetotext.core.data.local.AppPreferences 5 | import com.ryccoatika.imagetotext.core.data.local.LocalDataSource 6 | import com.ryccoatika.imagetotext.core.data.local.entity.TextScannedEntity 7 | import com.ryccoatika.imagetotext.core.utils.toTextScannedDomain 8 | import com.ryccoatika.imagetotext.core.utils.toTextScannedEntity 9 | import com.ryccoatika.imagetotext.domain.model.TextRecognized 10 | import com.ryccoatika.imagetotext.domain.model.TextScanned 11 | import com.ryccoatika.imagetotext.domain.repository.TextScannedRepository 12 | import kotlinx.coroutines.flow.Flow 13 | import kotlinx.coroutines.flow.first 14 | import kotlinx.coroutines.flow.map 15 | import javax.inject.Inject 16 | import javax.inject.Singleton 17 | 18 | @Singleton 19 | class TextScannedRepositoryImpl @Inject constructor( 20 | private val localDataSource: LocalDataSource, 21 | private val appPreferences: AppPreferences 22 | ) : TextScannedRepository { 23 | override suspend fun getIsUserFirstTime(): Boolean = 24 | appPreferences.isFirstTime.first() 25 | 26 | 27 | override suspend fun setUserFirstTime(isFirstTime: Boolean) { 28 | appPreferences.setFirstTime(isFirstTime) 29 | } 30 | 31 | override suspend fun saveTextScanned( 32 | image: Bitmap, 33 | textRecognized: TextRecognized, 34 | text: String 35 | ): TextScanned = 36 | localDataSource.saveTextScanned( 37 | TextScannedEntity( 38 | image = image, 39 | textRecognized = textRecognized, 40 | text = text 41 | ) 42 | ).toTextScannedDomain() 43 | 44 | override suspend fun removeTextScanned(textScanned: TextScanned) = 45 | localDataSource.removeTextScanned(textScanned.toTextScannedEntity()) 46 | 47 | override fun getAllTextScanned(query: String?): Flow> { 48 | return localDataSource.getAllTextScanned(query) 49 | .map { value -> value.map { it.toTextScannedDomain() } } 50 | } 51 | 52 | override suspend fun getTextScanned(id: Long): TextScanned { 53 | return localDataSource.getTextScanned(id).toTextScannedDomain() 54 | } 55 | } -------------------------------------------------------------------------------- /core/src/main/java/com/ryccoatika/imagetotext/core/data/local/AppPreferences.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.core.data.local 2 | 3 | import android.content.Context 4 | import androidx.datastore.core.DataStore 5 | import androidx.datastore.preferences.core.Preferences 6 | import androidx.datastore.preferences.core.booleanPreferencesKey 7 | import androidx.datastore.preferences.core.edit 8 | import androidx.datastore.preferences.preferencesDataStore 9 | import dagger.hilt.android.qualifiers.ApplicationContext 10 | import kotlinx.coroutines.flow.Flow 11 | import kotlinx.coroutines.flow.map 12 | import javax.inject.Inject 13 | import javax.inject.Singleton 14 | 15 | @Singleton 16 | class AppPreferences @Inject constructor( 17 | @ApplicationContext 18 | private val context: Context 19 | ) { 20 | private val Context.dataStore: DataStore by preferencesDataStore(name = "user_pref") 21 | 22 | companion object { 23 | private val FIRST_TIME_KEY = booleanPreferencesKey("first_time") 24 | } 25 | 26 | suspend fun setFirstTime(state: Boolean) { 27 | context.dataStore.edit { preferences -> 28 | preferences[FIRST_TIME_KEY] = state 29 | } 30 | } 31 | 32 | val isFirstTime: Flow = context.dataStore.data.map { preferences -> 33 | preferences[FIRST_TIME_KEY] ?: true 34 | } 35 | } -------------------------------------------------------------------------------- /core/src/main/java/com/ryccoatika/imagetotext/core/data/local/LocalDataSource.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.core.data.local 2 | 3 | import com.ryccoatika.imagetotext.core.data.local.entity.TextScannedEntity 4 | import com.ryccoatika.imagetotext.core.data.local.room.TextScannedDao 5 | import kotlinx.coroutines.flow.Flow 6 | import javax.inject.Inject 7 | 8 | class LocalDataSource @Inject constructor( 9 | private val textScannedDao: TextScannedDao 10 | ) { 11 | suspend fun saveTextScanned(textScanned: TextScannedEntity): TextScannedEntity { 12 | val textScannedId = textScannedDao.save(textScanned) 13 | return getTextScanned(textScannedId) 14 | } 15 | 16 | suspend fun removeTextScanned(textScanned: TextScannedEntity) = 17 | textScannedDao.remove(textScanned) 18 | 19 | fun getAllTextScanned(query: String?): Flow> { 20 | val q = if (query == null || query.isEmpty()) { 21 | null 22 | } else { 23 | "%$query%" 24 | } 25 | return textScannedDao.getAll(q) 26 | } 27 | 28 | suspend fun getTextScanned(id: Long): TextScannedEntity = 29 | textScannedDao.getById(id) 30 | } -------------------------------------------------------------------------------- /core/src/main/java/com/ryccoatika/imagetotext/core/data/local/entity/TextScannedEntity.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.core.data.local.entity 2 | 3 | import android.graphics.Bitmap 4 | import androidx.room.Entity 5 | import androidx.room.PrimaryKey 6 | import com.ryccoatika.imagetotext.domain.model.TextRecognized 7 | 8 | @Entity 9 | data class TextScannedEntity ( 10 | @PrimaryKey(autoGenerate = true) 11 | var id: Long? = null, 12 | var image: Bitmap, 13 | var text: String, 14 | val textRecognized: TextRecognized 15 | ) -------------------------------------------------------------------------------- /core/src/main/java/com/ryccoatika/imagetotext/core/data/local/room/AppDatabase.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.core.data.local.room 2 | 3 | import android.content.Context 4 | import androidx.room.Database 5 | import androidx.room.Room 6 | import androidx.room.RoomDatabase 7 | import androidx.room.TypeConverters 8 | import com.ryccoatika.imagetotext.core.data.local.entity.TextScannedEntity 9 | import com.ryccoatika.imagetotext.core.data.local.room.typeconverter.BitmapTypeConverter 10 | import com.ryccoatika.imagetotext.core.data.local.room.typeconverter.TextRecognizedTypeConverter 11 | 12 | @Database( 13 | entities = [TextScannedEntity::class], 14 | version = AppDatabase.DB_VERSION, 15 | exportSchema = false, 16 | ) 17 | @TypeConverters(TextRecognizedTypeConverter::class, BitmapTypeConverter::class) 18 | abstract class AppDatabase : RoomDatabase() { 19 | 20 | abstract fun textScannedDao(): TextScannedDao 21 | 22 | companion object { 23 | const val DB_VERSION = 3 24 | private const val DB_NAME = "imagetotext.db" 25 | private var INSTANCE: AppDatabase? = null 26 | 27 | fun getInstance(context: Context): AppDatabase = 28 | INSTANCE ?: synchronized(AppDatabase::class) { 29 | val newInstance = Room.databaseBuilder( 30 | context, 31 | AppDatabase::class.java, DB_NAME 32 | ) 33 | .fallbackToDestructiveMigration() 34 | .build() 35 | INSTANCE = newInstance 36 | newInstance 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /core/src/main/java/com/ryccoatika/imagetotext/core/data/local/room/TextScannedDao.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.core.data.local.room 2 | 3 | import androidx.room.* 4 | import com.ryccoatika.imagetotext.core.data.local.entity.TextScannedEntity 5 | import kotlinx.coroutines.flow.Flow 6 | 7 | @Dao 8 | interface TextScannedDao { 9 | @Insert(onConflict = OnConflictStrategy.REPLACE) 10 | suspend fun save(textScanned: TextScannedEntity): Long 11 | 12 | @Delete 13 | suspend fun remove(textScanned: TextScannedEntity) 14 | 15 | @Query("SELECT * FROM TextScannedEntity WHERE text LIKE :query OR :query IS NULL ORDER BY id DESC") 16 | fun getAll(query: String?): Flow> 17 | 18 | @Query("SELECT * FROM TextScannedEntity WHERE id = :id") 19 | suspend fun getById(id: Long): TextScannedEntity 20 | } -------------------------------------------------------------------------------- /core/src/main/java/com/ryccoatika/imagetotext/core/data/local/room/typeconverter/BitmapTypeConverter.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.core.data.local.room.typeconverter 2 | 3 | import android.graphics.Bitmap 4 | import android.graphics.BitmapFactory 5 | import androidx.room.TypeConverter 6 | import java.io.ByteArrayOutputStream 7 | 8 | class BitmapTypeConverter { 9 | @TypeConverter 10 | fun fromBitmap(bitmap: Bitmap): ByteArray { 11 | val outputStream = ByteArrayOutputStream() 12 | bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream) 13 | return outputStream.toByteArray() 14 | } 15 | 16 | @TypeConverter 17 | fun toBitmap(byteArray: ByteArray): Bitmap { 18 | return BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size) 19 | } 20 | } -------------------------------------------------------------------------------- /core/src/main/java/com/ryccoatika/imagetotext/core/data/local/room/typeconverter/TextRecognizedTypeConverter.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.core.data.local.room.typeconverter 2 | 3 | import androidx.room.TypeConverter 4 | import com.google.gson.Gson 5 | import com.ryccoatika.imagetotext.domain.model.TextRecognized 6 | 7 | class TextRecognizedTypeConverter { 8 | @TypeConverter 9 | fun fromString(value: String) = Gson().fromJson(value, TextRecognized::class.java) 10 | 11 | @TypeConverter 12 | fun toString(textRecognized: TextRecognized) = Gson().toJson(textRecognized) 13 | } -------------------------------------------------------------------------------- /core/src/main/java/com/ryccoatika/imagetotext/core/di/DatabaseModule.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.core.di 2 | 3 | import android.content.Context 4 | import com.ryccoatika.imagetotext.core.data.local.room.AppDatabase 5 | import com.ryccoatika.imagetotext.core.data.local.room.TextScannedDao 6 | import dagger.Module 7 | import dagger.Provides 8 | import dagger.hilt.InstallIn 9 | import dagger.hilt.android.qualifiers.ApplicationContext 10 | import dagger.hilt.components.SingletonComponent 11 | import javax.inject.Singleton 12 | 13 | @InstallIn(SingletonComponent::class) 14 | @Module 15 | object DatabaseModule { 16 | @Singleton 17 | @Provides 18 | fun provideDatabase( 19 | @ApplicationContext context: Context 20 | ): AppDatabase = AppDatabase.getInstance(context) 21 | } 22 | 23 | @InstallIn(SingletonComponent::class) 24 | @Module 25 | object DatabaseDaoModule { 26 | @Provides 27 | fun provideTextScannedDao(db: AppDatabase): TextScannedDao = db.textScannedDao() 28 | } -------------------------------------------------------------------------------- /core/src/main/java/com/ryccoatika/imagetotext/core/di/RepositoryBindsModule.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.core.di 2 | 3 | import com.ryccoatika.imagetotext.core.data.TextRecognitionRepositoryImpl 4 | import com.ryccoatika.imagetotext.core.data.TextScannedRepositoryImpl 5 | import com.ryccoatika.imagetotext.domain.repository.TextRecognitionRepository 6 | import com.ryccoatika.imagetotext.domain.repository.TextScannedRepository 7 | import dagger.Binds 8 | import dagger.Module 9 | import dagger.hilt.InstallIn 10 | import dagger.hilt.components.SingletonComponent 11 | 12 | @InstallIn(SingletonComponent::class) 13 | @Module 14 | abstract class RepositoryBindsModule { 15 | 16 | @Binds 17 | abstract fun provideTextScannedRepository(repository: TextScannedRepositoryImpl): TextScannedRepository 18 | 19 | @Binds 20 | abstract fun provideTextRecognitionRepository(repository: TextRecognitionRepositoryImpl): TextRecognitionRepository 21 | } -------------------------------------------------------------------------------- /core/src/main/java/com/ryccoatika/imagetotext/core/utils/DataMapper.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.core.utils 2 | 3 | import com.google.mlkit.vision.text.Text 4 | import com.ryccoatika.imagetotext.core.data.local.entity.TextScannedEntity 5 | import com.ryccoatika.imagetotext.domain.model.TextRecognized 6 | import com.ryccoatika.imagetotext.domain.model.TextScanned 7 | 8 | fun TextScanned.toTextScannedEntity(): TextScannedEntity { 9 | return TextScannedEntity( 10 | id = id, 11 | text = text, 12 | image = image, 13 | textRecognized = textRecognized 14 | ) 15 | } 16 | 17 | fun TextScannedEntity.toTextScannedDomain(): TextScanned = 18 | TextScanned( 19 | id = id ?: 0, 20 | text = text, 21 | image = image, 22 | textRecognized = textRecognized 23 | ) 24 | 25 | fun Text.toTextRecognizedDomain(): TextRecognized = TextRecognized( 26 | text = text, 27 | textBlocks = textBlocks.map { textBlock -> 28 | TextRecognized.TextBlock( 29 | text = textBlock.text, 30 | lines = textBlock.lines.map { line -> 31 | TextRecognized.Line( 32 | text = line.text, 33 | elements = line.elements.map { element -> 34 | TextRecognized.Element( 35 | text = element.text, 36 | angle = element.angle, 37 | boundingBox = element.boundingBox 38 | ) 39 | }, 40 | ) 41 | }, 42 | boundingBox = textBlock.boundingBox 43 | ) 44 | } 45 | ) -------------------------------------------------------------------------------- /design/app_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryccoatika/Image-To-Text/2a5c376837d332a99718a1cc32effb39b8d8cf53/design/app_icon.png -------------------------------------------------------------------------------- /design/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryccoatika/Image-To-Text/2a5c376837d332a99718a1cc32effb39b8d8cf53/design/banner.png -------------------------------------------------------------------------------- /design/screenshot_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryccoatika/Image-To-Text/2a5c376837d332a99718a1cc32effb39b8d8cf53/design/screenshot_1.png -------------------------------------------------------------------------------- /design/screenshot_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryccoatika/Image-To-Text/2a5c376837d332a99718a1cc32effb39b8d8cf53/design/screenshot_2.png -------------------------------------------------------------------------------- /design/screenshot_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryccoatika/Image-To-Text/2a5c376837d332a99718a1cc32effb39b8d8cf53/design/screenshot_3.png -------------------------------------------------------------------------------- /design/screenshot_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryccoatika/Image-To-Text/2a5c376837d332a99718a1cc32effb39b8d8cf53/design/screenshot_4.png -------------------------------------------------------------------------------- /design/screenshot_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryccoatika/Image-To-Text/2a5c376837d332a99718a1cc32effb39b8d8cf53/design/screenshot_5.png -------------------------------------------------------------------------------- /domain/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.library") 3 | kotlin("android") 4 | kotlin("kapt") 5 | } 6 | 7 | kapt { 8 | useBuildCache = true 9 | } 10 | 11 | android { 12 | namespace = "com.ryccoatika.imagetotext.domain" 13 | compileSdk = 33 14 | 15 | defaultConfig { 16 | minSdk = 23 17 | } 18 | 19 | compileOptions { 20 | sourceCompatibility = JavaVersion.VERSION_11 21 | targetCompatibility = JavaVersion.VERSION_11 22 | } 23 | } 24 | 25 | dependencies { 26 | implementation(libs.hilt.dagger) 27 | kapt(libs.hilt.android.compiler) 28 | implementation(libs.coroutines.core) 29 | 30 | implementation(libs.google.mlkit.textrecognition) 31 | } -------------------------------------------------------------------------------- /domain/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /domain/src/main/java/com/ryccoatika/imagetotext/domain/exceptions/ImageBroken.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.domain.exceptions 2 | 3 | class ImageBroken : Throwable() -------------------------------------------------------------------------------- /domain/src/main/java/com/ryccoatika/imagetotext/domain/exceptions/TextScanFailure.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.domain.exceptions 2 | 3 | class TextScanFailure : Throwable() -------------------------------------------------------------------------------- /domain/src/main/java/com/ryccoatika/imagetotext/domain/exceptions/TextScanNotFound.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.domain.exceptions 2 | 3 | class TextScanNotFound : Throwable() -------------------------------------------------------------------------------- /domain/src/main/java/com/ryccoatika/imagetotext/domain/model/InvokeStatus.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.domain.model 2 | 3 | sealed class InvokeStatus 4 | object InvokeStarted : InvokeStatus() 5 | object InvokeSuccess : InvokeStatus() 6 | data class InvokeError(val throwable: Throwable) : InvokeStatus() -------------------------------------------------------------------------------- /domain/src/main/java/com/ryccoatika/imagetotext/domain/model/RecognationLanguageModel.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.domain.model 2 | 3 | enum class RecognationLanguageModel { 4 | LATIN, 5 | CHINESE, 6 | DEVANAGARI, 7 | JAPANESE, 8 | KOREAN 9 | } -------------------------------------------------------------------------------- /domain/src/main/java/com/ryccoatika/imagetotext/domain/model/TextRecognized.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.domain.model 2 | 3 | import android.graphics.Rect 4 | 5 | data class TextRecognized( 6 | val text: String, 7 | val textBlocks: List 8 | ) { 9 | 10 | data class TextBlock( 11 | val text: String, 12 | val lines: List, 13 | val boundingBox: Rect? 14 | ) 15 | 16 | data class Line( 17 | val text: String, 18 | val elements: List 19 | ) 20 | 21 | data class Element( 22 | val text: String, 23 | val angle: Float, 24 | val boundingBox: Rect? 25 | ) 26 | } -------------------------------------------------------------------------------- /domain/src/main/java/com/ryccoatika/imagetotext/domain/model/TextScanned.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.domain.model 2 | 3 | import android.graphics.Bitmap 4 | 5 | data class TextScanned( 6 | val id: Long, 7 | val image: Bitmap, 8 | var text: String, 9 | val textRecognized: TextRecognized 10 | ) -------------------------------------------------------------------------------- /domain/src/main/java/com/ryccoatika/imagetotext/domain/repository/TextRecognitionRepository.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.domain.repository 2 | 3 | import com.google.mlkit.vision.common.InputImage 4 | import com.ryccoatika.imagetotext.domain.model.RecognationLanguageModel 5 | import com.ryccoatika.imagetotext.domain.model.TextRecognized 6 | 7 | interface TextRecognitionRepository { 8 | suspend fun convertImageToText(inputImage: InputImage, languageModel: RecognationLanguageModel): TextRecognized 9 | } -------------------------------------------------------------------------------- /domain/src/main/java/com/ryccoatika/imagetotext/domain/repository/TextScannedRepository.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.domain.repository 2 | 3 | import android.graphics.Bitmap 4 | import com.ryccoatika.imagetotext.domain.model.TextRecognized 5 | import com.ryccoatika.imagetotext.domain.model.TextScanned 6 | import kotlinx.coroutines.flow.Flow 7 | 8 | interface TextScannedRepository { 9 | suspend fun getIsUserFirstTime(): Boolean 10 | 11 | suspend fun setUserFirstTime(isFirstTime: Boolean) 12 | 13 | suspend fun saveTextScanned( 14 | image: Bitmap, 15 | textRecognized: TextRecognized, 16 | text: String 17 | ): TextScanned 18 | 19 | suspend fun removeTextScanned(textScanned: TextScanned) 20 | 21 | fun getAllTextScanned(query: String?): Flow> 22 | 23 | suspend fun getTextScanned(id: Long): TextScanned 24 | } -------------------------------------------------------------------------------- /domain/src/main/java/com/ryccoatika/imagetotext/domain/usecase/GetIsUserFirstTime.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.domain.usecase 2 | 3 | import com.ryccoatika.imagetotext.domain.utils.ResultInteractor 4 | import javax.inject.Inject 5 | 6 | class GetIsUserFirstTime @Inject constructor( 7 | private val textScannedRepository: com.ryccoatika.imagetotext.domain.repository.TextScannedRepository 8 | ) : ResultInteractor() { 9 | override suspend fun doWork(params: Unit): Boolean { 10 | return textScannedRepository.getIsUserFirstTime() 11 | } 12 | } -------------------------------------------------------------------------------- /domain/src/main/java/com/ryccoatika/imagetotext/domain/usecase/GetTextFromImage.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.domain.usecase 2 | 3 | import com.google.mlkit.vision.common.InputImage 4 | import com.ryccoatika.imagetotext.domain.model.RecognationLanguageModel 5 | import com.ryccoatika.imagetotext.domain.model.TextRecognized 6 | import com.ryccoatika.imagetotext.domain.repository.TextRecognitionRepository 7 | import com.ryccoatika.imagetotext.domain.utils.ResultInteractor 8 | import javax.inject.Inject 9 | 10 | 11 | class GetTextFromImage @Inject constructor( 12 | private val textRecognitionRepository: TextRecognitionRepository 13 | ) : ResultInteractor() { 14 | override suspend fun doWork(params: Params): TextRecognized { 15 | return textRecognitionRepository.convertImageToText(params.inputImage, params.languageModel) 16 | } 17 | 18 | data class Params( 19 | val inputImage: InputImage, 20 | val languageModel: RecognationLanguageModel 21 | ) 22 | } -------------------------------------------------------------------------------- /domain/src/main/java/com/ryccoatika/imagetotext/domain/usecase/GetTextScanned.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.domain.usecase 2 | 3 | import com.ryccoatika.imagetotext.domain.model.TextScanned 4 | import com.ryccoatika.imagetotext.domain.repository.TextScannedRepository 5 | import com.ryccoatika.imagetotext.domain.utils.ResultInteractor 6 | import javax.inject.Inject 7 | 8 | class GetTextScanned @Inject constructor( 9 | private val textScannedRepository: TextScannedRepository 10 | ) : ResultInteractor() { 11 | override suspend fun doWork(params: Params): TextScanned = textScannedRepository.getTextScanned(params.id) 12 | 13 | data class Params( 14 | val id: Long 15 | ) 16 | } -------------------------------------------------------------------------------- /domain/src/main/java/com/ryccoatika/imagetotext/domain/usecase/ObserveTextScanned.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.domain.usecase 2 | 3 | import com.ryccoatika.imagetotext.domain.model.TextScanned 4 | import com.ryccoatika.imagetotext.domain.utils.SubjectInteractor 5 | import kotlinx.coroutines.flow.Flow 6 | import javax.inject.Inject 7 | 8 | class ObserveTextScanned @Inject constructor( 9 | private val textScannedRepository: com.ryccoatika.imagetotext.domain.repository.TextScannedRepository 10 | ) : SubjectInteractor>() { 11 | override fun createObservable(params: Params): Flow> { 12 | return textScannedRepository.getAllTextScanned(params.query) 13 | } 14 | 15 | data class Params( 16 | val query: String? = null 17 | ) 18 | } -------------------------------------------------------------------------------- /domain/src/main/java/com/ryccoatika/imagetotext/domain/usecase/RemoveTextScanned.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.domain.usecase 2 | 3 | import com.ryccoatika.imagetotext.domain.utils.AppCoroutineDispatchers 4 | import com.ryccoatika.imagetotext.domain.utils.Interactor 5 | import kotlinx.coroutines.withContext 6 | import javax.inject.Inject 7 | 8 | class RemoveTextScanned @Inject constructor( 9 | private val dispatchers: AppCoroutineDispatchers, 10 | private val textScannedRepository: com.ryccoatika.imagetotext.domain.repository.TextScannedRepository 11 | ) : Interactor() { 12 | 13 | override suspend fun doWork(params: Params) = withContext(dispatchers.io) { 14 | textScannedRepository.removeTextScanned(params.textScanned) 15 | } 16 | 17 | data class Params( 18 | val textScanned: com.ryccoatika.imagetotext.domain.model.TextScanned 19 | ) 20 | } -------------------------------------------------------------------------------- /domain/src/main/java/com/ryccoatika/imagetotext/domain/usecase/SaveTextScanned.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.domain.usecase 2 | 3 | import android.graphics.Bitmap 4 | import com.ryccoatika.imagetotext.domain.model.TextRecognized 5 | import com.ryccoatika.imagetotext.domain.model.TextScanned 6 | import com.ryccoatika.imagetotext.domain.utils.ResultInteractor 7 | import javax.inject.Inject 8 | 9 | class SaveTextScanned @Inject constructor( 10 | private val textScannedRepository: com.ryccoatika.imagetotext.domain.repository.TextScannedRepository 11 | ) : ResultInteractor() { 12 | 13 | override suspend fun doWork(params: Params): TextScanned = textScannedRepository.saveTextScanned( 14 | image = params.image, 15 | textRecognized = params.textRecognized, 16 | text = params.text 17 | ) 18 | 19 | data class Params( 20 | val image: Bitmap, 21 | val textRecognized: TextRecognized, 22 | val text: String 23 | ) 24 | } -------------------------------------------------------------------------------- /domain/src/main/java/com/ryccoatika/imagetotext/domain/usecase/SetUserFirstTime.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.domain.usecase 2 | 3 | import com.ryccoatika.imagetotext.domain.utils.AppCoroutineDispatchers 4 | import com.ryccoatika.imagetotext.domain.utils.Interactor 5 | import kotlinx.coroutines.withContext 6 | import javax.inject.Inject 7 | 8 | class SetUserFirstTime @Inject constructor( 9 | private val dispatchers: AppCoroutineDispatchers, 10 | private val textScannedRepository: com.ryccoatika.imagetotext.domain.repository.TextScannedRepository 11 | ) : Interactor() { 12 | override suspend fun doWork(params: Params) = withContext(dispatchers.io) { 13 | textScannedRepository.setUserFirstTime(params.isFirstTime) 14 | } 15 | 16 | data class Params( 17 | val isFirstTime: Boolean 18 | ) 19 | } -------------------------------------------------------------------------------- /domain/src/main/java/com/ryccoatika/imagetotext/domain/utils/AppCoroutineDispatchers.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.domain.utils 2 | 3 | import kotlinx.coroutines.CoroutineDispatcher 4 | 5 | data class AppCoroutineDispatchers( 6 | val io: CoroutineDispatcher, 7 | val computation: CoroutineDispatcher, 8 | val main: CoroutineDispatcher 9 | ) -------------------------------------------------------------------------------- /domain/src/main/java/com/ryccoatika/imagetotext/domain/utils/CombineExts.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.domain.utils 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | 5 | fun combine( 6 | flow: Flow, 7 | flow2: Flow, 8 | flow3: Flow, 9 | flow4: Flow, 10 | flow5: Flow, 11 | flow6: Flow, 12 | transform: suspend (T1, T2, T3, T4, T5, T6) -> R 13 | ): Flow = kotlinx.coroutines.flow.combine( 14 | flow, 15 | flow2, 16 | flow3, 17 | flow4, 18 | flow5, 19 | flow6 20 | ) { args: Array<*> -> 21 | transform( 22 | args[0] as T1, 23 | args[1] as T2, 24 | args[2] as T3, 25 | args[3] as T4, 26 | args[4] as T5, 27 | args[5] as T6 28 | ) 29 | } 30 | 31 | fun combine( 32 | flow: Flow, 33 | flow2: Flow, 34 | flow3: Flow, 35 | flow4: Flow, 36 | flow5: Flow, 37 | flow6: Flow, 38 | flow7: Flow, 39 | transform: suspend (T1, T2, T3, T4, T5, T6, T7) -> R 40 | ): Flow = kotlinx.coroutines.flow.combine( 41 | flow, 42 | flow2, 43 | flow3, 44 | flow4, 45 | flow5, 46 | flow6, 47 | flow7, 48 | ) { args: Array<*> -> 49 | transform( 50 | args[0] as T1, 51 | args[1] as T2, 52 | args[2] as T3, 53 | args[3] as T4, 54 | args[4] as T5, 55 | args[5] as T6, 56 | args[6] as T7 57 | ) 58 | } -------------------------------------------------------------------------------- /domain/src/main/java/com/ryccoatika/imagetotext/domain/utils/ComposeFileProvider.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.domain.utils 2 | 3 | import android.content.Context 4 | import android.net.Uri 5 | 6 | interface ComposeFileProvider { 7 | fun getImageUri(context: Context): Uri 8 | } -------------------------------------------------------------------------------- /domain/src/main/java/com/ryccoatika/imagetotext/domain/utils/Interactor.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.domain.utils 2 | 3 | import com.ryccoatika.imagetotext.domain.model.InvokeError 4 | import com.ryccoatika.imagetotext.domain.model.InvokeStarted 5 | import com.ryccoatika.imagetotext.domain.model.InvokeStatus 6 | import com.ryccoatika.imagetotext.domain.model.InvokeSuccess 7 | import kotlinx.coroutines.ExperimentalCoroutinesApi 8 | import kotlinx.coroutines.TimeoutCancellationException 9 | import kotlinx.coroutines.channels.BufferOverflow 10 | import kotlinx.coroutines.flow.* 11 | import kotlinx.coroutines.withTimeout 12 | import java.util.concurrent.TimeUnit 13 | 14 | abstract class Interactor { 15 | operator fun invoke( 16 | params: P, 17 | timeoutMs: Long = defaultTimeoutMs 18 | ): Flow = flow { 19 | try { 20 | withTimeout(timeoutMs) { 21 | emit(InvokeStarted) 22 | doWork(params) 23 | emit(InvokeSuccess) 24 | } 25 | } catch (t: TimeoutCancellationException) { 26 | emit(InvokeError(t)) 27 | } 28 | }.catch { t -> emit(InvokeError(t)) } 29 | 30 | suspend fun executeSync(params: P) = doWork(params) 31 | 32 | protected abstract suspend fun doWork(params: P) 33 | 34 | companion object { 35 | private val defaultTimeoutMs = TimeUnit.SECONDS.toMillis(15) 36 | } 37 | } 38 | 39 | abstract class ResultInteractor

{ 40 | operator fun invoke(params: P): Flow = flow { 41 | emit(doWork(params)) 42 | } 43 | 44 | suspend fun executeSync(params: P) = doWork(params) 45 | 46 | protected abstract suspend fun doWork(params: P): R 47 | } 48 | 49 | abstract class SubjectInteractor

{ 50 | val isProcessing = MutableStateFlow(false) 51 | 52 | private val paramState = MutableSharedFlow

( 53 | replay = 1, 54 | extraBufferCapacity = 1, 55 | onBufferOverflow = BufferOverflow.DROP_OLDEST 56 | ) 57 | 58 | @OptIn(ExperimentalCoroutinesApi::class) 59 | val flow: Flow = paramState 60 | .distinctUntilChanged() 61 | .onEach { isProcessing.emit(true) } 62 | .flatMapLatest { createObservable(it) } 63 | .onEach { isProcessing.emit(false) } 64 | .distinctUntilChanged() 65 | 66 | operator fun invoke(params: P) { 67 | paramState.tryEmit(params) 68 | } 69 | 70 | protected abstract fun createObservable(params: P): Flow 71 | } -------------------------------------------------------------------------------- /domain/src/main/java/com/ryccoatika/imagetotext/domain/utils/ObservableLoadingCounter.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.domain.utils 2 | 3 | import com.ryccoatika.imagetotext.domain.model.InvokeError 4 | import com.ryccoatika.imagetotext.domain.model.InvokeStarted 5 | import com.ryccoatika.imagetotext.domain.model.InvokeStatus 6 | import com.ryccoatika.imagetotext.domain.model.InvokeSuccess 7 | import kotlinx.coroutines.flow.Flow 8 | import kotlinx.coroutines.flow.MutableStateFlow 9 | import kotlinx.coroutines.flow.distinctUntilChanged 10 | import kotlinx.coroutines.flow.map 11 | import java.util.concurrent.atomic.AtomicInteger 12 | 13 | class ObservableLoadingCounter { 14 | private val count = AtomicInteger() 15 | private val loadingState = MutableStateFlow(count.get()) 16 | 17 | val observable: Flow 18 | get() = loadingState.map { it > 0 }.distinctUntilChanged() 19 | 20 | fun addLoader() { 21 | loadingState.value = count.incrementAndGet() 22 | } 23 | 24 | fun removeLoader() { 25 | loadingState.value = count.decrementAndGet() 26 | } 27 | } 28 | 29 | suspend fun Flow.collectStatus( 30 | counter: ObservableLoadingCounter, 31 | uiMessageManager: UiMessageManager? = null, 32 | onSuccess: (() -> Unit)? = null 33 | ) = collect { status -> 34 | when (status) { 35 | is InvokeError -> { 36 | uiMessageManager?.emitMessage(UiMessage(status.throwable)) 37 | counter.removeLoader() 38 | } 39 | InvokeStarted -> counter.addLoader() 40 | InvokeSuccess -> { 41 | counter.removeLoader() 42 | onSuccess?.invoke() 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /domain/src/main/java/com/ryccoatika/imagetotext/domain/utils/UiMessageManager.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.domain.utils 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import kotlinx.coroutines.flow.MutableStateFlow 5 | import kotlinx.coroutines.flow.distinctUntilChanged 6 | import kotlinx.coroutines.flow.map 7 | import kotlinx.coroutines.sync.Mutex 8 | import kotlinx.coroutines.sync.withLock 9 | import java.util.UUID 10 | 11 | data class UiMessage( 12 | val message: String, 13 | val throwable: Throwable, 14 | val id: Long = UUID.randomUUID().mostSignificantBits 15 | ) 16 | 17 | fun UiMessage( 18 | t: Throwable, 19 | id: Long = UUID.randomUUID().mostSignificantBits 20 | ): UiMessage = UiMessage( 21 | message = t.message ?: "Error occured: $t", 22 | throwable = t, 23 | id = id 24 | ) 25 | 26 | class UiMessageManager { 27 | private val mutex = Mutex() 28 | 29 | private val _messages = MutableStateFlow(emptyList()) 30 | 31 | val message: Flow = _messages.map { it.firstOrNull() }.distinctUntilChanged() 32 | 33 | suspend fun emitMessage(message: UiMessage) { 34 | mutex.withLock { 35 | _messages.value = _messages.value + message 36 | } 37 | } 38 | 39 | suspend fun clearMessage(id: Long) { 40 | mutex.withLock { 41 | _messages.value = _messages.value.filterNot { it.id == id } 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app's APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Automatically convert third-party libraries to use AndroidX 19 | android.enableJetifier=true 20 | # Kotlin code style for this project: "official" or "obsolete": 21 | kotlin.code.style=official 22 | org.gradle.caching=true 23 | # https://docs.gradle.org/7.5/userguide/configuration_cache.html 24 | org.gradle.unsafe.configuration-cache=true 25 | # When configured, Gradle will run in incubating parallel mode. 26 | # This option should only be used with decoupled projects. More details, visit 27 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 28 | org.gradle.parallel=true 29 | 30 | # Enable R8 full mode: 31 | # https://developer.android.com/studio/build/shrink-code#full-mode 32 | android.enableR8.fullMode=true 33 | 34 | kotlin.incremental.useClasspathSnapshot=true 35 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | accompanist = "0.28.0" 3 | compose = "1.3.2" 4 | composecompiler = "1.3.2" 5 | composeMaterial = "1.3.1" 6 | coroutines = "1.6.4" 7 | datastore = "1.0.0" 8 | hilt = "2.44.2" 9 | room = "2.4.3" 10 | textrecognition = "16.0.0-beta6" 11 | 12 | [libraries] 13 | accompanist-pager = { module = "com.google.accompanist:accompanist-pager", version.ref = "accompanist" } 14 | accompanist-permission = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } 15 | accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanist" } 16 | accompanist-navigation-animation = { module = "com.google.accompanist:accompanist-navigation-animation", version.ref = "accompanist" } 17 | accompanist-navigation-material = { module = "com.google.accompanist:accompanist-navigation-material", version.ref = "accompanist" } 18 | 19 | activity-compose = { module = "androidx.activity:activity-compose", version = "1.6.1" } 20 | 21 | coil = { module = "io.coil-kt:coil-compose", version = "2.2.2" } 22 | 23 | compose-material = { module = "androidx.compose.material:material", version.ref = "composeMaterial" } 24 | compose-material-icons = { module = "androidx.compose.material:material-icons-extended", version.ref = "composeMaterial" } 25 | 26 | compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } 27 | compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" } 28 | 29 | coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } 30 | 31 | datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } 32 | 33 | google-firebase-core = { module = "com.google.firebase:firebase-core", version = "21.1.1" } 34 | google-firebase-analytics = { module = "com.google.firebase:firebase-analytics-ktx", version = "21.2.0" } 35 | google-firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics-ktx", version = "18.3.3" } 36 | google-mlkit-textrecognition = { module = "com.google.mlkit:text-recognition", version.ref = "textrecognition" } 37 | google-mlkit-textrecognition-chinese = { module = "com.google.mlkit:text-recognition-chinese", version.ref = "textrecognition" } 38 | google-mlkit-textrecognition-devanagari = { module = "com.google.mlkit:text-recognition-devanagari", version.ref = "textrecognition" } 39 | google-mlkit-textrecognition-japanese = { module = "com.google.mlkit:text-recognition-japanese", version.ref = "textrecognition" } 40 | google-mlkit-textrecognition-korean = { module = "com.google.mlkit:text-recognition-korean", version.ref = "textrecognition" } 41 | google-play-core = { module = "com.google.android.play:core-ktx", version = "1.8.1" } 42 | google-play-review = { module = "com.google.android.play:review-ktx", version = "2.0.1" } 43 | 44 | gson = { module = "com.google.code.gson:gson", version = "2.10.1" } 45 | 46 | hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } 47 | hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } 48 | hilt-dagger = { module = "com.google.dagger:dagger", version.ref = "hilt" } 49 | hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.0.0" } 50 | 51 | leakcanary = { module = "com.squareup.leakcanary:leakcanary-android", version = "2.10" } 52 | 53 | lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version = "2.5.1" } 54 | 55 | pluck = { module = "com.himanshoe:pluck", version = "1.0.0" } 56 | 57 | room = { module = "androidx.room:room-ktx", version.ref = "room" } 58 | room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } 59 | room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } 60 | 61 | splashscreen = { module = "androidx.core:core-splashscreen", version = "1.0.0"} 62 | 63 | [plugins] 64 | hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } 65 | gms-googleServices = "com.google.gms.google-services:4.3.14" 66 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryccoatika/Image-To-Text/2a5c376837d332a99718a1cc32effb39b8d8cf53/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Mon Jan 09 09:29:18 WIB 2023 2 | distributionBase=GRADLE_USER_HOME 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip 4 | distributionPath=wrapper/dists 5 | zipStorePath=wrapper/dists 6 | zipStoreBase=GRADLE_USER_HOME 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ]; do 14 | ls=$(ls -ld "$PRG") 15 | link=$(expr "$ls" : '.*-> \(.*\)$') 16 | if expr "$link" : '/.*' >/dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=$(dirname "$PRG")"/$link" 20 | fi 21 | done 22 | SAVED="$(pwd)" 23 | cd "$(dirname \"$PRG\")/" >/dev/null 24 | APP_HOME="$(pwd -P)" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=$(basename "$0") 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn() { 37 | echo "$*" 38 | } 39 | 40 | die() { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "$(uname)" in 53 | CYGWIN*) 54 | cygwin=true 55 | ;; 56 | Darwin*) 57 | darwin=true 58 | ;; 59 | MINGW*) 60 | msys=true 61 | ;; 62 | NONSTOP*) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ]; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ]; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ]; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ]; then 93 | MAX_FD_LIMIT=$(ulimit -H -n) 94 | if [ $? -eq 0 ]; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ]; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ]; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin; then 114 | APP_HOME=$(cygpath --path --mixed "$APP_HOME") 115 | CLASSPATH=$(cygpath --path --mixed "$CLASSPATH") 116 | JAVACMD=$(cygpath --unix "$JAVACMD") 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=$(find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null) 120 | SEP="" 121 | for dir in $ROOTDIRSRAW; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ]; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@"; do 133 | CHECK=$(echo "$arg" | egrep -c "$OURCYGPATTERN" -) 134 | CHECK2=$(echo "$arg" | egrep -c "^-") ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ]; then ### Added a condition 137 | eval $(echo args$i)=$(cygpath --path --ignore --mixed "$arg") 138 | else 139 | eval $(echo args$i)="\"$arg\"" 140 | fi 141 | i=$((i + 1)) 142 | done 143 | case $i in 144 | 0) set -- ;; 145 | 1) set -- "$args0" ;; 146 | 2) set -- "$args0" "$args1" ;; 147 | 3) set -- "$args0" "$args1" "$args2" ;; 148 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save() { 159 | for i; do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/"; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /release/debug.keystore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryccoatika/Image-To-Text/2a5c376837d332a99718a1cc32effb39b8d8cf53/release/debug.keystore -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name="Image To Text" 2 | 3 | include( 4 | ":ui", 5 | ":app" 6 | ) 7 | include(":core") 8 | include(":domain") 9 | -------------------------------------------------------------------------------- /ui/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.library") 3 | kotlin("android") 4 | kotlin("kapt") 5 | } 6 | 7 | kapt { 8 | useBuildCache = true 9 | } 10 | 11 | android { 12 | namespace = "com.ryccoatika.imagetotext.ui" 13 | compileSdk = 33 14 | 15 | defaultConfig { 16 | minSdk = 23 17 | } 18 | 19 | compileOptions { 20 | sourceCompatibility = JavaVersion.VERSION_11 21 | targetCompatibility = JavaVersion.VERSION_11 22 | } 23 | 24 | buildFeatures { 25 | compose = true 26 | } 27 | 28 | composeOptions { 29 | kotlinCompilerExtensionVersion = libs.versions.composecompiler.get() 30 | } 31 | } 32 | 33 | dependencies { 34 | implementation(project(":domain")) 35 | 36 | implementation(libs.accompanist.permission) 37 | 38 | debugImplementation(libs.compose.ui.tooling) 39 | implementation(libs.compose.ui.tooling.preview) 40 | 41 | implementation(libs.compose.material) 42 | implementation(libs.compose.material.icons) 43 | 44 | implementation(libs.lifecycle.viewmodel) 45 | 46 | implementation(libs.hilt.android) 47 | kapt(libs.hilt.android.compiler) 48 | implementation(libs.hilt.navigation.compose) 49 | 50 | implementation(libs.accompanist.pager) 51 | 52 | implementation(libs.coil) 53 | 54 | implementation(libs.google.mlkit.textrecognition) 55 | 56 | implementation(libs.google.play.review) 57 | } -------------------------------------------------------------------------------- /ui/src/main/java/com/ryccoatika/imagetotext/ui/common/theme/Spacing.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.ui.common.theme 2 | 3 | import androidx.compose.material.MaterialTheme 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.ReadOnlyComposable 6 | import androidx.compose.runtime.staticCompositionLocalOf 7 | import androidx.compose.ui.unit.Dp 8 | import androidx.compose.ui.unit.dp 9 | 10 | data class Spacing( 11 | val extraSmall: Dp = 4.dp, 12 | val small: Dp = 8.dp, 13 | val medium: Dp = 16.dp, 14 | val large: Dp = 24.dp, 15 | val extraLarge: Dp = 32.dp, 16 | ) 17 | 18 | val LocalSpacing = staticCompositionLocalOf { Spacing() } 19 | 20 | val MaterialTheme.spacing: Spacing 21 | @Composable 22 | @ReadOnlyComposable 23 | get() = LocalSpacing.current -------------------------------------------------------------------------------- /ui/src/main/java/com/ryccoatika/imagetotext/ui/common/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.ui.common.theme 2 | 3 | import androidx.compose.foundation.isSystemInDarkTheme 4 | import androidx.compose.foundation.shape.RoundedCornerShape 5 | import androidx.compose.material.* 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.graphics.Color 8 | import androidx.compose.ui.unit.dp 9 | 10 | private val AppDarkColorScheme = darkColors() 11 | 12 | private val AppLightColorScheme = lightColors( 13 | background = Color.White, 14 | onBackground = Color(0xFF475569), 15 | primary = Color(0xFF465BD8), 16 | onPrimary = Color.White, 17 | secondary = Color(0xFF73A5F7), 18 | onSecondary = Color.White 19 | ) 20 | 21 | private val AppShape = Shapes( 22 | large = RoundedCornerShape(5.dp), 23 | medium = RoundedCornerShape(6.dp), 24 | small = RoundedCornerShape(8.dp) 25 | ) 26 | 27 | private val AppType = Typography() 28 | 29 | @Composable 30 | fun AppTheme( 31 | darkTheme: Boolean = isSystemInDarkTheme(), 32 | content: @Composable () -> Unit 33 | ) { 34 | val colors = if (darkTheme) { 35 | AppDarkColorScheme 36 | } else { 37 | AppLightColorScheme 38 | } 39 | 40 | MaterialTheme( 41 | colors = colors, 42 | shapes = AppShape, 43 | typography = AppType, 44 | content = content 45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /ui/src/main/java/com/ryccoatika/imagetotext/ui/common/ui/AppSearchTextInput.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.ui.common.ui 2 | 3 | import androidx.compose.material.Icon 4 | import androidx.compose.material.MaterialTheme 5 | import androidx.compose.material.icons.Icons 6 | import androidx.compose.material.icons.filled.Search 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.res.stringResource 10 | import androidx.compose.ui.tooling.preview.Preview 11 | import androidx.compose.ui.unit.dp 12 | import com.ryccoatika.imagetotext.ui.R 13 | import com.ryccoatika.imagetotext.ui.common.theme.AppTheme 14 | 15 | @Composable 16 | fun AppSearchTextInput( 17 | value: String, 18 | onValueChange: (String) -> Unit, 19 | modifier: Modifier = Modifier 20 | ) { 21 | AppTextInput( 22 | value = value, 23 | onValueChange = onValueChange, 24 | leadingIcon = { 25 | Icon( 26 | imageVector = Icons.Default.Search, 27 | contentDescription = null, 28 | tint = MaterialTheme.colors.secondary 29 | ) 30 | }, 31 | placeholder = stringResource(id = R.string.hint_search), 32 | borderShape = MaterialTheme.shapes.small, 33 | borderWidth = 1.dp, 34 | borderColor = MaterialTheme.colors.secondary, 35 | modifier = modifier 36 | ) 37 | } 38 | 39 | @Preview 40 | @Composable 41 | private fun AppSearchTextInputPreview() { 42 | AppTheme { 43 | AppSearchTextInput( 44 | value = "", 45 | onValueChange = {}, 46 | ) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /ui/src/main/java/com/ryccoatika/imagetotext/ui/common/ui/AppTextInput.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.ui.common.ui 2 | 3 | import androidx.compose.foundation.border 4 | import androidx.compose.material.* 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Modifier 7 | import androidx.compose.ui.draw.clip 8 | import androidx.compose.ui.graphics.Color 9 | import androidx.compose.ui.graphics.Shape 10 | import androidx.compose.ui.tooling.preview.Preview 11 | import androidx.compose.ui.unit.Dp 12 | import com.ryccoatika.imagetotext.ui.common.theme.AppTheme 13 | 14 | @Composable 15 | fun AppTextInput( 16 | value: String, 17 | modifier: Modifier = Modifier, 18 | onValueChange: (String) -> Unit, 19 | backgroundColor: Color = Color.Transparent, 20 | cursorColor: Color = MaterialTheme.colors.onSurface, 21 | textColor: Color = MaterialTheme.colors.onSurface, 22 | leadingIcon: (@Composable () -> Unit)? = null, 23 | placeholder: String? = null, 24 | shape: Shape = MaterialTheme.shapes.small, 25 | borderShape: Shape? = null, 26 | borderWidth: Dp? = null, 27 | borderColor: Color? = null 28 | ) { 29 | require( 30 | (borderShape != null && borderWidth != null && borderColor != null) || 31 | (borderShape == null && borderWidth == null && borderColor == null) 32 | ) { "|borderWidth|, |borderShape|, and |borderColor| must be filled all" } 33 | TextFieldDefaults.textFieldColors() 34 | TextField( 35 | value = value, 36 | onValueChange = onValueChange, 37 | shape = MaterialTheme.shapes.large, 38 | leadingIcon = leadingIcon, 39 | placeholder = { 40 | if (placeholder != null) { 41 | Text( 42 | text = placeholder, 43 | color = MaterialTheme.colors.onSurface.copy(ContentAlpha.disabled) 44 | ) 45 | } 46 | }, 47 | colors = TextFieldDefaults.textFieldColors( 48 | focusedIndicatorColor = Color.Transparent, 49 | backgroundColor = backgroundColor, 50 | cursorColor = cursorColor, 51 | textColor = textColor 52 | ), 53 | modifier = modifier 54 | .then( 55 | if (borderShape != null && borderWidth != null && borderColor != null) Modifier.border( 56 | width = borderWidth, 57 | color = borderColor, 58 | shape = borderShape 59 | ) else Modifier 60 | ) 61 | .clip(shape) 62 | 63 | ) 64 | } 65 | 66 | @Preview 67 | @Composable 68 | private fun AppTextInputPreview() { 69 | AppTheme { 70 | AppTextInput( 71 | value = "", 72 | onValueChange = {} 73 | ) 74 | } 75 | } -------------------------------------------------------------------------------- /ui/src/main/java/com/ryccoatika/imagetotext/ui/common/ui/AppTopBar.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.ui.common.ui 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.material.* 6 | import androidx.compose.material.icons.Icons 7 | import androidx.compose.material.icons.filled.ArrowBack 8 | import androidx.compose.material.icons.filled.Share 9 | import androidx.compose.material.icons.outlined.Delete 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.graphics.Brush 13 | import androidx.compose.ui.graphics.Color 14 | import androidx.compose.ui.tooling.preview.Preview 15 | import androidx.compose.ui.unit.dp 16 | import com.ryccoatika.imagetotext.ui.common.theme.AppTheme 17 | import com.ryccoatika.imagetotext.ui.common.theme.spacing 18 | 19 | @Composable 20 | fun AppTopBar( 21 | navigationIcon: (@Composable () -> Unit)? = null, 22 | title: String? = null, 23 | actions: (@Composable RowScope.() -> Unit)? = null, 24 | ) { 25 | TopAppBar( 26 | contentPadding = WindowInsets.statusBars.only(WindowInsetsSides.Vertical).asPaddingValues(), 27 | backgroundColor = Color.Transparent, 28 | contentColor = MaterialTheme.colors.onPrimary, 29 | elevation = 0.dp, 30 | modifier = Modifier.background( 31 | Brush.horizontalGradient( 32 | colors = listOf( 33 | MaterialTheme.colors.primary, 34 | MaterialTheme.colors.secondary 35 | ), 36 | ), 37 | ) 38 | ) { 39 | navigationIcon?.invoke() 40 | if (navigationIcon == null) { 41 | Spacer(Modifier.width(MaterialTheme.spacing.medium)) 42 | } 43 | if (title != null) { 44 | Text( 45 | title, 46 | style = MaterialTheme.typography.h6 47 | ) 48 | } 49 | Spacer(Modifier.weight(1f)) 50 | actions?.invoke(this) 51 | } 52 | } 53 | 54 | @Preview 55 | @Composable 56 | private fun AppTopBarPreview() { 57 | AppTheme { 58 | AppTopBar( 59 | navigationIcon = { 60 | IconButton( 61 | onClick = {} 62 | ) { 63 | Icon(Icons.Default.ArrowBack, contentDescription = null) 64 | } 65 | }, 66 | title = "Title", 67 | actions = { 68 | IconButton( 69 | onClick = { } 70 | ) { 71 | Icon(Icons.Outlined.Delete, contentDescription = null) 72 | } 73 | IconButton( 74 | onClick = {} 75 | ) { 76 | Icon(Icons.Default.Share, contentDescription = null) 77 | } 78 | } 79 | ) 80 | } 81 | } -------------------------------------------------------------------------------- /ui/src/main/java/com/ryccoatika/imagetotext/ui/common/ui/DotIndicator.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.ui.common.ui 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.foundation.shape.CircleShape 6 | import androidx.compose.material.MaterialTheme 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Alignment 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.draw.clip 11 | import androidx.compose.ui.graphics.Color 12 | import androidx.compose.ui.tooling.preview.Preview 13 | import androidx.compose.ui.unit.Dp 14 | import androidx.compose.ui.unit.dp 15 | import com.ryccoatika.imagetotext.ui.common.theme.AppTheme 16 | 17 | @Composable 18 | fun DotIndicator( 19 | count: Int, 20 | activeIndex: Int, 21 | modifier: Modifier = Modifier, 22 | backgroundColor: Color = MaterialTheme.colors.primaryVariant, 23 | activeColor: Color = MaterialTheme.colors.primaryVariant.copy(alpha = 0.5f), 24 | width: Dp = 10.dp, 25 | gap: Dp = 5.dp 26 | ) { 27 | Row( 28 | horizontalArrangement = Arrangement.Center, 29 | verticalAlignment = Alignment.CenterVertically, 30 | modifier = modifier 31 | ) { 32 | for (i in 0 until count) { 33 | Box( 34 | modifier = Modifier 35 | .then(if (i != 0) Modifier.padding(start = gap) else Modifier) 36 | .width(width) 37 | .height(width) 38 | .clip(CircleShape) 39 | .then( 40 | if (i == activeIndex) Modifier.background(backgroundColor) else Modifier.background( 41 | activeColor 42 | ) 43 | ) 44 | ) 45 | } 46 | } 47 | } 48 | 49 | @Preview 50 | @Composable 51 | private fun DotIndicatorPreview() { 52 | AppTheme { 53 | DotIndicator( 54 | count = 3, 55 | activeIndex = 0, 56 | ) 57 | } 58 | } -------------------------------------------------------------------------------- /ui/src/main/java/com/ryccoatika/imagetotext/ui/common/ui/FabImagePicker.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.ui.common.ui 2 | 3 | import android.Manifest 4 | import android.content.Context 5 | import android.net.Uri 6 | import androidx.activity.compose.rememberLauncherForActivityResult 7 | import androidx.activity.result.contract.ActivityResultContracts 8 | import androidx.compose.animation.* 9 | import androidx.compose.animation.core.Animatable 10 | import androidx.compose.foundation.layout.Arrangement 11 | import androidx.compose.foundation.layout.Row 12 | import androidx.compose.foundation.layout.size 13 | import androidx.compose.material.FloatingActionButton 14 | import androidx.compose.material.Icon 15 | import androidx.compose.material.MaterialTheme 16 | import androidx.compose.material.icons.Icons 17 | import androidx.compose.material.icons.filled.* 18 | import androidx.compose.material.icons.outlined.Image 19 | import androidx.compose.material.icons.outlined.LinkedCamera 20 | import androidx.compose.runtime.* 21 | import androidx.compose.ui.Alignment 22 | import androidx.compose.ui.Modifier 23 | import androidx.compose.ui.draw.rotate 24 | import androidx.compose.ui.platform.LocalContext 25 | import androidx.compose.ui.tooling.preview.Preview 26 | import androidx.compose.ui.unit.dp 27 | import com.google.accompanist.permissions.ExperimentalPermissionsApi 28 | import com.google.accompanist.permissions.isGranted 29 | import com.google.accompanist.permissions.rememberPermissionState 30 | import com.ryccoatika.imagetotext.ui.common.theme.AppTheme 31 | import com.ryccoatika.imagetotext.ui.common.theme.spacing 32 | 33 | @OptIn(ExperimentalAnimationApi::class, ExperimentalPermissionsApi::class) 34 | @Composable 35 | fun FabImagePicker( 36 | pickedFromGallery: (Uri) -> Unit, 37 | pickedFromCamera: (Uri) -> Unit, 38 | generateImageUri: (Context) -> Uri 39 | ) { 40 | val context = LocalContext.current 41 | var fabAddActive by remember { mutableStateOf(false) } 42 | val galleryLauncher = 43 | rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> 44 | if (uri != null) { 45 | pickedFromGallery(uri) 46 | } 47 | } 48 | 49 | var imageUriFromCamera by remember { mutableStateOf(null) } 50 | val cameraLauncher = 51 | rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { result -> 52 | if (result) { 53 | imageUriFromCamera?.let { pickedFromCamera(it) } 54 | } 55 | } 56 | 57 | val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA) { granted -> 58 | if (granted) { 59 | imageUriFromCamera = generateImageUri(context) 60 | cameraLauncher.launch(imageUriFromCamera) 61 | } 62 | } 63 | 64 | Row( 65 | horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.extraLarge), 66 | verticalAlignment = Alignment.CenterVertically 67 | ) { 68 | AnimatedVisibility( 69 | visible = fabAddActive, 70 | enter = scaleIn() + slideInHorizontally { fullWidth -> 71 | fullWidth 72 | }, 73 | exit = scaleOut() + slideOutHorizontally { fullWidth -> 74 | fullWidth 75 | } 76 | ) { 77 | FloatingActionButton( 78 | backgroundColor = MaterialTheme.colors.secondary, 79 | contentColor = MaterialTheme.colors.onSecondary, 80 | modifier = Modifier.size(40.dp), 81 | onClick = { 82 | fabAddActive = false 83 | galleryLauncher.launch("image/*") 84 | }, 85 | ) { 86 | Icon( 87 | imageVector = Icons.Outlined.Image, 88 | contentDescription = null, 89 | ) 90 | } 91 | } 92 | val rotation = remember { Animatable(0f) } 93 | FloatingActionButton( 94 | backgroundColor = MaterialTheme.colors.primary, 95 | contentColor = MaterialTheme.colors.onPrimary, 96 | onClick = { 97 | fabAddActive = !fabAddActive 98 | }, 99 | ) { 100 | LaunchedEffect(fabAddActive) { 101 | if (fabAddActive) { 102 | rotation.animateTo(45f) 103 | } else { 104 | rotation.animateTo(0f) 105 | } 106 | } 107 | Icon( 108 | imageVector = Icons.Default.Add, 109 | contentDescription = null, 110 | modifier = Modifier.rotate(rotation.value) 111 | ) 112 | } 113 | AnimatedVisibility( 114 | visible = fabAddActive, 115 | enter = scaleIn() + slideInHorizontally { fullWidth -> 116 | -fullWidth 117 | }, 118 | exit = scaleOut() + slideOutHorizontally { fullWidth -> 119 | -fullWidth 120 | } 121 | ) { 122 | FloatingActionButton( 123 | backgroundColor = MaterialTheme.colors.secondary, 124 | contentColor = MaterialTheme.colors.onSecondary, 125 | modifier = Modifier.size(40.dp), 126 | onClick = { 127 | fabAddActive = false 128 | if (cameraPermissionState.status.isGranted) { 129 | imageUriFromCamera = generateImageUri(context) 130 | cameraLauncher.launch(imageUriFromCamera) 131 | } else { 132 | cameraPermissionState.launchPermissionRequest() 133 | } 134 | }, 135 | ) { 136 | Icon( 137 | imageVector = Icons.Outlined.LinkedCamera, 138 | contentDescription = null, 139 | ) 140 | } 141 | } 142 | } 143 | } 144 | 145 | @Preview 146 | @Composable 147 | private fun FabImagePicker() { 148 | AppTheme { 149 | FabImagePicker( 150 | pickedFromGallery = {}, 151 | pickedFromCamera = {}, 152 | generateImageUri = { Uri.EMPTY }, 153 | ) 154 | } 155 | } -------------------------------------------------------------------------------- /ui/src/main/java/com/ryccoatika/imagetotext/ui/common/ui/ScannedTextCard.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.ui.common.ui 2 | 3 | import android.graphics.Bitmap 4 | import androidx.compose.foundation.Image 5 | import androidx.compose.foundation.background 6 | import androidx.compose.foundation.border 7 | import androidx.compose.foundation.layout.* 8 | import androidx.compose.material.* 9 | import androidx.compose.material.icons.Icons 10 | import androidx.compose.material.icons.filled.Delete 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.draw.clip 15 | import androidx.compose.ui.graphics.Color 16 | import androidx.compose.ui.graphics.asImageBitmap 17 | import androidx.compose.ui.layout.ContentScale 18 | import androidx.compose.ui.text.style.TextOverflow 19 | import androidx.compose.ui.tooling.preview.Preview 20 | import androidx.compose.ui.unit.dp 21 | import com.ryccoatika.imagetotext.domain.model.TextRecognized 22 | import com.ryccoatika.imagetotext.domain.model.TextScanned 23 | import com.ryccoatika.imagetotext.ui.common.theme.AppTheme 24 | import com.ryccoatika.imagetotext.ui.common.theme.spacing 25 | 26 | @OptIn(ExperimentalMaterialApi::class) 27 | @Composable 28 | fun ScannedTextCard( 29 | textScanned: TextScanned, 30 | onDismissed: () -> Unit, 31 | modifier: Modifier = Modifier 32 | ) { 33 | val dismissState = rememberDismissState(confirmStateChange = { 34 | onDismissed() 35 | true 36 | }) 37 | 38 | SwipeToDismiss( 39 | state = dismissState, 40 | directions = setOf(DismissDirection.EndToStart), 41 | dismissThresholds = { 42 | FractionalThreshold(0.5f) 43 | }, 44 | modifier = modifier, 45 | background = { 46 | Row( 47 | horizontalArrangement = Arrangement.End, 48 | verticalAlignment = Alignment.CenterVertically, 49 | modifier = Modifier 50 | .clip(MaterialTheme.shapes.medium) 51 | .background(Color.Red) 52 | .fillMaxSize() 53 | ) { 54 | Icon( 55 | imageVector = Icons.Default.Delete, 56 | contentDescription = null, 57 | tint = Color.White, 58 | modifier = Modifier 59 | .padding(end = MaterialTheme.spacing.medium) 60 | .size(40.dp) 61 | ) 62 | } 63 | }, 64 | ) { 65 | Row( 66 | modifier = Modifier 67 | .clip(MaterialTheme.shapes.medium) 68 | .border( 69 | width = 1.dp, 70 | color = MaterialTheme.colors.secondary, 71 | shape = MaterialTheme.shapes.medium 72 | ) 73 | .background(MaterialTheme.colors.background) 74 | .fillMaxWidth() 75 | .sizeIn(minHeight = 75.dp) 76 | ) { 77 | Image( 78 | bitmap = textScanned.image.asImageBitmap(), 79 | contentDescription = null, 80 | contentScale = ContentScale.Fit, 81 | modifier = Modifier 82 | .size(72.dp) 83 | .padding(MaterialTheme.spacing.small) 84 | .clip(MaterialTheme.shapes.small) 85 | ) 86 | Spacer(Modifier.width(MaterialTheme.spacing.small)) 87 | Text( 88 | text = textScanned.text, 89 | overflow = TextOverflow.Ellipsis, 90 | maxLines = 3, 91 | color = MaterialTheme.colors.onBackground, 92 | modifier = Modifier 93 | .padding(MaterialTheme.spacing.small) 94 | ) 95 | } 96 | } 97 | } 98 | 99 | @Preview 100 | @Composable 101 | private fun ScannedTextCardPreview() { 102 | AppTheme { 103 | ScannedTextCard( 104 | textScanned = TextScanned( 105 | id = 0, 106 | image = Bitmap.createBitmap(1, 1, Bitmap.Config.RGB_565), 107 | text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", 108 | textRecognized = TextRecognized( 109 | text = "", 110 | textBlocks = emptyList() 111 | ) 112 | ), 113 | onDismissed = {} 114 | ) 115 | } 116 | } -------------------------------------------------------------------------------- /ui/src/main/java/com/ryccoatika/imagetotext/ui/common/ui/TextHighlightBlock.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.ui.common.ui 2 | 3 | import androidx.compose.foundation.ExperimentalFoundationApi 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.combinedClickable 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.offset 8 | import androidx.compose.foundation.layout.size 9 | import androidx.compose.material.MaterialTheme 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.draw.clip 13 | import androidx.compose.ui.draw.rotate 14 | import androidx.compose.ui.geometry.Offset 15 | import androidx.compose.ui.graphics.Color 16 | import androidx.compose.ui.platform.LocalDensity 17 | import com.ryccoatika.imagetotext.domain.model.TextRecognized 18 | 19 | @OptIn(ExperimentalFoundationApi::class) 20 | @Composable 21 | fun TextHighlightBlock( 22 | element: TextRecognized.Element, 23 | placeHolderOffset: Offset, 24 | imageSizeRatio: Float, 25 | onLongClick: () -> Unit 26 | ) { 27 | element.boundingBox?.let { rect -> 28 | LocalDensity.current.run { 29 | Box( 30 | modifier = Modifier 31 | .offset( 32 | x = placeHolderOffset.x.toDp(), 33 | y = placeHolderOffset.y.toDp() 34 | ) 35 | .offset( 36 | x = (rect.left * imageSizeRatio).toDp(), 37 | y = (rect.top * imageSizeRatio).toDp() 38 | ) 39 | .size( 40 | width = ((rect.right - rect.left) * imageSizeRatio).toDp(), 41 | height = ((rect.bottom - rect.top) * imageSizeRatio).toDp() 42 | ) 43 | .rotate(element.angle) 44 | .clip(MaterialTheme.shapes.large) 45 | .background(Color.Black.copy(0.2f)) 46 | .combinedClickable( 47 | onClick = {}, 48 | onLongClick = onLongClick 49 | ) 50 | ) 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /ui/src/main/java/com/ryccoatika/imagetotext/ui/common/ui/TextHighlightBlockSelected.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.ui.common.ui 2 | 3 | import android.graphics.Rect 4 | import android.view.MotionEvent 5 | import androidx.compose.animation.* 6 | import androidx.compose.foundation.background 7 | import androidx.compose.foundation.clickable 8 | import androidx.compose.foundation.layout.Box 9 | import androidx.compose.foundation.layout.offset 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.layout.size 12 | import androidx.compose.foundation.shape.CircleShape 13 | import androidx.compose.foundation.shape.ZeroCornerSize 14 | import androidx.compose.foundation.text.selection.LocalTextSelectionColors 15 | import androidx.compose.material.MaterialTheme 16 | import androidx.compose.material.Text 17 | import androidx.compose.runtime.* 18 | import androidx.compose.ui.Alignment 19 | import androidx.compose.ui.ExperimentalComposeUiApi 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.draw.clip 22 | import androidx.compose.ui.draw.rotate 23 | import androidx.compose.ui.geometry.Offset 24 | import androidx.compose.ui.graphics.Color 25 | import androidx.compose.ui.input.pointer.pointerInteropFilter 26 | import androidx.compose.ui.platform.LocalContext 27 | import androidx.compose.ui.platform.LocalDensity 28 | import androidx.compose.ui.res.stringResource 29 | import androidx.compose.ui.unit.dp 30 | import com.ryccoatika.imagetotext.domain.model.TextRecognized 31 | import com.ryccoatika.imagetotext.ui.R 32 | import com.ryccoatika.imagetotext.ui.common.theme.spacing 33 | import com.ryccoatika.imagetotext.ui.common.utils.copyToClipboard 34 | 35 | 36 | @OptIn(ExperimentalComposeUiApi::class, ExperimentalAnimationApi::class) 37 | @Composable 38 | fun TextHighlightBlockSelected( 39 | selectedElements: List, 40 | elements: List, 41 | placeHolderOffset: Offset, 42 | imageSizeRatio: Float, 43 | selectedElementsChanged: (List) -> Unit 44 | ) { 45 | if (selectedElements.isNotEmpty()) { 46 | val context = LocalContext.current 47 | val localTextSelectionColors = LocalTextSelectionColors.current 48 | var showCopyButton by remember { mutableStateOf(true) } 49 | 50 | val firstElementRect = selectedElements.first().boundingBox!! 51 | val lastElementRect = selectedElements.last().boundingBox!! 52 | 53 | LocalDensity.current.run { 54 | AnimatedVisibility( 55 | visible = showCopyButton, 56 | enter = scaleIn() + expandVertically(expandFrom = Alignment.CenterVertically), 57 | exit = scaleOut() + shrinkVertically(shrinkTowards = Alignment.CenterVertically) 58 | ) { 59 | Box( 60 | modifier = Modifier 61 | .offset( 62 | x = placeHolderOffset.x.toDp(), 63 | y = placeHolderOffset.y.toDp() 64 | ) 65 | .offset( 66 | x = firstElementRect.left 67 | .times(imageSizeRatio) 68 | .toDp() + 10.dp, 69 | y = firstElementRect.top 70 | .times(imageSizeRatio) 71 | .toDp() - 40.dp 72 | ) 73 | .clip(MaterialTheme.shapes.large) 74 | .background(Color.Black.copy(0.6f)) 75 | ) { 76 | Text( 77 | stringResource(R.string.button_copy), 78 | color = Color.White, 79 | modifier = Modifier 80 | .padding(MaterialTheme.spacing.extraSmall) 81 | .clickable { 82 | selectedElements.joinToString( 83 | separator = " " 84 | ) { it.text }.copyToClipboard(context) 85 | showCopyButton = false 86 | 87 | } 88 | ) 89 | } 90 | } 91 | 92 | Box( 93 | modifier = Modifier 94 | .offset( 95 | x = placeHolderOffset.x.toDp(), 96 | y = placeHolderOffset.y.toDp() 97 | ) 98 | .offset( 99 | x = firstElementRect.left 100 | .times(imageSizeRatio) 101 | .toDp() - 20.dp, 102 | y = firstElementRect.top 103 | .times(imageSizeRatio) 104 | .toDp() - 20.dp 105 | ) 106 | .size(20.dp) 107 | .clip(CircleShape.copy(bottomEnd = ZeroCornerSize)) 108 | .background(localTextSelectionColors.handleColor) 109 | .pointerInteropFilter { motionEvent -> 110 | when (motionEvent.action) { 111 | MotionEvent.ACTION_DOWN -> {} 112 | MotionEvent.ACTION_UP -> {} 113 | MotionEvent.ACTION_MOVE -> { 114 | if (elements.isNotEmpty()) { 115 | selectedElementsChanged(emptyList()) 116 | 117 | val dragX = 118 | firstElementRect.left.times(imageSizeRatio) + motionEvent.x - 20.dp.toPx() 119 | val dragY = 120 | firstElementRect.top.times(imageSizeRatio) + motionEvent.y - 20.dp.toPx() 121 | 122 | val firstElementIndex = 123 | elements 124 | .indexOfFirst { element -> 125 | element.boundingBox?.intersect( 126 | Rect( 127 | dragX.toInt(), 128 | dragY.toInt(), 129 | dragX.toInt(), 130 | dragY.toInt() 131 | ) 132 | ) == true 133 | } 134 | .run { 135 | if (this == -1) { 136 | elements.indexOfFirst { element -> 137 | val elementX = 138 | element.boundingBox!!.right.times( 139 | imageSizeRatio 140 | ) 141 | val elementY = 142 | element.boundingBox!!.bottom.times( 143 | imageSizeRatio 144 | ) 145 | 146 | dragX < elementX && dragY < elementY 147 | } 148 | 149 | } else { 150 | this 151 | } 152 | } 153 | 154 | val lastElementIndex = 155 | elements.indexOfLast { element -> element.boundingBox == lastElementRect } 156 | if (firstElementIndex != -1 && lastElementIndex != -1 && firstElementIndex <= lastElementIndex) { 157 | selectedElementsChanged( 158 | elements.subList( 159 | firstElementIndex, 160 | lastElementIndex + 1 161 | ) 162 | ) 163 | } 164 | } 165 | 166 | } 167 | else -> return@pointerInteropFilter false 168 | } 169 | true 170 | } 171 | ) 172 | selectedElements.forEach { element -> 173 | Box( 174 | modifier = Modifier 175 | .offset( 176 | x = placeHolderOffset.x.toDp(), 177 | y = placeHolderOffset.y.toDp() 178 | ) 179 | .offset( 180 | x = element.boundingBox!!.left 181 | .times(imageSizeRatio) 182 | .toDp(), 183 | y = element.boundingBox!!.top 184 | .times(imageSizeRatio) 185 | .toDp() 186 | ) 187 | .size( 188 | width = ((element.boundingBox!!.right - element.boundingBox!!.left).times( 189 | imageSizeRatio 190 | )).toDp(), 191 | height = ((element.boundingBox!!.bottom - element.boundingBox!!.top).times( 192 | imageSizeRatio 193 | )).toDp() 194 | ) 195 | .rotate(element.angle) 196 | .background(localTextSelectionColors.backgroundColor) 197 | .clickable { showCopyButton = !showCopyButton } 198 | ) 199 | } 200 | Box( 201 | modifier = Modifier 202 | .offset( 203 | x = placeHolderOffset.x.toDp(), 204 | y = placeHolderOffset.y.toDp() 205 | ) 206 | .offset( 207 | x = lastElementRect.right 208 | .times(imageSizeRatio) 209 | .toDp(), 210 | y = lastElementRect.bottom 211 | .times(imageSizeRatio) 212 | .toDp() 213 | ) 214 | .size(20.dp) 215 | .clip(CircleShape.copy(topStart = ZeroCornerSize)) 216 | .background(localTextSelectionColors.handleColor) 217 | .pointerInteropFilter { motionEvent -> 218 | when (motionEvent.action) { 219 | MotionEvent.ACTION_DOWN -> {} 220 | MotionEvent.ACTION_UP -> {} 221 | MotionEvent.ACTION_MOVE -> { 222 | val dragX = 223 | lastElementRect.right.times(imageSizeRatio) + motionEvent.x 224 | val dragY = 225 | lastElementRect.bottom.times(imageSizeRatio) + motionEvent.y 226 | 227 | if (elements.isNotEmpty()) { 228 | selectedElementsChanged(emptyList()) 229 | 230 | val firstElementIndex = 231 | elements.indexOfFirst { element -> element.boundingBox == firstElementRect } 232 | val lastElementIndex = elements 233 | .indexOfLast { element -> 234 | element.boundingBox?.intersect( 235 | Rect( 236 | dragX.toInt(), 237 | dragY.toInt(), 238 | dragX.toInt(), 239 | dragY.toInt() 240 | ) 241 | ) == true 242 | } 243 | .run { 244 | if (this == -1) { 245 | elements.indexOfLast { element -> 246 | val elementX = 247 | element.boundingBox!!.left.times( 248 | imageSizeRatio 249 | ) 250 | val elementY = 251 | element.boundingBox!!.top.times( 252 | imageSizeRatio 253 | ) 254 | 255 | elementX < dragX && elementY < dragY 256 | } 257 | } else { 258 | this 259 | } 260 | } 261 | 262 | if (firstElementIndex != -1 && lastElementIndex != -1 && firstElementIndex <= lastElementIndex) { 263 | selectedElementsChanged( 264 | elements.subList( 265 | firstElementIndex, 266 | lastElementIndex + 1 267 | ) 268 | ) 269 | } 270 | } 271 | 272 | } 273 | else -> return@pointerInteropFilter false 274 | } 275 | true 276 | } 277 | 278 | ) 279 | } 280 | 281 | } 282 | } -------------------------------------------------------------------------------- /ui/src/main/java/com/ryccoatika/imagetotext/ui/common/utils/FlowWithLifecycle.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.ui.common.utils 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.State 5 | import androidx.compose.runtime.produceState 6 | import androidx.compose.runtime.remember 7 | import androidx.compose.ui.platform.LocalLifecycleOwner 8 | import androidx.lifecycle.Lifecycle 9 | import androidx.lifecycle.repeatOnLifecycle 10 | import kotlinx.coroutines.flow.StateFlow 11 | 12 | @Composable 13 | fun rememberStateWithLifecycle( 14 | stateFlow: StateFlow, 15 | lifecycle: Lifecycle = LocalLifecycleOwner.current.lifecycle, 16 | minActiveState: Lifecycle.State = Lifecycle.State.STARTED 17 | ): State { 18 | val initialValue = remember(stateFlow) { stateFlow.value } 19 | return produceState( 20 | key1 = stateFlow, 21 | key2 = lifecycle, 22 | key3 = minActiveState, 23 | initialValue = initialValue 24 | ) { 25 | lifecycle.repeatOnLifecycle(minActiveState) { 26 | stateFlow.collect { 27 | this@produceState.value = it 28 | } 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /ui/src/main/java/com/ryccoatika/imagetotext/ui/common/utils/StringUtils.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.ui.common.utils 2 | 3 | import android.content.ClipData 4 | import android.content.ClipboardManager 5 | import android.content.Context 6 | import androidx.core.content.ContextCompat 7 | 8 | fun String.copyToClipboard(context: Context) { 9 | val clipboard = ContextCompat.getSystemService(context, ClipboardManager::class.java) 10 | clipboard?.setPrimaryClip(ClipData.newPlainText("", this)) 11 | } -------------------------------------------------------------------------------- /ui/src/main/java/com/ryccoatika/imagetotext/ui/convertresult/ImageConvertResult.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.ui.convertresult 2 | 3 | import android.app.Activity 4 | import android.content.Intent 5 | import androidx.compose.foundation.Image 6 | import androidx.compose.foundation.background 7 | import androidx.compose.foundation.border 8 | import androidx.compose.foundation.clickable 9 | import androidx.compose.foundation.interaction.MutableInteractionSource 10 | import androidx.compose.foundation.layout.* 11 | import androidx.compose.foundation.shape.CircleShape 12 | import androidx.compose.foundation.shape.RoundedCornerShape 13 | import androidx.compose.material.* 14 | import androidx.compose.material.icons.Icons 15 | import androidx.compose.material.icons.filled.ArrowBack 16 | import androidx.compose.material.icons.filled.Share 17 | import androidx.compose.material.icons.outlined.Delete 18 | import androidx.compose.runtime.* 19 | import androidx.compose.ui.Alignment 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.draw.clip 22 | import androidx.compose.ui.geometry.Offset 23 | import androidx.compose.ui.graphics.Color 24 | import androidx.compose.ui.graphics.asImageBitmap 25 | import androidx.compose.ui.layout.onGloballyPositioned 26 | import androidx.compose.ui.platform.LocalConfiguration 27 | import androidx.compose.ui.platform.LocalContext 28 | import androidx.compose.ui.res.stringResource 29 | import androidx.compose.ui.tooling.preview.Preview 30 | import androidx.compose.ui.unit.IntSize 31 | import androidx.compose.ui.unit.dp 32 | import androidx.hilt.navigation.compose.hiltViewModel 33 | import com.ryccoatika.imagetotext.domain.model.TextRecognized 34 | import com.ryccoatika.imagetotext.ui.R 35 | import com.ryccoatika.imagetotext.ui.common.theme.AppTheme 36 | import com.ryccoatika.imagetotext.ui.common.theme.spacing 37 | import com.ryccoatika.imagetotext.ui.common.ui.AppTextInput 38 | import com.ryccoatika.imagetotext.ui.common.ui.AppTopBar 39 | import com.ryccoatika.imagetotext.ui.common.ui.TextHighlightBlock 40 | import com.ryccoatika.imagetotext.ui.common.ui.TextHighlightBlockSelected 41 | import com.ryccoatika.imagetotext.ui.common.utils.rememberStateWithLifecycle 42 | import com.ryccoatika.imagetotext.ui.utils.ReviewHelper 43 | import kotlin.math.roundToInt 44 | 45 | @Composable 46 | fun ImageConvertResult( 47 | navigateBack: () -> Unit 48 | ) { 49 | ImageConvertResult( 50 | viewModel = hiltViewModel(), 51 | navigateBack = navigateBack 52 | ) 53 | } 54 | 55 | 56 | @Composable 57 | private fun ImageConvertResult( 58 | viewModel: ImageConvertResultViewModel, 59 | navigateBack: () -> Unit 60 | ) { 61 | val viewState by rememberStateWithLifecycle(viewModel.state) 62 | 63 | viewState.event?.let { event -> 64 | LaunchedEffect(event) { 65 | when (event) { 66 | ImageConvertResultViewState.Event.RemoveSuccess -> navigateBack() 67 | } 68 | } 69 | } 70 | 71 | ImageConvertResult( 72 | state = viewState, 73 | navigateUp = navigateBack, 74 | textChanged = viewModel::setText, 75 | onDeleteClick = viewModel::remove 76 | ) 77 | } 78 | 79 | @OptIn(ExperimentalMaterialApi::class) 80 | @Composable 81 | private fun ImageConvertResult( 82 | state: ImageConvertResultViewState, 83 | navigateUp: () -> Unit, 84 | textChanged: (String) -> Unit, 85 | onDeleteClick: () -> Unit, 86 | ) { 87 | val context = LocalContext.current 88 | val scaffoldState = rememberBottomSheetScaffoldState() 89 | 90 | LaunchedEffect(Unit) { 91 | ReviewHelper.launchInAppReview(context as Activity) 92 | } 93 | 94 | var imageSizeRatio by remember { mutableStateOf(1f) } 95 | var placeHolderOffset by remember { mutableStateOf(Offset.Zero) } 96 | val selectedElements = remember { mutableStateListOf() } 97 | 98 | BottomSheetScaffold( 99 | scaffoldState = scaffoldState, 100 | topBar = { 101 | AppTopBar( 102 | navigationIcon = { 103 | IconButton( 104 | onClick = navigateUp 105 | ) { 106 | Icon(Icons.Default.ArrowBack, contentDescription = null) 107 | } 108 | }, 109 | title = stringResource(R.string.title_preview), 110 | actions = { 111 | IconButton( 112 | onClick = onDeleteClick 113 | ) { 114 | Icon(Icons.Outlined.Delete, contentDescription = null) 115 | } 116 | IconButton( 117 | onClick = { 118 | Intent().apply { 119 | action = Intent.ACTION_SEND 120 | putExtra(Intent.EXTRA_TEXT, state.textScanned?.text ?: "") 121 | type = "text/plain" 122 | }.run { 123 | context.startActivity(Intent.createChooser(this, null)) 124 | } 125 | } 126 | ) { 127 | Icon(Icons.Default.Share, contentDescription = null) 128 | } 129 | } 130 | ) 131 | }, 132 | sheetBackgroundColor = Color.White, 133 | sheetShape = RoundedCornerShape( 134 | topStart = 20.dp, 135 | topEnd = 20.dp 136 | ), 137 | sheetPeekHeight = 100.dp, 138 | sheetElevation = 4.dp, 139 | sheetGesturesEnabled = true, 140 | sheetContent = { 141 | ImageConvertResultBottomSheet( 142 | text = state.textScanned?.text ?: "", 143 | textChanged = textChanged 144 | ) 145 | } 146 | ) { paddingValues -> 147 | Box( 148 | modifier = Modifier 149 | .padding(paddingValues) 150 | .fillMaxSize() 151 | .clickable( 152 | indication = null, 153 | interactionSource = remember { MutableInteractionSource() }, 154 | onClick = { 155 | selectedElements.clear() 156 | } 157 | ) 158 | ) { 159 | state.textScanned?.let { 160 | Image( 161 | bitmap = it.image.asImageBitmap(), 162 | contentDescription = null, 163 | modifier = Modifier 164 | .fillMaxSize() 165 | .onGloballyPositioned { coordinates -> 166 | if (coordinates.size != IntSize.Zero && state.inputImage != null) { 167 | // calculate size ratio 168 | val widthRatio = 169 | coordinates.size.width / state.inputImage.width.toFloat() 170 | val heightRatio = 171 | coordinates.size.height / state.inputImage.height.toFloat() 172 | imageSizeRatio = minOf(widthRatio, heightRatio) 173 | 174 | // calculate offset 175 | var yOffset = 0f 176 | var xOffset = 0f 177 | if ((imageSizeRatio * state.inputImage.width).roundToInt() == coordinates.size.width) { 178 | val scaledImageHeight = 179 | imageSizeRatio * state.inputImage.height 180 | yOffset = 181 | coordinates.size.height / 2 - scaledImageHeight / 2 182 | } 183 | if ((imageSizeRatio * state.inputImage.height).roundToInt() == coordinates.size.height) { 184 | val scaledImageWidth = 185 | imageSizeRatio * state.inputImage.width 186 | xOffset = coordinates.size.width / 2 - scaledImageWidth / 2 187 | } 188 | placeHolderOffset = Offset(xOffset, yOffset) 189 | } 190 | } 191 | ) 192 | } 193 | state.elements.forEach { element -> 194 | TextHighlightBlock( 195 | element = element, 196 | placeHolderOffset = placeHolderOffset, 197 | imageSizeRatio = imageSizeRatio 198 | ) { 199 | selectedElements.clear() 200 | selectedElements.add(element) 201 | } 202 | } 203 | TextHighlightBlockSelected( 204 | selectedElements = selectedElements, 205 | elements = state.elements, 206 | placeHolderOffset = placeHolderOffset, 207 | imageSizeRatio = imageSizeRatio, 208 | selectedElementsChanged = { value -> 209 | selectedElements.clear() 210 | selectedElements.addAll(value) 211 | } 212 | ) 213 | } 214 | } 215 | } 216 | 217 | @Composable 218 | private fun ImageConvertResultBottomSheet( 219 | text: String, 220 | textChanged: (String) -> Unit 221 | ) { 222 | Column( 223 | horizontalAlignment = Alignment.CenterHorizontally, 224 | modifier = Modifier 225 | .border( 226 | 1.dp, 227 | MaterialTheme.colors.secondary, 228 | RoundedCornerShape( 229 | topStart = 20.dp, 230 | topEnd = 20.dp 231 | ) 232 | ) 233 | .sizeIn( 234 | maxHeight = LocalConfiguration.current.screenHeightDp.dp + WindowInsets.navigationBars 235 | .asPaddingValues() 236 | .calculateBottomPadding() 237 | ) 238 | .padding(MaterialTheme.spacing.medium) 239 | .imePadding() 240 | .navigationBarsPadding() 241 | ) { 242 | Box( 243 | modifier = Modifier 244 | .width(40.dp) 245 | .height(7.dp) 246 | .clip(CircleShape) 247 | .background(MaterialTheme.colors.secondary) 248 | ) 249 | AppTextInput( 250 | value = text, 251 | onValueChange = textChanged, 252 | borderShape = MaterialTheme.shapes.small, 253 | borderWidth = 2.dp, 254 | borderColor = MaterialTheme.colors.secondary.copy(ContentAlpha.disabled), 255 | textColor = Color.Black, 256 | cursorColor = MaterialTheme.colors.primary, 257 | modifier = Modifier 258 | .fillMaxWidth() 259 | .padding(vertical = MaterialTheme.spacing.medium) 260 | ) 261 | } 262 | } 263 | 264 | @Preview 265 | @Composable 266 | private fun ImageConvertResultPreview() { 267 | AppTheme { 268 | ImageConvertResult( 269 | state = ImageConvertResultViewState.Empty, 270 | navigateUp = {}, 271 | textChanged = {}, 272 | onDeleteClick = {} 273 | ) 274 | } 275 | } -------------------------------------------------------------------------------- /ui/src/main/java/com/ryccoatika/imagetotext/ui/convertresult/ImageConvertResultViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.ui.convertresult 2 | 3 | import androidx.lifecycle.SavedStateHandle 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.viewModelScope 6 | import com.google.mlkit.vision.common.InputImage 7 | import com.ryccoatika.imagetotext.domain.model.TextRecognized 8 | import com.ryccoatika.imagetotext.domain.model.TextScanned 9 | import com.ryccoatika.imagetotext.domain.usecase.GetTextScanned 10 | import com.ryccoatika.imagetotext.domain.usecase.RemoveTextScanned 11 | import dagger.hilt.android.lifecycle.HiltViewModel 12 | import kotlinx.coroutines.flow.* 13 | import kotlinx.coroutines.launch 14 | import javax.inject.Inject 15 | 16 | @HiltViewModel 17 | class ImageConvertResultViewModel @Inject constructor( 18 | getTextScanned: GetTextScanned, 19 | savedStateHandle: SavedStateHandle, 20 | private val removeTextScanned: RemoveTextScanned 21 | ) : ViewModel() { 22 | 23 | private val id: Long = savedStateHandle["id"]!! 24 | private val textScanned = MutableStateFlow(null) 25 | private val inputImage: Flow = textScanned.filterNotNull().map { 26 | try { 27 | InputImage.fromBitmap(it.image, 0) 28 | } catch (e: Exception) { 29 | null 30 | } 31 | } 32 | private val textElements: Flow> = textScanned.filterNotNull().map { 33 | it.textRecognized.textBlocks.fold( 34 | emptyList() 35 | ) { acc1, textBlock -> 36 | acc1 + textBlock.lines.fold( 37 | emptyList() 38 | ) { acc2, line -> 39 | acc2 + line.elements.fold(emptyList()) { acc, element -> 40 | acc + element 41 | } 42 | } 43 | } 44 | } 45 | private val event = MutableStateFlow(null) 46 | 47 | val state: StateFlow = combine( 48 | textScanned, 49 | inputImage, 50 | textElements, 51 | event, 52 | ::ImageConvertResultViewState 53 | ).stateIn( 54 | scope = viewModelScope, 55 | started = SharingStarted.WhileSubscribed(5000), 56 | initialValue = ImageConvertResultViewState.Empty 57 | ) 58 | 59 | init { 60 | viewModelScope.launch { 61 | textScanned.value = getTextScanned.executeSync(GetTextScanned.Params(id)) 62 | } 63 | } 64 | 65 | fun setText(text: String) { 66 | if (textScanned.value != null) { 67 | this.textScanned.value = textScanned.value!!.copy( 68 | textRecognized = textScanned.value!!.textRecognized.copy( 69 | text = text 70 | ) 71 | ) 72 | } 73 | } 74 | 75 | fun remove() { 76 | viewModelScope.launch { 77 | textScanned.value?.let { 78 | removeTextScanned.executeSync(RemoveTextScanned.Params(it)) 79 | event.value = ImageConvertResultViewState.Event.RemoveSuccess 80 | } 81 | } 82 | } 83 | } -------------------------------------------------------------------------------- /ui/src/main/java/com/ryccoatika/imagetotext/ui/convertresult/ImageConvertResultViewState.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.ui.convertresult 2 | 3 | import com.google.mlkit.vision.common.InputImage 4 | import com.ryccoatika.imagetotext.domain.model.TextRecognized 5 | import com.ryccoatika.imagetotext.domain.model.TextScanned 6 | 7 | data class ImageConvertResultViewState( 8 | val textScanned: TextScanned? = null, 9 | val inputImage: InputImage? = null, 10 | val elements: List = emptyList(), 11 | val event: Event? = null 12 | ) { 13 | companion object { 14 | val Empty = ImageConvertResultViewState() 15 | } 16 | 17 | sealed class Event { 18 | object RemoveSuccess : Event() 19 | } 20 | } -------------------------------------------------------------------------------- /ui/src/main/java/com/ryccoatika/imagetotext/ui/home/Home.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.ui.home 2 | 3 | import android.content.Context 4 | import android.net.Uri 5 | import androidx.compose.foundation.Image 6 | import androidx.compose.foundation.clickable 7 | import androidx.compose.foundation.layout.* 8 | import androidx.compose.foundation.lazy.LazyColumn 9 | import androidx.compose.foundation.lazy.items 10 | import androidx.compose.material.* 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.getValue 13 | import androidx.compose.ui.Alignment 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.layout.ContentScale 16 | import androidx.compose.ui.res.painterResource 17 | import androidx.compose.ui.res.stringResource 18 | import androidx.compose.ui.tooling.preview.Preview 19 | import androidx.compose.ui.unit.dp 20 | import androidx.hilt.navigation.compose.hiltViewModel 21 | import com.ryccoatika.imagetotext.domain.model.TextScanned 22 | import com.ryccoatika.imagetotext.ui.R 23 | import com.ryccoatika.imagetotext.ui.common.theme.AppTheme 24 | import com.ryccoatika.imagetotext.ui.common.theme.spacing 25 | import com.ryccoatika.imagetotext.ui.common.ui.AppSearchTextInput 26 | import com.ryccoatika.imagetotext.ui.common.ui.AppTopBar 27 | import com.ryccoatika.imagetotext.ui.common.ui.FabImagePicker 28 | import com.ryccoatika.imagetotext.ui.common.ui.ScannedTextCard 29 | import com.ryccoatika.imagetotext.ui.common.utils.rememberStateWithLifecycle 30 | 31 | @Composable 32 | fun Home( 33 | openImageResultScreen: (Long) -> Unit, 34 | openImagePreviewScreen: (Uri) -> Unit 35 | ) { 36 | Home( 37 | viewModel = hiltViewModel(), 38 | openImageResultScreen = openImageResultScreen, 39 | openImagePreviewScreen = openImagePreviewScreen 40 | ) 41 | } 42 | 43 | @Composable 44 | private fun Home( 45 | viewModel: HomeViewModel, 46 | openImageResultScreen: (Long) -> Unit, 47 | openImagePreviewScreen: (Uri) -> Unit 48 | ) { 49 | val viewState by rememberStateWithLifecycle(viewModel.state) 50 | 51 | Home( 52 | state = viewState, 53 | generateImageUri = viewModel::getImageUri, 54 | onSearchChanged = viewModel::setQuery, 55 | onTextScannedRemove = viewModel::remove, 56 | openImageResultScreen = openImageResultScreen, 57 | openLanguageSelectorScreen = openImagePreviewScreen 58 | ) 59 | } 60 | 61 | @Composable 62 | private fun Home( 63 | state: HomeViewState, 64 | generateImageUri: (Context) -> Uri, 65 | onSearchChanged: (String) -> Unit, 66 | onTextScannedRemove: (TextScanned) -> Unit, 67 | openImageResultScreen: (Long) -> Unit, 68 | openLanguageSelectorScreen: (Uri) -> Unit 69 | ) { 70 | Scaffold( 71 | topBar = { 72 | AppTopBar( 73 | title = stringResource(R.string.app_name) 74 | ) 75 | }, 76 | floatingActionButtonPosition = FabPosition.Center, 77 | floatingActionButton = { 78 | FabImagePicker( 79 | pickedFromGallery = { uri -> 80 | openLanguageSelectorScreen(uri) 81 | }, 82 | pickedFromCamera = { uri -> 83 | openLanguageSelectorScreen(uri) 84 | }, 85 | generateImageUri = generateImageUri 86 | ) 87 | }, 88 | modifier = Modifier 89 | .navigationBarsPadding() 90 | ) { paddingValues -> 91 | Column( 92 | modifier = Modifier 93 | .padding(paddingValues) 94 | .padding(horizontal = MaterialTheme.spacing.medium) 95 | ) { 96 | Spacer(Modifier.height(MaterialTheme.spacing.medium)) 97 | Text( 98 | stringResource(id = R.string.title_your_documents), 99 | style = MaterialTheme.typography.h5, 100 | color = MaterialTheme.colors.primary, 101 | ) 102 | Spacer(Modifier.height(MaterialTheme.spacing.small)) 103 | AppSearchTextInput( 104 | value = state.query ?: "", 105 | onValueChange = onSearchChanged, 106 | modifier = Modifier 107 | .fillMaxWidth() 108 | ) 109 | if (state.textScannedCollection.isNotEmpty()) { 110 | LazyColumn( 111 | modifier = Modifier 112 | .weight(1f) 113 | .padding(top = MaterialTheme.spacing.medium) 114 | ) { 115 | items( 116 | items = state.textScannedCollection, 117 | key = { 118 | it.id 119 | } 120 | ) { textScanned -> 121 | ScannedTextCard( 122 | textScanned = textScanned, 123 | onDismissed = { 124 | onTextScannedRemove(textScanned) 125 | }, 126 | modifier = Modifier 127 | .padding(bottom = MaterialTheme.spacing.small) 128 | .clickable { 129 | openImageResultScreen(textScanned.id) 130 | } 131 | ) 132 | } 133 | } 134 | } else { 135 | Column( 136 | modifier = Modifier.fillMaxSize(), 137 | verticalArrangement = Arrangement.Center, 138 | horizontalAlignment = Alignment.CenterHorizontally 139 | ) { 140 | Image( 141 | painterResource(R.drawable.decoration_empty), 142 | contentDescription = null, 143 | contentScale = ContentScale.FillWidth, 144 | modifier = Modifier 145 | .fillMaxWidth() 146 | .padding(horizontal = MaterialTheme.spacing.medium) 147 | ) 148 | Spacer(Modifier.height(32.dp)) 149 | Text( 150 | stringResource(R.string.text_lets_scan), 151 | color = MaterialTheme.colors.secondary, 152 | ) 153 | } 154 | } 155 | } 156 | } 157 | } 158 | 159 | @Preview 160 | @Composable 161 | private fun HomePreview() { 162 | AppTheme { 163 | Home( 164 | state = HomeViewState.Empty, 165 | generateImageUri = { Uri.EMPTY }, 166 | onSearchChanged = {}, 167 | onTextScannedRemove = {}, 168 | openImageResultScreen = {}, 169 | openLanguageSelectorScreen = {} 170 | ) 171 | } 172 | } -------------------------------------------------------------------------------- /ui/src/main/java/com/ryccoatika/imagetotext/ui/home/HomeViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.ui.home 2 | 3 | import android.content.Context 4 | import android.net.Uri 5 | import androidx.lifecycle.ViewModel 6 | import androidx.lifecycle.viewModelScope 7 | import com.ryccoatika.imagetotext.domain.model.TextScanned 8 | import com.ryccoatika.imagetotext.domain.usecase.ObserveTextScanned 9 | import com.ryccoatika.imagetotext.domain.usecase.RemoveTextScanned 10 | import com.ryccoatika.imagetotext.domain.utils.* 11 | import dagger.hilt.android.lifecycle.HiltViewModel 12 | import kotlinx.coroutines.FlowPreview 13 | import kotlinx.coroutines.flow.* 14 | import kotlinx.coroutines.launch 15 | import javax.inject.Inject 16 | 17 | @OptIn(FlowPreview::class) 18 | @HiltViewModel 19 | class HomeViewModel @Inject constructor( 20 | observeTextScanned: ObserveTextScanned, 21 | private val removeTextScanned: RemoveTextScanned, 22 | private val composeFileProvider: ComposeFileProvider 23 | ) : ViewModel() { 24 | 25 | private val loadingState = ObservableLoadingCounter() 26 | private val query = MutableStateFlow(null) 27 | 28 | val state: StateFlow = combine( 29 | query, 30 | observeTextScanned.isProcessing, 31 | loadingState.observable, 32 | observeTextScanned.flow, 33 | ::HomeViewState 34 | ).stateIn( 35 | scope = viewModelScope, 36 | started = SharingStarted.WhileSubscribed(5000), 37 | initialValue = HomeViewState.Empty 38 | ) 39 | 40 | init { 41 | viewModelScope.launch { 42 | this@HomeViewModel.query 43 | .filterNotNull() 44 | .debounce(500) 45 | .distinctUntilChanged() 46 | .collect { query -> 47 | observeTextScanned(ObserveTextScanned.Params(query)) 48 | } 49 | } 50 | 51 | observeTextScanned(ObserveTextScanned.Params(query.value)) 52 | } 53 | 54 | fun setQuery(query: String) { 55 | this.query.value = query 56 | } 57 | 58 | fun remove(textScanned: TextScanned) { 59 | viewModelScope.launch { 60 | removeTextScanned(RemoveTextScanned.Params(textScanned)).collectStatus( 61 | loadingState 62 | ) 63 | } 64 | } 65 | 66 | fun getImageUri(context: Context): Uri = composeFileProvider.getImageUri(context) 67 | } -------------------------------------------------------------------------------- /ui/src/main/java/com/ryccoatika/imagetotext/ui/home/HomeViewState.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.ui.home 2 | 3 | import com.ryccoatika.imagetotext.domain.model.TextScanned 4 | 5 | data class HomeViewState( 6 | val query: String? = null, 7 | val loading: Boolean = false, 8 | val processing: Boolean = false, 9 | val textScannedCollection: List = emptyList() 10 | ) { 11 | companion object { 12 | val Empty = HomeViewState() 13 | } 14 | } -------------------------------------------------------------------------------- /ui/src/main/java/com/ryccoatika/imagetotext/ui/imagepreview/ImagePreview.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.ui.imagepreview 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.border 6 | import androidx.compose.foundation.layout.* 7 | import androidx.compose.material.* 8 | import androidx.compose.material.icons.Icons 9 | import androidx.compose.material.icons.filled.ArrowBack 10 | import androidx.compose.material.icons.filled.KeyboardArrowDown 11 | import androidx.compose.runtime.* 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.draw.clip 14 | import androidx.compose.ui.layout.ContentScale 15 | import androidx.compose.ui.res.stringResource 16 | import androidx.compose.ui.tooling.preview.Preview 17 | import androidx.compose.ui.unit.dp 18 | import androidx.hilt.navigation.compose.hiltViewModel 19 | import coil.compose.rememberAsyncImagePainter 20 | import com.ryccoatika.imagetotext.domain.model.RecognationLanguageModel 21 | import com.ryccoatika.imagetotext.ui.R 22 | import com.ryccoatika.imagetotext.ui.common.theme.AppTheme 23 | import com.ryccoatika.imagetotext.ui.common.theme.spacing 24 | import com.ryccoatika.imagetotext.ui.common.ui.AppTopBar 25 | import com.ryccoatika.imagetotext.ui.common.utils.rememberStateWithLifecycle 26 | 27 | @Composable 28 | fun ImagePreview( 29 | openImageResultScreen: (Long) -> Unit, 30 | navigateUp: () -> Unit, 31 | ) { 32 | 33 | ImagePreview( 34 | viewModel = hiltViewModel(), 35 | openImageResultScreen = openImageResultScreen, 36 | onNavigateUp = navigateUp 37 | ) 38 | } 39 | 40 | @Composable 41 | private fun ImagePreview( 42 | viewModel: ImagePreviewViewModel, 43 | openImageResultScreen: (Long) -> Unit, 44 | onNavigateUp: () -> Unit, 45 | ) { 46 | val viewState by rememberStateWithLifecycle(viewModel.state) 47 | 48 | viewState.event?.let { event -> 49 | LaunchedEffect(event) { 50 | when (event) { 51 | is ImagePreviewViewState.Event.OpenTextScannedDetail -> { 52 | openImageResultScreen(event.textScanned.id) 53 | } 54 | } 55 | viewModel.clearEvent() 56 | } 57 | } 58 | 59 | ImagePreview( 60 | state = viewState, 61 | onMessageShown = viewModel::clearMessage, 62 | onLanguageModelChanged = viewModel::setLanguageModel, 63 | onScanClick = viewModel::scanImage, 64 | onNavigateUp = onNavigateUp 65 | ) 66 | } 67 | 68 | @OptIn(ExperimentalMaterialApi::class) 69 | @Composable 70 | private fun ImagePreview( 71 | state: ImagePreviewViewState, 72 | onMessageShown: (Long) -> Unit, 73 | onLanguageModelChanged: (RecognationLanguageModel) -> Unit, 74 | onScanClick: () -> Unit, 75 | onNavigateUp: () -> Unit, 76 | ) { 77 | val scaffoldState = rememberScaffoldState() 78 | val imagePainter = rememberAsyncImagePainter(state.imageUri) 79 | var expanded by remember { mutableStateOf(false) } 80 | 81 | state.message?.let { message -> 82 | LaunchedEffect(message) { 83 | scaffoldState.snackbarHostState.showSnackbar( 84 | message = message.message 85 | ) 86 | onMessageShown(message.id) 87 | } 88 | } 89 | 90 | Scaffold( 91 | scaffoldState = scaffoldState, 92 | topBar = { 93 | AppTopBar( 94 | navigationIcon = { 95 | IconButton( 96 | onClick = onNavigateUp 97 | ) { 98 | Icon(Icons.Default.ArrowBack, contentDescription = null) 99 | } 100 | }, 101 | title = stringResource(R.string.title_preview_image) 102 | ) 103 | }, 104 | modifier = Modifier.navigationBarsPadding() 105 | ) { paddingValues -> 106 | Column( 107 | modifier = Modifier 108 | .padding(paddingValues) 109 | .padding(MaterialTheme.spacing.medium) 110 | ) { 111 | Spacer(Modifier.height(MaterialTheme.spacing.extraLarge)) 112 | Box( 113 | modifier = Modifier 114 | .fillMaxWidth() 115 | .aspectRatio(1f) 116 | .clip(MaterialTheme.shapes.large) 117 | .background(MaterialTheme.colors.secondary.copy(0.25f)) 118 | ) { 119 | Image( 120 | imagePainter, 121 | contentDescription = null, 122 | contentScale = ContentScale.Crop, 123 | modifier = Modifier 124 | .padding(MaterialTheme.spacing.medium) 125 | .fillMaxSize() 126 | .clip(MaterialTheme.shapes.large) 127 | .border(1.dp, MaterialTheme.colors.secondary, MaterialTheme.shapes.large) 128 | 129 | ) 130 | } 131 | Spacer(Modifier.height(MaterialTheme.spacing.large)) 132 | ExposedDropdownMenuBox( 133 | expanded = expanded, 134 | onExpandedChange = { 135 | expanded = !expanded 136 | }, 137 | modifier = Modifier 138 | .fillMaxWidth() 139 | .border(1.dp, MaterialTheme.colors.secondary, MaterialTheme.shapes.small) 140 | .padding(horizontal = 10.dp, vertical = 12.dp), 141 | ) { 142 | Row( 143 | horizontalArrangement = Arrangement.SpaceBetween, 144 | modifier = Modifier 145 | .fillMaxWidth() 146 | ) { 147 | Text( 148 | state.languageModel?.getText() 149 | ?: stringResource(R.string.text_choose_language) 150 | ) 151 | Icon(Icons.Default.KeyboardArrowDown, contentDescription = null) 152 | } 153 | ExposedDropdownMenu( 154 | expanded = expanded, 155 | onDismissRequest = { expanded = false } 156 | ) { 157 | RecognationLanguageModel.values().forEach { recogLangModel -> 158 | DropdownMenuItem( 159 | onClick = { 160 | onLanguageModelChanged(recogLangModel) 161 | expanded = false 162 | }, 163 | modifier = Modifier.fillMaxWidth() 164 | ) { 165 | Text(recogLangModel.getText()) 166 | } 167 | } 168 | } 169 | } 170 | Spacer(Modifier.weight(1f)) 171 | Button( 172 | onClick = onScanClick, 173 | enabled = state.isValid && !state.processing, 174 | modifier = Modifier.fillMaxWidth(), 175 | ) { 176 | Text(stringResource(R.string.button_continue)) 177 | } 178 | if (state.processing) { 179 | LinearProgressIndicator( 180 | modifier = Modifier.fillMaxWidth() 181 | ) 182 | } 183 | } 184 | } 185 | } 186 | 187 | @Composable 188 | private fun RecognationLanguageModel.getText(): String { 189 | return stringResource( 190 | when (this) { 191 | RecognationLanguageModel.LATIN -> R.string.lang_latin 192 | RecognationLanguageModel.CHINESE -> R.string.lang_chinese 193 | RecognationLanguageModel.JAPANESE -> R.string.lang_japanese 194 | RecognationLanguageModel.KOREAN -> R.string.lang_korean 195 | RecognationLanguageModel.DEVANAGARI -> R.string.lang_devanagari 196 | } 197 | ) 198 | } 199 | 200 | @Preview 201 | @Composable 202 | private fun ImagePreviewPreview() { 203 | AppTheme { 204 | ImagePreview( 205 | state = ImagePreviewViewState.Empty, 206 | onMessageShown = {}, 207 | onLanguageModelChanged = {}, 208 | onScanClick = {}, 209 | onNavigateUp = {} 210 | ) 211 | } 212 | } -------------------------------------------------------------------------------- /ui/src/main/java/com/ryccoatika/imagetotext/ui/imagepreview/ImagePreviewViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.ui.imagepreview 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.Context 5 | import android.graphics.BitmapFactory 6 | import android.net.Uri 7 | import androidx.lifecycle.SavedStateHandle 8 | import androidx.lifecycle.ViewModel 9 | import androidx.lifecycle.viewModelScope 10 | import com.google.mlkit.vision.common.InputImage 11 | import com.ryccoatika.imagetotext.domain.exceptions.ImageBroken 12 | import com.ryccoatika.imagetotext.domain.exceptions.TextScanFailure 13 | import com.ryccoatika.imagetotext.domain.exceptions.TextScanNotFound 14 | import com.ryccoatika.imagetotext.domain.model.RecognationLanguageModel 15 | import com.ryccoatika.imagetotext.domain.usecase.GetTextFromImage 16 | import com.ryccoatika.imagetotext.domain.usecase.SaveTextScanned 17 | import com.ryccoatika.imagetotext.domain.utils.ObservableLoadingCounter 18 | import com.ryccoatika.imagetotext.domain.utils.UiMessage 19 | import com.ryccoatika.imagetotext.domain.utils.UiMessageManager 20 | import com.ryccoatika.imagetotext.domain.utils.combine 21 | import com.ryccoatika.imagetotext.ui.R 22 | import dagger.hilt.android.lifecycle.HiltViewModel 23 | import dagger.hilt.android.qualifiers.ApplicationContext 24 | import kotlinx.coroutines.flow.* 25 | import kotlinx.coroutines.launch 26 | import javax.inject.Inject 27 | 28 | 29 | @HiltViewModel 30 | @SuppressLint("StaticFieldLeak") 31 | class ImagePreviewViewModel @Inject constructor( 32 | savedStateHandle: SavedStateHandle, 33 | @ApplicationContext 34 | private val context: Context, 35 | private val getTextFromImage: GetTextFromImage, 36 | private val saveTextScanned: SaveTextScanned, 37 | ) : ViewModel() { 38 | 39 | private val imageUri: Uri = Uri.parse(savedStateHandle["uri"]!!) 40 | private val recognitionLanguageModel = MutableStateFlow(null) 41 | private val isValid: StateFlow = recognitionLanguageModel.map { it != null }.stateIn( 42 | scope = viewModelScope, 43 | started = SharingStarted.Eagerly, 44 | initialValue = false 45 | ) 46 | 47 | private val loadingCounter = ObservableLoadingCounter() 48 | private val uiMessageManager = UiMessageManager() 49 | private val event = MutableStateFlow(null) 50 | 51 | val state: StateFlow = combine( 52 | loadingCounter.observable, 53 | flowOf(imageUri), 54 | recognitionLanguageModel, 55 | isValid, 56 | uiMessageManager.message, 57 | event, 58 | ::ImagePreviewViewState 59 | ).stateIn( 60 | scope = viewModelScope, 61 | started = SharingStarted.WhileSubscribed(5000), 62 | initialValue = ImagePreviewViewState.Empty 63 | ) 64 | 65 | fun setLanguageModel(langModel: RecognationLanguageModel) { 66 | recognitionLanguageModel.value = langModel 67 | } 68 | 69 | fun scanImage() { 70 | if (!isValid.value || recognitionLanguageModel.value == null) return 71 | viewModelScope.launch { 72 | try { 73 | loadingCounter.addLoader() 74 | val textRecognized = getTextFromImage.executeSync( 75 | GetTextFromImage.Params( 76 | inputImage = InputImage.fromFilePath(context, imageUri), 77 | languageModel = recognitionLanguageModel.value!! 78 | ) 79 | ) 80 | val text = textRecognized.textBlocks.joinToString("\n\n") { textBlock -> 81 | textBlock.lines.joinToString("\n") { line -> 82 | line.elements.joinToString(" ") { it.text } 83 | } 84 | } 85 | val parcelFileDescriptor = context.contentResolver.openFileDescriptor(imageUri, "r") 86 | ?: throw ImageBroken() 87 | 88 | val image = BitmapFactory.decodeFileDescriptor(parcelFileDescriptor.fileDescriptor) 89 | parcelFileDescriptor.close() 90 | 91 | val textScanned = saveTextScanned.executeSync( 92 | SaveTextScanned.Params( 93 | image = image, 94 | textRecognized = textRecognized, 95 | text = text 96 | ) 97 | ) 98 | event.value = ImagePreviewViewState.Event.OpenTextScannedDetail(textScanned) 99 | loadingCounter.removeLoader() 100 | } catch (e: TextScanFailure) { 101 | loadingCounter.removeLoader() 102 | uiMessageManager.emitMessage( 103 | UiMessage( 104 | message = context.getString(R.string.error_scan_failure), 105 | throwable = e 106 | ) 107 | ) 108 | } catch (e: TextScanNotFound) { 109 | loadingCounter.removeLoader() 110 | uiMessageManager.emitMessage( 111 | UiMessage( 112 | message = context.getString(R.string.error_scan_not_found), 113 | throwable = e 114 | ) 115 | ) 116 | } catch (e: ImageBroken) { 117 | loadingCounter.removeLoader() 118 | uiMessageManager.emitMessage( 119 | UiMessage( 120 | message = context.getString(R.string.error_image_can_not_be_read), 121 | throwable = e 122 | ) 123 | ) 124 | } 125 | } 126 | } 127 | 128 | fun clearMessage(id: Long) { 129 | viewModelScope.launch { 130 | uiMessageManager.clearMessage(id) 131 | } 132 | } 133 | 134 | fun clearEvent() { 135 | event.value = null 136 | } 137 | } -------------------------------------------------------------------------------- /ui/src/main/java/com/ryccoatika/imagetotext/ui/imagepreview/ImagePreviewViewState.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.ui.imagepreview 2 | 3 | import android.net.Uri 4 | import com.ryccoatika.imagetotext.domain.model.RecognationLanguageModel 5 | import com.ryccoatika.imagetotext.domain.model.TextScanned 6 | import com.ryccoatika.imagetotext.domain.utils.UiMessage 7 | 8 | data class ImagePreviewViewState( 9 | val processing: Boolean = false, 10 | val imageUri: Uri = Uri.EMPTY, 11 | val languageModel: RecognationLanguageModel? = null, 12 | val isValid: Boolean = false, 13 | val message: UiMessage? = null, 14 | val event: Event? = null 15 | ) { 16 | companion object { 17 | val Empty = ImagePreviewViewState() 18 | } 19 | 20 | sealed class Event { 21 | data class OpenTextScannedDetail(val textScanned: TextScanned) : Event() 22 | } 23 | } -------------------------------------------------------------------------------- /ui/src/main/java/com/ryccoatika/imagetotext/ui/intro/Intro.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.ui.intro 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.layout.* 6 | import androidx.compose.material.* 7 | import androidx.compose.material.icons.Icons 8 | import androidx.compose.material.icons.filled.KeyboardArrowRight 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.rememberCoroutineScope 11 | import androidx.compose.ui.Alignment 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.res.painterResource 14 | import androidx.compose.ui.res.stringResource 15 | import androidx.compose.ui.text.style.TextAlign 16 | import androidx.compose.ui.tooling.preview.Preview 17 | import com.google.accompanist.pager.ExperimentalPagerApi 18 | import com.google.accompanist.pager.HorizontalPager 19 | import com.google.accompanist.pager.rememberPagerState 20 | import com.ryccoatika.imagetotext.ui.R 21 | import com.ryccoatika.imagetotext.ui.common.theme.AppTheme 22 | import com.ryccoatika.imagetotext.ui.common.theme.spacing 23 | import com.ryccoatika.imagetotext.ui.common.ui.DotIndicator 24 | import kotlinx.coroutines.launch 25 | 26 | @OptIn(ExperimentalPagerApi::class) 27 | @Composable 28 | fun Intro( 29 | openHomeScreen: () -> Unit 30 | ) { 31 | val coroutineScope = rememberCoroutineScope() 32 | 33 | val introDrawables = listOf(R.drawable.intro_1, R.drawable.intro_2, R.drawable.intro_3) 34 | val introCaptions = 35 | listOf(R.string.intro_caption_1, R.string.intro_caption_2, R.string.intro_caption_3) 36 | 37 | val pagerState = rememberPagerState() 38 | 39 | Scaffold( 40 | modifier = Modifier.navigationBarsPadding() 41 | ) { paddingValues -> 42 | Column( 43 | modifier = Modifier 44 | .padding(paddingValues) 45 | .fillMaxSize() 46 | ) { 47 | HorizontalPager( 48 | count = introDrawables.size, 49 | state = pagerState, 50 | modifier = Modifier.weight(1f) 51 | ) { page -> 52 | Column( 53 | modifier = Modifier 54 | .padding(MaterialTheme.spacing.extraLarge) 55 | .fillMaxSize() 56 | ) { 57 | Image( 58 | painter = painterResource(id = introDrawables[page]), 59 | contentDescription = null, 60 | modifier = Modifier 61 | .weight(1f) 62 | .fillMaxWidth() 63 | ) 64 | Spacer(Modifier.height(MaterialTheme.spacing.small)) 65 | Text( 66 | stringResource(introCaptions[page]), 67 | textAlign = TextAlign.Center, 68 | modifier = Modifier.fillMaxWidth() 69 | ) 70 | } 71 | } 72 | Row( 73 | verticalAlignment = Alignment.CenterVertically, 74 | modifier = Modifier 75 | .fillMaxWidth() 76 | .background(MaterialTheme.colors.primary) 77 | ) { 78 | TextButton(onClick = openHomeScreen) { 79 | Text( 80 | stringResource(R.string.button_skip).uppercase(), 81 | color = MaterialTheme.colors.onPrimary 82 | ) 83 | } 84 | DotIndicator( 85 | count = pagerState.pageCount, 86 | activeIndex = pagerState.currentPage, 87 | modifier = Modifier.weight(1f) 88 | ) 89 | if (pagerState.currentPage == pagerState.pageCount - 1) { 90 | TextButton(onClick = openHomeScreen) { 91 | Text( 92 | stringResource(R.string.button_finish).uppercase(), 93 | color = MaterialTheme.colors.onPrimary 94 | ) 95 | } 96 | } else { 97 | IconButton( 98 | onClick = { 99 | coroutineScope.launch { 100 | pagerState.animateScrollToPage(pagerState.currentPage+1) 101 | } 102 | } 103 | ) { 104 | Icon( 105 | imageVector = Icons.Default.KeyboardArrowRight, 106 | contentDescription = null, 107 | tint = MaterialTheme.colors.onPrimary, 108 | ) 109 | } 110 | } 111 | } 112 | } 113 | } 114 | } 115 | 116 | @Preview 117 | @Composable 118 | private fun IntroPreview() { 119 | AppTheme { 120 | Intro( 121 | openHomeScreen = {} 122 | ) 123 | } 124 | } -------------------------------------------------------------------------------- /ui/src/main/java/com/ryccoatika/imagetotext/ui/utils/ReviewHelper.kt: -------------------------------------------------------------------------------- 1 | package com.ryccoatika.imagetotext.ui.utils 2 | 3 | import android.app.Activity 4 | import com.google.android.play.core.review.ReviewManagerFactory 5 | 6 | object ReviewHelper { 7 | fun launchInAppReview(activity: Activity, onCompleted: (() -> Unit)? = null) { 8 | val reviewManager = ReviewManagerFactory.create(activity) 9 | reviewManager.requestReviewFlow().addOnCompleteListener { result -> 10 | if (result.isSuccessful) { 11 | reviewManager 12 | .launchReviewFlow(activity, result.result) 13 | .addOnCompleteListener { onCompleted?.invoke() } 14 | } else { 15 | onCompleted?.invoke() 16 | } 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /ui/src/main/res/drawable/decoration_empty.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryccoatika/Image-To-Text/2a5c376837d332a99718a1cc32effb39b8d8cf53/ui/src/main/res/drawable/decoration_empty.png -------------------------------------------------------------------------------- /ui/src/main/res/drawable/intro_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryccoatika/Image-To-Text/2a5c376837d332a99718a1cc32effb39b8d8cf53/ui/src/main/res/drawable/intro_1.png -------------------------------------------------------------------------------- /ui/src/main/res/drawable/intro_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryccoatika/Image-To-Text/2a5c376837d332a99718a1cc32effb39b8d8cf53/ui/src/main/res/drawable/intro_2.png -------------------------------------------------------------------------------- /ui/src/main/res/drawable/intro_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryccoatika/Image-To-Text/2a5c376837d332a99718a1cc32effb39b8d8cf53/ui/src/main/res/drawable/intro_3.png -------------------------------------------------------------------------------- /ui/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Image to Text 4 | 5 | Oops, something wen\'t wrong, please try again later 6 | Oops, we can\'t find any text on the image 7 | Oops, we can\'t read your image 8 | 9 | Preview 10 | Preview Image 11 | Your Documents 12 | 13 | Continue 14 | Copy 15 | Finish 16 | Delete 17 | Skip 18 | 19 | Search 20 | 21 | Click the floating button on the bottom right to scan new image 22 | Long press on the item to copy the text 23 | Slide left on the item to delete 24 | 25 | Latin 26 | 中文 27 | हिन्दी 28 | 日本語 29 | 한국어 30 | 31 | Choose Language 32 | Let\'s scan texts from images 33 | --------------------------------------------------------------------------------