Overhaul navigation, add line tool and JPG export
Navigation: - Notebooks remember last page (last_page_id column, migration v1->v2) - Opening a notebook goes directly to last page's editor - Edge swipe (finger from screen edge) navigates prev/next page, auto-adds pages at end, no-op on first page - X button closes notebook back to list - Binder dropdown: "View all pages" and "Go to page" dialog - Page list shows stroke previews and page numbers below cards Line tool: - LINE tool: drag start-to-end for straight lines - Long-press Line chip for style variants: plain, arrow, double arrow, dashed - Arrow heads rendered as path segments, stored in stroke data - Dashed lines use DashPathEffect Export: - JPG export at full 300 DPI resolution - Export dropdown: PDF or JPG options - Refactored PdfExporter with shared drawStrokes helper Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,13 +4,15 @@ import android.content.Context
|
|||||||
import androidx.room.Database
|
import androidx.room.Database
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
import net.metacircular.engpad.data.model.Notebook
|
import net.metacircular.engpad.data.model.Notebook
|
||||||
import net.metacircular.engpad.data.model.Page
|
import net.metacircular.engpad.data.model.Page
|
||||||
import net.metacircular.engpad.data.model.Stroke
|
import net.metacircular.engpad.data.model.Stroke
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [Notebook::class, Page::class, Stroke::class],
|
entities = [Notebook::class, Page::class, Stroke::class],
|
||||||
version = 1,
|
version = 2,
|
||||||
exportSchema = false,
|
exportSchema = false,
|
||||||
)
|
)
|
||||||
abstract class EngPadDatabase : RoomDatabase() {
|
abstract class EngPadDatabase : RoomDatabase() {
|
||||||
@@ -22,13 +24,21 @@ abstract class EngPadDatabase : RoomDatabase() {
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var instance: EngPadDatabase? = null
|
private var instance: EngPadDatabase? = null
|
||||||
|
|
||||||
|
private val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL(
|
||||||
|
"ALTER TABLE notebooks ADD COLUMN last_page_id INTEGER NOT NULL DEFAULT 0"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getInstance(context: Context): EngPadDatabase =
|
fun getInstance(context: Context): EngPadDatabase =
|
||||||
instance ?: synchronized(this) {
|
instance ?: synchronized(this) {
|
||||||
instance ?: Room.databaseBuilder(
|
instance ?: Room.databaseBuilder(
|
||||||
context.applicationContext,
|
context.applicationContext,
|
||||||
EngPadDatabase::class.java,
|
EngPadDatabase::class.java,
|
||||||
"engpad.db",
|
"engpad.db",
|
||||||
).build().also { instance = it }
|
).addMigrations(MIGRATION_1_2).build().also { instance = it }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,4 +23,7 @@ interface NotebookDao {
|
|||||||
|
|
||||||
@Query("DELETE FROM notebooks WHERE id = :id")
|
@Query("DELETE FROM notebooks WHERE id = :id")
|
||||||
suspend fun deleteById(id: Long)
|
suspend fun deleteById(id: Long)
|
||||||
|
|
||||||
|
@Query("UPDATE notebooks SET last_page_id = :pageId, updated_at = :updatedAt WHERE id = :id")
|
||||||
|
suspend fun updateLastPage(id: Long, pageId: Long, updatedAt: Long)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,4 +22,7 @@ interface PageDao {
|
|||||||
|
|
||||||
@Query("DELETE FROM pages WHERE id = :id")
|
@Query("DELETE FROM pages WHERE id = :id")
|
||||||
suspend fun deleteById(id: Long)
|
suspend fun deleteById(id: Long)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM pages WHERE notebook_id = :notebookId ORDER BY page_number ASC")
|
||||||
|
suspend fun getByNotebookIdList(notebookId: Long): List<Page>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,4 +11,5 @@ data class Notebook(
|
|||||||
@ColumnInfo(name = "page_size") val pageSize: String,
|
@ColumnInfo(name = "page_size") val pageSize: String,
|
||||||
@ColumnInfo(name = "created_at") val createdAt: Long,
|
@ColumnInfo(name = "created_at") val createdAt: Long,
|
||||||
@ColumnInfo(name = "updated_at") val updatedAt: Long,
|
@ColumnInfo(name = "updated_at") val updatedAt: Long,
|
||||||
|
@ColumnInfo(name = "last_page_id", defaultValue = "0") val lastPageId: Long = 0,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ class NotebookRepository(
|
|||||||
|
|
||||||
suspend fun delete(id: Long) = notebookDao.deleteById(id)
|
suspend fun delete(id: Long) = notebookDao.deleteById(id)
|
||||||
|
|
||||||
|
suspend fun updateLastPage(notebookId: Long, pageId: Long) {
|
||||||
|
notebookDao.updateLastPage(notebookId, pageId, System.currentTimeMillis())
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun updateTitle(id: Long, title: String) {
|
suspend fun updateTitle(id: Long, title: String) {
|
||||||
val notebook = notebookDao.getById(id) ?: return
|
val notebook = notebookDao.getById(id) ?: return
|
||||||
notebookDao.update(
|
notebookDao.update(
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ class PageRepository(
|
|||||||
fun getPages(notebookId: Long): Flow<List<Page>> =
|
fun getPages(notebookId: Long): Flow<List<Page>> =
|
||||||
pageDao.getByNotebookId(notebookId)
|
pageDao.getByNotebookId(notebookId)
|
||||||
|
|
||||||
|
suspend fun getPagesList(notebookId: Long): List<Page> =
|
||||||
|
pageDao.getByNotebookIdList(notebookId)
|
||||||
|
|
||||||
suspend fun getById(id: Long): Page? = pageDao.getById(id)
|
suspend fun getById(id: Long): Page? = pageDao.getById(id)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,11 +8,20 @@ enum class Tool {
|
|||||||
ERASER,
|
ERASER,
|
||||||
SELECT,
|
SELECT,
|
||||||
BOX, // Draw rectangles
|
BOX, // Draw rectangles
|
||||||
|
LINE, // Draw straight lines (with style variants)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class LineStyle {
|
||||||
|
PLAIN,
|
||||||
|
ARROW, // Single arrow at end
|
||||||
|
DOUBLE_ARROW, // Arrows at both ends
|
||||||
|
DASHED,
|
||||||
}
|
}
|
||||||
|
|
||||||
data class CanvasState(
|
data class CanvasState(
|
||||||
val pageSize: PageSize = PageSize.REGULAR,
|
val pageSize: PageSize = PageSize.REGULAR,
|
||||||
val tool: Tool = Tool.PEN_FINE,
|
val tool: Tool = Tool.PEN_FINE,
|
||||||
|
val lineStyle: LineStyle = LineStyle.PLAIN,
|
||||||
val zoom: Float = 1f,
|
val zoom: Float = 1f,
|
||||||
val panX: Float = 0f,
|
val panX: Float = 0f,
|
||||||
val panY: Float = 0f,
|
val panY: Float = 0f,
|
||||||
@@ -21,7 +30,8 @@ data class CanvasState(
|
|||||||
get() = when (tool) {
|
get() = when (tool) {
|
||||||
Tool.PEN_FINE -> 4.49f
|
Tool.PEN_FINE -> 4.49f
|
||||||
Tool.PEN_MEDIUM -> 5.91f
|
Tool.PEN_MEDIUM -> 5.91f
|
||||||
Tool.BOX -> 4.49f // Box uses fine pen width
|
Tool.BOX -> 4.49f
|
||||||
|
Tool.LINE -> 4.49f
|
||||||
else -> 0f
|
else -> 0f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,26 +17,37 @@ import net.metacircular.engpad.data.db.EngPadDatabase
|
|||||||
import net.metacircular.engpad.data.db.toFloatArray
|
import net.metacircular.engpad.data.db.toFloatArray
|
||||||
import net.metacircular.engpad.data.model.Page
|
import net.metacircular.engpad.data.model.Page
|
||||||
import net.metacircular.engpad.data.model.PageSize
|
import net.metacircular.engpad.data.model.PageSize
|
||||||
|
import net.metacircular.engpad.data.repository.NotebookRepository
|
||||||
import net.metacircular.engpad.data.repository.PageRepository
|
import net.metacircular.engpad.data.repository.PageRepository
|
||||||
import net.metacircular.engpad.ui.export.PdfExporter
|
import net.metacircular.engpad.ui.export.PdfExporter
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun EditorScreen(
|
fun EditorScreen(
|
||||||
pageId: Long,
|
notebookId: Long,
|
||||||
pageSize: PageSize,
|
pageSize: PageSize,
|
||||||
|
initialPageId: Long,
|
||||||
database: EngPadDatabase,
|
database: EngPadDatabase,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
onViewAllPages: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val repository = remember {
|
val pageRepository = remember {
|
||||||
PageRepository(database.pageDao(), database.strokeDao())
|
PageRepository(database.pageDao(), database.strokeDao())
|
||||||
}
|
}
|
||||||
|
val notebookRepository = remember {
|
||||||
|
NotebookRepository(database.notebookDao(), database.pageDao())
|
||||||
|
}
|
||||||
val viewModel: EditorViewModel = viewModel(
|
val viewModel: EditorViewModel = viewModel(
|
||||||
factory = EditorViewModel.Factory(pageId, pageSize, repository),
|
factory = EditorViewModel.Factory(
|
||||||
|
notebookId, pageSize, pageRepository, notebookRepository, initialPageId,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
val canvasState by viewModel.canvasState.collectAsState()
|
val canvasState by viewModel.canvasState.collectAsState()
|
||||||
val strokes by viewModel.strokes.collectAsState()
|
val strokes by viewModel.strokes.collectAsState()
|
||||||
val canUndo by viewModel.undoManager.canUndo.collectAsState()
|
val canUndo by viewModel.undoManager.canUndo.collectAsState()
|
||||||
val canRedo by viewModel.undoManager.canRedo.collectAsState()
|
val canRedo by viewModel.undoManager.canRedo.collectAsState()
|
||||||
val hasSelection by viewModel.hasSelection.collectAsState()
|
val hasSelection by viewModel.hasSelection.collectAsState()
|
||||||
|
val currentPageIndex by viewModel.currentPageIndex.collectAsState()
|
||||||
|
val pages by viewModel.pages.collectAsState()
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val canvasView = remember { PadCanvasView(context) }
|
val canvasView = remember { PadCanvasView(context) }
|
||||||
@@ -68,7 +79,12 @@ fun EditorScreen(
|
|||||||
canvasView.onSelectionMoved = { ids, dx, dy ->
|
canvasView.onSelectionMoved = { ids, dx, dy ->
|
||||||
viewModel.moveSelection(dx, dy)
|
viewModel.moveSelection(dx, dy)
|
||||||
}
|
}
|
||||||
// Wire undo/redo visual callbacks
|
canvasView.onEdgeSwipe = { direction ->
|
||||||
|
when (direction) {
|
||||||
|
PadCanvasView.SwipeDirection.LEFT -> viewModel.navigateNext()
|
||||||
|
PadCanvasView.SwipeDirection.RIGHT -> viewModel.navigatePrev()
|
||||||
|
}
|
||||||
|
}
|
||||||
viewModel.onStrokeAdded = { stroke ->
|
viewModel.onStrokeAdded = { stroke ->
|
||||||
canvasView.addCompletedStroke(
|
canvasView.addCompletedStroke(
|
||||||
stroke.id, stroke.penSize, stroke.color,
|
stroke.id, stroke.penSize, stroke.color,
|
||||||
@@ -100,16 +116,35 @@ fun EditorScreen(
|
|||||||
viewModel.copySelection()
|
viewModel.copySelection()
|
||||||
canvasView.clearSelection()
|
canvasView.clearSelection()
|
||||||
},
|
},
|
||||||
onExport = {
|
onExportPdf = {
|
||||||
val page = Page(id = pageId, notebookId = 0, pageNumber = 1, createdAt = 0)
|
val currentPage = pages.getOrNull(currentPageIndex) ?: return@EditorToolbar
|
||||||
val file = PdfExporter.exportPages(
|
val file = PdfExporter.exportPages(
|
||||||
context = context,
|
context = context,
|
||||||
notebookTitle = "eng-pad-export",
|
notebookTitle = "eng-pad-export",
|
||||||
pageSize = pageSize,
|
pageSize = pageSize,
|
||||||
pages = listOf(page to strokes),
|
pages = listOf(currentPage to strokes),
|
||||||
)
|
)
|
||||||
PdfExporter.shareFile(context, file)
|
PdfExporter.shareFile(context, file)
|
||||||
},
|
},
|
||||||
|
onExportJpg = {
|
||||||
|
val file = PdfExporter.exportPageAsJpg(
|
||||||
|
context = context,
|
||||||
|
notebookTitle = "eng-pad-export",
|
||||||
|
pageSize = pageSize,
|
||||||
|
pageNumber = currentPageIndex + 1,
|
||||||
|
strokes = strokes,
|
||||||
|
)
|
||||||
|
PdfExporter.shareFile(context, file, "image/jpeg")
|
||||||
|
},
|
||||||
|
onClose = onClose,
|
||||||
|
currentPageNumber = currentPageIndex + 1,
|
||||||
|
totalPages = pages.size,
|
||||||
|
onViewAllPages = onViewAllPages,
|
||||||
|
onGoToPage = { pageNum ->
|
||||||
|
viewModel.navigateToPage(pageNum - 1)
|
||||||
|
},
|
||||||
|
lineStyle = canvasState.lineStyle,
|
||||||
|
onLineStyleChanged = { viewModel.setLineStyle(it) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
AndroidView(
|
AndroidView(
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import net.metacircular.engpad.data.db.toBlob
|
import net.metacircular.engpad.data.db.toBlob
|
||||||
|
import net.metacircular.engpad.data.model.Page
|
||||||
import net.metacircular.engpad.data.model.PageSize
|
import net.metacircular.engpad.data.model.PageSize
|
||||||
import net.metacircular.engpad.data.model.Stroke
|
import net.metacircular.engpad.data.model.Stroke
|
||||||
|
import net.metacircular.engpad.data.repository.NotebookRepository
|
||||||
import net.metacircular.engpad.data.repository.PageRepository
|
import net.metacircular.engpad.data.repository.PageRepository
|
||||||
import net.metacircular.engpad.undo.AddStrokeAction
|
import net.metacircular.engpad.undo.AddStrokeAction
|
||||||
import net.metacircular.engpad.undo.CopyStrokesAction
|
import net.metacircular.engpad.undo.CopyStrokesAction
|
||||||
@@ -19,9 +21,11 @@ import net.metacircular.engpad.undo.MoveStrokesAction
|
|||||||
import net.metacircular.engpad.undo.UndoManager
|
import net.metacircular.engpad.undo.UndoManager
|
||||||
|
|
||||||
class EditorViewModel(
|
class EditorViewModel(
|
||||||
private val pageId: Long,
|
private val notebookId: Long,
|
||||||
private val pageSize: PageSize,
|
private val pageSize: PageSize,
|
||||||
private val pageRepository: PageRepository,
|
private val pageRepository: PageRepository,
|
||||||
|
private val notebookRepository: NotebookRepository,
|
||||||
|
initialPageId: Long,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _canvasState = MutableStateFlow(CanvasState(pageSize = pageSize))
|
private val _canvasState = MutableStateFlow(CanvasState(pageSize = pageSize))
|
||||||
@@ -36,25 +40,90 @@ class EditorViewModel(
|
|||||||
val hasSelection: StateFlow<Boolean> = _hasSelection
|
val hasSelection: StateFlow<Boolean> = _hasSelection
|
||||||
private var selectedIds = emptySet<Long>()
|
private var selectedIds = emptySet<Long>()
|
||||||
|
|
||||||
// Callbacks for the canvas view to add/remove strokes visually
|
// Page navigation
|
||||||
|
private val _pages = MutableStateFlow<List<Page>>(emptyList())
|
||||||
|
val pages: StateFlow<List<Page>> = _pages
|
||||||
|
|
||||||
|
private val _currentPageIndex = MutableStateFlow(0)
|
||||||
|
val currentPageIndex: StateFlow<Int> = _currentPageIndex
|
||||||
|
|
||||||
|
private val _currentPageId = MutableStateFlow(initialPageId)
|
||||||
|
val currentPageId: StateFlow<Long> = _currentPageId
|
||||||
|
|
||||||
|
val pageCount: Int get() = _pages.value.size
|
||||||
|
|
||||||
|
// Callbacks for the canvas view
|
||||||
var onStrokeAdded: ((Stroke) -> Unit)? = null
|
var onStrokeAdded: ((Stroke) -> Unit)? = null
|
||||||
var onStrokeRemoved: ((Long) -> Unit)? = null
|
var onStrokeRemoved: ((Long) -> Unit)? = null
|
||||||
var onStrokesChanged: (() -> Unit)? = null
|
var onStrokesChanged: (() -> Unit)? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
loadPages()
|
||||||
|
// Find initial page index
|
||||||
|
val idx = _pages.value.indexOfFirst { it.id == initialPageId }
|
||||||
|
if (idx >= 0) {
|
||||||
|
_currentPageIndex.value = idx
|
||||||
|
_currentPageId.value = _pages.value[idx].id
|
||||||
|
}
|
||||||
loadStrokes()
|
loadStrokes()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadStrokes() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_strokes.value = pageRepository.getStrokes(pageId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun loadPages() {
|
||||||
|
_pages.value = pageRepository.getPagesList(notebookId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadStrokes() {
|
||||||
|
_strokes.value = pageRepository.getStrokes(_currentPageId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun navigateToPage(index: Int) {
|
||||||
|
val pages = _pages.value
|
||||||
|
if (index < 0 || index >= pages.size) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
// Save current page state
|
||||||
|
undoManager.clear()
|
||||||
|
_currentPageIndex.value = index
|
||||||
|
_currentPageId.value = pages[index].id
|
||||||
|
notebookRepository.updateLastPage(notebookId, pages[index].id)
|
||||||
|
_canvasState.value = _canvasState.value.copy(zoom = 1f, panX = 0f, panY = 0f)
|
||||||
|
clearSelection()
|
||||||
|
loadStrokes()
|
||||||
|
onStrokesChanged?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun navigateNext() {
|
||||||
|
val nextIndex = _currentPageIndex.value + 1
|
||||||
|
if (nextIndex < _pages.value.size) {
|
||||||
|
navigateToPage(nextIndex)
|
||||||
|
} else {
|
||||||
|
// Add a new page and navigate to it
|
||||||
|
viewModelScope.launch {
|
||||||
|
pageRepository.addPage(notebookId)
|
||||||
|
loadPages()
|
||||||
|
navigateToPage(_pages.value.size - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun navigatePrev() {
|
||||||
|
val prevIndex = _currentPageIndex.value - 1
|
||||||
|
if (prevIndex >= 0) {
|
||||||
|
navigateToPage(prevIndex)
|
||||||
|
}
|
||||||
|
// No-op on first page
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setTool(tool: Tool) {
|
fun setTool(tool: Tool) {
|
||||||
_canvasState.value = _canvasState.value.copy(tool = tool)
|
_canvasState.value = _canvasState.value.copy(tool = tool)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setLineStyle(style: LineStyle) {
|
||||||
|
_canvasState.value = _canvasState.value.copy(lineStyle = style)
|
||||||
|
}
|
||||||
|
|
||||||
fun onStrokeErased(strokeId: Long) {
|
fun onStrokeErased(strokeId: Long) {
|
||||||
val stroke = _strokes.value.find { it.id == strokeId } ?: return
|
val stroke = _strokes.value.find { it.id == strokeId } ?: return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -81,6 +150,7 @@ class EditorViewModel(
|
|||||||
|
|
||||||
fun onStrokeCompleted(penSize: Float, color: Int, points: FloatArray) {
|
fun onStrokeCompleted(penSize: Float, color: Int, points: FloatArray) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
val pageId = _currentPageId.value
|
||||||
val order = pageRepository.getNextStrokeOrder(pageId)
|
val order = pageRepository.getNextStrokeOrder(pageId)
|
||||||
val stroke = Stroke(
|
val stroke = Stroke(
|
||||||
pageId = pageId,
|
pageId = pageId,
|
||||||
@@ -138,6 +208,7 @@ class EditorViewModel(
|
|||||||
val toCopy = _strokes.value.filter { it.id in selectedIds }
|
val toCopy = _strokes.value.filter { it.id in selectedIds }
|
||||||
if (toCopy.isEmpty()) return
|
if (toCopy.isEmpty()) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
val pageId = _currentPageId.value
|
||||||
undoManager.perform(
|
undoManager.perform(
|
||||||
CopyStrokesAction(
|
CopyStrokesAction(
|
||||||
strokes = toCopy,
|
strokes = toCopy,
|
||||||
@@ -199,13 +270,17 @@ class EditorViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
class Factory(
|
class Factory(
|
||||||
private val pageId: Long,
|
private val notebookId: Long,
|
||||||
private val pageSize: PageSize,
|
private val pageSize: PageSize,
|
||||||
private val pageRepository: PageRepository,
|
private val pageRepository: PageRepository,
|
||||||
|
private val notebookRepository: NotebookRepository,
|
||||||
|
private val initialPageId: Long,
|
||||||
) : ViewModelProvider.Factory {
|
) : ViewModelProvider.Factory {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
return EditorViewModel(pageId, pageSize, pageRepository) as T
|
return EditorViewModel(
|
||||||
|
notebookId, pageSize, pageRepository, notebookRepository, initialPageId,
|
||||||
|
) as T
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,13 @@ class PadCanvasView(context: Context) : View(context) {
|
|||||||
private var boxEndY = 0f
|
private var boxEndY = 0f
|
||||||
private var isDrawingBox = false
|
private var isDrawingBox = false
|
||||||
|
|
||||||
|
// --- Line drawing ---
|
||||||
|
private var lineStartX = 0f
|
||||||
|
private var lineStartY = 0f
|
||||||
|
private var lineEndX = 0f
|
||||||
|
private var lineEndY = 0f
|
||||||
|
private var isDrawingLine = false
|
||||||
|
|
||||||
// --- Selection ---
|
// --- Selection ---
|
||||||
private var selectionStartX = 0f
|
private var selectionStartX = 0f
|
||||||
private var selectionStartY = 0f
|
private var selectionStartY = 0f
|
||||||
@@ -79,6 +86,9 @@ class PadCanvasView(context: Context) : View(context) {
|
|||||||
var onStrokeErased: ((strokeId: Long) -> Unit)? = null
|
var onStrokeErased: ((strokeId: Long) -> Unit)? = null
|
||||||
var onSelectionComplete: ((selectedIds: Set<Long>) -> Unit)? = null
|
var onSelectionComplete: ((selectedIds: Set<Long>) -> Unit)? = null
|
||||||
var onSelectionMoved: ((selectedIds: Set<Long>, deltaX: Float, deltaY: Float) -> Unit)? = null
|
var onSelectionMoved: ((selectedIds: Set<Long>, deltaX: Float, deltaY: Float) -> Unit)? = null
|
||||||
|
var onEdgeSwipe: ((SwipeDirection) -> Unit)? = null
|
||||||
|
|
||||||
|
enum class SwipeDirection { LEFT, RIGHT }
|
||||||
|
|
||||||
// --- Zoom/pan state ---
|
// --- Zoom/pan state ---
|
||||||
private var zoom = 1f
|
private var zoom = 1f
|
||||||
@@ -88,6 +98,11 @@ class PadCanvasView(context: Context) : View(context) {
|
|||||||
private var lastTouchY = 0f
|
private var lastTouchY = 0f
|
||||||
private var activePointerId = MotionEvent.INVALID_POINTER_ID
|
private var activePointerId = MotionEvent.INVALID_POINTER_ID
|
||||||
|
|
||||||
|
// --- Edge swipe detection ---
|
||||||
|
private var fingerDownX = 0f
|
||||||
|
private var fingerDownY = 0f
|
||||||
|
private var isEdgeSwipe = false
|
||||||
|
|
||||||
private val scaleGestureDetector = ScaleGestureDetector(
|
private val scaleGestureDetector = ScaleGestureDetector(
|
||||||
context,
|
context,
|
||||||
object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
|
object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
|
||||||
@@ -148,6 +163,20 @@ class PadCanvasView(context: Context) : View(context) {
|
|||||||
isAntiAlias = false
|
isAntiAlias = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Line preview paint ---
|
||||||
|
private val linePreviewPaint = Paint().apply {
|
||||||
|
color = Color.BLACK
|
||||||
|
style = Paint.Style.STROKE
|
||||||
|
isAntiAlias = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private val dashedLinePaint = Paint().apply {
|
||||||
|
color = Color.BLACK
|
||||||
|
style = Paint.Style.STROKE
|
||||||
|
isAntiAlias = false
|
||||||
|
pathEffect = android.graphics.DashPathEffect(floatArrayOf(30f, 20f), 0f)
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
setBackgroundColor(Color.WHITE)
|
setBackgroundColor(Color.WHITE)
|
||||||
}
|
}
|
||||||
@@ -224,6 +253,20 @@ class PadCanvasView(context: Context) : View(context) {
|
|||||||
drawRect(left, top, right, bottom, boxPreviewPaint)
|
drawRect(left, top, right, bottom, boxPreviewPaint)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Line preview
|
||||||
|
if (isDrawingLine) {
|
||||||
|
val paint = if (canvasState.lineStyle == LineStyle.DASHED) {
|
||||||
|
dashedLinePaint.also { it.strokeWidth = canvasState.penWidthPt }
|
||||||
|
} else {
|
||||||
|
linePreviewPaint.also { it.strokeWidth = canvasState.penWidthPt }
|
||||||
|
}
|
||||||
|
drawLine(lineStartX, lineStartY, lineEndX, lineEndY, paint)
|
||||||
|
drawArrowHeads(
|
||||||
|
this, lineStartX, lineStartY, lineEndX, lineEndY,
|
||||||
|
canvasState.lineStyle, canvasState.penWidthPt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Selection highlights
|
// Selection highlights
|
||||||
if (selectedStrokeIds.isNotEmpty()) {
|
if (selectedStrokeIds.isNotEmpty()) {
|
||||||
for (sr in completedStrokes) {
|
for (sr in completedStrokes) {
|
||||||
@@ -320,6 +363,9 @@ class PadCanvasView(context: Context) : View(context) {
|
|||||||
if (canvasState.tool == Tool.BOX) {
|
if (canvasState.tool == Tool.BOX) {
|
||||||
return handleBoxInput(event)
|
return handleBoxInput(event)
|
||||||
}
|
}
|
||||||
|
if (canvasState.tool == Tool.LINE) {
|
||||||
|
return handleLineInput(event)
|
||||||
|
}
|
||||||
|
|
||||||
when (event.actionMasked) {
|
when (event.actionMasked) {
|
||||||
MotionEvent.ACTION_DOWN -> {
|
MotionEvent.ACTION_DOWN -> {
|
||||||
@@ -455,6 +501,125 @@ class PadCanvasView(context: Context) : View(context) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Line drawing ---
|
||||||
|
|
||||||
|
private fun handleLineInput(event: MotionEvent): Boolean {
|
||||||
|
when (event.actionMasked) {
|
||||||
|
MotionEvent.ACTION_DOWN -> {
|
||||||
|
val pt = screenToCanonical(event.x, event.y)
|
||||||
|
lineStartX = pt[0]
|
||||||
|
lineStartY = pt[1]
|
||||||
|
lineEndX = pt[0]
|
||||||
|
lineEndY = pt[1]
|
||||||
|
isDrawingLine = true
|
||||||
|
invalidate()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
MotionEvent.ACTION_MOVE -> {
|
||||||
|
val pt = screenToCanonical(event.x, event.y)
|
||||||
|
lineEndX = pt[0]
|
||||||
|
lineEndY = pt[1]
|
||||||
|
invalidate()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
MotionEvent.ACTION_UP -> {
|
||||||
|
isDrawingLine = false
|
||||||
|
// Build the line stroke as points
|
||||||
|
val points = buildLinePoints(
|
||||||
|
lineStartX, lineStartY, lineEndX, lineEndY,
|
||||||
|
canvasState.lineStyle,
|
||||||
|
)
|
||||||
|
onStrokeCompleted?.invoke(canvasState.penWidthPt, Color.BLACK, points)
|
||||||
|
invalidate()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build points for a line stroke. For plain/dashed, just two endpoints.
|
||||||
|
* Arrow heads are encoded as extra path segments.
|
||||||
|
*/
|
||||||
|
private fun buildLinePoints(
|
||||||
|
x1: Float, y1: Float, x2: Float, y2: Float, style: LineStyle,
|
||||||
|
): FloatArray {
|
||||||
|
val points = mutableListOf<Float>()
|
||||||
|
points.add(x1); points.add(y1)
|
||||||
|
points.add(x2); points.add(y2)
|
||||||
|
|
||||||
|
val arrowLen = 40f // Arrow head length in canonical points
|
||||||
|
val arrowAngle = Math.toRadians(25.0)
|
||||||
|
val dx = (x2 - x1).toDouble()
|
||||||
|
val dy = (y2 - y1).toDouble()
|
||||||
|
val angle = Math.atan2(dy, dx)
|
||||||
|
|
||||||
|
if (style == LineStyle.ARROW || style == LineStyle.DOUBLE_ARROW) {
|
||||||
|
// Arrow at end (x2, y2)
|
||||||
|
val ax1 = x2 - (arrowLen * Math.cos(angle - arrowAngle)).toFloat()
|
||||||
|
val ay1 = y2 - (arrowLen * Math.sin(angle - arrowAngle)).toFloat()
|
||||||
|
val ax2 = x2 - (arrowLen * Math.cos(angle + arrowAngle)).toFloat()
|
||||||
|
val ay2 = y2 - (arrowLen * Math.sin(angle + arrowAngle)).toFloat()
|
||||||
|
// Draw as: tip -> left wing, then move back to tip -> right wing
|
||||||
|
points.add(ax1); points.add(ay1)
|
||||||
|
points.add(x2); points.add(y2)
|
||||||
|
points.add(ax2); points.add(ay2)
|
||||||
|
}
|
||||||
|
if (style == LineStyle.DOUBLE_ARROW) {
|
||||||
|
// Arrow at start (x1, y1)
|
||||||
|
val revAngle = angle + Math.PI
|
||||||
|
val bx1 = x1 - (arrowLen * Math.cos(revAngle - arrowAngle)).toFloat()
|
||||||
|
val by1 = y1 - (arrowLen * Math.sin(revAngle - arrowAngle)).toFloat()
|
||||||
|
val bx2 = x1 - (arrowLen * Math.cos(revAngle + arrowAngle)).toFloat()
|
||||||
|
val by2 = y1 - (arrowLen * Math.sin(revAngle + arrowAngle)).toFloat()
|
||||||
|
points.add(x1); points.add(y1)
|
||||||
|
points.add(bx1); points.add(by1)
|
||||||
|
points.add(x1); points.add(y1)
|
||||||
|
points.add(bx2); points.add(by2)
|
||||||
|
}
|
||||||
|
|
||||||
|
return points.toFloatArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drawArrowHeads(
|
||||||
|
canvas: Canvas, x1: Float, y1: Float, x2: Float, y2: Float,
|
||||||
|
style: LineStyle, strokeWidth: Float,
|
||||||
|
) {
|
||||||
|
if (style != LineStyle.ARROW && style != LineStyle.DOUBLE_ARROW) return
|
||||||
|
|
||||||
|
val paint = Paint().apply {
|
||||||
|
color = Color.BLACK
|
||||||
|
this.strokeWidth = strokeWidth
|
||||||
|
this.style = Paint.Style.STROKE
|
||||||
|
strokeCap = Paint.Cap.ROUND
|
||||||
|
isAntiAlias = false
|
||||||
|
}
|
||||||
|
|
||||||
|
val arrowLen = 40f
|
||||||
|
val arrowAngle = Math.toRadians(25.0)
|
||||||
|
val dx = (x2 - x1).toDouble()
|
||||||
|
val dy = (y2 - y1).toDouble()
|
||||||
|
val angle = Math.atan2(dy, dx)
|
||||||
|
|
||||||
|
// End arrow
|
||||||
|
val ax1 = x2 - (arrowLen * Math.cos(angle - arrowAngle)).toFloat()
|
||||||
|
val ay1 = y2 - (arrowLen * Math.sin(angle - arrowAngle)).toFloat()
|
||||||
|
val ax2 = x2 - (arrowLen * Math.cos(angle + arrowAngle)).toFloat()
|
||||||
|
val ay2 = y2 - (arrowLen * Math.sin(angle + arrowAngle)).toFloat()
|
||||||
|
canvas.drawLine(x2, y2, ax1, ay1, paint)
|
||||||
|
canvas.drawLine(x2, y2, ax2, ay2, paint)
|
||||||
|
|
||||||
|
if (style == LineStyle.DOUBLE_ARROW) {
|
||||||
|
val revAngle = angle + Math.PI
|
||||||
|
val bx1 = x1 - (arrowLen * Math.cos(revAngle - arrowAngle)).toFloat()
|
||||||
|
val by1 = y1 - (arrowLen * Math.sin(revAngle - arrowAngle)).toFloat()
|
||||||
|
val bx2 = x1 - (arrowLen * Math.cos(revAngle + arrowAngle)).toFloat()
|
||||||
|
val by2 = y1 - (arrowLen * Math.sin(revAngle + arrowAngle)).toFloat()
|
||||||
|
canvas.drawLine(x1, y1, bx1, by1, paint)
|
||||||
|
canvas.drawLine(x1, y1, bx2, by2, paint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun handleFingerInput(event: MotionEvent): Boolean {
|
private fun handleFingerInput(event: MotionEvent): Boolean {
|
||||||
scaleGestureDetector.onTouchEvent(event)
|
scaleGestureDetector.onTouchEvent(event)
|
||||||
|
|
||||||
@@ -463,27 +628,55 @@ class PadCanvasView(context: Context) : View(context) {
|
|||||||
activePointerId = event.getPointerId(0)
|
activePointerId = event.getPointerId(0)
|
||||||
lastTouchX = event.x
|
lastTouchX = event.x
|
||||||
lastTouchY = event.y
|
lastTouchY = event.y
|
||||||
|
fingerDownX = event.x
|
||||||
|
fingerDownY = event.y
|
||||||
|
// Detect if finger started at screen edge
|
||||||
|
val edgeZone = width * EDGE_ZONE_FRACTION
|
||||||
|
isEdgeSwipe = event.x < edgeZone || event.x > width - edgeZone
|
||||||
}
|
}
|
||||||
MotionEvent.ACTION_MOVE -> {
|
MotionEvent.ACTION_MOVE -> {
|
||||||
if (!scaleGestureDetector.isInProgress) {
|
if (!scaleGestureDetector.isInProgress && event.pointerCount == 1) {
|
||||||
val pointerIndex = event.findPointerIndex(activePointerId)
|
val pointerIndex = event.findPointerIndex(activePointerId)
|
||||||
if (pointerIndex >= 0) {
|
if (pointerIndex >= 0) {
|
||||||
val x = event.getX(pointerIndex)
|
val x = event.getX(pointerIndex)
|
||||||
val y = event.getY(pointerIndex)
|
val y = event.getY(pointerIndex)
|
||||||
|
if (!isEdgeSwipe) {
|
||||||
panX += x - lastTouchX
|
panX += x - lastTouchX
|
||||||
panY += y - lastTouchY
|
panY += y - lastTouchY
|
||||||
lastTouchX = x
|
|
||||||
lastTouchY = y
|
|
||||||
rebuildViewMatrix()
|
rebuildViewMatrix()
|
||||||
invalidate()
|
invalidate()
|
||||||
}
|
}
|
||||||
|
lastTouchX = x
|
||||||
|
lastTouchY = y
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
}
|
||||||
|
MotionEvent.ACTION_UP -> {
|
||||||
|
if (isEdgeSwipe && event.pointerCount == 1) {
|
||||||
|
val dx = event.x - fingerDownX
|
||||||
|
val dy = event.y - fingerDownY
|
||||||
|
val absDx = Math.abs(dx)
|
||||||
|
val absDy = Math.abs(dy)
|
||||||
|
if (absDx > EDGE_SWIPE_MIN_PX && absDx > absDy * 2) {
|
||||||
|
// Horizontal swipe detected
|
||||||
|
if (dx < 0) {
|
||||||
|
onEdgeSwipe?.invoke(SwipeDirection.LEFT)
|
||||||
|
} else {
|
||||||
|
onEdgeSwipe?.invoke(SwipeDirection.RIGHT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isEdgeSwipe = false
|
||||||
|
activePointerId = MotionEvent.INVALID_POINTER_ID
|
||||||
|
onZoomPanChanged?.invoke(zoom, panX, panY)
|
||||||
|
}
|
||||||
|
MotionEvent.ACTION_CANCEL -> {
|
||||||
|
isEdgeSwipe = false
|
||||||
activePointerId = MotionEvent.INVALID_POINTER_ID
|
activePointerId = MotionEvent.INVALID_POINTER_ID
|
||||||
onZoomPanChanged?.invoke(zoom, panX, panY)
|
onZoomPanChanged?.invoke(zoom, panX, panY)
|
||||||
}
|
}
|
||||||
MotionEvent.ACTION_POINTER_DOWN -> {
|
MotionEvent.ACTION_POINTER_DOWN -> {
|
||||||
|
isEdgeSwipe = false // Multi-finger = not an edge swipe
|
||||||
val newIndex = event.actionIndex
|
val newIndex = event.actionIndex
|
||||||
lastTouchX = event.getX(newIndex)
|
lastTouchX = event.getX(newIndex)
|
||||||
lastTouchY = event.getY(newIndex)
|
lastTouchY = event.getY(newIndex)
|
||||||
@@ -732,5 +925,11 @@ class PadCanvasView(context: Context) : View(context) {
|
|||||||
|
|
||||||
/** Max distance from origin (canonical pts) before snap is canceled (~5mm). */
|
/** Max distance from origin (canonical pts) before snap is canceled (~5mm). */
|
||||||
private const val LINE_SNAP_MOVE_THRESHOLD = 60f
|
private const val LINE_SNAP_MOVE_THRESHOLD = 60f
|
||||||
|
|
||||||
|
/** Fraction of screen width that counts as the edge zone for swipes. */
|
||||||
|
private const val EDGE_ZONE_FRACTION = 0.08f
|
||||||
|
|
||||||
|
/** Minimum horizontal pixels for an edge swipe to register. */
|
||||||
|
private const val EDGE_SWIPE_MIN_PX = 100f
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,24 @@ import androidx.compose.foundation.layout.Row
|
|||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
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.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -23,13 +35,54 @@ fun EditorToolbar(
|
|||||||
hasSelection: Boolean,
|
hasSelection: Boolean,
|
||||||
onDeleteSelection: () -> Unit,
|
onDeleteSelection: () -> Unit,
|
||||||
onCopySelection: () -> Unit,
|
onCopySelection: () -> Unit,
|
||||||
onExport: () -> Unit,
|
onExportPdf: () -> Unit,
|
||||||
|
onExportJpg: () -> Unit,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
currentPageNumber: Int,
|
||||||
|
totalPages: Int,
|
||||||
|
onViewAllPages: () -> Unit,
|
||||||
|
onGoToPage: (Int) -> Unit,
|
||||||
|
lineStyle: LineStyle,
|
||||||
|
onLineStyleChanged: (LineStyle) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
var showBinderMenu by remember { mutableStateOf(false) }
|
||||||
|
var showGoToPageDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
modifier = modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
|
// Close button
|
||||||
|
TextButton(onClick = onClose) { Text("X") }
|
||||||
|
|
||||||
|
// Binder dropdown
|
||||||
|
TextButton(onClick = { showBinderMenu = true }) {
|
||||||
|
Text("p$currentPageNumber/$totalPages")
|
||||||
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showBinderMenu,
|
||||||
|
onDismissRequest = { showBinderMenu = false },
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("View all pages") },
|
||||||
|
onClick = {
|
||||||
|
showBinderMenu = false
|
||||||
|
onViewAllPages()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Go to page...") },
|
||||||
|
onClick = {
|
||||||
|
showBinderMenu = false
|
||||||
|
showGoToPageDialog = true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
|
||||||
|
// Tool chips
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = currentTool == Tool.PEN_FINE,
|
selected = currentTool == Tool.PEN_FINE,
|
||||||
onClick = { onToolSelected(Tool.PEN_FINE) },
|
onClick = { onToolSelected(Tool.PEN_FINE) },
|
||||||
@@ -60,12 +113,38 @@ fun EditorToolbar(
|
|||||||
label = { Text("Box") },
|
label = { Text("Box") },
|
||||||
modifier = Modifier.padding(end = 4.dp),
|
modifier = Modifier.padding(end = 4.dp),
|
||||||
)
|
)
|
||||||
|
LineToolChip(
|
||||||
|
selected = currentTool == Tool.LINE,
|
||||||
|
lineStyle = lineStyle,
|
||||||
|
onSelect = { onToolSelected(Tool.LINE) },
|
||||||
|
onStyleChanged = onLineStyleChanged,
|
||||||
|
)
|
||||||
if (hasSelection) {
|
if (hasSelection) {
|
||||||
TextButton(onClick = onDeleteSelection) { Text("Del") }
|
TextButton(onClick = onDeleteSelection) { Text("Del") }
|
||||||
TextButton(onClick = onCopySelection) { Text("Copy") }
|
TextButton(onClick = onCopySelection) { Text("Copy") }
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
TextButton(onClick = onExport) { Text("PDF") }
|
var showExportMenu by remember { mutableStateOf(false) }
|
||||||
|
TextButton(onClick = { showExportMenu = true }) { Text("Export") }
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showExportMenu,
|
||||||
|
onDismissRequest = { showExportMenu = false },
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Export as PDF") },
|
||||||
|
onClick = {
|
||||||
|
showExportMenu = false
|
||||||
|
onExportPdf()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Export as JPG") },
|
||||||
|
onClick = {
|
||||||
|
showExportMenu = false
|
||||||
|
onExportJpg()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
TextButton(onClick = onUndo, enabled = canUndo) {
|
TextButton(onClick = onUndo, enabled = canUndo) {
|
||||||
Text("Undo")
|
Text("Undo")
|
||||||
}
|
}
|
||||||
@@ -74,4 +153,107 @@ fun EditorToolbar(
|
|||||||
Text("Redo")
|
Text("Redo")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showGoToPageDialog) {
|
||||||
|
GoToPageDialog(
|
||||||
|
totalPages = totalPages,
|
||||||
|
onDismiss = { showGoToPageDialog = false },
|
||||||
|
onGoTo = { pageNum ->
|
||||||
|
showGoToPageDialog = false
|
||||||
|
onGoToPage(pageNum)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GoToPageDialog(
|
||||||
|
totalPages: Int,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onGoTo: (Int) -> Unit,
|
||||||
|
) {
|
||||||
|
var text by remember { mutableStateOf("") }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Go to page") },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = text,
|
||||||
|
onValueChange = {
|
||||||
|
text = it
|
||||||
|
error = null
|
||||||
|
},
|
||||||
|
label = { Text("Page (1\u2013$totalPages)") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
singleLine = true,
|
||||||
|
isError = error != null,
|
||||||
|
supportingText = error?.let { { Text(it) } },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
val num = text.toIntOrNull()
|
||||||
|
if (num == null || num < 1 || num > totalPages) {
|
||||||
|
error = "Enter a number between 1 and $totalPages"
|
||||||
|
} else {
|
||||||
|
onGoTo(num)
|
||||||
|
}
|
||||||
|
}) { Text("Go") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun LineToolChip(
|
||||||
|
selected: Boolean,
|
||||||
|
lineStyle: LineStyle,
|
||||||
|
onSelect: () -> Unit,
|
||||||
|
onStyleChanged: (LineStyle) -> Unit,
|
||||||
|
) {
|
||||||
|
var showStyleMenu by remember { mutableStateOf(false) }
|
||||||
|
val label = when (lineStyle) {
|
||||||
|
LineStyle.PLAIN -> "Line"
|
||||||
|
LineStyle.ARROW -> "Arrow"
|
||||||
|
LineStyle.DOUBLE_ARROW -> "\u2194" // ↔
|
||||||
|
LineStyle.DASHED -> "Dash"
|
||||||
|
}
|
||||||
|
|
||||||
|
FilterChip(
|
||||||
|
selected = selected,
|
||||||
|
onClick = { onSelect() },
|
||||||
|
label = { Text(label) },
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(end = 4.dp)
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = { onSelect() },
|
||||||
|
onLongClick = { showStyleMenu = true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showStyleMenu,
|
||||||
|
onDismissRequest = { showStyleMenu = false },
|
||||||
|
) {
|
||||||
|
LineStyle.entries.forEach { style ->
|
||||||
|
val styleName = when (style) {
|
||||||
|
LineStyle.PLAIN -> "Plain line"
|
||||||
|
LineStyle.ARROW -> "Single arrow \u2192"
|
||||||
|
LineStyle.DOUBLE_ARROW -> "Double arrow \u2194"
|
||||||
|
LineStyle.DASHED -> "Dashed line"
|
||||||
|
}
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(styleName) },
|
||||||
|
onClick = {
|
||||||
|
showStyleMenu = false
|
||||||
|
onStyleChanged(style)
|
||||||
|
onSelect()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package net.metacircular.engpad.ui.export
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.graphics.Canvas
|
import android.graphics.Canvas
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
|
import androidx.core.graphics.createBitmap
|
||||||
import android.graphics.Paint
|
import android.graphics.Paint
|
||||||
import android.graphics.Path
|
import android.graphics.Path
|
||||||
import android.graphics.pdf.PdfDocument
|
import android.graphics.pdf.PdfDocument
|
||||||
@@ -14,10 +16,6 @@ import net.metacircular.engpad.data.model.PageSize
|
|||||||
import net.metacircular.engpad.data.model.Stroke
|
import net.metacircular.engpad.data.model.Stroke
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
/**
|
|
||||||
* Exports pages to PDF. Coordinates are in 300 DPI canonical space and
|
|
||||||
* scaled to 72 DPI (PDF points) during export via a scale factor of 0.24.
|
|
||||||
*/
|
|
||||||
object PdfExporter {
|
object PdfExporter {
|
||||||
|
|
||||||
private const val CANONICAL_TO_PDF = 72f / 300f // 0.24
|
private const val CANONICAL_TO_PDF = 72f / 300f // 0.24
|
||||||
@@ -39,23 +37,15 @@ object PdfExporter {
|
|||||||
val pdfPage = pdfDoc.startPage(pageInfo)
|
val pdfPage = pdfDoc.startPage(pageInfo)
|
||||||
val canvas = pdfPage.canvas
|
val canvas = pdfPage.canvas
|
||||||
|
|
||||||
// Scale from canonical (300 DPI) to PDF (72 DPI)
|
|
||||||
canvas.scale(CANONICAL_TO_PDF, CANONICAL_TO_PDF)
|
canvas.scale(CANONICAL_TO_PDF, CANONICAL_TO_PDF)
|
||||||
|
drawStrokes(canvas, strokes)
|
||||||
// Draw strokes (no grid)
|
|
||||||
for (stroke in strokes) {
|
|
||||||
val points = stroke.pointData.toFloatArray()
|
|
||||||
val path = buildPath(points)
|
|
||||||
val paint = buildPaint(stroke.penSize, stroke.color)
|
|
||||||
canvas.drawPath(path, paint)
|
|
||||||
}
|
|
||||||
|
|
||||||
pdfDoc.finishPage(pdfPage)
|
pdfDoc.finishPage(pdfPage)
|
||||||
}
|
}
|
||||||
|
|
||||||
val exportDir = File(context.cacheDir, "exports")
|
val exportDir = File(context.cacheDir, "exports")
|
||||||
exportDir.mkdirs()
|
exportDir.mkdirs()
|
||||||
val sanitizedTitle = notebookTitle.replace(Regex("[^a-zA-Z0-9._-]"), "_")
|
val sanitizedTitle = sanitize(notebookTitle)
|
||||||
val file = File(exportDir, "$sanitizedTitle.pdf")
|
val file = File(exportDir, "$sanitizedTitle.pdf")
|
||||||
file.outputStream().use { pdfDoc.writeTo(it) }
|
file.outputStream().use { pdfDoc.writeTo(it) }
|
||||||
pdfDoc.close()
|
pdfDoc.close()
|
||||||
@@ -63,18 +53,52 @@ object PdfExporter {
|
|||||||
return file
|
return file
|
||||||
}
|
}
|
||||||
|
|
||||||
fun shareFile(context: Context, file: File) {
|
fun exportPageAsJpg(
|
||||||
|
context: Context,
|
||||||
|
notebookTitle: String,
|
||||||
|
pageSize: PageSize,
|
||||||
|
pageNumber: Int,
|
||||||
|
strokes: List<Stroke>,
|
||||||
|
): File {
|
||||||
|
// Render at 300 DPI (full canonical resolution)
|
||||||
|
val bitmap = createBitmap(pageSize.widthPt, pageSize.heightPt)
|
||||||
|
val canvas = Canvas(bitmap)
|
||||||
|
canvas.drawColor(Color.WHITE)
|
||||||
|
drawStrokes(canvas, strokes)
|
||||||
|
|
||||||
|
val exportDir = File(context.cacheDir, "exports")
|
||||||
|
exportDir.mkdirs()
|
||||||
|
val sanitizedTitle = sanitize(notebookTitle)
|
||||||
|
val file = File(exportDir, "${sanitizedTitle}_p${pageNumber}.jpg")
|
||||||
|
file.outputStream().use {
|
||||||
|
bitmap.compress(Bitmap.CompressFormat.JPEG, 95, it)
|
||||||
|
}
|
||||||
|
bitmap.recycle()
|
||||||
|
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shareFile(context: Context, file: File, mimeType: String = "application/pdf") {
|
||||||
val uri = FileProvider.getUriForFile(
|
val uri = FileProvider.getUriForFile(
|
||||||
context,
|
context,
|
||||||
"${context.packageName}.fileprovider",
|
"${context.packageName}.fileprovider",
|
||||||
file,
|
file,
|
||||||
)
|
)
|
||||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||||
type = "application/pdf"
|
type = mimeType
|
||||||
putExtra(Intent.EXTRA_STREAM, uri)
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
}
|
}
|
||||||
context.startActivity(Intent.createChooser(intent, "Export PDF"))
|
context.startActivity(Intent.createChooser(intent, "Export"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drawStrokes(canvas: Canvas, strokes: List<Stroke>) {
|
||||||
|
for (stroke in strokes) {
|
||||||
|
val points = stroke.pointData.toFloatArray()
|
||||||
|
val path = buildPath(points)
|
||||||
|
val paint = buildPaint(stroke.penSize, stroke.color)
|
||||||
|
canvas.drawPath(path, paint)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildPath(points: FloatArray): Path {
|
private fun buildPath(points: FloatArray): Path {
|
||||||
@@ -99,4 +123,7 @@ object PdfExporter {
|
|||||||
isAntiAlias = true
|
isAntiAlias = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun sanitize(name: String): String =
|
||||||
|
name.replace(Regex("[^a-zA-Z0-9._-]"), "_")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package net.metacircular.engpad.ui.navigation
|
|||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -14,17 +15,18 @@ import androidx.navigation.navArgument
|
|||||||
import net.metacircular.engpad.data.db.EngPadDatabase
|
import net.metacircular.engpad.data.db.EngPadDatabase
|
||||||
import net.metacircular.engpad.data.model.PageSize
|
import net.metacircular.engpad.data.model.PageSize
|
||||||
import net.metacircular.engpad.data.repository.NotebookRepository
|
import net.metacircular.engpad.data.repository.NotebookRepository
|
||||||
|
import net.metacircular.engpad.data.repository.PageRepository
|
||||||
import net.metacircular.engpad.ui.editor.EditorScreen
|
import net.metacircular.engpad.ui.editor.EditorScreen
|
||||||
import net.metacircular.engpad.ui.notebooks.NotebookListScreen
|
import net.metacircular.engpad.ui.notebooks.NotebookListScreen
|
||||||
import net.metacircular.engpad.ui.pages.PageListScreen
|
import net.metacircular.engpad.ui.pages.PageListScreen
|
||||||
|
|
||||||
object Routes {
|
object Routes {
|
||||||
const val NOTEBOOKS = "notebooks"
|
const val NOTEBOOKS = "notebooks"
|
||||||
|
const val EDITOR = "editor/{notebookId}"
|
||||||
const val PAGES = "pages/{notebookId}"
|
const val PAGES = "pages/{notebookId}"
|
||||||
const val EDITOR = "editor/{pageId}/{pageSize}"
|
|
||||||
|
|
||||||
|
fun editor(notebookId: Long) = "editor/$notebookId"
|
||||||
fun pages(notebookId: Long) = "pages/$notebookId"
|
fun pages(notebookId: Long) = "pages/$notebookId"
|
||||||
fun editor(pageId: Long, pageSize: PageSize) = "editor/$pageId/${pageSize.name}"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -37,10 +39,65 @@ fun EngPadNavGraph(
|
|||||||
NotebookListScreen(
|
NotebookListScreen(
|
||||||
database = database,
|
database = database,
|
||||||
onNotebookClick = { notebookId ->
|
onNotebookClick = { notebookId ->
|
||||||
|
navController.navigate(Routes.editor(notebookId))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
route = Routes.EDITOR,
|
||||||
|
arguments = listOf(navArgument("notebookId") { type = NavType.LongType }),
|
||||||
|
) { backStackEntry ->
|
||||||
|
val notebookId = backStackEntry.arguments?.getLong("notebookId") ?: return@composable
|
||||||
|
val notebookRepo = remember {
|
||||||
|
NotebookRepository(database.notebookDao(), database.pageDao())
|
||||||
|
}
|
||||||
|
val pageRepo = remember {
|
||||||
|
PageRepository(database.pageDao(), database.strokeDao())
|
||||||
|
}
|
||||||
|
|
||||||
|
var pageSize by remember { mutableStateOf<PageSize?>(null) }
|
||||||
|
var initialPageId by remember { mutableStateOf<Long?>(null) }
|
||||||
|
|
||||||
|
LaunchedEffect(notebookId) {
|
||||||
|
val notebook = notebookRepo.getById(notebookId) ?: return@LaunchedEffect
|
||||||
|
pageSize = PageSize.fromString(notebook.pageSize)
|
||||||
|
// Use last page or fall back to first page
|
||||||
|
val pages = pageRepo.getPagesList(notebookId)
|
||||||
|
initialPageId = if (notebook.lastPageId > 0 && pages.any { it.id == notebook.lastPageId }) {
|
||||||
|
notebook.lastPageId
|
||||||
|
} else {
|
||||||
|
pages.firstOrNull()?.id ?: return@LaunchedEffect
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe page selection from the page list screen
|
||||||
|
val savedState = backStackEntry.savedStateHandle
|
||||||
|
val selectedPageId by savedState.getStateFlow("selectedPageId", 0L)
|
||||||
|
.collectAsState()
|
||||||
|
LaunchedEffect(selectedPageId) {
|
||||||
|
if (selectedPageId > 0) {
|
||||||
|
initialPageId = selectedPageId
|
||||||
|
savedState["selectedPageId"] = 0L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val ps = pageSize
|
||||||
|
val pid = initialPageId
|
||||||
|
if (ps != null && pid != null) {
|
||||||
|
EditorScreen(
|
||||||
|
notebookId = notebookId,
|
||||||
|
pageSize = ps,
|
||||||
|
initialPageId = pid,
|
||||||
|
database = database,
|
||||||
|
onClose = {
|
||||||
|
navController.popBackStack(Routes.NOTEBOOKS, false)
|
||||||
|
},
|
||||||
|
onViewAllPages = {
|
||||||
navController.navigate(Routes.pages(notebookId))
|
navController.navigate(Routes.pages(notebookId))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
composable(
|
composable(
|
||||||
route = Routes.PAGES,
|
route = Routes.PAGES,
|
||||||
arguments = listOf(navArgument("notebookId") { type = NavType.LongType }),
|
arguments = listOf(navArgument("notebookId") { type = NavType.LongType }),
|
||||||
@@ -68,27 +125,15 @@ fun EngPadNavGraph(
|
|||||||
notebookTitle = notebookTitle,
|
notebookTitle = notebookTitle,
|
||||||
pageSize = pageSize,
|
pageSize = pageSize,
|
||||||
database = database,
|
database = database,
|
||||||
onPageClick = { pageId, ps ->
|
onPageClick = { pageId, _ ->
|
||||||
navController.navigate(Routes.editor(pageId, ps))
|
// Pass selected page ID back to the editor
|
||||||
|
navController.previousBackStackEntry
|
||||||
|
?.savedStateHandle
|
||||||
|
?.set("selectedPageId", pageId)
|
||||||
|
navController.popBackStack()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
composable(
|
|
||||||
route = Routes.EDITOR,
|
|
||||||
arguments = listOf(
|
|
||||||
navArgument("pageId") { type = NavType.LongType },
|
|
||||||
navArgument("pageSize") { type = NavType.StringType },
|
|
||||||
),
|
|
||||||
) { backStackEntry ->
|
|
||||||
val pageId = backStackEntry.arguments?.getLong("pageId") ?: return@composable
|
|
||||||
val pageSizeStr = backStackEntry.arguments?.getString("pageSize") ?: return@composable
|
|
||||||
val pageSize = PageSize.fromString(pageSizeStr)
|
|
||||||
EditorScreen(
|
|
||||||
pageId = pageId,
|
|
||||||
pageSize = pageSize,
|
|
||||||
database = database,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package net.metacircular.engpad.ui.pages
|
package net.metacircular.engpad.ui.pages
|
||||||
|
|
||||||
|
import android.graphics.Color as AndroidColor
|
||||||
|
import android.graphics.Paint as AndroidPaint
|
||||||
|
import android.graphics.Path as AndroidPath
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -19,16 +22,24 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||||
|
import androidx.compose.ui.graphics.nativeCanvas
|
||||||
|
import androidx.core.graphics.withScale
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import net.metacircular.engpad.data.db.EngPadDatabase
|
import net.metacircular.engpad.data.db.EngPadDatabase
|
||||||
|
import net.metacircular.engpad.data.db.toFloatArray
|
||||||
import net.metacircular.engpad.data.model.Page
|
import net.metacircular.engpad.data.model.Page
|
||||||
import net.metacircular.engpad.data.model.PageSize
|
import net.metacircular.engpad.data.model.PageSize
|
||||||
|
import net.metacircular.engpad.data.model.Stroke
|
||||||
import net.metacircular.engpad.data.repository.PageRepository
|
import net.metacircular.engpad.data.repository.PageRepository
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -82,7 +93,9 @@ fun PageListScreen(
|
|||||||
items(pages, key = { it.id }) { page ->
|
items(pages, key = { it.id }) { page ->
|
||||||
PageThumbnail(
|
PageThumbnail(
|
||||||
page = page,
|
page = page,
|
||||||
|
pageSize = pageSize,
|
||||||
aspectRatio = aspectRatio,
|
aspectRatio = aspectRatio,
|
||||||
|
repository = repository,
|
||||||
onClick = { onPageClick(page.id, pageSize) },
|
onClick = { onPageClick(page.id, pageSize) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -94,24 +107,64 @@ fun PageListScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun PageThumbnail(
|
private fun PageThumbnail(
|
||||||
page: Page,
|
page: Page,
|
||||||
|
pageSize: PageSize,
|
||||||
aspectRatio: Float,
|
aspectRatio: Float,
|
||||||
|
repository: PageRepository,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Card(
|
var strokes by remember(page.id) { mutableStateOf<List<Stroke>>(emptyList()) }
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
LaunchedEffect(page.id) {
|
||||||
.clickable(onClick = onClick),
|
strokes = repository.getStrokes(page.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.clickable(onClick = onClick),
|
||||||
) {
|
) {
|
||||||
Box(
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Canvas(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.aspectRatio(aspectRatio),
|
.aspectRatio(aspectRatio),
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
) {
|
||||||
|
val scaleX = size.width / pageSize.widthPt.toFloat()
|
||||||
|
val scaleY = size.height / pageSize.heightPt.toFloat()
|
||||||
|
val scale = minOf(scaleX, scaleY)
|
||||||
|
|
||||||
|
drawIntoCanvas { canvas ->
|
||||||
|
val nativeCanvas = canvas.nativeCanvas
|
||||||
|
nativeCanvas.withScale(scale, scale) {
|
||||||
|
for (stroke in strokes) {
|
||||||
|
val points = stroke.pointData.toFloatArray()
|
||||||
|
if (points.size < 2) continue
|
||||||
|
val path = AndroidPath()
|
||||||
|
path.moveTo(points[0], points[1])
|
||||||
|
var i = 2
|
||||||
|
while (i < points.size - 1) {
|
||||||
|
path.lineTo(points[i], points[i + 1])
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
val paint = AndroidPaint().apply {
|
||||||
|
color = stroke.color
|
||||||
|
strokeWidth = stroke.penSize
|
||||||
|
style = AndroidPaint.Style.STROKE
|
||||||
|
strokeCap = AndroidPaint.Cap.ROUND
|
||||||
|
strokeJoin = AndroidPaint.Join.ROUND
|
||||||
|
isAntiAlias = false
|
||||||
|
}
|
||||||
|
drawPath(path, paint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
text = "Page ${page.pageNumber}",
|
text = "Page ${page.pageNumber}",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user