Gast commit!
@@ -0,0 +1,3 @@
|
||||
/build
|
||||
/libs/
|
||||
/schemas/
|
||||
@@ -0,0 +1,148 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
id("androidx.room")
|
||||
id("com.google.devtools.ksp")
|
||||
// id("com.google.gms.google-services")
|
||||
id("org.jetbrains.kotlin.plugin.serialization")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "mobi.librera.appcompose"
|
||||
compileSdk = rootProject.extra["compileSdk"] as Int
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "mobi.librera.appcompose"
|
||||
minSdk = rootProject.extra["minSdk"] as Int
|
||||
targetSdk = rootProject.extra["targetSdk"] as Int
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
jniLibs.srcDirs("libs")
|
||||
}
|
||||
}
|
||||
|
||||
room {
|
||||
schemaDirectory("$projectDir/schemas")
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += setOf(
|
||||
"META-INF/DEPENDENCIES",
|
||||
"META-INF/NOTICE",
|
||||
"META-INF/LICENSE",
|
||||
"META-INF/LICENSE.txt",
|
||||
"META-INF/NOTICE.txt",
|
||||
"META-INF/INDEX.LIST",
|
||||
"META-INF/io.netty.versions.properties"
|
||||
)
|
||||
pickFirsts += setOf(
|
||||
"google/protobuf/*.proto",
|
||||
"META-INF/services/io.grpc.LoadBalancerProvider",
|
||||
"META-INF/services/io.grpc.ManagedChannelProvider",
|
||||
"META-INF/services/io.grpc.NameResolverProvider"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = JvmTarget.JVM_11
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation(project(":appLibDrive"))
|
||||
// Firebase BOM
|
||||
|
||||
|
||||
//BOM begin
|
||||
implementation(platform("androidx.compose:compose-bom:2025.07.00"))
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-graphics")
|
||||
implementation("androidx.compose.material:material-icons-core")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
//BOM end
|
||||
|
||||
|
||||
implementation("androidx.activity:activity-compose:1.10.1")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.2")
|
||||
implementation("androidx.core:core-ktx:1.16.0")
|
||||
implementation("androidx.navigation:navigation-compose:2.9.3")
|
||||
|
||||
|
||||
implementation("io.coil-kt.coil3:coil-compose:3.3.0")
|
||||
implementation("io.coil-kt.coil3:coil-network-okhttp:3.3.0")
|
||||
implementation("com.squareup.okhttp3:okhttp:5.1.0")
|
||||
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
|
||||
|
||||
implementation("androidx.datastore:datastore:1.1.7")
|
||||
implementation("androidx.datastore:datastore-preferences:1.1.7")
|
||||
|
||||
|
||||
implementation(platform("io.insert-koin:koin-bom:4.1.0"))
|
||||
implementation("io.insert-koin:koin-core")
|
||||
implementation("io.insert-koin:koin-androidx-compose")
|
||||
implementation("io.insert-koin:koin-compose")
|
||||
implementation("io.insert-koin:koin-compose-viewmodel")
|
||||
|
||||
// Kotlin Coroutines
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
||||
|
||||
|
||||
|
||||
ksp("androidx.room:room-compiler:2.7.2")
|
||||
implementation("androidx.room:room-runtime:2.7.2")
|
||||
implementation("androidx.room:room-ktx:2.7.2")
|
||||
|
||||
implementation("androidx.sqlite:sqlite-bundled:2.5.2")
|
||||
|
||||
|
||||
|
||||
implementation("net.java.dev.jna:jna:5.17.0@aar")
|
||||
|
||||
implementation("io.github.vinceglb:filekit-dialogs:0.10.0")
|
||||
implementation("io.github.vinceglb:filekit-dialogs-compose:0.10.0")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation(platform("androidx.compose:compose-bom:2025.07.00"))
|
||||
androidTestImplementation("androidx.test.ext:junit:1.3.0")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0")
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
||||
# gRPC rules
|
||||
-keep class io.grpc.** {*;}
|
||||
-keep class com.google.protobuf.** {*;}
|
||||
-keepclassmembers class com.google.protobuf.** {
|
||||
public <methods>;
|
||||
}
|
||||
-dontwarn io.grpc.**
|
||||
-dontwarn com.google.protobuf.**
|
||||
-dontwarn javax.naming.**
|
||||
|
||||
# Firebase rules
|
||||
-keep class com.google.firebase.** { *; }
|
||||
-keep class com.google.android.gms.** { *; }
|
||||
-dontwarn com.google.firebase.**
|
||||
-dontwarn com.google.android.gms.**
|
||||
@@ -0,0 +1,24 @@
|
||||
package mobi.librera.appcompose
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("mobi.librera.appcompose", appContext.packageName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.SEARCH" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.TTS_SERVICE" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:name=".App"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
|
||||
android:label="@string/app_name"
|
||||
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Librera">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.Librera">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<receiver
|
||||
android:name="androidx.media.session.MediaButtonReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
</application>
|
||||
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,45 @@
|
||||
package mobi.librera.appcompose
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import coil3.ImageLoader
|
||||
import coil3.SingletonImageLoader
|
||||
import com.google.firebase.FirebaseApp
|
||||
import mobi.librera.appcompose.di.initKoin
|
||||
import mobi.librera.appcompose.imageloader.AppImageLoader
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
|
||||
class App : Application(), KoinComponent, SingletonImageLoader.Factory {
|
||||
|
||||
@SuppressLint("HardwareIds")
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
val androidId = Settings.Secure.getString(
|
||||
this.contentResolver,
|
||||
Settings.Secure.ANDROID_ID
|
||||
)
|
||||
DEVICE_ID = androidId + "_" + Build.VERSION.SDK_INT
|
||||
println("ANDROID_ID: $androidId")
|
||||
|
||||
|
||||
AppImageLoader.initialize(this)
|
||||
FirebaseApp.initializeApp(this)
|
||||
initKoin {
|
||||
androidContext(this@App)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun newImageLoader(context: Context): ImageLoader {
|
||||
return get<ImageLoader>()
|
||||
}
|
||||
|
||||
companion object {
|
||||
lateinit var DEVICE_ID: String
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package mobi.librera.appcompose
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import mobi.librera.appcompose.bookgrid.BookGridViewModel
|
||||
import mobi.librera.appcompose.ui.theme.LibreraTheme
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
||||
@SuppressLint("MutableCollectionMutableState")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
val dataModel: BookGridViewModel = koinViewModel()
|
||||
val isDark by dataModel.isDarkModeEnabled.collectAsState()
|
||||
|
||||
LibreraTheme(darkTheme = isDark) {
|
||||
Scaffold(
|
||||
topBar = { },
|
||||
bottomBar = {//AppNavigationBar()
|
||||
}, modifier = Modifier.fillMaxSize()
|
||||
) { innerPadding ->
|
||||
|
||||
Surface(
|
||||
tonalElevation = 1.dp, modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
NavigationRoot()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package mobi.librera.appcompose
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.toRoute
|
||||
import kotlinx.serialization.Serializable
|
||||
import mobi.librera.appcompose.bookgrid.BookGridScreen
|
||||
import mobi.librera.appcompose.bookgrid.BookGridViewModel
|
||||
import mobi.librera.appcompose.bookread.BookReadViewModel
|
||||
import mobi.librera.appcompose.bookread.ReadBookScreen
|
||||
import mobi.librera.appcompose.room.Book
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
|
||||
|
||||
typealias OnString = (String) -> Unit
|
||||
typealias OnBook = (Book) -> Unit
|
||||
typealias OnVoid = () -> Unit
|
||||
typealias OnInt = (Int) -> Unit
|
||||
|
||||
|
||||
sealed class Route {
|
||||
@Serializable
|
||||
data object BookGridRoot
|
||||
|
||||
@Serializable
|
||||
data class BookReadRoot(val bookPath: String)
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun NavigationRoot(modifier: Modifier = Modifier) {
|
||||
val navController = rememberNavController()
|
||||
|
||||
val gridModel = koinViewModel<BookGridViewModel>()
|
||||
val readModel = koinViewModel<BookReadViewModel>()
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Route.BookGridRoot
|
||||
) {
|
||||
|
||||
composable<Route.BookGridRoot> {
|
||||
|
||||
BookGridScreen(
|
||||
gridModel, onOpenBook = {
|
||||
navController.navigate(Route.BookReadRoot(bookPath = it)) {
|
||||
// popUpTo(Route.BookGridRoot) {
|
||||
// inclusive = true
|
||||
// }
|
||||
launchSingleTop = true
|
||||
}
|
||||
|
||||
},
|
||||
onHomeClick = {
|
||||
|
||||
})
|
||||
}
|
||||
composable<Route.BookReadRoot> {
|
||||
val bookPath = it.toRoute<Route.BookReadRoot>().bookPath
|
||||
|
||||
readModel.openBook(bookPath)
|
||||
|
||||
ReadBookScreen(
|
||||
gridModel,
|
||||
readModel,
|
||||
onBookClose = {
|
||||
//navController.navigate(Route.BookGridRoot)
|
||||
navController.popBackStack()
|
||||
},
|
||||
onOpenBook = { book ->
|
||||
readModel.openBook(book)
|
||||
//navController.navigate(Route.BookReadRoot(bookPath = book))
|
||||
}
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package mobi.librera.appcompose.bookgrid
|
||||
|
||||
import android.os.Environment
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import mobi.librera.appcompose.booksync.GoogleSignInScreen
|
||||
import mobi.librera.appcompose.components.BookGrid
|
||||
import mobi.librera.appcompose.components.BookSearchBar
|
||||
|
||||
@Composable
|
||||
fun BookGridScreen(
|
||||
dataModel: BookGridViewModel,
|
||||
onOpenBook: (String) -> Unit,
|
||||
onHomeClick: () -> Unit
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
BookSearchBar(dataModel)
|
||||
|
||||
var isManageStorageGranted by remember { mutableStateOf(Environment.isExternalStorageManager()) }
|
||||
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.StartActivityForResult()
|
||||
) {
|
||||
isManageStorageGranted = Environment.isExternalStorageManager()
|
||||
if (isManageStorageGranted) {
|
||||
dataModel.loadInitialBooks()
|
||||
}
|
||||
}
|
||||
|
||||
if (!isManageStorageGranted) {
|
||||
ManageStoragePermissionScreen(launcher)
|
||||
} else {
|
||||
GoogleSignInScreen()
|
||||
|
||||
// SelectedBooksBar(
|
||||
// dataModel, false,
|
||||
// onOpenBook = onOpenBook,
|
||||
// onHomeClick = onHomeClick
|
||||
// )
|
||||
|
||||
val books by dataModel.getAllBooks.collectAsState()
|
||||
|
||||
BookGrid(
|
||||
dataModel,
|
||||
books, dataModel.listGridStates,
|
||||
onStarClicked = { dataModel.updateStar(it, !it.isSelected) },
|
||||
onBookClicked = { onOpenBook(it.path) })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package mobi.librera.appcompose.bookgrid
|
||||
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import mobi.librera.appcompose.booksync.FirestoreBooksRepository
|
||||
import mobi.librera.appcompose.core.DEFAULT_SEARCH_DIR
|
||||
import mobi.librera.appcompose.core.FilesRepository
|
||||
import mobi.librera.appcompose.datastore.UserPreferencesRepository
|
||||
import mobi.librera.appcompose.room.Book
|
||||
import mobi.librera.appcompose.room.BookRepository
|
||||
|
||||
class BookGridViewModel(
|
||||
private val bookRepository: BookRepository,
|
||||
private val filesRepository: FilesRepository,
|
||||
private val preferenecesRepository: UserPreferencesRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val searchPath: StateFlow<String> = preferenecesRepository.userName.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = DEFAULT_SEARCH_DIR.path
|
||||
)
|
||||
|
||||
val isDarkModeEnabled: StateFlow<Boolean> = preferenecesRepository.isDarkModeEnabled.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = false
|
||||
)
|
||||
|
||||
fun updateDarkMode(value: Boolean) = viewModelScope.launch {
|
||||
preferenecesRepository.setDarkModeEnabled(value)
|
||||
}
|
||||
|
||||
fun updateSearchPath(name: String) = viewModelScope.launch {
|
||||
preferenecesRepository.saveUserName(name)
|
||||
}
|
||||
|
||||
var listGridStates = LazyGridState(0, 0)
|
||||
|
||||
var initialFirstVisibleItemIndex: Int by mutableIntStateOf(0)
|
||||
var initialFirstVisibleItemScrollOffset: Int by mutableIntStateOf(0)
|
||||
|
||||
|
||||
var isSearchPDF by mutableStateOf(true)
|
||||
var isSearchEPUB by mutableStateOf(true)
|
||||
|
||||
private val _currentSearchQuery = MutableStateFlow("")
|
||||
val currentSearchQuery: StateFlow<String>
|
||||
get() = _currentSearchQuery.asStateFlow()
|
||||
|
||||
fun updateSearchQuery(query: String) {
|
||||
_currentSearchQuery.value = query
|
||||
}
|
||||
|
||||
init {
|
||||
loadInitialBooks()
|
||||
}
|
||||
|
||||
|
||||
public fun loadInitialBooks() = viewModelScope.launch {
|
||||
val currentBooks = bookRepository.getAllBooks().first()
|
||||
|
||||
if (currentBooks.isEmpty()) {
|
||||
searchBooks()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateStar(book: Book, isSelected: Boolean) = viewModelScope.launch(Dispatchers.IO) {
|
||||
val bookToUpdate = bookRepository.updateBook(book.copy(isSelected = isSelected))
|
||||
FirestoreBooksRepository.syncBook(bookToUpdate)
|
||||
}
|
||||
|
||||
fun searchBooks() = viewModelScope.launch(Dispatchers.IO) {
|
||||
bookRepository.deleteAllBooks()
|
||||
val booksToInsert = filesRepository.getAllBooks(
|
||||
isPDF = isSearchPDF, isEPUB = isSearchEPUB, searchPath.first()
|
||||
).map { Book(it) }
|
||||
bookRepository.insertAll(booksToInsert)
|
||||
}
|
||||
|
||||
val getAllSelectedBooks = bookRepository.getAllSelected()
|
||||
.combine(currentSearchQuery) { books, query ->
|
||||
books.filter { it.path.contains(query, ignoreCase = true) }
|
||||
}
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
val getAllRecentBooks = bookRepository.getAllRecent()
|
||||
.combine(currentSearchQuery) { books, query ->
|
||||
books.filter { it.path.contains(query, ignoreCase = true) }
|
||||
}
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
|
||||
val getAllBooks = bookRepository.getAllBooks()
|
||||
.combine(currentSearchQuery) { books, query ->
|
||||
books.filter { it.path.contains(query, ignoreCase = true) }
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package mobi.librera.appcompose.bookgrid
|
||||
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.ManagedActivityResultLauncher
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun ManageStoragePermissionScreen(launcher: ManagedActivityResultLauncher<Intent, ActivityResult>) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
|
||||
Text(
|
||||
text = "MANAGE STORAGE Required",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
Text(
|
||||
text = "This application requires 'All files access' to function correctly. Please grant this permission in settings.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(bottom = 24.dp)
|
||||
)
|
||||
Button(onClick = {
|
||||
// Show rationale if needed, then launch settings
|
||||
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
|
||||
intent.data = Uri.fromParts("package", context.packageName, null)
|
||||
launcher.launch(intent)
|
||||
}) {
|
||||
Text("Grant Permission")
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun isManageExternalStorageGranted(context: android.content.Context): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
Environment.isExternalStorageManager()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
package mobi.librera.appcompose.bookread
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.Image
|
||||
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.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Remove
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SliderDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import mobi.librera.appcompose.OnString
|
||||
import mobi.librera.appcompose.OnVoid
|
||||
import mobi.librera.appcompose.bookgrid.BookGridViewModel
|
||||
import mobi.librera.appcompose.components.NumberPickerDialog
|
||||
import mobi.librera.appcompose.components.ObserveLifecycleEvents
|
||||
import mobi.librera.appcompose.components.SelectedBooksBar
|
||||
import mobi.librera.appcompose.room.Book
|
||||
|
||||
@Composable
|
||||
fun ReadBookScreen(
|
||||
dataModel: BookGridViewModel,
|
||||
readModel: BookReadViewModel,
|
||||
onBookClose: OnVoid,
|
||||
onOpenBook: OnString
|
||||
) {
|
||||
var hideShow by remember { mutableStateOf(true) }
|
||||
val uiState by readModel.uiState.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val density = LocalDensity.current
|
||||
var boxSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.onSizeChanged { boxSize = it }) {
|
||||
|
||||
LaunchedEffect(uiState.bookPathToOpen, uiState.book.fontSize) {
|
||||
//println("LaunchedEffect $bookPath ${uiState.book.fontSize}")
|
||||
readModel.loadBook(context, boxSize.width, boxSize.height)
|
||||
}
|
||||
|
||||
when {
|
||||
uiState.isLoading -> {
|
||||
CircularProgressIndicator(Modifier.align(Alignment.Center))
|
||||
}
|
||||
|
||||
uiState.error != null -> {
|
||||
Text("Loading Error....", Modifier.align(Alignment.Center))
|
||||
}
|
||||
|
||||
uiState.book != Book.Empty -> {
|
||||
|
||||
RenderBookView(
|
||||
uiState.book,
|
||||
hideShow,
|
||||
uiState.openPage,
|
||||
onHideShow = { hideShow = !hideShow },
|
||||
pageWidth = boxSize.width,
|
||||
modelAction = readModel
|
||||
)
|
||||
}
|
||||
}
|
||||
if (hideShow) {
|
||||
SelectedBooksBar(dataModel, true, onHomeClick = {
|
||||
readModel.saveBookState()
|
||||
readModel.closeDocument()
|
||||
onBookClose()
|
||||
}, onOpenBook = {
|
||||
readModel.saveBookState()
|
||||
readModel.closeDocument()
|
||||
//uiState.book.path = it
|
||||
onOpenBook(it)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
BackHandler(
|
||||
onBack = onBookClose
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@SuppressLint("WrongConstant")
|
||||
@Composable
|
||||
fun RenderBookView(
|
||||
book: Book, hideShow: Boolean,
|
||||
openPage: Int,
|
||||
onHideShow: OnVoid, pageWidth: Int, modelAction: ModelActions
|
||||
) {
|
||||
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var sliderPosition by remember { mutableFloatStateOf(0f) }
|
||||
|
||||
var showNumberPicker by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(listState.firstVisibleItemIndex, listState.isScrollInProgress) {
|
||||
if (listState.isScrollInProgress) {
|
||||
sliderPosition = listState.firstVisibleItemIndex.toFloat()
|
||||
}
|
||||
modelAction.onPageChanged(listState.firstVisibleItemIndex - 1)
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
listState.scrollToItem(openPage)
|
||||
sliderPosition = (openPage).toFloat()
|
||||
}
|
||||
|
||||
ObserveLifecycleEvents(onPause = modelAction::saveBookState)
|
||||
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
|
||||
if (event.type == PointerEventType.Press) {
|
||||
|
||||
val position = event.changes.first().position
|
||||
if (position.x < pageWidth / 4) {
|
||||
if (listState.firstVisibleItemIndex > 0) {
|
||||
coroutineScope.launch {
|
||||
listState.scrollToItem(listState.firstVisibleItemIndex - 1)
|
||||
sliderPosition =
|
||||
listState.firstVisibleItemIndex.toFloat()
|
||||
}
|
||||
}
|
||||
} else if (position.x > pageWidth - pageWidth / 4) {
|
||||
coroutineScope.launch {
|
||||
listState.scrollToItem(listState.firstVisibleItemIndex + 1)
|
||||
sliderPosition = listState.firstVisibleItemIndex.toFloat()
|
||||
}
|
||||
} else {
|
||||
onHideShow()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
state = listState,
|
||||
userScrollEnabled = true,
|
||||
) {
|
||||
items(book.pageCount, key = { index -> index }) { number ->
|
||||
var image by remember(book.path + number) {
|
||||
mutableStateOf(
|
||||
ImageBitmap(
|
||||
1, 1
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(book.path, number, pageWidth, book.fontSize) {
|
||||
image = modelAction.renderPage(number, pageWidth)
|
||||
}
|
||||
|
||||
|
||||
Image(
|
||||
image, modifier = Modifier.fillMaxWidth(),
|
||||
|
||||
contentScale = ContentScale.FillWidth, contentDescription = "Image $number"
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
if (hideShow) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter)
|
||||
) {
|
||||
if (book.path.endsWith(".epub")) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(42.dp)
|
||||
.padding(2.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.fillMaxWidth()
|
||||
.background(Color.Blue.copy(alpha = 0.8f)),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Remove,
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.clickable {
|
||||
modelAction.onFontSizeChange(book.fontSize - 1)
|
||||
},
|
||||
contentDescription = "",
|
||||
tint = Color.White,
|
||||
)
|
||||
|
||||
Text(
|
||||
"${book.fontSize}",
|
||||
|
||||
modifier = Modifier.clickable {
|
||||
showNumberPicker = true
|
||||
}, style = TextStyle(
|
||||
color = Color.White, fontWeight = FontWeight.Bold, fontSize = 18.sp
|
||||
)
|
||||
)
|
||||
NumberPickerDialog(
|
||||
showDialog = showNumberPicker,
|
||||
onDismissRequest = { showNumberPicker = false },
|
||||
onNumberSelected = {
|
||||
modelAction.onFontSizeChange(it)
|
||||
showNumberPicker = false
|
||||
},
|
||||
initialNumber = 23,//readModel.fontSize,
|
||||
range = 10..99
|
||||
)
|
||||
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Add,
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.clickable {
|
||||
modelAction.onFontSizeChange(book.fontSize + 1)
|
||||
},
|
||||
contentDescription = "",
|
||||
tint = Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(42.dp)
|
||||
.padding(2.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.fillMaxWidth()
|
||||
|
||||
.background(Color.Blue.copy(alpha = 0.8f)),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
"${sliderPosition.toInt() + 1}",
|
||||
modifier = Modifier.padding(start = 8.dp, end = 4.dp),
|
||||
style = TextStyle(
|
||||
color = Color.White, fontWeight = FontWeight.Bold, fontSize = 18.sp
|
||||
)
|
||||
)
|
||||
Slider(
|
||||
value = sliderPosition,
|
||||
|
||||
onValueChange = { newPosition ->
|
||||
sliderPosition = newPosition
|
||||
coroutineScope.launch {
|
||||
listState.scrollToItem(newPosition.toInt())
|
||||
}
|
||||
},
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = Color.White,
|
||||
activeTrackColor = Color.White,
|
||||
inactiveTrackColor = Color.White,
|
||||
),
|
||||
valueRange = 0f..(book.pageCount).toFloat(),
|
||||
steps = 0,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
"${book.pageCount}",
|
||||
modifier = Modifier.padding(start = 4.dp, end = 8.dp),
|
||||
style = TextStyle(
|
||||
color = Color.White, fontWeight = FontWeight.Bold, fontSize = 18.sp
|
||||
)
|
||||
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ReadBookScreenPreview() {
|
||||
//ReadBookScreen("Book Title", 0, onBookClose = {}, {})
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package mobi.librera.appcompose.bookread
|
||||
|
||||
import android.content.Context
|
||||
import android.util.LruCache
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import mobi.librera.appcompose.booksync.FirestoreBooksRepository
|
||||
import mobi.librera.appcompose.core.spToPx
|
||||
import mobi.librera.appcompose.pdf.FormatRepository
|
||||
import mobi.librera.appcompose.room.Book
|
||||
import mobi.librera.appcompose.room.BookRepository
|
||||
import mobi.librera.appcompose.room.toBookState
|
||||
|
||||
|
||||
interface ModelActions {
|
||||
suspend fun renderPage(page: Int, width: Int): ImageBitmap
|
||||
fun onPageChanged(page: Int)
|
||||
fun saveBookState()
|
||||
fun onFontSizeChange(size: Int)
|
||||
}
|
||||
|
||||
data class BookReadUiState(
|
||||
val isLoading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val book: Book = Book.Empty,
|
||||
val bookPathToOpen: String = "",
|
||||
val openPage: Int = 0,
|
||||
val showControls: Boolean = true
|
||||
)
|
||||
|
||||
class BookReadViewModel(
|
||||
private val source: FormatRepository, private val bookRepository: BookRepository
|
||||
) : ViewModel(), ModelActions {
|
||||
|
||||
private val _uiState = MutableStateFlow(BookReadUiState())
|
||||
val uiState = _uiState.asStateFlow()
|
||||
|
||||
private val pageCache = LruCache<Int, ImageBitmap>(10)
|
||||
private var loadJob: Job? = null
|
||||
|
||||
fun openBook(path: String) {
|
||||
_uiState.value.book.let { book ->
|
||||
_uiState.update { it.copy(bookPathToOpen = path) }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun loadBook(context: Context, screenWidth: Int, screenHeight: Int) {
|
||||
//loadJob?.cancel()
|
||||
loadJob = viewModelScope.launch {
|
||||
try {
|
||||
|
||||
_uiState.update { it.copy(isLoading = true) }
|
||||
|
||||
|
||||
val book = if (uiState.value.book == Book.Empty) {
|
||||
withContext(Dispatchers.IO) {
|
||||
bookRepository.getBookByPath(_uiState.value.bookPathToOpen)
|
||||
} ?: Book.Empty
|
||||
} else {
|
||||
uiState.value.book
|
||||
}
|
||||
|
||||
|
||||
pageCache.evictAll()
|
||||
source.closeDocument()
|
||||
source.openDocument(
|
||||
_uiState.value.bookPathToOpen,
|
||||
screenWidth,
|
||||
screenHeight,
|
||||
book.fontSize.spToPx(context)
|
||||
)
|
||||
|
||||
val updatedBook = book.copy(
|
||||
time = System.currentTimeMillis(),
|
||||
isRecent = true,
|
||||
pageCount = source.pagesCount()
|
||||
)
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
book = updatedBook,
|
||||
openPage = (updatedBook.pageCount * updatedBook.progress).toInt(),
|
||||
error = null
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.update { it.copy(isLoading = false, error = e.message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun renderPage(page: Int, width: Int): ImageBitmap {
|
||||
return pageCache.get(page) ?: withContext(Dispatchers.IO) {
|
||||
source.renderPage(page, width).also { pageCache.put(page, it) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onPageChanged(page: Int) {
|
||||
println("onPageChanged $page")
|
||||
_uiState.value.book.let { book ->
|
||||
val newProgress = (page + 1).toFloat() / book.pageCount
|
||||
_uiState.update { it.copy(book = book.copy(progress = newProgress)) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun saveBookState() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
bookRepository.updateBook(_uiState.value.book)
|
||||
FirestoreBooksRepository.syncBook(_uiState.value.book.toBookState())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onFontSizeChange(size: Int) {
|
||||
viewModelScope.launch {
|
||||
_uiState.value.book.let { book ->
|
||||
_uiState.update {
|
||||
it.copy(book = book.copy(fontSize = size))
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.IO) {
|
||||
bookRepository.updateBook(_uiState.value.book)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun closeDocument() = viewModelScope.launch(Dispatchers.IO) {
|
||||
//source.closeDocument()
|
||||
_uiState.update { it.copy(book = Book.Empty) }
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package mobi.librera.appcompose.booksync
|
||||
|
||||
import androidx.core.net.toUri
|
||||
import com.google.firebase.Firebase
|
||||
import com.google.firebase.auth.auth
|
||||
import com.google.firebase.firestore.firestore
|
||||
import com.google.firebase.storage.storage
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import mobi.librera.appcompose.App
|
||||
import mobi.librera.appcompose.imageloader.AppImageLoader
|
||||
import mobi.librera.appcompose.room.BookState
|
||||
import java.io.File
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
val KEY_USERS = "users"
|
||||
val KEY_BOOKS = "books"
|
||||
|
||||
object FirestoreBooksRepository {
|
||||
private val db = Firebase.firestore
|
||||
private val collection = db.collection("book_state")
|
||||
|
||||
fun listenToNotes(onChange: (List<BookState>) -> Unit) {
|
||||
Firebase.auth.currentUser?.let { user ->
|
||||
db.collection(KEY_USERS).document(user.uid).collection(KEY_BOOKS)
|
||||
.addSnapshotListener { snapshot, _ ->
|
||||
if (snapshot != null && !snapshot.isEmpty) {
|
||||
val books = snapshot.toObjects(BookState::class.java)
|
||||
onChange(books)
|
||||
} else {
|
||||
onChange(emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun uploadImageToFirebase(
|
||||
book: File
|
||||
): String = suspendCancellableCoroutine { continuation ->
|
||||
val storageRef = Firebase.storage
|
||||
val userUID = Firebase.auth.currentUser?.uid
|
||||
|
||||
val imageRef = storageRef.getReference("users/$userUID/${book.name}")
|
||||
|
||||
println("uploadImageToFirebase ${imageRef.path} $imageRef")
|
||||
|
||||
val coverFile = AppImageLoader.get().getCacheFile(book.path)
|
||||
|
||||
|
||||
|
||||
|
||||
imageRef.putFile(coverFile.toUri()).addOnSuccessListener {
|
||||
imageRef.downloadUrl.addOnSuccessListener { downloadUrl ->
|
||||
continuation.resume(downloadUrl.toString(), onCancellation = { a, b, c -> {} })
|
||||
//imaUrl(downloadUrl.toString())
|
||||
println("uploadImageToFirebase onSuccess |$downloadUrl")
|
||||
}
|
||||
}.addOnFailureListener { e ->
|
||||
println("uploadImageToFirebase onError ${e.message}")
|
||||
continuation.resumeWithException(e)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
fun syncBook(book: BookState) = runBlocking {
|
||||
if (book.bookPaths[App.DEVICE_ID].isNullOrEmpty()) {
|
||||
println("Sync Book skip")
|
||||
return@runBlocking
|
||||
}
|
||||
|
||||
// launch {
|
||||
if (book.imageUrl.isEmpty()) {
|
||||
val bookPath = book.bookPaths[App.DEVICE_ID]
|
||||
if (!bookPath.isNullOrEmpty()) {
|
||||
val coverFile = File(bookPath)
|
||||
println("uploadImageToFirebase coverFile ${coverFile.isFile}")
|
||||
if (coverFile.isFile) {
|
||||
println("uploadImageToFirebase 2")
|
||||
|
||||
book.imageUrl = uploadImageToFirebase(coverFile)
|
||||
println("Sync image url $book.imageUrl")
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println("Sync Book $book")
|
||||
Firebase.auth.currentUser?.let { user ->
|
||||
db.collection(KEY_USERS).document(user.uid).collection(KEY_BOOKS)
|
||||
.document(book.fileName).set(book).addOnSuccessListener { documentReference ->
|
||||
println("Sync success")
|
||||
}.addOnFailureListener { e ->
|
||||
println("Sync fail ${e.printStackTrace()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package mobi.librera.appcompose.booksync
|
||||
|
||||
import android.content.Context
|
||||
import androidx.credentials.ClearCredentialStateRequest
|
||||
import androidx.credentials.CredentialManager
|
||||
import androidx.credentials.CustomCredential
|
||||
import androidx.credentials.GetCredentialRequest
|
||||
import androidx.credentials.GetCredentialResponse
|
||||
import androidx.credentials.exceptions.GetCredentialException
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import coil3.Uri
|
||||
import coil3.toCoilUri
|
||||
import com.google.android.libraries.identity.googleid.GetGoogleIdOption
|
||||
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
|
||||
import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException
|
||||
import com.google.firebase.Firebase
|
||||
import com.google.firebase.auth.FirebaseUser
|
||||
import com.google.firebase.auth.GoogleAuthProvider
|
||||
import com.google.firebase.auth.auth
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import mobi.librera.appcompose.App
|
||||
import mobi.librera.appcompose.R
|
||||
import mobi.librera.appcompose.room.BookRepository
|
||||
|
||||
data class User(val name: String, val email: String, val photoUrl: Uri?)
|
||||
|
||||
fun FirebaseUser?.toUser(): User = User(
|
||||
this?.displayName.orEmpty(), this?.email.orEmpty(), this?.photoUrl?.toCoilUri()
|
||||
)
|
||||
|
||||
sealed class SingInState {
|
||||
data object NotSignIn : SingInState()
|
||||
data class Success(val user: User) : SingInState()
|
||||
data class Error(val message: String) : SingInState()
|
||||
}
|
||||
|
||||
|
||||
class GoogleSingInViewModel(private val bookRepository: BookRepository) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow<SingInState>(SingInState.NotSignIn)
|
||||
val singInState: StateFlow<SingInState> = _state
|
||||
|
||||
|
||||
private fun observeFirestoreAndSyncToRoom() {
|
||||
println("Sync: ${App.DEVICE_ID}")
|
||||
FirestoreBooksRepository.listenToNotes { remoteBooks ->
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val localBooks = bookRepository.getAllBookState()
|
||||
//val all = bookRepository.getAllBooks().collect()
|
||||
|
||||
println("Sync remoteBooks ${remoteBooks.size}")
|
||||
println("Sync localBooks ${localBooks.size}")
|
||||
|
||||
val allKeys = localBooks.map { it.fileName }.toMutableSet()
|
||||
allKeys.addAll(remoteBooks.map { it.fileName })
|
||||
|
||||
|
||||
allKeys.forEach { fileName ->
|
||||
val remoteBook = remoteBooks.find { it.fileName == fileName }
|
||||
val localBook = localBooks.find { it.fileName == fileName }
|
||||
if (remoteBook == null && localBook == null) {
|
||||
println("Sync skip")
|
||||
} else if (remoteBook == null && localBook != null) {
|
||||
FirestoreBooksRepository.syncBook(localBook)
|
||||
|
||||
|
||||
} else if (localBook == null && remoteBook != null) {
|
||||
|
||||
|
||||
bookRepository.insertBookState(remoteBook)
|
||||
println("Sync Local insertBook $remoteBook")
|
||||
}
|
||||
if (remoteBook != null && localBook != null) {
|
||||
if (remoteBook.time == localBook.time) {
|
||||
println("Sync skip by time")
|
||||
} else if (remoteBook.time > localBook.time) {
|
||||
remoteBook.bookPaths = remoteBook.bookPaths.plus(localBook.bookPaths)
|
||||
bookRepository.insertBookState(remoteBook)
|
||||
println("Sync Local insertBook2 $localBook")
|
||||
} else {
|
||||
localBook.bookPaths = localBook.bookPaths.plus(remoteBook.bookPaths)
|
||||
FirestoreBooksRepository.syncBook(localBook)
|
||||
println("Sync Remote syncBook2 $localBook")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
init {
|
||||
observeFirestoreAndSyncToRoom()
|
||||
}
|
||||
|
||||
|
||||
fun checkSingInState() {
|
||||
val auth = Firebase.auth
|
||||
|
||||
val currentUser = auth.currentUser
|
||||
if (currentUser == null) {
|
||||
_state.value = SingInState.NotSignIn
|
||||
} else {
|
||||
_state.value = SingInState.Success(currentUser.toUser())
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun signInWithGoogle(context: Context) {
|
||||
val credentialManager = CredentialManager.create(context)
|
||||
|
||||
val serverClientId = context.getString(R.string.default_web_client_id)
|
||||
|
||||
val googleIdOption: GetGoogleIdOption =
|
||||
GetGoogleIdOption.Builder().setFilterByAuthorizedAccounts(false)
|
||||
.setServerClientId(serverClientId).build()
|
||||
|
||||
val request: GetCredentialRequest =
|
||||
GetCredentialRequest.Builder().addCredentialOption(googleIdOption).build()
|
||||
|
||||
coroutineScope {
|
||||
try {
|
||||
val result = credentialManager.getCredential(
|
||||
request = request,
|
||||
context = context,
|
||||
)
|
||||
|
||||
handleSignInWithGoogleOption(result)
|
||||
} catch (e: GetCredentialException) {
|
||||
e.printStackTrace()
|
||||
_state.value = SingInState.Error(e.message.orEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun handleSignInWithGoogleOption(result: GetCredentialResponse) {
|
||||
when (val credential = result.credential) {
|
||||
is CustomCredential -> {
|
||||
if (credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) {
|
||||
try {
|
||||
val googleIdTokenCredential =
|
||||
GoogleIdTokenCredential.createFrom(credential.data)
|
||||
|
||||
firebaseAuthWithGoogle(googleIdTokenCredential.idToken)
|
||||
|
||||
} catch (e: GoogleIdTokenParsingException) {
|
||||
e.printStackTrace()
|
||||
_state.value = SingInState.Error(e.message.orEmpty())
|
||||
}
|
||||
} else {
|
||||
_state.value = SingInState.Error("Unexpected type of credential")
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
_state.value = SingInState.Error("Unexpected Error")
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun firebaseAuthWithGoogle(idToken: String) {
|
||||
val credential = GoogleAuthProvider.getCredential(idToken, null)
|
||||
val auth = Firebase.auth
|
||||
auth.signInWithCredential(credential).addOnCompleteListener { task ->
|
||||
if (task.isSuccessful) {
|
||||
val user = auth.currentUser
|
||||
println("current user $user")
|
||||
_state.value = SingInState.Success(user.toUser())
|
||||
|
||||
observeFirestoreAndSyncToRoom()
|
||||
|
||||
} else {
|
||||
_state.value = SingInState.Error(task.exception?.message.orEmpty())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun signOut(context: Context) {
|
||||
val auth = Firebase.auth
|
||||
|
||||
val credentialManager = CredentialManager.create(context)
|
||||
|
||||
val clearRequest = ClearCredentialStateRequest()
|
||||
coroutineScope {
|
||||
credentialManager.clearCredentialState(clearRequest)
|
||||
}
|
||||
|
||||
auth.signOut()
|
||||
|
||||
_state.value = SingInState.NotSignIn
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package mobi.librera.appcompose.components
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
fun AppNavigationBar() {
|
||||
NavigationBar {
|
||||
NavigationBarItem(selected = true, onClick = {}, icon = {
|
||||
Icon(
|
||||
Icons.Filled.Home, contentDescription = ""
|
||||
)
|
||||
}, label = { Text("Library") })
|
||||
NavigationBarItem(selected = false, onClick = {}, icon = {
|
||||
Icon(
|
||||
Icons.Filled.DateRange, contentDescription = ""
|
||||
)
|
||||
}, label = { Text("Folder") })
|
||||
NavigationBarItem(selected = false, onClick = {}, icon = {
|
||||
Icon(
|
||||
Icons.Filled.Star, contentDescription = ""
|
||||
)
|
||||
}, label = { Text("Recent") })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package mobi.librera.appcompose.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.StarBorder
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
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.layout.ContentScale
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import mobi.librera.appcompose.bookgrid.BookGridViewModel
|
||||
import mobi.librera.appcompose.core.ifOr
|
||||
import mobi.librera.appcompose.imageloader.MyAsyncImageView
|
||||
import mobi.librera.appcompose.room.Book
|
||||
import mobi.librera.appcompose.room.toBookState
|
||||
|
||||
@Composable
|
||||
fun BookGrid(
|
||||
dataModel: BookGridViewModel,
|
||||
books: List<Book>,
|
||||
state: LazyGridState,
|
||||
onStarClicked: (Book) -> Unit,
|
||||
onBookClicked: (Book) -> Unit,
|
||||
) {
|
||||
//val books by dataModel.getAllBooks.collectAsState()
|
||||
|
||||
//val state = listGridState
|
||||
|
||||
// val state = rememberLazyGridState(
|
||||
// dataModel.initialFirstVisibleItemIndex,
|
||||
// dataModel.initialFirstVisibleItemScrollOffset
|
||||
// )
|
||||
|
||||
// LaunchedEffect(state.firstVisibleItemIndex, dataModel.initialFirstVisibleItemScrollOffset) {
|
||||
// dataModel.initialFirstVisibleItemIndex = state.firstVisibleItemIndex
|
||||
// dataModel.initialFirstVisibleItemScrollOffset = state.firstVisibleItemScrollOffset
|
||||
// }
|
||||
|
||||
LazyVerticalGrid(
|
||||
state = state, columns = GridCells.Adaptive(minSize = 110.dp)
|
||||
) {
|
||||
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
SelectedBooksBar(
|
||||
dataModel, false,
|
||||
onOpenBook = { onBookClicked(Book(it)) },
|
||||
onHomeClick = { }
|
||||
)
|
||||
}
|
||||
|
||||
items(books.size) {
|
||||
val book = books[it]
|
||||
|
||||
val roundShapeRadius = 20.dp
|
||||
|
||||
Card(
|
||||
onClick = {
|
||||
onBookClicked(book)
|
||||
//dataModel.currentBookPath = book.path
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(4.dp),
|
||||
shape = RoundedCornerShape(roundShapeRadius)
|
||||
|
||||
) {
|
||||
Column {
|
||||
Box {
|
||||
MyAsyncImageView(
|
||||
bookState = book.toBookState(),
|
||||
contentDescription = "",
|
||||
modifier = Modifier
|
||||
.height(150.dp)
|
||||
.fillMaxWidth()
|
||||
.background(Color.White),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
onStarClicked(book)
|
||||
//dataModel.updateStar(book, !book.isSelected)
|
||||
},
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(bottomEnd = roundShapeRadius))
|
||||
.background(color = Color.Green.copy(alpha = 0.8f))
|
||||
|
||||
) {
|
||||
Icon(
|
||||
imageVector = book.isSelected.ifOr(
|
||||
Icons.Default.Star, Icons.Default.StarBorder
|
||||
),
|
||||
contentDescription = "Star",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val progress = book.progress
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(36.dp)
|
||||
.fillMaxWidth(progress)
|
||||
.background(color = Color.Blue.copy(alpha = 0.3f))
|
||||
)
|
||||
Text(
|
||||
book.path.substringAfterLast("/"),
|
||||
modifier = Modifier.padding(8.dp),
|
||||
maxLines = 1,
|
||||
style = TextStyle(fontSize = 14.sp)
|
||||
)
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package mobi.librera.appcompose.components
|
||||
|
||||
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.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import mobi.librera.appcompose.bookgrid.BookGridViewModel
|
||||
import mobi.librera.appcompose.core.DEFAULT_SEARCH_DIR
|
||||
import mobi.librera.appcompose.ui.theme.LibreraTheme
|
||||
|
||||
@Composable
|
||||
fun BookSearchBar(dataModel: BookGridViewModel) {
|
||||
val context = LocalContext.current
|
||||
val searchQuery by dataModel.currentSearchQuery.collectAsState()
|
||||
val books by dataModel.getAllBooks.collectAsState()
|
||||
val searchPath by dataModel.searchPath.collectAsState()
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var showPopup by remember { mutableStateOf(false) }
|
||||
var showFileChooser by remember { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp, bottom = 4.dp)
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
|
||||
|
||||
OutlinedTextField(
|
||||
value = searchQuery,
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(50.dp)
|
||||
.padding(start = 8.dp)
|
||||
.minimumInteractiveComponentSize(),
|
||||
shape = CircleShape,
|
||||
textStyle = TextStyle(fontSize = 14.sp),
|
||||
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = "Search",
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (searchQuery.isNotEmpty()) {
|
||||
IconButton(onClick = {
|
||||
dataModel.updateSearchQuery("")
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Clear,
|
||||
contentDescription = "Clear",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "${books.size}",
|
||||
modifier = Modifier.padding(16.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
},
|
||||
onValueChange = { dataModel.updateSearchQuery(it) },
|
||||
placeholder = { Text("Search...", style = MaterialTheme.typography.labelMedium) },
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
focusedBorderColor = Color.Transparent,
|
||||
focusedContainerColor = Color.White,
|
||||
unfocusedContainerColor = Color.White
|
||||
),
|
||||
|
||||
)
|
||||
|
||||
IconButton(onClick = {
|
||||
showPopup = true
|
||||
}, modifier = Modifier) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Settings, contentDescription = ""
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
if (showFileChooser) {
|
||||
Dialog(onDismissRequest = { showFileChooser = false }) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(400.dp)
|
||||
.padding(16.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
CustomFolderChooser(onFolderSelected = {
|
||||
dataModel.updateSearchPath(it.path)
|
||||
showFileChooser = false
|
||||
}, initialPath = DEFAULT_SEARCH_DIR)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (showPopup) {
|
||||
Dialog(onDismissRequest = { showPopup = false }) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(400.dp)
|
||||
.padding(16.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.padding(10.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text("Books", style = MaterialTheme.typography.labelMedium)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
dataModel.isSearchEPUB,
|
||||
onCheckedChange = { dataModel.isSearchEPUB = it })
|
||||
Text("EPUB")
|
||||
Spacer(modifier = Modifier.width(20.dp))
|
||||
Checkbox(
|
||||
dataModel.isSearchPDF, onCheckedChange = { dataModel.isSearchPDF = it })
|
||||
Text("PDF")
|
||||
}
|
||||
Button(onClick = {
|
||||
dataModel.searchBooks()
|
||||
showPopup = false
|
||||
}) {
|
||||
Text("Update search")
|
||||
}
|
||||
|
||||
val isDark by dataModel.isDarkModeEnabled.collectAsState()
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
isDark, onCheckedChange = {
|
||||
dataModel.updateDarkMode(it)
|
||||
showPopup = false
|
||||
})
|
||||
Text("Dark mode")
|
||||
}
|
||||
|
||||
|
||||
Text("Folder: $searchPath")
|
||||
|
||||
Button(onClick = { showFileChooser = true }) {
|
||||
Text("Choose Folder")
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@Preview(showBackground = false)
|
||||
@Composable
|
||||
private fun BookSearchBarPreview() {
|
||||
LibreraTheme {
|
||||
///BookSearchBar(bookCount = 12, onTextChanged = {}, onSearchBook = {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package mobi.librera.appcompose.components
|
||||
|
||||
import android.os.Environment
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.items
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.io.File
|
||||
|
||||
@Composable
|
||||
fun CustomFolderChooser(
|
||||
onFolderSelected: (File) -> Unit,
|
||||
initialPath: File = Environment.getExternalStorageDirectory()
|
||||
) {
|
||||
var currentDirectory by remember { mutableStateOf(initialPath) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
"${currentDirectory.absolutePath}",
|
||||
style = TextStyle(fontWeight = FontWeight.Bold),
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
// Navigate up button (if not at the root or external storage root)
|
||||
if (currentDirectory.parentFile != null && !currentDirectory.absolutePath.equals(
|
||||
Environment.getExternalStorageDirectory().absolutePath,
|
||||
ignoreCase = true
|
||||
)
|
||||
) {
|
||||
Button(
|
||||
onClick = { currentDirectory = currentDirectory.parentFile!! },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Go Up (..)")
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
val folders = remember(currentDirectory) {
|
||||
currentDirectory.listFiles()
|
||||
?.filter { it.isDirectory && it.canRead() }
|
||||
?.sortedBy { it.name.lowercase() }
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
LazyColumn(modifier = Modifier.weight(1f)) {
|
||||
|
||||
|
||||
if (folders.isEmpty() && currentDirectory.isDirectory) {
|
||||
item {
|
||||
Text(
|
||||
"No subfolders found in this directory.",
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
} else if (!currentDirectory.isDirectory) {
|
||||
item {
|
||||
Text("Cannot read this directory.", modifier = Modifier.padding(8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
items(folders) { folder ->
|
||||
Text(
|
||||
text = "📁 ${folder.name}",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { currentDirectory = folder }
|
||||
.padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = { onFolderSelected(currentDirectory) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = currentDirectory.isDirectory && currentDirectory.canRead() // Ensure it's a valid folder
|
||||
) {
|
||||
Text("Select This Folder")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package mobi.librera.appcompose.components
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
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.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun NumberPickerDialog(
|
||||
showDialog: Boolean,
|
||||
onDismissRequest: () -> Unit,
|
||||
onNumberSelected: (Int) -> Unit,
|
||||
initialNumber: Int,
|
||||
range: IntRange = 0..100,
|
||||
itemHeight: Dp = 48.dp, // Height of each number row
|
||||
visibleItems: Int = 5 // Must be odd for proper centering (e.g., 5 means 2 above, current, 2 below)
|
||||
) {
|
||||
if (!showDialog) return
|
||||
|
||||
val numbers = remember { range.toList() }
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
// Calculate the index of the initial number to scroll to
|
||||
val initialIndex = remember(initialNumber, numbers) {
|
||||
numbers.indexOf(initialNumber).coerceAtLeast(0)
|
||||
}
|
||||
|
||||
// State to hold the currently selected number (based on scroll position)
|
||||
var selectedNumber by remember { mutableIntStateOf(initialNumber) }
|
||||
|
||||
// Calculate padding needed to center the selected item
|
||||
val halfVisibleItems = visibleItems / 2
|
||||
val contentPadding = PaddingValues(vertical = itemHeight * halfVisibleItems)
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(listState, numbers) {
|
||||
// Scroll to the initial number, compensating for padding
|
||||
val targetScrollIndex = initialIndex - halfVisibleItems
|
||||
if (targetScrollIndex >= 0 && targetScrollIndex < numbers.size) {
|
||||
listState.scrollToItem(targetScrollIndex)
|
||||
}
|
||||
|
||||
snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
|
||||
.debounce(100) // Debounce to prevent rapid updates during scrolling
|
||||
.collect { (firstVisibleItemIndex, firstVisibleItemScrollOffset) ->
|
||||
// Calculate the true center item index
|
||||
val centerIndex = firstVisibleItemIndex + halfVisibleItems
|
||||
if (centerIndex >= 0 && centerIndex < numbers.size) {
|
||||
selectedNumber = numbers[centerIndex]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Dialog(onDismissRequest = onDismissRequest) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "Select a Number",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(itemHeight * visibleItems) // Set height based on visible items
|
||||
.width(100.dp) // Fixed width for the picker
|
||||
.background(Color.LightGray.copy(alpha = 0.2f), RoundedCornerShape(4.dp))
|
||||
) {
|
||||
// Highlight for the selected item
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(itemHeight)
|
||||
.align(Alignment.Center)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
|
||||
RoundedCornerShape(4.dp)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
contentPadding = contentPadding, // Apply padding to center content
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
itemsIndexed(numbers) { index, number ->
|
||||
val isSelected = remember {
|
||||
derivedStateOf { number == selectedNumber }
|
||||
}.value
|
||||
|
||||
Text(
|
||||
text = number.toString(),
|
||||
style = if (isSelected) MaterialTheme.typography.headlineMedium.copy(
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
else MaterialTheme.typography.bodyLarge.copy(color = Color.Black),
|
||||
modifier = Modifier
|
||||
.height(itemHeight)
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
// Scroll to clicked item, centering it
|
||||
val targetScrollIndex = index - halfVisibleItems
|
||||
if (targetScrollIndex >= 0 && targetScrollIndex < numbers.size) {
|
||||
coroutineScope.launch {
|
||||
listState.animateScrollToItem(targetScrollIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
.wrapContentHeight(align = Alignment.CenterVertically)
|
||||
.wrapContentWidth(align = Alignment.CenterHorizontally)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Button(
|
||||
onClick = onDismissRequest,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("Cancel")
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = { onNumberSelected(selectedNumber) },
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("OK")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Example usage in your main Composable
|
||||
@Composable
|
||||
fun AppContent() {
|
||||
var showPicker by remember { mutableStateOf(false) }
|
||||
var selectedValue by remember { mutableIntStateOf(25) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(50.dp))
|
||||
Text("Selected Number: $selectedValue", style = MaterialTheme.typography.headlineSmall)
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
Button(onClick = { showPicker = true }) {
|
||||
Text("Open Number Picker")
|
||||
}
|
||||
|
||||
NumberPickerDialog(
|
||||
showDialog = showPicker,
|
||||
onDismissRequest = { showPicker = false },
|
||||
onNumberSelected = { number ->
|
||||
selectedValue = number
|
||||
showPicker = false
|
||||
},
|
||||
initialNumber = selectedValue,
|
||||
range = 0..99
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun PreviewNumberPickerDialog() {
|
||||
AppContent()
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package mobi.librera.appcompose.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
|
||||
@Composable
|
||||
fun ObserveLifecycleEvents(
|
||||
onPause: () -> Unit = {},
|
||||
onStop: () -> Unit = {},
|
||||
onResume: () -> Unit = {},
|
||||
onDestroy: () -> Unit = {}
|
||||
) {
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_PAUSE -> onPause()
|
||||
Lifecycle.Event.ON_STOP -> onStop()
|
||||
Lifecycle.Event.ON_RESUME -> onResume()
|
||||
Lifecycle.Event.ON_DESTROY -> onDestroy()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package mobi.librera.appcompose.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import mobi.librera.appcompose.bookgrid.BookGridViewModel
|
||||
import mobi.librera.appcompose.imageloader.MyAsyncImageView
|
||||
import mobi.librera.appcompose.room.toBookState
|
||||
|
||||
|
||||
@Composable
|
||||
fun SelectedBooksBar(
|
||||
dataModel: BookGridViewModel,
|
||||
isHomeVisible: Boolean,
|
||||
onHomeClick: () -> Unit,
|
||||
onOpenBook: (String) -> Unit,
|
||||
) {
|
||||
|
||||
|
||||
val selectedBooks by dataModel.getAllSelectedBooks.collectAsState()
|
||||
val recentBooks by dataModel.getAllRecentBooks.collectAsState()
|
||||
val scrollState = rememberScrollState()
|
||||
val scrollState2 = rememberScrollState()
|
||||
|
||||
Column {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
|
||||
if (isHomeVisible) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(60.dp)
|
||||
.padding(4.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Blue.copy(alpha = 0.8f))
|
||||
.clickable { onHomeClick() }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Home,
|
||||
contentDescription = "",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Row(modifier = Modifier.horizontalScroll(scrollState)) {
|
||||
|
||||
|
||||
selectedBooks.forEach { book ->
|
||||
|
||||
MyAsyncImageView(
|
||||
bookState = book.toBookState(),
|
||||
contentDescription = "",
|
||||
modifier = Modifier
|
||||
.height(60.dp)
|
||||
.width(60.dp)
|
||||
.padding(4.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.clickable {
|
||||
onOpenBook(book.path)
|
||||
},
|
||||
|
||||
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
if (false) {
|
||||
Row(
|
||||
Modifier
|
||||
.width(140.dp)
|
||||
.height(42.dp)
|
||||
.padding(2.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color.Blue.copy(alpha = 0.6f)),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
|
||||
) {
|
||||
MyAsyncImageView(
|
||||
bookState = book.toBookState(),
|
||||
|
||||
contentDescription = "",
|
||||
modifier = Modifier
|
||||
.height(42.dp)
|
||||
.width(42.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable {
|
||||
onOpenBook(book.path)
|
||||
},
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
Text(
|
||||
book.path.substringAfterLast("/"),
|
||||
maxLines = 1,
|
||||
modifier = Modifier
|
||||
.padding(start = 16.dp)
|
||||
.weight(1f)
|
||||
.clickable {
|
||||
onOpenBook(book.path)
|
||||
|
||||
},
|
||||
style = TextStyle(
|
||||
color = Color.White, fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
dataModel.updateStar(book, !book.isSelected)
|
||||
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
tint = Color.White,
|
||||
contentDescription = "Close",
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.horizontalScroll(scrollState2)) {
|
||||
recentBooks.forEach { book ->
|
||||
MyAsyncImageView(
|
||||
bookState = book.toBookState(),
|
||||
contentDescription = "",
|
||||
modifier = Modifier
|
||||
.height(60.dp)
|
||||
.width(60.dp)
|
||||
.padding(4.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.clickable {
|
||||
onOpenBook(book.path)
|
||||
},
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package mobi.librera.appcompose.core
|
||||
|
||||
import android.os.Environment
|
||||
import java.io.File
|
||||
|
||||
val DEFAULT_SEARCH_DIR = Environment.getExternalStoragePublicDirectory(
|
||||
Environment.DIRECTORY_DOWNLOADS
|
||||
)
|
||||
|
||||
class FilesRepository() {
|
||||
|
||||
fun getAllBooks(isPDF: Boolean, isEPUB: Boolean, searchDir: String): List<String> {
|
||||
val foundFiles = mutableListOf<String>()
|
||||
|
||||
|
||||
val downloadsDir =
|
||||
if (searchDir.toFile().isDirectory) searchDir.toFile() else DEFAULT_SEARCH_DIR
|
||||
|
||||
println("Search Dir: $downloadsDir")
|
||||
|
||||
downloadsDir.walkTopDown().filter { it.isFile }.forEach { file ->
|
||||
val ext = file.extension.lowercase()
|
||||
if (isPDF && ext == "pdf") {
|
||||
foundFiles.add(file.absolutePath)
|
||||
} else if (isEPUB && ext == "epub") {
|
||||
foundFiles.add(file.absolutePath)
|
||||
}
|
||||
}
|
||||
return foundFiles
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun String.toFile(): File = File(this)
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package mobi.librera.appcompose.core
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asAndroidBitmap
|
||||
import coil3.ImageLoader
|
||||
import coil3.annotation.InternalCoilApi
|
||||
import coil3.asImage
|
||||
import coil3.decode.DecodeResult
|
||||
import coil3.decode.Decoder
|
||||
import coil3.decode.ImageSource
|
||||
import coil3.fetch.SourceFetchResult
|
||||
import coil3.request.Options
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import mobi.librera.mupdf.fz.lib.openDocument
|
||||
|
||||
val mutex = Mutex()
|
||||
|
||||
class MupdfPdfDecoder(
|
||||
private val source: ImageSource,
|
||||
private val options: Options,
|
||||
) : Decoder {
|
||||
|
||||
|
||||
@OptIn(InternalCoilApi::class)
|
||||
override suspend fun decode(): DecodeResult {
|
||||
if (!source.file().toFile().isFile) {
|
||||
return DecodeResult(
|
||||
image = ImageBitmap(1, 1).asAndroidBitmap().asImage(shareable = true),
|
||||
isSampled = true,
|
||||
)
|
||||
}
|
||||
mutex.withLock {
|
||||
println("Decode book begin ${source.file()} ${Thread.currentThread()}")
|
||||
|
||||
|
||||
val imageBitmap: Bitmap
|
||||
|
||||
|
||||
val muDoc = openDocument(
|
||||
source.file().toString(),
|
||||
byteArrayOf(0),
|
||||
128 * 4,
|
||||
180 * 4,
|
||||
24
|
||||
)
|
||||
|
||||
println("Decode pageCount ${muDoc.pageCount}")
|
||||
if (muDoc.pageCount == 0) {
|
||||
println("Decode ERROR")
|
||||
imageBitmap = ImageBitmap(1, 1).asAndroidBitmap()
|
||||
} else {
|
||||
println("Decode renderPage begin ")
|
||||
imageBitmap = muDoc.renderPage(0, 128 * 4)
|
||||
println("Decode renderPage end ")
|
||||
muDoc.close();
|
||||
println("Decode renderPage close ")
|
||||
}
|
||||
|
||||
|
||||
|
||||
println("Decode book end ${source.file()} ${Thread.currentThread()}")
|
||||
|
||||
|
||||
return DecodeResult(
|
||||
image = imageBitmap.asImage(shareable = true),
|
||||
isSampled = false,
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
class Factory : Decoder.Factory {
|
||||
override fun create(
|
||||
result: SourceFetchResult,
|
||||
options: Options,
|
||||
imageLoader: ImageLoader
|
||||
): Decoder? {
|
||||
println("Decoder.Factory mime: |${result.mimeType}|")
|
||||
return if (true || result.mimeType == "application/pdf" || result.mimeType == "application/epub+zip") {
|
||||
MupdfPdfDecoder(result.source, options)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package mobi.librera.appcompose.core
|
||||
|
||||
import android.graphics.pdf.PdfRenderer
|
||||
import android.os.ParcelFileDescriptor
|
||||
import androidx.core.graphics.createBitmap
|
||||
import coil3.ImageLoader
|
||||
import coil3.asImage
|
||||
import coil3.decode.DecodeResult
|
||||
import coil3.decode.Decoder
|
||||
import coil3.decode.ImageSource
|
||||
import coil3.fetch.SourceFetchResult
|
||||
import coil3.request.Options
|
||||
|
||||
|
||||
class NativePdfDecoder(
|
||||
private val source: ImageSource,
|
||||
private val options: Options,
|
||||
) : Decoder {
|
||||
override suspend fun decode(): DecodeResult {
|
||||
|
||||
val fileDescriptor =
|
||||
ParcelFileDescriptor.open(source.file().toFile(), ParcelFileDescriptor.MODE_READ_ONLY)
|
||||
val renderer = PdfRenderer(fileDescriptor)
|
||||
val page = renderer.openPage(0)
|
||||
|
||||
val bitmap = createBitmap(page.width, page.height)
|
||||
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
|
||||
page.close()
|
||||
renderer.close()
|
||||
fileDescriptor.close()
|
||||
|
||||
return DecodeResult(
|
||||
image = bitmap.asImage(shareable = true),
|
||||
isSampled = true,
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
|
||||
class Factory : Decoder.Factory {
|
||||
override fun create(
|
||||
result: SourceFetchResult,
|
||||
options: Options,
|
||||
imageLoader: ImageLoader
|
||||
): Decoder {
|
||||
return NativePdfDecoder(result.source, options)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package mobi.librera.appcompose.core
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.pdf.PdfRenderer
|
||||
import android.os.ParcelFileDescriptor
|
||||
import androidx.core.graphics.createBitmap
|
||||
import java.io.File
|
||||
|
||||
|
||||
fun renderFirstPage(file: File): Bitmap {
|
||||
val fileDescriptor = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
|
||||
val renderer = PdfRenderer(fileDescriptor)
|
||||
val page = renderer.openPage(0)
|
||||
|
||||
val bitmap = createBitmap(page.width, page.height)
|
||||
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
|
||||
page.close()
|
||||
renderer.close()
|
||||
fileDescriptor.close()
|
||||
return bitmap
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package mobi.librera.appcompose.core
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.util.TypedValue
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
fun <T> Iterable<T>.firstOrDefault(default: T): T {
|
||||
return this.firstOrNull() ?: default
|
||||
}
|
||||
|
||||
fun <T> Boolean.ifOr(valueIfTrue: T, valueIfFalse: T): T {
|
||||
return if (this) valueIfTrue else valueIfFalse
|
||||
}
|
||||
|
||||
|
||||
fun Int.spToPx(context: Context): Int {
|
||||
return TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_SP,
|
||||
this.toFloat(),
|
||||
context.resources.displayMetrics
|
||||
).roundToInt()
|
||||
}
|
||||
|
||||
fun getPathFromUri(context: Context, uri: Uri): String? {
|
||||
var path: String? = null
|
||||
val projection = arrayOf(MediaStore.Images.Media.DATA)
|
||||
val cursor = context.contentResolver.query(uri, projection, null, null, null)
|
||||
cursor?.use {
|
||||
if (it.moveToFirst()) {
|
||||
val columnIndex = it.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)
|
||||
path = it.getString(columnIndex)
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package mobi.librera.appcompose.datastore
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
val Context.userPreferencesDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
name = "user_settings"
|
||||
)
|
||||
|
||||
class UserPreferencesRepository(private val context: Context) {
|
||||
|
||||
private object PreferencesKeys {
|
||||
val USER_NAME = stringPreferencesKey("user_name")
|
||||
val IS_DARK_MODE_ENABLED = booleanPreferencesKey("is_dark_mode_enabled")
|
||||
val APP_LAUNCH_COUNT = intPreferencesKey("app_launch_count")
|
||||
}
|
||||
|
||||
val userName: Flow<String> = context.userPreferencesDataStore.data
|
||||
.map { preferences ->
|
||||
preferences[PreferencesKeys.USER_NAME] ?: "Guest"
|
||||
}
|
||||
|
||||
val isDarkModeEnabled: Flow<Boolean> = context.userPreferencesDataStore.data
|
||||
.map { preferences ->
|
||||
preferences[PreferencesKeys.IS_DARK_MODE_ENABLED] ?: false
|
||||
}
|
||||
|
||||
val appLaunchCount: Flow<Int> = context.userPreferencesDataStore.data
|
||||
.map { preferences ->
|
||||
preferences[PreferencesKeys.APP_LAUNCH_COUNT] ?: 0
|
||||
}
|
||||
|
||||
suspend fun saveUserName(name: String) {
|
||||
context.userPreferencesDataStore.edit { settings ->
|
||||
settings[PreferencesKeys.USER_NAME] = name
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setDarkModeEnabled(enabled: Boolean) {
|
||||
context.userPreferencesDataStore.edit { settings ->
|
||||
settings[PreferencesKeys.IS_DARK_MODE_ENABLED] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun incrementAppLaunchCount() {
|
||||
context.userPreferencesDataStore.edit { settings ->
|
||||
val currentCount = settings[PreferencesKeys.APP_LAUNCH_COUNT] ?: 0
|
||||
settings[PreferencesKeys.APP_LAUNCH_COUNT] = currentCount + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package mobi.librera.appcompose.di
|
||||
|
||||
import android.util.LruCache
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asAndroidBitmap
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.Image
|
||||
import coil3.ImageLoader
|
||||
import coil3.asImage
|
||||
import coil3.decode.DataSource
|
||||
import coil3.decode.Decoder
|
||||
import coil3.disk.DiskCache
|
||||
import coil3.intercept.Interceptor
|
||||
import coil3.memory.MemoryCache
|
||||
import coil3.request.CachePolicy
|
||||
import coil3.request.ImageResult
|
||||
import coil3.request.SuccessResult
|
||||
import coil3.request.crossfade
|
||||
import coil3.size.Precision
|
||||
import coil3.util.DebugLogger
|
||||
import mobi.librera.appcompose.bookgrid.BookGridViewModel
|
||||
import mobi.librera.appcompose.bookread.BookReadViewModel
|
||||
import mobi.librera.appcompose.booksync.GoogleSingInViewModel
|
||||
import mobi.librera.appcompose.core.FilesRepository
|
||||
import mobi.librera.appcompose.core.MupdfPdfDecoder
|
||||
import mobi.librera.appcompose.datastore.UserPreferencesRepository
|
||||
import mobi.librera.appcompose.pdf.FormatRepository
|
||||
import mobi.librera.appcompose.pdf.MupdfRepository
|
||||
import mobi.librera.appcompose.room.AppDatabase
|
||||
import mobi.librera.appcompose.room.BookDao
|
||||
import mobi.librera.appcompose.room.BookRepository
|
||||
import mobi.librera.appcompose.room.buildDatabase
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import org.koin.android.ext.koin.androidApplication
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.module.dsl.viewModelOf
|
||||
import org.koin.dsl.KoinAppDeclaration
|
||||
import org.koin.dsl.module
|
||||
import java.io.File
|
||||
|
||||
val appModule = module {
|
||||
|
||||
single<AppDatabase> { buildDatabase(androidApplication()) }
|
||||
|
||||
single<BookDao> {
|
||||
get<AppDatabase>().bookDao()
|
||||
}
|
||||
|
||||
single<BookRepository> {
|
||||
BookRepository(get())
|
||||
}
|
||||
single { FilesRepository() }
|
||||
single { GoogleSingInViewModel(get()) }
|
||||
single { UserPreferencesRepository(get()) }
|
||||
|
||||
|
||||
//single<Decoder.Factory> { NativePdfDecoder.Factory() }
|
||||
//single<FormatRepository> { NativePDFRepository() }
|
||||
|
||||
single<FormatRepository> { MupdfRepository() }
|
||||
single<Decoder.Factory> { MupdfPdfDecoder.Factory() }
|
||||
|
||||
|
||||
viewModelOf(::BookGridViewModel)
|
||||
viewModelOf(::BookReadViewModel)
|
||||
}
|
||||
|
||||
val imageLoaderModule = module {
|
||||
single<ImageLoader> {
|
||||
val context = get<android.content.Context>()
|
||||
val diskCache = File(context.cacheDir, "cache_pages").toOkioPath()
|
||||
diskCache.toFile().mkdirs()
|
||||
println("imageLoaderModule $diskCache ${diskCache.toFile().isDirectory}")
|
||||
|
||||
ImageLoader.Builder(context)
|
||||
.components {
|
||||
//add(CustomCacheInterceptor())
|
||||
add(get<Decoder.Factory>())
|
||||
}
|
||||
.logger(DebugLogger())
|
||||
.memoryCache {
|
||||
MemoryCache.Builder()
|
||||
.maxSizePercent(context, 0.50)
|
||||
.build()
|
||||
}
|
||||
.diskCache {
|
||||
DiskCache.Builder()
|
||||
.directory(diskCache)
|
||||
.maxSizeBytes(500L * 1024 * 1024) // 500 MB
|
||||
.build()
|
||||
}
|
||||
.placeholder(
|
||||
ImageBitmap(42.dp.value.toInt(), 42.dp.value.toInt()).asAndroidBitmap()
|
||||
.asImage(true)
|
||||
)
|
||||
.precision(Precision.EXACT)
|
||||
.networkCachePolicy(CachePolicy.ENABLED)
|
||||
.diskCachePolicy(CachePolicy.ENABLED)
|
||||
.memoryCachePolicy(CachePolicy.ENABLED)
|
||||
.crossfade(true)
|
||||
.build()
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
class CustomCacheInterceptor(
|
||||
|
||||
) : Interceptor {
|
||||
|
||||
private val cache: LruCache<String, Image> = LruCache<String, Image>(30)
|
||||
|
||||
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||
println("intercept-chain ${chain.request.data}")
|
||||
|
||||
|
||||
val value = cache.get(chain.request.data.toString())
|
||||
if (value != null) {
|
||||
return SuccessResult(
|
||||
image = value,
|
||||
request = chain.request,
|
||||
dataSource = DataSource.MEMORY_CACHE,
|
||||
)
|
||||
}
|
||||
return chain.proceed()
|
||||
}
|
||||
}
|
||||
|
||||
fun initKoin(config: KoinAppDeclaration) {
|
||||
startKoin {
|
||||
config.invoke(this)
|
||||
modules(
|
||||
appModule,
|
||||
imageLoaderModule
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package mobi.librera.appcompose.imageloader
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.LruCache
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asAndroidBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import mobi.librera.mupdf.fz.lib.openDocument
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.OutputStream
|
||||
import java.security.MessageDigest
|
||||
|
||||
interface ImageCache {
|
||||
fun get(key: String): ImageBitmap?
|
||||
fun put(key: String, bitmap: ImageBitmap)
|
||||
fun clear()
|
||||
}
|
||||
|
||||
class MemoryCache(maxSize: Int) : ImageCache {
|
||||
private val lruCache = object : LruCache<String, ImageBitmap>(maxSize) {}
|
||||
|
||||
override fun get(key: String): ImageBitmap? {
|
||||
return lruCache.get(key)
|
||||
}
|
||||
|
||||
override fun put(key: String, bitmap: ImageBitmap) {
|
||||
lruCache.put(key, bitmap)
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
lruCache.evictAll()
|
||||
}
|
||||
}
|
||||
|
||||
class DiskCache(private val context: Context) : ImageCache {
|
||||
private val cacheDir: File by lazy {
|
||||
File(context.cacheDir, "image_cache3").apply { mkdirs() }
|
||||
}
|
||||
|
||||
override fun get(key: String): ImageBitmap? {
|
||||
val file = getFileForKey(key)
|
||||
if (file.exists()) {
|
||||
return try {
|
||||
BitmapFactory.decodeFile(file.absolutePath)?.asImageBitmap()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
file.delete() // Delete corrupted file
|
||||
null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
override fun put(key: String, bitmap: ImageBitmap) {
|
||||
val file = getFileForKey(key)
|
||||
var fos: OutputStream? = null
|
||||
try {
|
||||
fos = BufferedOutputStream(FileOutputStream(file))
|
||||
bitmap.asAndroidBitmap().compress(Bitmap.CompressFormat.PNG, 100, fos)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
} finally {
|
||||
fos?.close()
|
||||
}
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
cacheDir.deleteRecursively()
|
||||
cacheDir.mkdirs()
|
||||
}
|
||||
|
||||
fun getFileForKey(key: String): File {
|
||||
val file = File(cacheDir, md5(key))
|
||||
println("getFileForKey $file")
|
||||
return file
|
||||
}
|
||||
}
|
||||
|
||||
private fun md5(input: String): String {
|
||||
val md = MessageDigest.getInstance("MD5")
|
||||
val digest = md.digest(input.toByteArray())
|
||||
return digest.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
object ImageDecoder {
|
||||
|
||||
suspend fun decodeFromFile(filePath: String): ImageBitmap? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
if (filePath.isNotEmpty()) {
|
||||
val muDoc = openDocument(
|
||||
filePath,
|
||||
byteArrayOf(0),
|
||||
128 * 4,
|
||||
180 * 4,
|
||||
24
|
||||
)
|
||||
val res = muDoc.renderPage(0, 400).asImageBitmap()
|
||||
muDoc.close()
|
||||
res
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val mutex = Mutex()
|
||||
|
||||
class ImageLoader(private val memoryCache: MemoryCache, private val diskCache: DiskCache) {
|
||||
|
||||
fun getFromMemory(url: String): ImageBitmap? {
|
||||
val cacheKey = url
|
||||
|
||||
memoryCache.get(cacheKey)?.let {
|
||||
println("ImageLoader: Loaded from Memory Cache: $cacheKey")
|
||||
return it
|
||||
}
|
||||
|
||||
diskCache.get(cacheKey)?.let {
|
||||
println("ImageLoader: Loaded from Disk Cache: $cacheKey")
|
||||
memoryCache.put(cacheKey, it)
|
||||
return it
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun getCacheFile(url: String): File {
|
||||
return diskCache.getFileForKey(url)
|
||||
}
|
||||
|
||||
fun getCacheImageBitmap(url: String): ImageBitmap? {
|
||||
return diskCache.get(url)
|
||||
}
|
||||
|
||||
suspend fun loadImage(
|
||||
imageUrl: String
|
||||
): ImageBitmap? {
|
||||
|
||||
val cacheKey = imageUrl.substringAfterLast("/")
|
||||
|
||||
memoryCache.get(cacheKey)?.let {
|
||||
println("ImageLoader: Loaded from Memory Cache: $cacheKey")
|
||||
return it
|
||||
}
|
||||
|
||||
diskCache.get(cacheKey)?.let {
|
||||
println("ImageLoader: Loaded from Disk Cache: $cacheKey")
|
||||
memoryCache.put(cacheKey, it)
|
||||
return it
|
||||
}
|
||||
|
||||
mutex.withLock {
|
||||
|
||||
|
||||
val decodedBitmap = ImageDecoder.decodeFromFile(imageUrl)
|
||||
|
||||
decodedBitmap?.let {
|
||||
memoryCache.put(cacheKey, it)
|
||||
diskCache.put(cacheKey, it)
|
||||
return it
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
object AppImageLoader {
|
||||
private lateinit var imageLoader: ImageLoader
|
||||
private var isInitialized = false
|
||||
|
||||
fun initialize(context: Context) {
|
||||
if (!isInitialized) {
|
||||
val memoryCache = MemoryCache(16 * 1024 * 1024)
|
||||
val diskCache = DiskCache(context.applicationContext)
|
||||
imageLoader = ImageLoader(memoryCache, diskCache)
|
||||
isInitialized = true
|
||||
}
|
||||
}
|
||||
|
||||
fun getCacheFile(book: String): File {
|
||||
return imageLoader.getCacheFile(book)
|
||||
}
|
||||
|
||||
fun get(): ImageLoader {
|
||||
if (!isInitialized) {
|
||||
error("AppImageLoader must be initialized with a context before use. Call AppImageLoader.initialize(context) first.")
|
||||
}
|
||||
return imageLoader
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package mobi.librera.appcompose.imageloader
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import mobi.librera.appcompose.App
|
||||
import mobi.librera.appcompose.room.BookState
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
||||
val client = OkHttpClient()
|
||||
|
||||
@Composable
|
||||
fun MyAsyncImageView(
|
||||
bookState: BookState,
|
||||
modifier: Modifier = Modifier,
|
||||
contentScale: ContentScale = ContentScale.Fit,
|
||||
contentDescription: String? = null
|
||||
) {
|
||||
var loadedImage by remember { mutableStateOf<ImageBitmap?>(null) }
|
||||
|
||||
assert(bookState.fileName.isNotBlank())
|
||||
|
||||
val bookPath = bookState.bookPaths[App.DEVICE_ID].orEmpty()
|
||||
|
||||
//println("MyAsyncImageView -> ${bookState.fileName} $bookPath")
|
||||
println("MyAsyncImageView -> ${bookState.imageUrl} ")
|
||||
|
||||
LaunchedEffect(bookState.fileName) {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (bookPath.isEmpty() && bookState.imageUrl.isNotEmpty()) {
|
||||
println("MyAsyncImageView ${bookState.fileName} ${bookState.imageUrl}")
|
||||
loadedImage = AppImageLoader.get().getCacheImageBitmap(bookState.fileName) ?: run {
|
||||
val request = Request.Builder()
|
||||
.url(bookState.imageUrl)
|
||||
.build();
|
||||
val response = client.newCall(request).execute()
|
||||
val out = AppImageLoader.get().getCacheFile(bookState.fileName)
|
||||
out.writeBytes(response.body.bytes())
|
||||
AppImageLoader.get().getCacheImageBitmap(bookState.fileName)
|
||||
}
|
||||
println("MyAsyncImageView loaded}")
|
||||
} else {
|
||||
withContext(Dispatchers.Default) {
|
||||
loadedImage = AppImageLoader.get().loadImage(bookPath)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
if (loadedImage == null) {
|
||||
Image(
|
||||
bitmap = ImageBitmap(1, 1),
|
||||
modifier = modifier.background(Color.Red),
|
||||
contentDescription = contentDescription,
|
||||
contentScale = contentScale
|
||||
)
|
||||
} else {
|
||||
Image(
|
||||
bitmap = loadedImage!!,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
contentScale = contentScale
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package mobi.librera.appcompose.pdf
|
||||
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
|
||||
interface FormatRepository {
|
||||
|
||||
suspend fun openDocument(bookPath: String, width: Int, height: Int, fontSize: Int)
|
||||
suspend fun renderPage(number: Int, pageWidth: Int): ImageBitmap
|
||||
suspend fun closeDocument()
|
||||
fun pagesCount(): Int
|
||||
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package mobi.librera.appcompose.pdf
|
||||
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import mobi.librera.mupdf.fz.lib.MupdfDocument
|
||||
|
||||
class MupdfRepository : FormatRepository {
|
||||
private var doc: MupdfDocument = MupdfDocument.EmptyDoc
|
||||
override suspend fun openDocument(bookPath: String, width: Int, height: Int, fontSize: Int) {
|
||||
doc =
|
||||
mobi.librera.mupdf.fz.lib.openDocument(bookPath, ByteArray(0), width, height, fontSize)
|
||||
}
|
||||
|
||||
override suspend fun renderPage(number: Int, pageWidth: Int): ImageBitmap {
|
||||
return doc.renderPage(number, pageWidth).asImageBitmap()
|
||||
}
|
||||
|
||||
override suspend fun closeDocument() = doc.close()
|
||||
|
||||
override fun pagesCount(): Int = doc.pageCount
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package mobi.librera.appcompose.pdf
|
||||
|
||||
import android.graphics.pdf.PdfRenderer
|
||||
import android.os.ParcelFileDescriptor
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.core.graphics.createBitmap
|
||||
import java.io.File
|
||||
|
||||
class NativePDFRepository : FormatRepository {
|
||||
private var renderer: PdfRenderer? = null
|
||||
private var fileDescriptor: ParcelFileDescriptor? = null
|
||||
|
||||
override suspend fun openDocument(bookPath: String, width: Int, height: Int, fontSize: Int) {
|
||||
closeDocument()
|
||||
fileDescriptor =
|
||||
ParcelFileDescriptor.open(File(bookPath), ParcelFileDescriptor.MODE_READ_ONLY)
|
||||
renderer = PdfRenderer(fileDescriptor!!)
|
||||
}
|
||||
|
||||
override suspend fun renderPage(number: Int, pageWidth: Int): ImageBitmap {
|
||||
if (renderer == null) {
|
||||
return ImageBitmap(0, 0);
|
||||
}
|
||||
val page = renderer!!.openPage(number)
|
||||
//val k: Float = page.height.toFloat() / page.width.toFloat()
|
||||
///val bitmap = createBitmap(pageWidth, (pageWidth * k).toInt())
|
||||
val bitmap = createBitmap(page.width, page.height)
|
||||
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
|
||||
page.close()
|
||||
return bitmap.asImageBitmap()
|
||||
|
||||
}
|
||||
|
||||
override suspend fun closeDocument() {
|
||||
renderer?.close()
|
||||
fileDescriptor?.close()
|
||||
renderer = null
|
||||
fileDescriptor = null
|
||||
}
|
||||
|
||||
override fun pagesCount(): Int = renderer?.pageCount ?: 0
|
||||
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package mobi.librera.appcompose.room
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
BookItem::class,
|
||||
BookMeta::class,
|
||||
BookState::class,
|
||||
BookTag::class
|
||||
], version = 1
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun bookDao(): BookDao
|
||||
}
|
||||
|
||||
fun buildDatabase(context: Context): AppDatabase {
|
||||
val db = Room.databaseBuilder(
|
||||
context,
|
||||
AppDatabase::class.java,
|
||||
"book_database_3"
|
||||
)
|
||||
db.setQueryCallback(
|
||||
object : RoomDatabase.QueryCallback {
|
||||
override fun onQuery(sqlQuery: String, bindArgs: List<Any?>) {
|
||||
Log.d("DB", "SQL Query: $sqlQuery, Args: $bindArgs")
|
||||
}
|
||||
},
|
||||
Executors.newSingleThreadExecutor()
|
||||
)
|
||||
return db.build()
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package mobi.librera.appcompose.room
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface BookDao {
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM book_item")
|
||||
fun getAllBookItemAndState(): Flow<List<BookItemAndState>>
|
||||
|
||||
@Query("SELECT * FROM book_item")
|
||||
fun getAll(): Flow<List<BookItem>>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM book_state
|
||||
WHERE is_selected = 1 ORDER BY time DESC
|
||||
"""
|
||||
)
|
||||
fun getAllSelected(): Flow<List<BookState>>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM book_state
|
||||
"""
|
||||
)
|
||||
fun getAllBookState(): List<BookState>
|
||||
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM book_state
|
||||
WHERE is_recent = 1 ORDER BY time DESC
|
||||
"""
|
||||
)
|
||||
fun getAllRecent(): Flow<List<BookState>>
|
||||
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun insertAll(users: List<BookItem>)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun insertBookState(state: BookState)
|
||||
|
||||
@Transaction
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun insertAllBookState(bookStates: List<BookState>)
|
||||
|
||||
@Query("DELETE FROM book_item")
|
||||
fun deleteAllBooks()
|
||||
|
||||
|
||||
@Query("SELECT * FROM book_item WHERE path=:path")
|
||||
fun getBookByPath(path: String): BookItemAndState?
|
||||
|
||||
|
||||
// @Transaction
|
||||
// @Query("SELECT * FROM book_item WHERE path = :bookPath")
|
||||
// fun getBookWithTags(bookPath: String): BookWithTags?
|
||||
|
||||
// @Transaction
|
||||
// @Query("SELECT * FROM book_tag WHERE name = :tagName")
|
||||
// suspend fun getTagWithBooks(tagName: String): TagWithBooks?
|
||||
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package mobi.librera.appcompose.room
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
class BookRepository(private val bookDao: BookDao) {
|
||||
|
||||
fun getAllBooks(): Flow<List<Book>> = bookDao.getAllBookItemAndState()
|
||||
.map {
|
||||
it.map { e -> e.toBook() }
|
||||
}
|
||||
|
||||
fun getAllSelected(): Flow<List<Book>> = bookDao.getAllSelected().map {
|
||||
it.map { e -> e.toBook() }
|
||||
}
|
||||
|
||||
fun getAllBookState(): List<BookState> = bookDao.getAllBookState()
|
||||
|
||||
|
||||
fun getAllRecent(): Flow<List<Book>> = bookDao.getAllRecent().map {
|
||||
it.map { e -> e.toBook() }
|
||||
}
|
||||
|
||||
fun insertAllBookState(bookStates: List<BookState>) =
|
||||
bookDao.insertAllBookState(bookStates)
|
||||
|
||||
|
||||
fun insertBookState(bookState: BookState) =
|
||||
bookDao.insertBookState(bookState)
|
||||
|
||||
fun insertAll(books: List<Book>) {
|
||||
bookDao.insertAll(books.map { it.toBookItem() })
|
||||
}
|
||||
|
||||
fun updateBook(book: Book): BookState {
|
||||
val bookToUpdate = book.toBookState().copy(time = System.currentTimeMillis())
|
||||
bookDao.insertBookState(bookToUpdate)
|
||||
return bookToUpdate
|
||||
}
|
||||
|
||||
fun deleteAllBooks() = bookDao.deleteAllBooks()
|
||||
|
||||
fun getBookByPath(bookPath: String): Book? {
|
||||
return bookDao.getBookByPath(bookPath)?.toBook()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package mobi.librera.appcompose.room
|
||||
|
||||
import androidx.room.ProvidedTypeConverter
|
||||
import androidx.room.TypeConverter
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@ProvidedTypeConverter
|
||||
object Converters {
|
||||
//private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@TypeConverter
|
||||
fun fromList(list: List<String>): String = Json.encodeToString(list)
|
||||
|
||||
@TypeConverter
|
||||
fun toList(data: String): List<String> = Json.decodeFromString(data)
|
||||
|
||||
|
||||
@TypeConverter
|
||||
fun fromMap(list: Map<String, String>): String = Json.encodeToString(list)
|
||||
|
||||
@TypeConverter
|
||||
fun toMap(data: String): Map<String, String> = Json.decodeFromString(data)
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package mobi.librera.appcompose.room
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.ForeignKey.Companion.CASCADE
|
||||
import androidx.room.Index
|
||||
import androidx.room.Junction
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.Relation
|
||||
import mobi.librera.appcompose.App
|
||||
import mobi.librera.appcompose.core.toFile
|
||||
|
||||
|
||||
data class Book(
|
||||
val path: String,
|
||||
val fileName: String = "",
|
||||
val isSelected: Boolean = false,
|
||||
val isRecent: Boolean = false,
|
||||
val progress: Float = 0.0f,
|
||||
val time: Long = 0,
|
||||
val fontSize: Int = DEFAULT_FONT_SIZE,
|
||||
val imageUrl: String = "",
|
||||
val pageCount: Int = 0,
|
||||
) {
|
||||
companion object {
|
||||
const val DEFAULT_FONT_SIZE = 24
|
||||
val Empty = Book("")
|
||||
}
|
||||
}
|
||||
|
||||
@Entity(tableName = "book_state")
|
||||
@Keep
|
||||
data class BookState(
|
||||
@PrimaryKey val fileName: String = "",
|
||||
@ColumnInfo(name = "book_paths") var bookPaths: Map<String, String> = mapOf(),
|
||||
@ColumnInfo(name = "progress") val progress: Float = 0.0f,
|
||||
@ColumnInfo(name = "time") val time: Long = 0,
|
||||
@ColumnInfo(name = "is_recent") val recent: Boolean = false,
|
||||
@ColumnInfo(name = "is_selected") val selected: Boolean = false,
|
||||
@ColumnInfo(name = "font_size") val fontSize: Int = 30,
|
||||
@ColumnInfo(name = "imageUrl") var imageUrl: String = "",
|
||||
)
|
||||
|
||||
data class BookItemWithDetails(
|
||||
val bookItem: BookItem, val state: BookState, val meta: BookMeta
|
||||
)
|
||||
|
||||
fun Book.toBookDetails(): BookItemWithDetails {
|
||||
val fileName = this.fileName.toFile().name
|
||||
return BookItemWithDetails(
|
||||
BookItem(this.path, fileName),
|
||||
BookState(fileName = fileName, bookPaths = mapOf(App.DEVICE_ID to this.path)),
|
||||
BookMeta(this.path)
|
||||
)
|
||||
}
|
||||
|
||||
fun Book.toBookItem() = BookItem(this.path, this.path.substringAfterLast("/"))
|
||||
fun Book.toBookState() = BookState(
|
||||
fileName = this.fileName,
|
||||
bookPaths = mapOf(App.DEVICE_ID to this.path),
|
||||
progress = this.progress,
|
||||
time = this.time,
|
||||
recent = this.isRecent,
|
||||
selected = this.isSelected,
|
||||
fontSize = this.fontSize,
|
||||
imageUrl = this.imageUrl
|
||||
)
|
||||
|
||||
fun BookItem.toBook() = Book(
|
||||
path = this.path, fileName = this.fileName
|
||||
)
|
||||
|
||||
fun BookItemAndState.toBook() = Book(
|
||||
this.bookItem.path,
|
||||
this.bookItem.fileName,
|
||||
this.bookState?.selected ?: false,
|
||||
this.bookState?.recent ?: false,
|
||||
this.bookState?.progress ?: 0f,
|
||||
this.bookState?.time ?: 0,
|
||||
)
|
||||
|
||||
fun BookState.toBook() = Book(
|
||||
this.bookPaths[App.DEVICE_ID] ?: "",
|
||||
this.fileName,
|
||||
this.selected,
|
||||
this.recent,
|
||||
this.progress,
|
||||
this.time,
|
||||
this.fontSize,
|
||||
imageUrl = this.imageUrl
|
||||
)
|
||||
|
||||
data class BookItemAndState(
|
||||
@Embedded val bookItem: BookItem,
|
||||
@Relation(
|
||||
parentColumn = "fileName",
|
||||
entityColumn = "fileName",
|
||||
) val bookState: BookState?,
|
||||
)
|
||||
|
||||
data class BookWithTags(
|
||||
@Embedded val bookItem: BookItem, @Relation(
|
||||
parentColumn = "fileName", entityColumn = "name", associateBy = Junction(
|
||||
BookItemTags::class, parentColumn = "fileName", entityColumn = "tagName"
|
||||
)
|
||||
) val tags: List<BookTag>
|
||||
)
|
||||
|
||||
data class TagWithBooks(
|
||||
@Embedded val bookTag: BookTag, @Relation(
|
||||
parentColumn = "name", entityColumn = "fileName", associateBy = Junction(
|
||||
BookItemTags::class, parentColumn = "tagName", entityColumn = "bookPath"
|
||||
)
|
||||
) val books: List<BookItem>
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "book_item_tags", primaryKeys = ["fileName", "tagName"], foreignKeys = [ForeignKey(
|
||||
entity = BookItem::class,
|
||||
parentColumns = ["fileName"],
|
||||
childColumns = ["fileName"],
|
||||
onDelete = CASCADE
|
||||
), ForeignKey(
|
||||
entity = BookTag::class,
|
||||
parentColumns = ["tagName"],
|
||||
childColumns = ["name"],
|
||||
onDelete = CASCADE
|
||||
)], indices = [Index(value = ["bookPath"]), Index(value = ["tagName"])]
|
||||
)
|
||||
data class BookItemTags(
|
||||
@ColumnInfo val fileName: String,
|
||||
@ColumnInfo val tagName: String,
|
||||
)
|
||||
|
||||
|
||||
@Entity(tableName = "book_item")
|
||||
data class BookItem(
|
||||
@PrimaryKey val path: String,
|
||||
@ColumnInfo val fileName: String,
|
||||
)
|
||||
|
||||
|
||||
@Entity(tableName = "book_meta")
|
||||
data class BookMeta(
|
||||
@PrimaryKey val path: String,
|
||||
@ColumnInfo(name = "author") val author: String = "",
|
||||
@ColumnInfo(name = "title") val title: String = "",
|
||||
@ColumnInfo(name = "description") val description: String = "",
|
||||
@ColumnInfo(name = "series") val series: String = "",
|
||||
@ColumnInfo(name = "series_index") val seriesIndex: String = "",
|
||||
)
|
||||
|
||||
@Entity(tableName = "book_tag")
|
||||
data class BookTag(
|
||||
@PrimaryKey val name: String,
|
||||
@ColumnInfo val order: Int = 0,
|
||||
@ColumnInfo val color: Int = 0,
|
||||
)
|
||||
@@ -0,0 +1,2 @@
|
||||
package mobi.librera.appcompose.room
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
package mobi.librera.appcompose.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val primaryLight = Color(0xFF415F91)
|
||||
val onPrimaryLight = Color(0xFFFFFFFF)
|
||||
val primaryContainerLight = Color(0xFFD6E3FF)
|
||||
val onPrimaryContainerLight = Color(0xFF284777)
|
||||
val secondaryLight = Color(0xFF565F71)
|
||||
val onSecondaryLight = Color(0xFFFFFFFF)
|
||||
val secondaryContainerLight = Color(0xFFDAE2F9)
|
||||
val onSecondaryContainerLight = Color(0xFF3E4759)
|
||||
val tertiaryLight = Color(0xFF705575)
|
||||
val onTertiaryLight = Color(0xFFFFFFFF)
|
||||
val tertiaryContainerLight = Color(0xFFFAD8FD)
|
||||
val onTertiaryContainerLight = Color(0xFF573E5C)
|
||||
val errorLight = Color(0xFFBA1A1A)
|
||||
val onErrorLight = Color(0xFFFFFFFF)
|
||||
val errorContainerLight = Color(0xFFFFDAD6)
|
||||
val onErrorContainerLight = Color(0xFF93000A)
|
||||
val backgroundLight = Color(0xFFF9F9FF)
|
||||
val onBackgroundLight = Color(0xFF191C20)
|
||||
val surfaceLight = Color(0xFFF9F9FF)
|
||||
val onSurfaceLight = Color(0xFF191C20)
|
||||
val surfaceVariantLight = Color(0xFFE0E2EC)
|
||||
val onSurfaceVariantLight = Color(0xFF44474E)
|
||||
val outlineLight = Color(0xFF74777F)
|
||||
val outlineVariantLight = Color(0xFFC4C6D0)
|
||||
val scrimLight = Color(0xFF000000)
|
||||
val inverseSurfaceLight = Color(0xFF2E3036)
|
||||
val inverseOnSurfaceLight = Color(0xFFF0F0F7)
|
||||
val inversePrimaryLight = Color(0xFFAAC7FF)
|
||||
val surfaceDimLight = Color(0xFFD9D9E0)
|
||||
val surfaceBrightLight = Color(0xFFF9F9FF)
|
||||
val surfaceContainerLowestLight = Color(0xFFFFFFFF)
|
||||
val surfaceContainerLowLight = Color(0xFFF3F3FA)
|
||||
val surfaceContainerLight = Color(0xFFEDEDF4)
|
||||
val surfaceContainerHighLight = Color(0xFFE7E8EE)
|
||||
val surfaceContainerHighestLight = Color(0xFFE2E2E9)
|
||||
|
||||
val primaryLightMediumContrast = Color(0xFF133665)
|
||||
val onPrimaryLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val primaryContainerLightMediumContrast = Color(0xFF506DA0)
|
||||
val onPrimaryContainerLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val secondaryLightMediumContrast = Color(0xFF2E3647)
|
||||
val onSecondaryLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val secondaryContainerLightMediumContrast = Color(0xFF646D80)
|
||||
val onSecondaryContainerLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val tertiaryLightMediumContrast = Color(0xFF452E4A)
|
||||
val onTertiaryLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val tertiaryContainerLightMediumContrast = Color(0xFF7F6484)
|
||||
val onTertiaryContainerLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val errorLightMediumContrast = Color(0xFF740006)
|
||||
val onErrorLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val errorContainerLightMediumContrast = Color(0xFFCF2C27)
|
||||
val onErrorContainerLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val backgroundLightMediumContrast = Color(0xFFF9F9FF)
|
||||
val onBackgroundLightMediumContrast = Color(0xFF191C20)
|
||||
val surfaceLightMediumContrast = Color(0xFFF9F9FF)
|
||||
val onSurfaceLightMediumContrast = Color(0xFF0F1116)
|
||||
val surfaceVariantLightMediumContrast = Color(0xFFE0E2EC)
|
||||
val onSurfaceVariantLightMediumContrast = Color(0xFF33363E)
|
||||
val outlineLightMediumContrast = Color(0xFF4F525A)
|
||||
val outlineVariantLightMediumContrast = Color(0xFF6A6D75)
|
||||
val scrimLightMediumContrast = Color(0xFF000000)
|
||||
val inverseSurfaceLightMediumContrast = Color(0xFF2E3036)
|
||||
val inverseOnSurfaceLightMediumContrast = Color(0xFFF0F0F7)
|
||||
val inversePrimaryLightMediumContrast = Color(0xFFAAC7FF)
|
||||
val surfaceDimLightMediumContrast = Color(0xFFC5C6CD)
|
||||
val surfaceBrightLightMediumContrast = Color(0xFFF9F9FF)
|
||||
val surfaceContainerLowestLightMediumContrast = Color(0xFFFFFFFF)
|
||||
val surfaceContainerLowLightMediumContrast = Color(0xFFF3F3FA)
|
||||
val surfaceContainerLightMediumContrast = Color(0xFFE7E8EE)
|
||||
val surfaceContainerHighLightMediumContrast = Color(0xFFDCDCE3)
|
||||
val surfaceContainerHighestLightMediumContrast = Color(0xFFD1D1D8)
|
||||
|
||||
val primaryLightHighContrast = Color(0xFF032B5B)
|
||||
val onPrimaryLightHighContrast = Color(0xFFFFFFFF)
|
||||
val primaryContainerLightHighContrast = Color(0xFF2A497A)
|
||||
val onPrimaryContainerLightHighContrast = Color(0xFFFFFFFF)
|
||||
val secondaryLightHighContrast = Color(0xFF232C3D)
|
||||
val onSecondaryLightHighContrast = Color(0xFFFFFFFF)
|
||||
val secondaryContainerLightHighContrast = Color(0xFF41495B)
|
||||
val onSecondaryContainerLightHighContrast = Color(0xFFFFFFFF)
|
||||
val tertiaryLightHighContrast = Color(0xFF3A2440)
|
||||
val onTertiaryLightHighContrast = Color(0xFFFFFFFF)
|
||||
val tertiaryContainerLightHighContrast = Color(0xFF59405E)
|
||||
val onTertiaryContainerLightHighContrast = Color(0xFFFFFFFF)
|
||||
val errorLightHighContrast = Color(0xFF600004)
|
||||
val onErrorLightHighContrast = Color(0xFFFFFFFF)
|
||||
val errorContainerLightHighContrast = Color(0xFF98000A)
|
||||
val onErrorContainerLightHighContrast = Color(0xFFFFFFFF)
|
||||
val backgroundLightHighContrast = Color(0xFFF9F9FF)
|
||||
val onBackgroundLightHighContrast = Color(0xFF191C20)
|
||||
val surfaceLightHighContrast = Color(0xFFF9F9FF)
|
||||
val onSurfaceLightHighContrast = Color(0xFF000000)
|
||||
val surfaceVariantLightHighContrast = Color(0xFFE0E2EC)
|
||||
val onSurfaceVariantLightHighContrast = Color(0xFF000000)
|
||||
val outlineLightHighContrast = Color(0xFF292C33)
|
||||
val outlineVariantLightHighContrast = Color(0xFF464951)
|
||||
val scrimLightHighContrast = Color(0xFF000000)
|
||||
val inverseSurfaceLightHighContrast = Color(0xFF2E3036)
|
||||
val inverseOnSurfaceLightHighContrast = Color(0xFFFFFFFF)
|
||||
val inversePrimaryLightHighContrast = Color(0xFFAAC7FF)
|
||||
val surfaceDimLightHighContrast = Color(0xFFB8B8BF)
|
||||
val surfaceBrightLightHighContrast = Color(0xFFF9F9FF)
|
||||
val surfaceContainerLowestLightHighContrast = Color(0xFFFFFFFF)
|
||||
val surfaceContainerLowLightHighContrast = Color(0xFFF0F0F7)
|
||||
val surfaceContainerLightHighContrast = Color(0xFFE2E2E9)
|
||||
val surfaceContainerHighLightHighContrast = Color(0xFFD3D4DB)
|
||||
val surfaceContainerHighestLightHighContrast = Color(0xFFC5C6CD)
|
||||
|
||||
val primaryDark = Color(0xFFAAC7FF)
|
||||
val onPrimaryDark = Color(0xFF0A305F)
|
||||
val primaryContainerDark = Color(0xFF284777)
|
||||
val onPrimaryContainerDark = Color(0xFFD6E3FF)
|
||||
val secondaryDark = Color(0xFFBEC6DC)
|
||||
val onSecondaryDark = Color(0xFF283141)
|
||||
val secondaryContainerDark = Color(0xFF3E4759)
|
||||
val onSecondaryContainerDark = Color(0xFFDAE2F9)
|
||||
val tertiaryDark = Color(0xFFDDBCE0)
|
||||
val onTertiaryDark = Color(0xFF3F2844)
|
||||
val tertiaryContainerDark = Color(0xFF573E5C)
|
||||
val onTertiaryContainerDark = Color(0xFFFAD8FD)
|
||||
val errorDark = Color(0xFFFFB4AB)
|
||||
val onErrorDark = Color(0xFF690005)
|
||||
val errorContainerDark = Color(0xFF93000A)
|
||||
val onErrorContainerDark = Color(0xFFFFDAD6)
|
||||
val backgroundDark = Color(0xFF111318)
|
||||
val onBackgroundDark = Color(0xFFE2E2E9)
|
||||
val surfaceDark = Color(0xFF111318)
|
||||
val onSurfaceDark = Color(0xFFE2E2E9)
|
||||
val surfaceVariantDark = Color(0xFF44474E)
|
||||
val onSurfaceVariantDark = Color(0xFFC4C6D0)
|
||||
val outlineDark = Color(0xFF8E9099)
|
||||
val outlineVariantDark = Color(0xFF44474E)
|
||||
val scrimDark = Color(0xFF000000)
|
||||
val inverseSurfaceDark = Color(0xFFE2E2E9)
|
||||
val inverseOnSurfaceDark = Color(0xFF2E3036)
|
||||
val inversePrimaryDark = Color(0xFF415F91)
|
||||
val surfaceDimDark = Color(0xFF111318)
|
||||
val surfaceBrightDark = Color(0xFF37393E)
|
||||
val surfaceContainerLowestDark = Color(0xFF0C0E13)
|
||||
val surfaceContainerLowDark = Color(0xFF191C20)
|
||||
val surfaceContainerDark = Color(0xFF1D2024)
|
||||
val surfaceContainerHighDark = Color(0xFF282A2F)
|
||||
val surfaceContainerHighestDark = Color(0xFF33353A)
|
||||
|
||||
val primaryDarkMediumContrast = Color(0xFFCDDDFF)
|
||||
val onPrimaryDarkMediumContrast = Color(0xFF002551)
|
||||
val primaryContainerDarkMediumContrast = Color(0xFF7491C7)
|
||||
val onPrimaryContainerDarkMediumContrast = Color(0xFF000000)
|
||||
val secondaryDarkMediumContrast = Color(0xFFD4DCF2)
|
||||
val onSecondaryDarkMediumContrast = Color(0xFF1D2636)
|
||||
val secondaryContainerDarkMediumContrast = Color(0xFF8891A5)
|
||||
val onSecondaryContainerDarkMediumContrast = Color(0xFF000000)
|
||||
val tertiaryDarkMediumContrast = Color(0xFFF3D2F7)
|
||||
val onTertiaryDarkMediumContrast = Color(0xFF331D39)
|
||||
val tertiaryContainerDarkMediumContrast = Color(0xFFA487A9)
|
||||
val onTertiaryContainerDarkMediumContrast = Color(0xFF000000)
|
||||
val errorDarkMediumContrast = Color(0xFFFFD2CC)
|
||||
val onErrorDarkMediumContrast = Color(0xFF540003)
|
||||
val errorContainerDarkMediumContrast = Color(0xFFFF5449)
|
||||
val onErrorContainerDarkMediumContrast = Color(0xFF000000)
|
||||
val backgroundDarkMediumContrast = Color(0xFF111318)
|
||||
val onBackgroundDarkMediumContrast = Color(0xFFE2E2E9)
|
||||
val surfaceDarkMediumContrast = Color(0xFF111318)
|
||||
val onSurfaceDarkMediumContrast = Color(0xFFFFFFFF)
|
||||
val surfaceVariantDarkMediumContrast = Color(0xFF44474E)
|
||||
val onSurfaceVariantDarkMediumContrast = Color(0xFFDADCE6)
|
||||
val outlineDarkMediumContrast = Color(0xFFAFB2BB)
|
||||
val outlineVariantDarkMediumContrast = Color(0xFF8E9099)
|
||||
val scrimDarkMediumContrast = Color(0xFF000000)
|
||||
val inverseSurfaceDarkMediumContrast = Color(0xFFE2E2E9)
|
||||
val inverseOnSurfaceDarkMediumContrast = Color(0xFF282A2F)
|
||||
val inversePrimaryDarkMediumContrast = Color(0xFF294878)
|
||||
val surfaceDimDarkMediumContrast = Color(0xFF111318)
|
||||
val surfaceBrightDarkMediumContrast = Color(0xFF43444A)
|
||||
val surfaceContainerLowestDarkMediumContrast = Color(0xFF06070C)
|
||||
val surfaceContainerLowDarkMediumContrast = Color(0xFF1B1E22)
|
||||
val surfaceContainerDarkMediumContrast = Color(0xFF26282D)
|
||||
val surfaceContainerHighDarkMediumContrast = Color(0xFF313238)
|
||||
val surfaceContainerHighestDarkMediumContrast = Color(0xFF3C3E43)
|
||||
|
||||
val primaryDarkHighContrast = Color(0xFFEBF0FF)
|
||||
val onPrimaryDarkHighContrast = Color(0xFF000000)
|
||||
val primaryContainerDarkHighContrast = Color(0xFFA6C3FC)
|
||||
val onPrimaryContainerDarkHighContrast = Color(0xFF000B20)
|
||||
val secondaryDarkHighContrast = Color(0xFFEBF0FF)
|
||||
val onSecondaryDarkHighContrast = Color(0xFF000000)
|
||||
val secondaryContainerDarkHighContrast = Color(0xFFBAC3D8)
|
||||
val onSecondaryContainerDarkHighContrast = Color(0xFF030B1A)
|
||||
val tertiaryDarkHighContrast = Color(0xFFFFE9FF)
|
||||
val onTertiaryDarkHighContrast = Color(0xFF000000)
|
||||
val tertiaryContainerDarkHighContrast = Color(0xFFD8B8DC)
|
||||
val onTertiaryContainerDarkHighContrast = Color(0xFF16041D)
|
||||
val errorDarkHighContrast = Color(0xFFFFECE9)
|
||||
val onErrorDarkHighContrast = Color(0xFF000000)
|
||||
val errorContainerDarkHighContrast = Color(0xFFFFAEA4)
|
||||
val onErrorContainerDarkHighContrast = Color(0xFF220001)
|
||||
val backgroundDarkHighContrast = Color(0xFF111318)
|
||||
val onBackgroundDarkHighContrast = Color(0xFFE2E2E9)
|
||||
val surfaceDarkHighContrast = Color(0xFF111318)
|
||||
val onSurfaceDarkHighContrast = Color(0xFFFFFFFF)
|
||||
val surfaceVariantDarkHighContrast = Color(0xFF44474E)
|
||||
val onSurfaceVariantDarkHighContrast = Color(0xFFFFFFFF)
|
||||
val outlineDarkHighContrast = Color(0xFFEEEFF9)
|
||||
val outlineVariantDarkHighContrast = Color(0xFFC0C2CC)
|
||||
val scrimDarkHighContrast = Color(0xFF000000)
|
||||
val inverseSurfaceDarkHighContrast = Color(0xFFE2E2E9)
|
||||
val inverseOnSurfaceDarkHighContrast = Color(0xFF000000)
|
||||
val inversePrimaryDarkHighContrast = Color(0xFF294878)
|
||||
val surfaceDimDarkHighContrast = Color(0xFF111318)
|
||||
val surfaceBrightDarkHighContrast = Color(0xFF4E5056)
|
||||
val surfaceContainerLowestDarkHighContrast = Color(0xFF000000)
|
||||
val surfaceContainerLowDarkHighContrast = Color(0xFF1D2024)
|
||||
val surfaceContainerDarkHighContrast = Color(0xFF2E3036)
|
||||
val surfaceContainerHighDarkHighContrast = Color(0xFF393B41)
|
||||
val surfaceContainerHighestDarkHighContrast = Color(0xFF45474C)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package mobi.librera.appcompose.ui.theme
|
||||
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Shapes
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
val shapes = Shapes(
|
||||
extraSmall = RoundedCornerShape(4.dp),
|
||||
small = RoundedCornerShape(8.dp),
|
||||
medium = RoundedCornerShape(16.dp),
|
||||
large = RoundedCornerShape(24.dp),
|
||||
extraLarge = RoundedCornerShape(32.dp),
|
||||
)
|
||||
@@ -0,0 +1,280 @@
|
||||
package mobi.librera.appcompose.ui.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
private val lightScheme = lightColorScheme(
|
||||
primary = primaryLight,
|
||||
onPrimary = onPrimaryLight,
|
||||
primaryContainer = primaryContainerLight,
|
||||
onPrimaryContainer = onPrimaryContainerLight,
|
||||
secondary = secondaryLight,
|
||||
onSecondary = onSecondaryLight,
|
||||
secondaryContainer = secondaryContainerLight,
|
||||
onSecondaryContainer = onSecondaryContainerLight,
|
||||
tertiary = tertiaryLight,
|
||||
onTertiary = onTertiaryLight,
|
||||
tertiaryContainer = tertiaryContainerLight,
|
||||
onTertiaryContainer = onTertiaryContainerLight,
|
||||
error = errorLight,
|
||||
onError = onErrorLight,
|
||||
errorContainer = errorContainerLight,
|
||||
onErrorContainer = onErrorContainerLight,
|
||||
background = backgroundLight,
|
||||
onBackground = onBackgroundLight,
|
||||
surface = surfaceLight,
|
||||
onSurface = onSurfaceLight,
|
||||
surfaceVariant = surfaceVariantLight,
|
||||
onSurfaceVariant = onSurfaceVariantLight,
|
||||
outline = outlineLight,
|
||||
outlineVariant = outlineVariantLight,
|
||||
scrim = scrimLight,
|
||||
inverseSurface = inverseSurfaceLight,
|
||||
inverseOnSurface = inverseOnSurfaceLight,
|
||||
inversePrimary = inversePrimaryLight,
|
||||
surfaceDim = surfaceDimLight,
|
||||
surfaceBright = surfaceBrightLight,
|
||||
surfaceContainerLowest = surfaceContainerLowestLight,
|
||||
surfaceContainerLow = surfaceContainerLowLight,
|
||||
surfaceContainer = surfaceContainerLight,
|
||||
surfaceContainerHigh = surfaceContainerHighLight,
|
||||
surfaceContainerHighest = surfaceContainerHighestLight,
|
||||
)
|
||||
|
||||
private val darkScheme = darkColorScheme(
|
||||
primary = primaryDark,
|
||||
onPrimary = onPrimaryDark,
|
||||
primaryContainer = primaryContainerDark,
|
||||
onPrimaryContainer = onPrimaryContainerDark,
|
||||
secondary = secondaryDark,
|
||||
onSecondary = onSecondaryDark,
|
||||
secondaryContainer = secondaryContainerDark,
|
||||
onSecondaryContainer = onSecondaryContainerDark,
|
||||
tertiary = tertiaryDark,
|
||||
onTertiary = onTertiaryDark,
|
||||
tertiaryContainer = tertiaryContainerDark,
|
||||
onTertiaryContainer = onTertiaryContainerDark,
|
||||
error = errorDark,
|
||||
onError = onErrorDark,
|
||||
errorContainer = errorContainerDark,
|
||||
onErrorContainer = onErrorContainerDark,
|
||||
background = backgroundDark,
|
||||
onBackground = onBackgroundDark,
|
||||
surface = surfaceDark,
|
||||
onSurface = onSurfaceDark,
|
||||
surfaceVariant = surfaceVariantDark,
|
||||
onSurfaceVariant = onSurfaceVariantDark,
|
||||
outline = outlineDark,
|
||||
outlineVariant = outlineVariantDark,
|
||||
scrim = scrimDark,
|
||||
inverseSurface = inverseSurfaceDark,
|
||||
inverseOnSurface = inverseOnSurfaceDark,
|
||||
inversePrimary = inversePrimaryDark,
|
||||
surfaceDim = surfaceDimDark,
|
||||
surfaceBright = surfaceBrightDark,
|
||||
surfaceContainerLowest = surfaceContainerLowestDark,
|
||||
surfaceContainerLow = surfaceContainerLowDark,
|
||||
surfaceContainer = surfaceContainerDark,
|
||||
surfaceContainerHigh = surfaceContainerHighDark,
|
||||
surfaceContainerHighest = surfaceContainerHighestDark,
|
||||
)
|
||||
|
||||
private val mediumContrastLightColorScheme = lightColorScheme(
|
||||
primary = primaryLightMediumContrast,
|
||||
onPrimary = onPrimaryLightMediumContrast,
|
||||
primaryContainer = primaryContainerLightMediumContrast,
|
||||
onPrimaryContainer = onPrimaryContainerLightMediumContrast,
|
||||
secondary = secondaryLightMediumContrast,
|
||||
onSecondary = onSecondaryLightMediumContrast,
|
||||
secondaryContainer = secondaryContainerLightMediumContrast,
|
||||
onSecondaryContainer = onSecondaryContainerLightMediumContrast,
|
||||
tertiary = tertiaryLightMediumContrast,
|
||||
onTertiary = onTertiaryLightMediumContrast,
|
||||
tertiaryContainer = tertiaryContainerLightMediumContrast,
|
||||
onTertiaryContainer = onTertiaryContainerLightMediumContrast,
|
||||
error = errorLightMediumContrast,
|
||||
onError = onErrorLightMediumContrast,
|
||||
errorContainer = errorContainerLightMediumContrast,
|
||||
onErrorContainer = onErrorContainerLightMediumContrast,
|
||||
background = backgroundLightMediumContrast,
|
||||
onBackground = onBackgroundLightMediumContrast,
|
||||
surface = surfaceLightMediumContrast,
|
||||
onSurface = onSurfaceLightMediumContrast,
|
||||
surfaceVariant = surfaceVariantLightMediumContrast,
|
||||
onSurfaceVariant = onSurfaceVariantLightMediumContrast,
|
||||
outline = outlineLightMediumContrast,
|
||||
outlineVariant = outlineVariantLightMediumContrast,
|
||||
scrim = scrimLightMediumContrast,
|
||||
inverseSurface = inverseSurfaceLightMediumContrast,
|
||||
inverseOnSurface = inverseOnSurfaceLightMediumContrast,
|
||||
inversePrimary = inversePrimaryLightMediumContrast,
|
||||
surfaceDim = surfaceDimLightMediumContrast,
|
||||
surfaceBright = surfaceBrightLightMediumContrast,
|
||||
surfaceContainerLowest = surfaceContainerLowestLightMediumContrast,
|
||||
surfaceContainerLow = surfaceContainerLowLightMediumContrast,
|
||||
surfaceContainer = surfaceContainerLightMediumContrast,
|
||||
surfaceContainerHigh = surfaceContainerHighLightMediumContrast,
|
||||
surfaceContainerHighest = surfaceContainerHighestLightMediumContrast,
|
||||
)
|
||||
|
||||
private val highContrastLightColorScheme = lightColorScheme(
|
||||
primary = primaryLightHighContrast,
|
||||
onPrimary = onPrimaryLightHighContrast,
|
||||
primaryContainer = primaryContainerLightHighContrast,
|
||||
onPrimaryContainer = onPrimaryContainerLightHighContrast,
|
||||
secondary = secondaryLightHighContrast,
|
||||
onSecondary = onSecondaryLightHighContrast,
|
||||
secondaryContainer = secondaryContainerLightHighContrast,
|
||||
onSecondaryContainer = onSecondaryContainerLightHighContrast,
|
||||
tertiary = tertiaryLightHighContrast,
|
||||
onTertiary = onTertiaryLightHighContrast,
|
||||
tertiaryContainer = tertiaryContainerLightHighContrast,
|
||||
onTertiaryContainer = onTertiaryContainerLightHighContrast,
|
||||
error = errorLightHighContrast,
|
||||
onError = onErrorLightHighContrast,
|
||||
errorContainer = errorContainerLightHighContrast,
|
||||
onErrorContainer = onErrorContainerLightHighContrast,
|
||||
background = backgroundLightHighContrast,
|
||||
onBackground = onBackgroundLightHighContrast,
|
||||
surface = surfaceLightHighContrast,
|
||||
onSurface = onSurfaceLightHighContrast,
|
||||
surfaceVariant = surfaceVariantLightHighContrast,
|
||||
onSurfaceVariant = onSurfaceVariantLightHighContrast,
|
||||
outline = outlineLightHighContrast,
|
||||
outlineVariant = outlineVariantLightHighContrast,
|
||||
scrim = scrimLightHighContrast,
|
||||
inverseSurface = inverseSurfaceLightHighContrast,
|
||||
inverseOnSurface = inverseOnSurfaceLightHighContrast,
|
||||
inversePrimary = inversePrimaryLightHighContrast,
|
||||
surfaceDim = surfaceDimLightHighContrast,
|
||||
surfaceBright = surfaceBrightLightHighContrast,
|
||||
surfaceContainerLowest = surfaceContainerLowestLightHighContrast,
|
||||
surfaceContainerLow = surfaceContainerLowLightHighContrast,
|
||||
surfaceContainer = surfaceContainerLightHighContrast,
|
||||
surfaceContainerHigh = surfaceContainerHighLightHighContrast,
|
||||
surfaceContainerHighest = surfaceContainerHighestLightHighContrast,
|
||||
)
|
||||
|
||||
private val mediumContrastDarkColorScheme = darkColorScheme(
|
||||
primary = primaryDarkMediumContrast,
|
||||
onPrimary = onPrimaryDarkMediumContrast,
|
||||
primaryContainer = primaryContainerDarkMediumContrast,
|
||||
onPrimaryContainer = onPrimaryContainerDarkMediumContrast,
|
||||
secondary = secondaryDarkMediumContrast,
|
||||
onSecondary = onSecondaryDarkMediumContrast,
|
||||
secondaryContainer = secondaryContainerDarkMediumContrast,
|
||||
onSecondaryContainer = onSecondaryContainerDarkMediumContrast,
|
||||
tertiary = tertiaryDarkMediumContrast,
|
||||
onTertiary = onTertiaryDarkMediumContrast,
|
||||
tertiaryContainer = tertiaryContainerDarkMediumContrast,
|
||||
onTertiaryContainer = onTertiaryContainerDarkMediumContrast,
|
||||
error = errorDarkMediumContrast,
|
||||
onError = onErrorDarkMediumContrast,
|
||||
errorContainer = errorContainerDarkMediumContrast,
|
||||
onErrorContainer = onErrorContainerDarkMediumContrast,
|
||||
background = backgroundDarkMediumContrast,
|
||||
onBackground = onBackgroundDarkMediumContrast,
|
||||
surface = surfaceDarkMediumContrast,
|
||||
onSurface = onSurfaceDarkMediumContrast,
|
||||
surfaceVariant = surfaceVariantDarkMediumContrast,
|
||||
onSurfaceVariant = onSurfaceVariantDarkMediumContrast,
|
||||
outline = outlineDarkMediumContrast,
|
||||
outlineVariant = outlineVariantDarkMediumContrast,
|
||||
scrim = scrimDarkMediumContrast,
|
||||
inverseSurface = inverseSurfaceDarkMediumContrast,
|
||||
inverseOnSurface = inverseOnSurfaceDarkMediumContrast,
|
||||
inversePrimary = inversePrimaryDarkMediumContrast,
|
||||
surfaceDim = surfaceDimDarkMediumContrast,
|
||||
surfaceBright = surfaceBrightDarkMediumContrast,
|
||||
surfaceContainerLowest = surfaceContainerLowestDarkMediumContrast,
|
||||
surfaceContainerLow = surfaceContainerLowDarkMediumContrast,
|
||||
surfaceContainer = surfaceContainerDarkMediumContrast,
|
||||
surfaceContainerHigh = surfaceContainerHighDarkMediumContrast,
|
||||
surfaceContainerHighest = surfaceContainerHighestDarkMediumContrast,
|
||||
)
|
||||
|
||||
private val highContrastDarkColorScheme = darkColorScheme(
|
||||
primary = primaryDarkHighContrast,
|
||||
onPrimary = onPrimaryDarkHighContrast,
|
||||
primaryContainer = primaryContainerDarkHighContrast,
|
||||
onPrimaryContainer = onPrimaryContainerDarkHighContrast,
|
||||
secondary = secondaryDarkHighContrast,
|
||||
onSecondary = onSecondaryDarkHighContrast,
|
||||
secondaryContainer = secondaryContainerDarkHighContrast,
|
||||
onSecondaryContainer = onSecondaryContainerDarkHighContrast,
|
||||
tertiary = tertiaryDarkHighContrast,
|
||||
onTertiary = onTertiaryDarkHighContrast,
|
||||
tertiaryContainer = tertiaryContainerDarkHighContrast,
|
||||
onTertiaryContainer = onTertiaryContainerDarkHighContrast,
|
||||
error = errorDarkHighContrast,
|
||||
onError = onErrorDarkHighContrast,
|
||||
errorContainer = errorContainerDarkHighContrast,
|
||||
onErrorContainer = onErrorContainerDarkHighContrast,
|
||||
background = backgroundDarkHighContrast,
|
||||
onBackground = onBackgroundDarkHighContrast,
|
||||
surface = surfaceDarkHighContrast,
|
||||
onSurface = onSurfaceDarkHighContrast,
|
||||
surfaceVariant = surfaceVariantDarkHighContrast,
|
||||
onSurfaceVariant = onSurfaceVariantDarkHighContrast,
|
||||
outline = outlineDarkHighContrast,
|
||||
outlineVariant = outlineVariantDarkHighContrast,
|
||||
scrim = scrimDarkHighContrast,
|
||||
inverseSurface = inverseSurfaceDarkHighContrast,
|
||||
inverseOnSurface = inverseOnSurfaceDarkHighContrast,
|
||||
inversePrimary = inversePrimaryDarkHighContrast,
|
||||
surfaceDim = surfaceDimDarkHighContrast,
|
||||
surfaceBright = surfaceBrightDarkHighContrast,
|
||||
surfaceContainerLowest = surfaceContainerLowestDarkHighContrast,
|
||||
surfaceContainerLow = surfaceContainerLowDarkHighContrast,
|
||||
surfaceContainer = surfaceContainerDarkHighContrast,
|
||||
surfaceContainerHigh = surfaceContainerHighDarkHighContrast,
|
||||
surfaceContainerHighest = surfaceContainerHighestDarkHighContrast,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class ColorFamily(
|
||||
val color: Color,
|
||||
val onColor: Color,
|
||||
val colorContainer: Color,
|
||||
val onColorContainer: Color
|
||||
)
|
||||
|
||||
val unspecified_scheme = ColorFamily(
|
||||
Color.Unspecified, Color.Unspecified, Color.Unspecified, Color.Unspecified
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun LibreraTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
// Dynamic color is available on Android 12+
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable() () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
|
||||
darkTheme -> darkScheme
|
||||
else -> lightScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
|
||||
colorScheme = colorScheme,
|
||||
typography = AppTypography,
|
||||
shapes = shapes,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package mobi.librera.appcompose.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
val AppTypography = Typography(
|
||||
headlineSmall = TextStyle(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 24.sp,
|
||||
lineHeight = 32.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.15.sp
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.25.sp
|
||||
),
|
||||
labelMedium = TextStyle(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
package mobi.librera.mupdf.fz
|
||||
|
||||
import com.sun.jna.Structure
|
||||
|
||||
@Structure.FieldOrder("x0", "y0", "x1", "y1")
|
||||
class fz_irect : Structure(), Structure.ByValue {
|
||||
@JvmField
|
||||
var x0: Int = 0
|
||||
@JvmField
|
||||
var y0: Int = 0
|
||||
@JvmField
|
||||
var x1: Int = 0
|
||||
@JvmField
|
||||
var y1: Int = 0
|
||||
|
||||
fun make(x0: Int, y0: Int, x1: Int, y1: Int): fz_irect {
|
||||
this.x0 = x0
|
||||
this.y0 = y0
|
||||
this.x1 = x1
|
||||
this.y1 = y1
|
||||
return this
|
||||
}
|
||||
|
||||
|
||||
override fun toString(): String {
|
||||
return "fz_rect [x0: $x0, y0: $y0, x1: $x1, y1: $y1]"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package mobi.librera.mupdf.fz
|
||||
|
||||
import com.sun.jna.Library
|
||||
import com.sun.jna.Pointer
|
||||
|
||||
interface fz_library : Library {
|
||||
|
||||
fun fz_new_context_imp(
|
||||
alloc: Pointer?,
|
||||
locks: Pointer?,
|
||||
max_store: Int,
|
||||
version: String
|
||||
): Pointer
|
||||
|
||||
|
||||
fun fz_open_document(ctx: Pointer?, filename: String): Pointer
|
||||
|
||||
fun fz_open_document_with_stream(ctx: Pointer?, magic: String, stream: Pointer): Pointer
|
||||
fun fz_open_document_with_buffer(ctx: Pointer?, magic: String, stream: Pointer): Pointer
|
||||
|
||||
|
||||
fun fz_open_memory(ctx: Pointer?, array: ByteArray, size: Int): Pointer
|
||||
fun fz_new_buffer_from_data(ctx: Pointer?, array: ByteArray, size: Int): Pointer
|
||||
|
||||
fun fz_keep_buffer(ctx: Pointer?, buffer: Pointer): Pointer
|
||||
|
||||
fun fz_register_document_handlers(ctx: Pointer?): Pointer?
|
||||
|
||||
fun fz_load_page(ctx: Pointer?, doc: Pointer?, pageNumber: Int): Pointer
|
||||
|
||||
fun fz_bound_page(ctx: Pointer?, page: Pointer): fz_rect
|
||||
|
||||
fun fz_count_pages(fzContext: Pointer?, fzDocument: Pointer?): Int
|
||||
|
||||
fun fz_new_pixmap_with_bbox(
|
||||
fzContext: Pointer?,
|
||||
device: Pointer?,
|
||||
bbox: fz_irect?,
|
||||
un: Pointer?,
|
||||
alpha: Int
|
||||
): Pointer?
|
||||
|
||||
fun fz_var_imp(variable: Pointer?)
|
||||
fun fz_do_catch(fzContext: Pointer?): Int
|
||||
fun fz_do_try(fzContext: Pointer?): Int
|
||||
fun fz_push_try(fzContext: Pointer?): Pointer
|
||||
|
||||
fun setjmp(fzPushTry: Pointer): Int
|
||||
fun sigsetjmp(buf: Pointer, value: Int): Int
|
||||
|
||||
fun fz_device_bgr(fzContext: Pointer?): Pointer?
|
||||
|
||||
fun fz_device_rgb(fzContext: Pointer?): Pointer?
|
||||
|
||||
fun fz_clear_pixmap_with_value(fzContext: Pointer?, fzPixmap: Pointer?, color: Int)
|
||||
|
||||
fun fz_new_draw_device(fzContext: Pointer?, fzMatrix: fz_matrix, fzPixmap: Pointer?): Pointer?
|
||||
|
||||
fun fz_run_page(
|
||||
fzContext: Pointer?,
|
||||
fzPage: Pointer?,
|
||||
fzDev: Pointer?,
|
||||
fzMatrix: fz_matrix?,
|
||||
unknown: Pointer?
|
||||
)
|
||||
|
||||
|
||||
fun fz_pixmap_height(fzContext: Pointer?, pixmap: Pointer?): Int
|
||||
|
||||
fun fz_pixmap_width(fzContext: Pointer?, pixmap: Pointer?): Int
|
||||
|
||||
fun fz_pixmap_size(fzContext: Pointer?, pixmap: Pointer?): Int
|
||||
|
||||
fun fz_pixmap_stride(fzContext: Pointer?, pixmap: Pointer?): Int
|
||||
|
||||
fun fz_pixmap_samples(fzContext: Pointer?, pixmap: Pointer?): Pointer?
|
||||
|
||||
fun fz_drop_page(fzContext: Pointer?, fzPage: Pointer)
|
||||
fun fz_drop_pixmap(fzContext: Pointer?, fzPixmap: Pointer?)
|
||||
fun fz_close_device(fzContext: Pointer?, fzDev: Pointer?)
|
||||
fun fz_drop_device(fzContext: Pointer?, fzDev: Pointer?)
|
||||
fun fz_drop_document(fzContext: Pointer?, fzDocument: Pointer?)
|
||||
fun fz_drop_context(fzContext: Pointer?)
|
||||
|
||||
fun fz_layout_document(
|
||||
fzContext: Pointer?,
|
||||
fzDocument: Pointer?,
|
||||
fl: Float,
|
||||
fl1: Float,
|
||||
fl2: Float
|
||||
)
|
||||
|
||||
fun fz_set_user_css(fzContext: Pointer?, s: String)
|
||||
fun fz_set_use_document_css(fzContext: Pointer?, i: Int)
|
||||
|
||||
fun fz_load_outline(fzContext: Pointer?, fzDocument: Pointer?): fz_outline?
|
||||
fun fz_drop_outline(fzContext: Pointer?, fzOutline: fz_outline?)
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package mobi.librera.mupdf.fz
|
||||
|
||||
import com.sun.jna.Structure
|
||||
|
||||
/**
|
||||
* {
|
||||
* int chapter;
|
||||
* int page;
|
||||
* } fz_location;
|
||||
*/
|
||||
|
||||
@Structure.FieldOrder("chapter", "page")
|
||||
class fz_location : Structure(), Structure.ByValue {
|
||||
@JvmField
|
||||
var chapter: Int = 0
|
||||
@JvmField
|
||||
var page: Int = 0
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package mobi.librera.mupdf.fz
|
||||
|
||||
import com.sun.jna.Structure
|
||||
|
||||
@Structure.FieldOrder("a", "b", "c", "d", "e", "f")
|
||||
class fz_matrix : Structure(), Structure.ByValue {
|
||||
@JvmField
|
||||
var a: Float = 1f
|
||||
@JvmField
|
||||
var b: Float = 0f
|
||||
@JvmField
|
||||
var c: Float = 0f
|
||||
|
||||
@JvmField
|
||||
var d: Float = 1f
|
||||
@JvmField
|
||||
var e: Float = 0f
|
||||
@JvmField
|
||||
var f: Float = 0f
|
||||
|
||||
override fun toString(): String {
|
||||
return "fz_matrix [a: $a, b: $b, b: $b, d: $d, e: $e, f: $f]"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package mobi.librera.mupdf.fz
|
||||
|
||||
import com.sun.jna.Pointer
|
||||
import com.sun.jna.Structure
|
||||
import mobi.librera.mupdf.fz.lib.MemoryStructure
|
||||
|
||||
/**
|
||||
*
|
||||
* typedef struct fz_outline
|
||||
* {
|
||||
* int refs;
|
||||
* char *title;
|
||||
* char *uri;
|
||||
* fz_location page;
|
||||
* float x, y;
|
||||
* struct fz_outline *next;
|
||||
* struct fz_outline *down;
|
||||
* int is_open;
|
||||
* } fz_outline;
|
||||
*/
|
||||
|
||||
@Structure.FieldOrder("refs", "title", "uri", "page", "x", "y", "next", "down", "is_open")
|
||||
class fz_outline : MemoryStructure(), Structure.ByReference {
|
||||
@JvmField
|
||||
var refs: Int = 0
|
||||
@JvmField
|
||||
var title: String = ""
|
||||
@JvmField
|
||||
var uri: String = ""
|
||||
@JvmField
|
||||
var page: fz_location? = null
|
||||
@JvmField
|
||||
var x: Float = 0f
|
||||
@JvmField
|
||||
var y: Float = 0f
|
||||
@JvmField
|
||||
var next: Pointer? = null
|
||||
@JvmField
|
||||
var down: Pointer? = null
|
||||
@JvmField
|
||||
var is_open: Int = 0
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package mobi.librera.mupdf.fz
|
||||
|
||||
import com.sun.jna.Pointer
|
||||
import com.sun.jna.Structure
|
||||
|
||||
class fz_pixmap : Structure(), Structure.ByValue {
|
||||
@JvmField
|
||||
var storable: Pointer? = null
|
||||
|
||||
// int x, y, w, h
|
||||
@JvmField
|
||||
var x: Int = 0
|
||||
@JvmField
|
||||
var y: Int = 0
|
||||
@JvmField
|
||||
var w: Int = 0
|
||||
@JvmField
|
||||
var h: Int = 0
|
||||
|
||||
// unsigned char n, s, alpha, flags
|
||||
@JvmField
|
||||
var n: Byte = 0
|
||||
@JvmField
|
||||
var s: Byte = 0
|
||||
@JvmField
|
||||
var alpha: Byte = 0
|
||||
@JvmField
|
||||
var flags: Byte = 0
|
||||
|
||||
// ptrdiff_t stride
|
||||
@JvmField
|
||||
var stride: Long = 0
|
||||
|
||||
// fz_separations *seps
|
||||
@JvmField
|
||||
var seps: Pointer? = null
|
||||
|
||||
// int xres, yres
|
||||
@JvmField
|
||||
var xres: Int = 0
|
||||
@JvmField
|
||||
var yres: Int = 0
|
||||
|
||||
// fz_colorspace *colorspace
|
||||
@JvmField
|
||||
var colorspace: Pointer? = null
|
||||
|
||||
// unsigned char *samples
|
||||
@JvmField
|
||||
var samples: Pointer? = null
|
||||
|
||||
// fz_pixmap *underlying
|
||||
@JvmField
|
||||
var underlying: Pointer? = null
|
||||
|
||||
override fun getFieldOrder(): List<String> {
|
||||
return listOf(
|
||||
"storable", "x", "y", "w", "h", "n", "s", "alpha", "flags",
|
||||
"stride", "seps", "xres", "yres", "colorspace", "samples", "underlying"
|
||||
)
|
||||
}
|
||||
|
||||
public override fun useMemory(m: Pointer?) {
|
||||
super.useMemory(m)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package mobi.librera.mupdf.fz
|
||||
|
||||
import com.sun.jna.Structure
|
||||
|
||||
@Structure.FieldOrder("x0", "y0", "x1", "y1")
|
||||
class fz_rect : Structure(), Structure.ByValue {
|
||||
@JvmField
|
||||
var x0: Float = 0f
|
||||
@JvmField
|
||||
var y0: Float = 0f
|
||||
@JvmField
|
||||
var x1: Float = 0f
|
||||
@JvmField
|
||||
var y1: Float = 0f
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package mobi.librera.mupdf.fz.lib
|
||||
|
||||
import com.sun.jna.Native
|
||||
import com.sun.jna.Pointer
|
||||
import mobi.librera.mupdf.fz.fz_irect
|
||||
import mobi.librera.mupdf.fz.fz_library
|
||||
import mobi.librera.mupdf.fz.fz_matrix
|
||||
import mobi.librera.mupdf.fz.fz_outline
|
||||
|
||||
val fz: fz_library = Native.load(
|
||||
"mupdf_java", fz_library::class.java
|
||||
)
|
||||
|
||||
class CommonLib(tempFile: String, width: Int, height: Int, fontSize: Int) {
|
||||
private var fzContext: Pointer? = null
|
||||
private var fzDocument: Pointer? = null
|
||||
var fzPagesCount: Int = 0
|
||||
var fzTitle: String = "title"
|
||||
private var fzMupdfVersion: String = "1.26.3"
|
||||
|
||||
init {
|
||||
println("Open document 1")
|
||||
fzContext = fz.fz_new_context_imp(null, null, 256000, fzMupdfVersion)
|
||||
println("Open document 2")
|
||||
fz.fz_register_document_handlers(fzContext)
|
||||
println("Open document 3")
|
||||
|
||||
fz.fz_set_user_css(fzContext, "body, div,p {margin:0em !important;}")
|
||||
fz.fz_set_use_document_css(fzContext, 1)
|
||||
|
||||
|
||||
println("Open document 4 $tempFile")
|
||||
|
||||
if (fz.setjmp(fz.fz_push_try(fzContext)) == 0)
|
||||
if (fz.fz_do_try(fzContext) == 1) {
|
||||
println("Open document 4.1 |$tempFile|")
|
||||
fzDocument = fz.fz_open_document(fzContext, tempFile)
|
||||
println("Open document 4.2")
|
||||
}
|
||||
val res = fz.fz_do_catch(fzContext)
|
||||
if (res == 0) {
|
||||
println("Open-document 5 OK $tempFile")
|
||||
|
||||
fz.fz_layout_document(
|
||||
fzContext, fzDocument, width.toFloat(), height.toFloat(), fontSize.toFloat()
|
||||
)
|
||||
println("Open-document 6")
|
||||
fzPagesCount = fz.fz_count_pages(fzContext, fzDocument)
|
||||
|
||||
println("Open-document fzPagesCount $fzPagesCount")
|
||||
} else {
|
||||
println("Open-document ERROR")
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
fun getOutline(): List<Outline> {
|
||||
var fzOutline = fz.fz_load_outline(fzContext, fzDocument)
|
||||
val initOutline = fzOutline
|
||||
|
||||
val result = mutableListOf<Outline>()
|
||||
|
||||
var level = 0
|
||||
while (fzOutline != null) {
|
||||
val title = fzOutline.title
|
||||
val page = fzOutline.page?.page!!
|
||||
val uri = fzOutline.uri
|
||||
|
||||
println("Outline-title: $title page: $page")
|
||||
|
||||
result.add(Outline(title, page, uri, level))
|
||||
//fzOutline = fzOutline.next.toStructure(fz_outline::class.java)
|
||||
//fzOutline = fzOutline.next.toStructure1<fz_outline>()
|
||||
fzOutline = fzOutline.next.toStructure2 { fz_outline() }
|
||||
}
|
||||
|
||||
//fz.fz_drop_outline(fzContext, initOutline) crash on desktop
|
||||
return result
|
||||
|
||||
}
|
||||
|
||||
|
||||
private fun <T : MemoryStructure> Pointer?.toStructure(clazz: Class<T>): T? {
|
||||
if (this == null) return null
|
||||
val structure = clazz.getDeclaredConstructor().newInstance()
|
||||
structure.useMemory(this)
|
||||
structure.read()
|
||||
return structure
|
||||
}
|
||||
|
||||
inline fun <reified T : MemoryStructure> Pointer?.toStructure1(): T? {
|
||||
if (this == null) return null
|
||||
val structure = T::class.java.getDeclaredConstructor().newInstance()
|
||||
structure.useMemory(this)
|
||||
structure.read()
|
||||
return structure
|
||||
}
|
||||
|
||||
inline fun <reified T : MemoryStructure> Pointer?.toStructure2(creator: () -> T): T? {
|
||||
if (this == null) return null
|
||||
val structure = creator()
|
||||
structure.useMemory(this)
|
||||
structure.read()
|
||||
return structure
|
||||
}
|
||||
|
||||
|
||||
fun close() {
|
||||
fz.fz_drop_document(fzContext, fzDocument)
|
||||
fz.fz_drop_context(fzContext)
|
||||
}
|
||||
|
||||
|
||||
fun renderPage(page: Int, pageWidth: Int): Triple<IntArray, Int, Int> {
|
||||
println("renderPage 1")
|
||||
val fzPage = fz.fz_load_page(fzContext, fzDocument, page)
|
||||
println("renderPage 2")
|
||||
val fzBounds = fz.fz_bound_page(fzContext, fzPage);
|
||||
println("renderPage 3")
|
||||
val fzColor: Pointer? = fz.fz_device_bgr(fzContext)
|
||||
|
||||
|
||||
println("log : pageWidth $pageWidth x1 ${fzBounds.x1} y1 ${fzBounds.y1}")
|
||||
|
||||
val scale: Float = pageWidth / fzBounds.x1
|
||||
|
||||
val pWidth: Int = pageWidth
|
||||
val pHeight: Int = (fzBounds.y1 * scale).toInt()
|
||||
|
||||
|
||||
|
||||
println("log : scale $scale width ${pWidth} height $pHeight")
|
||||
val fzMatrix = fz_matrix().apply {
|
||||
a = scale
|
||||
d = scale
|
||||
}
|
||||
var bbox = fz_irect().apply {
|
||||
x0 = 0
|
||||
y0 = 0
|
||||
x1 = pWidth
|
||||
y1 = pHeight
|
||||
}
|
||||
|
||||
println("renderPage 4")
|
||||
val fzPixmap = fz.fz_new_pixmap_with_bbox(fzContext, fzColor, bbox, null, 1);
|
||||
println("renderPage 5")
|
||||
|
||||
fz.fz_clear_pixmap_with_value(fzContext, fzPixmap, 0xff)
|
||||
|
||||
|
||||
val fzDev = fz.fz_new_draw_device(fzContext, fzMatrix, fzPixmap)
|
||||
fz.fz_run_page(fzContext, fzPage, fzDev, fz_matrix(), null)
|
||||
// val width = fz.fz_pixmap_width(fzContext, fzPixmap)
|
||||
//val height = fz.fz_pixmap_height(fzContext, fzPixmap)
|
||||
//val size = fz.fz_pixmap_size(fzContext, fzPixmap)
|
||||
//val stride = fz.fz_pixmap_stride(fzContext, fzPixmap)
|
||||
|
||||
val samples = fz.fz_pixmap_samples(fzContext, fzPixmap)
|
||||
if (samples == null) {
|
||||
return Triple(intArrayOf(), 0, 0)
|
||||
}
|
||||
|
||||
println("renderPage 6")
|
||||
val array = samples.getIntArray(0, pWidth * pHeight)
|
||||
|
||||
println("renderPage 7")
|
||||
|
||||
fz.fz_drop_page(fzContext, fzPage)
|
||||
fz.fz_drop_pixmap(fzContext, fzPixmap)
|
||||
|
||||
fz.fz_close_device(fzContext, fzDev)
|
||||
fz.fz_drop_device(fzContext, fzDev)
|
||||
println("renderPage 8")
|
||||
return Triple(array, pWidth, pHeight)
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package mobi.librera.mupdf.fz.lib
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asAndroidBitmap
|
||||
import com.sun.jna.Pointer
|
||||
import com.sun.jna.Structure
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
val mutex = Mutex()
|
||||
|
||||
suspend fun openDocument(
|
||||
name: String, document: ByteArray, width: Int, height: Int, fontSize: Int
|
||||
): MupdfDocument {
|
||||
//val temp = MuFile.createTempFile(name, document)
|
||||
val temp = name
|
||||
val common = mutex.withLock {
|
||||
CommonLib(temp, width, height, fontSize)
|
||||
}
|
||||
println("openDocument $width $height $fontSize $name")
|
||||
return object : MupdfDocument() {
|
||||
override val pageCount = common.fzPagesCount
|
||||
override val title = common.fzTitle
|
||||
|
||||
override suspend fun renderPage(page: Int, pageWidth: Int): Bitmap {
|
||||
if (pageCount == 0 || pageWidth == 0) {
|
||||
return ImageBitmap(1, 1).asAndroidBitmap()
|
||||
}
|
||||
|
||||
println("Load page number $page ")
|
||||
val (array, width1, height1) =
|
||||
mutex.withLock { common.renderPage(page, pageWidth) }
|
||||
|
||||
val image = Bitmap.createBitmap(
|
||||
array, width1, height1, Bitmap.Config.ARGB_8888
|
||||
)
|
||||
|
||||
return image
|
||||
|
||||
}
|
||||
|
||||
override suspend fun close() {
|
||||
mutex.withLock {
|
||||
common.close()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getOutline(): List<Outline> = common.getOutline()
|
||||
}
|
||||
}
|
||||
|
||||
open class MemoryStructure : Structure() {
|
||||
|
||||
public override fun useMemory(m: Pointer?) {
|
||||
super.useMemory(m)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
data class Outline(
|
||||
val title: String,
|
||||
val page: Int,
|
||||
val url: String,
|
||||
val level: Int,
|
||||
)
|
||||
|
||||
class EmptyDoc2 : MupdfDocument() {
|
||||
override val pageCount: Int
|
||||
get() = TODO("Not yet implemented")
|
||||
override val title: String
|
||||
get() = TODO("Not yet implemented")
|
||||
|
||||
override suspend fun renderPage(page: Int, pageWidth: Int): Bitmap {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun close() {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun getOutline(): List<Outline> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
abstract class MupdfDocument {
|
||||
abstract val pageCount: Int
|
||||
abstract val title: String
|
||||
abstract suspend fun renderPage(page: Int, pageWidth: Int): Bitmap
|
||||
abstract suspend fun close()
|
||||
|
||||
abstract suspend fun getOutline(): List<Outline>
|
||||
|
||||
companion object EmptyDoc : MupdfDocument() {
|
||||
override val pageCount: Int = 0;
|
||||
override val title: String = "";
|
||||
override suspend fun renderPage(page: Int, pageWidth: Int): Bitmap =
|
||||
ImageBitmap(1, 1).asAndroidBitmap()
|
||||
|
||||
override suspend fun close() {}
|
||||
override suspend fun getOutline(): List<Outline> = emptyList()
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_adaptive_back"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_adaptive_fore"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 852 B |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 459 B |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 114 KiB |
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">Librera Cat</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.Librera" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
@@ -0,0 +1,209 @@
|
||||
package mobi.librera.appcompose
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// Assuming these are your data classes and repositories
|
||||
data class BookState(
|
||||
val fileName: String,
|
||||
var time: Long, // Use Long for timestamps
|
||||
var bookPaths: Set<String> // Using a Set to avoid duplicate paths
|
||||
)
|
||||
|
||||
object FirestoreBooksRepository {
|
||||
// This should ideally return a Flow<List<BookState>> for continuous updates
|
||||
// For simplicity, I'm assuming listenToNotes provides updates
|
||||
fun listenToNotes(onUpdate: (List<BookState>) -> Unit) {
|
||||
// Mock implementation for demonstration
|
||||
// In a real app, this would be a Firestore listener
|
||||
// that calls onUpdate with the latest data.
|
||||
// For example:
|
||||
/*
|
||||
FirebaseFirestore.getInstance().collection("books")
|
||||
.addSnapshotListener { snapshot, e ->
|
||||
if (e != null) {
|
||||
println("Listen failed: $e")
|
||||
return@addSnapshotListener
|
||||
}
|
||||
val remoteBooks = snapshot?.documents?.mapNotNull { doc ->
|
||||
doc.toObject(BookState::class.java)
|
||||
} ?: emptyList()
|
||||
onUpdate(remoteBooks)
|
||||
}
|
||||
*/
|
||||
|
||||
// Simulating some updates for testing
|
||||
// This is not how a real Firestore listener works,
|
||||
// but it allows the code to compile and run for demonstration.
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
kotlinx.coroutines.delay(1000)
|
||||
onUpdate(
|
||||
listOf(
|
||||
BookState(
|
||||
"book1.pdf",
|
||||
System.currentTimeMillis() - 5000,
|
||||
setOf("/path/to/book1")
|
||||
),
|
||||
BookState(
|
||||
"book2.epub",
|
||||
System.currentTimeMillis() - 2000,
|
||||
setOf("/path/to/book2")
|
||||
)
|
||||
)
|
||||
)
|
||||
kotlinx.coroutines.delay(3000)
|
||||
onUpdate(
|
||||
listOf(
|
||||
BookState(
|
||||
"book1.pdf",
|
||||
System.currentTimeMillis(),
|
||||
setOf("/path/to/book1", "/another/path/to/book1")
|
||||
),
|
||||
BookState("book3.mobi", System.currentTimeMillis(), setOf("/path/to/book3"))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun syncBook(book: BookState) {
|
||||
// In a real app, this would upload the book to Firestore
|
||||
println("Firestore Sync: Uploading ${book.fileName}")
|
||||
// Simulate network delay
|
||||
kotlinx.coroutines.delay(500)
|
||||
}
|
||||
}
|
||||
|
||||
class BookRepository(private val database: Any) { // Replace Any with your actual Room DAO/Database type
|
||||
private val localBooks = mutableListOf<BookState>() // Mock for Room operations
|
||||
|
||||
init {
|
||||
// Initialize with some mock data
|
||||
localBooks.add(
|
||||
BookState(
|
||||
"book1.pdf",
|
||||
System.currentTimeMillis() - 10000,
|
||||
setOf("/old/path/book1")
|
||||
)
|
||||
)
|
||||
localBooks.add(
|
||||
BookState(
|
||||
"book4.txt",
|
||||
System.currentTimeMillis() - 1000,
|
||||
setOf("/path/to/book4")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun getAllBookState(): List<BookState> {
|
||||
// Simulate Room database access
|
||||
kotlinx.coroutines.delay(100)
|
||||
return localBooks.toList()
|
||||
}
|
||||
|
||||
suspend fun insertBookState(book: BookState) {
|
||||
// Simulate Room database insertion/update
|
||||
val existingIndex = localBooks.indexOfFirst { it.fileName == book.fileName }
|
||||
if (existingIndex != -1) {
|
||||
localBooks[existingIndex] = book
|
||||
println("Room DB: Updated ${book.fileName}")
|
||||
} else {
|
||||
localBooks.add(book)
|
||||
println("Room DB: Inserted ${book.fileName}")
|
||||
}
|
||||
kotlinx.coroutines.delay(50)
|
||||
}
|
||||
}
|
||||
|
||||
// Assume App.DEVICE_ID exists
|
||||
object App {
|
||||
const val DEVICE_ID = "my_device_123"
|
||||
}
|
||||
|
||||
|
||||
class SyncManager(
|
||||
private val bookRepository: BookRepository,
|
||||
private val viewModelScope: CoroutineScope // Pass a CoroutineScope (e.g., viewModelScope, lifecycleScope)
|
||||
) {
|
||||
|
||||
private fun observeFirestoreAndSyncToRoom() {
|
||||
println("Sync: ${App.DEVICE_ID}")
|
||||
|
||||
FirestoreBooksRepository.listenToNotes { remoteBooks ->
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val localBooks = bookRepository.getAllBookState()
|
||||
|
||||
println("Sync remoteBooks count: ${remoteBooks.size}")
|
||||
println("Sync localBooks count: ${localBooks.size}")
|
||||
|
||||
val allFileNames =
|
||||
(localBooks.map { it.fileName } + remoteBooks.map { it.fileName }).toSet()
|
||||
|
||||
allFileNames.forEach { fileName ->
|
||||
val remoteBook = remoteBooks.find { it.fileName == fileName }
|
||||
val localBook = localBooks.find { it.fileName == fileName }
|
||||
|
||||
when {
|
||||
// Case 1: Book only exists remotely (new book from Firestore)
|
||||
localBook == null && remoteBook != null -> {
|
||||
bookRepository.insertBookState(remoteBook)
|
||||
println("Sync: Local insert book from remote: ${remoteBook.fileName}")
|
||||
}
|
||||
// Case 2: Book only exists locally (new book from local device)
|
||||
remoteBook == null && localBook != null -> {
|
||||
FirestoreBooksRepository.syncBook(localBook)
|
||||
println("Sync: Remote sync book from local: ${localBook.fileName}")
|
||||
}
|
||||
// Case 3: Book exists in both, resolve conflicts
|
||||
remoteBook != null && localBook != null -> {
|
||||
when {
|
||||
// Timestamps are identical, no conflict, skip
|
||||
remoteBook.time == localBook.time -> {
|
||||
println("Sync: Skip by time for ${fileName} (timestamps are equal)")
|
||||
}
|
||||
// Remote is newer, update local and merge bookPaths
|
||||
remoteBook.time > localBook.time -> {
|
||||
val mergedBookPaths = remoteBook.bookPaths + localBook.bookPaths
|
||||
val updatedRemoteBook =
|
||||
remoteBook.copy(bookPaths = mergedBookPaths.toSet())
|
||||
bookRepository.insertBookState(updatedRemoteBook)
|
||||
println("Sync: Local update from newer remote: ${remoteBook.fileName}. Merged paths.")
|
||||
}
|
||||
// Local is newer, update remote and merge bookPaths
|
||||
else -> { // localBook.time > remoteBook.time
|
||||
val mergedBookPaths = localBook.bookPaths + remoteBook.bookPaths
|
||||
val updatedLocalBook =
|
||||
localBook.copy(bookPaths = mergedBookPaths.toSet())
|
||||
FirestoreBooksRepository.syncBook(updatedLocalBook)
|
||||
println("Sync: Remote sync from newer local: ${localBook.fileName}. Merged paths.")
|
||||
}
|
||||
}
|
||||
}
|
||||
// This case should ideally not happen if allFileNames is derived correctly
|
||||
else -> println("Sync: Should not reach here for $fileName (both null)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// You might want to expose a way to start observing
|
||||
fun startSyncing() {
|
||||
observeFirestoreAndSyncToRoom()
|
||||
}
|
||||
}
|
||||
|
||||
// Example usage (e.g., in a ViewModel or Application class)
|
||||
fun main() {
|
||||
val bookRepository =
|
||||
BookRepository(Any()) // Replace Any with your actual Room database instance
|
||||
val syncManager = SyncManager(
|
||||
bookRepository,
|
||||
CoroutineScope(Dispatchers.Default)
|
||||
) // Using Dispatchers.Default for main function example
|
||||
|
||||
syncManager.startSyncing()
|
||||
|
||||
// Keep the main thread alive for a bit to see the async operations
|
||||
Thread.sleep(10000)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package mobi.librera.appcompose
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||