Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion client/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Cestovni — mobile client

**Stack:** Flutter + Drift (ADR 003).
**Milestones:** M0 closed (CES-36, CES-37). **Android M1 closed** on `main` (`bb1d5d5`, 2026-08-16) — CES-38 math, CES-39 Log/History/vehicles, CES-57/65 prefs, CES-66 Metrics, CES-67 Maint, CES-40 photos. Next coding: **CES-41** export. See [`docs/product/delivery-plan-v1.md`](../docs/product/delivery-plan-v1.md).
**Milestones:** M0 closed (CES-36, CES-37). **Android M1 closed** on `main` (`bb1d5d5`, 2026-08-16) — CES-38 math, CES-39 Log/History/vehicles, CES-57/65 prefs, CES-66 Metrics, CES-67 Maint, CES-40 photos. **CES-41 export** on this branch. Next coding: **CES-70** import. See [`docs/product/delivery-plan-v1.md`](../docs/product/delivery-plan-v1.md).

## Quick start

Expand Down Expand Up @@ -35,6 +35,7 @@ client/
theme/ # CES-55 visual system
consumption/ # CES-38 math + validation
photos/ # CES-40 receipt photo pipeline
export/ # CES-41 ZIP export (CSV + STORE zip + share)
metrics/ # CES-66 aggregation
maintenance/ # CES-67 date-only + history ledger
db/
Expand All @@ -46,6 +47,7 @@ client/
app/ # log, history, settings, vehicle form widgets
consumption/ # golden fixtures + module purity
photos/ # EXIF strip, TTL, cleanup, no-upload invariant
export/ # ZIP golden, streaming, photos excluded
db/
shell_smoke_test.dart
```
Expand Down
98 changes: 97 additions & 1 deletion client/lib/app/pages/settings_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'package:flutter/material.dart';

import '../../db/app_database.dart';
import '../../db/repositories/settings_repository.dart';
import '../../export/export_service.dart';
import '../active_vehicle.dart';
import '../theme/cestovni_primitives.dart';
import '../theme/cestovni_tokens.dart';
Expand All @@ -16,10 +17,13 @@ import 'vehicle_form_page.dart';
/// default vehicle) to [SettingsRepository]. Debug stays reachable
/// from inside Settings until the rollback tooling lands (CES-50).
class SettingsPage extends StatelessWidget {
const SettingsPage({super.key, required this.db});
const SettingsPage({super.key, required this.db, this.onExport});

final AppDatabase db;

/// Test hook. Production leaves this null and uses [ExportService].
final Future<void> Function()? onExport;

@override
Widget build(BuildContext context) {
final colors = context.cestovniColors;
Expand All @@ -43,6 +47,7 @@ class SettingsPage extends StatelessWidget {
title: Text('Backup'),
subtitle: Text('Offline — sign in lands in M3.'),
),
_ExportDataSection(db: db, onExport: onExport),
const HairlineDivider(),
const _SectionLabel(text: 'Developer'),
ListTile(
Expand Down Expand Up @@ -512,6 +517,97 @@ class _DefaultVehicleTile extends StatelessWidget {
}
}

/// CES-41 — Settings → Export data. Isolated [StatefulWidget] so
/// progress / error do not rebuild the rest of Settings.
class _ExportDataSection extends StatefulWidget {
const _ExportDataSection({required this.db, this.onExport});

final AppDatabase db;
final Future<void> Function()? onExport;

@override
State<_ExportDataSection> createState() => _ExportDataSectionState();
}

class _ExportDataSectionState extends State<_ExportDataSection> {
bool _busy = false;
String? _error;

Future<void> _run() async {
if (_busy) return;
setState(() {
_busy = true;
_error = null;
});
try {
final hook = widget.onExport;
if (hook != null) {
await hook();
} else {
await ExportService(db: widget.db).exportAndShare();
}
} catch (_) {
if (mounted) {
setState(() => _error = 'Export failed. Try again.');
}
} finally {
if (mounted) setState(() => _busy = false);
}
}

@override
Widget build(BuildContext context) {
final colors = context.cestovniColors;
return Padding(
padding: const EdgeInsets.fromLTRB(
CestovniMetrics.pagePadding,
CestovniMetrics.tilePadding,
CestovniMetrics.pagePadding,
CestovniMetrics.tilePadding,
),
child: LedgerTile(
onTap: _busy ? null : _run,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'EXPORT',
style: CestovniTypography.labelMono(
color: colors.mutedForeground,
),
),
const SizedBox(height: 6),
Text(
'Export data',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
'Photos are not included.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colors.mutedForeground,
),
),
if (_busy) ...[
const SizedBox(height: 12),
const LinearProgressIndicator(minHeight: 2),
],
if (_error != null) ...[
const SizedBox(height: 8),
Text(
_error!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colors.destructive,
),
),
],
],
),
),
);
}
}

class _VehicleRow extends StatelessWidget {
const _VehicleRow({
required this.db,
Expand Down
6 changes: 6 additions & 0 deletions client/lib/db/repositories/outbox_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,12 @@ class OutboxRepository {
return query.watchSingle().map((row) => row.read(count) ?? 0);
}

/// Every pending `mutation_id`, unsorted. Export hashes the sorted set.
Future<List<String>> pendingMutationIds() async {
final rows = await _db.select(_db.outbox).get();
return [for (final r in rows) r.mutationId];
}

// ---------------------------------------------------------------- mutate

/// Drop a row after the server returns `applied` or `duplicate`.
Expand Down
9 changes: 9 additions & 0 deletions client/lib/export/app_version.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/// App version string written into `manifest.json`.
///
/// Locked decision 6: injected into the assembler so tests are
/// deterministic. Keep in lockstep with `client/pubspec.yaml` `version`
/// (the `+build` suffix is stripped — spec wants a semver string).
const String kAppVersion = '0.0.1';

/// Stage 1 export is Android-only (ADR 005 — PWA-lite has no export).
const String kExportAppPlatform = 'android';
93 changes: 93 additions & 0 deletions client/lib/export/assembler.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/// Streams export tables into a [ZipSink] (CES-41).
///
/// Pure of Flutter / `dart:io`. Takes already-filtered iterables so a
/// 1 000-row fill-up fixture can be a lazy generator. Each CSV row is
/// a separate [ZipSink.add] — that is the streaming invariant tests
/// assert.
library;

import 'dart:convert';
import 'dart:typed_data';

import '../photos/photo_export_guard.dart';
import 'csv.dart';
import 'zip_sink.dart';

class ExportCsvTable {
const ExportCsvTable({
required this.filename,
required this.header,
required this.rows,
});

final String filename;
final String header;

/// Lazy. One list of fields per row, matching [header] order.
final Iterable<List<Object?>> rows;
}

/// Write [tables] plus [manifestJson] and [readmeText] into [sink].
///
/// Throws [StateError] if any entry name is inside the photo sandbox
/// (the assembler must never put `photos/` in the ZIP).
void assembleExportZip({
required ZipSink sink,
required String manifestJson,
required String readmeText,
required List<ExportCsvTable> tables,
}) {
final written = <String>[];

void writeTextFile(String name, String text, {bool crlfAlready = false}) {
_start(sink, name, written);
final String body = crlfAlready ? text : text.replaceAll('\n', crlf);
// Chunk the body so a large README still does not land as one
// giant add — 512-byte slices are enough for the streaming test.
final Uint8List bytes = Uint8List.fromList(utf8.encode(body));
_addInSlices(sink, bytes);
sink.closeFile();
}

writeTextFile('manifest.json', manifestJson);
writeTextFile('README_export.txt', readmeText, crlfAlready: true);

for (final table in tables) {
_start(sink, table.filename, written);
sink.add(utf8Bom);
sink.add(csvHeaderBytes(table.header));
for (final row in table.rows) {
sink.add(csvRowBytes(row));
}
sink.closeFile();
}

sink.close();

final allowed = excludePhotoPaths(written);
if (allowed.length != written.length) {
throw StateError(
'export assembler attempted to write a photo-sandbox path: $written',
);
}
}

void _start(ZipSink sink, String name, List<String> written) {
if (isPhotoSandboxPath(name)) {
throw StateError('refusing to add photo path $name to export ZIP');
}
sink.startFile(name);
written.add(name);
}

void _addInSlices(ZipSink sink, Uint8List bytes) {
const int slice = 512;
if (bytes.length <= slice) {
sink.add(bytes);
return;
}
for (var i = 0; i < bytes.length; i += slice) {
final end = i + slice > bytes.length ? bytes.length : i + slice;
sink.add(bytes.sublist(i, end));
}
}
19 changes: 19 additions & 0 deletions client/lib/export/crc32.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/// IEEE CRC-32 (ZIP / PNG polynomial 0xEDB88320).
///
/// Used by the STORE zip writer so we do not pull `package:archive`
/// into the production write path.
library;

int crc32Update(int crc, List<int> bytes) {
var c = (crc ^ 0xFFFFFFFF) & 0xFFFFFFFF;
for (final b in bytes) {
c ^= b & 0xFF;
for (var i = 0; i < 8; i++) {
c = (c & 1) == 1 ? ((c >> 1) ^ 0xEDB88320) : (c >> 1);
c &= 0xFFFFFFFF;
}
}
return (c ^ 0xFFFFFFFF) & 0xFFFFFFFF;
}

int crc32(List<int> bytes) => crc32Update(0, bytes);
38 changes: 38 additions & 0 deletions client/lib/export/csv.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/// RFC 4180 CSV helpers for CES-41.
///
/// Spec: `docs/specs/export-v1.md` § CSV rules — UTF-8 with BOM, CRLF,
/// comma delimiter, empty field = null, booleans `true`/`false`.
library;

import 'dart:convert';
import 'dart:typed_data';

/// UTF-8 BOM bytes prepended to every CSV file.
final Uint8List utf8Bom = Uint8List.fromList(const [0xEF, 0xBB, 0xBF]);

const String crlf = '\r\n';

/// Encode one field. [null] becomes empty; [bool] becomes `true`/`false`.
String csvField(Object? value) {
if (value == null) return '';
final String raw = value is bool
? (value ? 'true' : 'false')
: value.toString();
final bool needsQuotes = raw.contains(',') ||
raw.contains('"') ||
raw.contains('\n') ||
raw.contains('\r');
if (!needsQuotes) return raw;
return '"${raw.replaceAll('"', '""')}"';
}

/// One CSV record including the trailing CRLF, UTF-8 encoded (no BOM).
Uint8List csvRowBytes(List<Object?> fields) {
final String line = '${fields.map(csvField).join(',')}$crlf';
return Uint8List.fromList(utf8.encode(line));
}

/// Header line including trailing CRLF, UTF-8 encoded (no BOM).
Uint8List csvHeaderBytes(String header) {
return Uint8List.fromList(utf8.encode('$header$crlf'));
}
40 changes: 40 additions & 0 deletions client/lib/export/derived.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/// Canonical → CSV derived-column conversions (CES-41).
///
/// Spec: `docs/specs/si-units.md` display rounding + `export-v1.md` § A2
/// (both unit columns always ship). CSV values have **no** thousands
/// separators so spreadsheet tools parse them as numbers.
library;

import '../consumption/rounding.dart';
import '../units/display_units.dart';

/// Canonical meters → whole km (0 decimals, banker's). Null stays null.
String? metersToKmCsv(int? meters) =>
meters == null ? null : metersToDisplayWhole(meters, 'km').toString();

/// Canonical meters → whole mi (0 decimals, banker's). Null stays null.
String? metersToMiCsv(int? meters) =>
meters == null ? null : metersToDisplayWhole(meters, 'mi').toString();

/// Canonical µL → litres with 2 decimals, no grouping.
String? volumeToLitersCsv(int? microliters) =>
microliters == null ? null : _volumeCsv(microliters, microlitersPerLiter);

/// Canonical µL → US gallons with 2 decimals, no grouping.
String? volumeToGallonsCsv(int? microliters) => microliters == null
? null
: _volumeCsv(microliters, microlitersPerUsGallon);

/// Canonical cents → major units with 2 decimals, no grouping.
String centsToMajorCsv(int cents) {
final String sign = cents < 0 ? '-' : '';
final int abs = cents.abs();
return '$sign${abs ~/ 100}.${(abs % 100).toString().padLeft(2, '0')}';
}

String _volumeCsv(int microliters, int perUnit) {
final int scaled = divideRoundHalfEven(microliters * 100, perUnit);
final String sign = scaled < 0 ? '-' : '';
final int abs = scaled.abs();
return '$sign${abs ~/ 100}.${(abs % 100).toString().padLeft(2, '0')}';
}
Loading
Loading