summaryrefslogtreecommitdiff
path: root/android-app/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'android-app/app/src')
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookFormatter.kt81
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookPdfExporter.kt137
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/LogbookEntry.kt19
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/logbook/LogbookFormatterTest.kt178
-rw-r--r--android-app/app/src/test/kotlin/org/terst/nav/data/model/LogbookEntryTest.kt67
5 files changed, 482 insertions, 0 deletions
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookFormatter.kt b/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookFormatter.kt
new file mode 100644
index 0000000..b0a910a
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookFormatter.kt
@@ -0,0 +1,81 @@
+package com.example.androidapp.logbook
+
+import com.example.androidapp.data.model.LogbookEntry
+import java.util.Calendar
+import java.util.TimeZone
+
+data class LogbookRow(
+ val time: String,
+ val position: String,
+ val sog: String,
+ val cog: String,
+ val wind: String,
+ val baro: String,
+ val depth: String,
+ val eventNotes: String
+)
+
+data class LogbookPage(
+ val title: String,
+ val columns: List<String>,
+ val rows: List<LogbookRow>
+)
+
+object LogbookFormatter {
+
+ val COLUMNS = listOf(
+ "Time (UTC)", "Position", "SOG", "COG", "Wind", "Baro", "Depth", "Event / Notes"
+ )
+
+ private val COMPASS_POINTS = arrayOf(
+ "N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
+ "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"
+ )
+
+ fun formatTime(timestampMs: Long): String {
+ val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
+ cal.timeInMillis = timestampMs
+ return "%02d:%02d".format(
+ cal.get(Calendar.HOUR_OF_DAY),
+ cal.get(Calendar.MINUTE)
+ )
+ }
+
+ fun formatPosition(lat: Double, lon: Double): String {
+ val latDir = if (lat >= 0) "N" else "S"
+ val lonDir = if (lon >= 0) "E" else "W"
+ val absLat = Math.abs(lat)
+ val absLon = Math.abs(lon)
+ val latDeg = absLat.toInt()
+ val lonDeg = absLon.toInt()
+ val latMin = (absLat - latDeg) * 60.0
+ val lonMin = (absLon - lonDeg) * 60.0
+ return "%d°%.1f%s %d°%.1f%s".format(latDeg, latMin, latDir, lonDeg, lonMin, lonDir)
+ }
+
+ fun toCompassPoint(degrees: Double): String {
+ val normalized = ((degrees % 360.0) + 360.0) % 360.0
+ val index = ((normalized + 11.25) / 22.5).toInt() % 16
+ return COMPASS_POINTS[index]
+ }
+
+ fun formatWind(knots: Double?, directionDeg: Double?): String {
+ if (knots == null) return ""
+ val knotsStr = "%.0fkt".format(knots)
+ return if (directionDeg == null) knotsStr else "$knotsStr ${toCompassPoint(directionDeg)}"
+ }
+
+ fun toRow(entry: LogbookEntry): LogbookRow = LogbookRow(
+ time = formatTime(entry.timestampMs),
+ position = formatPosition(entry.lat, entry.lon),
+ sog = "%.1f".format(entry.sogKnots),
+ cog = "%.0f".format(entry.cogDegrees),
+ wind = formatWind(entry.windKnots, entry.windDirectionDeg),
+ baro = entry.baroHpa?.let { "%.0f".format(it) } ?: "",
+ depth = entry.depthMeters?.let { "%.0fm".format(it) } ?: "",
+ eventNotes = listOfNotNull(entry.event, entry.notes).joinToString(": ")
+ )
+
+ fun toPage(entries: List<LogbookEntry>, title: String = "Trip Logbook"): LogbookPage =
+ LogbookPage(title = title, columns = COLUMNS, rows = entries.map { toRow(it) })
+}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookPdfExporter.kt b/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookPdfExporter.kt
new file mode 100644
index 0000000..ff8ce9a
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookPdfExporter.kt
@@ -0,0 +1,137 @@
+package com.example.androidapp.logbook
+
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.Typeface
+import android.graphics.pdf.PdfDocument
+import com.example.androidapp.data.model.LogbookEntry
+import java.io.OutputStream
+
+/**
+ * Renders trip logbook entries to a formatted PDF (landscape A4).
+ * Section 4.8 — Trip Logging and Electronic Logbook.
+ */
+object LogbookPdfExporter {
+
+ // Landscape A4 in points (1 point = 1/72 inch)
+ private const val PAGE_WIDTH = 842
+ private const val PAGE_HEIGHT = 595
+ private const val MARGIN = 36f
+ private const val ROW_HEIGHT = 22f
+ private const val HEADER_HEIGHT = 36f
+ private const val TITLE_SIZE = 16f
+ private const val CELL_TEXT_SIZE = 9f
+
+ // Column width fractions (must sum to 1.0)
+ private val COL_FRACTIONS = floatArrayOf(
+ 0.08f, // Time
+ 0.18f, // Position
+ 0.06f, // SOG
+ 0.06f, // COG
+ 0.10f, // Wind
+ 0.07f, // Baro
+ 0.07f, // Depth
+ 0.38f // Event / Notes
+ )
+
+ fun export(
+ entries: List<LogbookEntry>,
+ outputStream: OutputStream,
+ title: String = "Trip Logbook"
+ ) {
+ val page = LogbookFormatter.toPage(entries, title)
+ val document = PdfDocument()
+ try {
+ val pageInfo = PdfDocument.PageInfo.Builder(PAGE_WIDTH, PAGE_HEIGHT, 1).create()
+ val pdfPage = document.startPage(pageInfo)
+ drawPage(pdfPage.canvas, page)
+ document.finishPage(pdfPage)
+ document.writeTo(outputStream)
+ } finally {
+ document.close()
+ }
+ }
+
+ private fun drawPage(canvas: Canvas, page: LogbookPage) {
+ val usableWidth = PAGE_WIDTH - 2 * MARGIN
+ val colWidths = COL_FRACTIONS.map { it * usableWidth }
+
+ val titlePaint = Paint().apply {
+ textSize = TITLE_SIZE
+ typeface = Typeface.DEFAULT_BOLD
+ color = Color.BLACK
+ }
+ val headerTextPaint = Paint().apply {
+ textSize = CELL_TEXT_SIZE
+ typeface = Typeface.DEFAULT_BOLD
+ color = Color.WHITE
+ }
+ val cellPaint = Paint().apply {
+ textSize = CELL_TEXT_SIZE
+ color = Color.BLACK
+ }
+ val linePaint = Paint().apply {
+ color = Color.LTGRAY
+ strokeWidth = 0.5f
+ }
+ val headerBgPaint = Paint().apply {
+ color = Color.rgb(41, 82, 123)
+ style = Paint.Style.FILL
+ }
+ val altBgPaint = Paint().apply {
+ color = Color.rgb(235, 242, 252)
+ style = Paint.Style.FILL
+ }
+ val borderPaint = Paint().apply {
+ color = Color.DKGRAY
+ strokeWidth = 1f
+ style = Paint.Style.STROKE
+ }
+
+ var y = MARGIN
+
+ // Title
+ canvas.drawText(page.title, MARGIN, y + TITLE_SIZE, titlePaint)
+ y += HEADER_HEIGHT
+
+ val tableTop = y
+
+ // Column header background
+ canvas.drawRect(MARGIN, y, PAGE_WIDTH - MARGIN, y + ROW_HEIGHT, headerBgPaint)
+
+ // Column header text
+ var x = MARGIN + 3f
+ page.columns.forEachIndexed { i, col ->
+ canvas.drawText(col, x, y + ROW_HEIGHT - 6f, headerTextPaint)
+ x += colWidths[i]
+ }
+ y += ROW_HEIGHT
+
+ // Data rows
+ page.rows.forEach { row ->
+ if (y + ROW_HEIGHT > PAGE_HEIGHT - MARGIN) return@forEach
+
+ if (page.rows.indexOf(row) % 2 == 1) {
+ canvas.drawRect(MARGIN, y, PAGE_WIDTH - MARGIN, y + ROW_HEIGHT, altBgPaint)
+ }
+
+ val cells = listOf(
+ row.time, row.position, row.sog, row.cog,
+ row.wind, row.baro, row.depth, row.eventNotes
+ )
+ x = MARGIN + 3f
+ cells.forEachIndexed { i, cell ->
+ val maxChars = (colWidths[i] / (CELL_TEXT_SIZE * 0.55)).toInt().coerceAtLeast(4)
+ canvas.drawText(cell.take(maxChars), x, y + ROW_HEIGHT - 6f, cellPaint)
+ x += colWidths[i]
+ }
+
+ canvas.drawLine(MARGIN, y + ROW_HEIGHT, PAGE_WIDTH - MARGIN, y + ROW_HEIGHT, linePaint)
+ y += ROW_HEIGHT
+ }
+
+ // Table border
+ canvas.drawRect(MARGIN, tableTop, PAGE_WIDTH - MARGIN, y, borderPaint)
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/LogbookEntry.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/LogbookEntry.kt
new file mode 100644
index 0000000..f41e917
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/LogbookEntry.kt
@@ -0,0 +1,19 @@
+package org.terst.nav.data.model
+
+/**
+ * A single entry in the electronic trip logbook.
+ * Matches the log format from Section 4.8 of COMPONENT_DESIGN.md.
+ */
+data class LogbookEntry(
+ val timestampMs: Long,
+ val lat: Double,
+ val lon: Double,
+ val sogKnots: Double,
+ val cogDegrees: Double,
+ val windKnots: Double? = null,
+ val windDirectionDeg: Double? = null,
+ val baroHpa: Double? = null,
+ val depthMeters: Double? = null,
+ val event: String? = null,
+ val notes: String? = null
+)
diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/logbook/LogbookFormatterTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/logbook/LogbookFormatterTest.kt
new file mode 100644
index 0000000..30b421f
--- /dev/null
+++ b/android-app/app/src/test/kotlin/com/example/androidapp/logbook/LogbookFormatterTest.kt
@@ -0,0 +1,178 @@
+package com.example.androidapp.logbook
+
+import com.example.androidapp.data.model.LogbookEntry
+import org.junit.Assert.*
+import org.junit.Test
+
+class LogbookFormatterTest {
+
+ // 2021-06-15 08:00:00 UTC = 1623744000000 ms
+ private val t0 = 1_623_744_000_000L
+
+ private fun entry(
+ ts: Long = t0,
+ lat: Double = 41.39,
+ lon: Double = -71.202,
+ sog: Double = 6.2,
+ cog: Double = 225.0,
+ windKt: Double? = 15.0,
+ windDir: Double? = 225.0,
+ baro: Double? = 1018.0,
+ depth: Double? = 14.0,
+ event: String? = "Departed slip",
+ notes: String? = null
+ ) = LogbookEntry(ts, lat, lon, sog, cog, windKt, windDir, baro, depth, event, notes)
+
+ // --- formatTime ---
+
+ @Test
+ fun `formatTime returns HH_MM for UTC midnight`() {
+ // 2021-06-15 00:00:00 UTC
+ val ts = 1_623_715_200_000L
+ assertEquals("00:00", LogbookFormatter.formatTime(ts))
+ }
+
+ @Test
+ fun `formatTime returns correct UTC hour for known timestamp`() {
+ // t0 = 2021-06-15 08:00:00 UTC
+ assertEquals("08:00", LogbookFormatter.formatTime(t0))
+ }
+
+ @Test
+ fun `formatTime pads single-digit hour and minute`() {
+ // 2021-06-15 01:05:00 UTC = 1623715200000 + 65*60*1000 = 1623715200000 + 3900000
+ val ts = 1_623_715_200_000L + 65 * 60_000L
+ assertEquals("01:05", LogbookFormatter.formatTime(ts))
+ }
+
+ // --- formatPosition ---
+
+ @Test
+ fun `formatPosition north east`() {
+ // 41.39°N → 41°23.4N, 71.202°E → 71°12.1E
+ val result = LogbookFormatter.formatPosition(41.39, 71.202)
+ assertEquals("41°23.4N 71°12.1E", result)
+ }
+
+ @Test
+ fun `formatPosition south west`() {
+ // -41.39°S → 41°23.4S, -71.202°W → 71°12.1W
+ val result = LogbookFormatter.formatPosition(-41.39, -71.202)
+ assertEquals("41°23.4S 71°12.1W", result)
+ }
+
+ @Test
+ fun `formatPosition zero zero`() {
+ val result = LogbookFormatter.formatPosition(0.0, 0.0)
+ assertEquals("0°0.0N 0°0.0E", result)
+ }
+
+ // --- formatWind ---
+
+ @Test
+ fun `formatWind null knots returns empty string`() {
+ assertEquals("", LogbookFormatter.formatWind(null, null))
+ }
+
+ @Test
+ fun `formatWind with knots and null direction returns knots only`() {
+ assertEquals("15kt", LogbookFormatter.formatWind(15.0, null))
+ }
+
+ @Test
+ fun `formatWind 225 degrees is SW`() {
+ assertEquals("15kt SW", LogbookFormatter.formatWind(15.0, 225.0))
+ }
+
+ @Test
+ fun `formatWind 0 degrees is N`() {
+ assertEquals("10kt N", LogbookFormatter.formatWind(10.0, 0.0))
+ }
+
+ @Test
+ fun `formatWind 360 degrees is N`() {
+ assertEquals("10kt N", LogbookFormatter.formatWind(10.0, 360.0))
+ }
+
+ @Test
+ fun `formatWind 90 degrees is E`() {
+ assertEquals("8kt E", LogbookFormatter.formatWind(8.0, 90.0))
+ }
+
+ // --- toCompassPoint ---
+
+ @Test
+ fun `toCompassPoint covers all 16 cardinal and intercardinal points`() {
+ val expected = listOf("N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
+ "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW")
+ expected.forEachIndexed { i, dir ->
+ val degrees = i * 22.5
+ assertEquals("degrees=$degrees", dir, LogbookFormatter.toCompassPoint(degrees))
+ }
+ }
+
+ // --- toRow ---
+
+ @Test
+ fun `toRow formats all fields correctly`() {
+ val row = LogbookFormatter.toRow(entry())
+ assertEquals("08:00", row.time)
+ assertEquals("41°23.4N 71°12.1W", row.position)
+ assertEquals("6.2", row.sog)
+ assertEquals("225", row.cog)
+ assertEquals("15kt SW", row.wind)
+ assertEquals("1018", row.baro)
+ assertEquals("14m", row.depth)
+ assertEquals("Departed slip", row.eventNotes)
+ }
+
+ @Test
+ fun `toRow combines event and notes with colon`() {
+ val row = LogbookFormatter.toRow(entry(event = "Reef #1", notes = "Strong gusts"))
+ assertEquals("Reef #1: Strong gusts", row.eventNotes)
+ }
+
+ @Test
+ fun `toRow with only notes has no colon prefix`() {
+ val row = LogbookFormatter.toRow(entry(event = null, notes = "Calm seas"))
+ assertEquals("Calm seas", row.eventNotes)
+ }
+
+ @Test
+ fun `toRow with null optional fields uses empty strings`() {
+ val e = LogbookEntry(t0, 0.0, 0.0, 0.0, 0.0)
+ val row = LogbookFormatter.toRow(e)
+ assertEquals("", row.wind)
+ assertEquals("", row.baro)
+ assertEquals("", row.depth)
+ assertEquals("", row.eventNotes)
+ }
+
+ // --- toPage ---
+
+ @Test
+ fun `toPage returns page with default title and correct column count`() {
+ val page = LogbookFormatter.toPage(emptyList())
+ assertEquals("Trip Logbook", page.title)
+ assertEquals(8, page.columns.size)
+ }
+
+ @Test
+ fun `toPage maps entries to rows in order`() {
+ val entries = listOf(
+ entry(ts = t0, event = "First"),
+ entry(ts = t0 + 3_600_000L, event = "Second")
+ )
+ val page = LogbookFormatter.toPage(entries, "Voyage Log")
+ assertEquals("Voyage Log", page.title)
+ assertEquals(2, page.rows.size)
+ assertEquals("First", page.rows[0].eventNotes)
+ assertEquals("Second", page.rows[1].eventNotes)
+ }
+
+ @Test
+ fun `toPage empty entries produces empty rows`() {
+ val page = LogbookFormatter.toPage(emptyList())
+ assertTrue(page.rows.isEmpty())
+ }
+}
diff --git a/android-app/app/src/test/kotlin/org/terst/nav/data/model/LogbookEntryTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/data/model/LogbookEntryTest.kt
new file mode 100644
index 0000000..fc4580c
--- /dev/null
+++ b/android-app/app/src/test/kotlin/org/terst/nav/data/model/LogbookEntryTest.kt
@@ -0,0 +1,67 @@
+package org.terst.nav.data.model
+
+import org.junit.Assert.*
+import org.junit.Test
+
+class LogbookEntryTest {
+
+ @Test
+ fun `LogbookEntry holds all required fields`() {
+ val entry = LogbookEntry(
+ timestampMs = 1_000_000L,
+ lat = 41.39,
+ lon = -71.202,
+ sogKnots = 6.2,
+ cogDegrees = 225.0,
+ windKnots = 15.0,
+ windDirectionDeg = 225.0,
+ baroHpa = 1018.0,
+ depthMeters = 14.0,
+ event = "Departed slip",
+ notes = "Crew ready"
+ )
+ assertEquals(1_000_000L, entry.timestampMs)
+ assertEquals(41.39, entry.lat, 1e-9)
+ assertEquals(-71.202, entry.lon, 1e-9)
+ assertEquals(6.2, entry.sogKnots, 1e-9)
+ assertEquals(225.0, entry.cogDegrees, 1e-9)
+ assertEquals(15.0, entry.windKnots)
+ assertEquals(225.0, entry.windDirectionDeg)
+ assertEquals(1018.0, entry.baroHpa)
+ assertEquals(14.0, entry.depthMeters)
+ assertEquals("Departed slip", entry.event)
+ assertEquals("Crew ready", entry.notes)
+ }
+
+ @Test
+ fun `LogbookEntry optional fields default to null`() {
+ val entry = LogbookEntry(
+ timestampMs = 0L,
+ lat = 0.0,
+ lon = 0.0,
+ sogKnots = 0.0,
+ cogDegrees = 0.0
+ )
+ assertNull(entry.windKnots)
+ assertNull(entry.windDirectionDeg)
+ assertNull(entry.baroHpa)
+ assertNull(entry.depthMeters)
+ assertNull(entry.event)
+ assertNull(entry.notes)
+ }
+
+ @Test
+ fun `LogbookEntry data class equality`() {
+ val a = LogbookEntry(100L, 10.0, 20.0, 5.0, 90.0)
+ val b = LogbookEntry(100L, 10.0, 20.0, 5.0, 90.0)
+ assertEquals(a, b)
+ }
+
+ @Test
+ fun `LogbookEntry data class copy`() {
+ val original = LogbookEntry(100L, 10.0, 20.0, 5.0, 90.0, event = "anchor")
+ val copy = original.copy(sogKnots = 3.0)
+ assertEquals(3.0, copy.sogKnots, 1e-9)
+ assertEquals("anchor", copy.event)
+ }
+}