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:
2026-03-24 14:03:57 -07:00
parent 47778222b7
commit 0b53023a25
35 changed files with 1090 additions and 14 deletions

View 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) }
}

View 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")
}
}
}

View File

@@ -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() }
}

View File

@@ -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 }
}
}
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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) }
}
}

View File

@@ -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
}
}

View File

@@ -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())
)
}
}

View File

@@ -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)
}