Implement Phase 1: project skeleton and data layer
Android project with Kotlin, Jetpack Compose, and Room. Includes: - Gradle build system with version catalog, foojay JDK resolver, lint config - Room entities (Notebook, Page, Stroke) with packed float BLOB encoding - DAOs and repositories for all entities - Unit tests for blob roundtrip and PageSize enum (10 tests, all passing) - Minimal Application class and stub MainActivity Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
8
app/src/main/kotlin/net/metacircular/engpad/EngPadApp.kt
Normal file
8
app/src/main/kotlin/net/metacircular/engpad/EngPadApp.kt
Normal file
@@ -0,0 +1,8 @@
|
||||
package net.metacircular.engpad
|
||||
|
||||
import android.app.Application
|
||||
import net.metacircular.engpad.data.db.EngPadDatabase
|
||||
|
||||
class EngPadApp : Application() {
|
||||
val database: EngPadDatabase by lazy { EngPadDatabase.getInstance(this) }
|
||||
}
|
||||
15
app/src/main/kotlin/net/metacircular/engpad/MainActivity.kt
Normal file
15
app/src/main/kotlin/net/metacircular/engpad/MainActivity.kt
Normal file
@@ -0,0 +1,15 @@
|
||||
package net.metacircular.engpad
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.material3.Text
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
Text("eng-pad")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package net.metacircular.engpad.data.db
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
/**
|
||||
* Encode a FloatArray as a packed little-endian byte array.
|
||||
* Points are stored as [x0, y0, x1, y1, ...].
|
||||
*/
|
||||
fun FloatArray.toBlob(): ByteArray {
|
||||
val buffer = ByteBuffer.allocate(size * 4).order(ByteOrder.LITTLE_ENDIAN)
|
||||
for (f in this) buffer.putFloat(f)
|
||||
return buffer.array()
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a packed little-endian byte array back to a FloatArray.
|
||||
*/
|
||||
fun ByteArray.toFloatArray(): FloatArray {
|
||||
val buffer = ByteBuffer.wrap(this).order(ByteOrder.LITTLE_ENDIAN)
|
||||
return FloatArray(size / 4) { buffer.getFloat() }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package net.metacircular.engpad.data.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import net.metacircular.engpad.data.model.Notebook
|
||||
import net.metacircular.engpad.data.model.Page
|
||||
import net.metacircular.engpad.data.model.Stroke
|
||||
|
||||
@Database(
|
||||
entities = [Notebook::class, Page::class, Stroke::class],
|
||||
version = 1,
|
||||
exportSchema = false,
|
||||
)
|
||||
abstract class EngPadDatabase : RoomDatabase() {
|
||||
abstract fun notebookDao(): NotebookDao
|
||||
abstract fun pageDao(): PageDao
|
||||
abstract fun strokeDao(): StrokeDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var instance: EngPadDatabase? = null
|
||||
|
||||
fun getInstance(context: Context): EngPadDatabase =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
EngPadDatabase::class.java,
|
||||
"engpad.db",
|
||||
).build().also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package net.metacircular.engpad.data.db
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import androidx.room.Update
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import net.metacircular.engpad.data.model.Notebook
|
||||
|
||||
@Dao
|
||||
interface NotebookDao {
|
||||
@Insert
|
||||
suspend fun insert(notebook: Notebook): Long
|
||||
|
||||
@Update
|
||||
suspend fun update(notebook: Notebook)
|
||||
|
||||
@Query("SELECT * FROM notebooks ORDER BY updated_at DESC")
|
||||
fun getAll(): Flow<List<Notebook>>
|
||||
|
||||
@Query("SELECT * FROM notebooks WHERE id = :id")
|
||||
suspend fun getById(id: Long): Notebook?
|
||||
|
||||
@Query("DELETE FROM notebooks WHERE id = :id")
|
||||
suspend fun deleteById(id: Long)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package net.metacircular.engpad.data.db
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import net.metacircular.engpad.data.model.Page
|
||||
|
||||
@Dao
|
||||
interface PageDao {
|
||||
@Insert
|
||||
suspend fun insert(page: Page): Long
|
||||
|
||||
@Query("SELECT * FROM pages WHERE notebook_id = :notebookId ORDER BY page_number ASC")
|
||||
fun getByNotebookId(notebookId: Long): Flow<List<Page>>
|
||||
|
||||
@Query("SELECT * FROM pages WHERE id = :id")
|
||||
suspend fun getById(id: Long): Page?
|
||||
|
||||
@Query("SELECT MAX(page_number) FROM pages WHERE notebook_id = :notebookId")
|
||||
suspend fun getMaxPageNumber(notebookId: Long): Int?
|
||||
|
||||
@Query("DELETE FROM pages WHERE id = :id")
|
||||
suspend fun deleteById(id: Long)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package net.metacircular.engpad.data.db
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import net.metacircular.engpad.data.model.Stroke
|
||||
|
||||
@Dao
|
||||
interface StrokeDao {
|
||||
@Insert
|
||||
suspend fun insert(stroke: Stroke): Long
|
||||
|
||||
@Insert
|
||||
suspend fun insertAll(strokes: List<Stroke>)
|
||||
|
||||
@Query("SELECT * FROM strokes WHERE page_id = :pageId ORDER BY stroke_order ASC")
|
||||
suspend fun getByPageId(pageId: Long): List<Stroke>
|
||||
|
||||
@Query("DELETE FROM strokes WHERE id = :id")
|
||||
suspend fun deleteById(id: Long)
|
||||
|
||||
@Query("DELETE FROM strokes WHERE id IN (:ids)")
|
||||
suspend fun deleteByIds(ids: List<Long>)
|
||||
|
||||
@Query("SELECT MAX(stroke_order) FROM strokes WHERE page_id = :pageId")
|
||||
suspend fun getMaxStrokeOrder(pageId: Long): Int?
|
||||
|
||||
@Query("UPDATE strokes SET point_data = :pointData WHERE id = :id")
|
||||
suspend fun updatePointData(id: Long, pointData: ByteArray)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package net.metacircular.engpad.data.model
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "notebooks")
|
||||
data class Notebook(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val title: String,
|
||||
@ColumnInfo(name = "page_size") val pageSize: String,
|
||||
@ColumnInfo(name = "created_at") val createdAt: Long,
|
||||
@ColumnInfo(name = "updated_at") val updatedAt: Long,
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
package net.metacircular.engpad.data.model
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(
|
||||
tableName = "pages",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = Notebook::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["notebook_id"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
)
|
||||
],
|
||||
indices = [
|
||||
Index(value = ["notebook_id"]),
|
||||
Index(value = ["notebook_id", "page_number"], unique = true),
|
||||
],
|
||||
)
|
||||
data class Page(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
@ColumnInfo(name = "notebook_id") val notebookId: Long,
|
||||
@ColumnInfo(name = "page_number") val pageNumber: Int,
|
||||
@ColumnInfo(name = "created_at") val createdAt: Long,
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
package net.metacircular.engpad.data.model
|
||||
|
||||
/**
|
||||
* Page sizes in canonical coordinates (300 DPI).
|
||||
*/
|
||||
enum class PageSize(val widthPt: Int, val heightPt: Int) {
|
||||
/** 8.5 × 11 inches */
|
||||
REGULAR(2550, 3300),
|
||||
|
||||
/** 11 × 17 inches */
|
||||
LARGE(3300, 5100);
|
||||
|
||||
companion object {
|
||||
fun fromString(value: String): PageSize =
|
||||
entries.first { it.name.equals(value, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package net.metacircular.engpad.data.model
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(
|
||||
tableName = "strokes",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = Page::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["page_id"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
)
|
||||
],
|
||||
indices = [Index(value = ["page_id"])],
|
||||
)
|
||||
data class Stroke(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
@ColumnInfo(name = "page_id") val pageId: Long,
|
||||
@ColumnInfo(name = "pen_size") val penSize: Float,
|
||||
val color: Int,
|
||||
@ColumnInfo(name = "point_data") val pointData: ByteArray,
|
||||
@ColumnInfo(name = "stroke_order") val strokeOrder: Int,
|
||||
@ColumnInfo(name = "created_at") val createdAt: Long,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is Stroke) return false
|
||||
return id == other.id &&
|
||||
pageId == other.pageId &&
|
||||
penSize == other.penSize &&
|
||||
color == other.color &&
|
||||
pointData.contentEquals(other.pointData) &&
|
||||
strokeOrder == other.strokeOrder &&
|
||||
createdAt == other.createdAt
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = id.hashCode()
|
||||
result = 31 * result + pageId.hashCode()
|
||||
result = 31 * result + penSize.hashCode()
|
||||
result = 31 * result + color
|
||||
result = 31 * result + pointData.contentHashCode()
|
||||
result = 31 * result + strokeOrder
|
||||
result = 31 * result + createdAt.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package net.metacircular.engpad.data.repository
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import net.metacircular.engpad.data.db.NotebookDao
|
||||
import net.metacircular.engpad.data.db.PageDao
|
||||
import net.metacircular.engpad.data.model.Notebook
|
||||
import net.metacircular.engpad.data.model.Page
|
||||
|
||||
class NotebookRepository(
|
||||
private val notebookDao: NotebookDao,
|
||||
private val pageDao: PageDao,
|
||||
) {
|
||||
fun getAll(): Flow<List<Notebook>> = notebookDao.getAll()
|
||||
|
||||
suspend fun getById(id: Long): Notebook? = notebookDao.getById(id)
|
||||
|
||||
/**
|
||||
* Create a notebook and its first page. Returns the notebook ID.
|
||||
*/
|
||||
suspend fun create(title: String, pageSize: String): Long {
|
||||
val now = System.currentTimeMillis()
|
||||
val notebookId = notebookDao.insert(
|
||||
Notebook(
|
||||
title = title,
|
||||
pageSize = pageSize,
|
||||
createdAt = now,
|
||||
updatedAt = now,
|
||||
)
|
||||
)
|
||||
pageDao.insert(
|
||||
Page(
|
||||
notebookId = notebookId,
|
||||
pageNumber = 1,
|
||||
createdAt = now,
|
||||
)
|
||||
)
|
||||
return notebookId
|
||||
}
|
||||
|
||||
suspend fun delete(id: Long) = notebookDao.deleteById(id)
|
||||
|
||||
suspend fun updateTitle(id: Long, title: String) {
|
||||
val notebook = notebookDao.getById(id) ?: return
|
||||
notebookDao.update(
|
||||
notebook.copy(title = title, updatedAt = System.currentTimeMillis())
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package net.metacircular.engpad.data.repository
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import net.metacircular.engpad.data.db.PageDao
|
||||
import net.metacircular.engpad.data.db.StrokeDao
|
||||
import net.metacircular.engpad.data.model.Page
|
||||
import net.metacircular.engpad.data.model.Stroke
|
||||
|
||||
class PageRepository(
|
||||
private val pageDao: PageDao,
|
||||
private val strokeDao: StrokeDao,
|
||||
) {
|
||||
fun getPages(notebookId: Long): Flow<List<Page>> =
|
||||
pageDao.getByNotebookId(notebookId)
|
||||
|
||||
suspend fun getById(id: Long): Page? = pageDao.getById(id)
|
||||
|
||||
/**
|
||||
* Add a new page to a notebook. Returns the page ID.
|
||||
*/
|
||||
suspend fun addPage(notebookId: Long): Long {
|
||||
val maxPage = pageDao.getMaxPageNumber(notebookId) ?: 0
|
||||
return pageDao.insert(
|
||||
Page(
|
||||
notebookId = notebookId,
|
||||
pageNumber = maxPage + 1,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun deletePage(id: Long) = pageDao.deleteById(id)
|
||||
|
||||
suspend fun getStrokes(pageId: Long): List<Stroke> =
|
||||
strokeDao.getByPageId(pageId)
|
||||
|
||||
suspend fun addStroke(stroke: Stroke): Long = strokeDao.insert(stroke)
|
||||
|
||||
suspend fun deleteStroke(id: Long) = strokeDao.deleteById(id)
|
||||
|
||||
suspend fun deleteStrokes(ids: List<Long>) = strokeDao.deleteByIds(ids)
|
||||
|
||||
suspend fun insertStrokes(strokes: List<Stroke>) = strokeDao.insertAll(strokes)
|
||||
|
||||
suspend fun getNextStrokeOrder(pageId: Long): Int =
|
||||
(strokeDao.getMaxStrokeOrder(pageId) ?: 0) + 1
|
||||
|
||||
suspend fun updateStrokePoints(id: Long, pointData: ByteArray) =
|
||||
strokeDao.updatePointData(id, pointData)
|
||||
}
|
||||
Reference in New Issue
Block a user