Gast commit!

This commit is contained in:
2026-02-10 19:01:19 +08:00
commit 345f628a8a
3074 changed files with 278037 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
/build
/libs/
/schemas/
+148
View File
@@ -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")
}
+37
View File
@@ -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)
}
}
+66
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 852 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

+10
View File
@@ -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)
}
}