diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index bd30f27a..0d6399c7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -4,7 +4,7 @@ on:
push:
branches: [ "master", "dep/**" ]
pull_request:
- branches: [ "master", "dep/**" ]
+ branches: [ "master", "dep/**", "refactor/**" ]
jobs:
build:
@@ -14,7 +14,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: set up JDK 17
- uses: actions/setup-java@v3
+ uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
@@ -25,7 +25,7 @@ jobs:
- name: Build with Gradle
run: ./gradlew assembleDevDebug
- name: Upload debug APK
- uses: actions/upload-artifact@v3.1.3
+ uses: actions/upload-artifact@v4
with:
name: main-dev-debug.apk
path: main/build/outputs/apk/dev/debug/main-dev-debug.apk
diff --git a/.gitignore b/.gitignore
index d093678d..1abf2ccd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -49,3 +49,4 @@ google-services.json
!main/src/dev/google-services.json
config_ad.xml
!main/src/main/res/values/config_ad.xml
+.kotlin/
diff --git a/build.gradle b/build.gradle
index 552fdc34..81dcb03f 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,9 +1,18 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
- ext.nav_version = "2.6.0" // require SDK 34 from 2.7.0
+ ext.nav_version = "2.9.0" // require SDK 34 from 2.7.0
+ ext.hilt_version = "2.56.2"
+ ext.coroutines_version = "1.10.2"
+ ext.lifecycle_version = "2.9.0"
+ ext.text_recognition_version = "16.0.1"
+ ext.kotpref_version = "2.13.2"
+ ext.retrofit2_version = "3.0.0"
+ ext.moshi_version = "1.15.2"
+
repositories {
google()
+ gradlePluginPortal()
mavenCentral()
maven {
url "https://jitpack.io"
@@ -13,20 +22,28 @@ buildscript {
}
}
dependencies {
- classpath 'com.android.tools.build:gradle:8.1.3'
+ classpath 'com.android.tools.build:gradle:8.9.1'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
- classpath 'com.google.gms:google-services:4.4.0'
- classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.10"
+ classpath 'com.google.gms:google-services:4.4.2'
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.21"
classpath 'com.google.firebase:perf-plugin:1.4.2'
- classpath 'com.google.firebase:firebase-crashlytics-gradle:2.9.9'
+ classpath 'com.google.firebase:firebase-crashlytics-gradle:3.0.3'
// Refactor
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version"
+
+ // Detekt
+ classpath "io.gitlab.arturbosch.detekt:detekt-gradle-plugin:1.23.8"
}
}
+plugins {
+ id 'com.google.dagger.hilt.android' version '2.56.2' apply false
+ id 'com.google.devtools.ksp' version '2.1.21-2.0.1' apply false
+}
+
allprojects {
repositories {
google()
diff --git a/detekt.yml b/detekt.yml
new file mode 100644
index 00000000..63c721c0
--- /dev/null
+++ b/detekt.yml
@@ -0,0 +1,85 @@
+# From https://mrmans0n.github.io/compose-rules/detekt/
+Compose:
+ ComposableAnnotationNaming:
+ active: true
+ ComposableNaming:
+ active: true
+ # -- You can optionally disable the checks in this rule for regex matches against the composable name (e.g. molecule presenters)
+ # allowedComposableFunctionNames: .*Presenter,.*MoleculePresenter
+ ComposableParamOrder:
+ active: true
+ # -- You can optionally have a list of types to be treated as lambdas (e.g. typedefs or fun interfaces not picked up automatically)
+ # treatAsLambda: MyLambdaType
+ CompositionLocalAllowlist:
+ active: true
+ # -- You can optionally define a list of CompositionLocals that are allowed here
+ # allowedCompositionLocals: LocalSomething,LocalSomethingElse
+ CompositionLocalNaming:
+ active: true
+ ContentEmitterReturningValues:
+ active: true
+ # -- You can optionally add your own composables here
+ # contentEmitters: MyComposable,MyOtherComposable
+ DefaultsVisibility:
+ active: true
+ ModifierClickableOrder:
+ active: true
+ # -- You can optionally add your own Modifier types
+ # customModifiers: BananaModifier,PotatoModifier
+ ModifierComposable:
+ active: true
+ # -- You can optionally add your own Modifier types
+ # customModifiers: BananaModifier,PotatoModifier
+ ModifierMissing:
+ active: true
+ # -- You can optionally control the visibility of which composables to check for here
+ # -- Possible values are: `only_public`, `public_and_internal` and `all` (default is `only_public`)
+ # checkModifiersForVisibility: only_public
+ # -- You can optionally add your own Modifier types
+ # customModifiers: BananaModifier,PotatoModifier
+ ModifierNaming:
+ active: true
+ # -- You can optionally add your own Modifier types
+ # customModifiers: BananaModifier,PotatoModifier
+ ModifierNotUsedAtRoot:
+ active: true
+ # -- You can optionally add your own composables here
+ # contentEmitters: MyComposable,MyOtherComposable
+ # -- You can optionally add your own Modifier types
+ # customModifiers: BananaModifier,PotatoModifier
+ ModifierReused:
+ active: true
+ # -- You can optionally add your own Modifier types
+ # customModifiers: BananaModifier,PotatoModifier
+ ModifierWithoutDefault:
+ active: true
+ MultipleEmitters:
+ active: true
+ # -- You can optionally add your own composables here that will count as content emitters
+ # contentEmitters: MyComposable,MyOtherComposable
+ # -- You can add composables here that you don't want to count as content emitters (e.g. custom dialogs or modals)
+ # contentEmittersDenylist: MyNonEmitterComposable
+ MutableParams:
+ active: true
+ MutableStateParam:
+ active: true
+ PreviewAnnotationNaming:
+ active: true
+ PreviewPublic:
+ active: true
+ RememberMissing:
+ active: true
+ RememberContentMissing:
+ active: true
+ UnstableCollections:
+ active: true
+ ViewModelForwarding:
+ active: true
+ # -- You can optionally use this rule on things other than types ending in "ViewModel" or "Presenter" (which are the defaults). You can add your own via a regex here:
+ # allowedStateHolderNames: .*ViewModel,.*Presenter
+ # -- You can optionally add an allowlist for Composable names that won't be affected by this rule
+ # allowedForwarding: .*Content,.*FancyStuff
+ ViewModelInjection:
+ active: true
+ # -- You can optionally add your own ViewModel factories here
+ # viewModelFactories: hiltViewModel,potatoViewModel
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index ac72c34e..e2847c82 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/main/build.gradle b/main/build.gradle
index a757aef0..ecc2bc91 100644
--- a/main/build.gradle
+++ b/main/build.gradle
@@ -1,11 +1,14 @@
plugins {
id 'com.android.application'
id 'kotlin-android'
- id 'kotlin-kapt'
+ id 'com.google.devtools.ksp'
id 'androidx.navigation.safeargs.kotlin'
id 'com.google.gms.google-services'
id 'com.google.firebase.crashlytics'
id 'com.google.firebase.firebase-perf'
+ id 'com.google.dagger.hilt.android'
+ id "io.gitlab.arturbosch.detekt"
+ id("org.jetbrains.kotlin.plugin.compose") version "2.1.21"
}
def buildParams = getGradle().getStartParameter().toString().toLowerCase()
@@ -20,15 +23,14 @@ task ensureFiles {
}
android {
- compileSdk 33
- buildToolsVersion = "33.0.1"
+ compileSdk 35
defaultConfig {
applicationId "tw.firemaples.onscreenocr"
minSdkVersion 21
- targetSdkVersion 33
- versionCode 117
- versionName "3.1.25"
+ targetSdkVersion 35
+ versionCode 128
+ versionName "4.0.10"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -91,7 +93,11 @@ android {
jvmTarget = '17'
}
buildFeatures {
- viewBinding true
+ viewBinding true //TODO remove after full migrating to compose
+ compose true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = "1.5.15"
}
packagingOptions {
jniLibs {
@@ -101,26 +107,29 @@ android {
}
dependencies {
+ detektPlugins "io.nlopez.compose.rules:detekt:0.4.22"
+
implementation fileTree(include: ['*.jar', '*.aar'], dir: 'libs')
- implementation 'androidx.core:core-ktx:1.10.1'
- implementation 'androidx.appcompat:appcompat:1.6.1'
- implementation 'com.google.android.material:material:1.9.0'
- implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
+ implementation 'androidx.core:core-ktx:1.16.0'
+ implementation 'androidx.appcompat:appcompat:1.7.0'
+ implementation 'com.google.android.material:material:1.12.0'
+ implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
- implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.2'
- implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2'
+ implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
+ implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
+ implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version"
implementation "androidx.preference:preference-ktx:1.2.1"
- implementation 'androidx.webkit:webkit:1.7.0'
+ implementation 'androidx.webkit:webkit:1.13.0'
//noinspection GradleDynamicVersion
testImplementation 'junit:junit:4.13.2'
- androidTestImplementation 'androidx.test.ext:junit:1.1.5'
- androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
+ androidTestImplementation 'androidx.test.ext:junit:1.2.1'
+ androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
- implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
// For using coroutines in tests
// For runBlockingTest, CoroutineDispatcher etc.
- testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
+ testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
// For InstantTaskExecutorRule
testImplementation "androidx.arch.core:core-testing:2.2.0"
@@ -136,10 +145,27 @@ dependencies {
androidTestImplementation "androidx.navigation:navigation-testing:$nav_version"
// required to avoid crash on Android 12 API 31
- implementation 'androidx.work:work-runtime-ktx:2.8.1'
+ implementation 'androidx.work:work-runtime-ktx:2.10.1'
+
+ // Compose
+ def composeBom = platform('androidx.compose:compose-bom:2025.05.00')
+ implementation composeBom
+ androidTestImplementation composeBom
+ // Material Design 3
+ implementation 'androidx.compose.material3:material3'
+ // Android Studio Preview support
+ implementation 'androidx.compose.ui:ui-tooling-preview'
+ debugImplementation 'androidx.compose.ui:ui-tooling'
+ // UI Tests
+ androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
+ debugImplementation 'androidx.compose.ui:ui-test-manifest'
+
+ // Hilt
+ implementation "com.google.dagger:hilt-android:$hilt_version"
+ ksp "com.google.dagger:hilt-compiler:$hilt_version"
// Firebase
- implementation platform('com.google.firebase:firebase-bom:32.6.0')
+ implementation platform('com.google.firebase:firebase-bom:33.13.0')
implementation 'com.google.firebase:firebase-crashlytics'
implementation 'com.google.firebase:firebase-analytics'
// implementation 'com.google.firebase:firebase-core'
@@ -148,49 +174,52 @@ dependencies {
// Google MLKit - Text recognition v2
// To recognize Latin script
- implementation 'com.google.mlkit:text-recognition:16.0.0'
+ implementation "com.google.mlkit:text-recognition:$text_recognition_version"
// To recognize Chinese script
- implementation 'com.google.mlkit:text-recognition-chinese:16.0.0'
+ implementation "com.google.mlkit:text-recognition-chinese:$text_recognition_version"
// To recognize Devanagari script
- implementation 'com.google.mlkit:text-recognition-devanagari:16.0.0'
+ implementation "com.google.mlkit:text-recognition-devanagari:$text_recognition_version"
// To recognize Japanese script
- implementation 'com.google.mlkit:text-recognition-japanese:16.0.0'
+ implementation "com.google.mlkit:text-recognition-japanese:$text_recognition_version"
// To recognize Korean script
- implementation 'com.google.mlkit:text-recognition-korean:16.0.0'
+ implementation "com.google.mlkit:text-recognition-korean:$text_recognition_version"
// Google MLKit - Text translate
- implementation 'com.google.mlkit:translate:17.0.2'
+ implementation 'com.google.mlkit:translate:17.0.3'
// Google MLKit - Identify Languages
- implementation 'com.google.mlkit:language-id:17.0.4'
+ implementation 'com.google.mlkit:language-id:17.0.6'
// Kotpref
// core
- implementation 'com.chibatching.kotpref:kotpref:2.13.2'
+ implementation "com.chibatching.kotpref:kotpref:$kotpref_version"
// optional, auto initialization module
- implementation 'com.chibatching.kotpref:initializer:2.13.2'
+ implementation "com.chibatching.kotpref:initializer:$kotpref_version"
// optional, support saving enum value and ordinal
- implementation 'com.chibatching.kotpref:enum-support:2.13.2'
+ implementation "com.chibatching.kotpref:enum-support:$kotpref_version"
// optional, support saving json string through Gson
- implementation 'com.chibatching.kotpref:gson-support:2.13.2'
- implementation 'com.google.code.gson:gson:2.10.1'
+ implementation "com.chibatching.kotpref:gson-support:$kotpref_version"
+ implementation 'com.google.code.gson:gson:2.13.1'
// optional, support LiveData observable preference
- implementation 'com.chibatching.kotpref:livedata-support:2.13.2'
+ implementation "com.chibatching.kotpref:livedata-support:$kotpref_version"
// implementation 'androidx.lifecycle:lifecycle-livedata:2.2.0'
// // experimental, preference screen build dsl
// implementation 'com.chibatching.kotpref:preference-screen-dsl:2.13.1'
// Retrofit
- implementation 'com.squareup.retrofit2:retrofit:2.9.0'
- implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'
- implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0'
+ implementation "com.squareup.retrofit2:retrofit:$retrofit2_version"
+ implementation "com.squareup.retrofit2:converter-moshi:$retrofit2_version"
+ implementation "com.squareup.okhttp3:logging-interceptor:4.12.0"
+ // Moshi
+ ksp("com.squareup.moshi:moshi-kotlin-codegen:$moshi_version")
+ implementation "com.squareup.moshi:moshi-kotlin:$moshi_version"
// Tesseract
- implementation 'cz.adaptech.tesseract4android:tesseract4android:4.5.0'
+ implementation 'cz.adaptech.tesseract4android:tesseract4android:4.8.0'
// implementation 'cz.adaptech:tesseract4android:4.1.0'
// koral--/android-gif-drawable
- implementation 'pl.droidsonroids.gif:android-gif-drawable:1.2.28'
+ implementation 'pl.droidsonroids.gif:android-gif-drawable:1.2.29'
// // Ad network mediation - Mopub
// implementation('com.mopub:mopub-sdk:+@aar') {
@@ -210,9 +239,9 @@ dependencies {
// implementation 'com.mopub.mediation:adcolony:4.5.0.1'
// Admob
- implementation 'com.google.android.gms:play-services-ads:22.5.0'
+ implementation 'com.google.android.gms:play-services-ads:23.6.0'
// Facebook Ad network
- implementation 'com.google.ads.mediation:facebook:6.16.0.0'
+ implementation 'com.google.ads.mediation:facebook:6.19.0.0'
// Adcolony Ad network
implementation 'com.google.ads.mediation:adcolony:4.8.0.2'
// To fix build failed with issue
@@ -221,5 +250,5 @@ dependencies {
implementation 'com.facebook.infer.annotation:infer-annotation:0.18.0'
// TouchImageView
- implementation 'com.github.MikeOrtiz:TouchImageView:3.6'
+ implementation 'com.github.MikeOrtiz:TouchImageView:3.7.1'
}
diff --git a/main/src/main/AndroidManifest.xml b/main/src/main/AndroidManifest.xml
index 8282b884..256c14fb 100644
--- a/main/src/main/AndroidManifest.xml
+++ b/main/src/main/AndroidManifest.xml
@@ -3,7 +3,9 @@
xmlns:tools="http://schemas.android.com/tools"
android:installLocation="auto">
+
+
+
+
@@ -70,4 +77,4 @@
-
\ No newline at end of file
+
diff --git a/main/src/main/java/tw/firemaples/onscreenocr/CoreApplication.kt b/main/src/main/java/tw/firemaples/onscreenocr/CoreApplication.kt
index 58247809..8a06ea38 100644
--- a/main/src/main/java/tw/firemaples/onscreenocr/CoreApplication.kt
+++ b/main/src/main/java/tw/firemaples/onscreenocr/CoreApplication.kt
@@ -1,11 +1,13 @@
package tw.firemaples.onscreenocr
import android.app.Application
+import dagger.hilt.android.HiltAndroidApp
import tw.firemaples.onscreenocr.log.FirebaseEvent
import tw.firemaples.onscreenocr.log.UserInfoUtils
import tw.firemaples.onscreenocr.remoteconfig.RemoteConfigManager
import tw.firemaples.onscreenocr.utils.AdManager
+@HiltAndroidApp
class CoreApplication : Application() {
companion object {
lateinit var instance: Application
diff --git a/main/src/main/java/tw/firemaples/onscreenocr/api/ApiHub.kt b/main/src/main/java/tw/firemaples/onscreenocr/api/ApiHub.kt
index 3cd23db9..46ac5b0b 100644
--- a/main/src/main/java/tw/firemaples/onscreenocr/api/ApiHub.kt
+++ b/main/src/main/java/tw/firemaples/onscreenocr/api/ApiHub.kt
@@ -1,15 +1,27 @@
package tw.firemaples.onscreenocr.api
import android.content.Context
+import com.squareup.moshi.Moshi
+import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import retrofit2.Retrofit
+import retrofit2.converter.moshi.MoshiConverterFactory
import tw.firemaples.onscreenocr.utils.Utils
import java.io.File
object ApiHub {
private val context: Context by lazy { Utils.context }
+ private val moshi: Moshi by lazy {
+ Moshi.Builder()
+ .addLast(KotlinJsonAdapterFactory())
+ .build()
+ }
+
private val retrofit: Retrofit by lazy {
- Retrofit.Builder().baseUrl("http://localhost/").build()
+ Retrofit.Builder()
+ .baseUrl("http://localhost/")
+ .addConverterFactory(MoshiConverterFactory.create(moshi))
+ .build()
}
val tessDataTempFile: File by lazy {
diff --git a/main/src/main/java/tw/firemaples/onscreenocr/data/repo/PreferenceRepository.kt b/main/src/main/java/tw/firemaples/onscreenocr/data/repo/PreferenceRepository.kt
new file mode 100644
index 00000000..62217e6a
--- /dev/null
+++ b/main/src/main/java/tw/firemaples/onscreenocr/data/repo/PreferenceRepository.kt
@@ -0,0 +1,37 @@
+package tw.firemaples.onscreenocr.data.repo
+
+import android.graphics.Point
+import android.graphics.Rect
+import androidx.lifecycle.asFlow
+import com.chibatching.kotpref.livedata.asLiveData
+import tw.firemaples.onscreenocr.pref.AppPref
+import javax.inject.Inject
+
+class PreferenceRepository @Inject constructor() {
+ fun saveLastMainBarPosition(x: Int, y: Int) {
+ AppPref.lastMainBarPosition = Point(x, y)
+ }
+
+ fun getLastMainBarPosition(): Point =
+ AppPref.lastMainBarPosition
+
+ fun getShowTextSelectionOnResultView() =
+ AppPref.asLiveData(AppPref::displaySelectedTextOnResultWindow).asFlow()
+
+ fun setShowTextSelectionOnResultView(show: Boolean) {
+ AppPref.displaySelectedTextOnResultWindow = show
+ }
+
+ fun getResultViewFontSize() =
+ AppPref.asLiveData(AppPref::resultWindowFontSize).asFlow()
+
+ fun setResultViewFontSize(fontSize: Float) {
+ AppPref.resultWindowFontSize = fontSize
+ }
+
+ fun getLastSelectedArea() = AppPref.lastSelectionArea
+
+ fun setLastSelectedArea(rect: Rect) {
+ AppPref.lastSelectionArea = rect
+ }
+}
diff --git a/main/src/main/java/tw/firemaples/onscreenocr/data/repo/RecognitionRepository.kt b/main/src/main/java/tw/firemaples/onscreenocr/data/repo/RecognitionRepository.kt
new file mode 100644
index 00000000..dd859e7b
--- /dev/null
+++ b/main/src/main/java/tw/firemaples/onscreenocr/data/repo/RecognitionRepository.kt
@@ -0,0 +1,22 @@
+package tw.firemaples.onscreenocr.data.repo
+
+import androidx.lifecycle.asFlow
+import com.chibatching.kotpref.livedata.asLiveData
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import tw.firemaples.onscreenocr.pref.AppPref
+import tw.firemaples.onscreenocr.recognition.TextRecognitionProviderType
+import tw.firemaples.onscreenocr.utils.Constants
+import javax.inject.Inject
+
+class RecognitionRepository @Inject constructor() {
+ val ocrLanguage: Flow
+ get() = AppPref.asLiveData(AppPref::selectedOCRLang).asFlow()
+
+ val ocrProvider: Flow
+ get() = AppPref.asLiveData(AppPref::selectedOCRProviderKey).asFlow()
+ .map { key ->
+ TextRecognitionProviderType.entries.firstOrNull { it.key == key }
+ ?: Constants.DEFAULT_OCR_PROVIDER
+ }
+}
diff --git a/main/src/main/java/tw/firemaples/onscreenocr/data/repo/SettingRepository.kt b/main/src/main/java/tw/firemaples/onscreenocr/data/repo/SettingRepository.kt
new file mode 100644
index 00000000..d3c4fdb1
--- /dev/null
+++ b/main/src/main/java/tw/firemaples/onscreenocr/data/repo/SettingRepository.kt
@@ -0,0 +1,18 @@
+package tw.firemaples.onscreenocr.data.repo
+
+import tw.firemaples.onscreenocr.pages.setting.SettingManager
+import javax.inject.Inject
+
+class SettingRepository @Inject constructor() {
+ fun shouldRestoreMainBarPosition(): Boolean =
+ SettingManager.restoreMainBarPosition
+
+ fun hideOCRAreaAfterTranslated(): Boolean =
+ SettingManager.hideRecognizedResultAfterTranslated
+
+ fun limitResultViewMaxWidth(): Boolean =
+ SettingManager.limitResultViewMaxWidth
+
+ fun rememberLastSelectionArea(): Boolean =
+ SettingManager.saveLastSelectionArea
+}
diff --git a/main/src/main/java/tw/firemaples/onscreenocr/data/repo/TranslatorRepository.kt b/main/src/main/java/tw/firemaples/onscreenocr/data/repo/TranslatorRepository.kt
new file mode 100644
index 00000000..d47d623a
--- /dev/null
+++ b/main/src/main/java/tw/firemaples/onscreenocr/data/repo/TranslatorRepository.kt
@@ -0,0 +1,17 @@
+package tw.firemaples.onscreenocr.data.repo
+
+import androidx.lifecycle.asFlow
+import com.chibatching.kotpref.livedata.asLiveData
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import tw.firemaples.onscreenocr.pref.AppPref
+import tw.firemaples.onscreenocr.translator.TranslationProviderType
+import javax.inject.Inject
+
+class TranslatorRepository @Inject constructor() {
+ val currentProviderType: Flow
+ get() = AppPref.asLiveData(AppPref::selectedTranslationProvider).asFlow()
+ .map { TranslationProviderType.fromKey(it) }
+ val currentTranslationLang: Flow
+ get() = AppPref.asLiveData(AppPref::selectedTranslationLang).asFlow()
+}
diff --git a/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetCurrentOCRDisplayLangCodeUseCase.kt b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetCurrentOCRDisplayLangCodeUseCase.kt
new file mode 100644
index 00000000..32e8cadc
--- /dev/null
+++ b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetCurrentOCRDisplayLangCodeUseCase.kt
@@ -0,0 +1,16 @@
+package tw.firemaples.onscreenocr.data.usecase
+
+import kotlinx.coroutines.flow.combine
+import tw.firemaples.onscreenocr.data.repo.RecognitionRepository
+import tw.firemaples.onscreenocr.recognition.TextRecognizer
+import javax.inject.Inject
+
+class GetCurrentOCRDisplayLangCodeUseCase @Inject constructor(
+ private val recognitionRepository: RecognitionRepository,
+) {
+ operator fun invoke() =
+ recognitionRepository.ocrProvider
+ .combine(recognitionRepository.ocrLanguage) { provider, lang ->
+ TextRecognizer.getRecognizer(provider).parseToDisplayLangCode(lang)
+ }
+}
diff --git a/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetCurrentOCRLangUseCase.kt b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetCurrentOCRLangUseCase.kt
new file mode 100644
index 00000000..a35723b8
--- /dev/null
+++ b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetCurrentOCRLangUseCase.kt
@@ -0,0 +1,15 @@
+package tw.firemaples.onscreenocr.data.usecase
+
+import kotlinx.coroutines.flow.combine
+import tw.firemaples.onscreenocr.data.repo.RecognitionRepository
+import javax.inject.Inject
+
+class GetCurrentOCRLangUseCase @Inject constructor(
+ private val recognitionRepository: RecognitionRepository,
+) {
+ operator fun invoke() =
+ combine(
+ recognitionRepository.ocrProvider,
+ recognitionRepository.ocrLanguage,
+ ) { provider, lang -> provider to lang }
+}
diff --git a/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetCurrentTranslationLangUseCase.kt b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetCurrentTranslationLangUseCase.kt
new file mode 100644
index 00000000..84137687
--- /dev/null
+++ b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetCurrentTranslationLangUseCase.kt
@@ -0,0 +1,10 @@
+package tw.firemaples.onscreenocr.data.usecase
+
+import tw.firemaples.onscreenocr.data.repo.TranslatorRepository
+import javax.inject.Inject
+
+class GetCurrentTranslationLangUseCase @Inject constructor(
+ private val translatorRepository: TranslatorRepository,
+) {
+ operator fun invoke() = translatorRepository.currentTranslationLang
+}
diff --git a/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetCurrentTranslatorTypeUseCase.kt b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetCurrentTranslatorTypeUseCase.kt
new file mode 100644
index 00000000..3bfe5d09
--- /dev/null
+++ b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetCurrentTranslatorTypeUseCase.kt
@@ -0,0 +1,10 @@
+package tw.firemaples.onscreenocr.data.usecase
+
+import tw.firemaples.onscreenocr.data.repo.TranslatorRepository
+import javax.inject.Inject
+
+class GetCurrentTranslatorTypeUseCase @Inject constructor(
+ private val translatorRepository: TranslatorRepository,
+) {
+ operator fun invoke() = translatorRepository.currentProviderType
+}
diff --git a/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetHidingOCRAreaAfterTranslatedUseCase.kt b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetHidingOCRAreaAfterTranslatedUseCase.kt
new file mode 100644
index 00000000..46aca954
--- /dev/null
+++ b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetHidingOCRAreaAfterTranslatedUseCase.kt
@@ -0,0 +1,10 @@
+package tw.firemaples.onscreenocr.data.usecase
+
+import tw.firemaples.onscreenocr.data.repo.SettingRepository
+import javax.inject.Inject
+
+class GetHidingOCRAreaAfterTranslatedUseCase @Inject constructor(
+ private val settingRepository: SettingRepository,
+) {
+ operator fun invoke() = settingRepository.hideOCRAreaAfterTranslated()
+}
diff --git a/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetLastSelectedAreaUseCase.kt b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetLastSelectedAreaUseCase.kt
new file mode 100644
index 00000000..584fd596
--- /dev/null
+++ b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetLastSelectedAreaUseCase.kt
@@ -0,0 +1,10 @@
+package tw.firemaples.onscreenocr.data.usecase
+
+import tw.firemaples.onscreenocr.data.repo.PreferenceRepository
+import javax.inject.Inject
+
+class GetLastSelectedAreaUseCase @Inject constructor(
+ private val preferenceRepository: PreferenceRepository,
+) {
+ operator fun invoke() = preferenceRepository.getLastSelectedArea()
+}
diff --git a/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetLimitResultViewMaxWidthUseCase.kt b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetLimitResultViewMaxWidthUseCase.kt
new file mode 100644
index 00000000..24aa9a34
--- /dev/null
+++ b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetLimitResultViewMaxWidthUseCase.kt
@@ -0,0 +1,10 @@
+package tw.firemaples.onscreenocr.data.usecase
+
+import tw.firemaples.onscreenocr.data.repo.SettingRepository
+import javax.inject.Inject
+
+class GetLimitResultViewMaxWidthUseCase @Inject constructor(
+ private val settingRepository: SettingRepository,
+) {
+ operator fun invoke(): Boolean = settingRepository.limitResultViewMaxWidth()
+}
diff --git a/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetMainBarInitialPositionUseCase.kt b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetMainBarInitialPositionUseCase.kt
new file mode 100644
index 00000000..4ce09432
--- /dev/null
+++ b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetMainBarInitialPositionUseCase.kt
@@ -0,0 +1,16 @@
+package tw.firemaples.onscreenocr.data.usecase
+
+import android.graphics.Point
+import tw.firemaples.onscreenocr.data.repo.PreferenceRepository
+import tw.firemaples.onscreenocr.data.repo.SettingRepository
+import javax.inject.Inject
+
+class GetMainBarInitialPositionUseCase @Inject constructor(
+ private val settingRepository: SettingRepository,
+ private val preferenceRepository: PreferenceRepository,
+) {
+ operator fun invoke() =
+ if (settingRepository.shouldRestoreMainBarPosition())
+ preferenceRepository.getLastMainBarPosition()
+ else Point(0, 0)
+}
diff --git a/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetRememberLastSelectionAreaUseCase.kt b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetRememberLastSelectionAreaUseCase.kt
new file mode 100644
index 00000000..f9daea7d
--- /dev/null
+++ b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetRememberLastSelectionAreaUseCase.kt
@@ -0,0 +1,10 @@
+package tw.firemaples.onscreenocr.data.usecase
+
+import tw.firemaples.onscreenocr.data.repo.SettingRepository
+import javax.inject.Inject
+
+class GetRememberLastSelectionAreaUseCase @Inject constructor(
+ private val settingRepository: SettingRepository,
+) {
+ operator fun invoke() = settingRepository.rememberLastSelectionArea()
+}
diff --git a/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetResultViewFontSizeUseCase.kt b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetResultViewFontSizeUseCase.kt
new file mode 100644
index 00000000..783635d6
--- /dev/null
+++ b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetResultViewFontSizeUseCase.kt
@@ -0,0 +1,10 @@
+package tw.firemaples.onscreenocr.data.usecase
+
+import tw.firemaples.onscreenocr.data.repo.PreferenceRepository
+import javax.inject.Inject
+
+class GetResultViewFontSizeUseCase @Inject constructor(
+ private val preferenceRepository: PreferenceRepository,
+) {
+ operator fun invoke() = preferenceRepository.getResultViewFontSize()
+}
diff --git a/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetShowTextSelectorOnResultViewUseCase.kt b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetShowTextSelectorOnResultViewUseCase.kt
new file mode 100644
index 00000000..db215d9d
--- /dev/null
+++ b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/GetShowTextSelectorOnResultViewUseCase.kt
@@ -0,0 +1,10 @@
+package tw.firemaples.onscreenocr.data.usecase
+
+import tw.firemaples.onscreenocr.data.repo.PreferenceRepository
+import javax.inject.Inject
+
+class GetShowTextSelectorOnResultViewUseCase @Inject constructor(
+ private val preferenceRepository: PreferenceRepository,
+) {
+ operator fun invoke() = preferenceRepository.getShowTextSelectionOnResultView()
+}
diff --git a/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/SaveLastMainBarPositionUseCase.kt b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/SaveLastMainBarPositionUseCase.kt
new file mode 100644
index 00000000..c4ce9282
--- /dev/null
+++ b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/SaveLastMainBarPositionUseCase.kt
@@ -0,0 +1,11 @@
+package tw.firemaples.onscreenocr.data.usecase
+
+import tw.firemaples.onscreenocr.data.repo.PreferenceRepository
+import javax.inject.Inject
+
+class SaveLastMainBarPositionUseCase @Inject constructor(
+ private val preferenceRepository: PreferenceRepository,
+) {
+ operator fun invoke(x: Int, y: Int) =
+ preferenceRepository.saveLastMainBarPosition(x = x, y = y)
+}
diff --git a/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/SetLastSelectedAreaUseCase.kt b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/SetLastSelectedAreaUseCase.kt
new file mode 100644
index 00000000..9df40678
--- /dev/null
+++ b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/SetLastSelectedAreaUseCase.kt
@@ -0,0 +1,13 @@
+package tw.firemaples.onscreenocr.data.usecase
+
+import android.graphics.Rect
+import tw.firemaples.onscreenocr.data.repo.PreferenceRepository
+import javax.inject.Inject
+
+class SetLastSelectedAreaUseCase @Inject constructor(
+ private val preferenceRepository: PreferenceRepository,
+) {
+ operator fun invoke(selectedRect: Rect) {
+ preferenceRepository.setLastSelectedArea(selectedRect)
+ }
+}
diff --git a/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/SetShowTextSelectorOnResultViewUseCase.kt b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/SetShowTextSelectorOnResultViewUseCase.kt
new file mode 100644
index 00000000..fb6c2bb1
--- /dev/null
+++ b/main/src/main/java/tw/firemaples/onscreenocr/data/usecase/SetShowTextSelectorOnResultViewUseCase.kt
@@ -0,0 +1,12 @@
+package tw.firemaples.onscreenocr.data.usecase
+
+import tw.firemaples.onscreenocr.data.repo.PreferenceRepository
+import javax.inject.Inject
+
+class SetShowTextSelectorOnResultViewUseCase @Inject constructor(
+ private val preferenceRepository: PreferenceRepository,
+) {
+ operator fun invoke(show: Boolean) {
+ preferenceRepository.setShowTextSelectionOnResultView(show = show)
+ }
+}
diff --git a/main/src/main/java/tw/firemaples/onscreenocr/di/CoroutinesDispatchersModule.kt b/main/src/main/java/tw/firemaples/onscreenocr/di/CoroutinesDispatchersModule.kt
new file mode 100644
index 00000000..c22fccae
--- /dev/null
+++ b/main/src/main/java/tw/firemaples/onscreenocr/di/CoroutinesDispatchersModule.kt
@@ -0,0 +1,52 @@
+package tw.firemaples.onscreenocr.di
+
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import javax.inject.Singleton
+
+@InstallIn(SingletonComponent::class)
+@Module
+object CoroutinesDispatchersModule {
+ @DefaultDispatcher
+ @Provides
+ fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
+
+ @IoDispatcher
+ @Provides
+ fun providesIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
+
+ @MainDispatcher
+ @Provides
+ fun providesMainDispatcher(): CoroutineDispatcher = Dispatchers.Main
+
+ @MainImmediateDispatcher
+ @Provides
+ fun providesMainImmediateDispatcher(): CoroutineDispatcher = Dispatchers.Main.immediate
+
+ @Singleton
+ @MainImmediateCoroutineScope
+ @Provides
+ fun provideMainImmediateCoroutineScope(
+ @MainImmediateDispatcher dispatcher: CoroutineDispatcher,
+ ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
+
+ @Singleton
+ @MainCoroutineScope
+ @Provides
+ fun provideMainCoroutineScope(
+ @MainDispatcher dispatcher: CoroutineDispatcher,
+ ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
+
+ @Singleton
+ @DefaultCoroutineScope
+ @Provides
+ fun provideDefaultCoroutineScope(
+ @DefaultDispatcher dispatcher: CoroutineDispatcher,
+ ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
+}
diff --git a/main/src/main/java/tw/firemaples/onscreenocr/di/CoroutinesQualifiers.kt b/main/src/main/java/tw/firemaples/onscreenocr/di/CoroutinesQualifiers.kt
new file mode 100644
index 00000000..6fb4929b
--- /dev/null
+++ b/main/src/main/java/tw/firemaples/onscreenocr/di/CoroutinesQualifiers.kt
@@ -0,0 +1,31 @@
+package tw.firemaples.onscreenocr.di
+
+import javax.inject.Qualifier
+
+@Retention(AnnotationRetention.RUNTIME)
+@Qualifier
+annotation class DefaultDispatcher
+
+@Retention(AnnotationRetention.RUNTIME)
+@Qualifier
+annotation class IoDispatcher
+
+@Retention(AnnotationRetention.RUNTIME)
+@Qualifier
+annotation class MainDispatcher
+
+@Retention(AnnotationRetention.BINARY)
+@Qualifier
+annotation class MainImmediateDispatcher
+
+@Retention(AnnotationRetention.RUNTIME)
+@Qualifier
+annotation class MainImmediateCoroutineScope
+
+@Retention(AnnotationRetention.RUNTIME)
+@Qualifier
+annotation class MainCoroutineScope
+
+@Retention(AnnotationRetention.RUNTIME)
+@Qualifier
+annotation class DefaultCoroutineScope
diff --git a/main/src/main/java/tw/firemaples/onscreenocr/di/SingletonModule.kt b/main/src/main/java/tw/firemaples/onscreenocr/di/SingletonModule.kt
new file mode 100644
index 00000000..c23ed573
--- /dev/null
+++ b/main/src/main/java/tw/firemaples/onscreenocr/di/SingletonModule.kt
@@ -0,0 +1,21 @@
+package tw.firemaples.onscreenocr.di
+
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import tw.firemaples.onscreenocr.floatings.manager.StateNavigator
+import tw.firemaples.onscreenocr.floatings.manager.StateNavigatorImpl
+import tw.firemaples.onscreenocr.floatings.manager.StateOperator
+import tw.firemaples.onscreenocr.floatings.manager.StateOperatorImpl
+
+@Module
+@InstallIn(SingletonComponent::class)
+interface SingletonModule {
+
+ @Binds
+ fun bindStateNavigator(stateNavigatorImpl: StateNavigatorImpl): StateNavigator
+
+ @Binds
+ fun bindStateOperator(stateOperatorImpl: StateOperatorImpl): StateOperator
+}
diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/ViewHolderService.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/ViewHolderService.kt
index f749cb69..2bc2b4d9 100644
--- a/main/src/main/java/tw/firemaples/onscreenocr/floatings/ViewHolderService.kt
+++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/ViewHolderService.kt
@@ -1,6 +1,10 @@
package tw.firemaples.onscreenocr.floatings
-import android.app.*
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
@@ -9,19 +13,22 @@ import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
+import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import tw.firemaples.onscreenocr.R
-import tw.firemaples.onscreenocr.floatings.manager.FloatingStateManager
+import tw.firemaples.onscreenocr.floatings.manager.FloatingViewCoordinator
import tw.firemaples.onscreenocr.pages.launch.LaunchActivity
import tw.firemaples.onscreenocr.pages.setting.SettingManager
import tw.firemaples.onscreenocr.remoteconfig.RemoteConfigManager
import tw.firemaples.onscreenocr.screenshot.ScreenExtractor
import tw.firemaples.onscreenocr.utils.Logger
import tw.firemaples.onscreenocr.utils.SamsungSpenInsertedReceiver
+import javax.inject.Inject
+@AndroidEntryPoint
class ViewHolderService : Service() {
companion object {
private const val NOTIFICATION_CHANNEL_ID = "floating_view_notification_channel_v1"
@@ -59,10 +66,13 @@ class ViewHolderService : Service() {
private var floatingStateListenerJob: Job? = null
+ @Inject
+ lateinit var floatingViewCoordinator: FloatingViewCoordinator
+
override fun onCreate() {
super.onCreate()
floatingStateListenerJob = CoroutineScope(Dispatchers.Main).launch {
- FloatingStateManager.showingStateChangedFlow.collect { startForeground() }
+ floatingViewCoordinator.showingStateChangedFlow.collect { startForeground() }
}
if (SettingManager.exitAppWhileSPenInserted) {
SamsungSpenInsertedReceiver.start()
@@ -101,14 +111,14 @@ class ViewHolderService : Service() {
private fun showViews() {
if (ScreenExtractor.isGranted) {
- FloatingStateManager.showMainBar()
+ floatingViewCoordinator.showMainBar()
} else {
startActivity(LaunchActivity.getLaunchIntent(this))
}
}
private fun hideViews() {
- FloatingStateManager.detachAllViews()
+ floatingViewCoordinator.detachAllViews()
}
private fun exit() {
@@ -127,13 +137,13 @@ class ViewHolderService : Service() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
ONGOING_NOTIFICATION_ID,
- createNotification(!FloatingStateManager.isMainBarAttached),
+ createNotification(!floatingViewCoordinator.isMainBarAttached),
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION,
)
} else {
startForeground(
ONGOING_NOTIFICATION_ID,
- createNotification(!FloatingStateManager.isMainBarAttached),
+ createNotification(!floatingViewCoordinator.isMainBarAttached),
)
}
}
diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/base/BackButtonTrackerView.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/base/BackButtonTrackerView.kt
new file mode 100644
index 00000000..32a62315
--- /dev/null
+++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/base/BackButtonTrackerView.kt
@@ -0,0 +1,44 @@
+package tw.firemaples.onscreenocr.floatings.compose.base
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.KeyEvent
+import android.widget.FrameLayout
+
+open class BackButtonTrackerView @JvmOverloads constructor(
+ context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0,
+ var onAttachedToWindow: (() -> Unit)? = null,
+ var onDetachedFromWindow: (() -> Unit)? = null,
+ var onBackButtonPressed: (() -> Boolean)? = null,
+) : FrameLayout(context, attrs, defStyleAttr) {
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ onAttachedToWindow?.invoke()
+ }
+
+ override fun onDetachedFromWindow() {
+ super.onDetachedFromWindow()
+ onDetachedFromWindow?.invoke()
+ }
+
+ override fun dispatchKeyEvent(event: KeyEvent): Boolean {
+ if (event.keyCode == KeyEvent.KEYCODE_BACK && keyDispatcherState != null) {
+ if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
+ keyDispatcherState.startTracking(event, this)
+
+ return true
+ } else if (event.action == KeyEvent.ACTION_UP) {
+ keyDispatcherState.handleUpEvent(event)
+
+ if (event.isTracking && !event.isCanceled) {
+ if (onBackButtonPressed?.invoke() == true) {
+ return true
+ }
+ }
+ }
+ }
+
+ return super.dispatchKeyEvent(event)
+ }
+}
diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/base/ComposeFloatingView.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/base/ComposeFloatingView.kt
new file mode 100644
index 00000000..974b5c64
--- /dev/null
+++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/base/ComposeFloatingView.kt
@@ -0,0 +1,368 @@
+package tw.firemaples.onscreenocr.floatings.compose.base
+
+import android.content.Context
+import android.graphics.PixelFormat
+import android.graphics.Point
+import android.os.Build
+import android.os.Bundle
+import android.os.Looper
+import android.view.Gravity
+import android.view.OrientationEventListener
+import android.view.View
+import android.view.WindowManager
+import androidx.annotation.CallSuper
+import androidx.annotation.MainThread
+import androidx.compose.foundation.layout.Box
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.key.onKeyEvent
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.platform.ComposeView
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleRegistry
+import androidx.lifecycle.ViewModelStore
+import androidx.lifecycle.ViewModelStoreOwner
+import androidx.lifecycle.setViewTreeLifecycleOwner
+import androidx.lifecycle.setViewTreeViewModelStoreOwner
+import androidx.savedstate.SavedStateRegistry
+import androidx.savedstate.SavedStateRegistryController
+import androidx.savedstate.SavedStateRegistryOwner
+import androidx.savedstate.setViewTreeSavedStateRegistryOwner
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.cancelChildren
+import tw.firemaples.onscreenocr.theme.AppTheme
+import tw.firemaples.onscreenocr.utils.Logger
+import tw.firemaples.onscreenocr.utils.PermissionUtil
+import tw.firemaples.onscreenocr.utils.UIUtils
+import tw.firemaples.onscreenocr.wigets.HomeButtonWatcher
+import java.io.Closeable
+import kotlin.coroutines.CoroutineContext
+
+abstract class ComposeFloatingView(protected val context: Context) {
+
+ companion object {
+ private val attachedFloatingViews: MutableList = mutableListOf()
+
+ fun detachAllFloatingViews() {
+ attachedFloatingViews.toList().forEach { it.detachFromScreen() }
+ }
+ }
+
+ private val logger: Logger by lazy { Logger(this::class) }
+
+ private val windowManager: WindowManager by lazy { context.getSystemService(Context.WINDOW_SERVICE) as WindowManager }
+
+ open val initialPosition: Point = Point(0, 0)
+ open val layoutWidth: Int = WindowManager.LayoutParams.WRAP_CONTENT
+ open val layoutHeight: Int = WindowManager.LayoutParams.WRAP_CONTENT
+ open val layoutFocusable: Boolean = false
+ open val layoutCanMoveOutsideScreen: Boolean = false
+ open val fullscreenMode: Boolean = false
+ open val layoutGravity: Int = Gravity.TOP or Gravity.LEFT
+ open val enableHomeButtonWatcher: Boolean = false
+
+ protected val params: WindowManager.LayoutParams by lazy {
+ val type =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
+ WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
+ else WindowManager.LayoutParams.TYPE_PHONE
+
+ var flags = WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
+ if (!layoutFocusable)
+ flags = flags or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
+ if (layoutCanMoveOutsideScreen)
+ flags = flags or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
+ if (fullscreenMode)
+ flags = flags or WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN
+
+ WindowManager.LayoutParams(layoutWidth, layoutHeight, type, flags, PixelFormat.TRANSLUCENT)
+ .apply {
+ val initPoint = initialPosition
+ x = initPoint.x.fixXPosition()
+ y = initPoint.y.fixYPosition()
+ gravity = layoutGravity
+ }
+ }
+
+ private val homeButtonWatcher: HomeButtonWatcher by lazy {
+ HomeButtonWatcher(
+ context = context,
+ onHomeButtonPressed = {
+ logger.debug("onHomeButtonPressed()")
+ onHomeButtonPressed()
+ },
+ onHomeButtonLongPressed = {
+ logger.debug("onHomeButtonLongPressed()")
+ onHomeButtonLongPressed()
+ },
+ )
+ }
+
+ private val viewModelStore = ViewModelStore()
+ private val viewModelStoreOwner = object : ViewModelStoreOwner {
+ override val viewModelStore: ViewModelStore
+ get() = this@ComposeFloatingView.viewModelStore
+ }
+
+ @Composable
+ abstract fun RootContent()
+
+ // abstract val layoutId: Int
+// protected lateinit var rootLayout: View
+// protected val rootView: BackButtonTrackerView by lazy {
+// BackButtonTrackerView(
+// context = context,
+// onAttachedToWindow = { onAttachedToScreen() },
+// onDetachedFromWindow = { onDetachedFromScreen() },
+// onBackButtonPressed = { onBackButtonPressed() },
+// ).apply {
+// rootLayout = ComposeView(context).apply {
+// setContent {
+// RootContent()
+// }
+//
+// setViewTreeLifecycleOwner(lifecycleOwner)
+// setViewTreeSavedStateRegistryOwner(lifecycleOwner)
+//
+// setViewTreeViewModelStoreOwner(viewModelStoreOwner)
+// }
+//// rootLayout = context.getThemedLayoutInflater().inflate(layoutId, null)
+// addView(
+// rootLayout,
+// ViewGroup.LayoutParams(
+// ViewGroup.LayoutParams.MATCH_PARENT,
+// ViewGroup.LayoutParams.MATCH_PARENT
+// )
+// )
+// }
+// }
+ protected lateinit var rootView: View
+ private fun createView(): View =
+ ComposeView(context).apply {
+ setOnKeyListener { v, keyCode, event -> //TODO check or remove
+ logger.debug("setOnKeyListener, keyCode: $keyCode, event: $event")
+ false
+ }
+ setContent {
+ AppTheme {
+ Box(
+ modifier = Modifier
+ .onKeyEvent { event -> //TODO check or remove
+ logger.debug("onKeyEvent: $event")
+ false
+ }
+ .onPreviewKeyEvent { event -> //TODO check or remove
+ logger.debug("onPreviewKeyEvent: $event")
+ false
+ }
+ ) {
+ LaunchedEffect(Unit) {
+ onAttachedToScreen()
+ }
+
+ DisposableEffect(Unit) {
+ onDispose {
+ onDetachedFromScreen()
+ }
+ }
+
+ RootContent()
+ }
+ }
+ }
+
+ setViewTreeLifecycleOwner(lifecycleOwner)
+ setViewTreeSavedStateRegistryOwner(lifecycleOwner)
+
+ setViewTreeViewModelStoreOwner(viewModelStoreOwner)
+ }
+
+ private var lastScreenWidth: Int = -1
+ open val enableDeviceDirectionTracker: Boolean = false
+ private val orientationEventListener = object : OrientationEventListener(context) {
+ override fun onOrientationChanged(orientation: Int) {
+ val screenWidth = UIUtils.screenSize[0]
+ if (screenWidth != lastScreenWidth) {
+ lastScreenWidth = screenWidth
+ onDeviceDirectionChanged()
+ }
+ }
+ }
+
+ var attached: Boolean = false
+ private set
+
+ var onAttached: (() -> Unit)? = null
+ var onDetached: (() -> Unit)? = null
+
+ @MainThread
+ open fun attachToScreen() {
+ if (attached) return
+ if (!PermissionUtil.canDrawOverlays(context)) {
+ logger.warn("You should obtain the draw overlays permission first!")
+ return
+ }
+ if (Looper.myLooper() != Looper.getMainLooper()) {
+ logger.warn("attachToWindow() should be called in main thread")
+ return
+ }
+
+ rootView = createView()
+ windowManager.addView(rootView, params)
+
+ with(lifecycleOwner) {
+ handleLifecycleEvent(Lifecycle.Event.ON_START)
+ handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
+ }
+
+ attachedFloatingViews.add(this)
+
+ if (enableDeviceDirectionTracker)
+ orientationEventListener.enable()
+
+ attached = true
+ }
+
+ @MainThread
+ open fun detachFromScreen() {
+ if (!attached) return
+ if (Looper.myLooper() != Looper.getMainLooper()) {
+ logger.warn("attachToWindow() should be called in main thread")
+ return
+ }
+
+ if (enableHomeButtonWatcher) {
+ homeButtonWatcher.stopWatch()
+ }
+
+ viewScope.coroutineContext.cancelChildren()
+
+ with(lifecycleOwner) {
+ handleLifecycleEvent(Lifecycle.Event.ON_PAUSE)
+ handleLifecycleEvent(Lifecycle.Event.ON_STOP)
+ }
+
+ windowManager.removeView(rootView)
+
+ attachedFloatingViews.remove(this)
+
+ if (enableDeviceDirectionTracker)
+ orientationEventListener.disable()
+
+ attached = false
+ }
+
+ open fun release() {
+ detachFromScreen()
+ with(lifecycleOwner) {
+ handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
+ }
+ }
+
+ protected open fun onDeviceDirectionChanged() {
+ params.x = params.x.fixXPosition()
+ params.y = params.y.fixYPosition()
+ updateViewLayout()
+ }
+
+ @CallSuper
+ protected open fun onAttachedToScreen() {
+ if (enableHomeButtonWatcher) {
+ homeButtonWatcher.startWatch()
+ }
+
+ onAttached?.invoke()
+ }
+
+ @CallSuper
+ protected open fun onDetachedFromScreen() {
+ onDetached?.invoke()
+ }
+
+ fun changeViewPosition(x: Int, y: Int) {
+ params.x = x
+ params.y = y
+ updateViewLayout()
+ }
+
+ private fun updateViewLayout() {
+ try {
+ windowManager.updateViewLayout(rootView, params)
+ } catch (e: Exception) {
+// logger.warn(t = e)
+ }
+ }
+
+ open fun onBackButtonPressed(): Boolean = false
+
+ open fun onHomeButtonPressed() {
+
+ }
+
+ open fun onHomeButtonLongPressed() {
+
+ }
+
+ protected val lifecycleOwner: FloatingViewLifecycleOwner =
+ FloatingViewLifecycleOwner().apply {
+ performRestore(null)
+ handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
+ }
+
+// private val tasks = mutableListOf>()
+
+ protected val viewScope: CoroutineScope by lazy {
+ FloatingViewCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).apply {
+// tasks.add(WeakReference(this))
+ }
+ }
+
+ private class FloatingViewCoroutineScope(context: CoroutineContext) :
+ Closeable, CoroutineScope {
+ override val coroutineContext: CoroutineContext = context
+
+ override fun close() {
+ coroutineContext.cancel()
+ }
+ }
+
+ protected class FloatingViewLifecycleOwner : SavedStateRegistryOwner {
+ private val lifecycleRegistry: LifecycleRegistry = LifecycleRegistry(this)
+ private var savedStateRegistryController: SavedStateRegistryController =
+ SavedStateRegistryController.create(this)
+
+ val isInitialized: Boolean
+ get() = true
+
+ override val lifecycle: Lifecycle
+ get() = lifecycleRegistry
+
+ fun handleLifecycleEvent(event: Lifecycle.Event) {
+ lifecycleRegistry.handleLifecycleEvent(event)
+ }
+
+ override val savedStateRegistry: SavedStateRegistry
+ get() = savedStateRegistryController.savedStateRegistry
+
+ fun performRestore(savedState: Bundle?) {
+ savedStateRegistryController.performRestore(savedState)
+ }
+
+ fun performSave(outBundle: Bundle) {
+ savedStateRegistryController.performSave(outBundle)
+ }
+ }
+
+ protected fun Int.fixXPosition(): Int =
+ this.coerceAtLeast(0)
+ .coerceAtMost(UIUtils.screenSize[0] - rootView.width)
+
+ protected fun Int.fixYPosition(): Int =
+ this.coerceAtLeast(0)
+ .coerceAtMost(UIUtils.screenSize[1] - rootView.height)
+}
diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/base/ComposeMovableFloatingView.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/base/ComposeMovableFloatingView.kt
new file mode 100644
index 00000000..b93db8e2
--- /dev/null
+++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/base/ComposeMovableFloatingView.kt
@@ -0,0 +1,181 @@
+package tw.firemaples.onscreenocr.floatings.compose.base
+
+import android.animation.ValueAnimator
+import android.content.Context
+import android.view.Gravity
+import android.view.animation.OvershootInterpolator
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.pointer.PointerInputChange
+import androidx.core.animation.addListener
+import tw.firemaples.onscreenocr.utils.Logger
+import tw.firemaples.onscreenocr.utils.UIUtils
+import tw.firemaples.onscreenocr.utils.dpToPx
+
+abstract class ComposeMovableFloatingView(context: Context) : ComposeFloatingView(context) {
+ companion object {
+ private const val moveToEdgeDuration: Long = 450
+
+ private const val fromAlpha: Float = 1f
+ private const val fadeOutAnimationDuration: Long = 800
+ }
+
+ protected val logger: Logger by lazy { Logger(this::class) }
+
+ open val moveToEdgeAfterMoved: Boolean = false
+ open val moveToEdgeMarginInDP: Float = 0f
+ private val moveToEdgeMargin: Int by lazy { moveToEdgeMarginInDP.dpToPx() }
+ override val layoutCanMoveOutsideScreen: Boolean
+ get() = moveToEdgeAfterMoved
+
+ open val fadeOutAfterMoved: Boolean = false
+ open val fadeOutDelay: Long = 1000L
+ open val fadeOutDestinationAlpha: Float = 0.2f
+
+ override fun onAttachedToScreen() {
+ super.onAttachedToScreen()
+ moveToEdgeOrFadeOut()
+ }
+
+ override fun onDeviceDirectionChanged() {
+ super.onDeviceDirectionChanged()
+ moveToEdgeOrFadeOut()
+ }
+
+ val onDragStart: (Offset) -> Unit = { _ ->
+ cancelFadeOut()
+ }
+ val onDragEnd: () -> Unit = {
+ cancelFadeOut()
+ moveToEdgeOrFadeOut()
+ }
+ val onDragCancel: () -> Unit = {
+ cancelFadeOut()
+ moveToEdgeOrFadeOut()
+ }
+ val onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit = { change, dragAmount ->
+ cancelFadeOut()
+
+ val nextX = (params.x + (if (isAlignParentLeft) dragAmount.x else -dragAmount.x))
+ .toInt().fixXPosition()
+ val nextY = (params.y + (if (isAlignParentTop) dragAmount.y else -dragAmount.y))
+ .toInt().fixYPosition()
+
+ changeViewPosition(nextX, nextY)
+ }
+
+ private val isAlignParentLeft: Boolean
+ get() = Gravity.getAbsoluteGravity(layoutGravity, rootView.layoutDirection) and
+ Gravity.HORIZONTAL_GRAVITY_MASK == Gravity.LEFT
+
+ private val isAlignParentTop: Boolean
+ get() = layoutGravity and Gravity.VERTICAL_GRAVITY_MASK == Gravity.TOP
+
+ private fun moveToEdgeOrFadeOut() {
+ when {
+ moveToEdgeAfterMoved -> moveToEdge()
+ fadeOutAfterMoved -> fadeOut()
+ else -> cancelFadeOut()
+ }
+ }
+
+ //region Moving to edge
+ fun moveToEdgeIfEnabled() {
+ rootView.postDelayed({ if (moveToEdgeAfterMoved) moveToEdge() }, 100L)
+ }
+
+ private fun moveToEdge() {
+ val params = params
+
+ val edgePosition = getEdgePosition(params.x, params.y)
+
+ moveTo(params.x, params.y, edgePosition[0], edgePosition[1], true)
+ }
+
+ private fun getEdgePosition(currentX: Int, currentY: Int): IntArray {
+ val screenWidth = UIUtils.screenSize[0]
+
+ val viewWidth = rootView.width
+ val viewCenterX = currentX + viewWidth / 2
+
+ val margin = moveToEdgeMargin
+
+ val edgeX =
+ // near left
+ if (viewCenterX < screenWidth / 2) margin
+ // near right
+ else screenWidth - viewWidth - margin
+
+ return intArrayOf(edgeX, currentY)
+ }
+
+ private var moveEdgeAnimator: ValueAnimator? = null
+
+ private fun moveTo(
+ currentX: Int,
+ currentY: Int,
+ destPositionX: Int,
+ destPositionY: Int,
+ withAnimation: Boolean
+ ) {
+ val currentParams = params
+ if (!withAnimation) {
+ if (currentParams.x != destPositionX || currentParams.y != destPositionY) {
+ changeViewPosition(destPositionX, destPositionY)
+ }
+ } else {
+ moveEdgeAnimator = ValueAnimator.ofInt(0, 100).apply {
+ addUpdateListener { animation ->
+ val progress = animation.animatedValue as Int / 100f
+
+ val nextX = currentX + (destPositionX - currentX) * progress
+ val nextY = currentY + (destPositionY - currentY) * progress
+
+ changeViewPosition(nextX.toInt(), nextY.toInt())
+ }
+
+ duration = moveToEdgeDuration
+ interpolator = OvershootInterpolator(1.25f)
+ addListener(
+ onEnd = {
+ if (fadeOutAfterMoved) fadeOut()
+ },
+ )
+
+ start()
+ }
+ }
+ }
+ //endregion
+
+ //region Fade-out
+ private var fadeOutAnimator: ValueAnimator? = null
+
+ private fun fadeOut() {
+// logger.debug("fadeOut()")
+ cancelFadeOut()
+
+ fadeOutAnimator = ValueAnimator.ofFloat(fromAlpha, fadeOutDestinationAlpha).apply {
+ addUpdateListener { animation ->
+ rootView.alpha = animation.animatedValue as Float
+ }
+ duration = fadeOutAnimationDuration
+ startDelay = fadeOutDelay
+
+ start()
+ }
+ }
+
+ private fun cancelFadeOut() {
+// logger.debug("cancelFadeOut(): fadeOutAnimator: $fadeOutAnimator")
+ fadeOutAnimator?.cancel()
+
+ rootView.alpha = fromAlpha
+ }
+
+ protected fun rescheduleFadeOut() {
+// logger.debug("rescheduleFadeOut()")
+ cancelFadeOut()
+ if (fadeOutAfterMoved) fadeOut()
+ }
+ //endregion
+}
diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/base/ComposeUtils.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/base/ComposeUtils.kt
new file mode 100644
index 00000000..8de11767
--- /dev/null
+++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/base/ComposeUtils.kt
@@ -0,0 +1,119 @@
+package tw.firemaples.onscreenocr.floatings.compose.base
+
+import android.content.res.Configuration
+import android.graphics.Rect
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalLifecycleOwner
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntOffset
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.repeatOnLifecycle
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.first
+
+@Composable
+fun Flow.collectOnLifecycleResumed(state: (T) -> Unit) {
+ val lifecycleOwner = LocalLifecycleOwner.current
+ LaunchedEffect(this, lifecycleOwner) {
+ lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
+ this@collectOnLifecycleResumed.collect(state)
+ }
+ }
+}
+
+suspend fun MutableSharedFlow.awaitForSubscriber() {
+ subscriptionCount.first { it > 0 }
+}
+
+@Composable
+fun Dp.dpToPx() = with(LocalDensity.current) { this@dpToPx.toPx() }
+
+@Composable
+fun Int.pxToDp() = with(LocalDensity.current) { this@pxToDp.toDp() }
+
+fun Modifier.clickableWithoutRipple(
+ interactionSource: MutableInteractionSource,
+ onClick: () -> Unit
+) = then(
+ Modifier.clickable(
+ interactionSource = interactionSource,
+ indication = null,
+ onClick = { onClick() }
+ )
+)
+
+fun Modifier.calculateOffset(
+ anchor: Rect,
+ offset: MutableState,
+ viewPadding: Float = 0f,
+ verticalSpacing: Float = 0f,
+): Modifier = onGloballyPositioned { coordinates ->
+ val parent = coordinates.parentLayoutCoordinates?.size ?: return@onGloballyPositioned
+ val current = coordinates.size
+
+ val leftAnchor = maxOf(anchor.left, viewPadding.toInt())
+ val rightAnchor = minOf(anchor.right, parent.width - viewPadding.toInt())
+
+ val x = when {
+ leftAnchor + current.width + viewPadding < parent.width -> {
+ // Align left
+ anchor.left - viewPadding.toInt()
+ }
+
+ rightAnchor - current.width - viewPadding >= 0 -> {
+ // Align right
+ rightAnchor - current.width - viewPadding.toInt()
+ }
+
+ else -> {
+ // No horizontal alignment
+ 0
+ }
+ }
+
+ val topAnchor = anchor.bottom + verticalSpacing
+ val bottomAnchor = anchor.top - verticalSpacing
+
+ val y = when {
+ topAnchor + current.height + viewPadding < parent.height -> {
+ // Display at bottom
+ (topAnchor - viewPadding).toInt()
+ }
+
+ bottomAnchor - current.height - viewPadding >= 0 -> {
+ // Display at top
+ (bottomAnchor - current.height - viewPadding).toInt()
+ }
+
+ else -> {
+ // Display middle vertically
+ val middleAnchor = (parent.height - current.height) / 2
+ (middleAnchor - viewPadding).toInt()
+ }
+ }
+
+ offset.value = IntOffset(x, y)
+}
+
+/**
+ * A MultiPreview annotation for desplaying a @[Composable] method using light and dark themes.
+ *
+ * Note that the app theme should support dark and light modes for these previews to be different.
+ */
+@Retention(AnnotationRetention.BINARY)
+@Target(
+ AnnotationTarget.ANNOTATION_CLASS,
+ AnnotationTarget.FUNCTION
+)
+@Preview(showBackground = true)
+@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+annotation class PreviewThemes
diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/mainbar/MainBarContent.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/mainbar/MainBarContent.kt
new file mode 100644
index 00000000..75aeeb79
--- /dev/null
+++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/mainbar/MainBarContent.kt
@@ -0,0 +1,240 @@
+package tw.firemaples.onscreenocr.floatings.compose.mainbar
+
+import android.content.res.Configuration
+import android.graphics.Point
+import androidx.annotation.DrawableRes
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.detectDragGestures
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.input.pointer.PointerInputChange
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import tw.firemaples.onscreenocr.R
+import tw.firemaples.onscreenocr.theme.AppTheme
+
+@Composable
+fun MainBarContent(
+ viewModel: MainBarViewModel,
+ onDragStart: (Offset) -> Unit = { },
+ onDragEnd: () -> Unit = { },
+ onDragCancel: () -> Unit = { },
+ onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit,
+) {
+ val state by viewModel.state.collectAsState()
+
+ Box(
+ modifier = Modifier
+ .alpha(if (state.drawMainBar) 1f else 0f)
+ .background(
+ color = MaterialTheme.colorScheme.surface,
+ shape = RoundedCornerShape(8.dp),
+ ),
+ ) {
+ Row(
+ modifier = Modifier
+ .padding(4.dp)
+ ) {
+ LanguageBlock(
+ langText = state.langText,
+ translatorIcon = state.translatorIcon,
+ onClick = viewModel::onLanguageBlockClicked,
+ )
+ if (state.displaySelectButton) {
+ Spacer(modifier = Modifier.size(4.dp))
+ MainBarButton(
+ icon = R.drawable.ic_selection,
+ onClick = viewModel::onSelectClicked,
+ )
+ }
+ if (state.displayTranslateButton) {
+ Spacer(modifier = Modifier.size(4.dp))
+ MainBarButton(
+ icon = R.drawable.ic_translate,
+ onClick = viewModel::onTranslateClicked,
+ )
+ }
+ if (state.displayCloseButton) {
+ Spacer(modifier = Modifier.size(4.dp))
+ MainBarButton(
+ icon = R.drawable.ic_close,
+ onClick = viewModel::onCloseClicked,
+ )
+ }
+ Spacer(modifier = Modifier.size(4.dp))
+ MenuButton(
+ onClick = viewModel::onMenuButtonClicked,
+ onDragStart = onDragStart,
+ onDragEnd = onDragEnd,
+ onDragCancel = onDragCancel,
+ onDrag = onDrag,
+ )
+ }
+ }
+}
+
+@Composable
+private fun LanguageBlock(
+ langText: String,
+ translatorIcon: Int? = null,
+ onClick: () -> Unit,
+) {
+ Row(
+ modifier = Modifier
+ .height(32.dp)
+ .border(
+ width = 2.dp,
+ color = MaterialTheme.colorScheme.onSurface,
+ shape = RoundedCornerShape(4.dp),
+ )
+ .clickable(onClick = onClick)
+ .padding(horizontal = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = langText,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ if (translatorIcon != null) {
+ Image(
+ painter = painterResource(id = translatorIcon),
+ contentDescription = "",
+ colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface),
+ )
+ }
+ }
+}
+
+@Composable
+private fun MainBarButton(
+ @DrawableRes
+ icon: Int,
+ onClick: () -> Unit,
+) {
+ Image(
+ modifier = Modifier
+ .size(32.dp)
+ .clickable(onClick = onClick)
+ .background(color = MaterialTheme.colorScheme.primary, shape = RoundedCornerShape(4.dp))
+ .padding(4.dp),
+ painter = painterResource(id = icon),
+ contentDescription = "",
+ colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onPrimary),
+ )
+}
+
+@Composable
+private fun MenuButton(
+ onClick: () -> Unit,
+ onDragStart: (Offset) -> Unit = { },
+ onDragEnd: () -> Unit = { },
+ onDragCancel: () -> Unit = { },
+ onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit,
+) {
+ Image(
+ modifier = Modifier
+ .size(32.dp)
+ .pointerInput(Unit) {
+ detectDragGestures(
+ onDragStart = onDragStart,
+ onDragEnd = onDragEnd,
+ onDragCancel = onDragCancel,
+ onDrag = onDrag,
+ )
+ }
+ .clickable(onClick = onClick)
+ .padding(2.dp),
+ painter = painterResource(id = R.drawable.ic_menu_move),
+ colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface),
+ contentDescription = "",
+ )
+}
+
+private class MainBarStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = listOf(
+ MainBarState(
+ langText = "en>",
+ translatorIcon = R.drawable.ic_google_translate_dark_grey,
+ displaySelectButton = true,
+ displayTranslateButton = true,
+ displayCloseButton = true,
+ ),
+ MainBarState(
+ langText = "en>tw",
+ translatorIcon = null,
+ displaySelectButton = true,
+ displayTranslateButton = true,
+ displayCloseButton = true,
+ )
+ ).asSequence()
+
+}
+
+@Preview
+@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun MainBarContentPreview(
+ @PreviewParameter(MainBarStateProvider::class) state: MainBarState,
+) {
+ val viewModel = object : MainBarViewModel {
+ override val state: StateFlow
+ get() = MutableStateFlow(state)
+ override val action: SharedFlow
+ get() = MutableSharedFlow()
+
+ override fun getInitialPosition(): Point = Point()
+ override fun getFadeOutAfterMoved(): Boolean = false
+ override fun getFadeOutDelay(): Long = 0L
+ override fun getFadeOutDestinationAlpha(): Float = 0f
+ override fun onMenuItemClicked(key: String?) = Unit
+ override fun onSelectClicked() = Unit
+ override fun onTranslateClicked() = Unit
+ override fun onCloseClicked() = Unit
+ override fun onMenuButtonClicked() = Unit
+ override fun onAttachedToScreen() = Unit
+ override fun onDragEnd(x: Int, y: Int) = Unit
+ override fun onLanguageBlockClicked() = Unit
+ }
+
+ AppTheme {
+ MainBarContent(
+ viewModel = viewModel,
+ onDragStart = { offset -> },
+ onDragEnd = {},
+ onDragCancel = {},
+ onDrag = { change, dragAmount -> },
+ )
+ }
+
+}
diff --git a/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/mainbar/MainBarFloatingView.kt b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/mainbar/MainBarFloatingView.kt
new file mode 100644
index 00000000..c89db9b2
--- /dev/null
+++ b/main/src/main/java/tw/firemaples/onscreenocr/floatings/compose/mainbar/MainBarFloatingView.kt
@@ -0,0 +1,149 @@
+package tw.firemaples.onscreenocr.floatings.compose.mainbar
+
+import android.content.Context
+import android.graphics.Point
+import android.graphics.Rect
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import dagger.hilt.android.qualifiers.ApplicationContext
+import tw.firemaples.onscreenocr.R
+import tw.firemaples.onscreenocr.floatings.ViewHolderService
+import tw.firemaples.onscreenocr.floatings.compose.base.ComposeMovableFloatingView
+import tw.firemaples.onscreenocr.floatings.compose.base.collectOnLifecycleResumed
+import tw.firemaples.onscreenocr.floatings.compose.menu.MenuFloatingView
+import tw.firemaples.onscreenocr.floatings.compose.menu.MenuItem
+import tw.firemaples.onscreenocr.floatings.history.VersionHistoryView
+import tw.firemaples.onscreenocr.floatings.readme.ReadmeView
+import tw.firemaples.onscreenocr.floatings.translationSelectPanel.TranslationSelectPanel
+import tw.firemaples.onscreenocr.pages.setting.SettingActivity
+import tw.firemaples.onscreenocr.utils.Utils
+import javax.inject.Inject
+
+class MainBarFloatingView @Inject constructor(
+ @ApplicationContext context: Context,
+ private val viewModel: MainBarViewModel,
+ private val menuFloatingView: MenuFloatingView,
+) : ComposeMovableFloatingView(context) {
+
+ override val initialPosition: Point
+ get() = viewModel.getInitialPosition()
+
+ @Composable
+ override fun RootContent() {
+ viewModel.action.collectOnLifecycleResumed { action ->
+ when (action) {
+ MainBarAction.RescheduleFadeOut ->
+ rescheduleFadeOut()
+
+ MainBarAction.MoveToEdgeIfEnabled ->
+ moveToEdgeIfEnabled()
+
+ MainBarAction.OpenLanguageSelectionPanel -> {
+ rescheduleFadeOut()
+ // TODO wait to be refactored
+ TranslationSelectPanel(context).attachToScreen()
+ }
+
+ is MainBarAction.OpenBrowser ->
+ // TODO wait to be refactored
+ Utils.openBrowser(action.url)
+
+ MainBarAction.OpenReadme ->
+ // TODO wait to be refactored
+ ReadmeView(context).attachToScreen()
+
+ MainBarAction.OpenSettings ->
+ // TODO wait to be refactored
+ SettingActivity.start(context)
+
+ MainBarAction.OpenVersionHistory ->
+ // TODO wait to be refactored
+ VersionHistoryView(context).attachToScreen()
+
+ MainBarAction.HideMainBar ->
+ // TODO wait to be refactored
+ ViewHolderService.hideViews(context)
+
+ MainBarAction.ExitApp ->
+ // TODO wait to be refactored
+ ViewHolderService.exit(context)
+
+ MainBarAction.ShowMenu ->{
+ val anchor =
+ Rect(params.x, params.y, params.x + rootView.width, params.y + rootView.height)
+ menuFloatingView.getMenuViewDelegate().setAnchor(anchor)
+ menuFloatingView.attachToScreen()
+ }
+
+ MainBarAction.HideMenu ->
+ menuFloatingView.detachFromScreen()
+ }
+ }
+
+ val menuItems: List