From 04e67aa03c081827600b54b649c94a878ebff907 Mon Sep 17 00:00:00 2001 From: acclorite Date: Tue, 4 Jun 2024 21:31:32 +0300 Subject: [PATCH] =?UTF-8?q?Built-in=20Translator=20+=20ModalBottomSheet=20?= =?UTF-8?q?=E2=86=92=20CustomBottomSheet(fixes=20bugs)=20+=20Constants.NAV?= =?UTF-8?q?IGATION=5FITEMS=20for=20NavigationBars=20+=20Various=20dependen?= =?UTF-8?q?cies=20updates=20+=20ReleaseResponse=20=E2=86=92=20LatestReleas?= =?UTF-8?q?eInfo=20+=20Fixed:=20Modifier.clip=20did=20not=20apply=20correc?= =?UTF-8?q?tly=20+=20Room=20Version=20Migration(cause=20of=20translator)?= =?UTF-8?q?=20+=20Changed=20order=20of=20the=20selection=20toolbar=20items?= =?UTF-8?q?(revert):=20Translate,=20Copy,=20Dictionary=20>=20Copy,=20Trans?= =?UTF-8?q?late,=20Dictionary=20+=20Gradle=20Version=20Update=208.2=20?= =?UTF-8?q?=E2=86=92=208.4=20+=20StringWithId=20=E2=86=92=20LineWithTransl?= =?UTF-8?q?ation=20+=20Less=20transparent=20bars=20color=20in=20ReaderScre?= =?UTF-8?q?en=20+=20Small=20localization=20changes=20(v1.0.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/kotlinc.xml | 2 +- app/build.gradle.kts | 26 +- .../java/ua/acclorite/book_story/Activity.kt | 32 +- .../acclorite/book_story/data/di/AppModule.kt | 20 + .../book_story/data/local/dto/BookEntity.kt | 8 +- .../UpdatesNotificationService.kt | 4 +- .../UpdatesNotificationServiceImpl.kt | 4 +- .../data/local/room/BookDatabase.kt | 8 +- .../data/mapper/book/BookMapperImpl.kt | 14 +- .../book_story/data/parser/TextParser.kt | 3 +- .../book_story/data/parser/TextParserImpl.kt | 3 +- .../data/parser/epub/EpubFileParser.kt | 7 +- .../data/parser/epub/EpubTextParser.kt | 11 +- .../data/parser/pdf/PdfFileParser.kt | 7 +- .../data/parser/pdf/PdfTextParser.kt | 11 +- .../data/parser/txt/TxtFileParser.kt | 7 +- .../data/parser/txt/TxtTextParser.kt | 7 +- .../book_story/data/remote/GithubAPI.kt | 4 +- ...eleaseResponse.kt => LatestReleaseInfo.kt} | 2 +- .../data/repository/BookRepositoryImpl.kt | 203 ++- .../acclorite/book_story/domain/model/Book.kt | 6 + .../book_story/domain/model/Language.kt | 31 + .../{StringWithId.kt => LanguageHistory.kt} | 7 +- .../domain/model/LineWithTranslation.kt | 21 + .../book_story/domain/model/NavigationItem.kt | 12 +- .../book_story/domain/model/NullableBook.kt | 4 +- .../domain/repository/BookRepository.kt | 35 +- .../domain/use_case/CheckForUpdates.kt | 4 +- .../domain/use_case/DeleteLanguageModel.kt | 15 + .../domain/use_case/DownloadLanguageModel.kt | 21 + .../book_story/domain/use_case/GetText.kt | 9 +- .../domain/use_case/IdentifyLanguage.kt | 15 + .../book_story/domain/use_case/InsertBook.kt | 7 +- .../use_case/IsLanguageModelDownloaded.kt | 13 + .../domain/use_case/TranslateText.kt | 23 + .../domain/use_case/UpdateBookWithText.kt | 3 +- .../book_story/domain/util/Constants.kt | 34 +- .../domain/util/DataStoreConstants.kt | 6 + .../book_story/domain/util/Typealias.kt | 5 +- .../book_story/domain/util/UIText.kt | 11 +- .../components/CustomBottomSheet.kt | 71 ++ .../presentation/components/CustomCheckbox.kt | 12 +- .../components/CustomSelectionContainer.kt | 14 +- .../BottomNavigationBar.kt | 47 +- .../BottomNavigationItem.kt | 16 +- .../CustomDialogWithLazyColumn.kt | 3 +- .../CustomNavigationRail.kt | 44 +- .../CustomNavigationRailItem.kt | 16 +- .../TranslatorLanguageBottomSheet.kt | 320 +++++ .../TranslatorLanguageDownloadDialog.kt | 100 ++ .../components/TranslatorLanguageItem.kt | 243 ++++ .../components/TranslatorLanguageTopBar.kt | 107 ++ .../data/TranslatorLanguageEvent.kt | 39 + .../data/TranslatorLanguageState.kt | 21 + .../data/TranslatorLanguageViewModel.kt | 424 +++++++ .../presentation/data/ArithmeticOperations.kt | 58 + .../book_story/presentation/data/MainEvent.kt | 7 + .../book_story/presentation/data/MainState.kt | 84 +- .../presentation/data/MainViewModel.kt | 60 + .../screens/about/data/AboutState.kt | 4 +- .../about/nested/credits/CreditsScreen.kt | 4 +- .../nested/credits/components/CreditItem.kt | 7 +- .../nested/license_info/LicenseInfoScreen.kt | 5 +- .../nested/licenses/components/LicenseItem.kt | 7 +- .../components/BookInfoInfoSection.kt | 10 +- .../BookInfoChangeCoverBottomSheet.kt | 28 +- .../BookInfoDetailsBottomSheet.kt | 23 +- .../screens/book_info/data/BookInfoEvent.kt | 3 +- .../screens/book_info/data/BookInfoState.kt | 3 +- .../book_info/data/BookInfoViewModel.kt | 2 +- .../browse/components/BrowseFIleItem.kt | 19 +- .../screens/history/components/HistoryItem.kt | 14 +- .../screens/library/LibraryScreen.kt | 7 +- .../library/components/LibraryBookItem.kt | 13 +- .../library/components/LibraryTabRow.kt | 7 +- .../screens/reader/ReaderScreen.kt | 204 +-- .../reader/components/ReaderEndItem.kt | 13 +- .../components/app_bar/ReaderBottomBar.kt | 14 +- .../reader/components/app_bar/ReaderTopBar.kt | 26 +- .../ReaderDownloadLanguageDialog.kt | 116 ++ .../ReaderDownloadLanguageDialogItem.kt | 177 +++ .../ReaderSettingsBottomSheet.kt | 415 +++---- .../components/start_item/ReaderStartItem.kt | 8 +- .../components/text/ReaderTextParagraph.kt | 224 ++++ .../text/ReaderTextTrailingContent.kt | 97 ++ .../ReaderTranslatorBottomSheet.kt | 321 +++++ ...derTranslatorBottomSheetLanguageChooser.kt | 191 +++ .../screens/reader/data/ReaderEvent.kt | 52 +- .../screens/reader/data/ReaderState.kt | 16 +- .../screens/reader/data/ReaderViewModel.kt | 1102 +++++++++++++++-- .../screens/settings/data/SettingsEvent.kt | 5 + .../screens/settings/data/SettingsState.kt | 9 + .../settings/data/SettingsViewModel.kt | 17 + .../AppearanceSettingsThemeSwitcherItem.kt | 25 +- .../settings/nested/reader/ReaderSettings.kt | 155 ++- .../screens/start/components/StartSettings.kt | 14 +- app/src/main/res/drawable/empty_languages.png | Bin 0 -> 20005 bytes app/src/main/res/values-uk/strings.xml | 65 +- app/src/main/res/values/strings.xml | 58 +- build.gradle.kts | 10 +- gradle/wrapper/gradle-wrapper.properties | 3 +- 101 files changed, 5120 insertions(+), 724 deletions(-) rename app/src/main/java/ua/acclorite/book_story/data/remote/dto/{ReleaseResponse.kt => LatestReleaseInfo.kt} (91%) create mode 100644 app/src/main/java/ua/acclorite/book_story/domain/model/Language.kt rename app/src/main/java/ua/acclorite/book_story/domain/model/{StringWithId.kt => LanguageHistory.kt} (61%) create mode 100644 app/src/main/java/ua/acclorite/book_story/domain/model/LineWithTranslation.kt create mode 100644 app/src/main/java/ua/acclorite/book_story/domain/use_case/DeleteLanguageModel.kt create mode 100644 app/src/main/java/ua/acclorite/book_story/domain/use_case/DownloadLanguageModel.kt create mode 100644 app/src/main/java/ua/acclorite/book_story/domain/use_case/IdentifyLanguage.kt create mode 100644 app/src/main/java/ua/acclorite/book_story/domain/use_case/IsLanguageModelDownloaded.kt create mode 100644 app/src/main/java/ua/acclorite/book_story/domain/use_case/TranslateText.kt create mode 100644 app/src/main/java/ua/acclorite/book_story/presentation/components/CustomBottomSheet.kt create mode 100644 app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/TranslatorLanguageBottomSheet.kt create mode 100644 app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/components/TranslatorLanguageDownloadDialog.kt create mode 100644 app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/components/TranslatorLanguageItem.kt create mode 100644 app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/components/TranslatorLanguageTopBar.kt create mode 100644 app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/data/TranslatorLanguageEvent.kt create mode 100644 app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/data/TranslatorLanguageState.kt create mode 100644 app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/data/TranslatorLanguageViewModel.kt create mode 100644 app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/download_language_dialog/ReaderDownloadLanguageDialog.kt create mode 100644 app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/download_language_dialog/ReaderDownloadLanguageDialogItem.kt create mode 100644 app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/text/ReaderTextParagraph.kt create mode 100644 app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/text/ReaderTextTrailingContent.kt create mode 100644 app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/translator_bottom_sheet/ReaderTranslatorBottomSheet.kt create mode 100644 app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/translator_bottom_sheet/ReaderTranslatorBottomSheetLanguageChooser.kt create mode 100644 app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/data/SettingsState.kt create mode 100644 app/src/main/res/drawable/empty_languages.png diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index ae3f30ae..6d0ee1c2 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d3711c0e..dd1efada 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,10 +1,13 @@ plugins { id("com.android.application") id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") id("com.google.devtools.ksp") id("com.google.dagger.hilt.android") id("kotlin-parcelize") id("com.mikepenz.aboutlibraries.plugin") + id("androidx.room") + id("org.jetbrains.kotlin.plugin.serialization") } android { @@ -24,6 +27,10 @@ android { } } + room { + schemaDirectory("$projectDir/schemas") + } + buildTypes { getByName("debug") { applicationIdSuffix = ".debug" @@ -76,12 +83,12 @@ aboutLibraries { dependencies { // Default - implementation("androidx.core:core-ktx:1.13.0") - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.1") implementation("androidx.activity:activity-compose:1.9.0") // Compose BOM libraries - implementation(platform("androidx.compose:compose-bom:2024.04.01")) + implementation(platform("androidx.compose:compose-bom:2024.05.00")) implementation("androidx.compose.foundation:foundation") implementation("androidx.compose.animation:animation") implementation("androidx.compose.animation:animation-android") @@ -90,18 +97,18 @@ dependencies { implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.ui:ui-android") - implementation("androidx.compose.material3:material3:latest.release") + implementation("androidx.compose.material3:material3:1.3.0-alpha06") implementation("androidx.compose.material3:material3-window-size-class") implementation("androidx.compose.material:material-icons-extended") implementation("androidx.compose.material:material") // All dependencies - implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.1") implementation("com.google.accompanist:accompanist-swiperefresh:0.24.2-alpha") // Dagger - Hilt implementation("com.google.dagger:hilt-android:2.51.1") - debugImplementation("androidx.compose.ui:ui-tooling:1.6.6") + debugImplementation("androidx.compose.ui:ui-tooling:1.6.7") ksp("com.google.dagger:hilt-android-compiler:2.51") implementation("com.google.dagger:hilt-compiler:latest.release") ksp("androidx.hilt:hilt-compiler:latest.release") @@ -152,4 +159,11 @@ dependencies { // About open source libraries implementation("com.mikepenz:aboutlibraries-core:latest.release") implementation("com.mikepenz:aboutlibraries-compose-m3:latest.release") + + // Translator + implementation("com.google.mlkit:translate:latest.release") + implementation("com.google.mlkit:language-id:latest.release") + + // Navigation + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2") } \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/Activity.kt b/app/src/main/java/ua/acclorite/book_story/Activity.kt index 84fc182d..c0b89360 100644 --- a/app/src/main/java/ua/acclorite/book_story/Activity.kt +++ b/app/src/main/java/ua/acclorite/book_story/Activity.kt @@ -25,31 +25,23 @@ import ua.acclorite.book_story.presentation.data.MainViewModel import ua.acclorite.book_story.presentation.data.NavigationHost import ua.acclorite.book_story.presentation.data.Screen import ua.acclorite.book_story.presentation.screens.about.AboutScreenRoot -import ua.acclorite.book_story.presentation.screens.about.data.AboutViewModel import ua.acclorite.book_story.presentation.screens.about.nested.credits.CreditsScreenRoot import ua.acclorite.book_story.presentation.screens.about.nested.license_info.LicenseInfoScreenRoot -import ua.acclorite.book_story.presentation.screens.about.nested.license_info.data.LicenseInfoViewModel import ua.acclorite.book_story.presentation.screens.about.nested.licenses.LicensesScreenRoot -import ua.acclorite.book_story.presentation.screens.about.nested.licenses.data.LicensesViewModel import ua.acclorite.book_story.presentation.screens.book_info.BookInfoScreenRoot -import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoViewModel import ua.acclorite.book_story.presentation.screens.browse.BrowseScreenRoot import ua.acclorite.book_story.presentation.screens.browse.data.BrowseViewModel import ua.acclorite.book_story.presentation.screens.help.HelpScreenRoot -import ua.acclorite.book_story.presentation.screens.help.data.HelpViewModel import ua.acclorite.book_story.presentation.screens.history.HistoryScreenRoot import ua.acclorite.book_story.presentation.screens.history.data.HistoryViewModel import ua.acclorite.book_story.presentation.screens.library.LibraryScreenRoot import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel import ua.acclorite.book_story.presentation.screens.reader.ReaderScreenRoot -import ua.acclorite.book_story.presentation.screens.reader.data.ReaderViewModel import ua.acclorite.book_story.presentation.screens.settings.SettingsScreenRoot -import ua.acclorite.book_story.presentation.screens.settings.data.SettingsViewModel import ua.acclorite.book_story.presentation.screens.settings.nested.appearance.AppearanceSettingsRoot import ua.acclorite.book_story.presentation.screens.settings.nested.general.GeneralSettingsRoot import ua.acclorite.book_story.presentation.screens.settings.nested.reader.ReaderSettingsRoot import ua.acclorite.book_story.presentation.screens.start.StartScreenRoot -import ua.acclorite.book_story.presentation.screens.start.data.StartViewModel import ua.acclorite.book_story.presentation.ui.BookStoryTheme import ua.acclorite.book_story.presentation.ui.Transitions import ua.acclorite.book_story.presentation.ui.isDark @@ -66,14 +58,6 @@ class Activity : AppCompatActivity() { private val libraryViewModel: LibraryViewModel by viewModels() private val historyViewModel: HistoryViewModel by viewModels() private val browseViewModel: BrowseViewModel by viewModels() - private val bookInfoViewModel: BookInfoViewModel by viewModels() - private val readerViewModel: ReaderViewModel by viewModels() - private val helpViewModel: HelpViewModel by viewModels() - private val aboutViewModel: AboutViewModel by viewModels() - private val licenseInfoViewModel: LicenseInfoViewModel by viewModels() - private val licensesViewModel: LicensesViewModel by viewModels() - private val settingsViewModel: SettingsViewModel by viewModels() - private val startViewModel: StartViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -102,8 +86,8 @@ class Activity : AppCompatActivity() { WindowCompat.setDecorFitsSystemWindows(window, false) setContent { - val isLoaded by mainViewModel.isReady.collectAsState() - val state by mainViewModel.state.collectAsState() + val isLoaded = mainViewModel.isReady.collectAsState() + val state = mainViewModel.state.collectAsState() val density = LocalDensity.current val imeInsets = WindowInsets.ime @@ -120,15 +104,15 @@ class Activity : AppCompatActivity() { } } - if (isLoaded) { + if (isLoaded.value) { BookStoryTheme( - theme = state.theme!!, - isDark = state.darkTheme!!.isDark(), - isPureDark = state.pureDark!!.isPureDark(this), - themeContrast = state.themeContrast!! + theme = state.value.theme!!, + isDark = state.value.darkTheme!!.isDark(), + isPureDark = state.value.pureDark!!.isPureDark(this), + themeContrast = state.value.themeContrast!! ) { NavigationHost( - startScreen = if (state.showStartScreen!!) Screen.START + startScreen = if (state.value.showStartScreen!!) Screen.START else Screen.LIBRARY ) { navigation( diff --git a/app/src/main/java/ua/acclorite/book_story/data/di/AppModule.kt b/app/src/main/java/ua/acclorite/book_story/data/di/AppModule.kt index 396e888a..ed0173ba 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/di/AppModule.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/di/AppModule.kt @@ -2,6 +2,10 @@ package ua.acclorite.book_story.data.di import android.app.Application import androidx.room.Room +import com.google.mlkit.common.model.RemoteModelManager +import com.google.mlkit.nl.languageid.LanguageIdentification +import com.google.mlkit.nl.languageid.LanguageIdentificationOptions +import com.google.mlkit.nl.languageid.LanguageIdentifier import com.squareup.moshi.Moshi import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import dagger.Module @@ -41,4 +45,20 @@ object AppModule { "book_db" ).allowMainThreadQueries().build().dao } + + @Provides + @Singleton + fun provideModelManager(): RemoteModelManager { + return RemoteModelManager.getInstance() + } + + @Provides + @Singleton + fun provideLanguageIdentifier(): LanguageIdentifier { + return LanguageIdentification.getClient( + LanguageIdentificationOptions.Builder() + .setConfidenceThreshold(0.2f) + .build() + ) + } } \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/data/local/dto/BookEntity.kt b/app/src/main/java/ua/acclorite/book_story/data/local/dto/BookEntity.kt index 581eac47..cbd7cd84 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/local/dto/BookEntity.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/local/dto/BookEntity.kt @@ -1,5 +1,6 @@ package ua.acclorite.book_story.data.local.dto +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey import ua.acclorite.book_story.domain.model.Category @@ -16,5 +17,10 @@ data class BookEntity( val scrollOffset: Int, val progress: Float, val image: String? = null, - val category: Category + val category: Category, + @ColumnInfo(defaultValue = "false") val enableTranslator: Boolean, + @ColumnInfo(defaultValue = "auto") val translateFrom: String, + @ColumnInfo(defaultValue = "en") val translateTo: String, + @ColumnInfo(defaultValue = "true") val doubleClickTranslation: Boolean, + @ColumnInfo(defaultValue = "false") val translateWhenOpen: Boolean ) diff --git a/app/src/main/java/ua/acclorite/book_story/data/local/notification/UpdatesNotificationService.kt b/app/src/main/java/ua/acclorite/book_story/data/local/notification/UpdatesNotificationService.kt index 93e95033..8ff79222 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/local/notification/UpdatesNotificationService.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/local/notification/UpdatesNotificationService.kt @@ -1,10 +1,10 @@ package ua.acclorite.book_story.data.local.notification -import ua.acclorite.book_story.data.remote.dto.ReleaseResponse +import ua.acclorite.book_story.data.remote.dto.LatestReleaseInfo interface UpdatesNotificationService { - fun postNotification(releaseInfo: ReleaseResponse) + fun postNotification(releaseInfo: LatestReleaseInfo) companion object { const val CHANNEL_ID = "updates_channel" diff --git a/app/src/main/java/ua/acclorite/book_story/data/local/notification/UpdatesNotificationServiceImpl.kt b/app/src/main/java/ua/acclorite/book_story/data/local/notification/UpdatesNotificationServiceImpl.kt index 09a30dbc..985daeb6 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/local/notification/UpdatesNotificationServiceImpl.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/local/notification/UpdatesNotificationServiceImpl.kt @@ -8,7 +8,7 @@ import android.content.Context import android.content.Intent import android.net.Uri import ua.acclorite.book_story.R -import ua.acclorite.book_story.data.remote.dto.ReleaseResponse +import ua.acclorite.book_story.data.remote.dto.LatestReleaseInfo import javax.inject.Inject class UpdatesNotificationServiceImpl @Inject constructor( @@ -18,7 +18,7 @@ class UpdatesNotificationServiceImpl @Inject constructor( private val notificationManager = application.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - override fun postNotification(releaseInfo: ReleaseResponse) { + override fun postNotification(releaseInfo: LatestReleaseInfo) { val downloadLatestVersionIntent = Intent( Intent.ACTION_VIEW, Uri.parse(application.getString(R.string.download_latest_release_page)) diff --git a/app/src/main/java/ua/acclorite/book_story/data/local/room/BookDatabase.kt b/app/src/main/java/ua/acclorite/book_story/data/local/room/BookDatabase.kt index 232103de..481101c5 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/local/room/BookDatabase.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/local/room/BookDatabase.kt @@ -1,5 +1,6 @@ package ua.acclorite.book_story.data.local.room +import androidx.room.AutoMigration import androidx.room.Database import androidx.room.RoomDatabase import ua.acclorite.book_story.data.local.dto.BookEntity @@ -10,8 +11,11 @@ import ua.acclorite.book_story.data.local.dto.HistoryEntity BookEntity::class, HistoryEntity::class ], - version = 1, - exportSchema = false + version = 2, + autoMigrations = [ + AutoMigration(1, 2) + ], + exportSchema = true ) abstract class BookDatabase : RoomDatabase() { abstract val dao: BookDao diff --git a/app/src/main/java/ua/acclorite/book_story/data/mapper/book/BookMapperImpl.kt b/app/src/main/java/ua/acclorite/book_story/data/mapper/book/BookMapperImpl.kt index 2f1e2c9c..8dec9d11 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/mapper/book/BookMapperImpl.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/mapper/book/BookMapperImpl.kt @@ -20,7 +20,12 @@ class BookMapperImpl @Inject constructor() : BookMapper { textPath = book.textPath, description = book.description, image = if (book.coverImage != null) book.coverImage.toString() else null, - category = book.category + category = book.category, + enableTranslator = book.enableTranslator, + translateFrom = book.translateFrom, + translateTo = book.translateTo, + doubleClickTranslation = book.doubleClickTranslation, + translateWhenOpen = book.translateWhenOpen ) } @@ -39,7 +44,12 @@ class BookMapperImpl @Inject constructor() : BookMapper { filePath = bookEntity.filePath, lastOpened = null, category = bookEntity.category, - coverImage = if (bookEntity.image != null) Uri.parse(bookEntity.image) else null + coverImage = if (bookEntity.image != null) Uri.parse(bookEntity.image) else null, + enableTranslator = bookEntity.enableTranslator, + translateFrom = bookEntity.translateFrom, + translateTo = bookEntity.translateTo, + doubleClickTranslation = bookEntity.doubleClickTranslation, + translateWhenOpen = bookEntity.translateWhenOpen ) } } \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/data/parser/TextParser.kt b/app/src/main/java/ua/acclorite/book_story/data/parser/TextParser.kt index 870d523d..586e3dd5 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/parser/TextParser.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/parser/TextParser.kt @@ -1,9 +1,8 @@ package ua.acclorite.book_story.data.parser -import ua.acclorite.book_story.domain.model.StringWithId import ua.acclorite.book_story.domain.util.Resource import java.io.File interface TextParser { - suspend fun parse(file: File): Resource> + suspend fun parse(file: File): Resource> } \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/data/parser/TextParserImpl.kt b/app/src/main/java/ua/acclorite/book_story/data/parser/TextParserImpl.kt index 76362747..be99e317 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/parser/TextParserImpl.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/parser/TextParserImpl.kt @@ -4,7 +4,6 @@ import ua.acclorite.book_story.R import ua.acclorite.book_story.data.parser.epub.EpubTextParser import ua.acclorite.book_story.data.parser.pdf.PdfTextParser import ua.acclorite.book_story.data.parser.txt.TxtTextParser -import ua.acclorite.book_story.domain.model.StringWithId import ua.acclorite.book_story.domain.util.Resource import ua.acclorite.book_story.domain.util.UIText import java.io.File @@ -15,7 +14,7 @@ class TextParserImpl @Inject constructor( private val pdfTextParser: PdfTextParser, private val epubTextParser: EpubTextParser, ) : TextParser { - override suspend fun parse(file: File): Resource> { + override suspend fun parse(file: File): Resource> { val fileFormat = ".${file.name.substringAfterLast(".")}" if (fileFormat == ".pdf") { diff --git a/app/src/main/java/ua/acclorite/book_story/data/parser/epub/EpubFileParser.kt b/app/src/main/java/ua/acclorite/book_story/data/parser/epub/EpubFileParser.kt index 02d041f0..d86c98f3 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/parser/epub/EpubFileParser.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/parser/epub/EpubFileParser.kt @@ -55,7 +55,12 @@ class EpubFileParser @Inject constructor() : FileParser { filePath = file.path, lastOpened = null, category = Category.entries[0], - coverImage = null + coverImage = null, + enableTranslator = false, + translateFrom = "", + translateTo = "", + doubleClickTranslation = false, + translateWhenOpen = false ) to coverImage } catch (e: Exception) { return null diff --git a/app/src/main/java/ua/acclorite/book_story/data/parser/epub/EpubTextParser.kt b/app/src/main/java/ua/acclorite/book_story/data/parser/epub/EpubTextParser.kt index f5b7f32d..7f54836b 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/parser/epub/EpubTextParser.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/parser/epub/EpubTextParser.kt @@ -8,7 +8,6 @@ import org.jsoup.nodes.Document.OutputSettings import org.jsoup.safety.Safelist import ua.acclorite.book_story.R import ua.acclorite.book_story.data.parser.TextParser -import ua.acclorite.book_story.domain.model.StringWithId import ua.acclorite.book_story.domain.util.Resource import ua.acclorite.book_story.domain.util.UIText import java.io.BufferedReader @@ -21,7 +20,7 @@ import javax.inject.Inject class EpubTextParser @Inject constructor() : TextParser { - override suspend fun parse(file: File): Resource> { + override suspend fun parse(file: File): Resource> { if (!file.name.endsWith(".epub")) { return Resource.Error(UIText.StringResource(R.string.error_wrong_file_format)) } @@ -57,7 +56,7 @@ class EpubTextParser @Inject constructor() : TextParser { } } - val stringWithIds = mutableListOf() + val strings = mutableListOf() val parsedText = Jsoup.parse(unformattedText.toString()) parsedText.outputSettings(OutputSettings().prettyPrint(false)) @@ -78,15 +77,15 @@ class EpubTextParser @Inject constructor() : TextParser { .split("\n") .forEach { if (it.isNotBlank()) { - stringWithIds.add(StringWithId(it.trim())) + strings.add(it.trim()) } } - if (stringWithIds.isEmpty()) { + if (strings.isEmpty()) { return Resource.Error(UIText.StringResource(R.string.error_file_empty)) } - return Resource.Success(stringWithIds) + return Resource.Success(strings) } catch (e: Exception) { return Resource.Error( UIText.StringResource( diff --git a/app/src/main/java/ua/acclorite/book_story/data/parser/pdf/PdfFileParser.kt b/app/src/main/java/ua/acclorite/book_story/data/parser/pdf/PdfFileParser.kt index e6d797ff..b8ad38cc 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/parser/pdf/PdfFileParser.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/parser/pdf/PdfFileParser.kt @@ -43,7 +43,12 @@ class PdfFileParser @Inject constructor(private val application: Application) : filePath = file.path, lastOpened = null, category = Category.entries[0], - coverImage = null + coverImage = null, + enableTranslator = false, + translateFrom = "", + translateTo = "", + doubleClickTranslation = false, + translateWhenOpen = false ) to null } catch (e: Exception) { return null diff --git a/app/src/main/java/ua/acclorite/book_story/data/parser/pdf/PdfTextParser.kt b/app/src/main/java/ua/acclorite/book_story/data/parser/pdf/PdfTextParser.kt index 7ad281db..77a60472 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/parser/pdf/PdfTextParser.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/parser/pdf/PdfTextParser.kt @@ -6,7 +6,6 @@ import com.tom_roush.pdfbox.pdmodel.PDDocument import com.tom_roush.pdfbox.text.PDFTextStripper import ua.acclorite.book_story.R import ua.acclorite.book_story.data.parser.TextParser -import ua.acclorite.book_story.domain.model.StringWithId import ua.acclorite.book_story.domain.util.Resource import ua.acclorite.book_story.domain.util.UIText import java.io.File @@ -14,7 +13,7 @@ import javax.inject.Inject class PdfTextParser @Inject constructor(private val application: Application) : TextParser { - override suspend fun parse(file: File): Resource> { + override suspend fun parse(file: File): Resource> { if (!file.name.endsWith(".pdf")) { return Resource.Error(UIText.StringResource(R.string.error_wrong_file_format)) } @@ -23,7 +22,7 @@ class PdfTextParser @Inject constructor(private val application: Application) : PDFBoxResourceLoader.init(application) val document = PDDocument.load(file) - val stringWithIds = mutableListOf() + val strings = mutableListOf() val pdfStripper = PDFTextStripper() pdfStripper.paragraphStart = "
" @@ -103,14 +102,14 @@ class PdfTextParser @Inject constructor(private val application: Application) : } lines.forEach { line -> - stringWithIds.add(StringWithId(line.trim())) + strings.add(line.trim()) } - if (stringWithIds.isEmpty()) { + if (strings.isEmpty()) { return Resource.Error(UIText.StringResource(R.string.error_file_empty)) } - return Resource.Success(stringWithIds) + return Resource.Success(strings) } catch (e: Exception) { e.printStackTrace() return Resource.Error( diff --git a/app/src/main/java/ua/acclorite/book_story/data/parser/txt/TxtFileParser.kt b/app/src/main/java/ua/acclorite/book_story/data/parser/txt/TxtFileParser.kt index 69c97838..b71d3d3e 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/parser/txt/TxtFileParser.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/parser/txt/TxtFileParser.kt @@ -31,7 +31,12 @@ class TxtFileParser @Inject constructor() : FileParser { filePath = file.path, lastOpened = null, category = Category.entries[0], - coverImage = null + coverImage = null, + enableTranslator = false, + translateFrom = "", + translateTo = "", + doubleClickTranslation = false, + translateWhenOpen = false ) to null } catch (e: Exception) { return null diff --git a/app/src/main/java/ua/acclorite/book_story/data/parser/txt/TxtTextParser.kt b/app/src/main/java/ua/acclorite/book_story/data/parser/txt/TxtTextParser.kt index 303abad7..3f47ab8f 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/parser/txt/TxtTextParser.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/parser/txt/TxtTextParser.kt @@ -4,7 +4,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import ua.acclorite.book_story.R import ua.acclorite.book_story.data.parser.TextParser -import ua.acclorite.book_story.domain.model.StringWithId import ua.acclorite.book_story.domain.util.Resource import ua.acclorite.book_story.domain.util.UIText import java.io.BufferedReader @@ -14,19 +13,19 @@ import javax.inject.Inject class TxtTextParser @Inject constructor() : TextParser { - override suspend fun parse(file: File): Resource> { + override suspend fun parse(file: File): Resource> { if (!file.name.endsWith(".txt")) { return Resource.Error(UIText.StringResource(R.string.error_wrong_file_format)) } return try { - val formattedLines = mutableListOf() + val formattedLines = mutableListOf() withContext(Dispatchers.IO) { BufferedReader(FileReader(file)).forEachLine { line -> if (line.isNotBlank()) { formattedLines.add( - StringWithId(line) + line.trim() ) } } diff --git a/app/src/main/java/ua/acclorite/book_story/data/remote/GithubAPI.kt b/app/src/main/java/ua/acclorite/book_story/data/remote/GithubAPI.kt index fbfd1a90..a6e542d1 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/remote/GithubAPI.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/remote/GithubAPI.kt @@ -1,11 +1,11 @@ package ua.acclorite.book_story.data.remote import retrofit2.http.GET -import ua.acclorite.book_story.data.remote.dto.ReleaseResponse +import ua.acclorite.book_story.data.remote.dto.LatestReleaseInfo interface GithubAPI { @GET("repos/Acclorite/book-story/releases/latest") - suspend fun getLatestRelease(): ReleaseResponse + suspend fun getLatestRelease(): LatestReleaseInfo companion object { const val BASE_URL = "https://api.github.com/" diff --git a/app/src/main/java/ua/acclorite/book_story/data/remote/dto/ReleaseResponse.kt b/app/src/main/java/ua/acclorite/book_story/data/remote/dto/LatestReleaseInfo.kt similarity index 91% rename from app/src/main/java/ua/acclorite/book_story/data/remote/dto/ReleaseResponse.kt rename to app/src/main/java/ua/acclorite/book_story/data/remote/dto/LatestReleaseInfo.kt index 008539c8..b057526c 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/remote/dto/ReleaseResponse.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/remote/dto/LatestReleaseInfo.kt @@ -8,7 +8,7 @@ import com.squareup.moshi.JsonClass @Keep @JsonClass(generateAdapter = true) @Immutable -data class ReleaseResponse( +data class LatestReleaseInfo( @Json(name = "name") val name: String, @Json(name = "tag_name") val tagName: String ) diff --git a/app/src/main/java/ua/acclorite/book_story/data/repository/BookRepositoryImpl.kt b/app/src/main/java/ua/acclorite/book_story/data/repository/BookRepositoryImpl.kt index b222e180..0d21a0f0 100644 --- a/app/src/main/java/ua/acclorite/book_story/data/repository/BookRepositoryImpl.kt +++ b/app/src/main/java/ua/acclorite/book_story/data/repository/BookRepositoryImpl.kt @@ -6,6 +6,13 @@ import android.net.Uri import android.os.Environment import android.util.Log import androidx.datastore.preferences.core.Preferences +import com.google.mlkit.common.model.DownloadConditions +import com.google.mlkit.common.model.RemoteModelManager +import com.google.mlkit.nl.languageid.LanguageIdentifier +import com.google.mlkit.nl.translate.TranslateLanguage +import com.google.mlkit.nl.translate.TranslateRemoteModel +import com.google.mlkit.nl.translate.Translation +import com.google.mlkit.nl.translate.TranslatorOptions import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -24,28 +31,35 @@ import ua.acclorite.book_story.data.mapper.history.HistoryMapper import ua.acclorite.book_story.data.parser.FileParser import ua.acclorite.book_story.data.parser.TextParser import ua.acclorite.book_story.data.remote.GithubAPI -import ua.acclorite.book_story.data.remote.dto.ReleaseResponse +import ua.acclorite.book_story.data.remote.dto.LatestReleaseInfo import ua.acclorite.book_story.domain.model.Book import ua.acclorite.book_story.domain.model.History import ua.acclorite.book_story.domain.model.NullableBook -import ua.acclorite.book_story.domain.model.StringWithId import ua.acclorite.book_story.domain.repository.BookRepository import ua.acclorite.book_story.domain.util.Constants import ua.acclorite.book_story.domain.util.CoverImage +import ua.acclorite.book_story.domain.util.DataStoreConstants +import ua.acclorite.book_story.domain.util.LanguageCode import ua.acclorite.book_story.domain.util.Resource import ua.acclorite.book_story.domain.util.UIText import ua.acclorite.book_story.presentation.data.MainState import java.io.File import java.io.FileOutputStream +import java.util.Locale import java.util.UUID import javax.inject.Inject import javax.inject.Singleton +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine +@Suppress("DEPRECATION") @Singleton class BookRepositoryImpl @Inject constructor( private val application: Application, private val database: BookDao, private val dataStore: DataStore, + private val modelManager: RemoteModelManager, + private val languageIdentifier: LanguageIdentifier, private val githubAPI: GithubAPI, private val updatesNotificationService: UpdatesNotificationService, @@ -88,7 +102,7 @@ class BookRepositoryImpl @Inject constructor( } } - override suspend fun getBookText(textPath: String): List { + override suspend fun getBookText(textPath: String): List { val textFile = File(textPath) if (textPath.isBlank() || !textFile.exists()) { @@ -108,7 +122,7 @@ class BookRepositoryImpl @Inject constructor( override suspend fun insertBook( book: Book, coverImage: CoverImage?, - text: List + text: List ): Boolean { val filesDir = application.filesDir val coversDir = File(filesDir, "covers") @@ -135,7 +149,7 @@ class BookRepositoryImpl @Inject constructor( withContext(Dispatchers.IO) { FileOutputStream(textPath).use { stream -> text.forEach { line -> - stream.write(line.line.toByteArray()) + stream.write(line.toByteArray()) stream.write(System.lineSeparator().toByteArray()) } } @@ -169,12 +183,34 @@ class BookRepositoryImpl @Inject constructor( } } + val enableTranslator = dataStore.getNullableData(DataStoreConstants.ENABLE_TRANSLATOR) + ?: false + + val translateFrom = dataStore.getNullableData(DataStoreConstants.TRANSLATE_FROM) + ?: "auto" + + val translateTo = dataStore.getNullableData(DataStoreConstants.TRANSLATE_TO) ?: if ( + Constants.LANGUAGES.any { Locale.getDefault().language.take(2) == it.first } + ) { + Locale.getDefault().language.take(2) + } else { + "en" + } + + val doubleClickTranslation = dataStore.getNullableData( + DataStoreConstants.DOUBLE_CLICK_TRANSLATION + ) ?: true + val updatedBook = book.copy( textPath = "$booksDir/$textUri", coverImage = if (coverUri.isNotBlank()) { Uri.fromFile(File("$coversDir/$coverUri")) - } else null + } else null, + enableTranslator = enableTranslator, + translateFrom = translateFrom, + translateTo = translateTo, + doubleClickTranslation = doubleClickTranslation ) val bookToInsert = bookMapper.toBookEntity(updatedBook) @@ -197,7 +233,7 @@ class BookRepositoryImpl @Inject constructor( ) } - override suspend fun updateBookWithText(book: Book, text: List): Boolean { + override suspend fun updateBookWithText(book: Book, text: List): Boolean { // without cover image val filesDir = application.filesDir val booksDir = File(filesDir, "books") @@ -220,7 +256,7 @@ class BookRepositoryImpl @Inject constructor( withContext(Dispatchers.IO) { FileOutputStream(textPath).use { stream -> text.forEach { line -> - stream.write(line.line.toByteArray()) + stream.write(line.toByteArray()) stream.write(System.lineSeparator().toByteArray()) } } @@ -604,7 +640,7 @@ class BookRepositoryImpl @Inject constructor( database.deleteHistory(history.map { historyMapper.toHistoryEntity(it) }) } - override suspend fun checkForUpdates(postNotification: Boolean): ReleaseResponse? { + override suspend fun checkForUpdates(postNotification: Boolean): LatestReleaseInfo? { return withContext(Dispatchers.IO) { try { val result = githubAPI.getLatestRelease() @@ -625,6 +661,155 @@ class BookRepositoryImpl @Inject constructor( } } } + + override suspend fun isLanguageModelDownloaded(languageCode: String): Boolean { + val model = TranslateRemoteModel.Builder(languageCode).build() + return suspendCoroutine { continuation -> + modelManager.isModelDownloaded(model) + .addOnSuccessListener { + continuation.resume(it) + } + .addOnFailureListener { + continuation.resume(false) + } + } + } + + override suspend fun downloadLanguageModel( + languageCode: String, + onCompleted: () -> Unit, + onFailure: (Exception) -> Unit + ) { + val isLanguageDownloaded = isLanguageModelDownloaded(languageCode) + if (isLanguageDownloaded) { + onCompleted() + return + } + + return suspendCoroutine { continuation -> + val language = TranslateRemoteModel.Builder(languageCode).build() + val conditions = DownloadConditions.Builder().build() + + modelManager.download(language, conditions) + .addOnSuccessListener { + onCompleted() + continuation.resume(Unit) + } + .addOnFailureListener { + onFailure(it) + onCompleted() + continuation.resume(Unit) + } + .addOnCanceledListener { + onCompleted() + Log.i("TRANSLATOR", "Downloading Model: Canceled") + continuation.resume(Unit) + } + } + } + + override suspend fun deleteLanguageModel(languageCode: String): Boolean { + val isLanguageDownloaded = isLanguageModelDownloaded(languageCode) + if (!isLanguageDownloaded) { + return true + } + + return suspendCoroutine { continuation -> + val language = TranslateRemoteModel.Builder(languageCode).build() + + modelManager.deleteDownloadedModel(language) + .addOnSuccessListener { + continuation.resume(true) + } + .addOnFailureListener { + continuation.resume(false) + } + } + } + + override suspend fun identifyLanguage(text: String): Resource { + return suspendCoroutine { continuation -> + languageIdentifier.identifyLanguage(text) + .addOnSuccessListener { + val languageCode = it.trim().lowercase().take(2) + var identified = true + + if (languageCode == "ru") { + identified = false + } + + if (TranslateLanguage.fromLanguageTag(languageCode) == null) { + identified = false + } + + if (identified) { + continuation.resume( + Resource.Success(languageCode) + ) + return@addOnSuccessListener + } + + continuation.resume( + Resource.Error(UIText.StringResource(R.string.error_language_not_identified)) + ) + } + .addOnFailureListener { + continuation.resume( + Resource.Error( + UIText.StringResource( + R.string.error_query, + it.message ?: "" + ) + ) + ) + } + } + } + + override suspend fun translateText( + sourceLanguage: LanguageCode, + targetLanguage: LanguageCode, + text: String + ): Resource { + return suspendCoroutine { continuation -> + val translatorOptions = TranslatorOptions.Builder() + .setSourceLanguage(sourceLanguage) + .setTargetLanguage(targetLanguage) + .build() + val translator = Translation.getClient(translatorOptions) + + translator.translate(text) + .addOnSuccessListener { + translator.close() + continuation.resume( + Resource.Success( + it.trim() + ) + ) + } + .addOnFailureListener { + translator.close() + continuation.resume( + Resource.Error( + UIText.StringResource( + R.string.error_query, + it.message ?: "" + ) + ) + ) + } + .addOnCanceledListener { + translator.close() + continuation.resume( + Resource.Error( + UIText.StringResource( + R.string.error_something_went_wrong + ) + ) + ) + } + } + } } diff --git a/app/src/main/java/ua/acclorite/book_story/domain/model/Book.kt b/app/src/main/java/ua/acclorite/book_story/domain/model/Book.kt index f6d5ba17..f2762588 100644 --- a/app/src/main/java/ua/acclorite/book_story/domain/model/Book.kt +++ b/app/src/main/java/ua/acclorite/book_story/domain/model/Book.kt @@ -22,4 +22,10 @@ data class Book( val lastOpened: Long?, val category: Category, + + val enableTranslator: Boolean, + val translateFrom: String, + val translateTo: String, + val doubleClickTranslation: Boolean, + val translateWhenOpen: Boolean ) diff --git a/app/src/main/java/ua/acclorite/book_story/domain/model/Language.kt b/app/src/main/java/ua/acclorite/book_story/domain/model/Language.kt new file mode 100644 index 00000000..e8ec13a2 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/domain/model/Language.kt @@ -0,0 +1,31 @@ +package ua.acclorite.book_story.domain.model + +import androidx.compose.runtime.Immutable +import ua.acclorite.book_story.domain.util.ID +import ua.acclorite.book_story.domain.util.Selected +import ua.acclorite.book_story.domain.util.UIText + +@Immutable +data class Language( + val languageCode: String, + val displayLanguage: String, + val isDownloading: Boolean, + val isCanceled: Boolean, + val isDownloaded: Boolean, + val isSelected: Selected, + val historyOrder: Int? +) + +@Immutable +data class SelectableLanguage( + val languageCode: String, + val displayLanguage: String, + val isDownloading: Boolean, + val isCanceled: Boolean, + val isDownloaded: Boolean, + val isError: Boolean, + val errorMessage: UIText? = null, + val isSelected: Selected, + val canUnselect: Boolean, + val occurrences: List? +) diff --git a/app/src/main/java/ua/acclorite/book_story/domain/model/StringWithId.kt b/app/src/main/java/ua/acclorite/book_story/domain/model/LanguageHistory.kt similarity index 61% rename from app/src/main/java/ua/acclorite/book_story/domain/model/StringWithId.kt rename to app/src/main/java/ua/acclorite/book_story/domain/model/LanguageHistory.kt index f8e99868..70057589 100644 --- a/app/src/main/java/ua/acclorite/book_story/domain/model/StringWithId.kt +++ b/app/src/main/java/ua/acclorite/book_story/domain/model/LanguageHistory.kt @@ -3,11 +3,10 @@ package ua.acclorite.book_story.domain.model import android.os.Parcelable import androidx.compose.runtime.Immutable import kotlinx.parcelize.Parcelize -import java.util.UUID @Parcelize @Immutable -data class StringWithId( - val line: String, - val id: String = UUID.randomUUID().toString() +data class LanguageHistory( + val id: Int, + val languageCode: String ) : Parcelable diff --git a/app/src/main/java/ua/acclorite/book_story/domain/model/LineWithTranslation.kt b/app/src/main/java/ua/acclorite/book_story/domain/model/LineWithTranslation.kt new file mode 100644 index 00000000..0b636587 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/domain/model/LineWithTranslation.kt @@ -0,0 +1,21 @@ +package ua.acclorite.book_story.domain.model + +import android.os.Parcelable +import androidx.compose.runtime.Immutable +import kotlinx.parcelize.Parcelize +import ua.acclorite.book_story.domain.util.UIText + +@Parcelize +@Immutable +data class LineWithTranslation( + val originalLine: String, + + val useTranslation: Boolean = false, + val translatingFrom: String = "", + val translatingTo: String = "", + val translatedLine: String? = null, + + val isTranslationLoading: Boolean = false, + val isTranslationFailed: Boolean = false, + val errorMessage: UIText? = null +) : Parcelable diff --git a/app/src/main/java/ua/acclorite/book_story/domain/model/NavigationItem.kt b/app/src/main/java/ua/acclorite/book_story/domain/model/NavigationItem.kt index 3e62e80c..a68105f9 100644 --- a/app/src/main/java/ua/acclorite/book_story/domain/model/NavigationItem.kt +++ b/app/src/main/java/ua/acclorite/book_story/domain/model/NavigationItem.kt @@ -1,11 +1,15 @@ package ua.acclorite.book_story.domain.model +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes import androidx.compose.runtime.Immutable -import androidx.compose.ui.graphics.painter.Painter +import ua.acclorite.book_story.presentation.data.Screen @Immutable data class NavigationItem( - val title: String, - val selectedIcon: Painter, - val unselectedIcon: Painter + val screen: Screen, + @StringRes val title: Int, + @StringRes val tooltip: Int, + @DrawableRes val selectedIcon: Int, + @DrawableRes val unselectedIcon: Int ) \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/domain/model/NullableBook.kt b/app/src/main/java/ua/acclorite/book_story/domain/model/NullableBook.kt index add5bfe2..0d294faf 100644 --- a/app/src/main/java/ua/acclorite/book_story/domain/model/NullableBook.kt +++ b/app/src/main/java/ua/acclorite/book_story/domain/model/NullableBook.kt @@ -8,14 +8,14 @@ import ua.acclorite.book_story.domain.util.UIText sealed class NullableBook( val book: Book?, val coverImage: CoverImage? = null, - val text: List = emptyList(), + val text: List = emptyList(), val fileName: String?, val message: UIText? ) { class NotNull( book: Book, coverImage: CoverImage?, - text: List + text: List ) : NullableBook( book = book, text = text, diff --git a/app/src/main/java/ua/acclorite/book_story/domain/repository/BookRepository.kt b/app/src/main/java/ua/acclorite/book_story/domain/repository/BookRepository.kt index a2276cf0..80103010 100644 --- a/app/src/main/java/ua/acclorite/book_story/domain/repository/BookRepository.kt +++ b/app/src/main/java/ua/acclorite/book_story/domain/repository/BookRepository.kt @@ -3,12 +3,12 @@ package ua.acclorite.book_story.domain.repository import androidx.datastore.preferences.core.Preferences import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow -import ua.acclorite.book_story.data.remote.dto.ReleaseResponse +import ua.acclorite.book_story.data.remote.dto.LatestReleaseInfo import ua.acclorite.book_story.domain.model.Book import ua.acclorite.book_story.domain.model.History import ua.acclorite.book_story.domain.model.NullableBook -import ua.acclorite.book_story.domain.model.StringWithId import ua.acclorite.book_story.domain.util.CoverImage +import ua.acclorite.book_story.domain.util.LanguageCode import ua.acclorite.book_story.domain.util.Resource import ua.acclorite.book_story.presentation.data.MainState import java.io.File @@ -25,12 +25,12 @@ interface BookRepository { suspend fun getBookText( textPath: String - ): List + ): List suspend fun insertBook( book: Book, coverImage: CoverImage?, - text: List + text: List ): Boolean suspend fun updateBooks( @@ -39,7 +39,7 @@ interface BookRepository { suspend fun updateBookWithText( book: Book, - text: List + text: List ): Boolean suspend fun updateCoverImageOfBook( @@ -51,6 +51,7 @@ interface BookRepository { books: List ) + suspend fun retrieveDataFromDataStore( key: Preferences.Key, defaultValue: T @@ -61,12 +62,14 @@ interface BookRepository { value: T ) + suspend fun getAllSettings(scope: CoroutineScope): MainState suspend fun getFilesFromDevice(query: String = ""): Flow>> suspend fun getBooksFromFiles(files: List): List + suspend fun insertHistory(history: List) suspend fun getHistory(): Flow>> @@ -81,5 +84,25 @@ interface BookRepository { history: List ) - suspend fun checkForUpdates(postNotification: Boolean): ReleaseResponse? + + suspend fun checkForUpdates(postNotification: Boolean): LatestReleaseInfo? + + + suspend fun isLanguageModelDownloaded(languageCode: String): Boolean + + suspend fun downloadLanguageModel( + languageCode: String, + onCompleted: () -> Unit, + onFailure: (Exception) -> Unit + ) + + suspend fun deleteLanguageModel(languageCode: String): Boolean + + suspend fun identifyLanguage(text: String): Resource + + suspend fun translateText( + sourceLanguage: LanguageCode, + targetLanguage: LanguageCode, + text: String + ): Resource } \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/domain/use_case/CheckForUpdates.kt b/app/src/main/java/ua/acclorite/book_story/domain/use_case/CheckForUpdates.kt index e8177898..486dab23 100644 --- a/app/src/main/java/ua/acclorite/book_story/domain/use_case/CheckForUpdates.kt +++ b/app/src/main/java/ua/acclorite/book_story/domain/use_case/CheckForUpdates.kt @@ -1,12 +1,12 @@ package ua.acclorite.book_story.domain.use_case -import ua.acclorite.book_story.data.remote.dto.ReleaseResponse +import ua.acclorite.book_story.data.remote.dto.LatestReleaseInfo import ua.acclorite.book_story.domain.repository.BookRepository import javax.inject.Inject class CheckForUpdates @Inject constructor(private val repository: BookRepository) { - suspend fun execute(postNotification: Boolean): ReleaseResponse? { + suspend fun execute(postNotification: Boolean): LatestReleaseInfo? { return repository.checkForUpdates(postNotification) } } \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/domain/use_case/DeleteLanguageModel.kt b/app/src/main/java/ua/acclorite/book_story/domain/use_case/DeleteLanguageModel.kt new file mode 100644 index 00000000..c1fa11be --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/domain/use_case/DeleteLanguageModel.kt @@ -0,0 +1,15 @@ +package ua.acclorite.book_story.domain.use_case + +import ua.acclorite.book_story.domain.repository.BookRepository +import javax.inject.Inject + +class DeleteLanguageModel @Inject constructor( + private val repository: BookRepository +) { + + suspend fun execute(languageCode: String): Boolean { + return repository.deleteLanguageModel( + languageCode = languageCode + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/domain/use_case/DownloadLanguageModel.kt b/app/src/main/java/ua/acclorite/book_story/domain/use_case/DownloadLanguageModel.kt new file mode 100644 index 00000000..b9101483 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/domain/use_case/DownloadLanguageModel.kt @@ -0,0 +1,21 @@ +package ua.acclorite.book_story.domain.use_case + +import ua.acclorite.book_story.domain.repository.BookRepository +import javax.inject.Inject + +class DownloadLanguageModel @Inject constructor( + private val repository: BookRepository +) { + + suspend fun execute( + languageCode: String, + onCompleted: () -> Unit, + onFailure: (Exception) -> Unit + ) { + repository.downloadLanguageModel( + languageCode = languageCode, + onCompleted = onCompleted, + onFailure = onFailure + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/domain/use_case/GetText.kt b/app/src/main/java/ua/acclorite/book_story/domain/use_case/GetText.kt index db3d6d3e..03c88ceb 100644 --- a/app/src/main/java/ua/acclorite/book_story/domain/use_case/GetText.kt +++ b/app/src/main/java/ua/acclorite/book_story/domain/use_case/GetText.kt @@ -1,12 +1,15 @@ package ua.acclorite.book_story.domain.use_case -import ua.acclorite.book_story.domain.model.StringWithId +import ua.acclorite.book_story.domain.model.LineWithTranslation import ua.acclorite.book_story.domain.repository.BookRepository +import ua.acclorite.book_story.domain.util.ID import javax.inject.Inject class GetText @Inject constructor(private val repository: BookRepository) { - suspend fun execute(textPath: String): List { - return repository.getBookText(textPath = textPath) + suspend fun execute(textPath: String): Map { + return repository.getBookText(textPath = textPath).withIndex().associate { + it.index to LineWithTranslation(originalLine = it.value) + } } } \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/domain/use_case/IdentifyLanguage.kt b/app/src/main/java/ua/acclorite/book_story/domain/use_case/IdentifyLanguage.kt new file mode 100644 index 00000000..8050d985 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/domain/use_case/IdentifyLanguage.kt @@ -0,0 +1,15 @@ +package ua.acclorite.book_story.domain.use_case + +import ua.acclorite.book_story.domain.repository.BookRepository +import ua.acclorite.book_story.domain.util.LanguageCode +import ua.acclorite.book_story.domain.util.Resource +import javax.inject.Inject + +class IdentifyLanguage @Inject constructor( + private val repository: BookRepository +) { + + suspend fun execute(text: String): Resource { + return repository.identifyLanguage(text) + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/domain/use_case/InsertBook.kt b/app/src/main/java/ua/acclorite/book_story/domain/use_case/InsertBook.kt index 87db836d..679443e4 100644 --- a/app/src/main/java/ua/acclorite/book_story/domain/use_case/InsertBook.kt +++ b/app/src/main/java/ua/acclorite/book_story/domain/use_case/InsertBook.kt @@ -1,13 +1,16 @@ package ua.acclorite.book_story.domain.use_case import ua.acclorite.book_story.domain.model.Book -import ua.acclorite.book_story.domain.model.StringWithId import ua.acclorite.book_story.domain.repository.BookRepository import ua.acclorite.book_story.domain.util.CoverImage import javax.inject.Inject class InsertBook @Inject constructor(private val repository: BookRepository) { - suspend fun execute(book: Book, coverImage: CoverImage?, text: List): Boolean { + suspend fun execute( + book: Book, + coverImage: CoverImage?, + text: List + ): Boolean { return repository.insertBook(book, coverImage, text) } } \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/domain/use_case/IsLanguageModelDownloaded.kt b/app/src/main/java/ua/acclorite/book_story/domain/use_case/IsLanguageModelDownloaded.kt new file mode 100644 index 00000000..1b99e7f5 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/domain/use_case/IsLanguageModelDownloaded.kt @@ -0,0 +1,13 @@ +package ua.acclorite.book_story.domain.use_case + +import ua.acclorite.book_story.domain.repository.BookRepository +import javax.inject.Inject + +class IsLanguageModelDownloaded @Inject constructor( + private val repository: BookRepository +) { + + suspend fun execute(languageCode: String): Boolean { + return repository.isLanguageModelDownloaded(languageCode) + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/domain/use_case/TranslateText.kt b/app/src/main/java/ua/acclorite/book_story/domain/use_case/TranslateText.kt new file mode 100644 index 00000000..d4dcdb26 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/domain/use_case/TranslateText.kt @@ -0,0 +1,23 @@ +package ua.acclorite.book_story.domain.use_case + +import ua.acclorite.book_story.domain.repository.BookRepository +import ua.acclorite.book_story.domain.util.LanguageCode +import ua.acclorite.book_story.domain.util.Resource +import javax.inject.Inject + +class TranslateText @Inject constructor( + private val repository: BookRepository +) { + + suspend fun execute( + sourceLanguage: LanguageCode, + targetLanguage: LanguageCode, + text: String + ): Resource { + return repository.translateText( + sourceLanguage, + targetLanguage, + text + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/domain/use_case/UpdateBookWithText.kt b/app/src/main/java/ua/acclorite/book_story/domain/use_case/UpdateBookWithText.kt index e44ac5a8..5640f275 100644 --- a/app/src/main/java/ua/acclorite/book_story/domain/use_case/UpdateBookWithText.kt +++ b/app/src/main/java/ua/acclorite/book_story/domain/use_case/UpdateBookWithText.kt @@ -1,13 +1,12 @@ package ua.acclorite.book_story.domain.use_case import ua.acclorite.book_story.domain.model.Book -import ua.acclorite.book_story.domain.model.StringWithId import ua.acclorite.book_story.domain.repository.BookRepository import javax.inject.Inject class UpdateBookWithText @Inject constructor(private val repository: BookRepository) { - suspend fun execute(book: Book, text: List): Boolean { + suspend fun execute(book: Book, text: List): Boolean { return repository.updateBookWithText(book, text) } } \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/domain/util/Constants.kt b/app/src/main/java/ua/acclorite/book_story/domain/util/Constants.kt index 6d9f0763..d717ba21 100644 --- a/app/src/main/java/ua/acclorite/book_story/domain/util/Constants.kt +++ b/app/src/main/java/ua/acclorite/book_story/domain/util/Constants.kt @@ -9,6 +9,8 @@ import ua.acclorite.book_story.domain.model.Book import ua.acclorite.book_story.domain.model.Category import ua.acclorite.book_story.domain.model.Credit import ua.acclorite.book_story.domain.model.FontWithName +import ua.acclorite.book_story.domain.model.NavigationItem +import ua.acclorite.book_story.presentation.data.Screen import ua.acclorite.book_story.presentation.ui.Theme object Constants { @@ -25,6 +27,31 @@ object Constants { Pair("uk", "Українська"), ) + // Navigation items for NavigationBars. + val NAVIGATION_ITEMS = listOf( + NavigationItem( + screen = Screen.LIBRARY, + title = R.string.library_screen, + tooltip = R.string.library_content_desc, + selectedIcon = R.drawable.library_screen_filled, + unselectedIcon = R.drawable.library_screen_outlined + ), + NavigationItem( + screen = Screen.HISTORY, + title = R.string.history_screen, + tooltip = R.string.history_content_desc, + selectedIcon = R.drawable.history_screen_filled, + unselectedIcon = R.drawable.history_screen_outlined + ), + NavigationItem( + screen = Screen.BROWSE, + title = R.string.browse_screen, + tooltip = R.string.browse_content_desc, + selectedIcon = R.drawable.browse_screen_filled, + unselectedIcon = R.drawable.browse_screen_outlined + ) + ) + // Supported themes val THEMES = listOf( Pair(Theme.DYNAMIC, UIText.StringResource(R.string.dynamic_theme)), @@ -255,6 +282,11 @@ object Constants { scrollOffset = 0, progress = 0f, lastOpened = null, - category = Category.READING + category = Category.READING, + enableTranslator = false, + translateFrom = "", + translateTo = "", + doubleClickTranslation = false, + translateWhenOpen = false ) } \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/domain/util/DataStoreConstants.kt b/app/src/main/java/ua/acclorite/book_story/domain/util/DataStoreConstants.kt index 458424e4..630b1b17 100644 --- a/app/src/main/java/ua/acclorite/book_story/domain/util/DataStoreConstants.kt +++ b/app/src/main/java/ua/acclorite/book_story/domain/util/DataStoreConstants.kt @@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey object DataStoreConstants { + // App settings val LANGUAGE = stringPreferencesKey("language") val THEME = stringPreferencesKey("theme") val DARK_THEME = stringPreferencesKey("dark_theme") @@ -22,4 +23,9 @@ object DataStoreConstants { val PARAGRAPH_INDENTATION = booleanPreferencesKey("paragraph_indentation") val CHECK_FOR_UPDATES = booleanPreferencesKey("check_for_updates") val SIDE_PADDING = intPreferencesKey("side_padding") + val ENABLE_TRANSLATOR = booleanPreferencesKey("enable_translator") + val TRANSLATE_FROM = stringPreferencesKey("translate_from") + val TRANSLATE_TO = stringPreferencesKey("translate_to") + val DOUBLE_CLICK_TRANSLATION = booleanPreferencesKey("double_click_translation") + val TRANSLATOR_LANGUAGE_HISTORY = stringPreferencesKey("translator_languages_history") } \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/domain/util/Typealias.kt b/app/src/main/java/ua/acclorite/book_story/domain/util/Typealias.kt index 7855cb8a..cb84a09a 100644 --- a/app/src/main/java/ua/acclorite/book_story/domain/util/Typealias.kt +++ b/app/src/main/java/ua/acclorite/book_story/domain/util/Typealias.kt @@ -3,4 +3,7 @@ package ua.acclorite.book_story.domain.util import android.graphics.Bitmap typealias CoverImage = Bitmap -typealias Selected = Boolean \ No newline at end of file +typealias Selected = Boolean +typealias ID = Int +typealias LanguageCode = String +typealias Route = String \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/domain/util/UIText.kt b/app/src/main/java/ua/acclorite/book_story/domain/util/UIText.kt index a95beeea..0c396d5f 100644 --- a/app/src/main/java/ua/acclorite/book_story/domain/util/UIText.kt +++ b/app/src/main/java/ua/acclorite/book_story/domain/util/UIText.kt @@ -1,14 +1,19 @@ package ua.acclorite.book_story.domain.util import android.content.Context +import android.os.Parcelable import androidx.annotation.StringRes import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.ui.res.stringResource +import kotlinx.parcelize.Parcelize +import java.io.Serializable - -sealed class UIText(val string: String?) { +@Parcelize +@Immutable +sealed class UIText(val string: String?) : Parcelable { data class StringValue(val value: String) : UIText(value) - class StringResource(@StringRes val resId: Int, vararg val args: Any) : UIText(null) + class StringResource(@StringRes val resId: Int, vararg val args: Serializable) : UIText(null) @Composable fun asString(): String { diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/components/CustomBottomSheet.kt b/app/src/main/java/ua/acclorite/book_story/presentation/components/CustomBottomSheet.kt new file mode 100644 index 00000000..9c17e0ef --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/components/CustomBottomSheet.kt @@ -0,0 +1,71 @@ +package ua.acclorite.book_story.presentation.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp + +/** + * Custom Bottom Sheet. Fixes bugs with [ModalBottomSheet]. + * + * @param modifier [Modifier]. + * @param hasFixedHeight Whether the bottom sheet has fixed height passed through [modifier] or not. + * @param scrimColor Scrim color. + * @param shape Shape. + * @param containerColor Container color. + * @param onDismissRequest OnDismiss callback. + * @param dragHandle Drag Handle, pass null to disable. + * @param content Content inside [ModalBottomSheet]. Provides you navigation bar padding. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CustomBottomSheet( + modifier: Modifier = Modifier, + hasFixedHeight: Boolean = false, + scrimColor: Color = BottomSheetDefaults.ScrimColor, + shape: Shape = BottomSheetDefaults.ExpandedShape, + containerColor: Color = MaterialTheme.colorScheme.surfaceContainerLow, + onDismissRequest: () -> Unit, + dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, + content: @Composable ColumnScope.(navigationBarPadding: Dp) -> Unit +) { + val navigationBarPadding = + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + + Box(Modifier.fillMaxSize()) { + ModalBottomSheet( + scrimColor = scrimColor, + dragHandle = dragHandle, + modifier = Modifier + .then( + if (hasFixedHeight) Modifier.align(Alignment.BottomCenter) + else Modifier + ) + .fillMaxWidth() + .then(modifier), + onDismissRequest = { + onDismissRequest() + }, + shape = shape, + sheetState = rememberModalBottomSheetState(true), + contentWindowInsets = { WindowInsets(0, 0, 0, 0) }, + containerColor = containerColor + ) { + content(navigationBarPadding) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/components/CustomCheckbox.kt b/app/src/main/java/ua/acclorite/book_story/presentation/components/CustomCheckbox.kt index 5390f7d8..48bfa527 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/components/CustomCheckbox.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/components/CustomCheckbox.kt @@ -11,7 +11,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp @@ -20,14 +19,21 @@ import ua.acclorite.book_story.R /** * Custom Checkbox. Has a Circle shape. + * + * @param selected Whether checked or not. + * @param containerColor Container color. + * @param size Size of the checkbox icon(not the whole checkbox). */ @Composable -fun CustomCheckbox(selected: Boolean, containerColor: Color, size: Dp = 22.dp) { +fun CustomCheckbox( + selected: Boolean, + containerColor: Color, + size: Dp = 22.dp +) { Icon( imageVector = Icons.Default.Check, tint = if (selected) containerColor else Color.Transparent, modifier = Modifier - .clip(CircleShape) .border( width = if (!selected) 1.dp else 0.dp, if (!selected) MaterialTheme.colorScheme.outline else Color.Transparent, diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/components/CustomSelectionContainer.kt b/app/src/main/java/ua/acclorite/book_story/presentation/components/CustomSelectionContainer.kt index 00ec3070..8e96079c 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/components/CustomSelectionContainer.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/components/CustomSelectionContainer.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") + package ua.acclorite.book_story.presentation.components import android.content.ClipData @@ -7,6 +9,7 @@ import android.view.ActionMode import android.view.Menu import android.view.MenuItem import android.view.View +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -15,6 +18,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Rect import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalTextToolbar @@ -42,13 +46,13 @@ private class CustomTextActionModeCallback( requireNotNull(menu) requireNotNull(mode) - onTranslateRequested?.let { - menu.add(0, MENU_ITEM_TRANSLATE, 0, context.getString(R.string.translate)) + onCopyRequested?.let { + menu.add(0, MENU_ITEM_COPY, 0, context.getString(R.string.copy)) .setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER) } - onCopyRequested?.let { - menu.add(0, MENU_ITEM_COPY, 1, context.getString(R.string.copy)) + onTranslateRequested?.let { + menu.add(0, MENU_ITEM_TRANSLATE, 1, context.getString(R.string.translate)) .setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER) } @@ -233,7 +237,7 @@ fun CustomSelectionContainer( CompositionLocalProvider( LocalTextToolbar provides customSelectionToolbar ) { - SelectionContainer { + SelectionContainer(Modifier.fillMaxSize()) { content(isToolbarHidden.value) } } diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/components/bottom_navigation_bar/BottomNavigationBar.kt b/app/src/main/java/ua/acclorite/book_story/presentation/components/bottom_navigation_bar/BottomNavigationBar.kt index a7c44d8c..45d02e0f 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/components/bottom_navigation_bar/BottomNavigationBar.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/components/bottom_navigation_bar/BottomNavigationBar.kt @@ -7,10 +7,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import ua.acclorite.book_story.R -import ua.acclorite.book_story.domain.model.NavigationItem +import ua.acclorite.book_story.domain.util.Constants import ua.acclorite.book_story.presentation.data.LocalNavigator import ua.acclorite.book_story.presentation.data.Screen @@ -31,41 +28,13 @@ fun BottomNavigationBar() { } NavigationBar { - - BottomNavigationBarItem( - item = NavigationItem( - stringResource(id = R.string.library_screen), - selectedIcon = painterResource(id = R.drawable.library_screen_filled), - unselectedIcon = painterResource(id = R.drawable.library_screen_outlined) - ), - tooltipText = stringResource(id = R.string.library_content_desc), - isSelected = currentScreen == Screen.LIBRARY - ) { - navigator.navigate(Screen.LIBRARY, false) - } - - BottomNavigationBarItem( - item = NavigationItem( - stringResource(id = R.string.history_screen), - selectedIcon = painterResource(id = R.drawable.history_screen_filled), - unselectedIcon = painterResource(id = R.drawable.history_screen_outlined) - ), - tooltipText = stringResource(id = R.string.history_content_desc), - isSelected = currentScreen == Screen.HISTORY - ) { - navigator.navigate(Screen.HISTORY, false) - } - - BottomNavigationBarItem( - item = NavigationItem( - stringResource(id = R.string.browse_screen), - selectedIcon = painterResource(id = R.drawable.browse_screen_filled), - unselectedIcon = painterResource(id = R.drawable.browse_screen_outlined) - ), - tooltipText = stringResource(id = R.string.browse_content_desc), - isSelected = currentScreen == Screen.BROWSE - ) { - navigator.navigate(Screen.BROWSE, false) + Constants.NAVIGATION_ITEMS.forEach { + BottomNavigationBarItem( + item = it, + isSelected = currentScreen == it.screen + ) { + navigator.navigate(it.screen, false) + } } } } diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/components/bottom_navigation_bar/BottomNavigationItem.kt b/app/src/main/java/ua/acclorite/book_story/presentation/components/bottom_navigation_bar/BottomNavigationItem.kt index f00b92c6..decbebb4 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/components/bottom_navigation_bar/BottomNavigationItem.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/components/bottom_navigation_bar/BottomNavigationItem.kt @@ -8,6 +8,8 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import ua.acclorite.book_story.domain.model.NavigationItem @@ -15,12 +17,16 @@ import ua.acclorite.book_story.presentation.components.CustomTooltip /** * Bottom Navigation Bar Item, uses default [NavigationBarItem]. + * + * @param modifier Modifier. + * @param item [NavigationItem]. + * @param isSelected Whether this screen is currently selected. + * @param onClick OnClick callback. */ @Composable fun RowScope.BottomNavigationBarItem( modifier: Modifier = Modifier, item: NavigationItem, - tooltipText: String, isSelected: Boolean, onClick: () -> Unit ) { @@ -33,11 +39,11 @@ fun RowScope.BottomNavigationBarItem( NavigationBarItem( label = { CustomTooltip( - text = tooltipText, + text = stringResource(id = item.tooltip), padding = 64.dp ) { Text( - text = item.title, + text = stringResource(id = item.title), maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -47,11 +53,11 @@ fun RowScope.BottomNavigationBarItem( onClick = { onClick() }, icon = { CustomTooltip( - text = tooltipText, + text = stringResource(id = item.tooltip), padding = 32.dp ) { Icon( - painter = icon, + painter = painterResource(id = icon), contentDescription = null, modifier = Modifier.size(24.dp) ) diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/components/custom_dialog/CustomDialogWithLazyColumn.kt b/app/src/main/java/ua/acclorite/book_story/presentation/components/custom_dialog/CustomDialogWithLazyColumn.kt index 5ec0abec..521d1229 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/components/custom_dialog/CustomDialogWithLazyColumn.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/components/custom_dialog/CustomDialogWithLazyColumn.kt @@ -2,7 +2,6 @@ package ua.acclorite.book_story.presentation.components.custom_dialog import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -57,7 +56,7 @@ import ua.acclorite.book_story.R * @param withDivider Should divider be shown or not. * @param items The items of this dialog, use items(..) { .. } or item { .. }. */ -@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable fun CustomDialogWithLazyColumn( modifier: Modifier = Modifier, diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/components/custom_navigation_rail/CustomNavigationRail.kt b/app/src/main/java/ua/acclorite/book_story/presentation/components/custom_navigation_rail/CustomNavigationRail.kt index d6123fb7..81eb2e0f 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/components/custom_navigation_rail/CustomNavigationRail.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/components/custom_navigation_rail/CustomNavigationRail.kt @@ -22,11 +22,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import ua.acclorite.book_story.R -import ua.acclorite.book_story.domain.model.NavigationItem +import ua.acclorite.book_story.domain.util.Constants import ua.acclorite.book_story.presentation.data.LocalNavigator import ua.acclorite.book_story.presentation.data.Screen @@ -69,38 +66,13 @@ fun BoxScope.CustomNavigationRail() { verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally ) { - CustomNavigationRailItem( - item = NavigationItem( - stringResource(id = R.string.library_screen), - selectedIcon = painterResource(id = R.drawable.library_screen_filled), - unselectedIcon = painterResource(id = R.drawable.library_screen_outlined) - ), - tooltipText = stringResource(id = R.string.library_content_desc), - isSelected = currentScreen == Screen.LIBRARY - ) { - navigator.navigate(Screen.LIBRARY, false) - } - CustomNavigationRailItem( - item = NavigationItem( - stringResource(id = R.string.history_screen), - selectedIcon = painterResource(id = R.drawable.history_screen_filled), - unselectedIcon = painterResource(id = R.drawable.history_screen_outlined) - ), - tooltipText = stringResource(id = R.string.history_content_desc), - isSelected = currentScreen == Screen.HISTORY - ) { - navigator.navigate(Screen.HISTORY, false) - } - CustomNavigationRailItem( - item = NavigationItem( - stringResource(id = R.string.browse_screen), - selectedIcon = painterResource(id = R.drawable.browse_screen_filled), - unselectedIcon = painterResource(id = R.drawable.browse_screen_outlined) - ), - tooltipText = stringResource(id = R.string.browse_content_desc), - isSelected = currentScreen == Screen.BROWSE - ) { - navigator.navigate(Screen.BROWSE, false) + Constants.NAVIGATION_ITEMS.forEach { + CustomNavigationRailItem( + item = it, + isSelected = currentScreen == it.screen + ) { + navigator.navigate(it.screen, false) + } } } } diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/components/custom_navigation_rail/CustomNavigationRailItem.kt b/app/src/main/java/ua/acclorite/book_story/presentation/components/custom_navigation_rail/CustomNavigationRailItem.kt index 8d2bae4e..c8afd049 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/components/custom_navigation_rail/CustomNavigationRailItem.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/components/custom_navigation_rail/CustomNavigationRailItem.kt @@ -7,6 +7,8 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import ua.acclorite.book_story.domain.model.NavigationItem @@ -14,12 +16,16 @@ import ua.acclorite.book_story.presentation.components.CustomTooltip /** * Custom Navigation Rail item. + * + * @param modifier Modifier. + * @param item [NavigationItem]. + * @param isSelected Whether this screen is currently selected. + * @param onClick OnClick callback. */ @Composable fun CustomNavigationRailItem( modifier: Modifier = Modifier, item: NavigationItem, - tooltipText: String, isSelected: Boolean, onClick: () -> Unit ) { @@ -31,11 +37,11 @@ fun CustomNavigationRailItem( NavigationRailItem( label = { CustomTooltip( - text = tooltipText, + text = stringResource(id = item.tooltip), padding = 48.dp ) { Text( - text = item.title, + text = stringResource(id = item.title), maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -45,11 +51,11 @@ fun CustomNavigationRailItem( onClick = { onClick() }, icon = { CustomTooltip( - text = tooltipText, + text = stringResource(id = item.tooltip), padding = 16.dp ) { Icon( - painter = icon, + painter = painterResource(id = icon), contentDescription = null, modifier = Modifier.size(24.dp) ) diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/TranslatorLanguageBottomSheet.kt b/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/TranslatorLanguageBottomSheet.kt new file mode 100644 index 00000000..c5a6c6f0 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/TranslatorLanguageBottomSheet.kt @@ -0,0 +1,320 @@ +package ua.acclorite.book_story.presentation.components.translator_language + +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import ua.acclorite.book_story.R +import ua.acclorite.book_story.domain.model.LanguageHistory +import ua.acclorite.book_story.presentation.components.CategoryTitle +import ua.acclorite.book_story.presentation.components.CustomAnimatedVisibility +import ua.acclorite.book_story.presentation.components.CustomBottomSheet +import ua.acclorite.book_story.presentation.components.customItems +import ua.acclorite.book_story.presentation.components.is_messages.IsEmpty +import ua.acclorite.book_story.presentation.components.translator_language.components.TranslatorLanguageDownloadDialog +import ua.acclorite.book_story.presentation.components.translator_language.components.TranslatorLanguageItem +import ua.acclorite.book_story.presentation.components.translator_language.components.TranslatorLanguageItemWithIcon +import ua.acclorite.book_story.presentation.components.translator_language.components.TranslatorLanguageTopBar +import ua.acclorite.book_story.presentation.components.translator_language.data.TranslatorLanguageEvent +import ua.acclorite.book_story.presentation.components.translator_language.data.TranslatorLanguageState +import ua.acclorite.book_story.presentation.components.translator_language.data.TranslatorLanguageViewModel +import ua.acclorite.book_story.presentation.data.MainEvent +import ua.acclorite.book_story.presentation.data.MainViewModel +import ua.acclorite.book_story.presentation.ui.DefaultTransition +import ua.acclorite.book_story.presentation.ui.Transitions + +/** + * Translator language bottom sheet. + * Manages downloading, deleting and selecting language for translator. + * + * @param selectedLanguage Current selected language. + * @param unselectedLanguage Current unselected language(second). + * @param translateFromSelecting Whether user selects "Translate from" or "Translate to" language. + * @param onDismiss Dismissing BottomSheet. + * @param onSelect Gives two variables, first - Translate from, second - Translate to. If user selects "Translate from" and then selects current unselected language(in this case "Translate to"), then it also changes "Translate to" to previous "Translate from" value. + */ +@Composable +fun TranslatorLanguageBottomSheet( + selectedLanguage: String, + unselectedLanguage: String, + translateFromSelecting: Boolean, + onDismiss: () -> Unit, + onSelect: (String, String) -> Unit +) { + val translatorLanguageViewModel: TranslatorLanguageViewModel = hiltViewModel() + val mainViewModel: MainViewModel = hiltViewModel() + + val state = translatorLanguageViewModel.state.collectAsState() + val mainState = mainViewModel.state.collectAsState() + + val loading = remember { mutableStateOf(true) } + + LaunchedEffect(Unit) { + translatorLanguageViewModel.init( + selectedLanguage = selectedLanguage, + unselectedLanguage = unselectedLanguage, + translateFromSelecting = translateFromSelecting, + history = mainState.value.translatorLanguageHistory!!, + loaded = { + loading.value = false + } + ) + } + + TranslatorLanguageBottomSheetContent( + state = state, + loading = loading, + onSelect = { from, to -> + val fromSelecting = state.value.translateFromSelecting + val languageToChoose = if (fromSelecting) from else to + + if (languageToChoose != "auto") { + val currentHistory = mainState.value.translatorLanguageHistory!!.toMutableList() + val langToAdd = state.value.languageToSelect + val newHistory = mutableListOf() + val indexOfItem = currentHistory.indexOfFirst { it.languageCode == langToAdd } + + if (indexOfItem != -1) { + currentHistory.removeAt(indexOfItem) + } + + currentHistory.add(0, LanguageHistory(0, langToAdd)) + + currentHistory.sortedBy { it.id }.forEachIndexed { index, languageHistory -> + newHistory.add(LanguageHistory(index, languageHistory.languageCode)) + } + + mainViewModel.onEvent( + MainEvent.OnChangeTranslatorLanguageHistory( + newHistory + ) + ) + } + onSelect(from, to) + }, + onDismiss = onDismiss, + onEvent = translatorLanguageViewModel::onEvent + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TranslatorLanguageBottomSheetContent( + state: State, + loading: State, + onSelect: (String, String) -> Unit, + onDismiss: () -> Unit, + onEvent: (TranslatorLanguageEvent) -> Unit +) { + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + + val languagesWithHistory = remember(state.value.languages) { + state.value.languages.filter { + it.historyOrder != null + }.sortedBy { + it.historyOrder + } + } + val languages = remember(state.value.languages) { + state.value.languages.filter { + it.historyOrder == null + }.sortedBy { + it.languageCode + } + } + + CustomBottomSheet( + hasFixedHeight = true, + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(), + dragHandle = {}, + shape = RoundedCornerShape(0), + scrimColor = Color.Transparent, + onDismissRequest = { + onDismiss() + }, + containerColor = MaterialTheme.colorScheme.surface + ) { + Scaffold( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + containerColor = Color.Transparent, + topBar = { + TranslatorLanguageTopBar( + state = state, + scrollBehavior = scrollBehavior, + onDismissBottomSheet = onDismiss, + onEvent = onEvent + ) + } + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + DefaultTransition( + visible = !loading.value, + modifier = Modifier.fillMaxSize() + ) { + if (state.value.showDownloadLanguageDialog) { + TranslatorLanguageDownloadDialog( + state = state, + onSelect = onSelect, + onEvent = onEvent + ) + } + + LazyColumn(Modifier.fillMaxSize()) { + item { + Spacer( + modifier = Modifier + .animateItem() + .height(12.dp) + ) + } + + if ( + state.value.translateFromSelecting && + (!state.value.showSearch || state.value.searchQuery.isEmpty()) + ) { + item { + val isSelected = remember(state.value.selectedLanguage) { + state.value.selectedLanguage == "auto" + } + + TranslatorLanguageItemWithIcon( + isSelected = isSelected, + displayLanguage = stringResource(id = R.string.translator_auto), + customIcon = Icons.Default.AutoAwesome, + onSelect = { + onSelect("auto", state.value.unselectedLanguage) + } + ) + + Spacer( + modifier = Modifier + .animateItem() + .height(36.dp) + ) + } + } + + if (languagesWithHistory.isNotEmpty()) { + item { + CategoryTitle( + title = stringResource(id = R.string.translator_recent_languages), + color = MaterialTheme.colorScheme.primary, + padding = 28.dp, + modifier = Modifier.animateItem() + ) + Spacer( + modifier = Modifier + .animateItem() + .height(14.dp) + ) + } + + + customItems( + languagesWithHistory, + key = { it.languageCode } + ) { language -> + TranslatorLanguageItem( + language = language, + onSelect = onSelect, + onEvent = onEvent + ) + } + } + + if (languages.isNotEmpty()) { + item { + if (languagesWithHistory.isNotEmpty()) { + Spacer( + modifier = Modifier + .animateItem() + .height(36.dp) + ) + } + + CategoryTitle( + title = stringResource(id = R.string.translator_all_languages), + color = MaterialTheme.colorScheme.primary, + padding = 28.dp, + modifier = Modifier.animateItem() + ) + Spacer( + modifier = Modifier + .animateItem() + .height(14.dp) + ) + } + + customItems( + languages, + key = { it.languageCode } + ) { language -> + TranslatorLanguageItem( + language = language, + onSelect = onSelect, + onEvent = onEvent + ) + } + } + + item { + Spacer( + modifier = Modifier + .animateItem() + .height(12.dp) + ) + } + } + + CustomAnimatedVisibility( + visible = state.value.languages.isEmpty(), + modifier = Modifier.align(Alignment.Center), + enter = Transitions.DefaultTransitionIn, + exit = fadeOut(tween(0)) + ) { + IsEmpty( + message = stringResource(id = R.string.translator_languages_empty), + icon = painterResource(id = R.drawable.empty_languages) + ) + } + } + } + } + } +} diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/components/TranslatorLanguageDownloadDialog.kt b/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/components/TranslatorLanguageDownloadDialog.kt new file mode 100644 index 00000000..2a49a48b --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/components/TranslatorLanguageDownloadDialog.kt @@ -0,0 +1,100 @@ +package ua.acclorite.book_story.presentation.components.translator_language.components + +import android.widget.Toast +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Download +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import ua.acclorite.book_story.R +import ua.acclorite.book_story.presentation.components.custom_dialog.CustomDialogWithContent +import ua.acclorite.book_story.presentation.components.translator_language.data.TranslatorLanguageEvent +import ua.acclorite.book_story.presentation.components.translator_language.data.TranslatorLanguageState +import java.util.Locale + +/** + * Translator Language Download Dialog. + * Used for downloading languages and selecting them right after. + * + * @param state [TranslatorLanguageState]. + * @param onSelect OnSelect callback. Happens when language is downloaded. + * @param onEvent [TranslatorLanguageEvent] callback. + */ +@Composable +fun TranslatorLanguageDownloadDialog( + state: State, + onSelect: (String, String) -> Unit, + onEvent: (TranslatorLanguageEvent) -> Unit +) { + val context = LocalContext.current + val displayLanguage = remember(state.value.languageToSelect) { + val locale = Locale(state.value.languageToSelect) + locale.getDisplayLanguage(locale).replaceFirstChar { + it.uppercase() + } + } + + CustomDialogWithContent( + title = stringResource(id = R.string.download_language, displayLanguage), + imageVectorIcon = Icons.Default.Download, + description = stringResource( + id = R.string.download_language_description + ), + actionText = stringResource(id = R.string.download), + isActionEnabled = true, + disableOnClick = true, + onDismiss = { onEvent(TranslatorLanguageEvent.OnDismissDownloadDialog) }, + onAction = { + val selectedLanguage = state.value.languageToSelect + + onEvent(TranslatorLanguageEvent.OnDismissDownloadDialog) + onEvent( + TranslatorLanguageEvent.OnDownloadLanguage( + languageCode = state.value.languageToSelect, + onComplete = { + if (selectedLanguage == state.value.languageToSelect) { + withContext(Dispatchers.Main) { + Toast.makeText( + context, + context.getString(R.string.language_downloaded), + Toast.LENGTH_LONG + ).show() + } + + onEvent( + TranslatorLanguageEvent.OnSelectLanguage( + languageCode = state.value.languageToSelect, + onSelect = onSelect, + onError = { + Toast + .makeText( + context, + it.asString(context), + Toast.LENGTH_LONG + ) + .show() + } + ) + ) + } + }, + onFailure = { + Toast.makeText( + context, + context.getString( + R.string.error_query, + it.message + ), + Toast.LENGTH_LONG + ).show() + } + ) + ) + }, + withDivider = false + ) +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/components/TranslatorLanguageItem.kt b/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/components/TranslatorLanguageItem.kt new file mode 100644 index 00000000..5f1afe22 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/components/TranslatorLanguageItem.kt @@ -0,0 +1,243 @@ +package ua.acclorite.book_story.presentation.components.translator_language.components + +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Download +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import ua.acclorite.book_story.R +import ua.acclorite.book_story.domain.model.Language +import ua.acclorite.book_story.presentation.components.translator_language.data.TranslatorLanguageEvent +import ua.acclorite.book_story.presentation.ui.DefaultTransition + +/** + * Translator Language Item with Icon. + * + * @param isSelected Whether item is selected. + * @param displayLanguage Display language to be shown. + * @param customIcon Icon. + * @param onSelect OnSelect(click) callback. + */ +@Composable +fun LazyItemScope.TranslatorLanguageItemWithIcon( + isSelected: Boolean, + displayLanguage: String, + customIcon: ImageVector, + onSelect: () -> Unit, +) { + Row( + modifier = Modifier + .animateItem() + .padding(horizontal = 12.dp) + .clip(RoundedCornerShape(100)) + .background( + if (isSelected) MaterialTheme.colorScheme.primaryContainer + else Color.Transparent, + RoundedCornerShape(100) + ) + .clickable { + onSelect() + } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = stringResource(R.string.selected_content_desc), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(16.dp)) + } + + Text( + displayLanguage, + color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer + else MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f) + ) + + Spacer(modifier = Modifier.width(16.dp)) + Icon( + imageVector = customIcon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp) + ) + } +} + +/** + * Translator Language Item with Icon. + * + * @param language [Language] item. + * @param onSelect OnSelect(click) callback. + * @param onEvent [TranslatorLanguageEvent] callback. + */ +@Composable +fun LazyItemScope.TranslatorLanguageItem( + language: Language, + onSelect: (String, String) -> Unit, + onEvent: (TranslatorLanguageEvent) -> Unit +) { + val context = LocalContext.current + + Row( + modifier = Modifier + .animateItem() + .padding(horizontal = 12.dp) + .clip(RoundedCornerShape(100)) + .background( + if (language.isSelected) MaterialTheme.colorScheme.primaryContainer + else Color.Transparent, + RoundedCornerShape(100) + ) + .clickable(enabled = !language.isDownloading) { + onEvent( + TranslatorLanguageEvent.OnSelectLanguage( + languageCode = language.languageCode, + onSelect = onSelect, + onError = { + Toast + .makeText( + context, + it.asString(context), + Toast.LENGTH_LONG + ) + .show() + } + ) + ) + } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (language.isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = stringResource(R.string.selected_content_desc), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(16.dp)) + } + + Text( + language.displayLanguage, + color = if (language.isSelected) MaterialTheme.colorScheme.onPrimaryContainer + else MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f) + ) + + Box(contentAlignment = Alignment.CenterEnd) { + DefaultTransition(!language.isDownloaded && !language.isDownloading) { + Row { + Spacer(modifier = Modifier.width(16.dp)) + Icon( + imageVector = Icons.Default.Download, + contentDescription = stringResource(id = R.string.download), + tint = if (language.isSelected) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.primary, + modifier = Modifier + .size(24.dp) + .clickable( + interactionSource = null, + indication = null + ) { + onEvent( + TranslatorLanguageEvent.OnDownloadLanguage( + languageCode = language.languageCode, + onComplete = {}, + onFailure = { + Toast + .makeText( + context, + context.getString( + R.string.error_query, + it.message + ), + Toast.LENGTH_LONG + ) + .show() + } + ) + ) + } + ) + } + } + + DefaultTransition(language.isDownloading && !language.isDownloaded) { + Row { + Spacer(modifier = Modifier.width(16.dp)) + CircularProgressIndicator( + strokeWidth = 2.7.dp, + modifier = Modifier + .size(22.dp) + .clickable( + interactionSource = null, + indication = null + ) { + onEvent( + TranslatorLanguageEvent.OnCancelDownload( + languageCode = language.languageCode + ) + ) + }, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(1.dp)) + } + } + + DefaultTransition(language.isDownloaded && !language.isDownloading) { + Row { + Spacer(modifier = Modifier.width(16.dp)) + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = stringResource(id = R.string.done), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .size(24.dp) + .clickable( + interactionSource = null, + indication = null + ) { + onEvent( + TranslatorLanguageEvent.OnDeleteLanguage( + languageCode = language.languageCode + ) + ) + } + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/components/TranslatorLanguageTopBar.kt b/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/components/TranslatorLanguageTopBar.kt new file mode 100644 index 00000000..3274cbec --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/components/TranslatorLanguageTopBar.kt @@ -0,0 +1,107 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package ua.acclorite.book_story.presentation.components.translator_language.components + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.res.stringResource +import ua.acclorite.book_story.R +import ua.acclorite.book_story.presentation.components.AnimatedTopAppBar +import ua.acclorite.book_story.presentation.components.CustomIconButton +import ua.acclorite.book_story.presentation.components.CustomSearchTextField +import ua.acclorite.book_story.presentation.components.translator_language.data.TranslatorLanguageEvent +import ua.acclorite.book_story.presentation.components.translator_language.data.TranslatorLanguageState + +/** + * Translator Language TopBar. + * + * @param state [TranslatorLanguageState]. + * @param scrollBehavior [TopAppBarScrollBehavior]. + * @param onDismissBottomSheet OnDismiss callback. + * @param onEvent [TranslatorLanguageEvent] callback. + */ +@Composable +fun TranslatorLanguageTopBar( + state: State, + scrollBehavior: TopAppBarScrollBehavior, + onDismissBottomSheet: () -> Unit, + onEvent: (TranslatorLanguageEvent) -> Unit +) { + val focusRequester = remember { FocusRequester() } + + AnimatedTopAppBar( + scrollBehavior = scrollBehavior, + isTopBarScrolled = null, + + content1Visibility = !state.value.showSearch, + content1NavigationIcon = { + CustomIconButton( + icon = Icons.Default.Close, + contentDescription = R.string.go_back_content_desc, + disableOnClick = true + ) { + onDismissBottomSheet() + } + }, + content1Title = { + Text( + text = stringResource( + id = R.string.translator_query, stringResource( + if (state.value.translateFromSelecting) R.string.translator_from + else R.string.translator_to + ) + ) + ) + }, + content1Actions = { + CustomIconButton( + icon = Icons.Default.Search, + contentDescription = R.string.search_content_desc, + disableOnClick = true + ) { + onEvent(TranslatorLanguageEvent.OnSearchShowHide) + } + }, + + content2Visibility = state.value.showSearch, + content2NavigationIcon = { + CustomIconButton( + icon = Icons.AutoMirrored.Default.ArrowBack, + contentDescription = R.string.exit_search_content_desc, + disableOnClick = true + ) { + onEvent(TranslatorLanguageEvent.OnSearchShowHide) + } + }, + content2Title = { + CustomSearchTextField( + modifier = Modifier + .focusRequester(focusRequester) + .onGloballyPositioned { + onEvent(TranslatorLanguageEvent.OnRequestFocus(focusRequester)) + }, + query = state.value.searchQuery, + onQueryChange = { + onEvent(TranslatorLanguageEvent.OnSearchQueryChange(it)) + }, + onSearch = { onEvent(TranslatorLanguageEvent.OnSearch) }, + placeholder = stringResource( + id = R.string.search_query, + stringResource(id = R.string.languages) + ) + ) + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/data/TranslatorLanguageEvent.kt b/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/data/TranslatorLanguageEvent.kt new file mode 100644 index 00000000..dc851296 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/data/TranslatorLanguageEvent.kt @@ -0,0 +1,39 @@ +package ua.acclorite.book_story.presentation.components.translator_language.data + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.focus.FocusRequester +import ua.acclorite.book_story.domain.model.Language +import ua.acclorite.book_story.domain.util.UIText + +@Immutable +sealed class TranslatorLanguageEvent { + data class OnSelectLanguage( + val languageCode: String, + val onError: suspend (UIText) -> Unit, + val onSelect: (String, String) -> Unit + ) : TranslatorLanguageEvent() + + data class OnDownloadLanguage( + val languageCode: String, + val onComplete: suspend () -> Unit, + val onFailure: (Exception) -> Unit + ) : TranslatorLanguageEvent() + + data class OnCancelDownload( + val languageCode: String + ) : TranslatorLanguageEvent() + + data class OnDeleteLanguage( + val languageCode: String + ) : TranslatorLanguageEvent() + + data object OnSearchShowHide : TranslatorLanguageEvent() + data class OnRequestFocus(val focusRequester: FocusRequester) : TranslatorLanguageEvent() + data class OnSearchQueryChange(val query: String) : TranslatorLanguageEvent() + data object OnSearch : TranslatorLanguageEvent() + data object OnDismissDownloadDialog : TranslatorLanguageEvent() + data class OnUpdateLanguage( + val languageCode: String, + val block: (Language) -> Language + ) : TranslatorLanguageEvent() +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/data/TranslatorLanguageState.kt b/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/data/TranslatorLanguageState.kt new file mode 100644 index 00000000..0ffc0005 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/data/TranslatorLanguageState.kt @@ -0,0 +1,21 @@ +package ua.acclorite.book_story.presentation.components.translator_language.data + +import androidx.compose.runtime.Immutable +import ua.acclorite.book_story.domain.model.Language + +@Immutable +data class TranslatorLanguageState( + val selectedLanguage: String = "", + val unselectedLanguage: String = "", + val languageToSelect: String = "", + val translateFromSelecting: Boolean = false, + val showDownloadLanguageDialog: Boolean = false, + + val unfilteredLanguages: List = emptyList(), + val languages: List = emptyList(), + + val showSearch: Boolean = false, + val searchQuery: String = "", + val hasFocused: Boolean = false, + + ) diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/data/TranslatorLanguageViewModel.kt b/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/data/TranslatorLanguageViewModel.kt new file mode 100644 index 00000000..c3c07c51 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/components/translator_language/data/TranslatorLanguageViewModel.kt @@ -0,0 +1,424 @@ +package ua.acclorite.book_story.presentation.components.translator_language.data + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.mlkit.nl.translate.TranslateLanguage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.yield +import ua.acclorite.book_story.R +import ua.acclorite.book_story.domain.model.Language +import ua.acclorite.book_story.domain.model.LanguageHistory +import ua.acclorite.book_story.domain.use_case.DeleteLanguageModel +import ua.acclorite.book_story.domain.use_case.DownloadLanguageModel +import ua.acclorite.book_story.domain.use_case.IsLanguageModelDownloaded +import ua.acclorite.book_story.domain.util.UIText +import java.util.Locale +import javax.inject.Inject + +@HiltViewModel +class TranslatorLanguageViewModel @Inject constructor( + private val isLanguageModelDownloaded: IsLanguageModelDownloaded, + private val downloadLanguageModel: DownloadLanguageModel, + private val deleteLanguageModel: DeleteLanguageModel +) : ViewModel() { + + private val _state = MutableStateFlow(TranslatorLanguageState()) + val state = _state.asStateFlow() + + private var job: Job? = null + + @Suppress("LABEL_NAME_CLASH") + fun onEvent(event: TranslatorLanguageEvent) { + when (event) { + is TranslatorLanguageEvent.OnSelectLanguage -> { + viewModelScope.launch(Dispatchers.IO) { + if (_state.value.showDownloadLanguageDialog) { + return@launch + } + + val isDownloaded = isLanguageModelDownloaded.execute( + event.languageCode + ) + + _state.update { + it.copy( + languageToSelect = event.languageCode + ) + } + + onEvent( + TranslatorLanguageEvent.OnUpdateLanguage(event.languageCode) { + it.copy( + isDownloaded = isDownloaded + ) + } + ) + + if (isDownloaded) { + val from = _state.value.translateFromSelecting + val langToSelect = _state.value.languageToSelect + val unselectedLang = _state.value.unselectedLanguage + val selectedLang = _state.value.selectedLanguage + + val translateFrom: String + val translateTo: String + + if (langToSelect == unselectedLang) { + if (from) { + if (selectedLang == "auto") { + launch(Dispatchers.Main) { + event.onError( + UIText.StringResource(R.string.error_cannot_select_language) + ) + } + return@launch + } + + translateFrom = langToSelect + translateTo = selectedLang + } else { + translateTo = langToSelect + translateFrom = selectedLang + } + } else { + if (from) { + translateFrom = langToSelect + translateTo = unselectedLang + } else { + translateTo = langToSelect + translateFrom = unselectedLang + } + } + + if (translateTo == "auto") { + launch(Dispatchers.Main) { + event.onError( + UIText.StringResource(R.string.error_wrong_language) + ) + } + return@launch + } + + event.onSelect( + translateFrom, + translateTo + ) + return@launch + } + + _state.update { + it.copy( + showDownloadLanguageDialog = true + ) + } + } + } + + is TranslatorLanguageEvent.OnDownloadLanguage -> { + viewModelScope.launch(Dispatchers.IO) { + onEvent( + TranslatorLanguageEvent.OnUpdateLanguage(event.languageCode) { + it.copy( + isDownloading = true, + isCanceled = false + ) + } + ) + + val isDownloaded = isLanguageModelDownloaded.execute( + event.languageCode + ) + + onEvent( + TranslatorLanguageEvent.OnUpdateLanguage(event.languageCode) { + it.copy( + isDownloaded = isDownloaded + ) + } + ) + + if (isDownloaded) { + onEvent( + TranslatorLanguageEvent.OnUpdateLanguage(event.languageCode) { + it.copy( + isDownloading = false + ) + } + ) + return@launch + } + + downloadLanguageModel.execute( + languageCode = event.languageCode, + onCompleted = { + viewModelScope.launch(Dispatchers.IO) { + val modelDownloaded = isLanguageModelDownloaded.execute( + event.languageCode + ) + + onEvent( + TranslatorLanguageEvent.OnUpdateLanguage(event.languageCode) { + it.copy( + isDownloading = false, + isDownloaded = modelDownloaded + ) + } + ) + + val isCanceled = _state.value.unfilteredLanguages.find { + it.languageCode == event.languageCode + }?.isCanceled + + if (isCanceled == true && modelDownloaded) { + deleteLanguageModel.execute( + languageCode = event.languageCode + ) + onEvent( + TranslatorLanguageEvent.OnUpdateLanguage(event.languageCode) { + it.copy( + isDownloading = false, + isDownloaded = false, + isCanceled = false + ) + } + ) + return@launch + } + + event.onComplete() + } + }, + onFailure = { + event.onFailure(it) + } + ) + } + } + + is TranslatorLanguageEvent.OnCancelDownload -> { + viewModelScope.launch(Dispatchers.IO) { + onEvent( + TranslatorLanguageEvent.OnUpdateLanguage(event.languageCode) { + it.copy( + isDownloaded = false, + isDownloading = false, + isCanceled = true + ) + } + ) + } + } + + is TranslatorLanguageEvent.OnDeleteLanguage -> { + viewModelScope.launch(Dispatchers.IO) { + val deleted = deleteLanguageModel.execute(event.languageCode) + + if (deleted) { + onEvent( + TranslatorLanguageEvent.OnUpdateLanguage(event.languageCode) { + it.copy( + isDownloaded = false, + isDownloading = false + ) + } + ) + } + } + } + + is TranslatorLanguageEvent.OnSearchShowHide -> { + viewModelScope.launch(Dispatchers.IO) { + val shouldHide = _state.value.showSearch + + if (shouldHide) { + filterLanguages("") + } else { + _state.update { + it.copy( + searchQuery = "", + hasFocused = false + ) + } + } + + _state.update { + it.copy( + showSearch = !shouldHide + ) + } + } + } + + is TranslatorLanguageEvent.OnSearchQueryChange -> { + _state.update { + it.copy( + searchQuery = event.query + ) + } + job?.cancel() + job = viewModelScope.launch(Dispatchers.IO) { + delay(500) + yield() + onEvent(TranslatorLanguageEvent.OnSearch) + } + } + + is TranslatorLanguageEvent.OnSearch -> { + viewModelScope.launch(Dispatchers.IO) { + filterLanguages() + } + } + + is TranslatorLanguageEvent.OnRequestFocus -> { + if (!_state.value.hasFocused) { + event.focusRequester.requestFocus() + _state.update { + it.copy( + hasFocused = true + ) + } + } + } + + is TranslatorLanguageEvent.OnDismissDownloadDialog -> { + _state.update { + it.copy( + showDownloadLanguageDialog = false + ) + } + } + + is TranslatorLanguageEvent.OnUpdateLanguage -> { + viewModelScope.launch(Dispatchers.IO) { + val unfilteredLanguages = _state.value.unfilteredLanguages.map { + if (it.languageCode == event.languageCode) { + event.block(it) + } else { + it + } + } + + _state.update { + it.copy( + unfilteredLanguages = unfilteredLanguages + ) + } + filterLanguages() + } + } + } + } + + fun init( + selectedLanguage: String, + unselectedLanguage: String, + translateFromSelecting: Boolean, + history: List, + loaded: () -> Unit + ) { + viewModelScope.launch(Dispatchers.IO) { + val oldState = _state.value + _state.update { + TranslatorLanguageState() + } + + val unfilteredLanguages = TranslateLanguage.getAllLanguages() + .filter { + if (it == "ru") { + return@filter false + } + + true + } + .map { languageCode -> + val previousLanguage = oldState.languages.find { + it.languageCode == languageCode + } + + val locale = Locale(languageCode) + val displayLanguage = locale.getDisplayLanguage(locale).replaceFirstChar { + it.uppercase() + } + + val isDownloading = previousLanguage?.isDownloading ?: false + val isCancelled = previousLanguage?.isCanceled ?: false + val isSelected = selectedLanguage == languageCode + + val historyOrder = history.find { + it.languageCode == languageCode + }?.id + + Language( + languageCode = languageCode, + displayLanguage = displayLanguage, + isDownloading = isDownloading, + isDownloaded = false, + isCanceled = isCancelled, + isSelected = isSelected, + historyOrder = historyOrder + ) + } + + + _state.update { + it.copy( + selectedLanguage = selectedLanguage, + unselectedLanguage = unselectedLanguage, + translateFromSelecting = translateFromSelecting, + unfilteredLanguages = unfilteredLanguages + ) + } + filterLanguages() + loaded() + loadDownloadedState() + } + } + + private suspend fun loadDownloadedState() { + viewModelScope.launch(Dispatchers.IO) { + _state.value.unfilteredLanguages.map { language -> + val isDownloaded = isLanguageModelDownloaded.execute( + language.languageCode + ) + val isDownloading = if (isDownloaded) false else null + + onEvent( + TranslatorLanguageEvent.OnUpdateLanguage( + languageCode = language.languageCode, + block = { + it.copy( + isDownloaded = isDownloaded, + isDownloading = isDownloading ?: it.isDownloading + ) + } + ) + ) + } + } + } + + private fun filterLanguages( + query: String = if (_state.value.showSearch) _state.value.searchQuery else "" + ) { + _state.update { + it.copy( + languages = it.unfilteredLanguages.filter { language -> + if (query.isBlank()) { + return@filter true + } + + language.displayLanguage.lowercase().trim().contains( + query.lowercase().trim() + ) + } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/data/ArithmeticOperations.kt b/app/src/main/java/ua/acclorite/book_story/presentation/data/ArithmeticOperations.kt index 1759bdd2..91d8f848 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/data/ArithmeticOperations.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/data/ArithmeticOperations.kt @@ -10,3 +10,61 @@ fun String.removeTrailingZero(): String { fun Double.removeDigits(digits: Int) = "%.${digits}f".format(this).replace(",", ".") +fun Map.update(item: Pair): Map { + val keyExists = containsKey(item.first) + + return if (!keyExists) this + else this.plus(item) +} + +fun Map.update(items: List>): Map { + var map = this + + for (item in items) { + map = map.update(item) + } + + return map +} + +fun Map.update(items: Map): Map { + var map = this + + for (item in items) { + map = map.update(item.toPair()) + } + + return map +} + +fun Map.updateWithCopy( + calculation: (T) -> T +): Map { + var map = this + + for (item in this) { + map = map.update(item.key to calculation(item.value)) + } + + return map +} + +fun Map.removeKeys( + keys: Set +): Map { + val map = this.toMutableMap() + + for (key in keys) { + map.remove(key) + } + + return map.toMap() +} + +fun Map.removeKey( + key: K +): Map { + val map = this.toMutableMap() + map.remove(key) + return map.toMap() +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/data/MainEvent.kt b/app/src/main/java/ua/acclorite/book_story/presentation/data/MainEvent.kt index 50bd5c42..0f92ccbf 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/data/MainEvent.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/data/MainEvent.kt @@ -1,5 +1,7 @@ package ua.acclorite.book_story.presentation.data +import ua.acclorite.book_story.domain.model.LanguageHistory + sealed class MainEvent { data class OnChangeLanguage(val lang: String) : MainEvent() data class OnChangeTheme(val theme: String) : MainEvent() @@ -17,4 +19,9 @@ sealed class MainEvent { data class OnChangeShowStartScreen(val bool: Boolean) : MainEvent() data class OnChangeCheckForUpdates(val bool: Boolean) : MainEvent() data class OnChangeSidePadding(val sidePadding: Int) : MainEvent() + data class OnChangeEnableTranslator(val bool: Boolean) : MainEvent() + data class OnChangeTranslateFrom(val languageCode: String) : MainEvent() + data class OnChangeTranslateTo(val languageCode: String) : MainEvent() + data class OnChangeDoubleClickTranslation(val bool: Boolean) : MainEvent() + data class OnChangeTranslatorLanguageHistory(val history: List) : MainEvent() } \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/data/MainState.kt b/app/src/main/java/ua/acclorite/book_story/presentation/data/MainState.kt index 875f040e..fb5602cd 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/data/MainState.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/data/MainState.kt @@ -5,6 +5,12 @@ import android.os.Parcel import android.os.Parcelable import androidx.compose.runtime.Immutable import androidx.compose.ui.graphics.Color +import com.google.mlkit.nl.translate.TranslateLanguage +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import ua.acclorite.book_story.domain.model.LanguageHistory import ua.acclorite.book_story.domain.util.Constants import ua.acclorite.book_story.domain.util.DataStoreConstants import ua.acclorite.book_story.presentation.ui.DarkTheme @@ -17,8 +23,19 @@ import ua.acclorite.book_story.presentation.ui.toTheme import ua.acclorite.book_story.presentation.ui.toThemeContrast import java.util.Locale +val translatorHistoryMoshi: JsonAdapter> = Moshi + .Builder() + .add(KotlinJsonAdapterFactory()) + .build() + .adapter( + Types.newParameterizedType( + List::class.java, + LanguageHistory::class.java + ) + ) + /** - * Main State. All app's settings are here. Wrapped in SavedStateHandle, so it won't reset. + * Main State. All app's settings/preferences/permanent-variables are here. Wrapped in SavedStateHandle, so it won't reset. */ @Immutable data class MainState( @@ -38,7 +55,13 @@ data class MainState( val showStartScreen: Boolean? = null, val checkForUpdates: Boolean? = null, val sidePadding: Int? = null, + val enableTranslator: Boolean? = null, + val translateFrom: String? = null, + val translateTo: String? = null, + val doubleClickTranslation: Boolean? = null, + val translatorLanguageHistory: List? = null, ) : Parcelable { + constructor(parcel: Parcel) : this( // String language = parcel.readString(), @@ -75,6 +98,19 @@ data class MainState( .toInt() == 1 else null, // Int sidePadding = if (parcel.readByte().toInt() != 0) parcel.readInt() else null, + // Boolean + enableTranslator = if (parcel.readByte().toInt() != 0) parcel.readByte() + .toInt() == 1 else null, + // String + translateFrom = parcel.readString(), + // String + translateTo = parcel.readString(), + // Boolean + doubleClickTranslation = if (parcel.readByte().toInt() != 0) parcel.readByte() + .toInt() == 1 else null, + translatorLanguageHistory = parcel.readString()?.let { + translatorHistoryMoshi.fromJson(it) + } ) override fun writeToParcel(parcel: Parcel, flags: Int) { @@ -117,9 +153,21 @@ data class MainState( // Check For Updates parcel.writeByte((if (checkForUpdates != null) 1 else 0).toByte()) checkForUpdates?.let { parcel.writeByte(if (it) 1 else 0) } - // Side padding + // Side Padding parcel.writeByte((if (sidePadding != null) 1 else 0).toByte()) sidePadding?.let { parcel.writeInt(it) } + // Enable Translator + parcel.writeByte((if (enableTranslator != null) 1 else 0).toByte()) + enableTranslator?.let { parcel.writeByte(if (it) 1 else 0) } + // Translate From + parcel.writeString(translateFrom) + // Translate To + parcel.writeString(translateTo) + // Double Click Translation + parcel.writeByte((if (doubleClickTranslation != null) 1 else 0).toByte()) + doubleClickTranslation?.let { parcel.writeByte(if (it) 1 else 0) } + // Translator Language History + parcel.writeString(translatorHistoryMoshi.toJson(translatorLanguageHistory)) } override fun describeContents(): Int { @@ -140,7 +188,6 @@ data class MainState( */ fun initialize(data: Map): MainState { DataStoreConstants.apply { - val language: String = data[LANGUAGE.name] as? String ?: if ( Constants.LANGUAGES.any { Locale.getDefault().language.take(2) == it.first } ) { @@ -195,6 +242,30 @@ data class MainState( val sidePadding: Int = data[SIDE_PADDING.name] as? Int ?: 6 + val enableTranslator: Boolean = data[ENABLE_TRANSLATOR.name] as? Boolean + ?: false + + val translateFrom: String = data[TRANSLATE_FROM.name] as? String + ?: "auto" + + val translateTo: String = data[TRANSLATE_TO.name] as? String ?: if ( + TranslateLanguage.getAllLanguages().any { it == language } + ) { + language + } else { + "en" + } + + val doubleClickTranslation: Boolean = + data[DOUBLE_CLICK_TRANSLATION.name] as? Boolean + ?: true + + val translatorLanguageHistory: List = + (data[TRANSLATOR_LANGUAGE_HISTORY.name] as? String)?.let { + translatorHistoryMoshi.fromJson(it) + } ?: emptyList() + + return MainState( language = language, theme = theme.toTheme(), @@ -211,7 +282,12 @@ data class MainState( paragraphHeight = paragraphHeight, paragraphIndentation = paragraphIndentation, checkForUpdates = checkForUpdates, - sidePadding = sidePadding + sidePadding = sidePadding, + enableTranslator = enableTranslator, + translateFrom = translateFrom, + translateTo = translateTo, + doubleClickTranslation = doubleClickTranslation, + translatorLanguageHistory = translatorLanguageHistory ) } } diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/data/MainViewModel.kt b/app/src/main/java/ua/acclorite/book_story/presentation/data/MainViewModel.kt index e5664e58..07cbbd4b 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/data/MainViewModel.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/data/MainViewModel.kt @@ -227,6 +227,66 @@ class MainViewModel @Inject constructor( } } } + + is MainEvent.OnChangeEnableTranslator -> { + viewModelScope.launch(Dispatchers.IO) { + setDatastore.execute(DataStoreConstants.ENABLE_TRANSLATOR, event.bool) + updateStateWithSavedHandle { + it.copy( + enableTranslator = event.bool + ) + } + } + } + + is MainEvent.OnChangeTranslateFrom -> { + viewModelScope.launch(Dispatchers.IO) { + setDatastore.execute(DataStoreConstants.TRANSLATE_FROM, event.languageCode) + updateStateWithSavedHandle { + it.copy( + translateFrom = event.languageCode + ) + } + } + } + + is MainEvent.OnChangeTranslateTo -> { + viewModelScope.launch(Dispatchers.IO) { + setDatastore.execute(DataStoreConstants.TRANSLATE_TO, event.languageCode) + updateStateWithSavedHandle { + it.copy( + translateTo = event.languageCode + ) + } + } + } + + is MainEvent.OnChangeDoubleClickTranslation -> { + viewModelScope.launch(Dispatchers.IO) { + setDatastore.execute(DataStoreConstants.DOUBLE_CLICK_TRANSLATION, event.bool) + updateStateWithSavedHandle { + it.copy( + doubleClickTranslation = event.bool + ) + } + } + } + + is MainEvent.OnChangeTranslatorLanguageHistory -> { + viewModelScope.launch(Dispatchers.IO) { + val historyJson = translatorHistoryMoshi.toJson(event.history) + + setDatastore.execute( + DataStoreConstants.TRANSLATOR_LANGUAGE_HISTORY, + historyJson + ) + updateStateWithSavedHandle { + it.copy( + translatorLanguageHistory = event.history + ) + } + } + } } } diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/data/AboutState.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/data/AboutState.kt index c3f9b35e..3c32b2e4 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/data/AboutState.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/data/AboutState.kt @@ -1,10 +1,10 @@ package ua.acclorite.book_story.presentation.screens.about.data import androidx.compose.runtime.Immutable -import ua.acclorite.book_story.data.remote.dto.ReleaseResponse +import ua.acclorite.book_story.data.remote.dto.LatestReleaseInfo @Immutable data class AboutState( val showUpdateDialog: Boolean = false, - val updateInfo: ReleaseResponse? = null + val updateInfo: LatestReleaseInfo? = null ) diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/nested/credits/CreditsScreen.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/nested/credits/CreditsScreen.kt index 945f6533..030fcd00 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/nested/credits/CreditsScreen.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/nested/credits/CreditsScreen.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.MaterialTheme @@ -24,6 +23,7 @@ import ua.acclorite.book_story.R import ua.acclorite.book_story.domain.util.Constants import ua.acclorite.book_story.presentation.components.GoBackButton import ua.acclorite.book_story.presentation.components.collapsibleUntilExitScrollBehaviorWithLazyListState +import ua.acclorite.book_story.presentation.components.customItems import ua.acclorite.book_story.presentation.data.LocalNavigator import ua.acclorite.book_story.presentation.data.Navigator import ua.acclorite.book_story.presentation.screens.about.data.AboutEvent @@ -78,7 +78,7 @@ private fun CreditsScreen( .padding(top = paddingValues.calculateTopPadding()), state = scrollState.second ) { - items(Constants.CREDITS, key = { it.name }) { + customItems(Constants.CREDITS, key = { it.name }) { CreditItem(credit = it) { onAboutNavigateEvent( AboutEvent.OnNavigateToBrowserPage( diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/nested/credits/components/CreditItem.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/nested/credits/components/CreditItem.kt index 03d97029..98257077 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/nested/credits/components/CreditItem.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/nested/credits/components/CreditItem.kt @@ -23,6 +23,11 @@ import ua.acclorite.book_story.domain.model.Credit /** * Credit Item. + * + * @param credit [Credit] item. + * @param horizontalPadding Horizontal item padding. + * @param verticalPadding Vertical item padding. + * @param onClick OnClick callback. */ @OptIn(ExperimentalLayoutApi::class) @Composable @@ -67,7 +72,7 @@ fun CreditItem( overflow = TextOverflow.Ellipsis, modifier = Modifier .clip(RoundedCornerShape(100)) - .background(MaterialTheme.colorScheme.primary) + .background(MaterialTheme.colorScheme.primary, RoundedCornerShape(100)) .padding(vertical = 4.dp, horizontal = 8.dp) ) } diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/nested/license_info/LicenseInfoScreen.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/nested/license_info/LicenseInfoScreen.kt index c156d828..580a3ace 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/nested/license_info/LicenseInfoScreen.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/nested/license_info/LicenseInfoScreen.kt @@ -183,7 +183,10 @@ private fun LicenseInfoScreen( .clickable(enabled = license.licenseContent?.isNotBlank() == true) { showed.value = !showed.value } - .background(MaterialTheme.colorScheme.primary) + .background( + MaterialTheme.colorScheme.primary, + RoundedCornerShape(100) + ) .padding(vertical = 6.dp, horizontal = 12.dp) ) diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/nested/licenses/components/LicenseItem.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/nested/licenses/components/LicenseItem.kt index 30761762..d62603b3 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/nested/licenses/components/LicenseItem.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/about/nested/licenses/components/LicenseItem.kt @@ -27,6 +27,11 @@ import com.mikepenz.aboutlibraries.ui.compose.m3.util.author /** * License Item. + * + * @param modifier Modifier. + * @param library [Library] item. + * @param horizontalPadding Horizontal item padding. + * @param verticalPadding Vertical item padding. */ @OptIn(ExperimentalLayoutApi::class) @Composable @@ -85,7 +90,7 @@ fun LicenseItem( overflow = TextOverflow.Ellipsis, modifier = Modifier .clip(RoundedCornerShape(100)) - .background(MaterialTheme.colorScheme.primary) + .background(MaterialTheme.colorScheme.primary, RoundedCornerShape(100)) .padding(vertical = 4.dp, horizontal = 8.dp) ) } diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/components/BookInfoInfoSection.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/components/BookInfoInfoSection.kt index 3b5193ae..082fa423 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/components/BookInfoInfoSection.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/components/BookInfoInfoSection.kt @@ -45,7 +45,10 @@ import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoEvent import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoState /** - * Info section. + * BookInfo's Info section. + * + * @param state [BookInfoState] instance. + * @param onEvent [BookInfoEvent] callback. */ @OptIn(ExperimentalFoundationApi::class) @Composable @@ -65,7 +68,10 @@ fun BookInfoInfoSection( .height(195.dp) .width(130.dp) .clip(MaterialTheme.shapes.medium) - .background(MaterialTheme.colorScheme.surfaceContainerLow) + .background( + MaterialTheme.colorScheme.surfaceContainerLow, + MaterialTheme.shapes.medium + ) .combinedClickable( interactionSource = remember { MutableInteractionSource() }, indication = null, diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/components/change_cover_bottom_sheet/BookInfoChangeCoverBottomSheet.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/components/change_cover_bottom_sheet/BookInfoChangeCoverBottomSheet.kt index ec6bde27..7c9251f8 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/components/change_cover_bottom_sheet/BookInfoChangeCoverBottomSheet.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/components/change_cover_bottom_sheet/BookInfoChangeCoverBottomSheet.kt @@ -5,18 +5,11 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.HideImage import androidx.compose.material.icons.filled.ImageSearch -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.ui.Modifier @@ -24,6 +17,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import ua.acclorite.book_story.R +import ua.acclorite.book_story.presentation.components.CustomBottomSheet import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoEvent import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoState import ua.acclorite.book_story.presentation.screens.history.data.HistoryEvent @@ -31,8 +25,12 @@ import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent /** * Change cover bottom sheet. Lets user select photo from the gallery and replaces old one. + * + * @param state [BookInfoState] instance. + * @param onEvent [BookInfoEvent] callback. + * @param onLibraryUpdateEvent [LibraryEvent] callback. + * @param onHistoryUpdateEvent [HistoryEvent] callback. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun BookInfoChangeCoverBottomSheet( state: State, @@ -41,8 +39,6 @@ fun BookInfoChangeCoverBottomSheet( onHistoryUpdateEvent: (HistoryEvent.OnUpdateBook) -> Unit ) { val context = LocalContext.current - val navigationBarPadding = - WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() val photoPicker = rememberLauncherForActivityResult( contract = ActivityResultContracts.PickVisualMedia(), onResult = { uri -> @@ -66,16 +62,12 @@ fun BookInfoChangeCoverBottomSheet( } ) - ModalBottomSheet( + CustomBottomSheet( modifier = Modifier.fillMaxWidth(), onDismissRequest = { onEvent(BookInfoEvent.OnShowHideChangeCoverBottomSheet) - }, - sheetState = rememberModalBottomSheetState(true), - contentWindowInsets = { WindowInsets(0, 0, 0, 0) }, - containerColor = MaterialTheme.colorScheme.surfaceContainerLow - ) { - + } + ) { padding -> BookInfoChangeCoverBottomSheetItem( icon = Icons.Default.ImageSearch, text = stringResource(id = R.string.change_cover), @@ -110,7 +102,7 @@ fun BookInfoChangeCoverBottomSheet( Spacer( modifier = Modifier.height( - 8.dp + navigationBarPadding + 8.dp + padding ) ) } diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/components/details_bottom_sheet/BookInfoDetailsBottomSheet.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/components/details_bottom_sheet/BookInfoDetailsBottomSheet.kt index c8200c95..eaeb57cf 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/components/details_bottom_sheet/BookInfoDetailsBottomSheet.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/components/details_bottom_sheet/BookInfoDetailsBottomSheet.kt @@ -2,15 +2,8 @@ package ua.acclorite.book_story.presentation.screens.book_info.components.detail import android.widget.Toast import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.remember @@ -19,6 +12,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import ua.acclorite.book_story.R +import ua.acclorite.book_story.presentation.components.CustomBottomSheet import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoEvent import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoState import java.io.File @@ -28,16 +22,16 @@ import java.util.Locale /** * Details bottom sheet. Displays name, path, last opened and size of the file. + * + * @param state [BookInfoState] instance. + * @param onEvent [BookInfoEvent] callback. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun BookInfoDetailsBottomSheet( state: State, onEvent: (BookInfoEvent) -> Unit ) { val context = LocalContext.current - val navigationBarPadding = - WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() val pattern = remember { SimpleDateFormat("HH:mm dd MMM yyyy", Locale.getDefault()) @@ -66,14 +60,11 @@ fun BookInfoDetailsBottomSheet( else "" } - ModalBottomSheet( + CustomBottomSheet( modifier = Modifier.fillMaxWidth(), onDismissRequest = { onEvent(BookInfoEvent.OnShowHideDetailsBottomSheet) - }, - sheetState = rememberModalBottomSheetState(true), - contentWindowInsets = { WindowInsets(0, 0, 0, 0) }, - containerColor = MaterialTheme.colorScheme.surfaceContainerLow + } ) { BookInfoDetailsBottomSheetItem( title = stringResource(id = R.string.file_name), @@ -151,7 +142,7 @@ fun BookInfoDetailsBottomSheet( Spacer( modifier = Modifier.height( - 8.dp + navigationBarPadding + 8.dp + it ) ) } diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/data/BookInfoEvent.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/data/BookInfoEvent.kt index 95b5ee49..31017070 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/data/BookInfoEvent.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/data/BookInfoEvent.kt @@ -7,7 +7,6 @@ import androidx.compose.runtime.Immutable import androidx.compose.ui.focus.FocusRequester import ua.acclorite.book_story.domain.model.Book import ua.acclorite.book_story.domain.model.Category -import ua.acclorite.book_story.domain.model.StringWithId import ua.acclorite.book_story.presentation.data.Navigator @Immutable @@ -58,7 +57,7 @@ sealed class BookInfoEvent { data object OnDismissConfirmUpdateDialog : BookInfoEvent() data class OnShowConfirmUpdateDialog( - val updatedBook: Pair>, + val updatedBook: Pair>, val authorUpdated: Boolean, val descriptionUpdated: Boolean, val textUpdated: Boolean, diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/data/BookInfoState.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/data/BookInfoState.kt index 1f878d59..9a96949e 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/data/BookInfoState.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/data/BookInfoState.kt @@ -3,7 +3,6 @@ package ua.acclorite.book_story.presentation.screens.book_info.data import androidx.compose.runtime.Immutable import ua.acclorite.book_story.domain.model.Book import ua.acclorite.book_story.domain.model.Category -import ua.acclorite.book_story.domain.model.StringWithId import ua.acclorite.book_story.domain.util.Constants @Immutable @@ -13,7 +12,7 @@ data class BookInfoState( val isLoadingUpdate: Boolean = false, val isRefreshing: Boolean = false, val showConfirmUpdateDialog: Boolean = false, - val updatedBook: Pair>? = null, + val updatedBook: Pair>? = null, val authorChanged: Boolean = false, val descriptionChanged: Boolean = false, val textChanged: Boolean = false, diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/data/BookInfoViewModel.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/data/BookInfoViewModel.kt index 4abf5ad6..11de879a 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/data/BookInfoViewModel.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/book_info/data/BookInfoViewModel.kt @@ -386,7 +386,7 @@ class BookInfoViewModel @Inject constructor( val updatedText = updatedBook.text val text = getText.execute(book.textPath) - if (updatedText.map { it.line } != text.map { it.line }) { + if (updatedText != text.map { it.value.originalLine }) { textUpdated = true } diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/BrowseFIleItem.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/BrowseFIleItem.kt index 254bd113..b9936f55 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/BrowseFIleItem.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/browse/components/BrowseFIleItem.kt @@ -22,6 +22,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -41,6 +42,12 @@ import java.util.Locale /** * Browse list element item. Can be selected. + * + * @param file [File]. + * @param hasSelectedFiles Whether parent list has selected items. + * @param modifier Modifier. + * @param onClick OnClick callback. + * @param onLongClick OnLongClick callback. */ @OptIn(ExperimentalFoundationApi::class) @Composable @@ -52,12 +59,12 @@ fun BrowseFileItem( onLongClick: () -> Unit ) { val pattern = remember { SimpleDateFormat("HH:mm dd MMM yyyy", Locale.getDefault()) } - val lastModified = remember { pattern.format(Date(file.first.lastModified())) } - val sizeBytes = remember { file.first.length() } + val lastModified = rememberSaveable { pattern.format(Date(file.first.lastModified())) } + val sizeBytes = rememberSaveable { file.first.length() } - val fileSizeKB = remember { if (sizeBytes > 0) sizeBytes.toDouble() / 1024.0 else 0.0 } - val fileSizeMB = remember { if (sizeBytes > 0) fileSizeKB / 1024.0 else 0.0 } - val fileSize = remember { + val fileSizeKB = rememberSaveable { if (sizeBytes > 0) sizeBytes.toDouble() / 1024.0 else 0.0 } + val fileSizeMB = rememberSaveable { if (sizeBytes > 0) fileSizeKB / 1024.0 else 0.0 } + val fileSize = rememberSaveable { if (fileSizeMB >= 1.0) "%.2f MB".format(fileSizeMB) else if (fileSizeMB > 0.0) "%.2f KB".format(fileSizeKB) else "0 KB" @@ -74,7 +81,7 @@ fun BrowseFileItem( .fillMaxWidth() .padding(horizontal = 8.dp, vertical = 3.dp) .clip(RoundedCornerShape(10.dp)) - .background(backgroundColor) + .background(backgroundColor, RoundedCornerShape(10.dp)) .combinedClickable( onLongClick = { onLongClick() diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/history/components/HistoryItem.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/history/components/HistoryItem.kt index ac9b9fe0..796e379e 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/history/components/HistoryItem.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/history/components/HistoryItem.kt @@ -40,6 +40,14 @@ import java.util.Locale /** * History list element item. + * + * @param modifier Modifier. + * @param history [History] item. + * @param isOnClickEnabled Whether OnClick is enabled. + * @param onBodyClick OnBodyClick callback. + * @param onTitleClick OnTitleClick callback. + * @param isDeleteEnabled Whether Delete button is enabled. + * @param onDeleteClick OnDeleteClick callback. */ @Composable fun HistoryItem( @@ -73,8 +81,10 @@ fun HistoryItem( modifier = Modifier .height(90.dp) .width(60.dp) - .clip(RoundedCornerShape(10.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerLow) + .background( + MaterialTheme.colorScheme.surfaceContainerLow, + RoundedCornerShape(10.dp) + ) ) { if (history.book?.coverImage != null) { CustomCoverImage( diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/library/LibraryScreen.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/library/LibraryScreen.kt index 003b01a7..412eba05 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/library/LibraryScreen.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/library/LibraryScreen.kt @@ -47,7 +47,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.layout.onGloballyPositioned @@ -179,8 +178,10 @@ private fun LibraryScreen( Text( text = state.value.books.size.toString(), modifier = Modifier - .clip(RoundedCornerShape(14.dp)) - .background(MaterialTheme.colorScheme.surfaceContainer) + .background( + MaterialTheme.colorScheme.surfaceContainer, + RoundedCornerShape(14.dp) + ) .padding(horizontal = 8.dp, vertical = 3.dp), color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 16.sp diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/library/components/LibraryBookItem.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/library/components/LibraryBookItem.kt index b519556b..94800563 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/library/components/LibraryBookItem.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/library/components/LibraryBookItem.kt @@ -39,6 +39,12 @@ import ua.acclorite.book_story.presentation.data.removeTrailingZero /** * Library list element item. + * + * @param book [Pair] of [Book] and [Selected] (Boolean). + * @param modifier Modifier. + * @param onCoverImageClick OnCoverImageClick callback. + * @param onLongClick OnLongClick callback. + * @param onButtonClick OnButtonClick callback. */ @OptIn(ExperimentalFoundationApi::class) @Composable @@ -86,7 +92,10 @@ fun LibraryBookItem( .aspectRatio(1f / 1.5f) .fillMaxWidth() .clip(MaterialTheme.shapes.medium) - .background(MaterialTheme.colorScheme.surfaceContainerLow) + .background( + MaterialTheme.colorScheme.surfaceContainerLow, + MaterialTheme.shapes.medium + ) .combinedClickable( onClick = { onCoverImageClick() @@ -124,7 +133,7 @@ fun LibraryBookItem( modifier = Modifier .padding(6.dp) .clip(MaterialTheme.shapes.small) - .background(MaterialTheme.colorScheme.primary) + .background(MaterialTheme.colorScheme.primary, MaterialTheme.shapes.small) .padding(horizontal = 8.dp, vertical = 4.dp), style = MaterialTheme.typography.bodySmall ) diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/library/components/LibraryTabRow.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/library/components/LibraryTabRow.kt index 4d32d4ac..7671ab3e 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/library/components/LibraryTabRow.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/library/components/LibraryTabRow.kt @@ -21,7 +21,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp @@ -113,8 +112,10 @@ fun LibraryTabRow( Text( text = tabItem.second.toString(), modifier = Modifier - .clip(MaterialTheme.shapes.medium) - .background(MaterialTheme.colorScheme.surfaceContainer) + .background( + MaterialTheme.colorScheme.surfaceContainer, + MaterialTheme.shapes.medium + ) .padding(horizontal = 8.dp, vertical = 4.dp), color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/ReaderScreen.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/ReaderScreen.kt index 2c7cc93e..6596814c 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/ReaderScreen.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/ReaderScreen.kt @@ -15,10 +15,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.selection.DisableSelection import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme @@ -32,7 +29,6 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -42,24 +38,21 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.style.LineBreak import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.debounce import ua.acclorite.book_story.R import ua.acclorite.book_story.domain.util.Constants import ua.acclorite.book_story.presentation.components.CustomAnimatedVisibility import ua.acclorite.book_story.presentation.components.CustomSelectionContainer -import ua.acclorite.book_story.presentation.components.customItemsIndexed +import ua.acclorite.book_story.presentation.components.customItems import ua.acclorite.book_story.presentation.components.is_messages.IsError +import ua.acclorite.book_story.presentation.components.translator_language.TranslatorLanguageBottomSheet import ua.acclorite.book_story.presentation.data.LocalNavigator import ua.acclorite.book_story.presentation.data.MainEvent import ua.acclorite.book_story.presentation.data.MainState @@ -72,8 +65,11 @@ import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewMode import ua.acclorite.book_story.presentation.screens.reader.components.ReaderEndItem import ua.acclorite.book_story.presentation.screens.reader.components.app_bar.ReaderBottomBar import ua.acclorite.book_story.presentation.screens.reader.components.app_bar.ReaderTopBar +import ua.acclorite.book_story.presentation.screens.reader.components.download_language_dialog.ReaderDownloadLanguageDialog import ua.acclorite.book_story.presentation.screens.reader.components.settings_bottom_sheet.ReaderSettingsBottomSheet import ua.acclorite.book_story.presentation.screens.reader.components.start_item.ReaderStartItem +import ua.acclorite.book_story.presentation.screens.reader.components.text.ReaderTextParagraph +import ua.acclorite.book_story.presentation.screens.reader.components.translator_bottom_sheet.ReaderTranslatorBottomSheet import ua.acclorite.book_story.presentation.screens.reader.data.ReaderEvent import ua.acclorite.book_story.presentation.screens.reader.data.ReaderState import ua.acclorite.book_story.presentation.screens.reader.data.ReaderViewModel @@ -88,12 +84,27 @@ fun ReaderScreenRoot() { val libraryViewModel: LibraryViewModel = hiltViewModel() val historyViewModel: HistoryViewModel = hiltViewModel() - val listState = rememberLazyListState() val loading = remember { mutableStateOf(true) } val state = viewModel.state.collectAsState() val mainState = mainViewModel.state.collectAsState() + val canScroll by remember { + derivedStateOf { + state.value.listState.canScrollBackward && state.value.listState.canScrollForward + } + } + val firstVisibleItemIndex = remember { + derivedStateOf { + state.value.listState.firstVisibleItemIndex + } + } + val firstVisibleItemOffset = remember { + derivedStateOf { + state.value.listState.firstVisibleItemScrollOffset + } + } + LaunchedEffect(Unit) { viewModel.init( navigator, @@ -102,17 +113,37 @@ fun ReaderScreenRoot() { libraryViewModel.onEvent(LibraryEvent.OnUpdateBook(it)) historyViewModel.onEvent(HistoryEvent.OnLoadList) }, - listState = listState, + onError = { + Toast.makeText( + context, + it.asString(context), + Toast.LENGTH_LONG + ).show() + }, onLoaded = { loading.value = false } ) } + LaunchedEffect(canScroll) { + if (!canScroll && !state.value.showMenu && !loading.value) { + viewModel.onEvent(ReaderEvent.OnShowHideMenu(context = context)) + } + } + LaunchedEffect(firstVisibleItemIndex.value, firstVisibleItemOffset.value) { + viewModel.onUpdateProgress( + firstVisibleItemIndex = firstVisibleItemIndex.value, + firstVisibleItemOffset = firstVisibleItemOffset.value, + navigator = navigator, + isReaderLoading = loading.value, + onLibraryEvent = libraryViewModel::onEvent, + onHistoryEvent = historyViewModel::onEvent + ) + } ReaderScreen( state = state, mainState = mainState, - listState = listState, loading = loading, navigator = navigator, onEvent = viewModel::onEvent, @@ -122,13 +153,11 @@ fun ReaderScreenRoot() { ) } -@OptIn(FlowPreview::class) @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @Composable private fun ReaderScreen( state: State, mainState: State, - listState: LazyListState, loading: State, navigator: Navigator, onEvent: (ReaderEvent) -> Unit, @@ -137,13 +166,9 @@ private fun ReaderScreen( onHistoryEvent: (HistoryEvent) -> Unit, ) { val context = LocalContext.current as ComponentActivity - val systemBarsColor = MaterialTheme.colorScheme.surfaceContainerHighest.copy(0.85f) + val density = LocalDensity.current + val systemBarsColor = MaterialTheme.colorScheme.surfaceContainerHighest.copy(0.94f) - val canScroll by remember { - derivedStateOf { - listState.canScrollBackward && listState.canScrollForward - } - } val nestedScrollConnection = remember(state) { object : NestedScrollConnection { override fun onPostScroll( @@ -162,6 +187,9 @@ private fun ReaderScreen( } } + val text = remember(state.value.text) { + state.value.text.toList().sortedBy { it.first } + } val fontFamily = remember(mainState.value.fontFamily) { Constants.FONTS.find { it.id == mainState.value.fontFamily @@ -190,46 +218,45 @@ private fun ReaderScreen( } } - - LaunchedEffect(canScroll) { - if (!canScroll && !state.value.showMenu && !loading.value) { - onEvent(ReaderEvent.OnShowHideMenu(context = context)) - } + if (state.value.showTranslatorBottomSheet) { + ReaderTranslatorBottomSheet( + state = state, + onEvent = onEvent + ) } + if (state.value.showLanguageBottomSheet) { + TranslatorLanguageBottomSheet( + selectedLanguage = if (state.value.languageBottomSheetTranslateFrom) { + state.value.book.translateFrom + } else state.value.book.translateTo, - LaunchedEffect(listState) { - snapshotFlow { - listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset - }.debounce(500).collectLatest { items -> - if (!loading.value && state.value.text.isNotEmpty() && listState.layoutInfo.totalItemsCount > 0) { - val lastVisibleItemIndex = listState.layoutInfo.visibleItemsInfo.last().index - - val progress = if (items.first > 0) { - if (lastVisibleItemIndex >= (listState.layoutInfo.totalItemsCount - 1)) { - 1f - } else { - (items.first.toFloat() / (state.value.text.lastIndex).toFloat()) - } - } else { - 0f - } + unselectedLanguage = if (!state.value.languageBottomSheetTranslateFrom) { + state.value.book.translateFrom + } else state.value.book.translateTo, + translateFromSelecting = state.value.languageBottomSheetTranslateFrom, + onSelect = { from, to -> onEvent( - ReaderEvent.OnChangeProgress( - progress = progress, - navigator = navigator, - firstVisibleItemIndex = items.first, - firstVisibleItemOffset = items.second, - refreshList = { book -> - onLibraryEvent(LibraryEvent.OnUpdateBook(book)) - onHistoryEvent(HistoryEvent.OnUpdateBook(book)) - } + ReaderEvent.OnChangeTranslatorSettings( + translateFrom = from, + translateTo = to + ) + ) + onEvent( + ReaderEvent.OnShowHideLanguageBottomSheet( + show = false + ) + ) + }, + onDismiss = { + onEvent( + ReaderEvent.OnShowHideLanguageBottomSheet( + show = false ) ) } - } + ) } - if (state.value.showSettingsBottomSheet) { ReaderSettingsBottomSheet( mainState = mainState, @@ -237,6 +264,12 @@ private fun ReaderScreen( onMainEvent = onMainEvent ) } + if (state.value.showDownloadLanguageDialog) { + ReaderDownloadLanguageDialog( + state = state, + onEvent = onEvent + ) + } Scaffold( Modifier @@ -254,13 +287,13 @@ private fun ReaderScreen( onEvent = onEvent, onLibraryUpdateEvent = onLibraryEvent, onHistoryUpdateEvent = onHistoryEvent, - listState = listState, containerColor = systemBarsColor ) } }, bottomBar = { CustomAnimatedVisibility( + modifier = Modifier.fillMaxWidth(), visible = state.value.showMenu, enter = slideInVertically { it }, exit = slideOutVertically { it } @@ -270,7 +303,6 @@ private fun ReaderScreen( onEvent = onEvent, onLibraryUpdateEvent = onLibraryEvent, onHistoryUpdateEvent = onHistoryEvent, - listState = listState, containerColor = systemBarsColor ) } @@ -288,7 +320,7 @@ private fun ReaderScreen( }, onTranslateRequested = { textToTranslate -> onEvent( - ReaderEvent.OnTranslateText( + ReaderEvent.OnOpenTranslator( textToTranslate, context, noAppsFound = { @@ -318,9 +350,10 @@ private fun ReaderScreen( } ) { toolbarShowed -> LazyColumn( - state = listState, + state = state.value.listState, modifier = Modifier .fillMaxSize() + .background(backgroundColor) .then( if (!loading.value && toolbarShowed) { Modifier @@ -336,56 +369,49 @@ private fun ReaderScreen( } else { Modifier } - ) + ), + verticalArrangement = Arrangement.spacedBy(paragraphHeight) ) { if (state.value.text.isNotEmpty()) { item { DisableSelection { ReaderStartItem(state = state) } + + Spacer(modifier = Modifier.height(18.dp)) } } - customItemsIndexed( - state.value.text, key = { key -> key.id } - ) { index, line -> - val text = remember(mainState.value.paragraphIndentation, line) { - "${if (mainState.value.paragraphIndentation!!) " " else ""}${line.line}" - } - - Column( - Modifier - .background(backgroundColor) - .fillMaxWidth() - .padding( - top = if (index == 0) 18.dp else 0.dp, - start = sidePadding, - end = sidePadding, - bottom = if (index == state.value.text.lastIndex) 18.dp - else paragraphHeight - ) - ) { - Text( - text = text, - color = fontColor, - style = TextStyle(lineBreak = LineBreak.Paragraph), - fontFamily = fontFamily.font, - fontStyle = fontStyle, - fontSize = mainState.value.fontSize!!.sp, - lineHeight = lineHeight - ) - } + customItems( + text, key = { key -> key.first } + ) { line -> + ReaderTextParagraph( + state = state, + id = line.first, + line = line.second, + context = context, + density = density, + fontFamily = fontFamily, + fontColor = fontColor, + lineHeight = lineHeight, + fontStyle = fontStyle, + fontSize = mainState.value.fontSize!!.sp, + sidePadding = sidePadding, + paragraphIndentation = mainState.value.paragraphIndentation!!, + onEvent = onEvent + ) } if (state.value.text.isNotEmpty()) { item { + Spacer(modifier = Modifier.height(18.dp)) + DisableSelection { ReaderEndItem( state = state, onEvent = onEvent, onLibraryEvent = onLibraryEvent, onHistoryUpdateEvent = onHistoryEvent, - listState = listState ) } } @@ -417,7 +443,6 @@ private fun ReaderScreen( onLibraryEvent(LibraryEvent.OnUpdateBook(it)) onHistoryEvent(HistoryEvent.OnUpdateBook(it)) }, - listState = listState, navigate = { it.navigateBack() } @@ -452,7 +477,6 @@ private fun ReaderScreen( onLibraryEvent(LibraryEvent.OnUpdateBook(it)) onHistoryEvent(HistoryEvent.OnUpdateBook(it)) }, - listState = listState, navigate = { it.navigateBack() } diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/ReaderEndItem.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/ReaderEndItem.kt index 6742b490..649ebb30 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/ReaderEndItem.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/ReaderEndItem.kt @@ -9,11 +9,10 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsIgnoringVisibility import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyListState import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -40,6 +39,11 @@ import ua.acclorite.book_story.presentation.screens.reader.data.ReaderState /** * Reader end item. Displays at the end of the book. + * + * @param state [ReaderState]. + * @param onEvent [ReaderEvent] callback. + * @param onLibraryEvent [LibraryEvent] callback. + * @param onHistoryUpdateEvent [HistoryEvent] callback. */ @OptIn(ExperimentalLayoutApi::class) @Composable @@ -48,7 +52,6 @@ fun ReaderEndItem( onEvent: (ReaderEvent) -> Unit, onLibraryEvent: (LibraryEvent) -> Unit, onHistoryUpdateEvent: (HistoryEvent.OnUpdateBook) -> Unit, - listState: LazyListState, ) { val navigator = LocalNavigator.current val context = LocalContext.current as ComponentActivity @@ -62,7 +65,7 @@ fun ReaderEndItem( Column( modifier = Modifier - .fillMaxWidth() + .fillMaxSize() .background(MaterialTheme.colorScheme.surface) .padding( horizontal = 36.dp, @@ -104,7 +107,6 @@ fun ReaderEndItem( onLibraryEvent(LibraryEvent.OnUpdateCurrentPage(it)) }, navigator = navigator, - listState = listState, context = context ) ) @@ -123,7 +125,6 @@ fun ReaderEndItem( onLibraryEvent(LibraryEvent.OnUpdateBook(it)) onHistoryUpdateEvent(HistoryEvent.OnUpdateBook(it)) }, - listState = listState, navigate = { it.navigate(Screen.LIBRARY, true) } diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/app_bar/ReaderBottomBar.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/app_bar/ReaderBottomBar.kt index 2291b4d3..9cd839a9 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/app_bar/ReaderBottomBar.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/app_bar/ReaderBottomBar.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyListState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider import androidx.compose.material3.SliderDefaults @@ -33,6 +32,12 @@ import ua.acclorite.book_story.presentation.screens.reader.data.ReaderState /** * Reader bottom bar. Has a slider to change progress. + * + * @param state [ReaderState]. + * @param onEvent [ReaderEvent] callback. + * @param onLibraryUpdateEvent [LibraryEvent] callback. + * @param onHistoryUpdateEvent [HistoryEvent] callback. + * @param containerColor Container color. */ @Composable fun ReaderBottomBar( @@ -40,7 +45,6 @@ fun ReaderBottomBar( onEvent: (ReaderEvent) -> Unit, onLibraryUpdateEvent: (LibraryEvent.OnUpdateBook) -> Unit, onHistoryUpdateEvent: (HistoryEvent.OnUpdateBook) -> Unit, - listState: LazyListState, containerColor: Color ) { val navigator = LocalNavigator.current @@ -79,13 +83,13 @@ fun ReaderBottomBar( value = state.value.book.progress, enabled = !state.value.lockMenu, onValueChange = { - if (listState.layoutInfo.totalItemsCount > 0) { - onEvent(ReaderEvent.OnScroll(listState, it)) + if (state.value.listState.layoutInfo.totalItemsCount > 0) { + onEvent(ReaderEvent.OnScroll(it)) onEvent( ReaderEvent.OnChangeProgress( progress = it, navigator = navigator, - firstVisibleItemIndex = listState.firstVisibleItemIndex, + firstVisibleItemIndex = state.value.listState.firstVisibleItemIndex, firstVisibleItemOffset = 0, refreshList = { book -> onLibraryUpdateEvent(LibraryEvent.OnUpdateBook(book)) diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/app_bar/ReaderTopBar.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/app_bar/ReaderTopBar.kt index 4d63334c..28f98f45 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/app_bar/ReaderTopBar.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/app_bar/ReaderTopBar.kt @@ -5,11 +5,12 @@ import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.lazy.LazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Translate import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -39,6 +40,12 @@ import ua.acclorite.book_story.presentation.screens.reader.data.ReaderState /** * Reader top bar. Displays title of the book. + * + * @param state [ReaderState]. + * @param onEvent [ReaderEvent] callback. + * @param onLibraryUpdateEvent [LibraryEvent] callback. + * @param onHistoryUpdateEvent [HistoryEvent] callback. + * @param containerColor Container color. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -47,7 +54,6 @@ fun ReaderTopBar( onEvent: (ReaderEvent) -> Unit, onLibraryUpdateEvent: (LibraryEvent.OnUpdateBook) -> Unit, onHistoryUpdateEvent: (HistoryEvent.OnUpdateBook) -> Unit, - listState: LazyListState, containerColor: Color ) { val navigator = LocalNavigator.current @@ -78,7 +84,6 @@ fun ReaderTopBar( onLibraryUpdateEvent(LibraryEvent.OnUpdateBook(it)) onHistoryUpdateEvent(HistoryEvent.OnUpdateBook(it)) }, - listState = listState, navigate = { it.navigateBack() } @@ -93,6 +98,7 @@ fun ReaderTopBar( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 20.sp, + lineHeight = 20.sp, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, modifier = Modifier @@ -109,7 +115,6 @@ fun ReaderTopBar( onLibraryUpdateEvent(LibraryEvent.OnUpdateBook(it)) onHistoryUpdateEvent(HistoryEvent.OnUpdateBook(it)) }, - listState = listState, navigate = { it.navigateWithoutBackStack( Screen.BOOK_INFO, @@ -121,7 +126,7 @@ fun ReaderTopBar( } ) .basicMarquee( - delayMillis = 4000 + iterations = Int.MAX_VALUE ) ) Text( @@ -135,6 +140,17 @@ fun ReaderTopBar( } }, actions = { + CustomIconButton( + icon = Icons.Default.Translate, + contentDescription = R.string.translator_content_desc, + disableOnClick = false, + enabled = !state.value.lockMenu, + color = if (state.value.book.enableTranslator) { + MaterialTheme.colorScheme.primary + } else LocalContentColor.current + ) { + onEvent(ReaderEvent.OnShowHideTranslatorBottomSheet()) + } CustomIconButton( icon = Icons.Default.Settings, contentDescription = R.string.open_reader_settings_content_desc, diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/download_language_dialog/ReaderDownloadLanguageDialog.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/download_language_dialog/ReaderDownloadLanguageDialog.kt new file mode 100644 index 00000000..7ee3d1f3 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/download_language_dialog/ReaderDownloadLanguageDialog.kt @@ -0,0 +1,116 @@ +package ua.acclorite.book_story.presentation.screens.reader.components.download_language_dialog + +import android.widget.Toast +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Download +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import ua.acclorite.book_story.R +import ua.acclorite.book_story.presentation.components.customItems +import ua.acclorite.book_story.presentation.components.custom_dialog.CustomDialogWithLazyColumn +import ua.acclorite.book_story.presentation.screens.reader.data.ReaderEvent +import ua.acclorite.book_story.presentation.screens.reader.data.ReaderState + +/** + * Reader Download Language Dialog. Downloads multiple language models at the same time. + * + * @param state [ReaderState]. + * @param onEvent [ReaderEvent] callback. + */ +@Composable +fun ReaderDownloadLanguageDialog( + state: State, + onEvent: (ReaderEvent) -> Unit +) { + val context = LocalContext.current + var isOnClickEnabled by remember { mutableStateOf(true) } + val isDownloading by remember { + derivedStateOf { + state.value.languagesToTranslate.any { it.isDownloading } + } + } + val isDownloaded by remember { + derivedStateOf { + state.value.languagesToTranslate.filter { it.isSelected }.none { !it.isDownloaded } + } + } + val languages = remember(state.value.languagesToTranslate, isDownloading) { + if (isDownloading) { + state.value.languagesToTranslate.sortedByDescending { it.isDownloading } + } else { + state.value.languagesToTranslate + } + } + + CustomDialogWithLazyColumn( + title = stringResource(id = R.string.download_languages), + imageVectorIcon = Icons.Default.Download, + description = stringResource( + id = R.string.download_languages_description + ), + actionText = stringResource( + if (!isDownloaded) R.string.download + else R.string.continue_download + ), + disableOnClick = false, + isActionEnabled = isOnClickEnabled, + onDismiss = { onEvent(ReaderEvent.OnDismissDownloadLanguageDialog) }, + onAction = { + isOnClickEnabled = false + onEvent( + ReaderEvent.OnDownloadLanguages( + onSuccess = { + Toast.makeText( + context, + context.getString(R.string.languages_downloaded), + Toast.LENGTH_LONG + ).show() + + onEvent( + ReaderEvent.OnTranslateText( + it.keys, + error = { error -> + Toast.makeText( + context, + error.asString(context), + Toast.LENGTH_LONG + ).show() + } + ) + ) + }, + error = { + Toast.makeText( + context, + it.asString(context), + Toast.LENGTH_LONG + ).show() + } + ) + ) + }, + withDivider = true, + items = { + customItems( + languages, key = { it.languageCode } + ) { + ReaderDownloadLanguageDialogItem( + language = it, + isEnabled = !isDownloading, + onClick = { + onEvent( + ReaderEvent.OnSelectLanguage(it) + ) + } + ) + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/download_language_dialog/ReaderDownloadLanguageDialogItem.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/download_language_dialog/ReaderDownloadLanguageDialogItem.kt new file mode 100644 index 00000000..86f4237e --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/download_language_dialog/ReaderDownloadLanguageDialogItem.kt @@ -0,0 +1,177 @@ +package ua.acclorite.book_story.presentation.screens.reader.components.download_language_dialog + +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DownloadDone +import androidx.compose.material.icons.filled.Error +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import ua.acclorite.book_story.R +import ua.acclorite.book_story.domain.model.SelectableLanguage +import ua.acclorite.book_story.presentation.components.CustomCheckbox +import ua.acclorite.book_story.presentation.ui.DefaultTransition + +/** + * Reader Download Language Dialog Item. + * + * @param language [SelectableLanguage] item. + * @param isEnabled Whether OnClick is enabled or not. + * @param onClick OnClick callback. + */ +@Composable +fun LazyItemScope.ReaderDownloadLanguageDialogItem( + language: SelectableLanguage, + isEnabled: Boolean = true, + onClick: () -> Unit +) { + val context = LocalContext.current + + if ( + isEnabled || language.isDownloading || language.isDownloaded || language.isError + ) { + Row( + modifier = Modifier + .animateItem( + fadeInSpec = null, + fadeOutSpec = null + ) + .fillMaxWidth() + .clickable( + enabled = !language.isDownloading && + !language.isDownloaded && + language.canUnselect && + isEnabled || language.isError + ) { + if (!language.isError) { + onClick() + } else if (language.errorMessage != null) { + Toast + .makeText( + context, + language.errorMessage.asString(context), + Toast.LENGTH_LONG + ) + .show() + } + } + .padding(vertical = 12.dp, horizontal = 24.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(Modifier.weight(1f)) { + Text( + text = language.displayLanguage, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (!language.occurrences.isNullOrEmpty()) { + Text( + text = stringResource( + id = R.string.translator_occurrences, + language.occurrences.size + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + + Box(contentAlignment = Alignment.CenterEnd) { + DefaultTransition( + language.isDownloading && !language.isDownloaded && !language.isError + ) { + Row { + Spacer(modifier = Modifier.width(24.dp)) + CircularProgressIndicator( + strokeWidth = 2.5.dp, + modifier = Modifier + .size(22.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(4.dp)) + } + } + + + DefaultTransition( + language.isDownloaded && !language.isDownloading && !language.isError + ) { + Row { + Spacer(modifier = Modifier.width(24.dp)) + Icon( + imageVector = Icons.Default.DownloadDone, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .clip(CircleShape) + .background( + MaterialTheme.colorScheme.outlineVariant, + shape = CircleShape + ) + .padding(6.dp) + .size(17.dp), + contentDescription = stringResource(id = R.string.downloaded_content_desc) + ) + } + } + + DefaultTransition( + !language.isDownloaded && !language.isDownloading && isEnabled && !language.isError + ) { + Row { + Spacer(modifier = Modifier.width(24.dp)) + CustomCheckbox( + selected = language.isSelected, + size = 20.dp, + containerColor = + if (language.canUnselect) MaterialTheme.colorScheme.surfaceContainerHigh + else MaterialTheme.colorScheme.surfaceContainerHigh + ) + } + } + + DefaultTransition( + !language.isDownloaded && !language.isDownloading && language.isError + ) { + Row { + Spacer(modifier = Modifier.width(24.dp)) + Icon( + imageVector = Icons.Default.Error, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier + .size(27.dp), + contentDescription = stringResource(id = R.string.error_content_desc) + ) + Spacer(modifier = Modifier.width(1.5.dp)) + } + } + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/settings_bottom_sheet/ReaderSettingsBottomSheet.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/settings_bottom_sheet/ReaderSettingsBottomSheet.kt index 3d297ac9..211953d8 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/settings_bottom_sheet/ReaderSettingsBottomSheet.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/settings_bottom_sheet/ReaderSettingsBottomSheet.kt @@ -4,30 +4,22 @@ import androidx.activity.ComponentActivity import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext @@ -38,6 +30,7 @@ import ua.acclorite.book_story.R import ua.acclorite.book_story.domain.model.ButtonItem import ua.acclorite.book_story.domain.util.Constants import ua.acclorite.book_story.presentation.components.CategoryTitle +import ua.acclorite.book_story.presentation.components.CustomBottomSheet import ua.acclorite.book_story.presentation.data.MainEvent import ua.acclorite.book_story.presentation.data.MainState import ua.acclorite.book_story.presentation.screens.reader.data.ReaderEvent @@ -48,10 +41,12 @@ import ua.acclorite.book_story.presentation.screens.settings.components.SwitchWi /** * Settings bottom sheet. Has General and Colors categories. + * + * @param mainState [MainState]. + * @param onEvent [ReaderEvent] callback. + * @param onMainEvent [MainEvent] callback. */ -@OptIn( - ExperimentalMaterial3Api::class -) +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ReaderSettingsBottomSheet( mainState: State, @@ -62,9 +57,6 @@ fun ReaderSettingsBottomSheet( val currentPage by remember { derivedStateOf { pagerState.currentPage } } val context = LocalContext.current - val navigationBarPadding = - WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() - val fontFamily = remember(mainState.value.fontFamily) { Constants.FONTS.find { it.id == mainState.value.fontFamily @@ -98,210 +90,205 @@ fun ReaderSettingsBottomSheet( ) } - Box(modifier = Modifier.fillMaxSize()) { - ModalBottomSheet( - scrimColor = animatedScrimColor, - dragHandle = {}, - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .fillMaxHeight(animatedHeight), - onDismissRequest = { - onEvent(ReaderEvent.OnShowHideSettingsBottomSheet) - }, - sheetState = rememberModalBottomSheetState(true), - contentWindowInsets = { WindowInsets(0, 0, 0, 0) }, - containerColor = MaterialTheme.colorScheme.surfaceContainerLow - ) { - ReaderSettingsBottomSheetTabRow( - onEvent = onEvent, - pagerState = pagerState - ) - - HorizontalPager(state = pagerState) { page -> - LazyColumn { - if (page == 0) { - item { - Spacer(modifier = Modifier.height(22.dp)) - } - item { - CategoryTitle( - title = stringResource(id = R.string.font_reader_settings), - color = MaterialTheme.colorScheme.onSurface - ) - } - item { - Spacer(modifier = Modifier.height(8.dp)) - } - item { - ChipsWithTitle( - title = stringResource(id = R.string.font_family_option), - chips = Constants.FONTS - .map { - ButtonItem( - it.id, - it.fontName.asString(), - MaterialTheme.typography.labelLarge.copy( - fontFamily = it.font - ), - it.id == fontFamily.id - ) - }, - onClick = { - onMainEvent(MainEvent.OnChangeFontFamily(it.id)) - } - ) - } - item { - ChipsWithTitle( - title = stringResource(id = R.string.font_style_option), - chips = listOf( - ButtonItem( - "normal", - stringResource(id = R.string.font_style_normal), - MaterialTheme.typography.labelLarge.copy( - fontFamily = fontFamily.font, - fontStyle = FontStyle.Normal - ), - !mainState.value.isItalic!! - ), - ButtonItem( - "italic", - stringResource(id = R.string.font_style_italic), - MaterialTheme.typography.labelLarge.copy( - fontFamily = fontFamily.font, - fontStyle = FontStyle.Italic - ), - mainState.value.isItalic!! - ), - ), - onClick = { - onMainEvent( - MainEvent.OnChangeFontStyle( - when (it.id) { - "italic" -> true - else -> false - } - ) - ) - } - ) - } - item { - SliderWithTitle( - value = mainState.value.fontSize!! to "pt", - fromValue = 10, - toValue = 35, - title = stringResource(id = R.string.font_size_option), - onValueChange = { - onMainEvent( - MainEvent.OnChangeFontSize(it) - ) - } - ) - } - item { - Spacer(modifier = Modifier.height(22.dp)) - } - item { - CategoryTitle( - title = stringResource(id = R.string.text_reader_settings), - color = MaterialTheme.colorScheme.onSurface - ) - } - item { - Spacer(modifier = Modifier.height(8.dp)) - } - item { - SliderWithTitle( - value = mainState.value.sidePadding!! to "pt", - fromValue = 1, - toValue = 20, - title = stringResource(id = R.string.side_padding_option), - onValueChange = { - onMainEvent( - MainEvent.OnChangeSidePadding(it) - ) - } - ) - } - item { - SliderWithTitle( - value = mainState.value.lineHeight!! to "pt", - fromValue = 1, - toValue = 16, - title = stringResource(id = R.string.line_height_option), - onValueChange = { - onMainEvent( - MainEvent.OnChangeLineHeight(it) - ) - } - ) - } - item { - SliderWithTitle( - value = mainState.value.paragraphHeight!! to "pt", - fromValue = 0, - toValue = 24, - title = stringResource(id = R.string.paragraph_height_option), - onValueChange = { - onMainEvent( - MainEvent.OnChangeParagraphHeight(it) - ) - } - ) - } - item { - SwitchWithTitle( - selected = mainState.value.paragraphIndentation!!, - title = stringResource(id = R.string.paragraph_indentation_option) - ) { - onMainEvent( - MainEvent.OnChangeParagraphIndentation(!mainState.value.paragraphIndentation!!) - ) - } - } - } - - if (page == 1) { - item { - Spacer(modifier = Modifier.height(14.dp)) - } - item { - ColorPickerWithTitle( - value = Color(mainState.value.backgroundColor!!.toULong()), - title = stringResource(id = R.string.background_color_option), - onValueChange = { - onMainEvent( - MainEvent.OnChangeBackgroundColor( - it.value.toLong() - ) - ) - } - ) - } - item { - ColorPickerWithTitle( - value = Color(mainState.value.fontColor!!.toULong()), - title = stringResource(id = R.string.font_color_option), - onValueChange = { - onMainEvent( - MainEvent.OnChangeFontColor( - it.value.toLong() - ) - ) - } - ) - } - } + CustomBottomSheet( + hasFixedHeight = true, + scrimColor = animatedScrimColor, + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(animatedHeight), + dragHandle = {}, + onDismissRequest = { + onEvent(ReaderEvent.OnShowHideSettingsBottomSheet) + } + ) { + ReaderSettingsBottomSheetTabRow( + onEvent = onEvent, + pagerState = pagerState + ) + HorizontalPager(state = pagerState) { page -> + LazyColumn { + if (page == 0) { item { - Spacer( - modifier = Modifier.height( - 8.dp + navigationBarPadding - ) + Spacer(modifier = Modifier.height(22.dp)) + } + item { + CategoryTitle( + title = stringResource(id = R.string.font_reader_settings), + color = MaterialTheme.colorScheme.onSurface ) } + item { + Spacer(modifier = Modifier.height(8.dp)) + } + item { + ChipsWithTitle( + title = stringResource(id = R.string.font_family_option), + chips = Constants.FONTS + .map { + ButtonItem( + it.id, + it.fontName.asString(), + MaterialTheme.typography.labelLarge.copy( + fontFamily = it.font + ), + it.id == fontFamily.id + ) + }, + onClick = { + onMainEvent(MainEvent.OnChangeFontFamily(it.id)) + } + ) + } + item { + ChipsWithTitle( + title = stringResource(id = R.string.font_style_option), + chips = listOf( + ButtonItem( + "normal", + stringResource(id = R.string.font_style_normal), + MaterialTheme.typography.labelLarge.copy( + fontFamily = fontFamily.font, + fontStyle = FontStyle.Normal + ), + !mainState.value.isItalic!! + ), + ButtonItem( + "italic", + stringResource(id = R.string.font_style_italic), + MaterialTheme.typography.labelLarge.copy( + fontFamily = fontFamily.font, + fontStyle = FontStyle.Italic + ), + mainState.value.isItalic!! + ), + ), + onClick = { + onMainEvent( + MainEvent.OnChangeFontStyle( + when (it.id) { + "italic" -> true + else -> false + } + ) + ) + } + ) + } + item { + SliderWithTitle( + value = mainState.value.fontSize!! to "pt", + fromValue = 10, + toValue = 35, + title = stringResource(id = R.string.font_size_option), + onValueChange = { + onMainEvent( + MainEvent.OnChangeFontSize(it) + ) + } + ) + } + item { + Spacer(modifier = Modifier.height(22.dp)) + } + item { + CategoryTitle( + title = stringResource(id = R.string.text_reader_settings), + color = MaterialTheme.colorScheme.onSurface + ) + } + item { + Spacer(modifier = Modifier.height(8.dp)) + } + item { + SliderWithTitle( + value = mainState.value.sidePadding!! to "pt", + fromValue = 1, + toValue = 20, + title = stringResource(id = R.string.side_padding_option), + onValueChange = { + onMainEvent( + MainEvent.OnChangeSidePadding(it) + ) + } + ) + } + item { + SliderWithTitle( + value = mainState.value.lineHeight!! to "pt", + fromValue = 1, + toValue = 16, + title = stringResource(id = R.string.line_height_option), + onValueChange = { + onMainEvent( + MainEvent.OnChangeLineHeight(it) + ) + } + ) + } + item { + SliderWithTitle( + value = mainState.value.paragraphHeight!! to "pt", + fromValue = 0, + toValue = 24, + title = stringResource(id = R.string.paragraph_height_option), + onValueChange = { + onMainEvent( + MainEvent.OnChangeParagraphHeight(it) + ) + } + ) + } + item { + SwitchWithTitle( + selected = mainState.value.paragraphIndentation!!, + title = stringResource(id = R.string.paragraph_indentation_option) + ) { + onMainEvent( + MainEvent.OnChangeParagraphIndentation(!mainState.value.paragraphIndentation!!) + ) + } + } + } + + if (page == 1) { + item { + Spacer(modifier = Modifier.height(14.dp)) + } + item { + ColorPickerWithTitle( + value = Color(mainState.value.backgroundColor!!.toULong()), + title = stringResource(id = R.string.background_color_option), + onValueChange = { + onMainEvent( + MainEvent.OnChangeBackgroundColor( + it.value.toLong() + ) + ) + } + ) + } + item { + ColorPickerWithTitle( + value = Color(mainState.value.fontColor!!.toULong()), + title = stringResource(id = R.string.font_color_option), + onValueChange = { + onMainEvent( + MainEvent.OnChangeFontColor( + it.value.toLong() + ) + ) + } + ) + } + } + + item { + Spacer( + modifier = Modifier.height( + 8.dp + it + ) + ) } } } diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/start_item/ReaderStartItem.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/start_item/ReaderStartItem.kt index ea878582..13443b79 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/start_item/ReaderStartItem.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/start_item/ReaderStartItem.kt @@ -37,6 +37,8 @@ import ua.acclorite.book_story.presentation.screens.reader.data.ReaderState /** * Reader start item. Displays at the beginning of the book. + * + * @param state [ReaderState]. */ @OptIn(ExperimentalLayoutApi::class) @Composable @@ -76,8 +78,10 @@ fun ReaderStartItem(state: State) { modifier = Modifier .height(150.dp) .width(100.dp) - .clip(RoundedCornerShape(10.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerLow) + .background( + MaterialTheme.colorScheme.surfaceContainerLow, + RoundedCornerShape(10.dp) + ) ) { if (state.value.book.coverImage != null) { CustomCoverImage( diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/text/ReaderTextParagraph.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/text/ReaderTextParagraph.kt new file mode 100644 index 00000000..c17168ef --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/text/ReaderTextParagraph.kt @@ -0,0 +1,224 @@ +package ua.acclorite.book_story.presentation.screens.reader.components.text + +import android.content.Context +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.foundation.text.selection.DisableSelection +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.LineBreak +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import ua.acclorite.book_story.R +import ua.acclorite.book_story.domain.model.FontWithName +import ua.acclorite.book_story.domain.model.LineWithTranslation +import ua.acclorite.book_story.domain.util.ID +import ua.acclorite.book_story.presentation.components.CustomAnimatedVisibility +import ua.acclorite.book_story.presentation.screens.reader.data.ReaderEvent +import ua.acclorite.book_story.presentation.screens.reader.data.ReaderState +import java.util.Locale + +/** + * Reader Text Paragraph item. + * + * @param state [ReaderState]. + * @param id Current paragraph [ID]. + * @param line Current line. + * @param context Context. + * @param density [Density] from LocalDensity.current. + * @param fontFamily Line's font family. + * @param fontColor Line's font color. + * @param lineHeight Line's line height. + * @param fontStyle Line's font style. + * @param fontSize Line's font size. + * @param sidePadding Line's side padding. + * @param paragraphIndentation Whether Paragraph Indentation is enabled for this line. + * @param onEvent [ReaderEvent] callback. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LazyItemScope.ReaderTextParagraph( + state: State, + id: ID, + line: LineWithTranslation, + context: Context, + density: Density, + fontFamily: FontWithName, + fontColor: Color, + lineHeight: TextUnit, + fontStyle: FontStyle, + fontSize: TextUnit, + sidePadding: Dp, + paragraphIndentation: Boolean, + onEvent: (ReaderEvent) -> Unit +) { + val text = remember( + paragraphIndentation, + line.originalLine, + line.translatedLine, + line.useTranslation, + line.translatingTo, + line.translatingFrom + ) { + derivedStateOf { + if (paragraphIndentation) " " + else "" + + if (line.useTranslation && line.translatedLine != null) line.translatedLine + else line.originalLine + } + } + + val translated = remember(line.useTranslation, line.translatedLine) { + line.useTranslation && line.translatedLine != null + } + val showTrailingContent = remember( + line.isTranslationLoading, line.isTranslationFailed + ) { + line.isTranslationLoading || line.isTranslationFailed + } + + val translatingFrom = remember(line.translatingFrom) { + val locale = Locale(line.translatingFrom) + locale.getDisplayName(locale).replaceFirstChar { char -> + char.uppercase() + } + } + val translatingTo = remember(line.translatingTo) { + val locale = Locale(line.translatingTo) + locale.getDisplayName(locale).replaceFirstChar { char -> + char.uppercase() + } + } + + Column( + modifier = Modifier + .animateItem(fadeInSpec = null, fadeOutSpec = null) + .fillMaxWidth() + .padding( + horizontal = sidePadding + ), + verticalArrangement = Arrangement.Center + ) { + CustomAnimatedVisibility( + modifier = Modifier.animateItem( + placementSpec = tween(10), + fadeInSpec = null, + fadeOutSpec = null + ), + visible = translated, + enter = fadeIn(tween(250)) + expandVertically(tween(250)), + exit = fadeOut(tween(250)) + shrinkVertically(tween(250)), + ) { + DisableSelection { + Text( + text = stringResource( + id = R.string.translator_show_original, + translatingFrom, + translatingTo + ), + color = fontColor, + fontStyle = FontStyle.Italic, + fontWeight = FontWeight.Light, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.clickable(interactionSource = null, indication = null) { + onEvent(ReaderEvent.OnUndoTranslation(id)) + } + ) + } + } + + BasicText( + text = buildAnnotatedString { + append(text.value) + + if (showTrailingContent) { + append(" ") + appendInlineContent("translator") + } + }, + modifier = Modifier + .then( + if ( + state.value.book.enableTranslator && + state.value.book.doubleClickTranslation + ) { + Modifier.combinedClickable( + interactionSource = null, + indication = null, + onDoubleClick = { + if (line.isTranslationLoading) { + return@combinedClickable + } + + if (translated) { + onEvent(ReaderEvent.OnUndoTranslation(id)) + } else { + onEvent( + ReaderEvent.OnTranslateText(setOf(id)) { + Toast + .makeText( + context, + it.asString(context), + Toast.LENGTH_LONG + ) + .show() + } + ) + } + }, + onClick = { + onEvent( + ReaderEvent.OnShowHideMenu(context = context as ComponentActivity) + ) + } + ) + } else { + Modifier + } + ), + inlineContent = readerTextTrailingContent( + line = line, + color = fontColor, + textSize = fontSize, + context = context, + density = density + ), + style = TextStyle( + fontFamily = fontFamily.font, + fontStyle = fontStyle, + fontSize = fontSize, + lineHeight = lineHeight, + color = fontColor, + lineBreak = LineBreak.Paragraph + ) + ) + } + +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/text/ReaderTextTrailingContent.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/text/ReaderTextTrailingContent.kt new file mode 100644 index 00000000..55c9850e --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/text/ReaderTextTrailingContent.kt @@ -0,0 +1,97 @@ +package ua.acclorite.book_story.presentation.screens.reader.components.text + +import android.content.Context +import android.widget.Toast +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Error +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.TextUnit +import ua.acclorite.book_story.R +import ua.acclorite.book_story.domain.model.LineWithTranslation +import ua.acclorite.book_story.presentation.ui.DefaultTransition + +/** + * [ReaderTextParagraph]'s trailing content. + * + * @param line [ReaderTextParagraph]'s line. + * @param color Color. + * @param textSize Font size. + * @param context Context. + * @param density [Density] from LocalDensity.current. + */ +@Composable +fun readerTextTrailingContent( + line: LineWithTranslation, + color: Color, + textSize: TextUnit, + context: Context, + density: Density +): Map { + val sizeDp = remember(textSize, density) { with(density) { textSize.toDp() } } + return remember(line.isTranslationLoading, line.isTranslationFailed, sizeDp, color, textSize) { + mapOf( + Pair( + "translator", + InlineTextContent( + placeholder = Placeholder( + width = textSize, + height = textSize, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center + ), + ) { + Box( + Modifier.fillMaxSize(), + contentAlignment = Alignment.CenterEnd + ) { + DefaultTransition(line.isTranslationLoading) { + CircularProgressIndicator( + strokeWidth = sizeDp / 12, + modifier = Modifier + .fillMaxSize(0.7f), + color = color + ) + } + + DefaultTransition(line.isTranslationFailed && !line.isTranslationLoading) { + Icon( + imageVector = Icons.Filled.Error, + contentDescription = stringResource(id = R.string.error_content_desc), + tint = color, + modifier = Modifier + .fillMaxSize() + .clickable( + interactionSource = null, + indication = null + ) { + line.errorMessage?.let { + Toast + .makeText( + context, + it.asString(context), + Toast.LENGTH_LONG + ) + .show() + } + } + ) + } + } + } + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/translator_bottom_sheet/ReaderTranslatorBottomSheet.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/translator_bottom_sheet/ReaderTranslatorBottomSheet.kt new file mode 100644 index 00000000..3edea813 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/translator_bottom_sheet/ReaderTranslatorBottomSheet.kt @@ -0,0 +1,321 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package ua.acclorite.book_story.presentation.screens.reader.components.translator_bottom_sheet + +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import ua.acclorite.book_story.R +import ua.acclorite.book_story.presentation.components.CustomBottomSheet +import ua.acclorite.book_story.presentation.screens.reader.data.ReaderEvent +import ua.acclorite.book_story.presentation.screens.reader.data.ReaderState +import ua.acclorite.book_story.presentation.screens.settings.components.SwitchWithTitle +import java.util.Locale + +/** + * Reader Translator Bottom Sheet. You can set up preferences for an individual book here and apply/cancel translation. + * + * @param state [ReaderState]. + * @param onEvent [ReaderEvent] callback. + */ +@Composable +fun ReaderTranslatorBottomSheet( + state: State, + onEvent: (ReaderEvent) -> Unit +) { + val context = LocalContext.current + val book = remember(state.value) { state.value.book } + + val translateFrom = remember(book.translateFrom) { + if (book.translateFrom == "auto") { + return@remember context.getString(R.string.translator_auto_short) + } + + val locale = Locale(book.translateFrom) + locale.getDisplayName(locale).replaceFirstChar { char -> + char.uppercase() + } + } + val translateTo = remember(book.translateTo) { + val locale = Locale(book.translateTo) + locale.getDisplayName(locale).replaceFirstChar { char -> + char.uppercase() + } + } + + val loading = remember { mutableStateOf(false) } + val textTranslated = remember(state.value.isTranslating, state.value.text) { + loading.value = true + + val hasTranslatedText = state.value.text.any { + it.value.useTranslation && it.value.translatedLine != null + } + val loaded = state.value.text.none { + it.value.isTranslationLoading + } + + loading.value = false + + !state.value.isTranslating && hasTranslatedText && loaded + } + + CustomBottomSheet( + onDismissRequest = { + onEvent(ReaderEvent.OnShowHideTranslatorBottomSheet()) + } + ) { + LazyColumn(Modifier.fillMaxWidth()) { + item { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp) + .clip(RoundedCornerShape(25.dp)) + .background( + if (book.enableTranslator) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.outline, + RoundedCornerShape(25.dp) + ) + .clickable { + if (book.enableTranslator) { + onEvent(ReaderEvent.OnCancelTranslation) + } + + onEvent( + ReaderEvent.OnChangeTranslatorSettings( + enableTranslator = !book.enableTranslator + ) + ) + } + .padding(horizontal = 15.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(id = R.string.translator_reader_settings), + color = if (book.enableTranslator) MaterialTheme.colorScheme.onPrimary + else MaterialTheme.colorScheme.surface, + fontSize = 17.5.sp + ) + Switch( + checked = book.enableTranslator, + onCheckedChange = null, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.primary, + checkedTrackColor = MaterialTheme.colorScheme.secondaryContainer, + uncheckedBorderColor = MaterialTheme.colorScheme.surfaceContainerHighest + ) + ) + } + } + + item { + Spacer(modifier = Modifier.height(12.dp)) + } + + item { + ReaderTranslatorBottomSheetLanguageChooser( + fromLanguage = translateFrom, + toLanguage = translateTo, + onFromClick = { + onEvent( + ReaderEvent.OnShowHideLanguageBottomSheet( + show = true, + translateFrom = true + ) + ) + }, + onToClick = { + onEvent( + ReaderEvent.OnShowHideLanguageBottomSheet( + show = true, + translateFrom = false + ) + ) + }, + enabled = !state.value.isTranslating, + switchEnabled = book.translateFrom != "auto", + onSwitchClick = { + onEvent( + ReaderEvent.OnChangeTranslatorSettings( + translateFrom = book.translateTo, + translateTo = book.translateFrom + ) + ) + } + ) + } + + item { + Spacer(modifier = Modifier.height(6.dp)) + } + + item { + SwitchWithTitle( + selected = book.doubleClickTranslation, + title = stringResource(id = R.string.translator_double_click_to_translate_option), + description = stringResource(id = R.string.translator_double_click_to_translate_option_desc), + verticalPadding = 6.dp, + onClick = { + onEvent( + ReaderEvent.OnChangeTranslatorSettings( + doubleClickTranslation = !book.doubleClickTranslation + ) + ) + } + ) + } + + item { + SwitchWithTitle( + selected = book.translateWhenOpen, + title = stringResource(id = R.string.translator_translate_on_start_option), + description = stringResource(id = R.string.translator_translate_on_start_option_desc), + verticalPadding = 6.dp, + onClick = { + onEvent( + ReaderEvent.OnChangeTranslatorSettings( + translateWhenOpen = !book.translateWhenOpen + ) + ) + } + ) + } + + item { + Spacer(modifier = Modifier.height(48.dp)) + } + + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp), + contentAlignment = Alignment.CenterEnd + ) { + Button( + modifier = Modifier.fillMaxWidth(1f), + onClick = { + if (loading.value) { + return@Button + } + + if (!state.value.isTranslating) { + if (!textTranslated) { + Toast.makeText( + context, + context.getString(R.string.translator_starting), + Toast.LENGTH_SHORT + ).show() + onEvent( + ReaderEvent.OnTranslateText( + keys = null, /* means translating the whole text */ + error = { + Toast.makeText( + context, + it.asString(context), + Toast.LENGTH_LONG + ).show() + } + ) + ) + } else { + Toast.makeText( + context, + context.getString(R.string.translator_text_canceling), + Toast.LENGTH_SHORT + ).show() + onEvent( + ReaderEvent.OnUndoTranslation( + null /* means canceling the whole text translation */ + ) + ) + } + } else { + Toast.makeText( + context, + context.getString(R.string.translator_canceling), + Toast.LENGTH_SHORT + ).show() + + loading.value = true + onEvent( + ReaderEvent.OnCancelTranslation + ) + } + }, + colors = ButtonDefaults.buttonColors( + containerColor = if (state.value.isTranslating || textTranslated) { + MaterialTheme.colorScheme.secondary + } else { + MaterialTheme.colorScheme.primary + }, + contentColor = contentColorFor( + if (state.value.isTranslating || textTranslated) { + MaterialTheme.colorScheme.secondary + } else { + MaterialTheme.colorScheme.primary + } + ) + ) + ) { + if (!state.value.isTranslating && !loading.value) { + Text( + text = if (textTranslated) stringResource( + id = R.string.undo_translation + ) else stringResource(id = R.string.translate) + ) + } else { + CircularProgressIndicator( + color = LocalContentColor.current, + strokeCap = StrokeCap.Round, + modifier = Modifier.size(21.dp), + strokeWidth = 2.3.dp + ) + } + } + } + } + + item { + Spacer( + modifier = Modifier.height( + 8.dp + it + ) + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/translator_bottom_sheet/ReaderTranslatorBottomSheetLanguageChooser.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/translator_bottom_sheet/ReaderTranslatorBottomSheetLanguageChooser.kt new file mode 100644 index 00000000..192b2ac4 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/components/translator_bottom_sheet/ReaderTranslatorBottomSheetLanguageChooser.kt @@ -0,0 +1,191 @@ +package ua.acclorite.book_story.presentation.screens.reader.components.translator_bottom_sheet + +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.CompareArrows +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.yield +import ua.acclorite.book_story.R +import ua.acclorite.book_story.presentation.components.CustomAnimatedVisibility +import ua.acclorite.book_story.presentation.components.CustomIconButton + +/** + * Reader Translator BottomSheet Language Chooser. + * + * @param fromLanguage Current translate from language. + * @param toLanguage Current translate to language. + * @param enabled Whether it is enabled. + * @param switchEnabled Whether switch languages button is enabled. + * @param horizontalPadding Horizontal item padding. + * @param onFromClick OnFromClick callback. + * @param onToClick OnToClick callback. + * @param onSwitchClick OnSwitchClick callback. + */ +@Composable +fun ReaderTranslatorBottomSheetLanguageChooser( + fromLanguage: String, + toLanguage: String, + enabled: Boolean = true, + switchEnabled: Boolean = true, + horizontalPadding: Dp = 18.dp, + onFromClick: () -> Unit, + onToClick: () -> Unit, + onSwitchClick: () -> Unit +) { + val switchLanguage = remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + var job: Job? = null + + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = horizontalPadding), + verticalAlignment = Alignment.CenterVertically + ) { + Box(modifier = Modifier.weight(1f)) { + CustomLanguageSwitchingAnimation( + visible = switchLanguage.value, + rightPlacement = false + ) { + LanguageItem(text = fromLanguage, enabled = enabled) { + onFromClick() + } + } + + CustomLanguageSwitchingAnimation( + visible = !switchLanguage.value, + rightPlacement = false + ) { + LanguageItem(text = fromLanguage, enabled = enabled) { + onFromClick() + } + } + } + + + + Spacer(modifier = Modifier.width(4.dp)) + CustomIconButton( + icon = Icons.AutoMirrored.Filled.CompareArrows, + contentDescription = R.string.translator_language_content_desc, + disableOnClick = false, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), + enabled = enabled && switchEnabled, + onClick = { + job?.cancel() + job = scope.launch { + switchLanguage.value = !switchLanguage.value + + delay(100) + yield() + + onSwitchClick() + } + } + ) + Spacer(modifier = Modifier.width(4.dp)) + + Box(modifier = Modifier.weight(1f)) { + CustomLanguageSwitchingAnimation( + visible = switchLanguage.value, + rightPlacement = true + ) { + LanguageItem(text = toLanguage, enabled = enabled) { + onToClick() + } + } + + CustomLanguageSwitchingAnimation( + visible = !switchLanguage.value, + rightPlacement = true + ) { + LanguageItem(text = toLanguage, enabled = enabled) { + onToClick() + } + } + } + } +} + +@Composable +private fun CustomLanguageSwitchingAnimation( + visible: Boolean, + rightPlacement: Boolean, + content: @Composable () -> Unit +) { + val exitRightAnimation = remember { + fadeOut(tween(200)) + + slideOutHorizontally(tween(250)) { -it / 2 } + + scaleOut(tween(250), 0.4f) + } + val enterRightAnimation = remember { + slideInHorizontally(tween(350)) { -it / 2 } + fadeIn(tween(200)) + } + + val exitLeftAnimation = remember { + fadeOut(tween(200)) + + slideOutHorizontally(tween(250)) { it / 2 } + + scaleOut(tween(250), 0.4f) + } + val enterLeftAnimation = remember { + slideInHorizontally(tween(350)) { it / 2 } + fadeIn(tween(200)) + } + + CustomAnimatedVisibility( + visible = visible, + enter = if (rightPlacement) enterRightAnimation else enterLeftAnimation, + exit = if (rightPlacement) exitRightAnimation else exitLeftAnimation + ) { + content() + } +} + +@Composable +private fun LanguageItem(text: String, enabled: Boolean, onClick: () -> Unit) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.large) + .background(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.shapes.large) + .clickable(enabled = enabled) { + onClick() + } + .padding(horizontal = 4.dp, vertical = 14.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = text, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1 + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderEvent.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderEvent.kt index b42b731b..4ea7e720 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderEvent.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderEvent.kt @@ -1,29 +1,70 @@ package ua.acclorite.book_story.presentation.screens.reader.data import androidx.activity.ComponentActivity -import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.pager.PagerState import androidx.compose.runtime.Immutable import ua.acclorite.book_story.domain.model.Book +import ua.acclorite.book_story.domain.model.LineWithTranslation +import ua.acclorite.book_story.domain.model.SelectableLanguage +import ua.acclorite.book_story.domain.util.ID +import ua.acclorite.book_story.domain.util.LanguageCode +import ua.acclorite.book_story.domain.util.UIText import ua.acclorite.book_story.presentation.data.Navigator @Immutable sealed class ReaderEvent { data class OnTextIsEmpty(val onLoaded: () -> Unit) : ReaderEvent() data class OnLoadText( - val listState: LazyListState, val refreshList: (Book) -> Unit, val onLoaded: () -> Unit, + val onError: (UIText) -> Unit, val onTextIsEmpty: () -> Unit ) : ReaderEvent() data class OnShowHideMenu(val show: Boolean? = null, val context: ComponentActivity) : ReaderEvent() + data class OnShowHideTranslatorBottomSheet(val show: Boolean? = null) : ReaderEvent() + + data class OnChangeTranslatorSettings( + val enableTranslator: Boolean? = null, + val translateFrom: String? = null, + val translateTo: String? = null, + val doubleClickTranslation: Boolean? = null, + val translateWhenOpen: Boolean? = null + ) : ReaderEvent() + + data class OnShowHideLanguageBottomSheet( + val show: Boolean? = null, + val translateFrom: Boolean? = null + ) : ReaderEvent() + + data class OnTranslateText( + val keys: Set?, + val error: suspend (UIText) -> Unit + ) : ReaderEvent() + + data object OnDismissDownloadLanguageDialog : ReaderEvent() + + data object OnCancelTranslation : ReaderEvent() + + data class OnUndoTranslation(val id: ID?) : ReaderEvent() + + data class OnDownloadLanguages( + val onSuccess: suspend (Map) -> Unit, + val error: suspend (UIText) -> Unit + ) : ReaderEvent() + + data class OnSelectLanguage(val language: SelectableLanguage) : ReaderEvent() + + data class OnUpdateLanguage( + val languageCode: LanguageCode, + val calculation: (SelectableLanguage) -> SelectableLanguage + ) : ReaderEvent() + data class OnGoBack( val context: ComponentActivity, val navigator: Navigator, - val listState: LazyListState, val refreshList: (Book) -> Unit, val navigate: (Navigator) -> Unit ) : ReaderEvent() @@ -36,18 +77,17 @@ sealed class ReaderEvent { val refreshList: (Book) -> Unit ) : ReaderEvent() - data class OnScroll(val listState: LazyListState, val progress: Float) : ReaderEvent() + data class OnScroll(val progress: Float) : ReaderEvent() data object OnShowHideSettingsBottomSheet : ReaderEvent() data class OnScrollToSettingsPage(val page: Int, val pagerState: PagerState) : ReaderEvent() data class OnMoveBookToAlreadyRead( val context: ComponentActivity, - val listState: LazyListState, val onUpdateCategories: (Book) -> Unit, val updatePage: (Int) -> Unit, val navigator: Navigator ) : ReaderEvent() - data class OnTranslateText( + data class OnOpenTranslator( val textToTranslate: String, val context: ComponentActivity, val noAppsFound: () -> Unit diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderState.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderState.kt index e0846923..5b190ebc 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderState.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderState.kt @@ -1,23 +1,35 @@ package ua.acclorite.book_story.presentation.screens.reader.data +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Immutable import ua.acclorite.book_story.domain.model.Book -import ua.acclorite.book_story.domain.model.StringWithId +import ua.acclorite.book_story.domain.model.LineWithTranslation +import ua.acclorite.book_story.domain.model.SelectableLanguage import ua.acclorite.book_story.domain.util.Constants +import ua.acclorite.book_story.domain.util.ID import ua.acclorite.book_story.domain.util.UIText @Immutable data class ReaderState( val book: Book = Constants.EMPTY_BOOK, - val text: List = emptyList(), + val text: Map = emptyMap(), val words: Int = 0, val letters: Int = 0, + val listState: LazyListState = LazyListState(), + + val languagesToTranslate: List = emptyList(), + val showDownloadLanguageDialog: Boolean = false, + val isTranslating: Boolean = false, val errorMessage: UIText? = null, val showMenu: Boolean = false, val lockMenu: Boolean = false, + val showTranslatorBottomSheet: Boolean = false, + val showLanguageBottomSheet: Boolean = false, + val languageBottomSheetTranslateFrom: Boolean = false, + val showSettingsBottomSheet: Boolean = false, val currentPage: Int = 0, ) \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderViewModel.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderViewModel.kt index 2ee02f20..4533c717 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderViewModel.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/reader/data/ReaderViewModel.kt @@ -4,7 +4,6 @@ import android.app.SearchManager import android.content.Intent import android.util.Log import androidx.activity.ComponentActivity -import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.snapshotFlow import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat @@ -13,37 +12,72 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.yield import ua.acclorite.book_story.R import ua.acclorite.book_story.domain.model.Book import ua.acclorite.book_story.domain.model.Category +import ua.acclorite.book_story.domain.model.LineWithTranslation +import ua.acclorite.book_story.domain.model.SelectableLanguage +import ua.acclorite.book_story.domain.use_case.DeleteLanguageModel +import ua.acclorite.book_story.domain.use_case.DownloadLanguageModel import ua.acclorite.book_story.domain.use_case.GetBooksById import ua.acclorite.book_story.domain.use_case.GetLatestHistory import ua.acclorite.book_story.domain.use_case.GetText +import ua.acclorite.book_story.domain.use_case.IdentifyLanguage +import ua.acclorite.book_story.domain.use_case.IsLanguageModelDownloaded +import ua.acclorite.book_story.domain.use_case.TranslateText import ua.acclorite.book_story.domain.use_case.UpdateBooks +import ua.acclorite.book_story.domain.util.ID +import ua.acclorite.book_story.domain.util.LanguageCode +import ua.acclorite.book_story.domain.util.Resource import ua.acclorite.book_story.domain.util.UIText import ua.acclorite.book_story.presentation.data.Argument import ua.acclorite.book_story.presentation.data.Navigator import ua.acclorite.book_story.presentation.data.Screen +import ua.acclorite.book_story.presentation.data.removeKey +import ua.acclorite.book_story.presentation.data.removeKeys +import ua.acclorite.book_story.presentation.data.update +import ua.acclorite.book_story.presentation.data.updateWithCopy +import ua.acclorite.book_story.presentation.screens.history.data.HistoryEvent +import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent +import java.util.Locale import javax.inject.Inject import kotlin.math.roundToInt +@Suppress("LABEL_NAME_CLASH") @HiltViewModel class ReaderViewModel @Inject constructor( private val updateBooks: UpdateBooks, private val getText: GetText, private val getLatestHistory: GetLatestHistory, - private val getBooksById: GetBooksById + private val getBooksById: GetBooksById, + private val identifyLanguage: IdentifyLanguage, + private val isLanguageModelDownloaded: IsLanguageModelDownloaded, + private val downloadLanguageModel: DownloadLanguageModel, + private val deleteLanguageModel: DeleteLanguageModel, + private val translateText: TranslateText, ) : ViewModel() { private val _state = MutableStateFlow(ReaderState()) val state = _state.asStateFlow() + private var dismissTranslatorJob: Job? = null + private var cancelTranslationJob: Job? = null + private var translatorJob: Job? = null + private var undoTranslationJob: Job? = null + private var downloadLanguageJob: Job? = null + fun onEvent(event: ReaderEvent) { when (event) { is ReaderEvent.OnTextIsEmpty -> { @@ -56,90 +90,106 @@ class ReaderViewModel @Inject constructor( } is ReaderEvent.OnLoadText -> { - viewModelScope.launch(Dispatchers.IO) { - val text = getText.execute(_state.value.book.textPath) + viewModelScope.launch { + cancelJobs() + joinJobs() - if (text.isEmpty()) { - event.onTextIsEmpty() - } + launch(Dispatchers.IO) { + val text = getText.execute(_state.value.book.textPath) - val textAsLine = text.joinToString( - separator = "\n", - transform = { - it.line + if (text.isEmpty()) { + event.onTextIsEmpty() } - ) - val letters = textAsLine - .replace("\n", "") - .length - val words = textAsLine - .replace("\n", " ") - .split("\\s+".toRegex()) - .size - - _state.update { - it.copy( - text = text, - letters = letters, - words = words - ) - } - - val history = _state.value.book.id.let { - getLatestHistory.execute( - it - ) - } - - _state.update { - it.copy( - book = it.book.copy( - lastOpened = history?.time, + val textAsLine = text + .map { it.value.originalLine } + .joinToString( + separator = "\n" ) + + val letters = textAsLine + .replace("\n", "") + .length + val words = textAsLine + .replace("\n", " ") + .split("\\s+".toRegex()) + .size + + _state.update { + it.copy( + text = text, + letters = letters, + words = words + ) + } + + val history = _state.value.book.id.let { + getLatestHistory.execute( + it + ) + } + + _state.update { + it.copy( + book = it.book.copy( + lastOpened = history?.time, + ) + ) + } + + updateBooks.execute( + listOf(_state.value.book) ) - } + event.refreshList(_state.value.book) - updateBooks.execute( - listOf(_state.value.book) - ) - event.refreshList(_state.value.book) + viewModelScope.launch { + snapshotFlow { + _state.value.listState.layoutInfo.totalItemsCount + }.collectLatest { itemsCount -> + val index = _state.value.book.scrollIndex + val offset = _state.value.book.scrollOffset - viewModelScope.launch { - snapshotFlow { - event.listState.layoutInfo.totalItemsCount - }.collectLatest { itemsCount -> - val index = _state.value.book.scrollIndex - val offset = _state.value.book.scrollOffset + if (itemsCount >= _state.value.text.size) { + if (index > 0 || offset > 0) { + var loaded = false + for (i in 1..100) { + try { + _state.value.listState.scrollToItem( + index, + offset + ) + loaded = true + break + } catch (e: Exception) { + Log.w( + "READER", + "Couldn't scroll to desired index and offset" + ) + delay(100) + } + } - if (itemsCount >= _state.value.text.size) { - if (index > 0 || offset > 0) { - var loaded = false - for (i in 1..100) { - try { - event.listState.scrollToItem( - index, - offset - ) - loaded = true - break - } catch (e: Exception) { - Log.w( - "READER", - "Couldn't scroll to desired index and offset" - ) - delay(100) + if (!loaded) { + event.onTextIsEmpty() } } - if (!loaded) { - event.onTextIsEmpty() - } - } + delay(100) + event.onLoaded() - delay(100) - event.onLoaded() - return@collectLatest + if ( + _state.value.book.translateWhenOpen && + _state.value.book.enableTranslator + ) { + onEvent( + ReaderEvent.OnTranslateText(null) { + event.onError(it) + } + ) + } + + return@collectLatest + } } } } @@ -176,8 +226,648 @@ class ReaderViewModel @Inject constructor( } } + is ReaderEvent.OnShowHideTranslatorBottomSheet -> { + _state.update { + it.copy( + showTranslatorBottomSheet = event.show ?: !it.showTranslatorBottomSheet + ) + } + } + + is ReaderEvent.OnChangeTranslatorSettings -> { + viewModelScope.launch(Dispatchers.IO) { + val book = _state.value.book + val updatedBook = book.copy( + enableTranslator = event.enableTranslator ?: book.enableTranslator, + translateFrom = event.translateFrom ?: book.translateFrom, + translateTo = event.translateTo ?: book.translateTo, + doubleClickTranslation = event.doubleClickTranslation + ?: book.doubleClickTranslation, + translateWhenOpen = event.translateWhenOpen ?: book.translateWhenOpen + ) + + _state.update { + it.copy( + book = updatedBook + ) + } + updateBooks.execute(listOf(updatedBook)) + } + } + + is ReaderEvent.OnShowHideLanguageBottomSheet -> { + _state.update { + it.copy( + showLanguageBottomSheet = event.show ?: !it.showLanguageBottomSheet, + languageBottomSheetTranslateFrom = event.translateFrom ?: false + ) + } + } + + is ReaderEvent.OnTranslateText -> { + _state.update { + it.copy( + isTranslating = true + ) + } + + viewModelScope.launch { + joinJobs() + + translatorJob = launch(Dispatchers.IO) { + onEvent( + ReaderEvent.OnChangeTranslatorSettings( + enableTranslator = true + ) + ) + + val keys = event.keys ?: _state.value.text.keys + var linesToTranslate = if (keys.size >= _state.value.text.size) { + _state.value.text + } else { + _state.value.text.filterKeys { keys.any { key -> key == it } } + } + + val languages = mutableMapOf?>() + + yield() + linesToTranslate = linesToTranslate.updateWithCopy { + it.copy( + isTranslationLoading = true, + isTranslationFailed = false, + errorMessage = null, + useTranslation = false + ) + } + + yield() + onUpdateTextLines( + linesToTranslate, + wholeText = linesToTranslate.size >= _state.value.text.size + ) + + yield() + if (_state.value.book.translateFrom == "auto") { + val identifiedLanguages = mutableListOf>() + val errors = mutableMapOf() + + for (line in linesToTranslate.toMap()) { + yield() + val result = identifyLanguage.execute(line.value.originalLine) + + when (result) { + is Resource.Success -> { + identifiedLanguages.add( + result.data!! to line.key + ) + } + + is Resource.Error -> { + errors[line.key] = line.value.copy( + isTranslationFailed = true, + isTranslationLoading = false, + errorMessage = result.message + ) + } + } + } + + yield() + if (errors.isNotEmpty()) { + onUpdateTextLines( + errors, + errors.size >= _state.value.text.size + ) + linesToTranslate = linesToTranslate.removeKeys(errors.keys) + } + + yield() + languages.putAll( + identifiedLanguages + .groupBy { + it.first + }.mapValues { (_, value) -> + value.map { it.second } + } + ) + } else { + languages[_state.value.book.translateFrom] = emptyList() + } + + yield() + if (languages.isEmpty()) { + _state.update { + it.copy( + isTranslating = false + ) + } + + yield() + launch(Dispatchers.Main) { + event.error(UIText.StringResource(R.string.error_languages_not_identified)) + } + return@launch + } + + var shouldShowDialog = false + + if (languages.any { !isLanguageModelDownloaded.execute(it.key) }) { + shouldShowDialog = true + } + + if (!isLanguageModelDownloaded.execute(_state.value.book.translateTo)) { + shouldShowDialog = true + } + + yield() + if (shouldShowDialog) { + val languagesToDownload = mutableListOf() + + val translateToLocale = Locale(_state.value.book.translateTo) + val translateToDisplayLanguage = translateToLocale + .getDisplayLanguage(translateToLocale) + .replaceFirstChar { + it.uppercase() + } + + val translateToCanceled = _state.value.languagesToTranslate.find { + it.languageCode == _state.value.book.translateTo + }?.isCanceled ?: false + + languagesToDownload.add( + SelectableLanguage( + languageCode = _state.value.book.translateTo, + displayLanguage = translateToDisplayLanguage, + isDownloading = false, + isCanceled = translateToCanceled, + isError = false, + isDownloaded = isLanguageModelDownloaded.execute(_state.value.book.translateTo), + isSelected = true, + canUnselect = false, + occurrences = languages[_state.value.book.translateTo] + ) + ) + languagesToDownload.addAll( + languages + .filterNot { it.key == _state.value.book.translateTo } + .toList() + .sortedByDescending { it.second?.size } + .mapIndexed { index, language -> + val locale = Locale(language.first) + val displayLanguage = locale + .getDisplayLanguage(locale) + .replaceFirstChar { + it.uppercase() + } + + val isCanceled = _state.value.languagesToTranslate.find { + it.languageCode == language.first + }?.isCanceled ?: false + + SelectableLanguage( + languageCode = language.first, + displayLanguage = displayLanguage, + isDownloading = false, + isCanceled = isCanceled, + isDownloaded = isLanguageModelDownloaded.execute( + language.first + ), + isError = false, + isSelected = index == 0, + canUnselect = index > 0, + occurrences = language.second + ) + } + ) + + yield() + _state.update { + it.copy( + languagesToTranslate = languagesToDownload, + showDownloadLanguageDialog = true + ) + } + return@launch + } + + yield() + for (i in 0.. result.data!! + is Resource.Error -> continue + } + } else { + _state.value.book.translateFrom + } + val targetLanguage = _state.value.book.translateTo + + yield() + if ( + line.translatingFrom == sourceLanguage && + line.translatingTo == targetLanguage && + line.translatedLine != null + ) { + val translatedLine = line.copy( + useTranslation = true, + isTranslationFailed = false, + isTranslationLoading = false, + errorMessage = null + ) + + yield() + _state.update { + it.copy( + text = it.text.update( + closestLine to translatedLine + ) + ) + } + linesToTranslate = linesToTranslate.removeKey(closestLine) + continue + } + + var translation: String? = null + var error: UIText? = null + + yield() + val result = translateText.execute( + sourceLanguage = sourceLanguage, + targetLanguage = targetLanguage, + text = line.originalLine + ) + + when (result) { + is Resource.Success -> { + translation = result.data + } + + is Resource.Error -> { + error = result.message + } + } + + val translatedLine = line.copy( + useTranslation = translation != null, + translatedLine = translation, + translatingFrom = sourceLanguage, + translatingTo = targetLanguage, + isTranslationFailed = translation == null, + isTranslationLoading = false, + errorMessage = error + ) + + yield() + _state.update { + it.copy( + text = it.text.update( + closestLine to translatedLine + ) + ) + } + linesToTranslate = linesToTranslate.removeKey(closestLine) + } + + yield() + _state.update { + it.copy( + isTranslating = false + ) + } + } + } + } + + is ReaderEvent.OnDismissDownloadLanguageDialog -> { + _state.update { + it.copy( + showDownloadLanguageDialog = false, + isTranslating = false + ) + } + + viewModelScope.launch { + joinJobs() + + dismissTranslatorJob = launch(Dispatchers.IO) { + val languagesToDownload = _state.value.languagesToTranslate + + yield() + _state.update { + it.copy( + languagesToTranslate = it.languagesToTranslate.map { lang -> + lang.copy( + isCanceled = true, + isDownloading = false, + isError = false + ) + }, + showDownloadLanguageDialog = false + ) + } + + yield() + + downloadLanguageJob?.cancel() + val lines = getLinesFromLanguages(languagesToDownload) + onUpdateTextLines( + lines.updateWithCopy { + it.copy( + isTranslationLoading = false, + useTranslation = false + ) + }, + wholeText = lines.size >= _state.value.text.size + ) + } + } + } + + is ReaderEvent.OnCancelTranslation -> { + viewModelScope.launch { + cancelTranslationJob?.join() + undoTranslationJob?.join() + dismissTranslatorJob?.cancel() + translatorJob?.cancel() + + cancelTranslationJob = launch(Dispatchers.IO) { + _state.update { + it.copy( + isTranslating = false, + showDownloadLanguageDialog = false + ) + } + + yield() + onUpdateTextLines( + _state.value.text.updateWithCopy { + if (it.useTranslation) { + return@updateWithCopy it + } + + it.copy( + isTranslationLoading = false, + useTranslation = false + ) + }, + wholeText = true + ) + } + } + } + + is ReaderEvent.OnUndoTranslation -> { + viewModelScope.launch { + cancelTranslationJob?.join() + undoTranslationJob?.join() + + cancelTranslationJob = launch(Dispatchers.IO) { + yield() + + if (event.id != null) { + _state.update { + val line = it.text[event.id]?.copy( + useTranslation = false, + isTranslationLoading = false + ) + + if (line == null) { + return@update it + } + + it.copy( + text = it.text.update(event.id to line) + ) + } + } else { + _state.update { + it.copy( + text = it.text.updateWithCopy { line -> + if (!line.useTranslation) { + return@updateWithCopy line + } + + line.copy( + isTranslationLoading = false, + useTranslation = false + ) + } + ) + } + } + } + } + } + + is ReaderEvent.OnDownloadLanguages -> { + viewModelScope.launch { + downloadLanguageJob?.cancel() + + downloadLanguageJob = launch(Dispatchers.IO) { + _state.update { + it.copy( + languagesToTranslate = it.languagesToTranslate.map { lang -> + val isDownloaded = isLanguageModelDownloaded.execute( + lang.languageCode + ) + + lang.copy( + isDownloading = !isDownloaded && lang.isSelected, + isCanceled = false, + isDownloaded = isDownloaded + ) + } + ) + } + + val shouldDownload = _state.value.languagesToTranslate.filter { + it.isSelected + }.any { + it.isDownloading + } + + if (!shouldDownload) { + _state.update { + it.copy( + languagesToTranslate = it.languagesToTranslate.filter { lang -> + lang.isDownloaded + }, + showDownloadLanguageDialog = false + ) + } + + onUnselectLines() + launch(Dispatchers.Main) { + event.onSuccess(getLinesFromLanguages(_state.value.languagesToTranslate)) + } + return@launch + } + + val languagesToDownload = _state.value.languagesToTranslate.filter { + it.isSelected + } + val jobs = languagesToDownload.map { lang -> + async { + downloadLanguageModel.execute( + languageCode = lang.languageCode, + onCompleted = { + launch(Dispatchers.IO) { + val modelDownloaded = isLanguageModelDownloaded.execute( + lang.languageCode + ) + + if (!modelDownloaded) { + onEvent( + ReaderEvent.OnUpdateLanguage(lang.languageCode) { + it.copy( + isError = true, + isDownloading = false, + isDownloaded = false, + errorMessage = UIText.StringResource( + R.string.error_could_not_download_language + ) + ) + } + ) + return@launch + } + + onEvent( + ReaderEvent.OnUpdateLanguage(lang.languageCode) { + it.copy( + isDownloading = false, + isDownloaded = true + ) + } + ) + + val isCanceled = + _state.value.languagesToTranslate.find { + it.languageCode == lang.languageCode + }?.isCanceled ?: true + + if (isCanceled) { + deleteLanguageModel.execute( + languageCode = lang.languageCode + ) + onEvent( + ReaderEvent.OnUpdateLanguage(lang.languageCode) { + it.copy( + isDownloading = false, + isDownloaded = false, + isCanceled = false + ) + } + ) + return@launch + } + } + }, + onFailure = { error -> + onEvent( + ReaderEvent.OnUpdateLanguage(lang.languageCode) { + it.copy( + isError = true, + isDownloading = false, + isDownloaded = false, + errorMessage = UIText.StringResource( + R.string.error_query, + error.message ?: "" + ) + ) + } + ) + } + ) + } + } + jobs.awaitAll() + + yield() + + _state.update { + it.copy( + languagesToTranslate = it.languagesToTranslate.filter { lang -> + lang.isDownloaded + }, + showDownloadLanguageDialog = false + ) + } + + if (languagesToDownload.isEmpty()) { + yield() + launch(Dispatchers.Main) { + event.error( + UIText.StringResource( + R.string.error_could_not_download_languages + ) + ) + } + onEvent(ReaderEvent.OnDismissDownloadLanguageDialog) + return@launch + } + + yield() + + onUnselectLines() + launch(Dispatchers.Main) { + event.onSuccess(getLinesFromLanguages(_state.value.languagesToTranslate)) + } + } + } + } + + is ReaderEvent.OnSelectLanguage -> { + viewModelScope.launch(Dispatchers.IO) { + val editedList = _state.value.languagesToTranslate.map { + if (it.languageCode == event.language.languageCode) { + it.copy( + isSelected = !it.isSelected + ) + } else { + it + } + } + + _state.update { + it.copy( + languagesToTranslate = editedList + ) + } + } + } + + is ReaderEvent.OnUpdateLanguage -> { + viewModelScope.launch(Dispatchers.IO) { + val editedList = _state.value.languagesToTranslate.map { + if (it.languageCode == event.languageCode) { + event.calculation(it) + } else { + it + } + } + + _state.update { + it.copy( + languagesToTranslate = editedList + ) + } + } + } + is ReaderEvent.OnGoBack -> { viewModelScope.launch { + cancelJobs() _state.update { it.copy( lockMenu = true @@ -189,17 +879,18 @@ class ReaderViewModel @Inject constructor( event.context.window.decorView ) - if (event.listState.layoutInfo.totalItemsCount > 0) { - val firstVisibleItemIndex = event.listState.firstVisibleItemIndex - val firstVisibleItemOffset = event.listState.firstVisibleItemScrollOffset - val lastVisibleItemIndex = event.listState.layoutInfo + if (_state.value.listState.layoutInfo.totalItemsCount > 0) { + val firstVisibleItemIndex = _state.value.listState.firstVisibleItemIndex + val firstVisibleItemOffset = + _state.value.listState.firstVisibleItemScrollOffset + val lastVisibleItemIndex = _state.value.listState.layoutInfo .visibleItemsInfo.last().index val progress = if (firstVisibleItemIndex > 0) { - if (lastVisibleItemIndex >= (event.listState.layoutInfo.totalItemsCount - 1)) { + if (lastVisibleItemIndex >= (_state.value.listState.layoutInfo.totalItemsCount - 1)) { 1f } else { - (firstVisibleItemIndex.toFloat() / (_state.value.text.lastIndex) + (firstVisibleItemIndex.toFloat() / (_state.value.text.size - 1) .toFloat()) } } else { @@ -224,7 +915,6 @@ class ReaderViewModel @Inject constructor( Argument("book", _state.value.book.id) ) event.refreshList(_state.value.book) - insetsController.show(WindowInsetsCompat.Type.systemBars()) event.navigate(event.navigator) } @@ -234,7 +924,7 @@ class ReaderViewModel @Inject constructor( viewModelScope.launch { val scrollTo = (_state.value.text.size * event.progress).roundToInt() - event.listState.scrollToItem( + _state.value.listState.scrollToItem( scrollTo ) } @@ -288,14 +978,13 @@ class ReaderViewModel @Inject constructor( ReaderEvent.OnGoBack( event.context, event.navigator, - event.listState, refreshList = {}, navigate = {} ) ) - val firstVisibleItemIndex = event.listState.firstVisibleItemIndex - val firstVisibleItemOffset = event.listState.firstVisibleItemScrollOffset + val firstVisibleItemIndex = _state.value.listState.firstVisibleItemIndex + val firstVisibleItemOffset = _state.value.listState.firstVisibleItemScrollOffset _state.update { it.copy( @@ -317,11 +1006,14 @@ class ReaderViewModel @Inject constructor( it != Category.ALREADY_READ }.size - 1 ) + + cancelJobs() + event.navigator.navigate(Screen.LIBRARY, true) } } - is ReaderEvent.OnTranslateText -> { + is ReaderEvent.OnOpenTranslator -> { viewModelScope.launch { val translatorIntent = Intent() val browserIntent = Intent() @@ -375,11 +1067,188 @@ class ReaderViewModel @Inject constructor( } } + private fun cancelJobs() { + cancelTranslationJob?.cancel() + translatorJob?.cancel() + dismissTranslatorJob?.cancel() + undoTranslationJob?.cancel() + downloadLanguageJob?.cancel() + } + + private suspend fun joinJobs() { + cancelTranslationJob?.join() + translatorJob?.join() + dismissTranslatorJob?.join() + undoTranslationJob?.join() + } + + + private suspend fun findClosestLineToTranslate( + translatingLines: Map + ): ID? { + val lines = translatingLines.toMutableMap() + val visibleItems = _state.value.listState.layoutInfo.visibleItemsInfo + val listStateIndex = (visibleItems.getOrNull(visibleItems.size / 2)?.index ?: 0) - 1 + .coerceAtLeast(0) + + var closestLineIndex: Int? = null + var searchUpperLine = false + var currentLineIndex = listStateIndex + val invalidIndices = mutableListOf() + + while (true) { + yield() + + if (lines.isEmpty()) { + break + } + + currentLineIndex = currentLineIndex.coerceIn(0..lines.maxOf { it.key }) + + if (lines[currentLineIndex] == null) { + invalidIndices.add(currentLineIndex) + + if (searchUpperLine) { + val invalid = invalidIndices.filterNot { + it >= currentLineIndex + } + var minus = 1 + + while (true) { + if (invalid.any { currentLineIndex - minus == it }) { + minus++ + continue + } + break + } + + currentLineIndex -= minus + } else { + val invalid = invalidIndices.filterNot { + it <= currentLineIndex + } + var plus = 1 + + while (true) { + if (invalid.any { currentLineIndex + plus == it }) { + plus++ + continue + } + break + } + + currentLineIndex += plus + } + searchUpperLine = !searchUpperLine + continue + } + + if ( + lines[currentLineIndex]?.useTranslation == true || + lines[currentLineIndex]?.isTranslationFailed == true + ) { + invalidIndices.add(currentLineIndex) + lines.remove(currentLineIndex) + + if (searchUpperLine) { + val invalid = invalidIndices.filterNot { + it >= currentLineIndex + } + var minus = 1 + + while (true) { + if (invalid.any { currentLineIndex - minus == it }) { + minus++ + continue + } + break + } + + currentLineIndex -= minus + } else { + val invalid = invalidIndices.filterNot { + it <= currentLineIndex + } + var plus = 1 + + while (true) { + if (invalid.any { currentLineIndex + plus == it }) { + plus++ + continue + } + break + } + + currentLineIndex += plus + } + searchUpperLine = !searchUpperLine + continue + } + + closestLineIndex = currentLineIndex + break + } + + yield() + return closestLineIndex + } + + private suspend fun onUpdateTextLines( + newLines: Map, + wholeText: Boolean + ) { + val newText = if (wholeText) newLines + else _state.value.text.update(newLines) + + yield() + _state.update { + it.copy( + text = newText + ) + } + } + + private suspend fun onUnselectLines() { + onUpdateTextLines( + _state.value.text.updateWithCopy { + it.copy(isTranslationLoading = false) + }, + wholeText = true + ) + } + + private suspend fun getLinesFromLanguages( + languages: List + ): Map { + val lines = mutableMapOf() + + yield() + for (language in languages) { + yield() + + if (language.occurrences == null) { + continue + } + + if (language.occurrences.isEmpty()) { + lines += _state.value.text + break + } + + lines += language.occurrences.associateWith { + _state.value.text[it]!! + } + } + yield() + + return lines + } + fun init( navigator: Navigator, context: ComponentActivity, - listState: LazyListState, refreshList: (Book) -> Unit, + onError: (UIText) -> Unit, onLoaded: () -> Unit ) { viewModelScope.launch(Dispatchers.IO) { @@ -408,10 +1277,12 @@ class ReaderViewModel @Inject constructor( onEvent( ReaderEvent.OnLoadText( refreshList = { refreshList(it) }, - listState = listState, onLoaded = { onLoaded() }, + onError = { + onError(it) + }, onTextIsEmpty = { onEvent(ReaderEvent.OnTextIsEmpty(onLoaded = { onLoaded() })) } @@ -419,17 +1290,52 @@ class ReaderViewModel @Inject constructor( ) } } -} - - - - - - - - - - + @OptIn(FlowPreview::class) + suspend fun onUpdateProgress( + firstVisibleItemIndex: Int, + firstVisibleItemOffset: Int, + navigator: Navigator, + isReaderLoading: Boolean, + onLibraryEvent: (LibraryEvent) -> Unit, + onHistoryEvent: (HistoryEvent) -> Unit + ) { + snapshotFlow { + firstVisibleItemIndex to firstVisibleItemOffset + }.debounce(500).collectLatest { items -> + if ( + !isReaderLoading && + _state.value.text.isNotEmpty() && + _state.value.listState.layoutInfo.totalItemsCount > 0 + ) { + val lastVisibleItemIndex = _state.value.listState.layoutInfo.visibleItemsInfo + .last().index + val progress = if (items.first > 0) { + if ( + lastVisibleItemIndex >= (_state.value.listState.layoutInfo.totalItemsCount - 1) + ) { + 1f + } else { + (items.first.toFloat() / (_state.value.text.size - 1).toFloat()) + } + } else { + 0f + } + onEvent( + ReaderEvent.OnChangeProgress( + progress = progress, + navigator = navigator, + firstVisibleItemIndex = items.first, + firstVisibleItemOffset = items.second, + refreshList = { book -> + onLibraryEvent(LibraryEvent.OnUpdateBook(book)) + onHistoryEvent(HistoryEvent.OnUpdateBook(book)) + } + ) + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/data/SettingsEvent.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/data/SettingsEvent.kt index c9bfa87f..a32c8e28 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/data/SettingsEvent.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/data/SettingsEvent.kt @@ -15,4 +15,9 @@ sealed class SettingsEvent { val notificationsPermissionState: PermissionState, val onChangeCheckForUpdates: (Boolean) -> Unit ) : SettingsEvent() + + data class OnReaderShowHideTranslatorLanguageBottomSheet( + val show: Boolean? = null, + val translateFrom: Boolean? = null + ) : SettingsEvent() } \ No newline at end of file diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/data/SettingsState.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/data/SettingsState.kt new file mode 100644 index 00000000..91c6fc42 --- /dev/null +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/data/SettingsState.kt @@ -0,0 +1,9 @@ +package ua.acclorite.book_story.presentation.screens.settings.data + +import androidx.compose.runtime.Immutable + +@Immutable +data class SettingsState( + val showReaderTranslatorLanguageBottomSheet: Boolean = false, + val readerTranslatorLanguageBottomSheetTranslateFrom: Boolean = false, +) diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/data/SettingsViewModel.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/data/SettingsViewModel.kt index 58e802dd..7c44c99c 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/data/SettingsViewModel.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/data/SettingsViewModel.kt @@ -11,6 +11,9 @@ import com.google.accompanist.permissions.shouldShowRationale import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.yield import javax.inject.Inject @@ -21,6 +24,9 @@ class SettingsViewModel @Inject constructor( ) : ViewModel() { + private val _state = MutableStateFlow(SettingsState()) + val state = _state.asStateFlow() + private var notificationsPermissionJob: Job? = null fun onEvent(event: SettingsEvent) { @@ -71,6 +77,17 @@ class SettingsViewModel @Inject constructor( } } } + + is SettingsEvent.OnReaderShowHideTranslatorLanguageBottomSheet -> { + _state.update { + it.copy( + showReaderTranslatorLanguageBottomSheet = event.show + ?: !it.showReaderTranslatorLanguageBottomSheet, + readerTranslatorLanguageBottomSheetTranslateFrom = event.translateFrom + ?: false + ) + } + } } } } diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/appearance/components/theme_switcher/AppearanceSettingsThemeSwitcherItem.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/appearance/components/theme_switcher/AppearanceSettingsThemeSwitcherItem.kt index f86179d1..3c6f44b0 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/appearance/components/theme_switcher/AppearanceSettingsThemeSwitcherItem.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/appearance/components/theme_switcher/AppearanceSettingsThemeSwitcherItem.kt @@ -39,7 +39,14 @@ import ua.acclorite.book_story.presentation.ui.ThemeContrast import ua.acclorite.book_story.presentation.ui.animatedColorScheme /** - * Theme switcher item. + * Theme switcher item. Used to change app theme. + * + * @param theme Target [Theme]. + * @param darkTheme Whether should be shown in Dark Theme. + * @param isPureDark Whether should be shown in Pure Dark. + * @param themeContrast [ThemeContrast]. + * @param selected True if this theme is currently selected. + * @param onClick OnClick callback. */ @Composable fun AppearanceSettingsThemeSwitcherItem( @@ -69,7 +76,8 @@ fun AppearanceSettingsThemeSwitcherItem( onClick() } .background( - colorScheme.surface + colorScheme.surface, + MaterialTheme.shapes.large ) .border( 4.dp, @@ -87,10 +95,9 @@ fun AppearanceSettingsThemeSwitcherItem( ) { Box( Modifier - .clip(RoundedCornerShape(10.dp)) .height(20.dp) .width(80.dp) - .background(colorScheme.onSurface) + .background(colorScheme.onSurface, RoundedCornerShape(10.dp)) ) Spacer(modifier = Modifier.width(8.dp)) @@ -117,9 +124,9 @@ fun AppearanceSettingsThemeSwitcherItem( .padding(start = 8.dp) .height(80.dp) .width(70.dp) - .clip(RoundedCornerShape(14.dp)) .background( - colorScheme.surfaceContainer + colorScheme.surfaceContainer, + RoundedCornerShape(14.dp) ) ) Spacer(modifier = Modifier.height(40.dp)) @@ -135,16 +142,14 @@ fun AppearanceSettingsThemeSwitcherItem( ) { Box( Modifier - .clip(CircleShape) .size(20.dp) - .background(colorScheme.primary) + .background(colorScheme.primary, CircleShape) ) Box( Modifier - .clip(RoundedCornerShape(10.dp)) .height(20.dp) .width(60.dp) - .background(colorScheme.onSurfaceVariant) + .background(colorScheme.onSurfaceVariant, RoundedCornerShape(10.dp)) ) } diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/reader/ReaderSettings.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/reader/ReaderSettings.kt index d4fa290c..29d9f441 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/reader/ReaderSettings.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/settings/nested/reader/ReaderSettings.kt @@ -20,6 +20,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.dp @@ -30,26 +31,36 @@ import ua.acclorite.book_story.domain.util.Constants import ua.acclorite.book_story.presentation.components.CategoryTitle import ua.acclorite.book_story.presentation.components.GoBackButton import ua.acclorite.book_story.presentation.components.collapsibleUntilExitScrollBehaviorWithLazyListState +import ua.acclorite.book_story.presentation.components.translator_language.TranslatorLanguageBottomSheet import ua.acclorite.book_story.presentation.data.LocalNavigator import ua.acclorite.book_story.presentation.data.MainEvent import ua.acclorite.book_story.presentation.data.MainState import ua.acclorite.book_story.presentation.data.MainViewModel import ua.acclorite.book_story.presentation.data.Navigator +import ua.acclorite.book_story.presentation.screens.reader.components.translator_bottom_sheet.ReaderTranslatorBottomSheetLanguageChooser import ua.acclorite.book_story.presentation.screens.settings.components.ChipsWithTitle import ua.acclorite.book_story.presentation.screens.settings.components.SliderWithTitle import ua.acclorite.book_story.presentation.screens.settings.components.SwitchWithTitle +import ua.acclorite.book_story.presentation.screens.settings.data.SettingsEvent +import ua.acclorite.book_story.presentation.screens.settings.data.SettingsState +import ua.acclorite.book_story.presentation.screens.settings.data.SettingsViewModel +import java.util.Locale @Composable fun ReaderSettingsRoot() { val navigator = LocalNavigator.current val mainViewModel: MainViewModel = hiltViewModel() + val settingsViewModel: SettingsViewModel = hiltViewModel() val state = mainViewModel.state.collectAsState() + val settingsState = settingsViewModel.state.collectAsState() ReaderSettings( state = state, + settingsState = settingsState, navigator = navigator, - onMainEvent = mainViewModel::onEvent + onMainEvent = mainViewModel::onEvent, + onEvent = settingsViewModel::onEvent ) } @@ -57,16 +68,70 @@ fun ReaderSettingsRoot() { @Composable private fun ReaderSettings( state: State, + settingsState: State, navigator: Navigator, - onMainEvent: (MainEvent) -> Unit + onMainEvent: (MainEvent) -> Unit, + onEvent: (SettingsEvent) -> Unit ) { val scrollState = TopAppBarDefaults.collapsibleUntilExitScrollBehaviorWithLazyListState() + val context = LocalContext.current val fontFamily = remember(state.value.fontFamily) { Constants.FONTS.find { it.id == state.value.fontFamily } ?: Constants.FONTS[0] } + val translateFrom = remember(state.value.translateFrom) { + if (state.value.translateFrom!! == "auto") { + return@remember context.getString(R.string.translator_auto_short) + } + + val locale = Locale(state.value.translateFrom!!) + locale.getDisplayName(locale).replaceFirstChar { char -> + char.uppercase() + } + } + val translateTo = remember(state.value.translateTo) { + val locale = Locale(state.value.translateTo!!) + locale.getDisplayName(locale).replaceFirstChar { char -> + char.uppercase() + } + } + + if (settingsState.value.showReaderTranslatorLanguageBottomSheet) { + TranslatorLanguageBottomSheet( + selectedLanguage = if ( + settingsState.value.readerTranslatorLanguageBottomSheetTranslateFrom + ) { + state.value.translateFrom!! + } else state.value.translateTo!!, + + unselectedLanguage = if ( + !settingsState.value.readerTranslatorLanguageBottomSheetTranslateFrom + ) { + state.value.translateFrom!! + } else state.value.translateTo!!, + + translateFromSelecting = settingsState.value.readerTranslatorLanguageBottomSheetTranslateFrom, + onSelect = { from, to -> + onMainEvent(MainEvent.OnChangeTranslateFrom(from)) + onMainEvent(MainEvent.OnChangeTranslateTo(to)) + + onEvent( + SettingsEvent.OnReaderShowHideTranslatorLanguageBottomSheet( + show = false + ) + ) + }, + onDismiss = { + onEvent( + SettingsEvent.OnReaderShowHideTranslatorLanguageBottomSheet( + show = false + ) + ) + } + ) + } Scaffold( Modifier @@ -99,6 +164,91 @@ private fun ReaderSettings( item { Spacer(modifier = Modifier.height(16.dp)) } + item { + CategoryTitle( + title = stringResource(id = R.string.translator_reader_settings), + color = MaterialTheme.colorScheme.primary + ) + } + item { + Spacer(modifier = Modifier.height(8.dp)) + } + + item { + SwitchWithTitle( + selected = state.value.enableTranslator!!, + title = stringResource(id = R.string.translator_enable_default_option), + description = stringResource(id = R.string.translator_enable_default_option_desc), + onClick = { + onMainEvent( + MainEvent.OnChangeEnableTranslator( + !state.value.enableTranslator!! + ) + ) + } + ) + } + + item { + Spacer(modifier = Modifier.height(8.dp)) + + CategoryTitle( + title = stringResource(id = R.string.translator_language_option) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + ReaderTranslatorBottomSheetLanguageChooser( + fromLanguage = translateFrom, + toLanguage = translateTo, + onFromClick = { + onEvent( + SettingsEvent.OnReaderShowHideTranslatorLanguageBottomSheet( + show = true, + translateFrom = true + ) + ) + }, + onToClick = { + onEvent( + SettingsEvent.OnReaderShowHideTranslatorLanguageBottomSheet( + show = true, + translateFrom = false + ) + ) + }, + switchEnabled = state.value.translateFrom != "auto", + onSwitchClick = { + val from = state.value.translateFrom!! + val to = state.value.translateTo!! + + onMainEvent(MainEvent.OnChangeTranslateFrom(to)) + onMainEvent(MainEvent.OnChangeTranslateTo(from)) + } + ) + + Spacer(modifier = Modifier.height(8.dp)) + } + + + item { + SwitchWithTitle( + selected = state.value.doubleClickTranslation!!, + title = stringResource(id = R.string.translator_double_click_to_translate_option), + description = stringResource(id = R.string.translator_double_click_to_translate_option_desc), + onClick = { + onMainEvent( + MainEvent.OnChangeDoubleClickTranslation( + !state.value.doubleClickTranslation!! + ) + ) + } + ) + } + + item { + Spacer(modifier = Modifier.height(22.dp)) + } item { CategoryTitle( title = stringResource(id = R.string.font_reader_settings), @@ -108,6 +258,7 @@ private fun ReaderSettings( item { Spacer(modifier = Modifier.height(8.dp)) } + item { ChipsWithTitle( title = stringResource(id = R.string.font_family_option), diff --git a/app/src/main/java/ua/acclorite/book_story/presentation/screens/start/components/StartSettings.kt b/app/src/main/java/ua/acclorite/book_story/presentation/screens/start/components/StartSettings.kt index c66a3025..b7311ee3 100644 --- a/app/src/main/java/ua/acclorite/book_story/presentation/screens/start/components/StartSettings.kt +++ b/app/src/main/java/ua/acclorite/book_story/presentation/screens/start/components/StartSettings.kt @@ -43,7 +43,14 @@ import ua.acclorite.book_story.presentation.screens.start.data.StartState import ua.acclorite.book_story.presentation.ui.Theme /** - * Settings screen. + * Start Settings. + * + * @param state [StartState]. + * @param mainState [MainState]. + * @param onEvent [StartEvent] callback. + * @param onMainEvent [MainEvent] callback. + * @param storagePermissionState Storage [PermissionState]. + * @param notificationsPermissionState Notifications [PermissionState]. */ @OptIn(ExperimentalPermissionsApi::class) @Composable @@ -123,7 +130,10 @@ fun StartSettings( modifier = Modifier .fillMaxSize() .clip(MaterialTheme.shapes.large) - .background(MaterialTheme.colorScheme.surfaceContainerLow), + .background( + MaterialTheme.colorScheme.surfaceContainerLow, + MaterialTheme.shapes.large + ), ) { StartNavigationScreenItem( state = state, diff --git a/app/src/main/res/drawable/empty_languages.png b/app/src/main/res/drawable/empty_languages.png new file mode 100644 index 0000000000000000000000000000000000000000..fadacf56f7bdab3ff78c9ec5b4cce40f90d789af GIT binary patch literal 20005 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
S;Y411O=W*dTT8HepH-Q!Q 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

