diff options
| author | Claudomator Agent <agent@claudomator> | 2026-03-16 00:03:17 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-03-25 04:54:58 +0000 |
| commit | 7193b2b3478171a49330f9cbcae5cd238a7d74d7 (patch) | |
| tree | b3cb354c953266140e36813751450d626b84066b /android-app | |
| parent | 984f915525184a9aaff87f3d5687ef46ebb00702 (diff) | |
feat: implement PDF logbook export (Section 4.8)
- LogbookEntry data class: timestampMs, lat/lon, SOG, COG, wind, baro, depth, event/notes
- LogbookFormatter: UTC time, position (deg/dec-min), 16-pt compass, row/page builders
- LogbookPdfExporter: landscape A4 PDF via android.graphics.pdf.PdfDocument with column headers,
alternating row shading, and table border
- 20 unit tests covering all formatting helpers and data model behaviour
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'android-app')
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) + } +} |
