docs: Revamp README and add APK naming convention This commit introduces a complete overhaul of the `README.md` to improve project presentation. Key changes include: * Adds a new header with the app icon and a Google Play badge. * Updates the feature graphic and includes a note distinguishing the OSS version from the Play Store version. * Refines the "Open Source Libraries" section to be more structured. * Moves the main preview image into the `docs` directory. Additionally, the `app/build.gradle.kts` file is updated to configure a standardized naming convention for output APK files, including flavor, version, and build type. * refactor copyright headers and fix README icon alignment This commit introduces two main changes across the codebase: The `spotless/copyright.kt` template have been updated for consistency: * The author name is simplified from "Episteme Authors" to "Episteme". * The contact email prefix is shortened from "Electronic mail:" to "mail:". Second, the `README.md` file is adjusted to correct the vertical alignment of the app icon with the project title.
395 lines
No EOL
17 KiB
Kotlin
395 lines
No EOL
17 KiB
Kotlin
/*
|
|
* Episteme Reader - A native Android document reader.
|
|
* Copyright (C) 2026 Episteme
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as
|
|
* published by the Free Software Foundation, either version 3 of the
|
|
* License, or (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*
|
|
* mail: epistemereader@gmail.com
|
|
*/
|
|
package com.aryan.reader.pdf
|
|
|
|
import androidx.compose.foundation.background
|
|
import androidx.compose.foundation.border
|
|
import androidx.compose.foundation.gestures.detectDragGestures
|
|
import androidx.compose.foundation.gestures.detectTapGestures
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.foundation.layout.offset
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.layout.size
|
|
import androidx.compose.foundation.rememberScrollState
|
|
import androidx.compose.ui.zIndex
|
|
import androidx.compose.foundation.shape.CircleShape
|
|
import androidx.compose.foundation.text.BasicTextField
|
|
import androidx.compose.foundation.verticalScroll
|
|
import androidx.compose.material3.Icon
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.Surface
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.derivedStateOf
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import com.aryan.reader.R
|
|
import androidx.compose.ui.focus.FocusRequester
|
|
import androidx.compose.ui.focus.focusRequester
|
|
import androidx.compose.ui.geometry.Offset
|
|
import androidx.compose.ui.geometry.Rect
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.ui.graphics.SolidColor
|
|
import androidx.compose.ui.input.pointer.pointerInput
|
|
import androidx.compose.ui.platform.LocalDensity
|
|
import androidx.compose.ui.res.painterResource
|
|
import androidx.compose.ui.text.TextStyle
|
|
import androidx.compose.ui.text.font.FontFamily
|
|
import androidx.compose.ui.text.font.FontStyle
|
|
import androidx.compose.ui.text.font.FontWeight
|
|
import androidx.compose.ui.text.style.TextDecoration
|
|
import androidx.compose.ui.unit.IntOffset
|
|
import androidx.compose.ui.unit.dp
|
|
import com.aryan.reader.pdf.data.PdfTextBox
|
|
import timber.log.Timber
|
|
import kotlin.math.roundToInt
|
|
|
|
private enum class ResizeHandle {
|
|
TOP_LEFT, TOP_CENTER, TOP_RIGHT,
|
|
RIGHT_CENTER,
|
|
BOTTOM_RIGHT, BOTTOM_CENTER, BOTTOM_LEFT,
|
|
LEFT_CENTER,
|
|
NONE
|
|
}
|
|
|
|
enum class HandlePosition {
|
|
TOP, BOTTOM, AUTO
|
|
}
|
|
|
|
@Composable
|
|
fun ResizableTextBox(
|
|
box: PdfTextBox,
|
|
isSelected: Boolean,
|
|
isEditMode: Boolean,
|
|
isDarkMode: Boolean,
|
|
pageWidthPx: Float,
|
|
pageHeightPx: Float,
|
|
onBoundsChanged: (Rect) -> Unit,
|
|
onTextChanged: (String) -> Unit,
|
|
onSelect: () -> Unit,
|
|
onDragStart: (Offset) -> Unit,
|
|
onDrag: (Offset, Rect) -> Unit,
|
|
onDragEnd: () -> Unit,
|
|
modifier: Modifier = Modifier,
|
|
onDragCancel: () -> Unit = {},
|
|
handlePosition: HandlePosition = HandlePosition.AUTO
|
|
) {
|
|
if (pageWidthPx <= 0 || pageHeightPx <= 0) return
|
|
|
|
val density = LocalDensity.current
|
|
val focusRequester = remember { FocusRequester() }
|
|
|
|
val handleSize = 10.dp
|
|
val handleTouchSize = 40.dp
|
|
val handleSizePx = with(density) { handleSize.toPx() }
|
|
val halfHandlePx = handleSizePx / 2f
|
|
val handleTouchSizePx = with(density) { handleTouchSize.toPx() }
|
|
|
|
val borderColor = if (isDarkMode) Color.White else Color.Black
|
|
val handleColor = if (isDarkMode) Color.White else Color.Black
|
|
|
|
var isDraggingOrResizing by remember { mutableStateOf(false) }
|
|
|
|
val fontFamily = remember(box.fontPath, box.fontName) {
|
|
Timber.tag("PdfFontDebug").d("Rendering Box ${box.id}: FontPath=${box.fontPath}, FontName=${box.fontName}")
|
|
if (box.fontPath != null) {
|
|
PdfFontCache.getFontFamily(box.fontPath)
|
|
} else {
|
|
when (box.fontName) {
|
|
"Serif" -> FontFamily.Serif
|
|
"Sans" -> FontFamily.SansSerif
|
|
"Monospace" -> FontFamily.Monospace
|
|
"Cursive" -> FontFamily.Cursive
|
|
else -> FontFamily.Default
|
|
}
|
|
}
|
|
}
|
|
|
|
var currentRectPx by remember {
|
|
mutableStateOf(
|
|
Rect(
|
|
left = box.relativeBounds.left * pageWidthPx,
|
|
top = box.relativeBounds.top * pageHeightPx,
|
|
right = box.relativeBounds.right * pageWidthPx,
|
|
bottom = box.relativeBounds.bottom * pageHeightPx
|
|
)
|
|
)
|
|
}
|
|
|
|
LaunchedEffect(isSelected) {
|
|
if (isSelected && isEditMode) {
|
|
focusRequester.requestFocus()
|
|
}
|
|
}
|
|
|
|
LaunchedEffect(box.relativeBounds, pageWidthPx, pageHeightPx) {
|
|
if (!isDraggingOrResizing) {
|
|
val newPx = Rect(
|
|
left = box.relativeBounds.left * pageWidthPx,
|
|
top = box.relativeBounds.top * pageHeightPx,
|
|
right = box.relativeBounds.right * pageWidthPx,
|
|
bottom = box.relativeBounds.bottom * pageHeightPx
|
|
)
|
|
if (kotlin.math.abs(newPx.left - currentRectPx.left) > 1f ||
|
|
kotlin.math.abs(newPx.top - currentRectPx.top) > 1f ||
|
|
kotlin.math.abs(newPx.width - currentRectPx.width) > 1f ||
|
|
kotlin.math.abs(newPx.height - currentRectPx.height) > 1f
|
|
) {
|
|
currentRectPx = newPx
|
|
}
|
|
}
|
|
}
|
|
|
|
val requiredBottomSpacePx = with(density) { 60.dp.toPx() }
|
|
|
|
val isHandleAtTop by remember(currentRectPx, pageHeightPx, handlePosition) {
|
|
derivedStateOf {
|
|
when (handlePosition) {
|
|
HandlePosition.TOP -> true
|
|
HandlePosition.BOTTOM -> false
|
|
HandlePosition.AUTO -> {
|
|
if (pageHeightPx <= 0f) {
|
|
false
|
|
} else {
|
|
val spaceBelow = pageHeightPx - currentRectPx.bottom
|
|
spaceBelow < requiredBottomSpacePx
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Box(
|
|
modifier = modifier
|
|
.zIndex(if (isSelected) 10f else 0f)
|
|
.offset {
|
|
IntOffset(
|
|
(currentRectPx.left - halfHandlePx).roundToInt(),
|
|
(currentRectPx.top - halfHandlePx).roundToInt()
|
|
)
|
|
}
|
|
.size(
|
|
width = with(density) { (currentRectPx.width + handleSizePx).toDp() },
|
|
height = with(density) { (currentRectPx.height + handleSizePx).toDp() }
|
|
)
|
|
) {
|
|
// --- 1. Content Body ---
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.padding(handleSize / 2)
|
|
.pointerInput(Unit) {
|
|
detectTapGestures { onSelect() }
|
|
}
|
|
.then(
|
|
if (isSelected) Modifier.border(1.5.dp, borderColor) else Modifier
|
|
)
|
|
) {
|
|
BasicTextField(
|
|
value = box.text,
|
|
onValueChange = onTextChanged,
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.padding(8.dp)
|
|
.verticalScroll(rememberScrollState())
|
|
.focusRequester(focusRequester),
|
|
textStyle = TextStyle(
|
|
color = box.color,
|
|
background = box.backgroundColor,
|
|
fontFamily = fontFamily,
|
|
fontSize = with(LocalDensity.current) {
|
|
(box.fontSize * pageHeightPx).coerceAtLeast(10f).toSp()
|
|
},
|
|
fontWeight = if (box.isBold) FontWeight.Bold else FontWeight.Normal,
|
|
fontStyle = if (box.isItalic) FontStyle.Italic else FontStyle.Normal,
|
|
textDecoration = run {
|
|
val decs = mutableListOf<TextDecoration>()
|
|
if (box.isUnderline) decs.add(TextDecoration.Underline)
|
|
if (box.isStrikeThrough) decs.add(TextDecoration.LineThrough)
|
|
if (decs.isEmpty()) TextDecoration.None else TextDecoration.combine(decs)
|
|
}
|
|
),
|
|
cursorBrush = SolidColor(if (isDarkMode) Color.White else MaterialTheme.colorScheme.primary),
|
|
enabled = isEditMode && isSelected,
|
|
readOnly = !isEditMode
|
|
)
|
|
}
|
|
|
|
if (isSelected) {
|
|
val handles = ResizeHandle.entries.filter { it != ResizeHandle.NONE }
|
|
|
|
fun getHandleCenter(handle: ResizeHandle, w: Float, h: Float): Offset {
|
|
return when (handle) {
|
|
ResizeHandle.TOP_LEFT -> Offset(halfHandlePx, halfHandlePx)
|
|
ResizeHandle.TOP_CENTER -> Offset(halfHandlePx + w / 2, halfHandlePx)
|
|
ResizeHandle.TOP_RIGHT -> Offset(halfHandlePx + w, halfHandlePx)
|
|
ResizeHandle.RIGHT_CENTER -> Offset(halfHandlePx + w, halfHandlePx + h / 2)
|
|
ResizeHandle.BOTTOM_RIGHT -> Offset(halfHandlePx + w, halfHandlePx + h)
|
|
ResizeHandle.BOTTOM_CENTER -> Offset(halfHandlePx + w / 2, halfHandlePx + h)
|
|
ResizeHandle.BOTTOM_LEFT -> Offset(halfHandlePx, halfHandlePx + h)
|
|
ResizeHandle.LEFT_CENTER -> Offset(halfHandlePx, halfHandlePx + h / 2)
|
|
else -> Offset.Zero
|
|
}
|
|
}
|
|
|
|
handles.forEach { handle ->
|
|
val center = getHandleCenter(handle, currentRectPx.width, currentRectPx.height)
|
|
|
|
Box(
|
|
modifier = Modifier
|
|
.offset {
|
|
IntOffset(
|
|
(center.x - handleTouchSizePx / 2).roundToInt(),
|
|
(center.y - handleTouchSizePx / 2).roundToInt()
|
|
)
|
|
}
|
|
.size(handleTouchSize)
|
|
.pointerInput(onBoundsChanged) {
|
|
detectDragGestures(
|
|
onDragStart = { isDraggingOrResizing = true },
|
|
onDragEnd = {
|
|
isDraggingOrResizing = false
|
|
val normalized = Rect(
|
|
left = currentRectPx.left / pageWidthPx,
|
|
top = currentRectPx.top / pageHeightPx,
|
|
right = currentRectPx.right / pageWidthPx,
|
|
bottom = currentRectPx.bottom / pageHeightPx
|
|
)
|
|
onBoundsChanged(normalized)
|
|
},
|
|
onDragCancel = { isDraggingOrResizing = false }
|
|
) { change, dragAmount ->
|
|
change.consume()
|
|
var l = currentRectPx.left
|
|
var t = currentRectPx.top
|
|
var r = currentRectPx.right
|
|
var b = currentRectPx.bottom
|
|
val dx = dragAmount.x
|
|
val dy = dragAmount.y
|
|
val minSize = 50f
|
|
|
|
when (handle) {
|
|
ResizeHandle.TOP_LEFT -> {
|
|
l = (l + dx).coerceIn(0f, r - minSize)
|
|
t = (t + dy).coerceIn(0f, b - minSize)
|
|
}
|
|
ResizeHandle.TOP_CENTER -> t = (t + dy).coerceIn(0f, b - minSize)
|
|
ResizeHandle.TOP_RIGHT -> {
|
|
r = (r + dx).coerceIn(l + minSize, pageWidthPx)
|
|
t = (t + dy).coerceIn(0f, b - minSize)
|
|
}
|
|
ResizeHandle.RIGHT_CENTER -> r = (r + dx).coerceIn(l + minSize, pageWidthPx)
|
|
ResizeHandle.BOTTOM_RIGHT -> {
|
|
r = (r + dx).coerceIn(l + minSize, pageWidthPx)
|
|
b = (b + dy).coerceIn(t + minSize, pageHeightPx)
|
|
}
|
|
ResizeHandle.BOTTOM_CENTER -> b = (b + dy).coerceIn(t + minSize, pageHeightPx)
|
|
ResizeHandle.BOTTOM_LEFT -> {
|
|
l = (l + dx).coerceIn(0f, r - minSize)
|
|
b = (b + dy).coerceIn(t + minSize, pageHeightPx)
|
|
}
|
|
ResizeHandle.LEFT_CENTER -> l = (l + dx).coerceIn(0f, r - minSize)
|
|
else -> {}
|
|
}
|
|
currentRectPx = Rect(l, t, r, b)
|
|
}
|
|
}
|
|
) {
|
|
Box(
|
|
modifier = Modifier
|
|
.size(handleSize)
|
|
.background(handleColor, CircleShape)
|
|
.align(Alignment.Center)
|
|
)
|
|
}
|
|
}
|
|
|
|
DragPill(
|
|
isDarkMode = isDarkMode,
|
|
modifier = Modifier
|
|
.align(if (isHandleAtTop) Alignment.TopCenter else Alignment.BottomCenter)
|
|
.offset(y = if (isHandleAtTop) (-32).dp else 32.dp)
|
|
.zIndex(20f)
|
|
.pointerInput(pageWidthPx, pageHeightPx, onDragStart, onDragEnd, onDragCancel) {
|
|
detectDragGestures(
|
|
onDragStart = { offset ->
|
|
isDraggingOrResizing = true
|
|
onDragStart(offset)
|
|
},
|
|
onDragEnd = {
|
|
isDraggingOrResizing = false
|
|
val normalized = Rect(
|
|
left = currentRectPx.left / pageWidthPx,
|
|
top = currentRectPx.top / pageHeightPx,
|
|
right = currentRectPx.right / pageWidthPx,
|
|
bottom = currentRectPx.bottom / pageHeightPx
|
|
)
|
|
onBoundsChanged(normalized)
|
|
onDragEnd()
|
|
},
|
|
onDragCancel = {
|
|
isDraggingOrResizing = false
|
|
onDragCancel()
|
|
}
|
|
) { change, dragAmount ->
|
|
change.consume()
|
|
val w = currentRectPx.width
|
|
val h = currentRectPx.height
|
|
val rawLeft = currentRectPx.left + dragAmount.x
|
|
val rawTop = currentRectPx.top + dragAmount.y
|
|
val newLeft = rawLeft.coerceIn(0f, pageWidthPx - w)
|
|
val newTop = rawTop.coerceIn(0f, pageHeightPx - h)
|
|
val newRect = Rect(newLeft, newTop, newLeft + w, newTop + h)
|
|
currentRectPx = newRect
|
|
onDrag(dragAmount, newRect)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun DragPill(
|
|
modifier: Modifier = Modifier,
|
|
isDarkMode: Boolean
|
|
) {
|
|
Surface(
|
|
modifier = modifier
|
|
.size(width = 48.dp, height = 24.dp),
|
|
shape = CircleShape,
|
|
color = if (isDarkMode) Color.White else Color.Black,
|
|
contentColor = if (isDarkMode) Color.Black else Color.White,
|
|
shadowElevation = 4.dp
|
|
) {
|
|
Box(contentAlignment = Alignment.Center) {
|
|
Icon(
|
|
painter = painterResource(id = R.drawable.drag_handle),
|
|
contentDescription = "Drag to move text box",
|
|
modifier = Modifier.size(20.dp)
|
|
)
|
|
}
|
|
}
|
|
} |