a7*T)jK zE7xq*{A6O1nRboOIwpHE#K0Qipov{YeO*sph2%TBU6qPB6?#S)M4z%WRqu|+ijpFw ztYn(`kjZ|-I*aJh*L91ilH>QBp04!v_UXh+^}+#~e~v}4*u~=5Lg;`udVSC;x~0e# zcdGfcjy#J%Ow2|Vly!VlA)6OE@8+rsQGCAQl8oEJS#`WdY3ClK9%ah{2hJ>9rp?K z6AVrvN-zc3CO5Q*|Gu@StvDW#wNzsKlNa=MYb@9cPv*fVEo&mwqg$n(C7 zB+2}qxk999$WvohRN7*NhFwhb$c)RqtXDo=t+xoomnc!{BzHgtqj<^*X%eS5%#A&r zvtml|*>T{ne5NEoCK8eAu}AlQzy+zB67yU_7+SkTy)Aohj6j9?^R5xB9}45!3&KMK z4NtRnA?vK_+(xlYein}il)?mF>-cCoVajk9lcgt&8PTUoJm z9I~CI%afBJ<6Ri{N5<)l3($^@%55|=u7UuQgmONll{>HAB$JexPNeLdQ&U(vwg=|P R;eo{)L?L^Ts$6MT{tI`1b5H;P literal 0 HcmV?d00001 diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 8ce27f8c..daa84e2d 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -36,6 +36,8 @@ Перемістити книгу? Видалити історію читання? Підтвердити оновлення? + Завантажити %1$s? + Завантажити мови? @@ -78,6 +80,17 @@ Натиснувши на кнопку «Завантажити», ви перейдете в ваш браузер та завантажете останнє оновлення автоматично. + + Вам потрібно завантажити цю мову перед використанням. + Після завантаження, вона буде доступна офлайн та не потребуватиме інтернету. + Кожна мова має розмір близько ~30мб, для завантаження рекомендується використовувати Wi-Fi. + + + Вам потрібно завантажити ці мови перед використанням. + Кожна мова має розмір близько ~30мб, для завантаження рекомендується використовувати Wi-Fi. + Ви можете вибрати мови, які ви хочете завантажити, + всі не вибрані мови не будуть перекладені. + @@ -104,6 +117,12 @@ Щось пішло не так. Спробуйте пізніше. + Мова не була ідентифікована. + Не вдалося ідентифікувати жодної мови. + Неможливо вибрати ту саму мову двічі. + Не можна вибрати цю мову. + Не вдалося завантажити мову. + Не вдалося завантажити мови. @@ -116,6 +135,9 @@ Нехай ваша остання прочитана книга відкриє перед вами сторінки нової великої історії вашого читацького шляху + + Не існує мови з такою назвою, або вона не підтримується + %1$s обрано @@ -150,11 +172,14 @@ Ні Завантажити Готово + Відмінити + Далі файли книги історію + мови Невідомий @@ -181,13 +206,14 @@ Головні Мова застосунка Зовнішній вигляд - Налаштування теми, кольори + Тема, кольори Читач - Налаштування шрифта, налаштування текста + Перекладач, шрифт, текст Шрифт Текст + Перекладач Налаштування теми Кольори @@ -208,6 +234,13 @@ Перевіряти на оновлення Перевіряти на новішу версію застосунку при запуску Бокові відступи + Мова перекладача + Увімкнути перекладач + Увімкнути перекладач при додаванні книги за замовчуванням + Переклад подвійним кліком + Двічі клацніть на абзац, щоб перекласти його + Перекладати при відкритті книги + Автоматично перекладати весь текст при відкритті книги Червоний @@ -255,7 +288,12 @@ Ця книга була успішно переміщена. Скопійовано Історія була успішно видалена. - Остання версія застосунку вже встановлена. + Остання версія застосунка вже встановлена. + Мова була успішно завантажена та вибрана. + Всі необхідні мови завантажені. Переклад почнеться незабаром. + Переклад відміняється. Будь ласка, зачекайте. + Перекладений текст відміняється. Будь ласка, зачекайте. + Переклад скоро почнеться. Будь ласка, зачекайте. Елемент історії був успішно видалений. @@ -282,14 +320,25 @@ Дякуємо за те, що прочитали \"%1$s\" з нами. Ми сподіваємось, що вам сподобалася ця книга! - %1$s букв, %2$s слів. + %1$s букв(а), %2$s слів. + + + Визначити мову + Визначити + Перекласти %1$s + з + на + Всі мови + Недавні мови + %1$s абзац(ів) + Показати оригінал (%1$s на %2$s) - Версія застосунку + Версія застосунка Версія v%1$s Натисніть, щоб перевірити на оновлення Повідомити про помилку - Сторінка проєкту + Сторінка проєкта Підтримати мою роботу Ліцензії з відкритим кодом Заслуги @@ -474,6 +523,8 @@ Скинути початковий посібник Вийти з редагування назви Відкрити в браузері - + Перекладач + Поміняти мови місцями + Завантажено \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 53c763a8..42cb001f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -55,6 +55,8 @@ Move book? Delete reading history? Confirm update? + Download %1$s? + Download languages? @@ -97,6 +99,17 @@ By clicking the «Download» button you will be transferred to your browser and download the latest update automatically. + + You need to download this language before using it. + After downloading, it will be available offline and will not require internet. + Each language is about ~30mb in size, it is recommended to use Wi-Fi to download it. + + + You need to download these languages before. + Each language is about ~30mb in size, it is recommended to use Wi-Fi to download it. + You can select which languages you want to download, + all unselected languages will not be translated. + @@ -123,6 +136,12 @@ Something went wrong. Try again later. + Could not identify language. + Could not identify any languages. + Cannot select same language twice. + Cannot select this language. + Could not download language. + Could not download languages. @@ -134,6 +153,9 @@ Let your last book you read open the pages of a new great story of your reading journey + + There is no such language with this name, or it is not supported + %1$s selected @@ -168,11 +190,14 @@ No Download Done + Undo + Continue files books history + languages Unknown @@ -199,13 +224,14 @@ General App language Appearance - Theme preferences, colors + Theme, colors Reader - Font settings, text settings + Translator, font, text Font Text + Translator Theme preferences Colors @@ -226,6 +252,13 @@ Check for updates Check for newer app version on startup Side padding + Translator language + Enable translator + Enable translator when adding a book by default + Double click translation + Double click on paragraph to translate it + Translate when open a book + Automatically translate the whole text when open a book Red @@ -274,6 +307,13 @@ Copied History was successfully deleted. The latest version of the app is already installed. + Language was successfully downloaded and selected. + + All required languages are downloaded. Translation will start soon. + + Translation is canceling. Please wait. + Translated text is canceling. Please wait. + Translation will start soon. Please wait. History element was successfully deleted. @@ -302,6 +342,17 @@ %1$s characters, %2$s words. + + Detect language + Detect + Translate %1$s + from + to + All languages + Recent languages + %1$s paragraph(s) + Show original (%1$s to %2$s) + App version Version v%1$s @@ -493,5 +544,8 @@ Reset start guide Exit title editing Open in web + Translator + Change language places + Downloaded \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index b4e9c200..d09d2bba 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,11 @@ plugins { - id("com.android.application") version "8.2.0-rc03" apply false - id("org.jetbrains.kotlin.android") version "1.9.21" apply false - id("com.google.devtools.ksp") version "1.9.21-1.0.16" apply false + id("com.android.application") version "8.3.0" apply false + id("org.jetbrains.kotlin.android") version "2.0.0" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.0.0" apply false + id("com.google.devtools.ksp") version "2.0.0-1.0.21" apply false id("com.google.dagger.hilt.android") version "2.50" apply false id("com.mikepenz.aboutlibraries.plugin") version "11.1.3" apply false + id("androidx.room") version "2.6.1" apply false + kotlin("jvm") version "2.0.0" apply true + kotlin("plugin.serialization") version "2.0.0" apply false } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 459be50b..e411586a 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,5 @@ -#Fri Dec 29 23:56:29 EET 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists