├── .gitattributes ├── .github ├── ci-gradle.properties └── workflows │ └── fastlane-metadata.yaml ├── .gitignore ├── .idea ├── .gitignore ├── .name ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── compiler.xml ├── inspectionProfiles │ └── Project_Default.xml ├── kotlinc.xml ├── misc.xml └── vcs.xml ├── LICENSE ├── PrivacyPolicy.md ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── dev │ │ └── atajan │ │ └── lingva_android │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── ic_launcher-playstore.png │ ├── java │ │ └── dev │ │ │ └── atajan │ │ │ └── lingva_android │ │ │ ├── MainActivity.kt │ │ │ ├── MainApplication.kt │ │ │ ├── QuickTranslateActivity.kt │ │ │ ├── common │ │ │ ├── data │ │ │ │ ├── api │ │ │ │ │ ├── KtorLingvaApi.kt │ │ │ │ │ ├── LingvaApi.kt │ │ │ │ │ ├── constants │ │ │ │ │ │ └── ApiConstants.kt │ │ │ │ │ └── lingvadto │ │ │ │ │ │ ├── audio │ │ │ │ │ │ └── AudioDTO.kt │ │ │ │ │ │ ├── language │ │ │ │ │ │ ├── LanguageDTO.kt │ │ │ │ │ │ └── LanguagesDTO.kt │ │ │ │ │ │ └── translation │ │ │ │ │ │ ├── DefinitionDTO.kt │ │ │ │ │ │ ├── DefinitionInfoDTO.kt │ │ │ │ │ │ ├── ExtraTranslationDTO.kt │ │ │ │ │ │ ├── ExtraTranslationListDTO.kt │ │ │ │ │ │ ├── InfoDTO.kt │ │ │ │ │ │ ├── PronunciationDTO.kt │ │ │ │ │ │ └── TranslationDTO.kt │ │ │ │ └── datasource │ │ │ │ │ ├── AudioRepository.kt │ │ │ │ │ ├── LanguagesRepository.kt │ │ │ │ │ ├── TranslationRepository.kt │ │ │ │ │ └── impl │ │ │ │ │ ├── KtorAudioRepository.kt │ │ │ │ │ ├── KtorLanguagesRepository.kt │ │ │ │ │ ├── KtorTranslationRepository.kt │ │ │ │ │ └── PreferencesDatastore.kt │ │ │ ├── di │ │ │ │ ├── AppScopeModule.kt │ │ │ │ ├── AudioPlayerModule.kt │ │ │ │ ├── ClipboardManagerModule.kt │ │ │ │ ├── DataStoreModule.kt │ │ │ │ ├── LingvaApiModule.kt │ │ │ │ ├── RepositoryModule.kt │ │ │ │ └── UseCaseModule.kt │ │ │ ├── domain │ │ │ │ ├── errors │ │ │ │ │ └── Errors.kt │ │ │ │ ├── models │ │ │ │ │ ├── audio │ │ │ │ │ │ └── Audio.kt │ │ │ │ │ ├── language │ │ │ │ │ │ └── Language.kt │ │ │ │ │ └── translation │ │ │ │ │ │ ├── Translation.kt │ │ │ │ │ │ ├── TranslationInfo.kt │ │ │ │ │ │ └── TranslationWithInfo.kt │ │ │ │ └── results │ │ │ │ │ └── RepositoryResponse.kt │ │ │ ├── media │ │ │ │ ├── AudioPlayer.kt │ │ │ │ └── NativeAudioPlayer.kt │ │ │ ├── redux │ │ │ │ ├── MVIViewModel.kt │ │ │ │ └── MiddleWares.kt │ │ │ ├── ui │ │ │ │ ├── components │ │ │ │ │ ├── AppThemeSelectionRadioButtonRows.kt │ │ │ │ │ ├── BottomSheetSectionHeader.kt │ │ │ │ │ ├── LanguageListPopUp.kt │ │ │ │ │ ├── LanguageSelectionAndSettingsBar.kt │ │ │ │ │ ├── LanguageSelectionBar.kt │ │ │ │ │ ├── NotificationDialog.kt │ │ │ │ │ ├── SelectDefaultLanguagesColumn.kt │ │ │ │ │ ├── SettingsBottomSheet.kt │ │ │ │ │ ├── SettingsBottomSheetThemedOutlinedTextField.kt │ │ │ │ │ └── TitleBar.kt │ │ │ │ └── theme │ │ │ │ │ ├── Color.kt │ │ │ │ │ ├── Helpers.kt │ │ │ │ │ ├── Shape.kt │ │ │ │ │ ├── Theme.kt │ │ │ │ │ └── Type.kt │ │ │ └── usecases │ │ │ │ ├── FetchSupportedLanguagesUseCase.kt │ │ │ │ ├── ObserveAudioDataUseCase.kt │ │ │ │ ├── ObserveTranslationResultUseCase.kt │ │ │ │ ├── PlayByteArrayAudioUseCase.kt │ │ │ │ ├── RequestAudioDataUseCase.kt │ │ │ │ ├── TranslateUseCase.kt │ │ │ │ └── ktorimpl │ │ │ │ ├── AudioPlayerPlayByteArrayAudioUseCase.kt │ │ │ │ ├── KtorFetchSupportedLanguagesUseCase.kt │ │ │ │ ├── KtorObserveAudioDataUseCase.kt │ │ │ │ ├── KtorObserveTranslationResultUseCase.kt │ │ │ │ ├── KtorRequestAudioDataUseCase.kt │ │ │ │ └── KtorTranslateUseCase.kt │ │ │ ├── quicktranslatefeature │ │ │ ├── redux │ │ │ │ ├── QuickTranslateScreenIntention.kt │ │ │ │ ├── QuickTranslateScreenSideEffect.kt │ │ │ │ └── QuickTranslateScreenState.kt │ │ │ └── screens │ │ │ │ ├── QuickTranslateScreen.kt │ │ │ │ └── QuickTranslateScreenViewModel.kt │ │ │ └── translatefeature │ │ │ ├── redux │ │ │ ├── TranslateScreenIntention.kt │ │ │ ├── TranslateScreenSideEffect.kt │ │ │ └── TranslateScreenState.kt │ │ │ └── screens │ │ │ ├── TranslateScreen.kt │ │ │ └── TranslateScreenViewModel.kt │ └── res │ │ ├── drawable │ │ ├── ic_launcher_foreground.xml │ │ └── ic_launcher_monochrome_foreground.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── values-de │ │ └── strings.xml │ │ ├── values-fr │ │ └── strings.xml │ │ ├── values-it │ │ └── strings.xml │ │ ├── values-ja │ │ └── strings.xml │ │ ├── values-night │ │ └── themes.xml │ │ ├── values-pt-rBR │ │ └── strings.xml │ │ ├── values-pt │ │ └── strings.xml │ │ ├── values-ru │ │ └── strings.xml │ │ ├── values-tr │ │ └── strings.xml │ │ └── values │ │ ├── colors.xml │ │ ├── ic_launcher_background.xml │ │ ├── strings.xml │ │ └── themes.xml │ └── test │ └── java │ └── dev │ └── atajan │ └── lingva_android │ └── common │ └── domain │ └── language │ └── LanguageTest.kt ├── build.gradle.kts ├── fastlane └── metadata │ └── android │ └── en-US │ ├── changelogs │ ├── 15.txt │ ├── 16.txt │ ├── 17.txt │ ├── 18.txt │ ├── 19.txt │ ├── 20.txt │ ├── 21.txt │ └── 22.txt │ ├── full_description.txt │ ├── images │ ├── icon.png │ └── phoneScreenshots │ │ ├── 1.png │ │ ├── 2.png │ │ └── 3.png │ ├── short_description.txt │ └── title.txt ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle.kts /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/ci-gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.daemon=false 2 | org.gradle.parallel=true 3 | org.gradle.jvmargs=-Xmx5120m 4 | org.gradle.workers.max=2 5 | 6 | kotlin.incremental=false 7 | kotlin.compiler.execution.strategy=in-process -------------------------------------------------------------------------------- /.github/workflows/fastlane-metadata.yaml: -------------------------------------------------------------------------------- 1 | name: Fastlane Metadata 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - "[0-9]+.[0-9]+.x" 7 | tags: 8 | - "[0-9]+.[0-9]+.[0-9]+" 9 | paths: 10 | - "fastlane/**" 11 | - ".github/workflows/fastlane-metadata.yaml" 12 | pull_request: 13 | branches: 14 | - main 15 | - "[0-9]+.[0-9]+.x" 16 | paths: 17 | - "fastlane/**" 18 | - ".github/workflows/fastlane-metadata.yaml" 19 | 20 | jobs: 21 | validate: 22 | name: Validate 23 | runs-on: ubuntu-latest 24 | timeout-minutes: 15 25 | steps: 26 | - uses: actions/checkout@v3 27 | - uses: ashutoshgngwr/validate-fastlane-supply-metadata@v2 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # MacOS 2 | *.DS_Store 3 | 4 | # Built application files 5 | *.apk 6 | *.ap_ 7 | *.aab 8 | 9 | # Files for the ART/Dalvik VM 10 | *.dex 11 | 12 | # Java class files 13 | *.class 14 | 15 | # Generated files 16 | bin/ 17 | gen/ 18 | out/ 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 | .idea/caches 48 | 49 | # Keystore files 50 | # Uncomment the following lines if you do not want to check your keystore files in. 51 | #*.jks 52 | #*.keystore 53 | 54 | # External native build folder generated in Android Studio 2.2 and later 55 | .externalNativeBuild 56 | 57 | # Google Services (e.g. APIs or Firebase) 58 | google-services.json 59 | 60 | # Freeline 61 | freeline.py 62 | freeline/ 63 | freeline_project_description.json 64 | 65 | # fastlane 66 | fastlane/report.xml 67 | fastlane/Preview.html 68 | fastlane/screenshots 69 | fastlane/test_output 70 | fastlane/readme.md 71 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.idea/.name: -------------------------------------------------------------------------------- 1 | lingva-android -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 11 | 12 | 124 | 125 | 127 | 128 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 37 | -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 26 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /PrivacyPolicy.md: -------------------------------------------------------------------------------- 1 | # LENTIL TRANSLATE PRIVACY POLICY 2 | **Privacy Policy** 3 | 4 | Lentil Translate app is built as an Open Source app. This SERVICE is provided by Yaxarat at no cost and is intended for use as is. 5 | 6 | This page is used to inform visitors regarding my policies with the collection, use, and disclosure of Personal Information if anyone decided to use my Service. 7 | 8 | If you choose to use my Service, then you agree to the collection and use of information in relation to this policy. The Personal Information that I collect is used for providing and improving the Service. I will not use or share your information with anyone except as described in this Privacy Policy. 9 | 10 | The terms used in this Privacy Policy have the same meanings as in our Terms and Conditions, which are accessible at Lentil Translate unless otherwise defined in this Privacy Policy. 11 | 12 | **Information Collection and Use** 13 | 14 | For a better experience, while using our Service, I may require you to provide us with certain personally identifiable information. The information that I request will be retained on your device and is not collected by me in any way. 15 | 16 | The app does use third-party services that may collect information used to identify you. 17 | 18 | Link to the privacy policy of third-party service providers used by the app 19 | 20 | * [Google Play Services](https://www.google.com/policies/privacy/) 21 | 22 | **Log Data** 23 | 24 | I want to inform you that whenever you use my Service, in a case of an error in the app I collect data and information (through third-party products) on your phone called Log Data. This Log Data may include information such as your device Internet Protocol (“IP”) address, device name, operating system version, the configuration of the app when utilizing my Service, the time and date of your use of the Service, and other statistics. 25 | 26 | **Cookies** 27 | 28 | Cookies are files with a small amount of data that are commonly used as anonymous unique identifiers. These are sent to your browser from the websites that you visit and are stored on your device's internal memory. 29 | 30 | This Service does not use these “cookies” explicitly. However, the app may use third-party code and libraries that use “cookies” to collect information and improve their services. You have the option to either accept or refuse these cookies and know when a cookie is being sent to your device. If you choose to refuse our cookies, you may not be able to use some portions of this Service. 31 | 32 | **Service Providers** 33 | 34 | I may employ third-party companies and individuals due to the following reasons: 35 | 36 | * To facilitate our Service; 37 | * To provide the Service on our behalf; 38 | * To perform Service-related services; or 39 | * To assist us in analyzing how our Service is used. 40 | 41 | I want to inform users of this Service that these third parties have access to their Personal Information. The reason is to perform the tasks assigned to them on our behalf. However, they are obligated not to disclose or use the information for any other purpose. 42 | 43 | **Security** 44 | 45 | I value your trust in providing us your Personal Information, thus we are striving to use commercially acceptable means of protecting it. But remember that no method of transmission over the internet, or method of electronic storage is 100% secure and reliable, and I cannot guarantee its absolute security. 46 | 47 | **Children’s Privacy** 48 | 49 | These Services do not address anyone under the age of 13. I do not knowingly collect personally identifiable information from children under 13 years of age. In the case I discover that a child under 13 has provided me with personal information, I immediately delete this from our servers. If you are a parent or guardian and you are aware that your child has provided us with personal information, please contact me so that I will be able to do the necessary actions. 50 | 51 | **Changes to This Privacy Policy** 52 | 53 | I may update our Privacy Policy from time to time. Thus, you are advised to review this page periodically for any changes. I will notify you of any changes by posting the new Privacy Policy on this page. 54 | 55 | This policy is effective as of 2022-05-05 56 | 57 | **Contact Us** 58 | 59 | If you have any questions or suggestions about my Privacy Policy, do not hesitate to contact me at ya.atajan@gmail.com. 60 | 61 | This privacy policy page was created at [privacypolicytemplate.net](https://privacypolicytemplate.net) and modified/generated by [App Privacy Policy Generator](https://app-privacy-policy-generator.nisrulz.com/) 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lentil Translate 2 | An unofficial Android client for [Lingva Translate](https://github.com/TheDavidDelta/lingva-translate) 3 | 4 | > If you want to try on the web client: 5 | https://lingva.ml 6 | 7 | [Get it on F-Droid](https://f-droid.org/packages/dev.atajan.lingva_android) 10 | [Get it on Google Play](https://play.google.com/store/apps/details?id=dev.atajan.lingva_android) 13 | 14 | --- 15 | 16 | ## Features 17 | 18 | • `It's open source.` 19 | 20 | • `It's privacy respecting.` 21 | > Check our [privacy policy](https://github.com/yaxarat/lingvaandroid/blob/main/PrivacyPolicy.md) 22 | 23 | • `Audio playbacks for your translations.` 24 | 25 | • `Supports multiple themes, including Material You.` 26 | 27 | • `Custom Lingva 28 | endpoints.` 29 | 30 | --- 31 | 32 | ## Screenshots 33 |
34 | 35 |
36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 |
45 | 46 |
47 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.application") 3 | id("dagger.hilt.android.plugin") 4 | kotlin("kapt") 5 | kotlin("android") 6 | kotlin("plugin.serialization") 7 | } 8 | 9 | android { 10 | compileSdk = 34 11 | 12 | defaultConfig { 13 | applicationId = "dev.atajan.lingva_android" 14 | minSdk = 26 15 | targetSdk = 34 16 | versionCode = 22 17 | versionName = "1.3.4" 18 | 19 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 20 | vectorDrawables { 21 | useSupportLibrary = true 22 | } 23 | } 24 | 25 | buildTypes { 26 | release { 27 | postprocessing { 28 | isRemoveUnusedCode = false 29 | isRemoveUnusedResources = true 30 | isObfuscate = false 31 | isOptimizeCode = true 32 | } 33 | } 34 | debug { } 35 | } 36 | compileOptions { 37 | sourceCompatibility = JavaVersion.VERSION_1_8 38 | targetCompatibility = JavaVersion.VERSION_1_8 39 | } 40 | kotlinOptions { 41 | jvmTarget = "1.8" 42 | } 43 | buildFeatures { 44 | compose = true 45 | } 46 | composeOptions { 47 | kotlinCompilerExtensionVersion = "1.4.3" 48 | } 49 | packagingOptions { // https://stackoverflow.com/a/47509465/8685398 50 | resources.excludes.add("META-INF/DEPENDENCIES") 51 | resources.excludes.add("META-INF/AL2.0") 52 | resources.excludes.add("META-INF/LGPL2.1") 53 | } 54 | namespace = "dev.atajan.lingva_android" 55 | } 56 | 57 | kotlin.sourceSets.all { 58 | languageSettings.apply { 59 | optIn("kotlinx.coroutines.ExperimentalCoroutinesApi") 60 | optIn("androidx.compose.ui.ExperimentalComposeUiApi") 61 | optIn("androidx.compose.foundation.ExperimentalFoundationApi") 62 | } 63 | } 64 | 65 | dependencies { 66 | implementation("androidx.core:core-ktx:1.12.0") 67 | implementation("androidx.appcompat:appcompat:1.6.1") 68 | implementation("com.google.android.material:material:1.9.0") 69 | implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2") 70 | implementation("androidx.activity:activity-compose:1.7.2") 71 | 72 | // Accompanist 73 | implementation("com.google.accompanist:accompanist-systemuicontroller:0.30.1") 74 | 75 | // Datastore 76 | implementation("androidx.datastore:datastore-preferences:1.0.0") 77 | 78 | // Result 79 | implementation("com.michael-bull.kotlin-result:kotlin-result:1.1.14") 80 | 81 | // Ktor 82 | implementation("io.ktor:ktor-client-android:2.1.2") 83 | implementation("io.ktor:ktor-serialization-kotlinx-json:2.1.2") 84 | implementation("io.ktor:ktor-client-logging-jvm:2.1.2") 85 | implementation("io.ktor:ktor-client-content-negotiation-jvm:2.1.2") 86 | 87 | // Compose 88 | implementation("androidx.compose.ui:ui:1.5.1") 89 | implementation("androidx.compose.material:material:1.5.1") 90 | implementation("androidx.compose.animation:animation:1.5.1") 91 | implementation("androidx.compose.ui:ui-tooling-preview:1.5.1") 92 | implementation("androidx.compose.material3:material3-android:1.2.0-alpha07") 93 | 94 | // note that due to the very large size of this dependency you should make sure to use 95 | // R8 / ProGuard to remove unused icons from your application. 96 | implementation("androidx.compose.material:material-icons-extended:1.5.1") 97 | 98 | // Hilt 99 | implementation("com.google.dagger:hilt-android:2.47") 100 | kapt("com.google.dagger:hilt-android-compiler:2.47") 101 | 102 | testImplementation("junit:junit:4.13.2") 103 | debugImplementation("androidx.compose.ui:ui-tooling:1.5.1") 104 | } 105 | -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /app/src/androidTest/java/dev/atajan/lingva_android/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("dev.atajan.lingva_android", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 15 | 16 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yaxarat/lingvaandroid/a159c29933e037f31b11f68b4a243b82bef0be52/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import androidx.activity.viewModels 7 | import androidx.compose.foundation.isSystemInDarkTheme 8 | import androidx.compose.material.ExperimentalMaterialApi 9 | import androidx.compose.runtime.MutableState 10 | import androidx.compose.runtime.mutableStateOf 11 | import androidx.lifecycle.lifecycleScope 12 | import dagger.hilt.android.AndroidEntryPoint 13 | import dev.atajan.lingva_android.common.ui.theme.LingvaAndroidTheme 14 | import dev.atajan.lingva_android.common.ui.theme.ThemingOptions 15 | import dev.atajan.lingva_android.common.ui.theme.canUseDynamicColor 16 | import dev.atajan.lingva_android.common.ui.theme.isSystemInNightMode 17 | import dev.atajan.lingva_android.common.ui.theme.selectedThemeFlow 18 | import dev.atajan.lingva_android.translatefeature.screens.TranslateScreenViewModel 19 | import dev.atajan.lingva_android.translatefeature.screens.TranslationScreen 20 | import kotlinx.coroutines.flow.launchIn 21 | import kotlinx.coroutines.flow.onEach 22 | 23 | @ExperimentalMaterialApi 24 | @AndroidEntryPoint 25 | class MainActivity : ComponentActivity() { 26 | 27 | private val viewModel: TranslateScreenViewModel by viewModels() 28 | 29 | override fun onCreate(savedInstanceState: Bundle?) { 30 | super.onCreate(savedInstanceState) 31 | 32 | val theme: MutableState = mutableStateOf( 33 | if (canUseDynamicColor) { 34 | ThemingOptions.YOU 35 | } else if (isSystemInNightMode(this)) { 36 | ThemingOptions.DARK 37 | } else { 38 | ThemingOptions.LIGHT 39 | } 40 | ) 41 | 42 | selectedThemeFlow(applicationContext) 43 | .onEach { 44 | it?.let { theme.value = ThemingOptions.valueOf(it) } 45 | } 46 | .launchIn(lifecycleScope) 47 | 48 | setContent { 49 | isSystemInDarkTheme() 50 | LingvaAndroidTheme(appTheme = theme.value) { 51 | TranslationScreen( 52 | viewModel = viewModel, 53 | currentTheme = theme 54 | ) 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/MainApplication.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android 2 | 3 | import android.app.Application 4 | import dagger.hilt.android.HiltAndroidApp 5 | 6 | @HiltAndroidApp 7 | class MainApplication : Application() 8 | -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/QuickTranslateActivity.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android 2 | 3 | import android.content.Intent 4 | import android.os.Build 5 | import android.os.Bundle 6 | import androidx.activity.ComponentActivity 7 | import androidx.activity.compose.setContent 8 | import androidx.activity.viewModels 9 | import androidx.compose.foundation.isSystemInDarkTheme 10 | import androidx.compose.material.ExperimentalMaterialApi 11 | import androidx.compose.runtime.collectAsState 12 | import androidx.compose.runtime.getValue 13 | import dagger.hilt.android.AndroidEntryPoint 14 | import dev.atajan.lingva_android.common.ui.theme.LingvaAndroidTheme 15 | import dev.atajan.lingva_android.common.ui.theme.ThemingOptions 16 | import dev.atajan.lingva_android.common.ui.theme.canUseDynamicColor 17 | import dev.atajan.lingva_android.common.ui.theme.selectedThemeFlow 18 | import dev.atajan.lingva_android.quicktranslatefeature.screens.QuickTranslateScreen 19 | import dev.atajan.lingva_android.quicktranslatefeature.screens.QuickTranslateScreenViewModel 20 | 21 | @ExperimentalMaterialApi 22 | @AndroidEntryPoint 23 | class QuickTranslateActivity : ComponentActivity() { 24 | 25 | private val viewModel: QuickTranslateScreenViewModel by viewModels() 26 | 27 | override fun onCreate(savedInstanceState: Bundle?) { 28 | super.onCreate(savedInstanceState) 29 | 30 | setContent { 31 | val selectedTheme: String? by selectedThemeFlow(applicationContext) 32 | .collectAsState(initial = null) 33 | 34 | val appTheme: ThemingOptions = if (selectedTheme.isNullOrBlank()) { 35 | if (canUseDynamicColor) { 36 | ThemingOptions.YOU 37 | } else if (isSystemInDarkTheme()) { 38 | ThemingOptions.DARK 39 | } else { 40 | ThemingOptions.LIGHT 41 | } 42 | } else { 43 | ThemingOptions.valueOf(selectedTheme!!) 44 | } 45 | 46 | LingvaAndroidTheme(appTheme = appTheme) { 47 | QuickTranslateScreen( 48 | textToTranslate = getTextToTranslate(), 49 | viewModel = viewModel 50 | ) 51 | } 52 | } 53 | } 54 | 55 | override fun onPause() { 56 | super.onPause() 57 | finish() 58 | } 59 | 60 | 61 | private fun getTextToTranslate(): String { 62 | val textToQuickTranslate = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 63 | intent.getCharSequenceExtra(Intent.EXTRA_TEXT) 64 | } else { 65 | null 66 | } 67 | 68 | return (textToQuickTranslate ?: intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT)) as String 69 | } 70 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/data/api/KtorLingvaApi.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.data.api 2 | 3 | import androidx.datastore.core.DataStore 4 | import androidx.datastore.preferences.core.Preferences 5 | import dev.atajan.lingva_android.common.data.api.constants.AUDIO_PATH_SEGMENT 6 | import dev.atajan.lingva_android.common.data.api.constants.SUPPORTED_LANGUAGE_PATH_SEGMENT 7 | import dev.atajan.lingva_android.common.data.api.lingvadto.audio.AudioDTO 8 | import dev.atajan.lingva_android.common.data.api.lingvadto.language.LanguagesDTO 9 | import dev.atajan.lingva_android.common.data.api.lingvadto.translation.TranslationDTO 10 | import dev.atajan.lingva_android.common.data.datasource.impl.CUSTOM_LINGVA_ENDPOINT 11 | import dev.atajan.lingva_android.common.domain.errors.LingvaApiError.BadCustomEndpoint 12 | import dev.atajan.lingva_android.common.domain.errors.LingvaApiError.BadEndpoints 13 | import io.ktor.client.HttpClient 14 | import io.ktor.client.call.body 15 | import io.ktor.client.request.get 16 | import io.ktor.client.statement.HttpResponse 17 | import io.ktor.http.appendPathSegments 18 | import kotlinx.coroutines.Dispatchers 19 | import kotlinx.coroutines.flow.first 20 | import kotlinx.coroutines.withContext 21 | import javax.inject.Inject 22 | 23 | class KtorLingvaApi @Inject constructor( 24 | private val androidHttpClient: HttpClient, 25 | private val dataStore: DataStore, 26 | private val endpoints: List 27 | ) : LingvaApi { 28 | 29 | /** 30 | * Requesting list of supported languages from Lingva API 31 | */ 32 | override suspend fun getSupportedLanguages() = attemptSupportedLanguagesRequest() 33 | 34 | private suspend fun attemptSupportedLanguagesRequest(endpointIndex: Int = 0): LanguagesDTO { 35 | 36 | // Throw only when all fallback endpoints have been exhausted 37 | if (endpointIndex > endpoints.lastIndex) throw BadEndpoints 38 | 39 | return try { 40 | val response = androidHttpClient 41 | .get(endpoints[endpointIndex] + SUPPORTED_LANGUAGE_PATH_SEGMENT) 42 | 43 | if (response.isSuccessful()) return response.body() else throw BadEndpoints 44 | } catch (e: Exception) { 45 | attemptSupportedLanguagesRequest(endpointIndex = endpointIndex + 1) 46 | } 47 | } 48 | 49 | /** 50 | * Requesting translation from Lingva API 51 | */ 52 | override suspend fun translate( 53 | source: String, 54 | target: String, 55 | query: String 56 | ): TranslationDTO { 57 | val customEndpoint = getCustomEndpoint() 58 | val encodedQuery = withContext(Dispatchers.IO) { 59 | escapeQuery(query.trim()) 60 | } 61 | 62 | return if (customEndpoint.isNotEmpty()) { 63 | try { 64 | requestToEndpoint( 65 | source = source, 66 | target = target, 67 | query = encodedQuery, 68 | endpoint = customEndpoint 69 | ) 70 | } catch (e: Exception) { 71 | throw BadCustomEndpoint 72 | } 73 | } else { 74 | attemptTranslationRequest( 75 | source = source, 76 | target = target, 77 | query = encodedQuery 78 | ) 79 | } 80 | } 81 | 82 | private suspend fun getCustomEndpoint(): String { 83 | return dataStore.data.first()[CUSTOM_LINGVA_ENDPOINT] ?: "" 84 | } 85 | 86 | private suspend fun attemptTranslationRequest( 87 | source: String, 88 | target: String, 89 | query: String, 90 | endpointIndex: Int = 0 91 | ): TranslationDTO { 92 | // Throw only when all fallback endpoints have been exhausted 93 | if (endpointIndex > endpoints.lastIndex) throw BadEndpoints 94 | 95 | return try { 96 | requestToEndpoint( 97 | source = source, 98 | target = target, 99 | query = query, 100 | endpoint = endpoints[endpointIndex] 101 | ) 102 | } catch (e: Exception) { 103 | attemptTranslationRequest( 104 | source = source, 105 | target = target, 106 | query = query, 107 | endpointIndex = endpointIndex + 1 108 | ) 109 | } 110 | } 111 | 112 | private suspend fun requestToEndpoint( 113 | source: String, 114 | target: String, 115 | query: String, 116 | endpoint: String 117 | ): TranslationDTO { 118 | val response = androidHttpClient.get(endpoint) { 119 | url { 120 | appendPathSegments(source, target, query) 121 | } 122 | } 123 | 124 | if (response.isSuccessful()) return response.body() else throw BadEndpoints 125 | } 126 | 127 | /** 128 | * Requesting audio from Lingva API 129 | */ 130 | override suspend fun getAudio( 131 | language: String, 132 | query: String 133 | ): AudioDTO { 134 | return attemptAudioRequest( 135 | language = language, 136 | query = query 137 | ) 138 | } 139 | 140 | private suspend fun attemptAudioRequest( 141 | language: String, 142 | query: String, 143 | endpointIndex: Int = 0 144 | ): AudioDTO { 145 | // Throw only when all fallback endpoints have been exhausted 146 | if (endpointIndex > endpoints.lastIndex) throw BadEndpoints 147 | 148 | return try { 149 | requestToAudioEndoint( 150 | language = language, 151 | query = query, 152 | endpoint = endpoints[endpointIndex] 153 | ) 154 | } catch (e: Exception) { 155 | attemptAudioRequest( 156 | language = language, 157 | query = query, 158 | endpointIndex = endpointIndex + 1 159 | ) 160 | } 161 | } 162 | 163 | private suspend fun requestToAudioEndoint( 164 | language: String, 165 | query: String, 166 | endpoint: String 167 | ): AudioDTO { 168 | val response = androidHttpClient.get(endpoint + AUDIO_PATH_SEGMENT) { 169 | url { 170 | appendPathSegments(language, query) 171 | } 172 | } 173 | 174 | if (response.isSuccessful()) return response.body() else throw BadEndpoints 175 | } 176 | 177 | 178 | /** 179 | * Helper functions 180 | */ 181 | private fun escapeQuery(query: String): String { 182 | return query.replace("/", "%2F") 183 | } 184 | 185 | private fun HttpResponse.isSuccessful() = status.value in 200..299 186 | } 187 | -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/data/api/LingvaApi.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.data.api 2 | 3 | import dev.atajan.lingva_android.common.data.api.lingvadto.audio.AudioDTO 4 | import dev.atajan.lingva_android.common.data.api.lingvadto.language.LanguagesDTO 5 | import dev.atajan.lingva_android.common.data.api.lingvadto.translation.TranslationDTO 6 | 7 | interface LingvaApi { 8 | 9 | suspend fun translate( 10 | source: String, 11 | target: String, 12 | query: String 13 | ): TranslationDTO 14 | 15 | suspend fun getSupportedLanguages(): LanguagesDTO 16 | 17 | suspend fun getAudio( 18 | language: String, 19 | query: String 20 | ): AudioDTO 21 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/data/api/constants/ApiConstants.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.data.api.constants 2 | 3 | internal const val LINGVA = "https://lingva.ml/api/v1/" 4 | //internal const val PLAUSIBILITY = "https://translate.plausibility.cloud/api/v1/" // DOES NOT INCLUDE TRANSLATION INFO IN RESPONSE! 5 | internal const val PROJECTSEGFAU = "https://translate.projectsegfau.lt/api/v1/" 6 | internal const val DR460NF1R3 = "https://translate.dr460nf1r3.org/api/v1/" 7 | internal const val GARUDALINUX = "https://lingva.garudalinux.org/api/v1/" 8 | 9 | internal val TRANSLATION_PROVIDERS by lazy { 10 | listOf( 11 | LINGVA, 12 | // PLAUSIBILITY, 13 | PROJECTSEGFAU, 14 | DR460NF1R3, 15 | GARUDALINUX 16 | ) 17 | } 18 | 19 | internal const val SUPPORTED_LANGUAGE_PATH_SEGMENT = "languages/?:(source|target)" 20 | 21 | internal const val AUDIO_PATH_SEGMENT = "audio/" -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/data/api/lingvadto/audio/AudioDTO.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.data.api.lingvadto.audio 2 | 3 | import androidx.annotation.Keep 4 | import kotlinx.serialization.Serializable 5 | 6 | @Keep 7 | @Serializable 8 | data class AudioDTO(val audio : ArrayList? = null) 9 | -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/data/api/lingvadto/language/LanguageDTO.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.data.api.lingvadto.language 2 | 3 | import androidx.annotation.Keep 4 | import kotlinx.serialization.Serializable 5 | 6 | @Keep 7 | @Serializable 8 | data class LanguageDTO( 9 | val code: String? = null, 10 | val name: String? = null, 11 | ) -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/data/api/lingvadto/language/LanguagesDTO.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.data.api.lingvadto.language 2 | 3 | import androidx.annotation.Keep 4 | import kotlinx.serialization.Serializable 5 | 6 | @Keep 7 | @Serializable 8 | data class LanguagesDTO( 9 | val languages: List? = null, 10 | ) -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/data/api/lingvadto/translation/DefinitionDTO.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.data.api.lingvadto.translation 2 | 3 | import androidx.annotation.Keep 4 | import kotlinx.serialization.Serializable 5 | 6 | @Keep 7 | @Serializable 8 | data class DefinitionDTO( 9 | val list: List? = null, 10 | val type: String? = null, 11 | ) -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/data/api/lingvadto/translation/DefinitionInfoDTO.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.data.api.lingvadto.translation 2 | 3 | import androidx.annotation.Keep 4 | import kotlinx.serialization.Serializable 5 | 6 | @Keep 7 | @Serializable 8 | data class DefinitionInfoDTO( 9 | val definition: String? = null, 10 | val example: String? = null, 11 | val synonyms: List? = null, 12 | ) -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/data/api/lingvadto/translation/ExtraTranslationDTO.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.data.api.lingvadto.translation 2 | 3 | import androidx.annotation.Keep 4 | import kotlinx.serialization.Serializable 5 | 6 | @Keep 7 | @Serializable 8 | data class ExtraTranslationDTO( 9 | val list: List? = null, 10 | val type: String? = null, 11 | ) -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/data/api/lingvadto/translation/ExtraTranslationListDTO.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.data.api.lingvadto.translation 2 | 3 | import androidx.annotation.Keep 4 | import kotlinx.serialization.Serializable 5 | 6 | @Keep 7 | @Serializable 8 | data class ExtraTranslationListDTO( 9 | val frequency: Int? = null, 10 | val meanings: List? = null, 11 | val word: String? = null, 12 | ) -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/data/api/lingvadto/translation/InfoDTO.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.data.api.lingvadto.translation 2 | 3 | import androidx.annotation.Keep 4 | import kotlinx.serialization.Serializable 5 | 6 | @Keep 7 | @Serializable 8 | data class InfoDTO( 9 | val detectedSource: String? = null, 10 | val definitions: List? = null, 11 | val examples: List? = null, 12 | val extraTranslation: List? = null, 13 | val pronunciation: PronunciationDTO? = null, 14 | val similar: List? = null, 15 | ) -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/data/api/lingvadto/translation/PronunciationDTO.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.data.api.lingvadto.translation 2 | 3 | import androidx.annotation.Keep 4 | import kotlinx.serialization.Serializable 5 | 6 | @Keep 7 | @Serializable 8 | data class PronunciationDTO( 9 | val query: String? = null, 10 | val translation: String? = null 11 | ) -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/data/api/lingvadto/translation/TranslationDTO.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.data.api.lingvadto.translation 2 | 3 | import androidx.annotation.Keep 4 | import kotlinx.serialization.Serializable 5 | 6 | @Keep 7 | @Serializable 8 | data class TranslationDTO( 9 | val info: InfoDTO? = null, 10 | val translation: String? = null, 11 | ) -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/data/datasource/AudioRepository.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.data.datasource 2 | 3 | import dev.atajan.lingva_android.common.domain.results.AudioRepositoryResponse 4 | import kotlinx.coroutines.flow.Flow 5 | 6 | interface AudioRepository { 7 | 8 | val audioRequestResult: Flow 9 | 10 | fun requestAudio( 11 | language: String, 12 | query: String 13 | ) 14 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/data/datasource/LanguagesRepository.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.data.datasource 2 | 3 | import dev.atajan.lingva_android.common.domain.results.LanguagesRepositoryResponse 4 | 5 | interface LanguagesRepository { 6 | 7 | suspend fun fetchSupportedLanguages(): LanguagesRepositoryResponse 8 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/data/datasource/TranslationRepository.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.data.datasource 2 | 3 | import dev.atajan.lingva_android.common.domain.models.translation.Translation 4 | import dev.atajan.lingva_android.common.domain.models.translation.TranslationWithInfo 5 | import dev.atajan.lingva_android.common.domain.results.TranslationRepositoryResponse 6 | import kotlinx.coroutines.flow.Flow 7 | 8 | interface TranslationRepository { 9 | 10 | /** 11 | * Emits translation result as either [Translation] or [TranslationWithInfo] depending on 12 | * whether [translate] has requireInfo set to true or not. 13 | */ 14 | val translationResult: Flow 15 | 16 | /** 17 | * Translates the [query] from [source] to [target]. [requireInfo] determines whether the translation 18 | * result should be emitted as [Translation] or [TranslationWithInfo]. 19 | */ 20 | fun translate( 21 | source: String, 22 | target: String, 23 | query: String 24 | ) 25 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/data/datasource/impl/KtorAudioRepository.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.data.datasource.impl 2 | 3 | import dev.atajan.lingva_android.common.data.api.KtorLingvaApi 4 | import dev.atajan.lingva_android.common.data.datasource.AudioRepository 5 | import dev.atajan.lingva_android.common.domain.errors.DTOToDomainModelMappingError 6 | import dev.atajan.lingva_android.common.domain.errors.LingvaApiError 7 | import dev.atajan.lingva_android.common.domain.models.audio.Audio.Companion.toAudioDomain 8 | import dev.atajan.lingva_android.common.domain.results.AudioRepositoryResponse 9 | import dev.atajan.lingva_android.common.domain.results.AudioRepositoryResponse.Failure 10 | import dev.atajan.lingva_android.common.domain.results.AudioRepositoryResponse.Success 11 | import kotlinx.coroutines.CoroutineScope 12 | import kotlinx.coroutines.Dispatchers 13 | import kotlinx.coroutines.flow.MutableSharedFlow 14 | import kotlinx.coroutines.launch 15 | 16 | class KtorAudioRepository( 17 | private val api: KtorLingvaApi, 18 | applicationScope: CoroutineScope, 19 | ) : AudioRepository, CoroutineScope by applicationScope { 20 | 21 | override val audioRequestResult: MutableSharedFlow = MutableSharedFlow() 22 | 23 | override fun requestAudio( 24 | language: String, 25 | query: String 26 | ) { 27 | launch(Dispatchers.IO) { 28 | val rawAudio = try { 29 | api.getAudio( 30 | language = language, 31 | query = query 32 | ) 33 | } catch (error: LingvaApiError) { 34 | Failure(error.message).emit() 35 | return@launch 36 | } 37 | 38 | try { 39 | Success(rawAudio.toAudioDomain()).emit() 40 | } catch (error: DTOToDomainModelMappingError) { 41 | Failure(error.message).emit() 42 | } 43 | } 44 | } 45 | 46 | private suspend fun AudioRepositoryResponse.emit() { 47 | audioRequestResult.emit(this) 48 | } 49 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/data/datasource/impl/KtorLanguagesRepository.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.data.datasource.impl 2 | 3 | import dev.atajan.lingva_android.common.data.api.KtorLingvaApi 4 | import dev.atajan.lingva_android.common.data.datasource.LanguagesRepository 5 | import dev.atajan.lingva_android.common.domain.errors.DTOToDomainModelMappingError 6 | import dev.atajan.lingva_android.common.domain.errors.LingvaApiError 7 | import dev.atajan.lingva_android.common.domain.models.language.Language.Companion.toDomainModel 8 | import dev.atajan.lingva_android.common.domain.results.LanguagesRepositoryResponse 9 | import dev.atajan.lingva_android.common.domain.results.LanguagesRepositoryResponse.Failure 10 | import dev.atajan.lingva_android.common.domain.results.LanguagesRepositoryResponse.Success 11 | 12 | class KtorLanguagesRepository(private val api: KtorLingvaApi) : LanguagesRepository { 13 | 14 | // No need to hit remote on every check, we can cache the result. 15 | private var supportedLanguagesReceived: LanguagesRepositoryResponse? = null 16 | 17 | override suspend fun fetchSupportedLanguages(): LanguagesRepositoryResponse { 18 | return if (supportedLanguagesReceived != null && supportedLanguagesReceived is Success) { 19 | supportedLanguagesReceived as Success 20 | } else { 21 | getFromRemote().also { supportedLanguagesReceived = it } 22 | } 23 | } 24 | 25 | private suspend fun getFromRemote(): LanguagesRepositoryResponse { 26 | val supportedLanguagesDTO = try { 27 | api.getSupportedLanguages() 28 | } catch (error: LingvaApiError) { 29 | return Failure(error.message) 30 | } 31 | 32 | with(supportedLanguagesDTO) { 33 | if (languages.isNullOrEmpty()) { 34 | return Failure("Languages can't be null or empty") 35 | } 36 | 37 | languages.let { languages -> 38 | return try { 39 | languages 40 | .map { it.toDomainModel() } 41 | .let { Success(it) } 42 | } catch (error: DTOToDomainModelMappingError) { 43 | Failure(error.message) 44 | } 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/data/datasource/impl/KtorTranslationRepository.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.data.datasource.impl 2 | 3 | import dev.atajan.lingva_android.common.data.api.KtorLingvaApi 4 | import dev.atajan.lingva_android.common.data.datasource.TranslationRepository 5 | import dev.atajan.lingva_android.common.domain.errors.DTOToDomainModelMappingError 6 | import dev.atajan.lingva_android.common.domain.errors.LingvaApiError 7 | import dev.atajan.lingva_android.common.domain.models.translation.TranslationWithInfo.Companion.toTranslationWithInfoDomain 8 | import dev.atajan.lingva_android.common.domain.results.TranslationRepositoryResponse 9 | import dev.atajan.lingva_android.common.domain.results.TranslationRepositoryResponse.Failure 10 | import dev.atajan.lingva_android.common.domain.results.TranslationRepositoryResponse.Loading 11 | import dev.atajan.lingva_android.common.domain.results.TranslationRepositoryResponse.Success 12 | import kotlinx.coroutines.CoroutineScope 13 | import kotlinx.coroutines.Dispatchers 14 | import kotlinx.coroutines.flow.MutableStateFlow 15 | import kotlinx.coroutines.launch 16 | 17 | class KtorTranslationRepository( 18 | private val api: KtorLingvaApi, 19 | applicationScope: CoroutineScope, 20 | ) : TranslationRepository, CoroutineScope by applicationScope { 21 | 22 | override val translationResult: MutableStateFlow = MutableStateFlow(Loading) 23 | 24 | override fun translate( 25 | source: String, 26 | target: String, 27 | query: String 28 | ) { 29 | launch(Dispatchers.IO) { 30 | val translated = try { 31 | api.translate( 32 | source = source, 33 | target = target, 34 | query = query 35 | ) 36 | } catch (error: LingvaApiError) { 37 | Failure(error.message).emit() 38 | return@launch 39 | } 40 | 41 | try { 42 | Success(translated.toTranslationWithInfoDomain()).emit() 43 | } catch (error: DTOToDomainModelMappingError) { 44 | Failure(error.message).emit() 45 | } 46 | } 47 | } 48 | 49 | private fun TranslationRepositoryResponse.emit() { 50 | translationResult.value = this 51 | } 52 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/data/datasource/impl/PreferencesDatastore.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.data.datasource.impl 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.stringPreferencesKey 8 | import androidx.datastore.preferences.preferencesDataStore 9 | 10 | /** 11 | * Name of the datastore. 12 | */ 13 | private const val PREFERENCE_NAME: String = "settings" 14 | 15 | /** 16 | * Corresponding key type function to define a key for each value that you need to store. 17 | */ 18 | val APP_THEME: Preferences.Key = stringPreferencesKey("app_theme") 19 | val DEFAULT_SOURCE_LANGUAGE: Preferences.Key = stringPreferencesKey("default_source_language") 20 | val DEFAULT_TARGET_LANGUAGE: Preferences.Key = stringPreferencesKey("default_target_language") 21 | val CUSTOM_LINGVA_ENDPOINT: Preferences.Key = stringPreferencesKey("custom_lingva_endpoint") 22 | val LIVE_TRANSLATE_ENABLED: Preferences.Key = booleanPreferencesKey("live_translate_enabled") 23 | 24 | /** 25 | * Use the property delegate to create an instance of Datastore. 26 | * Call it once at the top level of your kotlin file, and access it through this property throughout the rest of your application. 27 | */ 28 | val Context.dataStore: DataStore by preferencesDataStore(name = PREFERENCE_NAME) -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/di/AppScopeModule.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.di 2 | 3 | import dagger.Module 4 | import dagger.Provides 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.components.SingletonComponent 7 | import kotlinx.coroutines.CoroutineScope 8 | import kotlinx.coroutines.Dispatchers 9 | import javax.inject.Singleton 10 | 11 | @Module 12 | @InstallIn(SingletonComponent::class) 13 | object AppScopeModule { 14 | 15 | @Singleton 16 | @Provides 17 | fun provideApplicationScope(): CoroutineScope { 18 | return CoroutineScope(Dispatchers.IO) 19 | } 20 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/di/AudioPlayerModule.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.di 2 | 3 | import dagger.Module 4 | import dagger.Provides 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.components.SingletonComponent 7 | import dev.atajan.lingva_android.common.media.AudioPlayer 8 | import dev.atajan.lingva_android.common.media.NativeAudioPlayer 9 | import javax.inject.Singleton 10 | 11 | @Module 12 | @InstallIn(SingletonComponent::class) 13 | object AudioPlayerModule { 14 | 15 | @Singleton 16 | @Provides 17 | fun provideAudioPlayer(): AudioPlayer { 18 | return NativeAudioPlayer() 19 | } 20 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/di/ClipboardManagerModule.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.di 2 | 3 | import android.content.ClipboardManager 4 | import android.content.Context 5 | import dagger.Module 6 | import dagger.Provides 7 | import dagger.hilt.InstallIn 8 | import dagger.hilt.android.qualifiers.ApplicationContext 9 | import dagger.hilt.components.SingletonComponent 10 | import javax.inject.Singleton 11 | 12 | @Module 13 | @InstallIn(SingletonComponent::class) 14 | object ClipboardManagerModule { 15 | 16 | @Singleton 17 | @Provides 18 | fun provideClipboardManager(@ApplicationContext application: Context): ClipboardManager { 19 | return application.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager 20 | } 21 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/di/DataStoreModule.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.di 2 | 3 | import android.content.Context 4 | import androidx.datastore.core.DataStore 5 | import androidx.datastore.preferences.core.Preferences 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 dev.atajan.lingva_android.common.data.datasource.impl.dataStore 12 | import javax.inject.Singleton 13 | 14 | @Module 15 | @InstallIn(SingletonComponent::class) 16 | object DataStoreModule { 17 | 18 | @Singleton 19 | @Provides 20 | fun provideDataStore(@ApplicationContext application: Context): DataStore { 21 | return application.applicationContext.dataStore 22 | } 23 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/di/LingvaApiModule.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.di 2 | 3 | import android.util.Log 4 | import androidx.datastore.core.DataStore 5 | import androidx.datastore.preferences.core.Preferences 6 | import dagger.Module 7 | import dagger.Provides 8 | import dagger.hilt.InstallIn 9 | import dagger.hilt.components.SingletonComponent 10 | import dev.atajan.lingva_android.common.data.api.KtorLingvaApi 11 | import dev.atajan.lingva_android.common.data.api.LingvaApi 12 | import dev.atajan.lingva_android.common.data.api.constants.TRANSLATION_PROVIDERS 13 | import io.ktor.client.HttpClient 14 | import io.ktor.client.engine.android.Android 15 | import io.ktor.client.plugins.contentnegotiation.ContentNegotiation 16 | import io.ktor.client.plugins.logging.LogLevel 17 | import io.ktor.client.plugins.logging.Logger 18 | import io.ktor.client.plugins.logging.Logging 19 | import io.ktor.http.ContentType 20 | import io.ktor.serialization.ContentConverter 21 | import io.ktor.serialization.kotlinx.KotlinxSerializationConverter 22 | import kotlinx.serialization.ExperimentalSerializationApi 23 | import kotlinx.serialization.json.Json 24 | import javax.inject.Singleton 25 | 26 | @Module 27 | @InstallIn(SingletonComponent::class) 28 | object LingvaApiModule { 29 | 30 | @OptIn(ExperimentalSerializationApi::class) 31 | @Singleton 32 | @Provides 33 | fun provideKotlinxSerializationConverter(): ContentConverter { 34 | return KotlinxSerializationConverter( 35 | Json { 36 | prettyPrint = true 37 | isLenient = true 38 | ignoreUnknownKeys = true 39 | explicitNulls = false 40 | } 41 | ) 42 | } 43 | 44 | @Singleton 45 | @Provides 46 | fun provideAndroidHttpClient(kotlinxSerializationConverter: ContentConverter): HttpClient { 47 | return HttpClient(Android) { 48 | install(ContentNegotiation) { 49 | register( 50 | contentType = ContentType.Application.Json, 51 | converter = kotlinxSerializationConverter 52 | ) 53 | } 54 | 55 | install(Logging) { 56 | logger = object : Logger { 57 | override fun log(message: String) { 58 | Log.d("Logger Ktor =>", message) 59 | } 60 | } 61 | level = LogLevel.INFO 62 | } 63 | } 64 | } 65 | 66 | @Singleton 67 | @Provides 68 | fun provideTranslationEndpoints(): List { 69 | return TRANSLATION_PROVIDERS 70 | } 71 | 72 | @Singleton 73 | @Provides 74 | fun provideLingvaApi( 75 | androidHttpClient: HttpClient, 76 | dataStore: DataStore, 77 | endpoints: List 78 | ): LingvaApi { 79 | return KtorLingvaApi( 80 | androidHttpClient = androidHttpClient, 81 | dataStore = dataStore, 82 | endpoints = endpoints 83 | ) 84 | } 85 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/di/RepositoryModule.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.di 2 | 3 | import dagger.Module 4 | import dagger.Provides 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.components.SingletonComponent 7 | import dev.atajan.lingva_android.common.data.api.KtorLingvaApi 8 | import dev.atajan.lingva_android.common.data.datasource.AudioRepository 9 | import dev.atajan.lingva_android.common.data.datasource.LanguagesRepository 10 | import dev.atajan.lingva_android.common.data.datasource.TranslationRepository 11 | import dev.atajan.lingva_android.common.data.datasource.impl.KtorAudioRepository 12 | import dev.atajan.lingva_android.common.data.datasource.impl.KtorLanguagesRepository 13 | import dev.atajan.lingva_android.common.data.datasource.impl.KtorTranslationRepository 14 | import kotlinx.coroutines.CoroutineScope 15 | import javax.inject.Singleton 16 | 17 | @Module 18 | @InstallIn(SingletonComponent::class) 19 | object RepositoryModule { 20 | 21 | @Singleton 22 | @Provides 23 | fun provideLanguagesRepository(api: KtorLingvaApi): LanguagesRepository { 24 | return KtorLanguagesRepository(api) 25 | } 26 | 27 | @Singleton 28 | @Provides 29 | fun provideTranslationRepository( 30 | api: KtorLingvaApi, 31 | applicationScope: CoroutineScope 32 | ): TranslationRepository { 33 | return KtorTranslationRepository( 34 | api = api, 35 | applicationScope = applicationScope 36 | ) 37 | } 38 | 39 | @Singleton 40 | @Provides 41 | fun provideAudioRepository( 42 | api: KtorLingvaApi, 43 | applicationScope: CoroutineScope 44 | ): AudioRepository { 45 | return KtorAudioRepository( 46 | api = api, 47 | applicationScope = applicationScope 48 | ) 49 | } 50 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/di/UseCaseModule.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.di 2 | 3 | import dagger.Module 4 | import dagger.Provides 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.components.SingletonComponent 7 | import dev.atajan.lingva_android.common.data.datasource.AudioRepository 8 | import dev.atajan.lingva_android.common.data.datasource.LanguagesRepository 9 | import dev.atajan.lingva_android.common.data.datasource.TranslationRepository 10 | import dev.atajan.lingva_android.common.media.AudioPlayer 11 | import dev.atajan.lingva_android.common.usecases.FetchSupportedLanguagesUseCase 12 | import dev.atajan.lingva_android.common.usecases.ObserveAudioDataUseCase 13 | import dev.atajan.lingva_android.common.usecases.ObserveTranslationResultUseCase 14 | import dev.atajan.lingva_android.common.usecases.PlayByteArrayAudioUseCase 15 | import dev.atajan.lingva_android.common.usecases.RequestAudioDataUseCase 16 | import dev.atajan.lingva_android.common.usecases.TranslateUseCase 17 | import dev.atajan.lingva_android.common.usecases.ktorimpl.AudioPlayerPlayByteArrayAudioUseCase 18 | import dev.atajan.lingva_android.common.usecases.ktorimpl.KtorFetchSupportedLanguagesUseCase 19 | import dev.atajan.lingva_android.common.usecases.ktorimpl.KtorObserveAudioDataUseCase 20 | import dev.atajan.lingva_android.common.usecases.ktorimpl.KtorObserveTranslationResultUseCase 21 | import dev.atajan.lingva_android.common.usecases.ktorimpl.KtorRequestAudioDataUseCase 22 | import dev.atajan.lingva_android.common.usecases.ktorimpl.KtorTranslateUseCase 23 | import javax.inject.Singleton 24 | 25 | @Module 26 | @InstallIn(SingletonComponent::class) 27 | object UseCaseModule { 28 | 29 | @Singleton 30 | @Provides 31 | fun provideKtorFetchSupportedLanguagesUseCase(languagesRepository: LanguagesRepository): FetchSupportedLanguagesUseCase { 32 | return KtorFetchSupportedLanguagesUseCase(languagesRepository) 33 | } 34 | 35 | @Singleton 36 | @Provides 37 | fun provideKtorTranslateUseCase(translationRepository: TranslationRepository): TranslateUseCase { 38 | return KtorTranslateUseCase(translationRepository) 39 | } 40 | 41 | @Singleton 42 | @Provides 43 | fun provideKtorObserveTranslationResultUseCase(translationRepository: TranslationRepository): ObserveTranslationResultUseCase { 44 | return KtorObserveTranslationResultUseCase(translationRepository) 45 | } 46 | 47 | @Singleton 48 | @Provides 49 | fun provideAudioPlayerPlayByteArrayAudioUseCase(audioPlayer: AudioPlayer): PlayByteArrayAudioUseCase { 50 | return AudioPlayerPlayByteArrayAudioUseCase(audioPlayer) 51 | } 52 | 53 | @Singleton 54 | @Provides 55 | fun provideKtorObserveAudioDataUseCase(audioRepository: AudioRepository): ObserveAudioDataUseCase { 56 | return KtorObserveAudioDataUseCase(audioRepository) 57 | } 58 | 59 | @Singleton 60 | @Provides 61 | fun provideKtorRequestAudioDataUseCase(audioRepository: AudioRepository): RequestAudioDataUseCase { 62 | return KtorRequestAudioDataUseCase(audioRepository) 63 | } 64 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/domain/errors/Errors.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.domain.errors 2 | 3 | sealed class DTOToDomainModelMappingError(override val message: String) : Exception() { 4 | class NullValue(message: String) : DTOToDomainModelMappingError(message) 5 | } 6 | 7 | sealed class LingvaApiError(override val message: String) : Exception() { 8 | object BadEndpoints : LingvaApiError("All endpoints failed") 9 | object BadCustomEndpoint : LingvaApiError("Invalid custom endpoint") 10 | object TranslationFailure : LingvaApiError("Error during translation request") 11 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/domain/models/audio/Audio.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.domain.models.audio 2 | 3 | import dev.atajan.lingva_android.common.data.api.lingvadto.audio.AudioDTO 4 | import dev.atajan.lingva_android.common.domain.errors.DTOToDomainModelMappingError.NullValue 5 | 6 | data class Audio(val audioByteArray: ByteArray) { 7 | 8 | companion object { 9 | fun AudioDTO.toAudioDomain() : Audio { 10 | return Audio( 11 | audio 12 | ?.map { it.toByte() } 13 | ?.toByteArray() ?: throw NullValue("audio can't be null") 14 | ) 15 | } 16 | } 17 | 18 | override fun equals(other: Any?): Boolean { 19 | if (this === other) return true 20 | if (javaClass != other?.javaClass) return false 21 | 22 | other as Audio 23 | 24 | if (!audioByteArray.contentEquals(other.audioByteArray)) return false 25 | 26 | return true 27 | } 28 | 29 | override fun hashCode(): Int { 30 | return audioByteArray.contentHashCode() 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/domain/models/language/Language.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.domain.models.language 2 | 3 | import dev.atajan.lingva_android.common.data.api.lingvadto.language.LanguageDTO 4 | import dev.atajan.lingva_android.common.domain.errors.DTOToDomainModelMappingError.NullValue 5 | 6 | data class Language( 7 | val code: String, 8 | val name: String, 9 | ) { 10 | companion object { 11 | fun LanguageDTO.toDomainModel(): Language { 12 | return Language( 13 | code = code ?: throw NullValue("language code can't be null"), 14 | name = name ?: throw NullValue("language name can't be null") 15 | ) 16 | } 17 | } 18 | } 19 | 20 | internal fun List.containsLanguageOrNull(languageCode: String): Language? { 21 | return this.find { it.code == languageCode } 22 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/domain/models/translation/Translation.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.domain.models.translation 2 | 3 | import dev.atajan.lingva_android.common.data.api.lingvadto.translation.TranslationDTO 4 | import dev.atajan.lingva_android.common.domain.errors.DTOToDomainModelMappingError.NullValue 5 | 6 | data class Translation(val result: String) { 7 | companion object { 8 | fun TranslationDTO.toTranslationDomain() : Translation { 9 | return Translation( 10 | result = translation ?: throw NullValue("translation can't be null") 11 | ) 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/domain/models/translation/TranslationInfo.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.domain.models.translation 2 | 3 | import dev.atajan.lingva_android.common.data.api.lingvadto.translation.InfoDTO 4 | 5 | /** 6 | * Although the API returns more information about the translation, 7 | * only subset of it is used for the app features at the moment. 8 | * 9 | * See [InfoDTO] for other fields returned by the API. 10 | */ 11 | data class TranslationInfo( 12 | val detectedSource: String, 13 | val pronunciation: String, 14 | ) { 15 | companion object { 16 | fun InfoDTO.toTranslationInfoDomain() : TranslationInfo { 17 | return TranslationInfo( 18 | detectedSource = detectedSource ?: "", 19 | pronunciation = pronunciation?.translation ?: "" 20 | ) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/domain/models/translation/TranslationWithInfo.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.domain.models.translation 2 | 3 | import dev.atajan.lingva_android.common.data.api.lingvadto.translation.TranslationDTO 4 | import dev.atajan.lingva_android.common.domain.errors.DTOToDomainModelMappingError.NullValue 5 | import dev.atajan.lingva_android.common.domain.models.translation.Translation.Companion.toTranslationDomain 6 | import dev.atajan.lingva_android.common.domain.models.translation.TranslationInfo.Companion.toTranslationInfoDomain 7 | 8 | data class TranslationWithInfo( 9 | val translation: Translation, 10 | val info: TranslationInfo 11 | ) { 12 | companion object { 13 | fun TranslationDTO.toTranslationWithInfoDomain() : TranslationWithInfo { 14 | return TranslationWithInfo( 15 | translation = toTranslationDomain(), 16 | info = info?.toTranslationInfoDomain() ?: throw NullValue("translation info can't be null") 17 | ) 18 | } 19 | 20 | fun TranslationWithInfo.toTranslation() : Translation { 21 | return Translation(this.translation.result) 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/domain/results/RepositoryResponse.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.domain.results 2 | 3 | import dev.atajan.lingva_android.common.domain.models.audio.Audio 4 | import dev.atajan.lingva_android.common.domain.models.language.Language 5 | import dev.atajan.lingva_android.common.domain.models.translation.TranslationWithInfo 6 | 7 | sealed interface LanguagesRepositoryResponse { 8 | data class Success(val languageList: List) : LanguagesRepositoryResponse 9 | data class Failure(val errorMessage: String) : LanguagesRepositoryResponse 10 | } 11 | 12 | sealed interface TranslationRepositoryResponse { 13 | data class Success(val response: TranslationWithInfo) : TranslationRepositoryResponse 14 | data class Failure(val errorMessage: String) : TranslationRepositoryResponse 15 | object Loading : TranslationRepositoryResponse 16 | } 17 | 18 | sealed interface AudioRepositoryResponse { 19 | data class Success(val audio: Audio) : AudioRepositoryResponse 20 | data class Failure(val errorMessage: String) : AudioRepositoryResponse 21 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/media/AudioPlayer.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.media 2 | 3 | interface AudioPlayer { 4 | 5 | // Play audio from a ByteArray 6 | fun playAudio(audio: ByteArray) 7 | 8 | // Release the MediaPlayer 9 | fun releaseMediaPlayer() 10 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/media/NativeAudioPlayer.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.media 2 | 3 | import android.media.MediaPlayer 4 | import java.io.File 5 | import javax.inject.Inject 6 | 7 | class NativeAudioPlayer @Inject constructor() : AudioPlayer { 8 | 9 | private var mediaPlayer: MediaPlayer? = null 10 | private var temporaryAudioFile: File? = null 11 | 12 | override fun playAudio(audio: ByteArray) { 13 | if (mediaPlayer != null) releaseMediaPlayer() 14 | mediaPlayer = MediaPlayer() 15 | temporaryAudioFile = createTempAudioFile(audio) 16 | 17 | mediaPlayer?.setOnCompletionListener { 18 | releaseMediaPlayer() 19 | } 20 | 21 | temporaryAudioFile?.let { file -> 22 | mediaPlayer?.apply { 23 | setDataSource(file.absolutePath) 24 | prepare() 25 | start() 26 | } 27 | } 28 | } 29 | 30 | override fun releaseMediaPlayer() { 31 | mediaPlayer?.release() 32 | mediaPlayer = null 33 | temporaryAudioFile?.delete() 34 | temporaryAudioFile = null 35 | } 36 | 37 | private fun createTempAudioFile(audio: ByteArray): File { 38 | return File 39 | .createTempFile( 40 | /* prefix = */ "temp_audio", 41 | /* suffix = */ ".mp3" 42 | ) 43 | .apply { writeBytes(audio) } 44 | } 45 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/redux/MVIViewModel.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.redux 2 | 3 | import androidx.lifecycle.ViewModel 4 | import kotlinx.coroutines.CoroutineScope 5 | import kotlinx.coroutines.ObsoleteCoroutinesApi 6 | import kotlinx.coroutines.channels.Channel 7 | import kotlinx.coroutines.channels.actor 8 | import kotlinx.coroutines.channels.consumeEach 9 | import kotlinx.coroutines.flow.MutableSharedFlow 10 | import kotlinx.coroutines.flow.MutableStateFlow 11 | import kotlinx.coroutines.flow.SharedFlow 12 | import kotlinx.coroutines.flow.StateFlow 13 | import kotlinx.coroutines.flow.asSharedFlow 14 | import kotlinx.coroutines.flow.asStateFlow 15 | 16 | typealias MiddleWare = (S, I) -> Unit 17 | 18 | @OptIn(ObsoleteCoroutinesApi::class) 19 | abstract class MVIViewModel( 20 | scope: CoroutineScope, 21 | initialState: State 22 | ) : ViewModel() { 23 | 24 | private val _states = MutableStateFlow(initialState) 25 | private val _sideEffects = MutableSharedFlow(Channel.UNLIMITED) 26 | private val _middleWareList = mutableListOf>() 27 | 28 | val states: StateFlow = _states.asStateFlow() 29 | val sideEffects: SharedFlow = _sideEffects.asSharedFlow() 30 | val middleWareList: List> = _middleWareList.toList() 31 | 32 | private val actor = scope.actor(capacity = Channel.UNLIMITED) { 33 | channel.consumeEach { intention -> 34 | _states.value = reduce( 35 | currentState = _states.value, 36 | intention = intention, 37 | middleWares = _middleWareList 38 | ) 39 | } 40 | } 41 | 42 | abstract fun reduce( 43 | currentState: State, 44 | intention: Intention, 45 | middleWares: List> = emptyList() 46 | ): State 47 | 48 | protected fun sideEffect(sideEffect: SideEffect) { 49 | _sideEffects.tryEmit(sideEffect) 50 | } 51 | 52 | fun send(intention: Intention) { 53 | actor.trySend(intention) 54 | } 55 | 56 | fun provideMiddleWares(vararg middleWares: MiddleWare) { 57 | _middleWareList.addAll(middleWares) 58 | } 59 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/redux/MiddleWares.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.redux 2 | 3 | import android.util.Log 4 | 5 | fun Any.stateLogger(state: String, intention: String) { 6 | Log.d(this.javaClass.simpleName, "state: $state \nintention: $intention") 7 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/ui/components/AppThemeSelectionRadioButtonRows.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.ui.components 2 | 3 | import android.content.Context 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.Row 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.selection.selectableGroup 9 | import androidx.compose.material.RadioButton 10 | import androidx.compose.material.RadioButtonDefaults 11 | import androidx.compose.material.Text 12 | import androidx.compose.material3.MaterialTheme 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.runtime.MutableState 15 | import androidx.compose.ui.Alignment 16 | import androidx.compose.ui.Modifier 17 | import dev.atajan.lingva_android.R 18 | import dev.atajan.lingva_android.common.ui.theme.ThemingOptions 19 | import dev.atajan.lingva_android.common.ui.theme.canUseDynamicColor 20 | 21 | @Composable 22 | fun AppThemeSelectionRadioButtonRows( 23 | toggleTheme: (ThemingOptions) -> Unit, 24 | currentTheme: MutableState, 25 | context: Context 26 | ) { 27 | Column(modifier = Modifier.selectableGroup()) { 28 | ThemingOptions.values() 29 | .forEach { option -> 30 | if (option == ThemingOptions.YOU) { 31 | if (canUseDynamicColor) { 32 | Row( 33 | horizontalArrangement = Arrangement.Start, 34 | verticalAlignment = Alignment.CenterVertically, 35 | modifier = Modifier.fillMaxWidth() 36 | ) { 37 | RadioButton( 38 | selected = currentTheme.value.name == option.name, 39 | onClick = { 40 | toggleTheme(ThemingOptions.valueOf(option.name)) 41 | }, 42 | colors = RadioButtonDefaults.colors( 43 | selectedColor = MaterialTheme.colorScheme.primary, 44 | unselectedColor = MaterialTheme.colorScheme.onBackground, 45 | disabledColor = MaterialTheme.colorScheme.error 46 | ) 47 | ) 48 | 49 | Text( 50 | text = option.name.uppercase() + " - " + context.getString(R.string.material_you_descriptor), 51 | style = MaterialTheme.typography.labelLarge, 52 | color = MaterialTheme.colorScheme.onBackground 53 | ) 54 | } 55 | } 56 | } else { 57 | Row( 58 | horizontalArrangement = Arrangement.Start, 59 | verticalAlignment = Alignment.CenterVertically, 60 | modifier = Modifier.fillMaxWidth() 61 | ) { 62 | RadioButton( 63 | selected = currentTheme.value.name == option.name, 64 | onClick = { 65 | toggleTheme(ThemingOptions.valueOf(option.name)) 66 | }, 67 | colors = RadioButtonDefaults.colors( 68 | selectedColor = MaterialTheme.colorScheme.primary, 69 | unselectedColor = MaterialTheme.colorScheme.onBackground, 70 | disabledColor = MaterialTheme.colorScheme.error 71 | ) 72 | ) 73 | 74 | Text( 75 | text = option.name.uppercase(), 76 | style = MaterialTheme.typography.labelLarge, 77 | color = MaterialTheme.colorScheme.onBackground 78 | ) 79 | } 80 | } 81 | } 82 | } 83 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/ui/components/BottomSheetSectionHeader.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.ui.components 2 | 3 | import androidx.compose.foundation.layout.padding 4 | import androidx.compose.material.Text 5 | import androidx.compose.material3.MaterialTheme 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.unit.dp 9 | 10 | @Composable 11 | fun BottomSheetSectionHeader( 12 | titleName: String 13 | ) { 14 | Text( 15 | text = titleName, 16 | color = MaterialTheme.colorScheme.primary, 17 | style = MaterialTheme.typography.titleLarge, 18 | modifier = Modifier.padding(16.dp) 19 | ) 20 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/ui/components/LanguageListPopUp.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.ui.components 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.border 5 | import androidx.compose.foundation.clickable 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.Column 8 | import androidx.compose.foundation.layout.fillMaxHeight 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.lazy.LazyColumn 12 | import androidx.compose.foundation.lazy.itemsIndexed 13 | import androidx.compose.foundation.shape.RoundedCornerShape 14 | import androidx.compose.material.Divider 15 | import androidx.compose.material.Text 16 | import androidx.compose.material3.MaterialTheme 17 | import androidx.compose.runtime.Composable 18 | import androidx.compose.runtime.MutableState 19 | import androidx.compose.ui.Modifier 20 | import androidx.compose.ui.unit.dp 21 | import androidx.compose.ui.window.Dialog 22 | import dev.atajan.lingva_android.common.domain.models.language.Language 23 | 24 | @Composable 25 | fun LanguageListPopUp( 26 | openDialog: MutableState, 27 | languageList: List, 28 | onLanguageSelected: ((Language) -> Unit), 29 | ) { 30 | if (languageList.isEmpty()) { 31 | openDialog.value = false 32 | } else { 33 | if (openDialog.value) { 34 | Dialog(onDismissRequest = { openDialog.value = false }) { 35 | Box( 36 | Modifier 37 | .fillMaxWidth() 38 | .fillMaxHeight(0.95f) 39 | .background( 40 | color = MaterialTheme.colorScheme.background, 41 | RoundedCornerShape(16.dp) 42 | ) 43 | .border( 44 | width = 3.dp, 45 | color = MaterialTheme.colorScheme.primary, 46 | shape = RoundedCornerShape(16.dp) 47 | ) 48 | ) { 49 | LazyColumn(modifier = Modifier.padding(8.dp)) { 50 | itemsIndexed(items = languageList) { index, language -> 51 | Column( 52 | modifier = Modifier 53 | .fillParentMaxWidth() 54 | .clickable { 55 | onLanguageSelected(language) 56 | openDialog.value = false 57 | } 58 | ) { 59 | Text( 60 | text = language.name, 61 | style = MaterialTheme.typography.titleMedium, 62 | color = MaterialTheme.colorScheme.onBackground, 63 | modifier = Modifier 64 | .padding(horizontal = 16.dp) 65 | .padding(top = 8.dp) 66 | 67 | ) 68 | if (index < languageList.size) { 69 | Divider( 70 | color = MaterialTheme.colorScheme.secondary, 71 | modifier = Modifier 72 | .padding(horizontal = 8.dp) 73 | .padding(top = 8.dp) 74 | ) 75 | } 76 | } 77 | } 78 | } 79 | } 80 | } 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/ui/components/LanguageSelectionAndSettingsBar.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.ui.components 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.fillMaxHeight 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.layout.height 9 | import androidx.compose.material.icons.Icons 10 | import androidx.compose.material.icons.rounded.Settings 11 | import androidx.compose.material3.Icon 12 | import androidx.compose.material3.IconButton 13 | import androidx.compose.material3.MaterialTheme 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.ui.Alignment 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.graphics.vector.ImageVector 18 | import androidx.compose.ui.platform.LocalContext 19 | import androidx.compose.ui.unit.dp 20 | import dev.atajan.lingva_android.R 21 | import dev.atajan.lingva_android.common.domain.models.language.Language 22 | 23 | @Composable 24 | fun LanguageSelectionAndSettingsBar( 25 | supportedLanguages: List, 26 | sourceLanguage: Language, 27 | targetLanguage: Language, 28 | toggleErrorDialogState: (Boolean) -> Unit, 29 | onNewSourceLanguageSelected: (Language) -> Unit, 30 | onNewTargetLanguageSelected: (Language) -> Unit, 31 | middleIcon: ImageVector, 32 | onMiddleIconTap: () -> Unit, 33 | onEndIconTap: () -> Unit, 34 | modifier: Modifier = Modifier 35 | ) { 36 | val context = LocalContext.current 37 | 38 | Row( 39 | modifier = modifier 40 | .height(50.dp) 41 | .fillMaxSize(), 42 | horizontalArrangement = Arrangement.SpaceBetween, 43 | verticalAlignment = Alignment.CenterVertically 44 | ) { 45 | LanguageSelectionBar( 46 | supportedLanguages = supportedLanguages, 47 | sourceLanguage = sourceLanguage, 48 | targetLanguage = targetLanguage, 49 | toggleErrorDialogState = toggleErrorDialogState, 50 | onNewSourceLanguageSelected = onNewSourceLanguageSelected, 51 | onNewTargetLanguageSelected = onNewTargetLanguageSelected, 52 | middleIcon = middleIcon, 53 | onMiddleIconTap = onMiddleIconTap, 54 | modifier = Modifier 55 | .fillMaxHeight() 56 | .fillMaxWidth(0.85f) 57 | ) 58 | 59 | IconButton( 60 | onClick = onEndIconTap, 61 | modifier = Modifier.fillMaxHeight() 62 | ) { 63 | Icon( 64 | imageVector = Icons.Rounded.Settings, 65 | contentDescription = context.getString(R.string.setting_icon_ax), 66 | tint = MaterialTheme.colorScheme.onBackground 67 | ) 68 | } 69 | } 70 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/ui/components/LanguageSelectionBar.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.ui.components 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.fillMaxHeight 7 | import androidx.compose.foundation.layout.fillMaxSize 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.shape.RoundedCornerShape 11 | import androidx.compose.material.Icon 12 | import androidx.compose.material.IconButton 13 | import androidx.compose.material.Surface 14 | import androidx.compose.material.Text 15 | import androidx.compose.material3.MaterialTheme 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.runtime.mutableStateOf 18 | import androidx.compose.runtime.remember 19 | import androidx.compose.ui.Alignment 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.graphics.vector.ImageVector 22 | import androidx.compose.ui.platform.LocalContext 23 | import androidx.compose.ui.text.style.TextAlign 24 | import androidx.compose.ui.text.style.TextOverflow 25 | import androidx.compose.ui.unit.dp 26 | import dev.atajan.lingva_android.R 27 | import dev.atajan.lingva_android.common.domain.models.language.Language 28 | 29 | @Composable 30 | fun LanguageSelectionBar( 31 | supportedLanguages: List, 32 | sourceLanguage: Language, 33 | targetLanguage: Language, 34 | toggleErrorDialogState: (Boolean) -> Unit, 35 | onNewSourceLanguageSelected: (Language) -> Unit, 36 | onNewTargetLanguageSelected: (Language) -> Unit, 37 | middleIcon: ImageVector, 38 | onMiddleIconTap: () -> Unit, 39 | modifier: Modifier = Modifier 40 | ) { 41 | val context = LocalContext.current 42 | val sourceLanguagesPopUpShown = remember { mutableStateOf(false) } 43 | val targetLanguagesPopUpShown = remember { mutableStateOf(false) } 44 | 45 | Row( 46 | modifier = modifier, 47 | horizontalArrangement = Arrangement.SpaceBetween 48 | ) { 49 | Surface( 50 | modifier = Modifier 51 | .weight(1f) 52 | .fillMaxSize() 53 | .clickable { 54 | if (supportedLanguages.isNotEmpty()) { 55 | sourceLanguagesPopUpShown.value = true 56 | } else { 57 | toggleErrorDialogState(true) 58 | } 59 | }, 60 | shape = RoundedCornerShape(20.dp), 61 | color = MaterialTheme.colorScheme.secondary 62 | ) { 63 | Row(verticalAlignment = Alignment.CenterVertically) { 64 | Text( 65 | text = if (sourceLanguage.name == "Detect") { 66 | context.getString(R.string.detect_language) 67 | } else { 68 | sourceLanguage.name 69 | }, 70 | style = MaterialTheme.typography.labelLarge, 71 | textAlign = TextAlign.Center, 72 | softWrap = false, 73 | overflow = TextOverflow.Ellipsis, 74 | color = MaterialTheme.colorScheme.onSecondary, 75 | modifier = Modifier 76 | .fillMaxWidth() 77 | .padding(horizontal = 8.dp) 78 | ) 79 | } 80 | } 81 | 82 | IconButton( 83 | onClick = onMiddleIconTap, 84 | modifier = Modifier 85 | .fillMaxHeight() 86 | .fillMaxWidth(0.25f) 87 | ) { 88 | Icon( 89 | imageVector = middleIcon, 90 | contentDescription = context.getString(R.string.swap_icon_ax), 91 | tint = MaterialTheme.colorScheme.onBackground, 92 | modifier = Modifier.fillMaxSize() 93 | ) 94 | } 95 | 96 | Surface( 97 | modifier = Modifier 98 | .weight(1f) 99 | .fillMaxSize() 100 | .clickable { 101 | if (supportedLanguages.isNotEmpty()) { 102 | targetLanguagesPopUpShown.value = true 103 | } else { 104 | toggleErrorDialogState(true) 105 | } 106 | }, 107 | shape = RoundedCornerShape(20.dp), 108 | color = MaterialTheme.colorScheme.secondary 109 | ) { 110 | Row(verticalAlignment = Alignment.CenterVertically) { 111 | Text( 112 | text = targetLanguage.name, 113 | style = MaterialTheme.typography.labelLarge, 114 | textAlign = TextAlign.Center, 115 | softWrap = false, 116 | overflow = TextOverflow.Ellipsis, 117 | color = MaterialTheme.colorScheme.onSecondary, 118 | modifier = Modifier 119 | .fillMaxWidth() 120 | .padding(horizontal = 8.dp) 121 | ) 122 | } 123 | } 124 | } 125 | 126 | LanguageListPopUp( 127 | openDialog = sourceLanguagesPopUpShown, 128 | languageList = supportedLanguages 129 | ) { onNewSourceLanguageSelected(it) } 130 | 131 | // Drop the first language, "Detect", since it won't make sense for target language 132 | LanguageListPopUp( 133 | openDialog = targetLanguagesPopUpShown, 134 | languageList = supportedLanguages.drop(1) 135 | ) { onNewTargetLanguageSelected(it) } 136 | } 137 | -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/ui/components/NotificationDialog.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.ui.components 2 | 3 | import androidx.compose.material.AlertDialog 4 | import androidx.compose.material.Text 5 | import androidx.compose.material.TextButton 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.platform.LocalContext 9 | import androidx.compose.ui.tooling.preview.Preview 10 | import dev.atajan.lingva_android.R 11 | 12 | @Composable 13 | fun ErrorNotificationDialog( 14 | shouldShowDialog: Boolean, 15 | onDismissRequest: () -> Unit 16 | ) { 17 | val context = LocalContext.current 18 | 19 | if (shouldShowDialog) { 20 | AlertDialog( 21 | backgroundColor = MaterialTheme.colorScheme.secondary, 22 | contentColor = MaterialTheme.colorScheme.onSecondary, 23 | onDismissRequest = { 24 | // Dismiss the dialog when the user clicks outside the dialog or on the back 25 | // button. If you want to disable that functionality, simply use an empty onCloseRequest. 26 | onDismissRequest() 27 | }, 28 | title = { 29 | Text( 30 | text = "Uh oh :(", 31 | style = MaterialTheme.typography.headlineLarge 32 | ) 33 | }, 34 | text = { 35 | Text(context.getString(R.string.error_dialog_body)) 36 | }, 37 | confirmButton = { 38 | // this component is meant only for a passive error notification. No positive action should be here. 39 | }, 40 | dismissButton = { 41 | TextButton( 42 | onClick = { 43 | onDismissRequest() 44 | } 45 | ) { 46 | Text( 47 | text = context.getString(R.string.error_dialog_dismiss_button), 48 | color = MaterialTheme.colorScheme.onSecondary, 49 | style = MaterialTheme.typography.labelLarge 50 | ) 51 | } 52 | } 53 | ) 54 | } 55 | } 56 | 57 | @Preview 58 | @Composable 59 | fun PreviewNotificationDialog() { 60 | ErrorNotificationDialog( 61 | shouldShowDialog = true, 62 | onDismissRequest = {} 63 | ) 64 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/ui/components/SelectDefaultLanguagesColumn.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.ui.components 2 | 3 | import android.content.Context 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.Arrangement 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.Row 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.material.Text 11 | import androidx.compose.material3.MaterialTheme 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.runtime.mutableStateOf 14 | import androidx.compose.runtime.remember 15 | import androidx.compose.ui.Alignment 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.unit.dp 18 | import dev.atajan.lingva_android.R 19 | import dev.atajan.lingva_android.common.domain.models.language.Language 20 | 21 | @Composable 22 | fun SelectDefaultLanguagesColumn( 23 | defaultSourceLanguage: String, 24 | defaultTargetLanguage: String, 25 | setDefaultSourceLanguage: (Language) -> Unit, 26 | setDefaultTargetLanguage: (Language) -> Unit, 27 | supportedLanguages: List, 28 | toggleErrorDialogState: (Boolean) -> Unit, 29 | context: Context 30 | ) { 31 | val sourceLanguagesPopUpShown = remember { mutableStateOf(false) } 32 | val targetLanguagesPopUpShown = remember { mutableStateOf(false) } 33 | 34 | Column( 35 | horizontalAlignment = Alignment.Start, 36 | verticalArrangement = Arrangement.Center 37 | ) { 38 | Row( 39 | horizontalArrangement = Arrangement.Start, 40 | verticalAlignment = Alignment.CenterVertically, 41 | modifier = Modifier.fillMaxWidth() 42 | ) { 43 | Text( 44 | text = context.getString(R.string.default_language_source), 45 | color = MaterialTheme.colorScheme.primary, 46 | style = MaterialTheme.typography.labelLarge, 47 | modifier = Modifier.padding( 48 | start = 16.dp, 49 | top = 16.dp, 50 | bottom = 16.dp, 51 | ) 52 | ) 53 | 54 | Text( 55 | text = defaultSourceLanguage.ifEmpty { context.getString(R.string.tap_to_select) }, 56 | color = MaterialTheme.colorScheme.onBackground, 57 | style = MaterialTheme.typography.labelLarge, 58 | modifier = Modifier 59 | .padding(16.dp) 60 | .clickable { 61 | if (supportedLanguages.isNotEmpty()) { 62 | sourceLanguagesPopUpShown.value = true 63 | } else { 64 | toggleErrorDialogState(true) 65 | } 66 | } 67 | ) 68 | } 69 | 70 | Row( 71 | horizontalArrangement = Arrangement.Start, 72 | verticalAlignment = Alignment.CenterVertically, 73 | modifier = Modifier.fillMaxWidth() 74 | ) { 75 | Text( 76 | text = context.getString(R.string.default_language_target), 77 | color = MaterialTheme.colorScheme.primary, 78 | style = MaterialTheme.typography.labelLarge, 79 | modifier = Modifier.padding( 80 | start = 16.dp, 81 | top = 16.dp, 82 | bottom = 16.dp, 83 | ) 84 | ) 85 | 86 | Text( 87 | text = defaultTargetLanguage.ifEmpty { context.getString(R.string.tap_to_select) }, 88 | color = MaterialTheme.colorScheme.onBackground, 89 | style = MaterialTheme.typography.labelLarge, 90 | modifier = Modifier 91 | .padding(16.dp) 92 | .clickable { 93 | if (supportedLanguages.isNotEmpty()) { 94 | targetLanguagesPopUpShown.value = true 95 | } else { 96 | toggleErrorDialogState(true) 97 | } 98 | } 99 | ) 100 | } 101 | } 102 | 103 | LanguageListPopUp( 104 | openDialog = sourceLanguagesPopUpShown, 105 | languageList = supportedLanguages, 106 | ) { selectedLanguage: Language -> 107 | setDefaultSourceLanguage(selectedLanguage) 108 | } 109 | 110 | // Drop the first language, "Detect", since it won't make sense for target language 111 | LanguageListPopUp( 112 | openDialog = targetLanguagesPopUpShown, 113 | languageList = supportedLanguages.drop(1), 114 | ) { selectedLanguage: Language -> 115 | setDefaultTargetLanguage(selectedLanguage) 116 | } 117 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/ui/components/SettingsBottomSheet.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.ui.components 2 | 3 | import android.content.Context 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.layout.Arrangement 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.Row 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.material.ExperimentalMaterialApi 11 | import androidx.compose.material.ModalBottomSheetLayout 12 | import androidx.compose.material.ModalBottomSheetState 13 | import androidx.compose.material.Switch 14 | import androidx.compose.material.SwitchDefaults 15 | import androidx.compose.material3.MaterialTheme 16 | import androidx.compose.material3.Text 17 | import androidx.compose.runtime.Composable 18 | import androidx.compose.runtime.MutableState 19 | import androidx.compose.ui.Alignment 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.semantics.contentDescription 22 | import androidx.compose.ui.semantics.semantics 23 | import androidx.compose.ui.unit.dp 24 | import dev.atajan.lingva_android.R 25 | import dev.atajan.lingva_android.common.domain.models.language.Language 26 | import dev.atajan.lingva_android.common.ui.theme.ThemingOptions 27 | 28 | @ExperimentalMaterialApi 29 | @Composable 30 | fun SettingsBottomSheet( 31 | modalBottomSheetState: ModalBottomSheetState, 32 | toggleTheme: (ThemingOptions) -> Unit, 33 | currentTheme: MutableState, 34 | setDefaultSourceLanguage: (Language) -> Unit, 35 | setDefaultTargetLanguage: (Language) -> Unit, 36 | supportedLanguages: List, 37 | defaultSourceLanguage: String, 38 | defaultTargetLanguage: String, 39 | toggleErrorDialogState: (Boolean) -> Unit, 40 | customLingvaServerUrl: MutableState, 41 | currentCustomLingvaServerUrl: String, 42 | liveTranslateEnabled: Boolean, 43 | onToggleLiveTranslate: (Boolean) -> Unit, 44 | context: Context, 45 | ) { 46 | 47 | ModalBottomSheetLayout( 48 | sheetState = modalBottomSheetState, 49 | sheetContent = { 50 | Column(modifier = Modifier 51 | .fillMaxWidth() 52 | .background(MaterialTheme.colorScheme.background) 53 | ) { 54 | BottomSheetSectionHeader(context.getString(R.string.app_theme_setting_title)) 55 | AppThemeSelectionRadioButtonRows( 56 | toggleTheme = toggleTheme, 57 | currentTheme = currentTheme, 58 | context = context 59 | ) 60 | 61 | BottomSheetSectionHeader(context.getString(R.string.default_languages_title)) 62 | SelectDefaultLanguagesColumn( 63 | defaultSourceLanguage = defaultSourceLanguage, 64 | defaultTargetLanguage = defaultTargetLanguage, 65 | setDefaultSourceLanguage = setDefaultSourceLanguage, 66 | setDefaultTargetLanguage = setDefaultTargetLanguage, 67 | supportedLanguages = supportedLanguages, 68 | toggleErrorDialogState = toggleErrorDialogState, 69 | context = context 70 | ) 71 | 72 | Row( 73 | verticalAlignment = Alignment.CenterVertically, 74 | horizontalArrangement = Arrangement.SpaceBetween, 75 | modifier = Modifier 76 | .padding( 77 | start = 16.dp, 78 | bottom = 16.dp, 79 | ) 80 | .fillMaxWidth() 81 | ) { 82 | Text( 83 | text = context.getString(R.string.toggle_live_translate), 84 | color = MaterialTheme.colorScheme.primary, 85 | style = MaterialTheme.typography.labelLarge, 86 | ) 87 | 88 | Switch( 89 | modifier = Modifier.semantics { contentDescription = "toggle live translate" }, 90 | checked = liveTranslateEnabled, 91 | onCheckedChange = { 92 | onToggleLiveTranslate(it) 93 | }, 94 | colors = SwitchDefaults.colors( 95 | checkedThumbColor = MaterialTheme.colorScheme.primary, 96 | uncheckedThumbColor = MaterialTheme.colorScheme.secondary, 97 | checkedTrackColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f), 98 | uncheckedTrackColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.3f) 99 | ) 100 | ) 101 | } 102 | 103 | BottomSheetSectionHeader(context.getString(R.string.advanced_settings_title)) 104 | SettingsBottomSheetOutlinedTextField( 105 | label = context.getString(R.string.custom_lingva_instance_address), 106 | currentTextFieldValue = customLingvaServerUrl, 107 | hint = currentCustomLingvaServerUrl, 108 | onValueChange = { customLingvaServerUrl.value = it }, 109 | ) 110 | } 111 | } 112 | ) { } 113 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/ui/components/SettingsBottomSheetThemedOutlinedTextField.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.ui.components 2 | 3 | import androidx.compose.foundation.layout.fillMaxWidth 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.foundation.text.KeyboardOptions 6 | import androidx.compose.material.ContentAlpha 7 | import androidx.compose.material.OutlinedTextField 8 | import androidx.compose.material.TextFieldDefaults 9 | import androidx.compose.material3.MaterialTheme 10 | import androidx.compose.material3.Text 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.MutableState 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.text.input.ImeAction 15 | import androidx.compose.ui.text.input.KeyboardType 16 | import androidx.compose.ui.unit.dp 17 | import dev.atajan.lingva_android.common.data.api.constants.LINGVA 18 | 19 | @Composable 20 | fun SettingsBottomSheetOutlinedTextField( 21 | label: String, 22 | currentTextFieldValue: MutableState, 23 | hint: String, 24 | onValueChange: (String) -> Unit, 25 | ) { 26 | // TODO: add URL validation 27 | OutlinedTextField( 28 | value = currentTextFieldValue.value, 29 | placeholder = { 30 | Text( 31 | text = if (hint.isEmpty()) "Default: $LINGVA" else "Currently: $hint", 32 | color = MaterialTheme.colorScheme.onBackground.copy(alpha = ContentAlpha.disabled) 33 | ) 34 | }, 35 | onValueChange = { onValueChange(it) }, 36 | label = { 37 | Text( 38 | text = label, 39 | color = MaterialTheme.colorScheme.primary 40 | ) 41 | }, 42 | modifier = Modifier 43 | .fillMaxWidth() 44 | .padding(16.dp), 45 | keyboardOptions = KeyboardOptions( 46 | keyboardType = KeyboardType.Uri, 47 | imeAction = ImeAction.Default 48 | ), 49 | textStyle = MaterialTheme.typography.labelLarge, 50 | colors = TextFieldDefaults.outlinedTextFieldColors( 51 | focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = ContentAlpha.high), 52 | unfocusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = ContentAlpha.disabled), 53 | focusedLabelColor = MaterialTheme.colorScheme.primary.copy(alpha = ContentAlpha.high), 54 | unfocusedLabelColor = MaterialTheme.colorScheme.primary.copy(alpha = ContentAlpha.disabled), 55 | cursorColor = MaterialTheme.colorScheme.primary, 56 | textColor = MaterialTheme.colorScheme.onBackground, 57 | ) 58 | ) 59 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/ui/components/TitleBar.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.ui.components 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.fillMaxHeight 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.foundation.layout.height 8 | import androidx.compose.material.Text 9 | import androidx.compose.material.icons.Icons 10 | import androidx.compose.material.icons.rounded.Settings 11 | import androidx.compose.material3.Icon 12 | import androidx.compose.material3.IconButton 13 | import androidx.compose.material3.MaterialTheme 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.ui.Alignment 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.platform.LocalContext 18 | import androidx.compose.ui.text.style.TextAlign 19 | import androidx.compose.ui.text.style.TextOverflow 20 | import androidx.compose.ui.unit.dp 21 | import dev.atajan.lingva_android.R 22 | 23 | @Composable 24 | fun TitleBar( 25 | title: String, 26 | onEndIconTap: () -> Unit, 27 | modifier: Modifier = Modifier 28 | ) { 29 | val context = LocalContext.current 30 | 31 | Row( 32 | modifier = modifier.height(30.dp).fillMaxSize(), 33 | horizontalArrangement = Arrangement.SpaceBetween, 34 | verticalAlignment = Alignment.CenterVertically 35 | ) { 36 | Text( 37 | text = title, 38 | style = MaterialTheme.typography.headlineSmall, 39 | textAlign = TextAlign.Start, 40 | softWrap = false, 41 | overflow = TextOverflow.Ellipsis, 42 | color = MaterialTheme.colorScheme.onBackground 43 | ) 44 | 45 | IconButton( 46 | onClick = onEndIconTap, 47 | modifier = Modifier.fillMaxHeight() 48 | ) { 49 | Icon( 50 | imageVector = Icons.Rounded.Settings, 51 | contentDescription = context.getString(R.string.setting_icon_ax), 52 | tint = MaterialTheme.colorScheme.onBackground 53 | ) 54 | } 55 | } 56 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val LingvaGreen = Color(0xFF61FD96) 6 | val LingvaGreenLighter = Color(0xFFc5fed3) 7 | val LingvaGreenDarker = Color(0xFF00cc49) 8 | val LingvaGray = Color(0xFFE5E5E5) 9 | val LingvaDarkGray = Color(0xFF5E5C5C) 10 | val AccentMagenta = Color(0xFFfd61c9) -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/ui/theme/Helpers.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.ui.theme 2 | 3 | import android.content.Context 4 | import dev.atajan.lingva_android.common.data.datasource.impl.APP_THEME 5 | import dev.atajan.lingva_android.common.data.datasource.impl.dataStore 6 | import kotlinx.coroutines.flow.Flow 7 | import kotlinx.coroutines.flow.map 8 | 9 | fun selectedThemeFlow(applicationContext: Context): Flow { 10 | return applicationContext.dataStore.data 11 | .map { preferences -> 12 | preferences[APP_THEME] 13 | } 14 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/ui/theme/Shape.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.ui.theme 2 | 3 | import androidx.compose.foundation.shape.RoundedCornerShape 4 | import androidx.compose.ui.unit.dp 5 | 6 | val smallRoundedCornerShape = RoundedCornerShape(2.dp) 7 | val mediumRoundedCornerShape = RoundedCornerShape(4.dp) 8 | val largeRoundedCornerShape = RoundedCornerShape(8.dp) -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.ui.theme 2 | 3 | import android.content.Context 4 | import android.content.res.Configuration 5 | import android.os.Build 6 | import androidx.compose.foundation.isSystemInDarkTheme 7 | import androidx.compose.material3.MaterialTheme 8 | import androidx.compose.material3.darkColorScheme 9 | import androidx.compose.material3.dynamicDarkColorScheme 10 | import androidx.compose.material3.dynamicLightColorScheme 11 | import androidx.compose.material3.lightColorScheme 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.ui.graphics.Color 14 | import androidx.compose.ui.platform.LocalContext 15 | import com.google.accompanist.systemuicontroller.rememberSystemUiController 16 | 17 | val canUseDynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S 18 | 19 | fun isSystemInNightMode(context: Context): Boolean { 20 | return when (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) { 21 | Configuration.UI_MODE_NIGHT_YES -> true 22 | Configuration.UI_MODE_NIGHT_NO -> false 23 | else -> false 24 | } 25 | } 26 | 27 | enum class ThemingOptions { 28 | LIGHT, DARK, YOU 29 | } 30 | 31 | private val darkColorScheme = darkColorScheme( 32 | primary = LingvaGreen, 33 | primaryContainer = LingvaGreenLighter, 34 | secondary = LingvaDarkGray, 35 | secondaryContainer = AccentMagenta, 36 | onPrimary = Color.Black, 37 | onSecondary = Color.White, 38 | surface = Color.Black, 39 | onSurface = Color.White, 40 | background = Color.Black, 41 | onBackground = Color.White, 42 | ) 43 | 44 | private val lightColorScheme = lightColorScheme( 45 | primary = LingvaGreenDarker, 46 | primaryContainer = LingvaGreenDarker, 47 | secondary = LingvaGray, 48 | secondaryContainer = AccentMagenta, 49 | onPrimary = Color.Black, 50 | onSecondary = Color.Black, 51 | surface = Color.White, 52 | onSurface = Color.Black, 53 | background = Color.White, 54 | onBackground = Color.Black, 55 | ) 56 | 57 | @Composable 58 | fun LingvaAndroidTheme( 59 | appTheme: ThemingOptions, 60 | content: @Composable () -> Unit 61 | ) { 62 | val systemUiController = rememberSystemUiController() 63 | 64 | val colorScheme = when (appTheme) { 65 | ThemingOptions.LIGHT -> lightColorScheme 66 | ThemingOptions.DARK -> darkColorScheme 67 | ThemingOptions.YOU -> { 68 | if (isSystemInDarkTheme()) dynamicDarkColorScheme(LocalContext.current) else dynamicLightColorScheme(LocalContext.current) 69 | } 70 | } 71 | 72 | systemUiController.setSystemBarsColor( 73 | color = colorScheme.background 74 | ) 75 | 76 | MaterialTheme( 77 | colorScheme = colorScheme, 78 | typography = material3Typography, 79 | content = content 80 | ) 81 | } 82 | -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.ui.theme 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | val material3Typography = Typography( 10 | bodyLarge = TextStyle( 11 | fontFamily = FontFamily.Default, 12 | fontWeight = FontWeight.Normal, 13 | fontSize = 16.sp 14 | ) 15 | ) -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/usecases/FetchSupportedLanguagesUseCase.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.usecases 2 | 3 | import dev.atajan.lingva_android.common.domain.results.LanguagesRepositoryResponse 4 | 5 | interface FetchSupportedLanguagesUseCase { 6 | /** 7 | * Requests for a list of supported languages. 8 | */ 9 | suspend operator fun invoke(): LanguagesRepositoryResponse 10 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/usecases/ObserveAudioDataUseCase.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.usecases 2 | 3 | import dev.atajan.lingva_android.common.domain.results.AudioRepositoryResponse 4 | import kotlinx.coroutines.flow.Flow 5 | 6 | interface ObserveAudioDataUseCase { 7 | operator fun invoke(): Flow 8 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/usecases/ObserveTranslationResultUseCase.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.usecases 2 | 3 | import dev.atajan.lingva_android.common.domain.results.TranslationRepositoryResponse 4 | import kotlinx.coroutines.flow.Flow 5 | 6 | interface ObserveTranslationResultUseCase { 7 | /** 8 | * Observes the translation result. 9 | */ 10 | operator fun invoke(): Flow 11 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/usecases/PlayByteArrayAudioUseCase.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.usecases 2 | 3 | interface PlayByteArrayAudioUseCase { 4 | operator fun invoke(audio: ByteArray) 5 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/usecases/RequestAudioDataUseCase.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.usecases 2 | 3 | interface RequestAudioDataUseCase { 4 | 5 | operator fun invoke( 6 | language: String, 7 | query: String 8 | ) 9 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/usecases/TranslateUseCase.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.usecases 2 | 3 | interface TranslateUseCase { 4 | 5 | /** 6 | * Translates a [textToTranslate] from [sourceLanguageCode] language to [targetLanguageCode] language. 7 | * The result must be observed via [ObserveTranslationResultUseCase]. 8 | */ 9 | operator fun invoke( 10 | sourceLanguageCode: String, 11 | targetLanguageCode: String, 12 | textToTranslate: String 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/usecases/ktorimpl/AudioPlayerPlayByteArrayAudioUseCase.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.usecases.ktorimpl 2 | 3 | import dev.atajan.lingva_android.common.media.AudioPlayer 4 | import dev.atajan.lingva_android.common.usecases.PlayByteArrayAudioUseCase 5 | 6 | class AudioPlayerPlayByteArrayAudioUseCase( 7 | private val audioPlayer: AudioPlayer 8 | ) : PlayByteArrayAudioUseCase { 9 | 10 | override fun invoke(audio: ByteArray) { 11 | audioPlayer.playAudio(audio) 12 | } 13 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/usecases/ktorimpl/KtorFetchSupportedLanguagesUseCase.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.usecases.ktorimpl 2 | 3 | import dev.atajan.lingva_android.common.data.datasource.LanguagesRepository 4 | import dev.atajan.lingva_android.common.usecases.FetchSupportedLanguagesUseCase 5 | 6 | class KtorFetchSupportedLanguagesUseCase( 7 | private val languagesRepository: LanguagesRepository 8 | ) : FetchSupportedLanguagesUseCase { 9 | 10 | override suspend fun invoke() = languagesRepository.fetchSupportedLanguages() 11 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/usecases/ktorimpl/KtorObserveAudioDataUseCase.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.usecases.ktorimpl 2 | 3 | import dev.atajan.lingva_android.common.data.datasource.AudioRepository 4 | import dev.atajan.lingva_android.common.usecases.ObserveAudioDataUseCase 5 | 6 | class KtorObserveAudioDataUseCase( 7 | private val audioRepository: AudioRepository 8 | ) : ObserveAudioDataUseCase { 9 | 10 | override operator fun invoke() = audioRepository.audioRequestResult 11 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/usecases/ktorimpl/KtorObserveTranslationResultUseCase.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.usecases.ktorimpl 2 | 3 | import dev.atajan.lingva_android.common.data.datasource.TranslationRepository 4 | import dev.atajan.lingva_android.common.usecases.ObserveTranslationResultUseCase 5 | 6 | class KtorObserveTranslationResultUseCase( 7 | private val translationRepository: TranslationRepository 8 | ) : ObserveTranslationResultUseCase { 9 | 10 | override fun invoke() = translationRepository.translationResult 11 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/usecases/ktorimpl/KtorRequestAudioDataUseCase.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.usecases.ktorimpl 2 | 3 | import dev.atajan.lingva_android.common.data.datasource.AudioRepository 4 | import dev.atajan.lingva_android.common.usecases.RequestAudioDataUseCase 5 | 6 | class KtorRequestAudioDataUseCase( 7 | private val audioRepository: AudioRepository 8 | ) : RequestAudioDataUseCase { 9 | 10 | override operator fun invoke( 11 | language: String, 12 | query: String 13 | ) { 14 | audioRepository.requestAudio( 15 | language = language, 16 | query = query 17 | ) 18 | } 19 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/common/usecases/ktorimpl/KtorTranslateUseCase.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.common.usecases.ktorimpl 2 | 3 | import dev.atajan.lingva_android.common.data.datasource.TranslationRepository 4 | import dev.atajan.lingva_android.common.usecases.TranslateUseCase 5 | 6 | class KtorTranslateUseCase( 7 | private val translationRepository: TranslationRepository 8 | ) : TranslateUseCase { 9 | 10 | override fun invoke( 11 | sourceLanguageCode: String, 12 | targetLanguageCode: String, 13 | textToTranslate: String 14 | ) { 15 | translationRepository.translate( 16 | source = sourceLanguageCode, 17 | target = targetLanguageCode, 18 | query = textToTranslate 19 | ) 20 | } 21 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/quicktranslatefeature/redux/QuickTranslateScreenIntention.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.quicktranslatefeature.redux 2 | 3 | import dev.atajan.lingva_android.common.domain.models.language.Language 4 | 5 | sealed interface QuickTranslateScreenIntention { 6 | data class OnTextToTranslateChange(val newValue: String) : QuickTranslateScreenIntention 7 | data class SetDefaultTargetLanguage(val languageName: String) : QuickTranslateScreenIntention 8 | data class SetNewSourceLanguage(val language: Language) : QuickTranslateScreenIntention 9 | data class SetNewTargetLanguage(val language: Language) : QuickTranslateScreenIntention 10 | data class ShowErrorDialog(val show: Boolean) : QuickTranslateScreenIntention 11 | data class SupportedLanguagesReceived(val languages: List) : QuickTranslateScreenIntention 12 | data class TranslationSuccess(val result: String) : QuickTranslateScreenIntention 13 | object CopyTextToClipboard : QuickTranslateScreenIntention 14 | object Translate : QuickTranslateScreenIntention 15 | object TranslationFailure : QuickTranslateScreenIntention 16 | 17 | object ReadTextOutLoud : QuickTranslateScreenIntention 18 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/quicktranslatefeature/redux/QuickTranslateScreenSideEffect.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.quicktranslatefeature.redux 2 | 3 | sealed interface QuickTranslateScreenSideEffect -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/quicktranslatefeature/redux/QuickTranslateScreenState.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.quicktranslatefeature.redux 2 | 3 | import dev.atajan.lingva_android.common.domain.models.language.Language 4 | 5 | data class QuickTranslateScreenState( 6 | val supportedLanguages: List = emptyList(), 7 | val translatedText: String = "", 8 | val sourceLanguage: Language = Language("auto", "Detect"), 9 | val targetLanguage: Language = Language("es", "Spanish"), 10 | val textToTranslate: String = "", 11 | val errorDialogState: Boolean = false, 12 | val defaultTargetLanguage: String = "" 13 | ) -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/quicktranslatefeature/screens/QuickTranslateScreen.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.quicktranslatefeature.screens 2 | 3 | import androidx.compose.foundation.BorderStroke 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.layout.Arrangement 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.Column 8 | import androidx.compose.foundation.layout.Row 9 | import androidx.compose.foundation.layout.fillMaxHeight 10 | import androidx.compose.foundation.layout.fillMaxSize 11 | import androidx.compose.foundation.layout.fillMaxWidth 12 | import androidx.compose.foundation.layout.height 13 | import androidx.compose.foundation.layout.padding 14 | import androidx.compose.foundation.rememberScrollState 15 | import androidx.compose.foundation.text.selection.SelectionContainer 16 | import androidx.compose.foundation.verticalScroll 17 | import androidx.compose.material.Card 18 | import androidx.compose.material.ContentAlpha 19 | import androidx.compose.material.ExperimentalMaterialApi 20 | import androidx.compose.material.OutlinedTextField 21 | import androidx.compose.material.TextFieldDefaults 22 | import androidx.compose.material.icons.Icons 23 | import androidx.compose.material.icons.rounded.ArrowRightAlt 24 | import androidx.compose.material.icons.rounded.ContentCopy 25 | import androidx.compose.material.icons.rounded.VolumeUp 26 | import androidx.compose.material3.Icon 27 | import androidx.compose.material3.IconButton 28 | import androidx.compose.material3.MaterialTheme 29 | import androidx.compose.material3.Text 30 | import androidx.compose.runtime.Composable 31 | import androidx.compose.runtime.collectAsState 32 | import androidx.compose.runtime.getValue 33 | import androidx.compose.ui.Alignment 34 | import androidx.compose.ui.Modifier 35 | import androidx.compose.ui.platform.LocalContext 36 | import androidx.compose.ui.unit.dp 37 | import dev.atajan.lingva_android.R 38 | import dev.atajan.lingva_android.common.ui.components.ErrorNotificationDialog 39 | import dev.atajan.lingva_android.common.ui.components.LanguageSelectionBar 40 | import dev.atajan.lingva_android.common.ui.theme.mediumRoundedCornerShape 41 | import dev.atajan.lingva_android.quicktranslatefeature.redux.QuickTranslateScreenIntention.CopyTextToClipboard 42 | import dev.atajan.lingva_android.quicktranslatefeature.redux.QuickTranslateScreenIntention.OnTextToTranslateChange 43 | import dev.atajan.lingva_android.quicktranslatefeature.redux.QuickTranslateScreenIntention.ReadTextOutLoud 44 | import dev.atajan.lingva_android.quicktranslatefeature.redux.QuickTranslateScreenIntention.SetNewSourceLanguage 45 | import dev.atajan.lingva_android.quicktranslatefeature.redux.QuickTranslateScreenIntention.SetNewTargetLanguage 46 | import dev.atajan.lingva_android.quicktranslatefeature.redux.QuickTranslateScreenIntention.ShowErrorDialog 47 | import dev.atajan.lingva_android.quicktranslatefeature.redux.QuickTranslateScreenIntention.Translate 48 | 49 | @ExperimentalMaterialApi 50 | @Composable 51 | fun QuickTranslateScreen( 52 | textToTranslate: String, 53 | viewModel: QuickTranslateScreenViewModel 54 | ) { 55 | val context = LocalContext.current 56 | val scrollState = rememberScrollState(0) 57 | val quickTranslateScreenState by viewModel.states.collectAsState() 58 | 59 | viewModel.apply { 60 | send(OnTextToTranslateChange(textToTranslate)) 61 | send(Translate) 62 | } 63 | 64 | Column( 65 | modifier = Modifier 66 | .fillMaxSize() 67 | .background(MaterialTheme.colorScheme.background) 68 | ) { 69 | LanguageSelectionBar( 70 | modifier = Modifier 71 | .height(80.dp) 72 | .fillMaxSize() 73 | .padding(all = 16.dp), 74 | supportedLanguages = quickTranslateScreenState.supportedLanguages, 75 | sourceLanguage = quickTranslateScreenState.sourceLanguage, 76 | targetLanguage = quickTranslateScreenState.targetLanguage, 77 | toggleErrorDialogState = { 78 | viewModel.send(ShowErrorDialog(it)) 79 | }, 80 | middleIcon = Icons.Rounded.ArrowRightAlt, 81 | onMiddleIconTap = { 82 | // No action necessary for this user flow 83 | }, 84 | onNewSourceLanguageSelected = { 85 | viewModel.send(SetNewSourceLanguage(it)) 86 | viewModel.send(Translate) 87 | }, 88 | onNewTargetLanguageSelected = { 89 | viewModel.send(SetNewTargetLanguage(it)) 90 | viewModel.send(Translate) 91 | } 92 | ) 93 | 94 | Box( 95 | modifier = Modifier 96 | .fillMaxWidth() 97 | .fillMaxHeight(0.40f) 98 | .padding(all = 16.dp) 99 | ) { 100 | OutlinedTextField( 101 | value = textToTranslate, 102 | onValueChange = { }, 103 | label = { 104 | Text( 105 | text = context.getString(R.string.source_text), 106 | color = MaterialTheme.colorScheme.primary 107 | ) 108 | }, 109 | modifier = Modifier.fillMaxSize(), 110 | textStyle = MaterialTheme.typography.titleMedium, 111 | colors = TextFieldDefaults.outlinedTextFieldColors( 112 | focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = ContentAlpha.high), 113 | unfocusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = ContentAlpha.high), 114 | focusedLabelColor = MaterialTheme.colorScheme.primary.copy(alpha = ContentAlpha.high), 115 | unfocusedLabelColor = MaterialTheme.colorScheme.primary.copy(alpha = ContentAlpha.high), 116 | cursorColor = MaterialTheme.colorScheme.primary, 117 | textColor = MaterialTheme.colorScheme.onBackground, 118 | ), 119 | readOnly = true 120 | ) 121 | } 122 | 123 | Box( 124 | modifier = Modifier 125 | .fillMaxWidth() 126 | .fillMaxHeight(0.95f) 127 | .padding(all = 16.dp) 128 | ) { 129 | Card( 130 | shape = mediumRoundedCornerShape, 131 | border = BorderStroke( 132 | width = 2.dp, 133 | color = MaterialTheme.colorScheme.primary 134 | ), 135 | elevation = 0.dp, 136 | backgroundColor = MaterialTheme.colorScheme.background, 137 | modifier = Modifier.fillMaxSize(), 138 | ) { 139 | SelectionContainer { 140 | Text( 141 | quickTranslateScreenState.translatedText, 142 | color = MaterialTheme.colorScheme.onBackground, 143 | style = MaterialTheme.typography.titleMedium, 144 | modifier = Modifier 145 | .padding(16.dp) 146 | .verticalScroll(scrollState) 147 | ) 148 | } 149 | } 150 | 151 | Column( 152 | modifier = Modifier.fillMaxSize(), 153 | horizontalAlignment = Alignment.End, 154 | verticalArrangement = Arrangement.Bottom 155 | ) { 156 | Row( 157 | modifier = Modifier.fillMaxWidth(), 158 | horizontalArrangement = Arrangement.SpaceBetween 159 | ) { 160 | IconButton( 161 | onClick = { 162 | viewModel.send(ReadTextOutLoud) 163 | }, 164 | modifier = Modifier 165 | .padding(bottom = 16.dp) 166 | ) { 167 | Icon( 168 | imageVector = Icons.Rounded.VolumeUp, 169 | contentDescription = context.getString(R.string.text_to_speech_icon_ax), 170 | tint = MaterialTheme.colorScheme.primary, 171 | ) 172 | } 173 | 174 | IconButton( 175 | onClick = { 176 | viewModel.send(CopyTextToClipboard) 177 | }, 178 | modifier = Modifier 179 | .padding(bottom = 16.dp), 180 | ) { 181 | Icon( 182 | imageVector = Icons.Rounded.ContentCopy, 183 | contentDescription = context.getString(R.string.copy_icon_ax), 184 | tint = MaterialTheme.colorScheme.primary, 185 | ) 186 | } 187 | } 188 | } 189 | } 190 | } 191 | 192 | ErrorNotificationDialog(quickTranslateScreenState.errorDialogState) { 193 | viewModel.send(ShowErrorDialog(false)) 194 | } 195 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/quicktranslatefeature/screens/QuickTranslateScreenViewModel.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.quicktranslatefeature.screens 2 | 3 | import android.content.ClipData 4 | import android.content.ClipboardManager 5 | import androidx.datastore.core.DataStore 6 | import androidx.datastore.preferences.core.Preferences 7 | import androidx.lifecycle.viewModelScope 8 | import dagger.hilt.android.lifecycle.HiltViewModel 9 | import dev.atajan.lingva_android.common.data.datasource.impl.DEFAULT_TARGET_LANGUAGE 10 | import dev.atajan.lingva_android.common.domain.models.language.Language 11 | import dev.atajan.lingva_android.common.domain.models.translation.TranslationWithInfo.Companion.toTranslation 12 | import dev.atajan.lingva_android.common.domain.results.AudioRepositoryResponse 13 | import dev.atajan.lingva_android.common.domain.results.LanguagesRepositoryResponse 14 | import dev.atajan.lingva_android.common.domain.results.TranslationRepositoryResponse 15 | import dev.atajan.lingva_android.common.redux.MVIViewModel 16 | import dev.atajan.lingva_android.common.redux.MiddleWare 17 | import dev.atajan.lingva_android.common.usecases.FetchSupportedLanguagesUseCase 18 | import dev.atajan.lingva_android.common.usecases.ObserveAudioDataUseCase 19 | import dev.atajan.lingva_android.common.usecases.ObserveTranslationResultUseCase 20 | import dev.atajan.lingva_android.common.usecases.PlayByteArrayAudioUseCase 21 | import dev.atajan.lingva_android.common.usecases.RequestAudioDataUseCase 22 | import dev.atajan.lingva_android.common.usecases.TranslateUseCase 23 | import dev.atajan.lingva_android.quicktranslatefeature.redux.QuickTranslateScreenIntention 24 | import dev.atajan.lingva_android.quicktranslatefeature.redux.QuickTranslateScreenIntention.CopyTextToClipboard 25 | import dev.atajan.lingva_android.quicktranslatefeature.redux.QuickTranslateScreenIntention.OnTextToTranslateChange 26 | import dev.atajan.lingva_android.quicktranslatefeature.redux.QuickTranslateScreenIntention.ReadTextOutLoud 27 | import dev.atajan.lingva_android.quicktranslatefeature.redux.QuickTranslateScreenIntention.SetDefaultTargetLanguage 28 | import dev.atajan.lingva_android.quicktranslatefeature.redux.QuickTranslateScreenIntention.SetNewSourceLanguage 29 | import dev.atajan.lingva_android.quicktranslatefeature.redux.QuickTranslateScreenIntention.SetNewTargetLanguage 30 | import dev.atajan.lingva_android.quicktranslatefeature.redux.QuickTranslateScreenIntention.ShowErrorDialog 31 | import dev.atajan.lingva_android.quicktranslatefeature.redux.QuickTranslateScreenIntention.SupportedLanguagesReceived 32 | import dev.atajan.lingva_android.quicktranslatefeature.redux.QuickTranslateScreenIntention.Translate 33 | import dev.atajan.lingva_android.quicktranslatefeature.redux.QuickTranslateScreenIntention.TranslationFailure 34 | import dev.atajan.lingva_android.quicktranslatefeature.redux.QuickTranslateScreenIntention.TranslationSuccess 35 | import dev.atajan.lingva_android.quicktranslatefeature.redux.QuickTranslateScreenSideEffect 36 | import dev.atajan.lingva_android.quicktranslatefeature.redux.QuickTranslateScreenState 37 | import kotlinx.coroutines.CoroutineScope 38 | import kotlinx.coroutines.flow.distinctUntilChanged 39 | import kotlinx.coroutines.flow.launchIn 40 | import kotlinx.coroutines.flow.mapNotNull 41 | import kotlinx.coroutines.flow.onEach 42 | import kotlinx.coroutines.launch 43 | import javax.inject.Inject 44 | 45 | @HiltViewModel 46 | class QuickTranslateScreenViewModel @Inject constructor( 47 | applicationScope: CoroutineScope, 48 | translationResult: ObserveTranslationResultUseCase, 49 | audioData: ObserveAudioDataUseCase, 50 | private val clipboardManager: ClipboardManager, 51 | private val dataStore: DataStore, 52 | private val supportedLanguages: FetchSupportedLanguagesUseCase, 53 | private val translate: TranslateUseCase, 54 | private val playByteArrayAudio: PlayByteArrayAudioUseCase, 55 | private val requestAudioData: RequestAudioDataUseCase, 56 | ) : MVIViewModel( 57 | scope = applicationScope, 58 | initialState = QuickTranslateScreenState() 59 | ) { 60 | 61 | init { 62 | observeTranslationResults(translationResult) 63 | viewModelScope.launch { 64 | // These operations need to be sequential 65 | getSupportedLanguages() 66 | observeDefaultLanguages(this) 67 | } 68 | observeAudioData(audioData) 69 | } 70 | 71 | override fun reduce( 72 | currentState: QuickTranslateScreenState, 73 | intention: QuickTranslateScreenIntention, 74 | middleWares: List> 75 | ): QuickTranslateScreenState { 76 | return when (intention) { 77 | is SetDefaultTargetLanguage -> { 78 | if (currentState.defaultTargetLanguage != intention.languageName) { 79 | getDefaultLanguageIfProvided( 80 | supportedLanguages = currentState.supportedLanguages, 81 | lookUpLanguage = intention.languageName 82 | ).let { language -> 83 | currentState.copy( 84 | targetLanguage = language ?: currentState.targetLanguage, 85 | defaultTargetLanguage = language?.name ?: currentState.defaultTargetLanguage 86 | ) 87 | } 88 | } else { 89 | currentState 90 | } 91 | } 92 | is SetNewSourceLanguage -> currentState.copy(sourceLanguage = intention.language) 93 | is SetNewTargetLanguage -> currentState.copy(targetLanguage = intention.language) 94 | is ShowErrorDialog -> currentState.copy(errorDialogState = intention.show) 95 | is SupportedLanguagesReceived -> currentState.copy(supportedLanguages = intention.languages) 96 | is TranslationSuccess -> currentState.copy(translatedText = intention.result) 97 | Translate -> { 98 | requestTranslation( 99 | sourceLanguageCode = currentState.sourceLanguage.code, 100 | targetLanguageCode = currentState.targetLanguage.code, 101 | textToTranslate = currentState.textToTranslate 102 | ) 103 | currentState 104 | } 105 | TranslationFailure -> currentState 106 | CopyTextToClipboard -> { 107 | copyTextToClipboard(currentState.translatedText) 108 | currentState 109 | } 110 | 111 | is OnTextToTranslateChange -> { 112 | currentState.copy(textToTranslate = intention.newValue) 113 | } 114 | ReadTextOutLoud -> { 115 | requestAudioData( 116 | language = currentState.targetLanguage.code, 117 | query = currentState.translatedText 118 | ) 119 | currentState 120 | } 121 | } 122 | } 123 | 124 | private suspend fun getSupportedLanguages() { 125 | supportedLanguages().let { result -> 126 | when (result) { 127 | is LanguagesRepositoryResponse.Success -> { 128 | send(SupportedLanguagesReceived(result.languageList)) 129 | } 130 | is LanguagesRepositoryResponse.Failure -> { 131 | send(ShowErrorDialog(true)) 132 | } 133 | } 134 | } 135 | } 136 | 137 | private fun observeDefaultLanguages(scope: CoroutineScope) { 138 | dataStore.data.mapNotNull { 139 | it[DEFAULT_TARGET_LANGUAGE] 140 | } 141 | .distinctUntilChanged() 142 | .onEach { send(SetDefaultTargetLanguage(it)) } 143 | .launchIn(scope) 144 | } 145 | 146 | private fun observeTranslationResults(translationResult: ObserveTranslationResultUseCase) { 147 | translationResult().onEach { 148 | when (it) { 149 | is TranslationRepositoryResponse.Success -> { 150 | val responseWithInfo = it.response 151 | val pureTranslationResult = responseWithInfo.toTranslation().result 152 | 153 | send(TranslationSuccess(pureTranslationResult)) 154 | } 155 | is TranslationRepositoryResponse.Failure -> { 156 | send(TranslationFailure) 157 | } 158 | TranslationRepositoryResponse.Loading -> { 159 | // Loading UI? 160 | } 161 | } 162 | }.launchIn(viewModelScope) 163 | } 164 | 165 | private fun requestTranslation( 166 | sourceLanguageCode: String, 167 | targetLanguageCode: String, 168 | textToTranslate: String, 169 | ) { 170 | viewModelScope.launch { 171 | translate( 172 | sourceLanguageCode = sourceLanguageCode, 173 | targetLanguageCode = targetLanguageCode, 174 | textToTranslate = textToTranslate 175 | ) 176 | } 177 | } 178 | 179 | private fun copyTextToClipboard(translatedText: String) { 180 | val clipData = ClipData.newPlainText("Translation", translatedText) 181 | 182 | clipboardManager.setPrimaryClip(clipData) 183 | } 184 | 185 | private fun getDefaultLanguageIfProvided( 186 | supportedLanguages: List, 187 | lookUpLanguage: String 188 | ): Language? { 189 | return supportedLanguages.find { it.name == lookUpLanguage } 190 | } 191 | 192 | private fun observeAudioData(audioRepository: ObserveAudioDataUseCase) { 193 | audioRepository().onEach { 194 | when (it) { 195 | is AudioRepositoryResponse.Success -> { 196 | playByteArrayAudio(it.audio.audioByteArray) 197 | } 198 | is AudioRepositoryResponse.Failure -> { 199 | send(ShowErrorDialog(true)) 200 | } 201 | } 202 | }.launchIn(viewModelScope) 203 | } 204 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/translatefeature/redux/TranslateScreenIntention.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.translatefeature.redux 2 | 3 | import dev.atajan.lingva_android.common.domain.models.language.Language 4 | import dev.atajan.lingva_android.common.domain.models.translation.TranslationWithInfo 5 | import dev.atajan.lingva_android.common.ui.theme.ThemingOptions 6 | 7 | sealed interface TranslateScreenIntention { 8 | data class DefaultSourceLanguageSelected(val language: Language) : TranslateScreenIntention 9 | data class DefaultTargetLanguageSelected(val language: Language) : TranslateScreenIntention 10 | data class SetDefaultSourceLanguage(val languageName: String) : TranslateScreenIntention 11 | data class SetDefaultTargetLanguage(val languageName: String) : TranslateScreenIntention 12 | data class UserToggleLiveTranslate(val enabled: Boolean) : TranslateScreenIntention 13 | data class SetLiveTranslate(val enabled: Boolean) : TranslateScreenIntention 14 | data class SetNewSourceLanguage(val language: Language) : TranslateScreenIntention 15 | data class SetNewTargetLanguage(val language: Language) : TranslateScreenIntention 16 | data class ShowErrorDialog(val show: Boolean) : TranslateScreenIntention 17 | data class SupportedLanguagesReceived(val languages: List) : TranslateScreenIntention 18 | data class ToggleAppTheme(val newTheme: ThemingOptions) : TranslateScreenIntention 19 | data class TranslationSuccess(val translationWithInfo: TranslationWithInfo) : TranslateScreenIntention 20 | data class UserUpdateCustomLingvaServerUrl(val url: String) : TranslateScreenIntention 21 | data class SetCustomLingvaServerUrl(val url: String) : TranslateScreenIntention 22 | object ClearCustomLingvaServerUrl : TranslateScreenIntention 23 | object ClearInputField : TranslateScreenIntention 24 | object CopyTextToClipboard : TranslateScreenIntention 25 | object DisplayPronunciation : TranslateScreenIntention 26 | object ReadTextOutLoud : TranslateScreenIntention 27 | object Translate : TranslateScreenIntention 28 | object TranslationFailure : TranslateScreenIntention 29 | object TrySwapLanguages : TranslateScreenIntention 30 | } -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/translatefeature/redux/TranslateScreenSideEffect.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.translatefeature.redux 2 | 3 | sealed interface TranslateScreenSideEffect -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/translatefeature/redux/TranslateScreenState.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.translatefeature.redux 2 | 3 | import dev.atajan.lingva_android.common.domain.models.language.Language 4 | 5 | data class TranslateScreenState( 6 | val defaultSourceLanguage: String = "", 7 | val defaultTargetLanguage: String = "", 8 | val displayPronunciation: Boolean = false, 9 | val errorDialogState: Boolean = false, // TODO: Should be a side effect. 10 | val liveTranslationEnabled: Boolean = true, 11 | val sourceLanguage: Language = Language("auto", "Detect"), 12 | val supportedLanguages: List = emptyList(), 13 | val targetLanguage: Language = Language("es", "Spanish"), 14 | val translatedText: String = "", 15 | val translatedTextPronunciation: String = "", 16 | val customLingvaServerUrl: String = "", 17 | ) 18 | -------------------------------------------------------------------------------- /app/src/main/java/dev/atajan/lingva_android/translatefeature/screens/TranslateScreen.kt: -------------------------------------------------------------------------------- 1 | package dev.atajan.lingva_android.translatefeature.screens 2 | 3 | import androidx.compose.animation.AnimatedVisibility 4 | import androidx.compose.foundation.BorderStroke 5 | import androidx.compose.foundation.background 6 | import androidx.compose.foundation.layout.Arrangement 7 | import androidx.compose.foundation.layout.Box 8 | import androidx.compose.foundation.layout.Column 9 | import androidx.compose.foundation.layout.Row 10 | import androidx.compose.foundation.layout.fillMaxHeight 11 | import androidx.compose.foundation.layout.fillMaxSize 12 | import androidx.compose.foundation.layout.fillMaxWidth 13 | import androidx.compose.foundation.layout.padding 14 | import androidx.compose.foundation.rememberScrollState 15 | import androidx.compose.foundation.text.KeyboardOptions 16 | import androidx.compose.foundation.text.selection.SelectionContainer 17 | import androidx.compose.foundation.verticalScroll 18 | import androidx.compose.material.Card 19 | import androidx.compose.material.ContentAlpha 20 | import androidx.compose.material.ExperimentalMaterialApi 21 | import androidx.compose.material.ModalBottomSheetValue 22 | import androidx.compose.material.OutlinedTextField 23 | import androidx.compose.material.TextFieldDefaults 24 | import androidx.compose.material.icons.Icons 25 | import androidx.compose.material.icons.rounded.ContentCopy 26 | import androidx.compose.material.icons.rounded.Delete 27 | import androidx.compose.material.icons.rounded.SpeakerNotes 28 | import androidx.compose.material.icons.rounded.SwapHoriz 29 | import androidx.compose.material.icons.rounded.Translate 30 | import androidx.compose.material.icons.rounded.VolumeUp 31 | import androidx.compose.material.rememberModalBottomSheetState 32 | import androidx.compose.material3.Icon 33 | import androidx.compose.material3.IconButton 34 | import androidx.compose.material3.MaterialTheme 35 | import androidx.compose.material3.Text 36 | import androidx.compose.runtime.Composable 37 | import androidx.compose.runtime.LaunchedEffect 38 | import androidx.compose.runtime.MutableState 39 | import androidx.compose.runtime.collectAsState 40 | import androidx.compose.runtime.getValue 41 | import androidx.compose.runtime.mutableStateOf 42 | import androidx.compose.runtime.remember 43 | import androidx.compose.runtime.rememberCoroutineScope 44 | import androidx.compose.ui.Alignment 45 | import androidx.compose.ui.Modifier 46 | import androidx.compose.ui.platform.LocalContext 47 | import androidx.compose.ui.platform.LocalSoftwareKeyboardController 48 | import androidx.compose.ui.text.input.ImeAction 49 | import androidx.compose.ui.unit.dp 50 | import dev.atajan.lingva_android.R 51 | import dev.atajan.lingva_android.common.ui.components.ErrorNotificationDialog 52 | import dev.atajan.lingva_android.common.ui.components.LanguageSelectionAndSettingsBar 53 | import dev.atajan.lingva_android.common.ui.components.SettingsBottomSheet 54 | import dev.atajan.lingva_android.common.ui.theme.ThemingOptions 55 | import dev.atajan.lingva_android.common.ui.theme.mediumRoundedCornerShape 56 | import dev.atajan.lingva_android.translatefeature.redux.TranslateScreenIntention.ClearInputField 57 | import dev.atajan.lingva_android.translatefeature.redux.TranslateScreenIntention.CopyTextToClipboard 58 | import dev.atajan.lingva_android.translatefeature.redux.TranslateScreenIntention.DefaultSourceLanguageSelected 59 | import dev.atajan.lingva_android.translatefeature.redux.TranslateScreenIntention.DefaultTargetLanguageSelected 60 | import dev.atajan.lingva_android.translatefeature.redux.TranslateScreenIntention.DisplayPronunciation 61 | import dev.atajan.lingva_android.translatefeature.redux.TranslateScreenIntention.ReadTextOutLoud 62 | import dev.atajan.lingva_android.translatefeature.redux.TranslateScreenIntention.SetNewSourceLanguage 63 | import dev.atajan.lingva_android.translatefeature.redux.TranslateScreenIntention.SetNewTargetLanguage 64 | import dev.atajan.lingva_android.translatefeature.redux.TranslateScreenIntention.ShowErrorDialog 65 | import dev.atajan.lingva_android.translatefeature.redux.TranslateScreenIntention.ToggleAppTheme 66 | import dev.atajan.lingva_android.translatefeature.redux.TranslateScreenIntention.Translate 67 | import dev.atajan.lingva_android.translatefeature.redux.TranslateScreenIntention.TrySwapLanguages 68 | import dev.atajan.lingva_android.translatefeature.redux.TranslateScreenIntention.UserToggleLiveTranslate 69 | import dev.atajan.lingva_android.translatefeature.redux.TranslateScreenIntention.UserUpdateCustomLingvaServerUrl 70 | import kotlinx.coroutines.launch 71 | 72 | @ExperimentalMaterialApi 73 | @Composable 74 | fun TranslationScreen( 75 | viewModel: TranslateScreenViewModel, 76 | currentTheme: MutableState 77 | ) { 78 | val context = LocalContext.current 79 | val scrollState = rememberScrollState(0) 80 | val softwareKeyboardController = LocalSoftwareKeyboardController.current 81 | val scope = rememberCoroutineScope() 82 | val modalBottomSheetState = rememberModalBottomSheetState( 83 | initialValue = ModalBottomSheetValue.Hidden, 84 | confirmStateChange = { it != ModalBottomSheetValue.HalfExpanded } 85 | ) 86 | val translationScreenState by viewModel.states.collectAsState() 87 | val customLingvaServerUrl = remember { mutableStateOf("") } 88 | val textToTranslate by viewModel.textToTranslate.collectAsState() 89 | 90 | Column( 91 | modifier = Modifier 92 | .fillMaxSize() 93 | .background(MaterialTheme.colorScheme.background) 94 | ) { 95 | LanguageSelectionAndSettingsBar( 96 | supportedLanguages = translationScreenState.supportedLanguages, 97 | sourceLanguage = translationScreenState.sourceLanguage, 98 | targetLanguage = translationScreenState.targetLanguage, 99 | toggleErrorDialogState = { 100 | viewModel.send(ShowErrorDialog(it)) 101 | }, 102 | middleIcon = Icons.Rounded.SwapHoriz, 103 | onMiddleIconTap = { viewModel.send(TrySwapLanguages) }, 104 | onNewSourceLanguageSelected = { viewModel.send(SetNewSourceLanguage(it)) }, 105 | onNewTargetLanguageSelected = { viewModel.send(SetNewTargetLanguage(it)) }, 106 | onEndIconTap = { 107 | scope.launch { 108 | if (modalBottomSheetState.isVisible) { 109 | modalBottomSheetState.hide() 110 | } else { 111 | modalBottomSheetState.show() 112 | } 113 | } 114 | }, 115 | modifier = Modifier 116 | .padding(horizontal = 16.dp) 117 | .padding(top = 16.dp) 118 | ) 119 | 120 | Box( 121 | modifier = Modifier 122 | .fillMaxWidth() 123 | .fillMaxHeight(0.4f) 124 | .padding(all = 16.dp) 125 | ) { 126 | OutlinedTextField( 127 | value = textToTranslate, 128 | onValueChange = { 129 | viewModel.onTextToTranslateChange(it) 130 | }, 131 | label = { 132 | Text( 133 | text = context.getString(R.string.source_text), 134 | color = MaterialTheme.colorScheme.primary 135 | ) 136 | }, 137 | modifier = Modifier.fillMaxSize(), 138 | keyboardOptions = KeyboardOptions(imeAction = ImeAction.Default), 139 | textStyle = MaterialTheme.typography.titleMedium, 140 | colors = TextFieldDefaults.outlinedTextFieldColors( 141 | focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = ContentAlpha.high), 142 | unfocusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = ContentAlpha.disabled), 143 | focusedLabelColor = MaterialTheme.colorScheme.primary.copy(alpha = ContentAlpha.high), 144 | unfocusedLabelColor = MaterialTheme.colorScheme.primary.copy(alpha = ContentAlpha.disabled), 145 | cursorColor = MaterialTheme.colorScheme.primary, 146 | textColor = MaterialTheme.colorScheme.onBackground, 147 | ) 148 | ) 149 | 150 | Column( 151 | modifier = Modifier.fillMaxSize(), 152 | horizontalAlignment = Alignment.End, 153 | verticalArrangement = Arrangement.Bottom 154 | ) { 155 | AnimatedVisibility(textToTranslate.isNotEmpty()) { 156 | Row( 157 | modifier = Modifier.fillMaxWidth(), 158 | horizontalArrangement = Arrangement.SpaceBetween 159 | ) { 160 | IconButton( 161 | onClick = { 162 | softwareKeyboardController?.hide() 163 | viewModel.send(ClearInputField) 164 | }, 165 | modifier = Modifier 166 | .padding(bottom = 16.dp) 167 | ) { 168 | Icon( 169 | imageVector = Icons.Rounded.Delete, 170 | contentDescription = context.getString(R.string.delete_icon_ax), 171 | tint = MaterialTheme.colorScheme.primary, 172 | ) 173 | } 174 | 175 | IconButton( 176 | onClick = { 177 | softwareKeyboardController?.hide() 178 | viewModel.send(Translate) 179 | }, 180 | modifier = Modifier 181 | .padding(bottom = 16.dp) 182 | ) { 183 | Icon( 184 | imageVector = Icons.Rounded.Translate, 185 | contentDescription = context.getString(R.string.translate_icon_ax), 186 | tint = MaterialTheme.colorScheme.primary, 187 | ) 188 | } 189 | } 190 | } 191 | } 192 | } 193 | 194 | AnimatedVisibility(translationScreenState.translatedText.isNotEmpty()) { 195 | Box( 196 | modifier = Modifier 197 | .fillMaxWidth() 198 | .fillMaxHeight() 199 | .padding(all = 16.dp) 200 | ) { 201 | Card( 202 | shape = mediumRoundedCornerShape, 203 | border = BorderStroke( 204 | width = 2.dp, 205 | color = MaterialTheme.colorScheme.primary 206 | ), 207 | elevation = 0.dp, 208 | backgroundColor = MaterialTheme.colorScheme.background, 209 | modifier = Modifier.fillMaxSize(), 210 | ) { 211 | SelectionContainer { 212 | Text( 213 | text = if (translationScreenState.displayPronunciation) { 214 | translationScreenState.translatedTextPronunciation 215 | } else { 216 | translationScreenState.translatedText 217 | }, 218 | color = MaterialTheme.colorScheme.onBackground, 219 | style = MaterialTheme.typography.titleMedium, 220 | modifier = Modifier 221 | .padding(16.dp) 222 | .verticalScroll(scrollState) 223 | ) 224 | } 225 | } 226 | 227 | Column( 228 | modifier = Modifier.fillMaxSize(), 229 | horizontalAlignment = Alignment.End, 230 | verticalArrangement = Arrangement.Bottom 231 | ) { 232 | Row( 233 | modifier = Modifier.fillMaxWidth(), 234 | horizontalArrangement = Arrangement.SpaceBetween 235 | ) { 236 | IconButton( 237 | onClick = { 238 | viewModel.send(ReadTextOutLoud) 239 | }, 240 | modifier = Modifier.padding(bottom = 16.dp) 241 | ) { 242 | Icon( 243 | imageVector = Icons.Rounded.VolumeUp, 244 | contentDescription = context.getString(R.string.text_to_speech_icon_ax), 245 | tint = MaterialTheme.colorScheme.primary, 246 | ) 247 | } 248 | 249 | IconButton( 250 | onClick = { 251 | viewModel.send(DisplayPronunciation) 252 | }, 253 | modifier = Modifier.padding(bottom = 16.dp) 254 | ) { 255 | Icon( 256 | imageVector = Icons.Rounded.SpeakerNotes, 257 | contentDescription = context.getString(R.string.text_to_speech_icon_ax), 258 | tint = MaterialTheme.colorScheme.primary, 259 | ) 260 | } 261 | 262 | IconButton( 263 | onClick = { 264 | viewModel.send(CopyTextToClipboard) 265 | }, 266 | modifier = Modifier.padding(bottom = 16.dp), 267 | ) { 268 | Icon( 269 | imageVector = Icons.Rounded.ContentCopy, 270 | contentDescription = context.getString(R.string.copy_icon_ax), 271 | tint = MaterialTheme.colorScheme.primary, 272 | ) 273 | } 274 | } 275 | } 276 | } 277 | } 278 | } 279 | 280 | SettingsBottomSheet( 281 | modalBottomSheetState = modalBottomSheetState, 282 | toggleTheme = { 283 | viewModel.send(ToggleAppTheme(it)) 284 | }, 285 | currentTheme = currentTheme, 286 | setDefaultSourceLanguage = { 287 | viewModel.send(DefaultSourceLanguageSelected(it)) 288 | }, 289 | setDefaultTargetLanguage = { 290 | viewModel.send(DefaultTargetLanguageSelected(it)) 291 | }, 292 | supportedLanguages = translationScreenState.supportedLanguages, 293 | defaultSourceLanguage = translationScreenState.defaultSourceLanguage, 294 | defaultTargetLanguage = translationScreenState.defaultTargetLanguage, 295 | toggleErrorDialogState = { 296 | viewModel.send(ShowErrorDialog(it)) 297 | }, 298 | customLingvaServerUrl = customLingvaServerUrl, 299 | liveTranslateEnabled = translationScreenState.liveTranslationEnabled, 300 | onToggleLiveTranslate = { viewModel.send(UserToggleLiveTranslate(it)) }, 301 | currentCustomLingvaServerUrl = translationScreenState.customLingvaServerUrl, 302 | context = context, 303 | ) 304 | 305 | LaunchedEffect(!modalBottomSheetState.isVisible) { 306 | if (customLingvaServerUrl.value.isNotEmpty()) { 307 | viewModel.send(UserUpdateCustomLingvaServerUrl(customLingvaServerUrl.value)) 308 | customLingvaServerUrl.value = "" 309 | } 310 | } 311 | 312 | ErrorNotificationDialog(translationScreenState.errorDialogState) { 313 | viewModel.send(ShowErrorDialog(false)) 314 | } 315 | } 316 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 14 | 19 | 23 | 27 | 31 | 35 | 39 | 43 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_monochrome_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 14 | 19 | 23 | 27 | 31 | 35 | 40 | 44 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/values-de/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Lentil Translate 4 | Quellentext 5 | App-Thema 6 | Basierend auf dem Hintergrundbild und Thema Ihres Geräts 7 | Etwas ist schief gelaufen. Bitte versuchen Sie es später erneut. 8 | Okay 9 | Standardsprachen 10 | Ausgangssprache: 11 | Zielsprache: 12 | Tippen Sie hier, um auszuwählen 13 | Erkennen 14 | Erweiterte Einstellungen 15 | Benutzerdefinierte Übersetzungsserver-URL 16 | Benutzerdefinierte Lingva-Server-URL 17 | Live-Übersetzung umschalten 18 | 19 | 20 | Ausgangs- und Zielsprache tauschen 21 | Einstellung 22 | Eingabetext löschen 23 | Übersetzen 24 | Kopieren 25 | Übersetzten Text abspielen 26 | -------------------------------------------------------------------------------- /app/src/main/res/values-fr/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Lentil Translate 3 | Texte source 4 | Thème de l\'application 5 | Basé sur le fond d\'écran et le thème de votre appareil 6 | Quelque chose s\'est mal passé. Veuillez réessayer plus tard. 7 | D\'accord 8 | Langues par défaut 9 | Langue originelle: 10 | Langue cible: 11 | Appuyez ici pour sélectionner 12 | Détecter 13 | Paramètres avancés 14 | URL de serveur de traduction personnalisé 15 | URL de serveur Lingva personnalisé 16 | Basculer la traduction en direct 17 | 18 | 19 | Permuter les langues source et cible 20 | Paramètre 21 | Effacer le texte saisi 22 | Traduire 23 | Copie 24 | Lire le texte traduit 25 | -------------------------------------------------------------------------------- /app/src/main/res/values-it/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Lentil Translate 3 | Testo sorgente 4 | Tema dell\'app 5 | In base allo sfondo e al tema del tuo dispositivo 6 | Qualcosa è andato storto. Per favore riprova più tardi. 7 | Bene 8 | Lingue predefinite 9 | Linguaggio del codice: 10 | Lingua di destinazione: 11 | Tocca qui per selezionare 12 | Rileva 13 | Impostazioni avanzate 14 | URL server di traduzione personalizzato 15 | URL server lingva personalizzato 16 | Attiva/Disattiva traduzione dal vivo 17 | 18 | 19 | Scambia le lingue di origine e di destinazione 20 | Ambientazione 21 | Cancella il testo di input 22 | Tradurre 23 | copia 24 | Riproduci il testo tradotto 25 | -------------------------------------------------------------------------------- /app/src/main/res/values-ja/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Lentil Translate 3 | ソーステキスト 4 | アプリのテーマ 5 | デバイスの壁紙とテーマに基づく 6 | エラーが発生しました。後でもう一度やり直してください。 7 | オッケー 8 | デフォルト言語 9 | ソース言語: 10 | 目標とする言語: 11 | ここをタップして選択 12 | 探知 13 | 詳細設定 14 | カスタム翻訳サーバーURL 15 | カスタムlingvaサーバーURL 16 | ライブ翻訳を切り替える 17 | 18 | 19 | ソース言語とターゲット言語を入れ替える 20 | 設定 21 | 入力テキストをクリア 22 | 翻訳 23 | コピー 24 | 翻訳されたテキストを再生 25 | -------------------------------------------------------------------------------- /app/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/values-pt-rBR/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Lentil Translate 3 | Texto original 4 | Tema do aplicativo 5 | Com base no papel de parede e no tema do seu dispositivo 6 | Algo deu errado. Por favor, tente novamente mais tarde. 7 | Ok 8 | Idiomas padrão 9 | Idioma de origem: 10 | Idioma de destino: 11 | Toque aqui para selecionar 12 | Detectar 13 | Configurações avançadas 14 | URL do servidor de tradução personalizada 15 | URL do servidor lingva personalizado 16 | Alternar tradução ao vivo 17 | 18 | 19 | Troque os idiomas de origem e de destino 20 | Contexto 21 | Limpar texto de entrada 22 | Traduzir 23 | cópia de 24 | Reproduzir texto traduzido 25 | -------------------------------------------------------------------------------- /app/src/main/res/values-pt/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Lentil Translate 3 | Texto original 4 | Tema do aplicativo 5 | Com base no papel de parede e no tema do seu dispositivo 6 | Algo deu errado. Por favor, tente novamente mais tarde. 7 | Ok 8 | Idiomas padrão 9 | Idioma de origem: 10 | Idioma de destino: 11 | Toque aqui para selecionar 12 | Detectar 13 | Configurações avançadas 14 | URL do servidor de tradução personalizada 15 | URL do servidor lingva personalizado 16 | Alternar tradução ao vivo 17 | 18 | 19 | Troque os idiomas de origem e de destino 20 | Contexto 21 | Limpar texto de entrada 22 | Traduzir 23 | cópia de 24 | Reproduzir texto traduzido 25 | -------------------------------------------------------------------------------- /app/src/main/res/values-ru/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Lentil Translate 3 | Исходный текст 4 | Тема приложения 5 | На основе обоев и темы вашего устройства 6 | Что-то пошло не так. Пожалуйста, попробуйте позже. 7 | Хорошо 8 | Языки по умолчанию 9 | Исходный язык: 10 | Язык перевода: 11 | Нажмите здесь, чтобы выбрать 12 | Обнаружить 13 | Расширенные настройки 14 | URL-адрес пользовательского сервера перевода 15 | URL-адрес пользовательского сервера Lingva 16 | Переключить живой перевод 17 | 18 | 19 | Поменять местами исходный и целевой языки 20 | Параметр 21 | Очистить вводимый текст 22 | Перевести 23 | Копировать 24 | Воспроизвести переведенный текст 25 | -------------------------------------------------------------------------------- /app/src/main/res/values-tr/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Lentil Translate 3 | Kaynak metin 4 | Uygulama Teması 5 | Aygıtınızın duvar kağıdına ve temasına göre 6 | Bir şeyler yanlış gitti. Lütfen daha sonra tekrar deneyin. 7 | Tamam 8 | Öntanımlı Diller 9 | Kaynak Dil: 10 | Hedef Dil: 11 | Seçmek için buraya dokunun 12 | Algıla 13 | Gelişmiş Ayarlar 14 | Özel çeviri sunucusu URL\'si 15 | Özel lingva sunucusu URL\'si 16 | Canlı çeviriyi aç/kapat 17 | 18 | 19 | Kaynak ve hedef dilleri değiştir 20 | Ayar 21 | Giriş metnini temizle 22 | Çevir 23 | Kopyala 24 | Çevrilen metni oku 25 | 26 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #287F8F 4 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Lentil Translate 3 | Source text 4 | App Theme 5 | Based on your device wallpaper and theme 6 | Something went wrong. Please try again later. 7 | Okay 8 | Default Languages 9 | Source Language: 10 | Target Language: 11 | Tap here to select 12 | Detect 13 | Advanced Settings 14 | Custom translation server URL 15 | Custom lingva server URL 16 | Toggle live translate 17 | 18 | 19 | Swap source and target languages 20 | Setting 21 | Clear input text 22 | Translate 23 | Copy 24 | Playback translated text 25 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | 17 | 21 | 22 |