diff --git a/app/android/app/src/androidTest/kotlin/aero/flyfun/forms/data/DataTransferTest.kt b/app/android/app/src/androidTest/kotlin/aero/flyfun/forms/data/DataTransferTest.kt index 113f37d..b42f6f3 100644 --- a/app/android/app/src/androidTest/kotlin/aero/flyfun/forms/data/DataTransferTest.kt +++ b/app/android/app/src/androidTest/kotlin/aero/flyfun/forms/data/DataTransferTest.kt @@ -83,6 +83,26 @@ class DataTransferTest { assertEquals(listOf("Eriksson"), target.flightDao().crewOn(flightId).map { it.lastName }) } + @Test + fun trip_extra_fields_survive_the_round_trip() = runTest { + val trip = TripEntity(name = "Alps", extraFieldsJson = """{"reason_for_visit":"Maintenance"}""") + source.tripDao().upsert(trip) + // Generated, as the real export does, rather than a literal a secret + // scanner reads as a hardcoded password. + val password = aero.flyfun.forms.logic.DataFileCrypto.generatePassphrase().toCharArray() + + val bytes = DataTransfer(source).exportEncrypted("test", password) + val incoming = DataTransfer(target) + incoming.preview(bytes, password.copyOf()).let { incoming.apply(it.second) } + + val stored = target.tripDao().byId(trip.id) + assertNotNull(stored) + assertEquals( + mapOf("reason_for_visit" to "Maintenance"), + aero.flyfun.forms.logic.TripExtras.decode(stored!!.extraFieldsJson), + ) + } + @Test fun re_importing_the_same_file_changes_nothing() = runTest { populate(source) diff --git a/app/android/app/src/androidTest/kotlin/aero/flyfun/forms/data/FlyFunDatabaseTest.kt b/app/android/app/src/androidTest/kotlin/aero/flyfun/forms/data/FlyFunDatabaseTest.kt index b7543bb..377ddf2 100644 --- a/app/android/app/src/androidTest/kotlin/aero/flyfun/forms/data/FlyFunDatabaseTest.kt +++ b/app/android/app/src/androidTest/kotlin/aero/flyfun/forms/data/FlyFunDatabaseTest.kt @@ -119,6 +119,29 @@ class FlyFunDatabaseTest { assertEquals(Instant.parse("2026-09-19T12:00:00Z"), row!!.person.deletedAt) } + @Test + fun undo_restores_a_tombstoned_person_flight_and_aircraft() = runTest { + val p = person("Gita", "Rao") + val ac = AircraftEntity(registration = "G-ABCD", type = "SR22").also { aircraft.upsert(it) } + val f = FlightEntity(departureInstant = dep, arrivalInstant = arr, aircraftId = ac.id) + .also { flights.upsert(it) } + val deleted = Instant.parse("2026-09-19T12:00:00Z") + val undone = Instant.parse("2026-09-19T12:00:05Z") + + people.softDelete(p.id, deleted) + aircraft.softDelete(ac.id, deleted) + flights.softDelete(f.id, deleted) + people.restore(p.id, undone) + aircraft.restore(ac.id, undone) + flights.restore(f.id, undone) + + assertEquals(listOf(p.id), people.observeAll().first().map { it.person.id }) + assertEquals(listOf(ac.id), aircraft.all().map { it.id }) + assertEquals(listOf(f.id), flights.observeAll().first().map { it.id }) + // Newer than the tombstone, so an import carrying the deletion loses. + assertEquals(undone, people.byId(p.id)!!.person.updatedAt) + } + @Test fun documents_come_back_with_their_person_and_calendar_dates_survive() = runTest { val p = person("Hugo", "Silva") diff --git a/app/android/app/src/main/kotlin/aero/flyfun/forms/MainActivity.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/MainActivity.kt index 1616309..2278041 100644 --- a/app/android/app/src/main/kotlin/aero/flyfun/forms/MainActivity.kt +++ b/app/android/app/src/main/kotlin/aero/flyfun/forms/MainActivity.kt @@ -2,6 +2,7 @@ package aero.flyfun.forms import aero.flyfun.forms.auth.AuthService import aero.flyfun.forms.auth.TokenStore +import aero.flyfun.forms.data.FormFiles import aero.flyfun.forms.net.ApiClient import aero.flyfun.forms.net.ApiConfig import aero.flyfun.forms.ui.FlyFunApp @@ -20,20 +21,42 @@ class MainActivity : ComponentActivity() { private lateinit var api: ApiClient private lateinit var auth: AuthService + /** The redirect already handled, so a recreated activity does not handle it twice. */ + private var handledCallback: String? = null + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) tokens = TokenStore(this) api = ApiClient(tokens) auth = AuthService(this, api, tokens) + // Forms left from an earlier run carry passport data; nothing can + // still be reading them now. See FormFiles. + if (!purgedThisProcess) { + purgedThisProcess = true + FormFiles.purge(cacheDir) + } + setContent { MaterialTheme { FlyFunApp(auth = auth, tokens = tokens, api = api) } } + handledCallback = savedInstanceState?.getString(KEY_HANDLED_CALLBACK) handleAuthRedirect(intent) } + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putString(KEY_HANDLED_CALLBACK, handledCallback) + } + + override fun onResume() { + super.onResume() + // Back from a share: whatever took the file has had time to read it. + FormFiles.purge(cacheDir, olderThan = FormFiles.SHARE_GRACE) + } + /** * The activity is singleTask, so the OAuth redirect arrives here rather * than starting a second copy on top of the running task. @@ -44,12 +67,21 @@ class MainActivity : ComponentActivity() { handleAuthRedirect(intent) } + private companion object { + var purgedThisProcess = false + const val KEY_HANDLED_CALLBACK = "handled_auth_callback" + } + private fun handleAuthRedirect(intent: Intent?) { val uri = intent?.data ?: return if (uri.scheme != ApiConfig.CALLBACK_SCHEME) return + // Not again on rotation: the nonce is spent, and a second pass would + // report a sign-in that just worked as failed. + if (uri.toString() == handledCallback) return + handledCallback = uri.toString() lifecycleScope.launch { + // Success needs nothing here: the UI observes TokenStore.signedIn. auth.handleCallback(uri) - .onSuccess { recreate() } .onFailure { Toast.makeText( this@MainActivity, diff --git a/app/android/app/src/main/kotlin/aero/flyfun/forms/auth/AuthService.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/auth/AuthService.kt index 99b960c..12a341c 100644 --- a/app/android/app/src/main/kotlin/aero/flyfun/forms/auth/AuthService.kt +++ b/app/android/app/src/main/kotlin/aero/flyfun/forms/auth/AuthService.kt @@ -29,8 +29,34 @@ class AuthService( private val tokens: TokenStore, ) { - /** Held between launching the tab and handling the redirect. */ - private var pendingState: String? = null + /** + * The `state` nonce, held between launching the tab and handling the + * redirect. + * + * On disk rather than in a field: while the Custom Tab is in front, this + * process is in the background and may be killed, and the redirect then + * starts a fresh one that would refuse the callback. It is a one-use + * anti-forgery nonce, not a credential, so plain app-private preferences + * are enough; it expires after [PENDING_TTL_MILLIS] so an abandoned + * sign-in cannot be completed much later. + */ + private val pending = context.getSharedPreferences("flyfun-auth-pending", Context.MODE_PRIVATE) + + private var pendingState: String? + get() { + val state = pending.getString(KEY_STATE, null) ?: return null + val age = System.currentTimeMillis() - pending.getLong(KEY_STARTED, 0) + return state.takeIf { age in 0..PENDING_TTL_MILLIS } + } + set(value) { + pending.edit().apply { + if (value == null) { + remove(KEY_STATE); remove(KEY_STARTED) + } else { + putString(KEY_STATE, value); putLong(KEY_STARTED, System.currentTimeMillis()) + } + }.commit() + } fun startSignIn(provider: String = "google") { val state = newState().also { pendingState = it } @@ -89,8 +115,26 @@ class AuthService( tokens.clear() } + /** + * Delete the account on the server, then sign out here. + * + * People, aircraft and flights stay on the device: they were never on the + * server, and the pilot may want to keep using the app offline. + */ + suspend fun deleteAccount(): Result = runCatching { + val response = api.auth.deleteAccount() + if (!response.isSuccessful) error("The server returned ${response.code()}. Your account was not deleted.") + tokens.clear() + } + val isSignedIn: Boolean get() = tokens.isSignedIn + private companion object { + const val KEY_STATE = "state" + const val KEY_STARTED = "started_at" + const val PENDING_TTL_MILLIS = 10 * 60 * 1000L + } + private fun newState(): String { val bytes = ByteArray(24) SecureRandom().nextBytes(bytes) diff --git a/app/android/app/src/main/kotlin/aero/flyfun/forms/auth/TokenStore.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/auth/TokenStore.kt index 1879039..70fa014 100644 --- a/app/android/app/src/main/kotlin/aero/flyfun/forms/auth/TokenStore.kt +++ b/app/android/app/src/main/kotlin/aero/flyfun/forms/auth/TokenStore.kt @@ -4,6 +4,9 @@ import android.content.Context import android.content.SharedPreferences import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow /** * The session JWT, in EncryptedSharedPreferences backed by a Keystore master @@ -28,11 +31,19 @@ class TokenStore(context: Context) { ) } + private val _signedIn = MutableStateFlow(prefs.getString(KEY_TOKEN, null) != null) + + /** Observed by the UI, so a sign-out or an expired token shows the sign-in screen. */ + val signedIn: StateFlow = _signedIn.asStateFlow() + var token: String? get() = prefs.getString(KEY_TOKEN, null) - set(value) = prefs.edit().apply { - if (value == null) remove(KEY_TOKEN) else putString(KEY_TOKEN, value) - }.apply() + set(value) { + prefs.edit().apply { + if (value == null) remove(KEY_TOKEN) else putString(KEY_TOKEN, value) + }.apply() + _signedIn.value = value != null + } val isSignedIn: Boolean get() = token != null @@ -40,6 +51,15 @@ class TokenStore(context: Context) { token = null } + /** + * Drop [rejected] after the server answered 401 to it - unless a newer + * token has replaced it meanwhile, which a late 401 must not throw away. + */ + @Synchronized + fun clearIfCurrent(rejected: String) { + if (token == rejected) clear() + } + private companion object { const val KEY_TOKEN = "session_jwt" } diff --git a/app/android/app/src/main/kotlin/aero/flyfun/forms/data/Daos.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/data/Daos.kt index c5d0145..c46df70 100644 --- a/app/android/app/src/main/kotlin/aero/flyfun/forms/data/Daos.kt +++ b/app/android/app/src/main/kotlin/aero/flyfun/forms/data/Daos.kt @@ -55,6 +55,10 @@ interface PersonDao { @Query("UPDATE person SET deletedAt = :at, updatedAt = :at WHERE id = :id") suspend fun softDelete(id: String, at: Instant = Instant.now()) + /** Undo a [softDelete]. The newer `updatedAt` makes the restore win a later merge. */ + @Query("UPDATE person SET deletedAt = NULL, updatedAt = :at WHERE id = :id") + suspend fun restore(id: String, at: Instant = Instant.now()) + @Delete suspend fun hardDelete(person: PersonEntity) } @@ -100,6 +104,9 @@ interface AircraftDao { @Query("UPDATE aircraft SET deletedAt = :at, updatedAt = :at WHERE id = :id") suspend fun softDelete(id: String, at: Instant = Instant.now()) + @Query("UPDATE aircraft SET deletedAt = NULL, updatedAt = :at WHERE id = :id") + suspend fun restore(id: String, at: Instant = Instant.now()) + @Query("SELECT * FROM aircraft") suspend fun allIncludingDeleted(): List } @@ -171,6 +178,9 @@ interface FlightDao { @Query("UPDATE flight SET deletedAt = :at, updatedAt = :at WHERE id = :id") suspend fun softDelete(id: String, at: Instant = Instant.now()) + @Query("UPDATE flight SET deletedAt = NULL, updatedAt = :at WHERE id = :id") + suspend fun restore(id: String, at: Instant = Instant.now()) + @Query("SELECT * FROM flight") suspend fun allIncludingDeleted(): List diff --git a/app/android/app/src/main/kotlin/aero/flyfun/forms/data/DataTransfer.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/data/DataTransfer.kt index d3dbd98..72cac47 100644 --- a/app/android/app/src/main/kotlin/aero/flyfun/forms/data/DataTransfer.kt +++ b/app/android/app/src/main/kotlin/aero/flyfun/forms/data/DataTransfer.kt @@ -9,6 +9,7 @@ import aero.flyfun.forms.logic.InterchangeMerge import aero.flyfun.forms.logic.MergeSummary import aero.flyfun.forms.logic.PersonRecord import aero.flyfun.forms.logic.TravelDocumentRecord +import aero.flyfun.forms.logic.TripExtras import aero.flyfun.forms.logic.TripRecord import androidx.room.withTransaction import java.time.Instant @@ -159,12 +160,13 @@ private fun AircraftRecord.toEntity() = AircraftEntity( private fun TripEntity.toRecord() = TripRecord( id = id, name = name, createdAt = createdAt.toString(), - extraFields = emptyMap(), + extraFields = TripExtras.decode(extraFieldsJson), updatedAt = updatedAt.toString(), deletedAt = deletedAt?.toString(), ) private fun TripRecord.toEntity() = TripEntity( id = id, name = name, createdAt = Instant.parse(createdAt), + extraFieldsJson = TripExtras.encode(extraFields), updatedAt = Instant.parse(updatedAt), deletedAt = deletedAt?.let(Instant::parse), ) diff --git a/app/android/app/src/main/kotlin/aero/flyfun/forms/data/FlightRepository.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/data/FlightRepository.kt index c89ccf0..bf6e969 100644 --- a/app/android/app/src/main/kotlin/aero/flyfun/forms/data/FlightRepository.kt +++ b/app/android/app/src/main/kotlin/aero/flyfun/forms/data/FlightRepository.kt @@ -16,6 +16,8 @@ interface FlightRepository { suspend fun saveAircraft(aircraft: AircraftEntity) suspend fun deleteFlight(id: String) suspend fun deleteAircraft(id: String) + suspend fun restoreFlight(id: String) + suspend fun restoreAircraft(id: String) suspend fun crew(flightId: String): List suspend fun passengers(flightId: String): List @@ -50,6 +52,9 @@ class RoomFlightRepository( override suspend fun deleteFlight(id: String) = flights.softDelete(id, Instant.now()) override suspend fun deleteAircraft(id: String) = aircraftDao.softDelete(id, Instant.now()) + override suspend fun restoreFlight(id: String) = flights.restore(id, Instant.now()) + override suspend fun restoreAircraft(id: String) = aircraftDao.restore(id, Instant.now()) + override suspend fun crew(flightId: String) = flights.crewOn(flightId) override suspend fun passengers(flightId: String) = flights.passengersOn(flightId) diff --git a/app/android/app/src/main/kotlin/aero/flyfun/forms/data/FormFiles.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/data/FormFiles.kt new file mode 100644 index 0000000..40334b2 --- /dev/null +++ b/app/android/app/src/main/kotlin/aero/flyfun/forms/data/FormFiles.kt @@ -0,0 +1,31 @@ +package aero.flyfun.forms.data + +import java.io.File +import java.time.Duration +import java.time.Instant + +/** + * Where generated forms and data exports are written before they are shared, + * and how they are cleared. + * + * These files carry passport numbers, so they must not sit in the cache until + * the system gets round to evicting it. A file cannot be deleted the moment it + * is handed to another app - the mail app reads it after our share intent has + * returned, sometimes much later if the pilot leaves the draft open - so they + * go on the next start, and on returning to the app once a share has had + * [SHARE_GRACE] to be read. + */ +object FormFiles { + + val SHARE_GRACE: Duration = Duration.ofMinutes(15) + + fun dir(cacheDir: File): File = File(cacheDir, "forms").apply { mkdirs() } + + /** Delete every file last written more than [olderThan] ago; [Duration.ZERO] clears them all. */ + fun purge(cacheDir: File, olderThan: Duration = Duration.ZERO, now: Instant = Instant.now()) { + val cutoff = now.minus(olderThan).toEpochMilli() + File(cacheDir, "forms").listFiles() + ?.filter { it.isFile && it.lastModified() <= cutoff } + ?.forEach { it.delete() } + } +} diff --git a/app/android/app/src/main/kotlin/aero/flyfun/forms/data/FormRequestBuilder.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/data/FormRequestBuilder.kt index 902401c..2daa421 100644 --- a/app/android/app/src/main/kotlin/aero/flyfun/forms/data/FormRequestBuilder.kt +++ b/app/android/app/src/main/kotlin/aero/flyfun/forms/data/FormRequestBuilder.kt @@ -1,9 +1,12 @@ package aero.flyfun.forms.data import aero.flyfun.forms.net.AircraftPayload +import aero.flyfun.forms.net.ExtraFieldInfo +import aero.flyfun.forms.net.ExtraFieldValue import aero.flyfun.forms.net.FlightPayload import aero.flyfun.forms.net.GenerateRequest import aero.flyfun.forms.net.PersonPayload +import aero.flyfun.forms.net.ReturnFlightPayload import java.time.Instant import java.time.ZoneOffset import java.time.format.DateTimeFormatter @@ -25,7 +28,11 @@ object FormRequestBuilder { fun utcTime(instant: Instant): String = TIME.format(instant.atOffset(ZoneOffset.UTC)) - fun flightPayload(flight: FlightEntity): FlightPayload = FlightPayload( + /** + * @param contactName who to contact about the flight. iOS sends the + * responsible person's name and falls back to the stored `contact`. + */ + fun flightPayload(flight: FlightEntity, contactName: String? = null): FlightPayload = FlightPayload( origin = flight.originICAO.uppercase(), destination = flight.destinationICAO.uppercase(), departureDate = utcDate(flight.departureInstant), @@ -33,9 +40,69 @@ object FormRequestBuilder { arrivalDate = utcDate(flight.arrivalInstant), arrivalTimeUtc = utcTime(flight.arrivalInstant), nature = flight.nature, - contact = flight.contact, + contact = contactName?.takeIf { it.isNotBlank() } ?: flight.contact, ) + fun returnFlightPayload(leg: FlightEntity, peopleOnBoard: Int): ReturnFlightPayload = ReturnFlightPayload( + origin = leg.originICAO.uppercase(), + destination = leg.destinationICAO.uppercase(), + departureDate = utcDate(leg.departureInstant), + departureTimeUtc = utcTime(leg.departureInstant), + arrivalDate = utcDate(leg.arrivalInstant), + arrivalTimeUtc = utcTime(leg.arrivalInstant), + peopleOnBoard = peopleOnBoard, + ) + + /** Extra fields the flight itself supplies, so the per-form UI does not ask for them. */ + val FLIGHT_SUPPLIED_EXTRAS: Set = setOf(REASON_FOR_VISIT, RESPONSIBLE_PERSON) + + /** Extra fields read from the responsible person rather than typed per form. */ + val PERSON_SUPPLIED_EXTRAS: Set = setOf(TELEPHONE, EMAIL) + + const val REASON_FOR_VISIT = "reason_for_visit" + const val RESPONSIBLE_PERSON = "responsible_person" + const val TELEPHONE = "telephone" + const val EMAIL = "email" + + /** + * A choice field shows its first option until the pilot picks another, so + * that option is what gets sent. iOS sends nothing for an untouched + * choice, which the server rejects as missing when the field is required. + */ + fun withChoiceDefaults( + fields: List, + entered: Map, + ): Map { + val defaults = fields + .filter { it.type == "choice" && it.key !in entered && it.key !in FLIGHT_SUPPLIED_EXTRAS } + .mapNotNull { field -> field.options?.firstOrNull()?.let { field.key to ExtraFieldValue.Text(it) } } + return entered + defaults + } + + /** + * The extra fields sent with a form: what was entered for it, plus what + * the flight supplies. Port of the injection in iOS `buildRequest`: + * reason for visit and the responsible person always come from the + * flight, and the person's phone and e-mail fill `telephone` / `email` + * unless the form already has them. + */ + fun extraFields( + entered: Map, + reasonForVisit: String?, + responsiblePerson: PersonEntity?, + ): Map { + val extras = entered.toMutableMap() + reasonForVisit?.takeIf { it.isNotBlank() }?.let { extras[REASON_FOR_VISIT] = ExtraFieldValue.Text(it) } + responsiblePerson?.let { person -> + extras[RESPONSIBLE_PERSON] = ExtraFieldValue.Person( + mapOf("name" to person.displayName, "address" to person.address.orEmpty()), + ) + person.phone?.takeIf { it.isNotBlank() }?.let { extras.putIfAbsent(TELEPHONE, ExtraFieldValue.Text(it)) } + person.email?.takeIf { it.isNotBlank() }?.let { extras.putIfAbsent(EMAIL, ExtraFieldValue.Text(it)) } + } + return extras + } + fun aircraftPayload(aircraft: AircraftEntity): AircraftPayload = AircraftPayload( registration = aircraft.registration, type = aircraft.type, @@ -73,6 +140,9 @@ object FormRequestBuilder { /** * @param documentFor resolves the document to use for a person, already * scoped to the target airport + * @param extraFields the complete extras, see [extraFields] + * @param connectingFlight the leg [aero.flyfun.forms.logic.FlightLegs.connecting] + * found, with its contact name, for forms that ask for it */ fun build( airport: String, @@ -82,30 +152,25 @@ object FormRequestBuilder { crew: List, passengers: List, documentFor: (PersonEntity) -> TravelDocumentEntity?, + responsiblePerson: PersonEntity? = null, + extraFields: Map = emptyMap(), + connectingFlight: Pair? = null, + returnFlight: ReturnFlightPayload? = null, ): GenerateRequest = GenerateRequest( airport = airport.uppercase(), form = formId, - flight = flightPayload(flight), + flight = flightPayload(flight, responsiblePerson?.displayName), aircraft = aircraftPayload(aircraft), - // The first crew member is the one in command, which is what the - // forms mean by "function". + // The first crew member is the one in command. "Pilot", not "PIC": + // the word is printed as-is on the gendec and LSGS forms, and it is + // what iOS sends. crew = crew.mapIndexed { i, p -> - personPayload(p, documentFor(p), function = if (i == 0) "PIC" else "Crew") + personPayload(p, documentFor(p), function = if (i == 0) "Pilot" else "Crew") }, passengers = passengers.map { personPayload(it, documentFor(it)) }, + connectingFlight = connectingFlight?.let { (leg, contact) -> flightPayload(leg, contact) }, + returnFlight = returnFlight, + extraFields = extraFields.takeIf { it.isNotEmpty() }, observations = flight.observations?.takeIf { it.isNotBlank() }, ) - - /** - * Which side of the flight this airport is on. - * - * The server derives this itself by comparing the form's airport against - * origin and destination; this mirrors it so the UI can label the form and - * hide ones that only apply the other way. - */ - fun directionFor(airport: String, flight: FlightEntity): String? = when (airport.uppercase()) { - flight.destinationICAO.uppercase() -> "arrival" - flight.originICAO.uppercase() -> "departure" - else -> null - } } diff --git a/app/android/app/src/main/kotlin/aero/flyfun/forms/data/PeopleRepository.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/data/PeopleRepository.kt index b0c5349..ebc6b85 100644 --- a/app/android/app/src/main/kotlin/aero/flyfun/forms/data/PeopleRepository.kt +++ b/app/android/app/src/main/kotlin/aero/flyfun/forms/data/PeopleRepository.kt @@ -19,6 +19,7 @@ interface PeopleRepository { suspend fun save(person: PersonEntity) suspend fun saveDocument(document: TravelDocumentEntity) suspend fun deletePerson(id: String) + suspend fun restorePerson(id: String) suspend fun deleteDocument(id: String) /** The document to use for this person at this airport. */ @@ -42,6 +43,8 @@ class RoomPeopleRepository( override suspend fun deletePerson(id: String) = people.softDelete(id, Instant.now()) + override suspend fun restorePerson(id: String) = people.restore(id, Instant.now()) + override suspend fun deleteDocument(id: String) = documents.softDelete(id, Instant.now()) override suspend fun resolveDocument(personId: String, airport: String): TravelDocumentEntity? { diff --git a/app/android/app/src/main/kotlin/aero/flyfun/forms/data/Preferences.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/data/Preferences.kt new file mode 100644 index 0000000..8826d2e --- /dev/null +++ b/app/android/app/src/main/kotlin/aero/flyfun/forms/data/Preferences.kt @@ -0,0 +1,32 @@ +package aero.flyfun.forms.data + +import aero.flyfun.forms.logic.SpokenLanguages +import android.content.Context +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Small app settings, in SharedPreferences: one string today. DataStore would + * be a new dependency for a value this size. + */ +class Preferences(context: Context) { + + private val prefs = context.getSharedPreferences("flyfun-settings", Context.MODE_PRIVATE) + + private val _spokenLanguages = MutableStateFlow(SpokenLanguages.parse(prefs.getString(KEY_SPOKEN, null))) + + /** ISO 639-1 codes of the languages the pilot speaks besides English. */ + val spokenLanguages: StateFlow> = _spokenLanguages.asStateFlow() + + fun setSpeaks(code: String, speaks: Boolean) { + val codes = if (speaks) _spokenLanguages.value + code else _spokenLanguages.value - code + prefs.edit().putString(KEY_SPOKEN, SpokenLanguages.serialize(codes)).apply() + _spokenLanguages.value = codes + } + + private companion object { + /** The iOS `@AppStorage` key, so the two read alike. */ + const val KEY_SPOKEN = "spokenLanguageCodes" + } +} diff --git a/app/android/app/src/main/kotlin/aero/flyfun/forms/net/ApiClient.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/net/ApiClient.kt index f2b3f39..b4b695e 100644 --- a/app/android/app/src/main/kotlin/aero/flyfun/forms/net/ApiClient.kt +++ b/app/android/app/src/main/kotlin/aero/flyfun/forms/net/ApiClient.kt @@ -30,7 +30,15 @@ class ApiClient(private val tokens: TokenStore, baseUrl: String = ApiConfig.BASE encodeDefaults = true } - /** Attaches the bearer token when there is one; requests before sign-in go out bare. */ + /** + * Attaches the bearer token when there is one; requests before sign-in go + * out bare. + * + * A 401 to a request that carried a token means the session is over + * (expired, revoked, account deleted elsewhere). The token is dropped, and + * the UI, which observes [TokenStore.signedIn], goes back to sign-in - + * rather than every later request failing with the same 401. + */ private val authInterceptor = Interceptor { chain -> val token = tokens.token val request = if (token != null) { @@ -38,7 +46,9 @@ class ApiClient(private val tokens: TokenStore, baseUrl: String = ApiConfig.BASE } else { chain.request() } - chain.proceed(request) + val response = chain.proceed(request) + if (response.code == 401 && token != null) tokens.clearIfCurrent(token) + response } private val http = OkHttpClient.Builder() diff --git a/app/android/app/src/main/kotlin/aero/flyfun/forms/net/FormsApi.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/net/FormsApi.kt index b929637..c96f447 100644 --- a/app/android/app/src/main/kotlin/aero/flyfun/forms/net/FormsApi.kt +++ b/app/android/app/src/main/kotlin/aero/flyfun/forms/net/FormsApi.kt @@ -3,6 +3,7 @@ package aero.flyfun.forms.net import okhttp3.ResponseBody import retrofit2.Response import retrofit2.http.Body +import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.POST import retrofit2.http.Path @@ -66,4 +67,8 @@ interface AuthApi { @POST("auth/logout") suspend fun logout(): Response + + /** Deletes the account and everything the server holds for it. Play requires the app to offer this. */ + @DELETE("auth/account") + suspend fun deleteAccount(): Response } diff --git a/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/FlyFunApp.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/FlyFunApp.kt index af8b6d2..4b0eedd 100644 --- a/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/FlyFunApp.kt +++ b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/FlyFunApp.kt @@ -9,6 +9,7 @@ import aero.flyfun.forms.data.FlyFunDatabase import aero.flyfun.forms.data.PeopleRepository import aero.flyfun.forms.data.PersonEntity import aero.flyfun.forms.data.PersonWithDocuments +import aero.flyfun.forms.data.Preferences import aero.flyfun.forms.data.RoomFlightRepository import aero.flyfun.forms.data.DataTransfer import aero.flyfun.forms.data.RoomPeopleRepository @@ -46,6 +47,10 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -53,6 +58,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -68,10 +74,9 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import java.io.File -import java.time.Instant -import java.time.temporal.ChronoUnit /** * Hand-rolled factory rather than a DI framework: the graph is one database, @@ -85,19 +90,63 @@ private class Factory( private val cacheDir: File, private val transfer: DataTransfer, private val appVersion: String, + private val preferences: Preferences, ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T = when { modelClass.isAssignableFrom(PeopleViewModel::class.java) -> PeopleViewModel(people) as T modelClass.isAssignableFrom(AircraftViewModel::class.java) -> AircraftViewModel(flights) as T modelClass.isAssignableFrom(FlightsViewModel::class.java) -> - FlightsViewModel(flights, people, api, cacheDir) as T + FlightsViewModel(flights, people, api, cacheDir) { preferences.spokenLanguages.value } as T modelClass.isAssignableFrom(DataTransferViewModel::class.java) -> DataTransferViewModel(transfer, cacheDir, appVersion) as T else -> error("Unknown ViewModel ${modelClass.name}") } } +/** + * Deletes from anywhere - a swiped row, an edit screen's menu - and offers + * Undo. + * + * Runs on the app's scope with the repositories, not a screen's ViewModel: a + * delete from an edit screen pops that screen, and its ViewModel's scope goes + * with it, before the pilot can reach Undo. + */ +private class Deletions( + private val people: PeopleRepository, + private val flights: FlightRepository, + private val scope: CoroutineScope, + private val snackbar: SnackbarHostState, +) { + fun person(person: PersonEntity) = delete( + "Deleted ${person.displayName.ifBlank { "person" }}", + { people.deletePerson(person.id) }, + { people.restorePerson(person.id) }, + ) + + fun aircraft(aircraft: AircraftEntity) = delete( + "Deleted ${aircraft.registration.ifBlank { "aircraft" }}", + { flights.deleteAircraft(aircraft.id) }, + { flights.restoreAircraft(aircraft.id) }, + ) + + fun flight(flight: FlightEntity) = delete( + "Deleted ${flight.originICAO.ifBlank { "????" }} → ${flight.destinationICAO.ifBlank { "????" }}", + { flights.deleteFlight(flight.id) }, + { flights.restoreFlight(flight.id) }, + ) + + private fun delete(message: String, remove: suspend () -> Unit, restore: suspend () -> Unit) { + scope.launch { + remove() + // One Undo at a time: a second delete replaces the first's offer. + snackbar.currentSnackbarData?.dismiss() + val result = snackbar.showSnackbar(message, actionLabel = "Undo", duration = SnackbarDuration.Long) + if (result == SnackbarResult.ActionPerformed) restore() + } + } +} + private enum class Tab(val route: String, val label: String, val icon: ImageVector) { FLIGHTS("flights", "Flights", Icons.Default.Flight), PEOPLE("people", "People", Icons.Default.People), @@ -116,6 +165,7 @@ fun FlyFunApp(auth: AuthService, tokens: TokenStore, api: ApiClient) { db, ) } + val preferences = remember { Preferences(context) } val factory = remember { Factory( people = repositories.first, @@ -126,26 +176,45 @@ fun FlyFunApp(auth: AuthService, tokens: TokenStore, api: ApiClient) { appVersion = runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName.orEmpty() }.getOrDefault(""), + preferences = preferences, ) } - var signedIn by remember { mutableStateOf(tokens.isSignedIn) } + val signedIn by tokens.signedIn.collectAsState() + // Saveable so a process death while the Custom Tab is open does not drop a + // pilot who chose to carry on offline back onto the sign-in screen. + var skippedSignIn by rememberSaveable { mutableStateOf(false) } + // Signing in clears the skip, so a later sign-out or an expired token (a + // 401 clears it, see ApiClient) comes back here rather than failing + // quietly on every form. + androidx.compose.runtime.LaunchedEffect(signedIn) { if (signedIn) skippedSignIn = false } - if (!signedIn) { + // Above the sign-in screen, so the controller - and the back stack whose + // entries own the flight draft's ViewModel - outlives a detour to sign in + // after a token expires mid-edit. + val navController = rememberNavController() + val snackbar = remember { SnackbarHostState() } + val appScope = rememberCoroutineScope() + val deletions = remember { + Deletions(repositories.first, repositories.second, appScope, snackbar) + } + + if (!signedIn && !skippedSignIn) { SignInScreen( onSignIn = { auth.startSignIn() }, // Form generation is the only thing that needs the server. Everything // else - people, aircraft, flights - is local, so let a pilot get on // with data entry rather than blocking the whole app behind a login. - onContinueOffline = { signedIn = true }, + // Settings offers sign-in again. + onContinueOffline = { skippedSignIn = true }, ) return } - val navController = rememberNavController() val backStack by navController.currentBackStackEntryAsState() val currentRoute = backStack?.destination?.route Scaffold( + snackbarHost = { SnackbarHost(snackbar) }, bottomBar = { if (Tab.entries.any { it.route == currentRoute }) { NavigationBar { @@ -172,10 +241,10 @@ fun FlyFunApp(auth: AuthService, tokens: TokenStore, api: ApiClient) { startDestination = Tab.FLIGHTS.route, modifier = Modifier.padding(padding), ) { - flightRoutes(navController, factory, context) - peopleRoutes(navController, factory) - aircraftRoutes(navController, factory) - settingsRoute(factory, context, tokens, auth) { signedIn = false } + flightRoutes(navController, factory, context, deletions) + peopleRoutes(navController, factory, deletions) + aircraftRoutes(navController, factory, deletions) + settingsRoute(factory, context, tokens, auth, preferences) } } } @@ -184,29 +253,19 @@ private fun androidx.navigation.NavGraphBuilder.flightRoutes( nav: androidx.navigation.NavHostController, factory: ViewModelProvider.Factory, context: Context, + deletions: Deletions, ) { composable(Tab.FLIGHTS.route) { val vm: FlightsViewModel = viewModel(factory = factory) val flights by vm.allFlights.collectAsState() - val scope = rememberCoroutineScope() + val aircraft by vm.aircraft.collectAsState() FlightListScreen( flights = flights, + aircraft = aircraft, onOpen = { nav.navigate("flight/$it") }, - onAdd = { - scope.launch { - val suggested = vm.newFlightDefaults() - val now = Instant.now().truncatedTo(ChronoUnit.HOURS).plus(1, ChronoUnit.DAYS) - val flight = FlightEntity( - departureInstant = now, - arrivalInstant = now.plus(2, ChronoUnit.HOURS), - aircraftId = suggested?.id, - // Seed the route from where the aircraft usually lives. - originICAO = suggested?.usualBase.orEmpty(), - ) - vm.save(flight) - nav.navigate("flight/${flight.id}") - } - }, + // A draft, not a row: backing out of it leaves nothing behind. + onAdd = { nav.navigate("flight/${FlightsViewModel.NEW_FLIGHT}") }, + onDelete = { deletions.flight(it) }, ) } @@ -218,12 +277,23 @@ private fun androidx.navigation.NavGraphBuilder.flightRoutes( val vm: FlightsViewModel = viewModel(factory = factory) val peopleVm: PeopleViewModel = viewModel(factory = factory) val detail by vm.detail.collectAsState() + val unsaved by vm.hasUnsavedChanges.collectAsState() val aircraft by vm.aircraft.collectAsState() val people by peopleVm.people.collectAsState() val forms by vm.airportForms.collectAsState() val generate by vm.generate.collectAsState() + val extraValues by vm.extraValues.collectAsState() + val scope = rememberCoroutineScope() - androidx.compose.runtime.LaunchedEffect(flightId) { vm.load(flightId) } + androidx.compose.runtime.LaunchedEffect(flightId) { vm.open(flightId) } + + // Sent once: the state is cleared as soon as the mail app is asked. + (generate as? aero.flyfun.forms.ui.flights.GenerateState.EmailReady)?.let { email -> + androidx.compose.runtime.LaunchedEffect(email) { + emailFile(context, email) + vm.clearGenerateState() + } + } // A fetched fill plan takes over the screen until it is dismissed. (generate as? aero.flyfun.forms.ui.flights.GenerateState.WebPlan)?.let { web -> @@ -233,18 +303,34 @@ private fun androidx.navigation.NavGraphBuilder.flightRoutes( FlightEditScreen( detail = detail, + hasUnsavedChanges = unsaved, aircraftOptions = aircraft, people = people.map { it.person }, airportForms = forms, generateState = generate, - onSave = { vm.save(it) }, - onSetCrew = { vm.setCrew(flightId, it) }, - onSetPassengers = { vm.setPassengers(flightId, it) }, + onEditFlight = { vm.editFlight(it) }, + onSetDeparture = { vm.setDeparture(it) }, + onSetAircraft = { vm.setAircraft(it) }, + onSetCrew = { vm.setCrew(it) }, + onSetPassengers = { vm.setPassengers(it) }, + onSetResponsiblePerson = { vm.setResponsiblePerson(it) }, + extraValues = extraValues, + onSetExtra = { airport, formId, key, value -> vm.setExtra(airport, formId, key, value) }, + onSave = { vm.save() }, + onSaveAndBack = { scope.launch { vm.save().join(); nav.popBackStack() } }, onGenerate = { airport, form -> vm.generateForm(airport, form) }, + onEmail = { airport, form -> vm.emailForm(airport, form) }, onOpenWebForm = { airport, form -> vm.prefillWebForm(airport, form) }, onShare = { shareFile(context, it) }, onDismissGenerate = { vm.clearGenerateState() }, onBack = { nav.popBackStack() }, + onDelete = { + detail?.flight?.let { deletions.flight(it) } + nav.popBackStack() + }, + onCreateReturn = { vm.createReturnFlight() }, + onCreateNextLeg = { vm.createNextLeg() }, + onDuplicate = { vm.duplicateFlight() }, ) } } @@ -252,6 +338,7 @@ private fun androidx.navigation.NavGraphBuilder.flightRoutes( private fun androidx.navigation.NavGraphBuilder.peopleRoutes( nav: androidx.navigation.NavHostController, factory: ViewModelProvider.Factory, + deletions: Deletions, ) { composable(Tab.PEOPLE.route) { val vm: PeopleViewModel = viewModel(factory = factory) @@ -260,6 +347,7 @@ private fun androidx.navigation.NavGraphBuilder.peopleRoutes( people = people, onOpen = { nav.navigate("person/$it") }, onAdd = { nav.navigate("person/new") }, + onDelete = { deletions.person(it) }, ) } composable("person/new") { @@ -354,6 +442,7 @@ private fun androidx.navigation.NavGraphBuilder.peopleRoutes( onDeleteDocument = { id -> vm.deleteDocument(id) }, onBack = { nav.popBackStack() }, onScan = { person -> persistThen(person, "person/$personId/scan") }, + onDelete = { deletions.person(it.person); nav.popBackStack() }, ) } } @@ -362,6 +451,7 @@ private fun androidx.navigation.NavGraphBuilder.peopleRoutes( private fun androidx.navigation.NavGraphBuilder.aircraftRoutes( nav: androidx.navigation.NavHostController, factory: ViewModelProvider.Factory, + deletions: Deletions, ) { composable(Tab.AIRCRAFT.route) { val vm: AircraftViewModel = viewModel(factory = factory) @@ -370,6 +460,7 @@ private fun androidx.navigation.NavGraphBuilder.aircraftRoutes( aircraft = aircraft, onOpen = { nav.navigate("aircraft/$it") }, onAdd = { nav.navigate("aircraft/new") }, + onDelete = { deletions.aircraft(it) }, ) } composable("aircraft/new") { @@ -385,7 +476,12 @@ private fun androidx.navigation.NavGraphBuilder.aircraftRoutes( val aircraft by vm.aircraft.collectAsState() val existing: AircraftEntity? = aircraft.firstOrNull { it.id == id } existing?.let { - AircraftEditScreen(it, onSave = { u -> vm.save(u); nav.popBackStack() }, onBack = { nav.popBackStack() }) + AircraftEditScreen( + it, + onSave = { u -> vm.save(u); nav.popBackStack() }, + onBack = { nav.popBackStack() }, + onDelete = { deletions.aircraft(it); nav.popBackStack() }, + ) } } } @@ -417,14 +513,14 @@ private fun SignInScreen(onSignIn: () -> Unit, onContinueOffline: () -> Unit) { * FileUriExposedException since API 24, and this one is well above that. */ private fun shareFile(context: Context, file: File) { + // Cleared after a while in the background (FormFiles); regenerating is one tap. + if (!file.exists()) { + android.widget.Toast.makeText(context, "That file has been cleared. Generate it again.", android.widget.Toast.LENGTH_LONG).show() + return + } val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) val intent = Intent(Intent.ACTION_SEND).apply { - type = when (file.extension.lowercase()) { - "pdf" -> "application/pdf" - "docx" -> "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - "xlsx" -> "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" - else -> "application/octet-stream" - } + type = mimeType(file) putExtra(Intent.EXTRA_STREAM, uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } @@ -432,16 +528,56 @@ private fun shareFile(context: Context, file: File) { } +/** + * Opens a mail app with the form attached, addressed and written. + * + * ACTION_SEND so the attachment goes with it, and a `mailto:` selector so only + * mail apps answer rather than every app that takes a PDF. With no mail app, + * falls back to the share sheet, as iOS does without a mail account. + */ +private fun emailFile(context: Context, email: aero.flyfun.forms.ui.flights.GenerateState.EmailReady) { + val file = email.file + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) + val intent = Intent(Intent.ACTION_SEND).apply { + type = mimeType(file) + putExtra(Intent.EXTRA_EMAIL, email.to.toTypedArray()) + putExtra(Intent.EXTRA_CC, email.cc.toTypedArray()) + putExtra(Intent.EXTRA_SUBJECT, email.subject) + putExtra(Intent.EXTRA_TEXT, email.body) + putExtra(Intent.EXTRA_STREAM, uri) + // ClipData carries the read grant through the selector to the mail app. + clipData = android.content.ClipData.newRawUri(file.name, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + selector = Intent(Intent.ACTION_SENDTO, android.net.Uri.parse("mailto:")) + } + try { + context.startActivity(intent) + } catch (_: android.content.ActivityNotFoundException) { + shareFile(context, file) + } +} + +private fun mimeType(file: File): String = when (file.extension.lowercase()) { + "pdf" -> "application/pdf" + "docx" -> "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + "xlsx" -> "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + else -> "application/octet-stream" +} + private fun androidx.navigation.NavGraphBuilder.settingsRoute( factory: ViewModelProvider.Factory, context: Context, tokens: TokenStore, auth: AuthService, - onSignedOut: () -> Unit, + preferences: Preferences, ) { composable(Tab.SETTINGS.route) { val vm: DataTransferViewModel = viewModel(factory = factory) val state by vm.state.collectAsState() + val signedIn by tokens.signedIn.collectAsState() + val spokenLanguages by preferences.spokenLanguages.collectAsState() + var deletingAccount by remember { mutableStateOf(false) } + var deleteAccountError by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() // OpenDocument rather than GetContent: this reads one file the user @@ -458,7 +594,8 @@ private fun androidx.navigation.NavGraphBuilder.settingsRoute( SettingsScreen( state = state, - signedIn = tokens.isSignedIn, + signedIn = signedIn, + onSignIn = { auth.startSignIn() }, onExportEncrypted = { vm.exportEncrypted() }, onExportPlain = { vm.exportPlain() }, onPickFile = { picker.launch(arrayOf("*/*")) }, @@ -470,8 +607,24 @@ private fun androidx.navigation.NavGraphBuilder.settingsRoute( }, onConfirmImport = { vm.confirmImport() }, onShare = { shareFile(context, it) }, - onSignOut = { scope.launch { auth.signOut(); onSignedOut() } }, + // The sign-in screen follows from the token going; see FlyFunApp. + onSignOut = { scope.launch { auth.signOut() } }, onDismiss = { vm.reset() }, + spokenLanguages = spokenLanguages, + onSetSpeaks = { code, speaks -> preferences.setSpeaks(code, speaks) }, + deletingAccount = deletingAccount, + deleteAccountError = deleteAccountError, + onDeleteAccount = { + scope.launch { + deletingAccount = true + deleteAccountError = null + // Success clears the token, and the sign-in screen follows. + auth.deleteAccount().onFailure { + deleteAccountError = it.message ?: "Could not delete the account." + } + deletingAccount = false + } + }, ) } } diff --git a/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/aircraft/AircraftScreens.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/aircraft/AircraftScreens.kt index a0ee2ef..2f1e8ef 100644 --- a/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/aircraft/AircraftScreens.kt +++ b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/aircraft/AircraftScreens.kt @@ -1,6 +1,8 @@ package aero.flyfun.forms.ui.aircraft import aero.flyfun.forms.data.AircraftEntity +import aero.flyfun.forms.ui.common.DeleteOverflowMenu +import aero.flyfun.forms.ui.common.SwipeToDelete import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -10,6 +12,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowBack @@ -41,6 +45,7 @@ fun AircraftListScreen( aircraft: List, onOpen: (String) -> Unit, onAdd: () -> Unit, + onDelete: (AircraftEntity) -> Unit, ) { Scaffold( topBar = { TopAppBar(title = { Text("Aircraft") }) }, @@ -64,14 +69,16 @@ fun AircraftListScreen( } } else { LazyColumn(Modifier.fillMaxSize().padding(padding)) { - items(aircraft, key = { it.id }) { a -> - ListItem( - headlineContent = { Text(a.registration.ifBlank { "New Aircraft" }) }, - supportingContent = { - Text(listOfNotNull(a.type.ifBlank { null }, a.usualBase).joinToString(" · ")) - }, - modifier = Modifier.clickable { onOpen(a.id) }, - ) + items(aircraft, key = { "${it.id}:${it.updatedAt}" }) { a -> + SwipeToDelete(onDelete = { onDelete(a) }) { + ListItem( + headlineContent = { Text(a.registration.ifBlank { "New Aircraft" }) }, + supportingContent = { + Text(listOfNotNull(a.type.ifBlank { null }, a.usualBase).joinToString(" · ")) + }, + modifier = Modifier.clickable { onOpen(a.id) }, + ) + } HorizontalDivider() } } @@ -85,6 +92,8 @@ fun AircraftEditScreen( initial: AircraftEntity?, onSave: (AircraftEntity) -> Unit, onBack: () -> Unit, + /** Null for an aircraft not stored yet. */ + onDelete: (() -> Unit)? = null, ) { var registration by remember { mutableStateOf(initial?.registration.orEmpty()) } var type by remember { mutableStateOf(initial?.type.orEmpty()) } @@ -118,12 +127,13 @@ fun AircraftEditScreen( ) }, ) { Text("Save") } + onDelete?.let { DeleteOverflowMenu(onDelete = it) } }, ) }, ) { padding -> Column( - Modifier.fillMaxSize().padding(padding).padding(16.dp), + Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { OutlinedTextField(registration, { registration = it }, label = { Text("Registration") }, diff --git a/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/common/ChoiceField.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/common/ChoiceField.kt new file mode 100644 index 0000000..88373a4 --- /dev/null +++ b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/common/ChoiceField.kt @@ -0,0 +1,65 @@ +package aero.flyfun.forms.ui.common + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * A labelled row that opens a menu of choices: the counterpart of an iOS + * `Picker` row in a `Form`. Plain `DropdownMenu` rather than + * `ExposedDropdownMenuBox`, whose anchor API keeps changing between Material + * releases. + */ +@Composable +fun ChoiceField( + label: String, + selected: T, + options: List, + display: (T) -> String, + onSelect: (T) -> Unit, + modifier: Modifier = Modifier, +) { + var open by remember { mutableStateOf(false) } + Box(modifier.fillMaxWidth()) { + Row( + Modifier + .fillMaxWidth() + .clickable { open = true } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + Text( + display(selected), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Icon(Icons.Default.ArrowDropDown, contentDescription = null) + } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + options.forEach { option -> + DropdownMenuItem( + text = { Text(display(option)) }, + onClick = { open = false; onSelect(option) }, + ) + } + } + } +} diff --git a/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/common/Deletion.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/common/Deletion.kt new file mode 100644 index 0000000..df016a0 --- /dev/null +++ b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/common/Deletion.kt @@ -0,0 +1,79 @@ +package aero.flyfun.forms.ui.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.Text +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * A list row that deletes on a swipe towards the start. + * + * No confirmation: deletion is a tombstone and the caller offers Undo, which + * is the Android convention (designs/future/android-parity.md §3). Give the + * row a lazy-list key that changes on restore (the id plus `updatedAt`), or + * the list restores the swiped-away state along with the row. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SwipeToDelete(onDelete: () -> Unit, content: @Composable () -> Unit) { + val state = rememberSwipeToDismissBoxState() + SwipeToDismissBox( + state = state, + enableDismissFromStartToEnd = false, + onDismiss = { direction -> if (direction == SwipeToDismissBoxValue.EndToStart) onDelete() }, + backgroundContent = { + Box( + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.errorContainer) + .padding(horizontal = 24.dp), + contentAlignment = Alignment.CenterEnd, + ) { + Icon( + Icons.Default.Delete, + contentDescription = "Delete", + tint = MaterialTheme.colorScheme.onErrorContainer, + ) + } + }, + ) { content() } +} + +/** The overflow (⋮) menu of an edit screen, holding Delete. */ +@Composable +fun DeleteOverflowMenu(onDelete: () -> Unit) { + var open by remember { mutableStateOf(false) } + Box { + IconButton(onClick = { open = true }) { + Icon(Icons.Default.MoreVert, contentDescription = "More") + } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + DropdownMenuItem( + text = { Text("Delete") }, + leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null) }, + onClick = { open = false; onDelete() }, + ) + } + } +} diff --git a/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/flights/FlightScreens.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/flights/FlightScreens.kt index 1a1e518..5178cf7 100644 --- a/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/flights/FlightScreens.kt +++ b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/flights/FlightScreens.kt @@ -5,7 +5,17 @@ import aero.flyfun.forms.data.FlightEntity import aero.flyfun.forms.data.PersonEntity import aero.flyfun.forms.net.FormInfo import aero.flyfun.forms.net.displayField +import aero.flyfun.forms.data.FormRequestBuilder +import aero.flyfun.forms.logic.FormSides +import aero.flyfun.forms.net.ExtraFieldValue +import aero.flyfun.forms.ui.common.ChoiceField +import aero.flyfun.forms.ui.common.DeleteOverflowMenu +import aero.flyfun.forms.ui.common.SwipeToDelete +import androidx.activity.compose.BackHandler import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -17,7 +27,12 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.automirrored.filled.Undo import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card @@ -40,11 +55,14 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId import java.time.ZoneOffset import java.time.format.DateTimeFormatter @@ -55,12 +73,18 @@ private val HHMM: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm").withZ @Composable fun FlightListScreen( flights: List, + aircraft: List, onOpen: (String) -> Unit, onAdd: () -> Unit, + onDelete: (FlightEntity) -> Unit, ) { - val now = Instant.now() - val upcoming = flights.filter { !it.departureInstant.isBefore(now) }.sortedBy { it.departureInstant } - val past = flights.filter { it.departureInstant.isBefore(now) } + // Split at the start of today, as iOS does: a flight earlier today is + // still one the pilot is working on. + val startOfToday = LocalDate.now().atStartOfDay(ZoneId.systemDefault()).toInstant() + val upcoming = flights.filter { !it.departureInstant.isBefore(startOfToday) }.sortedBy { it.departureInstant } + val past = flights.filter { it.departureInstant.isBefore(startOfToday) }.sortedByDescending { it.departureInstant } + val registrations = aircraft.associate { it.id to it.registration } + var showPast by rememberSaveable { mutableStateOf(false) } Scaffold( topBar = { TopAppBar(title = { Text("Flights") }) }, @@ -84,13 +108,39 @@ fun FlightListScreen( } } else { LazyColumn(Modifier.fillMaxSize().padding(padding)) { - if (upcoming.isNotEmpty()) { - item { SectionHeader("Upcoming") } - items(upcoming, key = { it.id }) { FlightRow(it, onOpen) } + item { SectionHeader("Upcoming") } + if (upcoming.isEmpty()) { + item { + Text( + "No upcoming flights", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + } + items(upcoming, key = { "${it.id}:${it.updatedAt}" }) { + FlightRow(it, registrations[it.aircraftId], onOpen, onDelete) } if (past.isNotEmpty()) { - item { SectionHeader("Past") } - items(past, key = { it.id }) { FlightRow(it, onOpen) } + // Collapsed by default: the list is for what is coming up. + item { + ListItem( + headlineContent = { Text("Past Flights (${past.size})") }, + trailingContent = { + Icon( + if (showPast) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (showPast) "Hide past flights" else "Show past flights", + ) + }, + modifier = Modifier.clickable { showPast = !showPast }, + ) + } + if (showPast) { + items(past, key = { "${it.id}:${it.updatedAt}" }) { + FlightRow(it, registrations[it.aircraftId], onOpen, onDelete) + } + } } } } @@ -107,16 +157,29 @@ private fun SectionHeader(text: String) { } @Composable -private fun FlightRow(flight: FlightEntity, onOpen: (String) -> Unit) { - ListItem( - headlineContent = { - Text("${flight.originICAO.ifBlank { "????" }} → ${flight.destinationICAO.ifBlank { "????" }}") - }, - supportingContent = { - Text("${DAY.format(flight.departureInstant)} · ${HHMM.format(flight.departureInstant)}Z") - }, - modifier = Modifier.clickable { onOpen(flight.id) }, - ) +private fun FlightRow( + flight: FlightEntity, + registration: String?, + onOpen: (String) -> Unit, + onDelete: (FlightEntity) -> Unit, +) { + SwipeToDelete(onDelete = { onDelete(flight) }) { + ListItem( + headlineContent = { + Text("${flight.originICAO.ifBlank { "????" }} → ${flight.destinationICAO.ifBlank { "????" }}") + }, + supportingContent = { + Text( + listOfNotNull( + DAY.format(flight.departureInstant), + "${HHMM.format(flight.departureInstant)}Z", + registration?.takeIf { it.isNotBlank() }, + ).joinToString(" · "), + ) + }, + modifier = Modifier.clickable { onOpen(flight.id) }, + ) + } HorizontalDivider() } @@ -124,18 +187,32 @@ private fun FlightRow(flight: FlightEntity, onOpen: (String) -> Unit) { @Composable fun FlightEditScreen( detail: FlightDetail?, + hasUnsavedChanges: Boolean, aircraftOptions: List, people: List, airportForms: List, generateState: GenerateState, - onSave: (FlightEntity) -> Unit, - onSetCrew: (List) -> Unit, - onSetPassengers: (List) -> Unit, + onEditFlight: ((FlightEntity) -> FlightEntity) -> Unit, + onSetDeparture: (java.time.Instant) -> Unit, + onSetAircraft: (String?) -> Unit, + onSetCrew: (List) -> Unit, + onSetPassengers: (List) -> Unit, + onSetResponsiblePerson: (PersonEntity?) -> Unit, + extraValues: Map>, + onSetExtra: (airport: String, formId: String, key: String, value: ExtraFieldValue?) -> Unit, + onSave: () -> Unit, + /** Save, and leave once it is stored: leaving first would cancel the write. */ + onSaveAndBack: () -> Unit, onGenerate: (String, FormInfo) -> Unit, + onEmail: (String, FormInfo) -> Unit, onOpenWebForm: (String, FormInfo) -> Unit, onShare: (java.io.File) -> Unit, onDismissGenerate: () -> Unit, onBack: () -> Unit, + onDelete: () -> Unit, + onCreateReturn: () -> Unit, + onCreateNextLeg: () -> Unit, + onDuplicate: () -> Unit, ) { if (detail == null) { Scaffold(topBar = { TopAppBar(title = { Text("Flight") }) }) { p -> @@ -146,29 +223,35 @@ fun FlightEditScreen( return } val flight = detail.flight + // Local text state so typing does not round-trip through the ViewModel; + // keyed on the flight so a different leg starts from its own values. var origin by remember(flight.id) { mutableStateOf(flight.originICAO) } var destination by remember(flight.id) { mutableStateOf(flight.destinationICAO) } var observations by remember(flight.id) { mutableStateOf(flight.observations.orEmpty()) } + var confirmDiscard by remember { mutableStateOf(false) } + + // Back with edits asks first, whether it came from the arrow or the system. + val leave = { if (hasUnsavedChanges) confirmDiscard = true else onBack() } + BackHandler(enabled = hasUnsavedChanges) { confirmDiscard = true } Scaffold( topBar = { TopAppBar( - title = { Text("${flight.originICAO} → ${flight.destinationICAO}") }, + title = { + Text( + if (detail.isNew && origin.isBlank() && destination.isBlank()) "New Flight" + else "${origin.ifBlank { "????" }} → ${destination.ifBlank { "????" }}", + ) + }, navigationIcon = { - IconButton(onClick = onBack) { + IconButton(onClick = leave) { Icon(Icons.Default.ArrowBack, contentDescription = "Back") } }, actions = { - TextButton(onClick = { - onSave( - flight.copy( - originICAO = origin.trim().uppercase(), - destinationICAO = destination.trim().uppercase(), - observations = observations.trim().ifBlank { null }, - ), - ) - }) { Text("Save") } + TextButton(onClick = onSave, enabled = hasUnsavedChanges) { Text("Save") } + // A draft has nothing stored to delete; Back discards it. + if (!detail.isNew) DeleteOverflowMenu(onDelete = onDelete) }, ) }, @@ -178,14 +261,30 @@ fun FlightEditScreen( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Row(Modifier.fillMaxWidth(), Arrangement.spacedBy(8.dp)) { - OutlinedTextField(origin, { if (it.length <= 4) origin = it.uppercase() }, - label = { Text("From (ICAO)") }, singleLine = true, modifier = Modifier.weight(1f)) - OutlinedTextField(destination, { if (it.length <= 4) destination = it.uppercase() }, - label = { Text("To (ICAO)") }, singleLine = true, modifier = Modifier.weight(1f)) + OutlinedTextField( + origin, + { value -> + if (value.length <= 4) { + origin = value.uppercase() + onEditFlight { it.copy(originICAO = origin.trim()) } + } + }, + label = { Text("From (ICAO)") }, singleLine = true, modifier = Modifier.weight(1f), + ) + OutlinedTextField( + destination, + { value -> + if (value.length <= 4) { + destination = value.uppercase() + onEditFlight { it.copy(destinationICAO = destination.trim()) } + } + }, + label = { Text("To (ICAO)") }, singleLine = true, modifier = Modifier.weight(1f), + ) } - ScheduleField("Departure", flight.departureInstant) { onSave(flight.copy(departureInstant = it)) } - ScheduleField("Arrival", flight.arrivalInstant) { onSave(flight.copy(arrivalInstant = it)) } + ScheduleField("Departure", flight.departureInstant, onSetDeparture) + ScheduleField("Arrival", flight.arrivalInstant) { t -> onEditFlight { it.copy(arrivalInstant = t) } } if (flight.arrivalInstant.isBefore(flight.departureInstant)) { Text( "Arrival is before departure.", @@ -195,11 +294,12 @@ fun FlightEditScreen( } Text("Aircraft", style = MaterialTheme.typography.titleMedium) - Row(Modifier.fillMaxWidth(), Arrangement.spacedBy(8.dp)) { + // Scrolls sideways: a fleet does not fit across a phone. + Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), Arrangement.spacedBy(8.dp)) { aircraftOptions.forEach { a -> FilterChip( selected = detail.aircraft?.id == a.id, - onClick = { onSave(flight.copy(aircraftId = a.id)) }, + onClick = { onSetAircraft(a.id) }, label = { Text(a.registration) }, ) } @@ -208,47 +308,132 @@ fun FlightEditScreen( Text("Add an aircraft first.", style = MaterialTheme.typography.bodySmall) } - PeoplePicker("Crew", people, detail.crew.map { it.id }, onSetCrew) - PeoplePicker("Passengers", people, detail.passengers.map { it.id }, onSetPassengers) + ChoiceField( + label = "Nature", + selected = flight.nature, + options = listOf("private", "commercial"), + display = { if (it == "commercial") "Commercial" else "Private" }, + onSelect = { value -> onEditFlight { it.copy(nature = value) } }, + ) + ChoiceField( + label = "Reason for Visit", + selected = flight.reasonForVisit.orEmpty(), + options = listOf("") + REASONS_FOR_VISIT, + display = { it.ifBlank { "—" } }, + onSelect = { value -> onEditFlight { it.copy(reasonForVisit = value.ifBlank { null }) } }, + ) + ChoiceField( + label = "Responsible Person", + selected = detail.responsiblePerson, + options = listOf(null) + people, + display = { it?.displayName?.ifBlank { "Unnamed" } ?: "—" }, + onSelect = onSetResponsiblePerson, + ) + detail.responsiblePerson?.let { person -> + person.phone?.takeIf { it.isNotBlank() }?.let { DetailLine("Phone", it) } + person.address?.takeIf { it.isNotBlank() }?.let { DetailLine("Address", it) } + } - OutlinedTextField(observations, { observations = it }, - label = { Text("Observations") }, modifier = Modifier.fillMaxWidth()) + PeoplePicker("Crew", people, detail.crew, onSetCrew) + PeoplePicker("Passengers", people, detail.passengers, onSetPassengers) + + OutlinedTextField( + observations, + { value -> + observations = value + onEditFlight { it.copy(observations = value.trim().ifBlank { null }) } + }, + label = { Text("Observations") }, modifier = Modifier.fillMaxWidth(), + ) Text("Forms", style = MaterialTheme.typography.titleMedium) - airportForms.forEach { airport -> - AirportFormsCard(airport, generateState, onGenerate, onOpenWebForm) - } + val rowContext = FormRowContext( + state = generateState, + flightPeople = detail.crew + detail.passengers, + responsiblePerson = detail.responsiblePerson, + extraValues = extraValues, + onSetExtra = onSetExtra, + onGenerate = onGenerate, + onEmail = onEmail, + onOpenWebForm = onOpenWebForm, + ) + airportForms.forEach { airport -> AirportFormsCard(airport, rowContext) } if (airportForms.isEmpty()) { Text( - "Save the route to see which forms these airports need.", + "Enter the route to see which forms these airports need.", style = MaterialTheme.typography.bodySmall, ) } + + // Each stores this flight first, then opens the new leg. + Text("Actions", style = MaterialTheme.typography.titleMedium) + LegAction(Icons.AutoMirrored.Filled.Undo, "Create Return Flight", onCreateReturn) + LegAction(Icons.AutoMirrored.Filled.ArrowForward, "Create Next Leg", onCreateNextLeg) + LegAction(Icons.Default.ContentCopy, "Duplicate Flight", onDuplicate) } } + if (confirmDiscard) { + AlertDialog( + onDismissRequest = { confirmDiscard = false }, + title = { Text(if (detail.isNew) "Save this flight?" else "Save your changes?") }, + text = { Text("Leaving now discards what you have not saved.") }, + confirmButton = { + TextButton(onClick = { confirmDiscard = false; onSaveAndBack() }) { Text("Save") } + }, + dismissButton = { + TextButton(onClick = { confirmDiscard = false; onBack() }) { Text("Discard") } + }, + ) + } + GenerateFeedback(generateState, onShare, onDismissGenerate) } +@Composable +private fun LegAction(icon: ImageVector, label: String, onClick: () -> Unit) { + TextButton(onClick = onClick) { + Icon(icon, contentDescription = null) + Text(label, Modifier.padding(start = 8.dp)) + } +} + +/** Same vocabulary as iOS `Flight.reasonForVisitOptions`; the server matches on these strings. */ +private val REASONS_FOR_VISIT = listOf("Based", "Short Term Visit", "Maintenance", "Permanent Import", "Repair") + +@Composable +private fun DetailLine(label: String, value: String) { + Row(Modifier.fillMaxWidth(), Arrangement.spacedBy(8.dp)) { + Text(label, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +/** + * Chips for everyone, wrapping onto as many lines as they need rather than one + * chip per line. The full picker (search, crew/passenger toggle) is PR 2. + */ +@OptIn(ExperimentalLayoutApi::class) @Composable private fun PeoplePicker( title: String, all: List, - selected: List, - onChange: (List) -> Unit, + selected: List, + onChange: (List) -> Unit, ) { Text(title, style = MaterialTheme.typography.titleMedium) if (all.isEmpty()) { Text("Add people first.", style = MaterialTheme.typography.bodySmall) return } - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + val selectedIds = selected.map { it.id }.toSet() + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { all.forEach { person -> - val isSelected = person.id in selected + val isSelected = person.id in selectedIds FilterChip( selected = isSelected, onClick = { - onChange(if (isSelected) selected - person.id else selected + person.id) + onChange(if (isSelected) selected.filterNot { it.id == person.id } else selected + person) }, label = { Text(person.displayName.ifBlank { "Unnamed" }) }, ) @@ -256,60 +441,211 @@ private fun PeoplePicker( } } +/** + * What a form row needs from the flight screen, bundled so each row does not + * take a dozen parameters. + */ +class FormRowContext( + val state: GenerateState, + /** Crew and passengers: the people a "person" extra field can name. */ + val flightPeople: List, + val responsiblePerson: PersonEntity?, + val extraValues: Map>, + val onSetExtra: (airport: String, formId: String, key: String, value: ExtraFieldValue?) -> Unit, + val onGenerate: (String, FormInfo) -> Unit, + val onEmail: (String, FormInfo) -> Unit, + val onOpenWebForm: (String, FormInfo) -> Unit, +) + @Composable -private fun AirportFormsCard( - airport: AirportForms, - state: GenerateState, - onGenerate: (String, FormInfo) -> Unit, - onOpenWebForm: (String, FormInfo) -> Unit, -) { +private fun AirportFormsCard(airport: AirportForms, ctx: FormRowContext) { Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( buildString { append(airport.icao) if (airport.name.isNotBlank()) append(" · ${airport.name}") - airport.direction?.let { append(" · $it") } + append(" · ${airport.direction}") }, style = MaterialTheme.typography.titleSmall, ) when { + airport.loading -> CircularProgressIndicator(Modifier.padding(4.dp)) airport.error != null -> Text(airport.error, style = MaterialTheme.typography.bodySmall) airport.forms.isEmpty() -> Text("No forms needed here.", style = MaterialTheme.typography.bodySmall) - else -> airport.forms.forEach { form -> - val working = state is GenerateState.Working && state.formId == form.id - Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically) { - Column(Modifier.weight(1f)) { - Text(form.label) - if (form.isWebForm) { - Text("Official web form", style = MaterialTheme.typography.bodySmall) - } + else -> { + val grouped = FormSides.group(airport.forms) { it.isWebForm } + grouped.primary?.let { FormRow(airport.icao, it, ctx) } + grouped.web.forEach { WebFormRow(airport.icao, it, ctx) } + if (grouped.others.isNotEmpty()) { + var showOthers by rememberSaveable(airport.icao, airport.direction) { mutableStateOf(false) } + TextButton(onClick = { showOthers = !showOthers }) { + Text("Other forms (${grouped.others.size})") + Icon( + if (showOthers) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = null, + ) } - if (form.isWebForm) { - // The airport's own page: prefilled via /prefill and - // submitted by the pilot, never by the app. - OutlinedButton( - enabled = state !is GenerateState.Working, - onClick = { onOpenWebForm(airport.icao, form) }, - ) { Text("Open") } - } else { - OutlinedButton( - enabled = state !is GenerateState.Working, - onClick = { onGenerate(airport.icao, form) }, - ) { - if (working) CircularProgressIndicator(Modifier.padding(2.dp)) - else Text("Generate") - } + if (showOthers) grouped.others.forEach { FormRow(airport.icao, it, ctx) } + } + } + } + } + } +} + +@Composable +private fun FormRow(airport: String, form: FormInfo, ctx: FormRowContext) { + val working = ctx.state is GenerateState.Working && ctx.state.formId == form.id + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(form.label, style = MaterialTheme.typography.titleSmall) + ExtraFields(airport, form, ctx) + Row(Modifier.fillMaxWidth(), Arrangement.spacedBy(8.dp, Alignment.End), Alignment.CenterVertically) { + if (working) CircularProgressIndicator(Modifier.padding(2.dp)) + OutlinedButton( + enabled = ctx.state !is GenerateState.Working, + onClick = { ctx.onEmail(airport, form) }, + ) { Text("Email") } + OutlinedButton( + enabled = ctx.state !is GenerateState.Working, + onClick = { ctx.onGenerate(airport, form) }, + ) { Text("Generate") } + } + } +} + +@Composable +private fun WebFormRow(airport: String, form: FormInfo, ctx: FormRowContext) { + val working = ctx.state is GenerateState.Working && ctx.state.formId == form.id + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(form.label, style = MaterialTheme.typography.titleSmall) + Text("Official web form", style = MaterialTheme.typography.bodySmall) + } + // The airport's own page: prefilled via /prefill and submitted by + // the pilot, never by the app. + OutlinedButton( + enabled = ctx.state !is GenerateState.Working, + onClick = { ctx.onOpenWebForm(airport, form) }, + ) { + if (working) CircularProgressIndicator(Modifier.padding(2.dp)) + else Text("Open prefilled") + } + } + if (ctx.responsiblePerson == null && + form.extraFields.any { it.key in FormRequestBuilder.PERSON_SUPPLIED_EXTRAS } + ) { + Hint("Pick a responsible person to fill in phone and email") + } + } +} + +/** + * The form's own questions, from `FormInfo.extraFields`. Port of iOS + * `extraFieldsView`: reason for visit and the responsible person come from the + * flight, and phone and e-mail from the responsible person, so those are shown + * rather than asked. + */ +@Composable +private fun ExtraFields(airport: String, form: FormInfo, ctx: FormRowContext) { + val values = ctx.extraValues[FlightsViewModel.formKey(airport, form.id)].orEmpty() + val set = { key: String, value: ExtraFieldValue? -> ctx.onSetExtra(airport, form.id, key, value) } + + form.extraFields + .filter { it.key !in FormRequestBuilder.FLIGHT_SUPPLIED_EXTRAS } + .forEach { field -> + when { + field.type == "choice" -> { + val options = field.options.orEmpty() + ChoiceField( + label = field.label, + selected = (values[field.key] as? ExtraFieldValue.Text)?.value ?: options.firstOrNull().orEmpty(), + options = options, + display = { it }, + onSelect = { set(field.key, ExtraFieldValue.Text(it)) }, + ) + } + + field.type == "person" -> { + val chosen = (values[field.key] as? ExtraFieldValue.Person)?.value.orEmpty() + ChoiceField( + label = field.label, + selected = ctx.flightPeople.firstOrNull { it.displayName == chosen["name"] }, + options = listOf(null) + ctx.flightPeople, + display = { it?.displayName?.ifBlank { "Unnamed" } ?: "—" }, + onSelect = { person -> + set( + field.key, + person?.let { + ExtraFieldValue.Person(mapOf("name" to it.displayName, "address" to it.address.orEmpty())) + }, + ) + }, + ) + if (chosen.isNotEmpty()) { + var address by remember(airport, form.id, field.key, chosen["name"]) { + mutableStateOf(chosen["address"].orEmpty()) } + OutlinedTextField( + address, + { value -> + address = value + set(field.key, ExtraFieldValue.Person(chosen + ("address" to value))) + }, + label = { Text("Address") }, + modifier = Modifier.fillMaxWidth(), + ) } } + + field.key in FormRequestBuilder.PERSON_SUPPLIED_EXTRAS -> + ResponsiblePersonValue(field.label, field.key, ctx.responsiblePerson) + + else -> { + var text by remember(airport, form.id, field.key) { + mutableStateOf((values[field.key] as? ExtraFieldValue.Text)?.value.orEmpty()) + } + OutlinedTextField( + text, + { value -> + text = value + set(field.key, value.takeIf { it.isNotBlank() }?.let { ExtraFieldValue.Text(it) }) + }, + label = { Text(field.label) }, + modifier = Modifier.fillMaxWidth(), + ) + } } } +} + +/** A phone or e-mail field, read from the responsible person. */ +@Composable +private fun ResponsiblePersonValue(label: String, key: String, person: PersonEntity?) { + val value = when (key) { + FormRequestBuilder.TELEPHONE -> person?.phone + FormRequestBuilder.EMAIL -> person?.email + else -> null + } + Row(Modifier.fillMaxWidth().padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) { + Text(label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + when { + person == null -> Hint("Pick a responsible person") + value.isNullOrBlank() -> Hint("Set ${label.lowercase()} on ${person.displayName}") + else -> Text(value, color = MaterialTheme.colorScheme.onSurfaceVariant) + } } } +/** Something the pilot has to fix elsewhere before the form fills completely. */ +@Composable +private fun Hint(text: String) { + Text(text, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) +} + @Composable private fun GenerateFeedback( state: GenerateState, diff --git a/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/flights/FlightsViewModel.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/flights/FlightsViewModel.kt index c2754e5..8ebb81b 100644 --- a/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/flights/FlightsViewModel.kt +++ b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/flights/FlightsViewModel.kt @@ -3,40 +3,73 @@ package aero.flyfun.forms.ui.flights import aero.flyfun.forms.data.AircraftEntity import aero.flyfun.forms.data.FlightEntity import aero.flyfun.forms.data.FlightRepository +import aero.flyfun.forms.data.FormFiles import aero.flyfun.forms.data.FormRequestBuilder import aero.flyfun.forms.data.PeopleRepository import aero.flyfun.forms.data.PersonEntity +import aero.flyfun.forms.logic.EmailText +import aero.flyfun.forms.logic.FlightLegs +import aero.flyfun.forms.logic.FormSides +import aero.flyfun.forms.logic.Leg +import aero.flyfun.forms.net.EmailTextRequest +import aero.flyfun.forms.net.ExtraFieldValue +import aero.flyfun.forms.net.GenerateRequest import aero.flyfun.forms.net.ApiClient import aero.flyfun.forms.net.FillPlan import aero.flyfun.forms.net.FormInfo import aero.flyfun.forms.net.ServerValidationError import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.io.File +import java.time.Instant +import java.time.temporal.ChronoUnit -/** What the flight detail screen needs, loaded together. */ +/** + * The flight being edited, with who and what is on it. + * + * This is a draft: every edit lands here and nothing reaches the database until + * [FlightsViewModel.save]. Schedule and aircraft used to save on the spot while + * route and observations waited for Save, so Back kept half an edit. + */ data class FlightDetail( val flight: FlightEntity, val aircraft: AircraftEntity? = null, val crew: List = emptyList(), val passengers: List = emptyList(), + /** Named as the contact on forms; their phone and e-mail fill form fields. */ + val responsiblePerson: PersonEntity? = null, + /** Not stored yet: + opens a draft, and only Save creates the row. */ + val isNew: Boolean = false, ) -/** Forms available at one end of the flight. */ +/** Forms for one side of the flight. A local flight has two, for the one airport. */ data class AirportForms( val icao: String, + val direction: String, val name: String = "", - val direction: String? = null, val forms: List = emptyList(), + val loading: Boolean = false, val error: String? = null, ) +/** One airport's forms as fetched, shared by both sides of a local flight. */ +private sealed interface FetchedForms { + data object Loading : FetchedForms + data class Loaded(val name: String, val forms: List) : FetchedForms + data class Failed(val message: String) : FetchedForms +} + sealed interface GenerateState { data object Idle : GenerateState data class Working(val formId: String) : GenerateState @@ -46,6 +79,15 @@ sealed interface GenerateState { /** A web form's prefill plan, ready to open in a WebView. */ data class WebPlan(val plan: FillPlan) : GenerateState + + /** A form ready to go to a mail app; the screen sends it once and clears this. */ + data class EmailReady( + val file: File, + val to: List, + val cc: List, + val subject: String, + val body: String, + ) : GenerateState } class FlightsViewModel( @@ -53,6 +95,8 @@ class FlightsViewModel( private val people: PeopleRepository, private val api: ApiClient, private val cacheDir: File, + /** Read when an e-mail is written, so a change in Settings applies at once. */ + private val spokenLanguages: () -> Set = { emptySet() }, ) : ViewModel() { val allFlights: StateFlow> = @@ -66,76 +110,258 @@ class FlightsViewModel( private val _detail = MutableStateFlow(null) val detail: StateFlow = _detail.asStateFlow() - private val _airportForms = MutableStateFlow>(emptyList()) - val airportForms: StateFlow> = _airportForms.asStateFlow() + /** The draft as last loaded or saved; the draft differs from it when there is something to lose. */ + private val baseline = MutableStateFlow(null) + + val hasUnsavedChanges: StateFlow = + combine(_detail, baseline) { draft, saved -> draft != null && draft != saved } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + private val fetched = MutableStateFlow>(emptyMap()) + + val airportForms: StateFlow> = + combine(_detail, fetched) { draft, byIcao -> + val flight = draft?.flight ?: return@combine emptyList() + FormSides.of(flight.originICAO, flight.destinationICAO).map { side -> + when (val result = byIcao[side.icao]) { + is FetchedForms.Loaded -> AirportForms( + icao = side.icao, + direction = side.direction, + name = result.name, + forms = FormSides.applicable(result.forms, side.direction, { it.isWebForm }, { it.direction }), + ) + is FetchedForms.Failed -> AirportForms(side.icao, side.direction, error = result.message) + FetchedForms.Loading, null -> AirportForms(side.icao, side.direction, loading = true) + } + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) private val _generate = MutableStateFlow(GenerateState.Idle) val generate: StateFlow = _generate.asStateFlow() - fun load(flightId: String) = viewModelScope.launch { - val flight = flights.flight(flightId) ?: return@launch - _detail.value = FlightDetail( + private var opened = false + + /** + * Open a stored flight, or a new draft for [NEW_FLIGHT]. + * + * Once per ViewModel: the screen calls this on every composition of its + * route, including after rotation, and must not throw away the draft. + */ + fun open(flightId: String) { + if (opened) return + opened = true + viewModelScope.launch { + val detail = if (flightId == NEW_FLIGHT) newDraft() else loadStored(flightId) ?: return@launch + show(detail) + } + } + + private suspend fun loadStored(flightId: String): FlightDetail? { + val flight = flights.flight(flightId) ?: return null + return FlightDetail( flight = flight, aircraft = flight.aircraftId?.let { flights.aircraft(it) }, crew = flights.crew(flightId), passengers = flights.passengers(flightId), + responsiblePerson = flight.responsiblePersonId?.let { people.person(it)?.person }, + ) + } + + private suspend fun newDraft(): FlightDetail { + val suggested = flights.suggestedAircraft() + val departure = Instant.now().truncatedTo(ChronoUnit.HOURS).plus(1, ChronoUnit.DAYS) + return FlightDetail( + flight = FlightEntity( + departureInstant = departure, + arrivalInstant = departure.plus(2, ChronoUnit.HOURS), + aircraftId = suggested?.id, + // Seed the route from where the aircraft usually lives. + originICAO = suggested?.usualBase.orEmpty(), + ), + aircraft = suggested, + isNew = true, + ) + } + + /** + * Make [detail] the draft. It is compared against itself, so nothing is + * unsaved yet - unless [unsaved], for a leg the pilot just asked for, + * which Back should not drop without asking. + */ + private fun show(detail: FlightDetail, unsaved: Boolean = false) { + _detail.value = detail + baseline.value = if (unsaved) null else detail + _extraValues.value = emptyMap() + fetchForms(detail.flight) + } + + /** Apply an edit to the draft. Nothing is stored until [save]. */ + fun edit(transform: (FlightDetail) -> FlightDetail) { + val before = _detail.value ?: return + val after = transform(before) + _detail.value = after + if (after.flight.originICAO != before.flight.originICAO || + after.flight.destinationICAO != before.flight.destinationICAO + ) { + fetchForms(after.flight) + } + } + + fun editFlight(transform: (FlightEntity) -> FlightEntity) = edit { it.copy(flight = transform(it.flight)) } + + /** Moving the departure carries an arrival on the same day along with it. */ + fun setDeparture(departure: Instant) = editFlight { + it.copy( + departureInstant = departure, + arrivalInstant = FlightLegs.arrivalFollowing(it.departureInstant, departure, it.arrivalInstant), + ) + } + + fun setAircraft(aircraftId: String?) = edit { detail -> + detail.copy( + flight = detail.flight.copy(aircraftId = aircraftId), + aircraft = aircraft.value.firstOrNull { it.id == aircraftId }, ) - loadForms(flight) } - fun save(flight: FlightEntity) = viewModelScope.launch { - flights.saveFlight(flight) - load(flight.id) + fun setCrew(crew: List) = edit { it.copy(crew = crew) } + + fun setPassengers(passengers: List) = edit { it.copy(passengers = passengers) } + + /** Also keeps `contact` in step, as iOS `setResponsiblePerson` does for older builds reading it. */ + fun setResponsiblePerson(person: PersonEntity?) = edit { + it.copy( + flight = it.flight.copy(responsiblePersonId = person?.id, contact = person?.phone), + responsiblePerson = person, + ) + } + + private val _extraValues = MutableStateFlow>>(emptyMap()) + + /** + * What was entered in each form's own extra fields, by [formKey]. Held for + * the screen only, as on iOS: they are per form and rarely reused. + */ + val extraValues: StateFlow>> = _extraValues.asStateFlow() + + fun setExtra(airport: String, formId: String, key: String, value: ExtraFieldValue?) { + val formKey = formKey(airport, formId) + _extraValues.update { all -> + val current = all[formKey].orEmpty() + all + (formKey to if (value == null) current - key else current + (key to value)) + } + } + + /** Store the draft: the flight row, then who is on it. */ + fun save(): Job = viewModelScope.launch { + val draft = _detail.value ?: return@launch + val id = draft.flight.id + flights.saveFlight(draft.flight) + flights.setCrew(id, draft.crew.map { it.id }) + flights.setPassengers(id, draft.passengers.map { it.id }) + val stored = draft.copy(isNew = false) + // Only if nothing was edited while this ran; a later edit stays unsaved. + if (_detail.compareAndSet(draft, stored)) baseline.value = stored } fun delete(id: String) = viewModelScope.launch { flights.deleteFlight(id) } - fun setCrew(flightId: String, ids: List) = viewModelScope.launch { - flights.setCrew(flightId, ids); load(flightId) + /** The way back: the route reversed, leaving when this flight lands. */ + fun createReturnFlight() = createLeg { from, leg -> + leg.copy( + originICAO = from.destinationICAO, + destinationICAO = from.originICAO, + departureInstant = from.arrivalInstant, + arrivalInstant = from.arrivalInstant, + ) } - fun setPassengers(flightId: String, ids: List) = viewModelScope.launch { - flights.setPassengers(flightId, ids); load(flightId) + /** On from where this flight lands, in the same trip; the destination is left to fill in. */ + fun createNextLeg() = createLeg { from, leg -> + leg.copy( + originICAO = from.destinationICAO, + departureInstant = from.arrivalInstant, + arrivalInstant = from.arrivalInstant, + tripId = from.tripId, + legOrder = from.legOrder + 1, + ) + } + + fun duplicateFlight() = createLeg { from, leg -> + leg.copy( + originICAO = from.originICAO, + destinationICAO = from.destinationICAO, + departureInstant = from.departureInstant, + arrivalInstant = from.arrivalInstant, + observations = from.observations, + ) } - suspend fun newFlightDefaults(): AircraftEntity? = flights.suggestedAircraft() + /** + * Store this flight, then open a new leg made from it. Port of iOS + * `createReturnFlight` / `createNextLeg` / `duplicateFlight`: the aircraft, + * people, nature, reason and responsible person carry over (`copyCommon`), + * and [shape] sets the route and schedule from the flight it came [from]. + * + * The new leg is a draft like any other, shown as unsaved: iOS inserts it + * at once, and here Save stores it. + */ + private fun createLeg(shape: (from: FlightEntity, leg: FlightEntity) -> FlightEntity) = viewModelScope.launch { + save().join() + val current = _detail.value ?: return@launch + val from = current.flight + val common = FlightEntity( + departureInstant = from.departureInstant, + arrivalInstant = from.arrivalInstant, + aircraftId = from.aircraftId, + nature = from.nature, + contact = from.contact, + reasonForVisit = from.reasonForVisit, + responsiblePersonId = from.responsiblePersonId, + ) + show(current.copy(flight = shape(from, common), isNew = true), unsaved = true) + } /** * Forms for both ends of the route. * * Both are queried because a trip usually needs paperwork at each end - a * departure form where you leave and a customs form where you arrive - and - * the pilot should see both without editing the flight to find out. + * the pilot should see both without editing the flight to find out. An + * airport that failed (offline, signed out) is tried again on the next + * route change or open. */ - private fun loadForms(flight: FlightEntity) = viewModelScope.launch { - val ends = listOf(flight.originICAO, flight.destinationICAO) - .map { it.trim().uppercase() } - .filter { it.length == 4 } + private fun fetchForms(flight: FlightEntity) { + FormSides.of(flight.originICAO, flight.destinationICAO) + .map { it.icao } .distinct() - - _airportForms.value = ends.map { icao -> - runCatching { api.forms.airport(icao) }.fold( - onSuccess = { detail -> - val direction = FormRequestBuilder.directionFor(icao, flight) - AirportForms( - icao = icao, - name = detail.name, - direction = direction, - // A form tagged for one side only is noise on the other. - forms = detail.forms.filter { it.direction == null || it.direction == direction }, + .filter { icao -> + val known = fetched.value[icao] + known == null || known is FetchedForms.Failed + } + .forEach { icao -> + fetched.update { it + (icao to FetchedForms.Loading) } + viewModelScope.launch { + val result: FetchedForms = runCatching { api.forms.airport(icao) }.fold( + onSuccess = { FetchedForms.Loaded(it.name, it.forms) }, + onFailure = { FetchedForms.Failed(it.friendlyMessage()) }, ) - }, - onFailure = { AirportForms(icao = icao, error = it.friendlyMessage()) }, - ) - } + fetched.update { it + (icao to result) } + } + } } - fun generateForm(airport: String, form: FormInfo) = viewModelScope.launch { - val current = _detail.value ?: return@launch + /** + * Everything a form is generated from, built from the draft - so what is + * on screen is what gets filled, saved or not. Null, with the state set to + * say why, when the form cannot be asked for yet. + */ + private suspend fun buildRequest(airport: String, form: FormInfo): GenerateRequest? { + val current = _detail.value ?: return null val aircraft = current.aircraft if (aircraft == null) { _generate.value = GenerateState.Failed("Pick an aircraft for this flight first.") - return@launch + return null } _generate.value = GenerateState.Working(form.id) @@ -144,7 +370,43 @@ class FlightsViewModel( person.id to people.resolveDocument(person.id, airport) } - val request = FormRequestBuilder.build( + val entered = _extraValues.value[formKey(airport, form.id)].orEmpty() + val extras = FormRequestBuilder.extraFields( + entered = FormRequestBuilder.withChoiceDefaults(form.extraFields, entered), + reasonForVisit = current.flight.reasonForVisit, + responsiblePerson = current.responsiblePerson, + ) + + // Other legs as stored; this one as drafted. + val stored = flights.observeFlights().first().filter { it.id != current.flight.id } + val legs = stored.map { it.toLeg() } + val thisLeg = current.flight.toLeg() + + val connecting = if (form.hasConnectingFlight) { + FlightLegs.connecting(thisLeg, airport, legs) + ?.let { leg -> stored.first { it.id == leg.id } } + ?.let { leg -> leg to leg.responsiblePersonId?.let { people.person(it)?.person?.displayName } } + } else { + null + } + + val returning = if (form.hasReturnFlight == true) { + FlightLegs.returning(thisLeg, airport, legs)?.let { leg -> + if (leg.id == current.flight.id) { + FormRequestBuilder.returnFlightPayload(current.flight, current.crew.size + current.passengers.size) + } else { + val back = stored.first { it.id == leg.id } + FormRequestBuilder.returnFlightPayload( + back, + flights.crew(back.id).size + flights.passengers(back.id).size, + ) + } + } + } else { + null + } + + return FormRequestBuilder.build( airport = airport, formId = form.id, flight = current.flight, @@ -152,59 +414,96 @@ class FlightsViewModel( crew = current.crew, passengers = current.passengers, documentFor = { documents[it.id] }, + responsiblePerson = current.responsiblePerson, + extraFields = extras, + connectingFlight = connecting, + returnFlight = returning, ) + } - _generate.value = runCatching { api.forms.generate(request) }.fold( - onSuccess = { response -> - when { - response.isSuccessful -> { - val bytes = response.body()?.bytes() - if (bytes == null) { - GenerateState.Failed("The server returned an empty file.") - } else { - val name = response.suggestedFileName(airport, form.id) - val file = File(cacheDir, "forms").apply { mkdirs() }.resolve(name) - file.writeBytes(bytes) - GenerateState.Ready(file, form.label) - } - } - response.code() == 422 -> { - val body = response.errorBody()?.string().orEmpty() - GenerateState.Invalid(api.parseValidationErrors(body)) - } - else -> GenerateState.Failed("Server returned ${response.code()}.") - } - }, - onFailure = { GenerateState.Failed(it.friendlyMessage()) }, - ) + fun generateForm(airport: String, form: FormInfo) = viewModelScope.launch { + val file = generateFile(airport, form) ?: return@launch + _generate.value = GenerateState.Ready(file, form.label) } /** - * Web forms are the airport's own page, so the server returns a plan of - * values to type into it rather than a file. Same request body as - * /generate - only the endpoint and what comes back differ. + * Generate the form and hand it to a mail app, addressed as the form says + * and with the server's covering text. Port of iOS `generateAndEmail`: the + * text is fetched alongside the file, in the airport's language when the + * pilot speaks it. */ - fun prefillWebForm(airport: String, form: FormInfo) = viewModelScope.launch { + fun emailForm(airport: String, form: FormInfo) = viewModelScope.launch { val current = _detail.value ?: return@launch - val aircraft = current.aircraft - if (aircraft == null) { - _generate.value = GenerateState.Failed("Pick an aircraft for this flight first.") + val flight = current.flight + val registration = current.aircraft?.registration.orEmpty() + val departureDate = FormRequestBuilder.utcDate(flight.departureInstant) + val text = async { + runCatching { + api.forms.emailText( + EmailTextRequest( + airport = airport, + form = form.id, + origin = flight.originICAO, + destination = flight.destinationICAO, + departureDate = departureDate, + registration = registration, + aircraftType = current.aircraft?.type, + ), + ) + }.getOrNull() + } + val file = generateFile(airport, form) + if (file == null) { + text.cancel() return@launch } - _generate.value = GenerateState.Working(form.id) + val message = text.await()?.let { + EmailText.choose(it.localLanguage, spokenLanguages(), it.subjectEn, it.subjectLocal, it.bodyEn, it.bodyLocal) + } ?: EmailText.fallback(form.label, flight.originICAO, flight.destinationICAO, departureDate, registration) - val documents = (current.crew + current.passengers).associate { person -> - person.id to people.resolveDocument(person.id, airport) - } - val request = FormRequestBuilder.build( - airport = airport, - formId = form.id, - flight = current.flight, - aircraft = aircraft, - crew = current.crew, - passengers = current.passengers, - documentFor = { documents[it.id] }, + _generate.value = GenerateState.EmailReady( + file = file, + to = form.email?.to?.takeIf { it.isNotEmpty() } ?: listOfNotNull(form.sendTo?.takeIf { it.isNotBlank() }), + cc = form.email?.cc.orEmpty(), + subject = message.subject, + body = message.body, ) + } + + /** The filled form as a file, or null with the state saying why not. */ + private suspend fun generateFile(airport: String, form: FormInfo): File? { + val request = buildRequest(airport, form) ?: return null + val response = runCatching { api.forms.generate(request) }.getOrElse { + _generate.value = GenerateState.Failed(it.friendlyMessage()) + return null + } + when { + response.isSuccessful -> { + val bytes = response.body()?.bytes() + if (bytes == null) { + _generate.value = GenerateState.Failed("The server returned an empty file.") + return null + } + val file = FormFiles.dir(cacheDir).resolve(response.suggestedFileName(airport, form.id)) + file.writeBytes(bytes) + return file + } + response.code() == 422 -> { + val body = response.errorBody()?.string().orEmpty() + _generate.value = GenerateState.Invalid(api.parseValidationErrors(body)) + } + else -> _generate.value = GenerateState.Failed("Server returned ${response.code()}.") + } + return null + } + + /** + * Web forms are the airport's own page, so the server returns a plan of + * values to type into it rather than a file. Same request body as + * /generate - only the endpoint and what comes back differ. + */ + fun prefillWebForm(airport: String, form: FormInfo) = viewModelScope.launch { + val request = buildRequest(airport, form) ?: return@launch _generate.value = runCatching { api.forms.prefill(request) }.fold( onSuccess = { GenerateState.WebPlan(it) }, onFailure = { GenerateState.Failed(it.friendlyMessage()) }, @@ -212,6 +511,14 @@ class FlightsViewModel( } fun clearGenerateState() { _generate.value = GenerateState.Idle } + + companion object { + /** Route argument for a flight that does not exist yet. */ + const val NEW_FLIGHT = "new" + + /** One form at one airport; a local flight's two sides share it, as on iOS. */ + fun formKey(airport: String, formId: String) = "${airport.uppercase()}_$formId" + } } /** @@ -253,3 +560,11 @@ private fun retrofit2.Response.suggestedFileName( } return "$airport-$formId.$extension" } + +private fun FlightEntity.toLeg() = Leg( + id = id, + origin = originICAO, + destination = destinationICAO, + departure = departureInstant, + arrival = arrivalInstant, +) diff --git a/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/people/PeopleScreens.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/people/PeopleScreens.kt index 44bf1f6..2e8bf48 100644 --- a/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/people/PeopleScreens.kt +++ b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/people/PeopleScreens.kt @@ -3,6 +3,8 @@ package aero.flyfun.forms.ui.people import aero.flyfun.forms.data.PersonEntity import aero.flyfun.forms.data.PersonWithDocuments import aero.flyfun.forms.data.TravelDocumentEntity +import aero.flyfun.forms.ui.common.DeleteOverflowMenu +import aero.flyfun.forms.ui.common.SwipeToDelete import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -56,6 +58,7 @@ fun PeopleListScreen( people: List, onOpen: (String) -> Unit, onAdd: () -> Unit, + onDelete: (PersonEntity) -> Unit, ) { Scaffold( topBar = { TopAppBar(title = { Text("People") }) }, @@ -80,22 +83,24 @@ fun PeopleListScreen( } } else { LazyColumn(modifier = Modifier.fillMaxSize().padding(padding)) { - items(people, key = { it.person.id }) { row -> - ListItem( - headlineContent = { Text(row.person.displayName.ifBlank { "New Person" }) }, - supportingContent = { - val active = row.documents.count { it.isActive && it.deletedAt == null } - Text( - buildString { - if (row.person.isUsualCrew) append("Usual crew") - if (row.person.isUsualCrew && active > 0) append(" · ") - if (active > 0) append("$active document${if (active == 1) "" else "s"}") - if (isEmpty()) append("No documents") - }, - ) - }, - modifier = Modifier.clickable { onOpen(row.person.id) }, - ) + items(people, key = { "${it.person.id}:${it.person.updatedAt}" }) { row -> + SwipeToDelete(onDelete = { onDelete(row.person) }) { + ListItem( + headlineContent = { Text(row.person.displayName.ifBlank { "New Person" }) }, + supportingContent = { + val active = row.documents.count { it.isActive && it.deletedAt == null } + Text( + buildString { + if (row.person.isUsualCrew) append("Usual crew") + if (row.person.isUsualCrew && active > 0) append(" · ") + if (active > 0) append("$active document${if (active == 1) "" else "s"}") + if (isEmpty()) append("No documents") + }, + ) + }, + modifier = Modifier.clickable { onOpen(row.person.id) }, + ) + } HorizontalDivider() } } @@ -128,6 +133,8 @@ fun PersonEditScreen( onDeleteDocument: (String) -> Unit, onBack: () -> Unit, onScan: ((PersonEntity) -> Unit)? = null, + /** Null for a person not stored yet. */ + onDelete: (() -> Unit)? = null, ) { val existing = initial?.person val id = remember { existing?.id ?: UUID.randomUUID().toString() } @@ -164,6 +171,7 @@ fun PersonEditScreen( }, actions = { TextButton(onClick = { onSave(edited()) }) { Text("Save") } + onDelete?.let { DeleteOverflowMenu(onDelete = it) } }, ) }, diff --git a/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/settings/DataTransferViewModel.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/settings/DataTransferViewModel.kt index 730185e..d832cd9 100644 --- a/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/settings/DataTransferViewModel.kt +++ b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/settings/DataTransferViewModel.kt @@ -1,6 +1,7 @@ package aero.flyfun.forms.ui.settings import aero.flyfun.forms.data.DataTransfer +import aero.flyfun.forms.data.FormFiles import aero.flyfun.forms.logic.DataFileCrypto import aero.flyfun.forms.logic.InterchangeMerge import aero.flyfun.forms.logic.MergeSummary @@ -94,7 +95,8 @@ class DataTransferViewModel( _state.value = TransferState.Idle } - private fun outDir() = File(cacheDir, "forms").apply { mkdirs() } + // Same directory as generated forms, so the same purge clears exports. + private fun outDir() = FormFiles.dir(cacheDir) /** Kept so the import sheet can describe what it is about to do. */ val mergeDescription: String? get() = pending?.describe() diff --git a/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/settings/SettingsScreen.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/settings/SettingsScreen.kt index 7ea7d61..d71da10 100644 --- a/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/settings/SettingsScreen.kt +++ b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/settings/SettingsScreen.kt @@ -1,7 +1,9 @@ package aero.flyfun.forms.ui.settings +import aero.flyfun.forms.logic.SpokenLanguages import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -10,6 +12,7 @@ import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api @@ -17,6 +20,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -25,6 +29,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import java.io.File @@ -34,6 +39,7 @@ import java.io.File fun SettingsScreen( state: TransferState, signedIn: Boolean, + onSignIn: () -> Unit, onExportEncrypted: () -> Unit, onExportPlain: () -> Unit, onPickFile: () -> Unit, @@ -42,12 +48,40 @@ fun SettingsScreen( onShare: (File) -> Unit, onSignOut: () -> Unit, onDismiss: () -> Unit, + spokenLanguages: Set, + onSetSpeaks: (code: String, speaks: Boolean) -> Unit, + deletingAccount: Boolean, + deleteAccountError: String?, + onDeleteAccount: () -> Unit, ) { + var confirmDeleteAccount by remember { mutableStateOf(false) } + Scaffold(topBar = { TopAppBar(title = { Text("Settings") }) }) { padding -> Column( Modifier.fillMaxSize().padding(padding).padding(16.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(16.dp), ) { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp)) { + Text("Languages you speak", style = MaterialTheme.typography.titleMedium) + SpokenLanguages.ALL.forEach { (code, name) -> + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(name, style = MaterialTheme.typography.bodyLarge) + Switch(checked = code in spokenLanguages, onCheckedChange = { onSetSpeaks(code, it) }) + } + } + Text( + "When an airport's local language matches one you speak, emails are written " + + "in that language. Otherwise English is used.", + style = MaterialTheme.typography.bodySmall, + ) + } + } + Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp), Arrangement.spacedBy(8.dp)) { Text("Move my data", style = MaterialTheme.typography.titleMedium) @@ -83,12 +117,65 @@ fun SettingsScreen( if (signedIn) { OutlinedButton(onClick = onSignOut) { Text("Sign out") } + + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), Arrangement.spacedBy(8.dp)) { + Text("Delete account", style = MaterialTheme.typography.titleMedium) + Text( + deleteAccountError + ?: "Permanently deletes your account and all server data. " + + "People, aircraft and flights on this device are kept.", + style = MaterialTheme.typography.bodySmall, + color = if (deleteAccountError != null) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + OutlinedButton( + onClick = { confirmDeleteAccount = true }, + enabled = !deletingAccount, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { Text("Delete account") } + if (deletingAccount) CircularProgressIndicator(Modifier.padding(start = 12.dp)) + } + } + } + } else { + // The way back for a pilot who chose "Enter data without + // signing in": generating forms needs an account. + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), Arrangement.spacedBy(8.dp)) { + Text("Not signed in", style = MaterialTheme.typography.titleMedium) + Text( + "Sign in to load each airport's forms and generate them. " + + "Your people, aircraft and flights stay on this device either way.", + style = MaterialTheme.typography.bodySmall, + ) + Button(onClick = onSignIn) { Text("Sign in with Google") } + } + } } if (state is TransferState.Working) CircularProgressIndicator() } } + if (confirmDeleteAccount) { + AlertDialog( + onDismissRequest = { confirmDeleteAccount = false }, + title = { Text("Delete account") }, + text = { Text("This will permanently delete your account. This action cannot be undone.") }, + confirmButton = { + TextButton( + onClick = { confirmDeleteAccount = false; onDeleteAccount() }, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { Text("Delete my account") } + }, + dismissButton = { TextButton(onClick = { confirmDeleteAccount = false }) { Text("Cancel") } }, + ) + } + when (state) { is TransferState.Exported -> AlertDialog( onDismissRequest = onDismiss, diff --git a/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/webform/WebFormScreen.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/webform/WebFormScreen.kt index 5d96890..9992a98 100644 --- a/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/webform/WebFormScreen.kt +++ b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/webform/WebFormScreen.kt @@ -2,7 +2,10 @@ package aero.flyfun.forms.ui.webform import aero.flyfun.forms.net.FillPlan import android.annotation.SuppressLint +import android.webkit.CookieManager +import android.webkit.WebStorage import android.webkit.WebView +import androidx.activity.compose.BackHandler import android.webkit.WebViewClient import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -22,6 +25,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -45,6 +49,31 @@ fun WebFormScreen(plan: FillPlan, onBack: () -> Unit) { var status by remember { mutableStateOf(null) } var webView by remember { mutableStateOf(null) } + // Back walks the page's own history first (a multi-step form, its + // confirmation page), and only leaves once there is nothing to go back to. + BackHandler { + val view = webView + if (view != null && view.canGoBack()) view.goBack() else onBack() + } + + // iOS gives each web form a non-persistent store. The WebView's cookie jar + // and storage are app-wide and on disk, so clear them on the way out: + // these pages hold the pilot's name, passport and phone number. + DisposableEffect(Unit) { + onDispose { + webView?.apply { + stopLoading() + clearHistory() + clearCache(true) + } + CookieManager.getInstance().apply { + removeAllCookies(null) + flush() + } + WebStorage.getInstance().deleteAllData() + } + } + Scaffold( topBar = { TopAppBar( diff --git a/app/android/app/src/test/kotlin/aero/flyfun/forms/data/FormRequestBuilderTest.kt b/app/android/app/src/test/kotlin/aero/flyfun/forms/data/FormRequestBuilderTest.kt index 3ccea76..6ee01e5 100644 --- a/app/android/app/src/test/kotlin/aero/flyfun/forms/data/FormRequestBuilderTest.kt +++ b/app/android/app/src/test/kotlin/aero/flyfun/forms/data/FormRequestBuilderTest.kt @@ -1,5 +1,7 @@ package aero.flyfun.forms.data +import aero.flyfun.forms.net.ExtraFieldInfo +import aero.flyfun.forms.net.ExtraFieldValue import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test @@ -76,7 +78,7 @@ class FormRequestBuilderTest { } @Test - fun `first crew member is PIC and the rest are crew`() { + fun `first crew member is Pilot and the rest are crew`() { val req = FormRequestBuilder.build( airport = "lfrm", formId = "customs", @@ -87,7 +89,7 @@ class FormRequestBuilderTest { documentFor = { doc("FRA") }, ) assertEquals("LFRM", req.airport) - assertEquals(listOf("PIC", "Crew"), req.crew.map { it.function }) + assertEquals(listOf("Pilot", "Crew"), req.crew.map { it.function }) assertNull(req.passengers.single().function) assertEquals("Marchand", req.passengers.single().lastName) } @@ -100,18 +102,87 @@ class FormRequestBuilderTest { assertNull(p.usualBase) } + private val anna = PersonEntity( + firstName = "Anna", lastName = "Eriksson", + phone = "+33 6 00 00 00 00", email = "anna@example.test", address = "1 Rue de Test", + ) + + @Test + fun `the responsible person names the contact and fills phone and e-mail`() { + val extras = FormRequestBuilder.extraFields( + entered = emptyMap(), + reasonForVisit = "Maintenance", + responsiblePerson = anna, + ) + assertEquals(ExtraFieldValue.Text("Maintenance"), extras["reason_for_visit"]) + assertEquals( + ExtraFieldValue.Person(mapOf("name" to "Anna Eriksson", "address" to "1 Rue de Test")), + extras["responsible_person"], + ) + assertEquals(ExtraFieldValue.Text("+33 6 00 00 00 00"), extras["telephone"]) + assertEquals(ExtraFieldValue.Text("anna@example.test"), extras["email"]) + + val req = FormRequestBuilder.build( + airport = "LFRM", formId = "customs", flight = flight, aircraft = aircraft, + crew = emptyList(), passengers = emptyList(), documentFor = { null }, + responsiblePerson = anna, extraFields = extras, + ) + assertEquals("Anna Eriksson", req.flight.contact) + } + + @Test + fun `a telephone typed for the form wins over the responsible person's`() { + val extras = FormRequestBuilder.extraFields( + entered = mapOf("telephone" to ExtraFieldValue.Text("+44 20 0000 0000")), + reasonForVisit = null, + responsiblePerson = anna, + ) + assertEquals(ExtraFieldValue.Text("+44 20 0000 0000"), extras["telephone"]) + assertNull(extras["reason_for_visit"]) + } + + @Test + fun `without a responsible person the stored contact is sent`() { + val req = FormRequestBuilder.build( + airport = "LFRM", formId = "customs", flight = flight, aircraft = aircraft, + crew = emptyList(), passengers = emptyList(), documentFor = { null }, + ) + assertEquals("Anna", req.flight.contact) + assertNull(req.extraFields) + } + @Test - fun `direction is derived from which end the airport is`() { - assertEquals("departure", FormRequestBuilder.directionFor("EGTF", flight)) - assertEquals("arrival", FormRequestBuilder.directionFor("lfrm", flight)) - assertNull(FormRequestBuilder.directionFor("LSGS", flight)) + fun `an untouched choice sends the option it shows`() { + val fields = listOf( + ExtraFieldInfo(key = "purpose", label = "Purpose", type = "choice", options = listOf("Tourism", "Business")), + ExtraFieldInfo(key = "landing", label = "Landing", type = "choice", options = listOf("A", "B")), + ExtraFieldInfo(key = "reason_for_visit", label = "Reason", type = "choice", options = listOf("Based")), + ExtraFieldInfo(key = "notes", label = "Notes", type = "text"), + ) + val extras = FormRequestBuilder.withChoiceDefaults(fields, mapOf("landing" to ExtraFieldValue.Text("B"))) + assertEquals( + mapOf("landing" to ExtraFieldValue.Text("B"), "purpose" to ExtraFieldValue.Text("Tourism")), + extras, + ) } @Test - fun `a local flight counts as arrival at its own airport`() { - // origin == destination: the destination branch wins, matching the - // server's own ordering. - val local = flight.copy(destinationICAO = "EGTF") - assertEquals("arrival", FormRequestBuilder.directionFor("EGTF", local)) + fun `connecting and return legs go on the wire`() { + val onward = flight.copy( + id = "onward", originICAO = "LFRM", destinationICAO = "LSGS", + departureInstant = Instant.parse("2026-09-22T09:00:00Z"), + arrivalInstant = Instant.parse("2026-09-22T10:30:00Z"), + ) + val req = FormRequestBuilder.build( + airport = "LFRM", formId = "customs", flight = flight, aircraft = aircraft, + crew = emptyList(), passengers = emptyList(), documentFor = { null }, + connectingFlight = onward to "Bo Lindqvist", + returnFlight = FormRequestBuilder.returnFlightPayload(onward, peopleOnBoard = 3), + ) + assertEquals("LSGS", req.connectingFlight?.destination) + assertEquals("09:00", req.connectingFlight?.departureTimeUtc) + assertEquals("Bo Lindqvist", req.connectingFlight?.contact) + assertEquals(3, req.returnFlight?.peopleOnBoard) + assertEquals("2026-09-22", req.returnFlight?.arrivalDate) } } diff --git a/app/android/app/src/test/kotlin/aero/flyfun/forms/net/ApiTypesTest.kt b/app/android/app/src/test/kotlin/aero/flyfun/forms/net/ApiTypesTest.kt index 69804dc..8392224 100644 --- a/app/android/app/src/test/kotlin/aero/flyfun/forms/net/ApiTypesTest.kt +++ b/app/android/app/src/test/kotlin/aero/flyfun/forms/net/ApiTypesTest.kt @@ -56,7 +56,7 @@ class ApiTypesTest { @Test fun `PersonPayload encodes every snake_case field`() { val p = PersonPayload( - function = "PIC", + function = "Pilot", firstName = "Anna", lastName = "Eriksson", dob = "1974-08-12", diff --git a/app/android/core-logic/src/main/kotlin/aero/flyfun/forms/logic/EmailText.kt b/app/android/core-logic/src/main/kotlin/aero/flyfun/forms/logic/EmailText.kt new file mode 100644 index 0000000..f74b260 --- /dev/null +++ b/app/android/core-logic/src/main/kotlin/aero/flyfun/forms/logic/EmailText.kt @@ -0,0 +1,63 @@ +package aero.flyfun.forms.logic + +/** + * The languages a pilot may say they speak beyond English, and how that picks + * the language of a form's covering e-mail. Port of iOS `SpokenLanguage`, + * `SpokenLanguageStorage` and the body choice in `generateAndEmail`. + */ +object SpokenLanguages { + + /** ISO 639-1, matching the server's `local_language`, with each language's own name. */ + val ALL: List> = listOf( + "fr" to "Français", + "de" to "Deutsch", + "it" to "Italiano", + "es" to "Español", + "pt" to "Português", + "nl" to "Nederlands", + ) + + /** Comma-separated, as iOS stores `spokenLanguageCodes`. */ + fun parse(stored: String?): Set = + stored.orEmpty().split(',').map { it.trim() }.filter { it.isNotEmpty() }.toSet() + + fun serialize(codes: Set): String = codes.sorted().joinToString(",") +} + +object EmailText { + + data class Message(val subject: String, val body: String) + + /** + * The server's text, in the airport's language when the pilot speaks it + * and English otherwise. The local subject is used either way, as on + * iOS: it is codes and dates, and some airports mandate it word for word. + */ + fun choose( + localLanguage: String?, + spoken: Set, + subjectEn: String, + subjectLocal: String, + bodyEn: String, + bodyLocal: String, + ): Message { + val speaksLocal = !localLanguage.isNullOrBlank() && localLanguage in spoken + return Message( + subject = subjectLocal.ifBlank { subjectEn }, + body = if (speaksLocal) bodyLocal.ifBlank { bodyEn } else bodyEn, + ) + } + + /** When `/email-text` cannot be reached. Same wording as iOS `emailSubject` / `emailBody`. */ + fun fallback( + formLabel: String, + origin: String, + destination: String, + departureDate: String, + registration: String, + ): Message = Message( + subject = "$formLabel - $destination - $departureDate - $registration", + body = "Please find attached the $formLabel for flight $origin → $destination " + + "on $departureDate, aircraft $registration.", + ) +} diff --git a/app/android/core-logic/src/main/kotlin/aero/flyfun/forms/logic/FlightLegs.kt b/app/android/core-logic/src/main/kotlin/aero/flyfun/forms/logic/FlightLegs.kt new file mode 100644 index 0000000..a9c96f4 --- /dev/null +++ b/app/android/core-logic/src/main/kotlin/aero/flyfun/forms/logic/FlightLegs.kt @@ -0,0 +1,90 @@ +package aero.flyfun.forms.logic + +import java.time.Duration +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneOffset + +/** A flight reduced to what leg matching needs; the app maps its entities onto this. */ +data class Leg( + val id: String, + val origin: String, + val destination: String, + val departure: Instant, + val arrival: Instant, +) + +/** + * Finds the other flights a form asks about. Port of the connecting- and + * return-flight lookups in iOS `FlightEditView.buildRequest`. + * + * Legs are matched by time and airport alone, with no trip linkage required: + * pilots rarely group legs into trips, and the forms only care what flew + * through the airport either side of this one. + */ +object FlightLegs { + + /** How far either side of this flight a related leg may be. */ + val WINDOW: Duration = Duration.ofDays(14) + + /** + * The leg connecting with [current] at [airport]. + * + * Arriving there, it is the next flight departing from it; leaving from + * there, it is the previous flight that landed there. A local flight + * counts as arriving, as on iOS. + */ + fun connecting(current: Leg, airport: String, all: List): Leg? { + val icao = airport.icao() + val nearby = all + .filter { it.id != current.id && Duration.between(current.departure, it.departure).abs() < WINDOW } + .sortedBy { it.departure } + return if (icao == current.destination.icao()) { + nearby.firstOrNull { it.departure >= current.departure && it.origin.icao() == icao } + } else { + nearby.lastOrNull { it.departure <= current.departure && it.destination.icao() == icao } + } + } + + /** + * For forms that ask when you will be back (book-outs): the first later + * flight landing at [airport] again, however many legs away. Only asked + * of the departure airport. A local flight is its own return. + */ + fun returning(current: Leg, airport: String, all: List): Leg? { + val icao = airport.icao() + if (icao != current.origin.icao()) return null + if (current.destination.icao() == icao) return current + return all + .filter { + it.id != current.id && + it.departure > current.departure && + Duration.between(current.departure, it.departure) < WINDOW && + it.destination.icao() == icao + } + .minByOrNull { it.departure } + } + + /** + * Where the arrival goes when the departure moves from [oldDeparture] to + * [newDeparture]. Port of iOS `autoSyncArrivalDate`. + * + * The arrival day follows the departure day only while the pilot has not + * moved it off that day themselves, and only the day moves: a leg landing + * at 21:30 stays at 21:30. Compared in UTC, the day it is filed under. + */ + fun arrivalFollowing(oldDeparture: Instant, newDeparture: Instant, arrival: Instant): Instant { + if (arrival.utcDay() != oldDeparture.utcDay()) return arrival + return alignUtcDay(arrival, newDeparture) + } + + /** [instant]'s UTC time of day on [reference]'s UTC day. Port of `Flight.alignUTCDay`. */ + fun alignUtcDay(instant: Instant, reference: Instant): Instant { + val time = instant.atOffset(ZoneOffset.UTC).toLocalTime().withSecond(0).withNano(0) + return reference.utcDay().atTime(time).toInstant(ZoneOffset.UTC) + } + + private fun Instant.utcDay(): LocalDate = atOffset(ZoneOffset.UTC).toLocalDate() + + private fun String.icao() = trim().uppercase() +} diff --git a/app/android/core-logic/src/main/kotlin/aero/flyfun/forms/logic/FormSides.kt b/app/android/core-logic/src/main/kotlin/aero/flyfun/forms/logic/FormSides.kt new file mode 100644 index 0000000..78419b9 --- /dev/null +++ b/app/android/core-logic/src/main/kotlin/aero/flyfun/forms/logic/FormSides.kt @@ -0,0 +1,54 @@ +package aero.flyfun.forms.logic + +/** + * Which ends of a flight get a forms section, and which forms each one shows. + * + * Port of `FlightEditView.formSection` / `hasForms`. A local flight (origin = + * destination) has two sections for the one airport: its arrival forms and its + * departure forms are different paperwork, and folding them into one section + * lost the departure side entirely. + */ +object FormSides { + + const val ARRIVAL = "arrival" + const val DEPARTURE = "departure" + + data class Side(val icao: String, val direction: String) + + /** Arrival first, then departure, as on iOS. Blank or partial ICAO codes are skipped. */ + fun of(origin: String, destination: String): List = listOfNotNull( + destination.normalisedIcao()?.let { Side(it, ARRIVAL) }, + origin.normalisedIcao()?.let { Side(it, DEPARTURE) }, + ) + + /** + * The forms one side shows. + * + * Only web forms (book-out, PPR) are filtered by direction; a form with no + * direction applies to either side. Document forms always show: iOS does + * not filter them, and a customs form tagged "arrival" at the origin is + * still the one the pilot may need to file for a local flight. + */ + fun applicable( + forms: List, + direction: String, + isWeb: (T) -> Boolean, + directionOf: (T) -> String?, + ): List = forms.filter { !isWeb(it) || (directionOf(it) ?: direction) == direction } + + /** One side's forms in the order they are offered. */ + data class Grouped(val primary: T?, val web: List, val others: List) + + /** + * The primary form - the server lists the one to file first, e.g. the + * customs notice the AIP names - then the airport's web forms, then any + * other document forms, which the screen folds away. Port of iOS + * `formSection`. + */ + fun group(forms: List, isWeb: (T) -> Boolean): Grouped { + val documents = forms.filterNot(isWeb) + return Grouped(primary = documents.firstOrNull(), web = forms.filter(isWeb), others = documents.drop(1)) + } + + private fun String.normalisedIcao(): String? = trim().uppercase().takeIf { it.length == 4 } +} diff --git a/app/android/core-logic/src/main/kotlin/aero/flyfun/forms/logic/TripExtras.kt b/app/android/core-logic/src/main/kotlin/aero/flyfun/forms/logic/TripExtras.kt new file mode 100644 index 0000000..1f3a26f --- /dev/null +++ b/app/android/core-logic/src/main/kotlin/aero/flyfun/forms/logic/TripExtras.kt @@ -0,0 +1,26 @@ +package aero.flyfun.forms.logic + +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json + +/** + * A trip's form extras: stored as a JSON-encoded map on the Room row, as iOS + * stores `Trip.extraFieldsData`, and carried as a plain map in the interchange + * file. + */ +object TripExtras { + + private val serializer = MapSerializer(String.serializer(), String.serializer()) + private val json = Json { ignoreUnknownKeys = true } + + /** Unreadable JSON reads as empty, as `Trip.extraFields` does on iOS. */ + fun decode(stored: String?): Map { + if (stored.isNullOrBlank()) return emptyMap() + return runCatching { json.decodeFromString(serializer, stored) }.getOrDefault(emptyMap()) + } + + /** Null for an empty map, so a trip without extras keeps a null column. */ + fun encode(extras: Map): String? = + if (extras.isEmpty()) null else json.encodeToString(serializer, extras) +} diff --git a/app/android/core-logic/src/test/kotlin/aero/flyfun/forms/logic/EmailTextTest.kt b/app/android/core-logic/src/test/kotlin/aero/flyfun/forms/logic/EmailTextTest.kt new file mode 100644 index 0000000..f04f194 --- /dev/null +++ b/app/android/core-logic/src/test/kotlin/aero/flyfun/forms/logic/EmailTextTest.kt @@ -0,0 +1,44 @@ +package aero.flyfun.forms.logic + +import org.junit.Assert.assertEquals +import org.junit.Test + +class EmailTextTest { + + @Test + fun `spoken languages round trip in the iOS storage format`() { + assertEquals("de,fr", SpokenLanguages.serialize(setOf("fr", "de"))) + assertEquals(setOf("fr", "de"), SpokenLanguages.parse(" fr, de ,")) + assertEquals(emptySet(), SpokenLanguages.parse(null)) + } + + @Test + fun `the body is local when the pilot speaks the airport's language`() { + val message = EmailText.choose("fr", setOf("fr"), "LFOH", "ppf le havre octeville", "Hello", "Bonjour") + assertEquals(EmailText.Message("ppf le havre octeville", "Bonjour"), message) + } + + @Test + fun `otherwise the body is English and the subject still local`() { + assertEquals( + EmailText.Message("LFOH 2026-10-01", "Hello"), + EmailText.choose("fr", setOf("de"), "en", "LFOH 2026-10-01", "Hello", "Bonjour"), + ) + assertEquals("Hello", EmailText.choose(null, setOf("fr"), "s", "s", "Hello", "Bonjour").body) + } + + @Test + fun `an empty local subject falls back to the English one`() { + assertEquals("Customs - LFOH", EmailText.choose(null, emptySet(), "Customs - LFOH", "", "b", "").subject) + } + + @Test + fun `the fallback reads like the iOS one`() { + val message = EmailText.fallback("Préavis Douane", "EGTF", "LFOH", "2026-10-01", "ZZ-TEST") + assertEquals("Préavis Douane - LFOH - 2026-10-01 - ZZ-TEST", message.subject) + assertEquals( + "Please find attached the Préavis Douane for flight EGTF → LFOH on 2026-10-01, aircraft ZZ-TEST.", + message.body, + ) + } +} diff --git a/app/android/core-logic/src/test/kotlin/aero/flyfun/forms/logic/FlightLegsTest.kt b/app/android/core-logic/src/test/kotlin/aero/flyfun/forms/logic/FlightLegsTest.kt new file mode 100644 index 0000000..b0cc391 --- /dev/null +++ b/app/android/core-logic/src/test/kotlin/aero/flyfun/forms/logic/FlightLegsTest.kt @@ -0,0 +1,113 @@ +package aero.flyfun.forms.logic + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.time.Instant + +class FlightLegsTest { + + private fun leg(id: String, from: String, to: String, day: Int, hour: Int = 9) = Leg( + id = id, + origin = from, + destination = to, + departure = Instant.parse("2026-10-%02dT%02d:00:00Z".format(day, hour)), + arrival = Instant.parse("2026-10-%02dT%02d:00:00Z".format(day, hour + 2)), + ) + + private val out = leg("out", "EGTF", "LFAC", day = 10) + + @Test + fun `arriving, the connection is the next departure from that airport`() { + val onward = leg("onward", "LFAC", "LFRM", day = 12) + val later = leg("later", "LFAC", "LSGS", day = 15) + val elsewhere = leg("elsewhere", "EGTF", "EGKA", day = 11) + assertEquals(onward, FlightLegs.connecting(out, "LFAC", listOf(later, elsewhere, onward, out))) + } + + @Test + fun `leaving, the connection is the previous arrival at that airport`() { + val earlier = leg("earlier", "EGKA", "EGTF", day = 2) + val inbound = leg("inbound", "EGLL", "EGTF", day = 8) + assertEquals(inbound, FlightLegs.connecting(out, "egtf", listOf(earlier, inbound, out))) + } + + @Test + fun `nothing connects beyond two weeks`() { + val tooLate = leg("late", "LFAC", "LFRM", day = 25) + assertNull(FlightLegs.connecting(out, "LFAC", listOf(tooLate))) + } + + @Test + fun `the flight never connects with itself`() { + assertNull(FlightLegs.connecting(out, "LFAC", listOf(out))) + } + + @Test + fun `a local flight connects as an arrival`() { + val local = leg("local", "LFAC", "LFAC", day = 10) + val before = leg("before", "EGTF", "LFAC", day = 9) + val after = leg("after", "LFAC", "EGTF", day = 11) + assertEquals(after, FlightLegs.connecting(local, "LFAC", listOf(before, after))) + } + + @Test + fun `the return is the first later landing back at the departure airport`() { + val hop = leg("hop", "LFAC", "LFRM", day = 12) + val back = leg("back", "LFRM", "EGTF", day = 14) + val backAgain = leg("again", "LFAC", "EGTF", day = 20) + assertEquals(back, FlightLegs.returning(out, "EGTF", listOf(backAgain, hop, back, out))) + } + + @Test + fun `only the departure airport has a return`() { + val back = leg("back", "LFAC", "EGTF", day = 12) + assertNull(FlightLegs.returning(out, "LFAC", listOf(back))) + } + + @Test + fun `a local flight is its own return`() { + val local = leg("local", "EGTF", "EGTF", day = 10) + assertEquals(local, FlightLegs.returning(local, "EGTF", emptyList())) + } + + @Test + fun `no return within two weeks is no return`() { + val muchLater = leg("later", "LFAC", "EGTF", day = 30) + assertNull(FlightLegs.returning(out, "EGTF", listOf(muchLater))) + } + + private fun at(s: String) = Instant.parse(s) + + @Test + fun `an arrival on the departure day moves with it and keeps its time`() { + assertEquals( + at("2026-10-12T21:30:00Z"), + FlightLegs.arrivalFollowing( + oldDeparture = at("2026-10-10T20:00:00Z"), + newDeparture = at("2026-10-12T08:00:00Z"), + arrival = at("2026-10-10T21:30:00Z"), + ), + ) + } + + @Test + fun `an arrival the pilot put on another day stays put`() { + val overnight = at("2026-10-11T01:10:00Z") + assertEquals( + overnight, + FlightLegs.arrivalFollowing(at("2026-10-10T22:00:00Z"), at("2026-10-12T22:00:00Z"), overnight), + ) + } + + @Test + fun `days are compared in UTC`() { + // Half an hour after departure, but on the next UTC day - the day the + // forms file it under - so it counts as moved off the departure day. + val arrival = at("2026-10-11T00:30:00Z") + assertEquals( + arrival, + FlightLegs.arrivalFollowing(at("2026-10-10T23:30:00Z"), at("2026-10-13T23:30:00Z"), arrival), + ) + } +} diff --git a/app/android/core-logic/src/test/kotlin/aero/flyfun/forms/logic/FormSidesTest.kt b/app/android/core-logic/src/test/kotlin/aero/flyfun/forms/logic/FormSidesTest.kt new file mode 100644 index 0000000..7b1b47a --- /dev/null +++ b/app/android/core-logic/src/test/kotlin/aero/flyfun/forms/logic/FormSidesTest.kt @@ -0,0 +1,71 @@ +package aero.flyfun.forms.logic + +import aero.flyfun.forms.logic.FormSides.ARRIVAL +import aero.flyfun.forms.logic.FormSides.DEPARTURE +import aero.flyfun.forms.logic.FormSides.Side +import org.junit.Assert.assertEquals +import org.junit.Test + +class FormSidesTest { + + private data class Form(val id: String, val web: Boolean = false, val direction: String? = null) + + private fun applicable(forms: List
, direction: String) = + FormSides.applicable(forms, direction, { it.web }, { it.direction }).map { it.id } + + @Test + fun `arrival comes before departure`() { + assertEquals( + listOf(Side("LFPB", ARRIVAL), Side("EGTF", DEPARTURE)), + FormSides.of(origin = "EGTF", destination = "LFPB"), + ) + } + + @Test + fun `a local flight keeps both sides of the one airport`() { + assertEquals( + listOf(Side("LFAC", ARRIVAL), Side("LFAC", DEPARTURE)), + FormSides.of(origin = "LFAC", destination = "lfac "), + ) + } + + @Test + fun `blank and partial codes have no side`() { + assertEquals(listOf(Side("EGTF", DEPARTURE)), FormSides.of(origin = "EGTF", destination = "LF")) + assertEquals(emptyList(), FormSides.of(origin = "", destination = "")) + } + + @Test + fun `document forms show on both sides whatever their direction`() { + val forms = listOf(Form("customs", direction = ARRIVAL), Form("gendec")) + assertEquals(listOf("customs", "gendec"), applicable(forms, DEPARTURE)) + assertEquals(listOf("customs", "gendec"), applicable(forms, ARRIVAL)) + } + + @Test + fun `web forms show only on the side they cover`() { + val forms = listOf( + Form("bookout", web = true, direction = DEPARTURE), + Form("ppr", web = true, direction = ARRIVAL), + Form("either", web = true), + ) + assertEquals(listOf("bookout", "either"), applicable(forms, DEPARTURE)) + assertEquals(listOf("ppr", "either"), applicable(forms, ARRIVAL)) + } + + @Test + fun `the first document form is primary, then web forms, then the rest`() { + val forms = listOf(Form("customs"), Form("bookout", web = true), Form("gendec"), Form("handling")) + val grouped = FormSides.group(forms) { it.web } + assertEquals("customs", grouped.primary?.id) + assertEquals(listOf("bookout"), grouped.web.map { it.id }) + assertEquals(listOf("gendec", "handling"), grouped.others.map { it.id }) + } + + @Test + fun `web forms alone have no primary`() { + val grouped = FormSides.group(listOf(Form("ppr", web = true))) { it.web } + assertEquals(null, grouped.primary) + assertEquals(emptyList(), grouped.others) + } +} diff --git a/app/android/core-logic/src/test/kotlin/aero/flyfun/forms/logic/TripExtrasTest.kt b/app/android/core-logic/src/test/kotlin/aero/flyfun/forms/logic/TripExtrasTest.kt new file mode 100644 index 0000000..035f558 --- /dev/null +++ b/app/android/core-logic/src/test/kotlin/aero/flyfun/forms/logic/TripExtrasTest.kt @@ -0,0 +1,31 @@ +package aero.flyfun.forms.logic + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class TripExtrasTest { + + @Test + fun `round trips a map`() { + val extras = mapOf("reason_for_visit" to "Maintenance", "telephone" to "+44 1234") + assertEquals(extras, TripExtras.decode(TripExtras.encode(extras))) + } + + @Test + fun `empty map is stored as null`() { + assertNull(TripExtras.encode(emptyMap())) + } + + @Test + fun `null, blank and unreadable columns read as empty`() { + assertEquals(emptyMap(), TripExtras.decode(null)) + assertEquals(emptyMap(), TripExtras.decode("")) + assertEquals(emptyMap(), TripExtras.decode("not json")) + } + + @Test + fun `reads what iOS JSONEncoder writes`() { + assertEquals(mapOf("a" to "b"), TripExtras.decode("""{"a":"b"}""")) + } +} diff --git a/designs/future/android-parity.md b/designs/future/android-parity.md index 82ee51d..f642210 100644 --- a/designs/future/android-parity.md +++ b/designs/future/android-parity.md @@ -22,7 +22,7 @@ Gap survey of 2026-09-26, verified against the code. Size: S small, M medium, L | PR | Issue | Scope | Sections | State | |---|---|---|---|---| -| 1 | #29 | Forms come out right and complete | §4 (1a–1e) | Not started | +| 1 | #29 | Forms come out right and complete | §4 (1a–1e) | Code complete; emulator run owed (§8) | | 2 | #30 | Getting data in fast | §5 (2a–2e) | Not started — after PR 1 | | 3 | #31 | Platform and integrations | §6 (3a–3d) | Not started — after PR 2 | | — | — | Blocked follow-ups | §7 | Blocked (G4, translator, device) | @@ -77,44 +77,44 @@ and Settings, which is why it is one PR. ### 1a — Correctness fixes (S, Sonnet) -- [ ] First crew member sent as function `"PIC"`; iOS sends `"Pilot"`, printed on gendec/LSGS. `data/FormRequestBuilder.kt:93` vs `Views/FlightEditView.swift:827` -- [ ] Local flight (origin = destination) hides departure-side forms: `directionFor` returns arrival first and the airport list is `distinct()`. Android also filters *document* forms by direction; iOS filters only web forms (`FlightEditView.swift:421-422`) -- [ ] Sign-in `pendingState` held in memory only (`auth/AuthService.kt:33`) — fails after process death while the Custom Tab is open. Persist with a short TTL -- [ ] No way to sign in after "Enter data without signing in"; a 401 never clears the token or routes to sign-in -- [ ] + saves a blank flight immediately; backing out leaves a "???? → ????" row. Create on first save instead -- [ ] Flight edits lost on Back with no prompt; schedule/aircraft save immediately but route/observations only on Save. Make it consistent -- [ ] `ui/webform/WebFormScreen.kt`: `BackHandler` (WebView history, then close); clear cookies on exit (iOS uses a non-persistent store) -- [ ] Generated PDFs in `cacheDir/forms` carry passport data and are never deleted — clear after share and on start -- [ ] Aircraft `FilterChip` row doesn't scroll; people chips grow unbounded -- [ ] Move my data drops trip extra fields: `data/DataTransfer.kt:160-168` (`TripRecord.toEntity` / `toRecord`) -- [ ] `ui/aircraft/AircraftScreens.kt` lacks `verticalScroll` — fields go under the keyboard +- [x] First crew member sent as function `"PIC"`; iOS sends `"Pilot"`, printed on gendec/LSGS. `data/FormRequestBuilder.kt:93` vs `Views/FlightEditView.swift:827` +- [x] Local flight (origin = destination) hides departure-side forms: `directionFor` returns arrival first and the airport list is `distinct()`. Android also filters *document* forms by direction; iOS filters only web forms (`FlightEditView.swift:421-422`) +- [x] Sign-in `pendingState` held in memory only (`auth/AuthService.kt:33`) — fails after process death while the Custom Tab is open. Persist with a short TTL +- [x] No way to sign in after "Enter data without signing in"; a 401 never clears the token or routes to sign-in +- [x] + saves a blank flight immediately; backing out leaves a "???? → ????" row. Create on first save instead +- [x] Flight edits lost on Back with no prompt; schedule/aircraft save immediately but route/observations only on Save. Make it consistent +- [x] `ui/webform/WebFormScreen.kt`: `BackHandler` (WebView history, then close); clear cookies on exit (iOS uses a non-persistent store) +- [x] Generated PDFs in `cacheDir/forms` carry passport data and are never deleted — clear after share and on start +- [x] Aircraft `FilterChip` row doesn't scroll; people chips grow unbounded +- [x] Move my data drops trip extra fields: `data/DataTransfer.kt:160-168` (`TripRecord.toEntity` / `toRecord`) +- [x] `ui/aircraft/AircraftScreens.kt` lacks `verticalScroll` — fields go under the keyboard ### 1b — Delete account; delete with Undo (S, Sonnet) -- [ ] Delete account in Settings → `DELETE /auth/account` (iOS `ContentView.swift:215-257`). **Play Store requirement** -- [ ] Swipe-to-delete + Undo on people, aircraft, flights (VM `delete` exists, unused). iOS `PeopleListView.swift:68`, `FlightsListView.swift:42,53` -- [ ] Delete in each edit screen's overflow menu +- [x] Delete account in Settings → `DELETE /auth/account` (iOS `ContentView.swift:215-257`). **Play Store requirement** +- [x] Swipe-to-delete + Undo on people, aircraft, flights (VM `delete` exists, unused). iOS `PeopleListView.swift:68`, `FlightsListView.swift:42,53` +- [x] Delete in each edit screen's overflow menu ### 1c — Flight fields the forms depend on (M, Opus for logic, Sonnet for UI) Entity already has `nature`, `reasonForVisit`, `responsiblePersonId` — no migration. -- [ ] Nature (private/commercial) and Reason for Visit picker (`FlightEditView.swift:264-272`) -- [ ] Responsible person picker; sets `contact`, auto-fills telephone/email extras (`:273-294`, `:815-829`) -- [ ] Connecting flight (nearest leg ≤14 days through this airport) and return flight (`has_return_flight`) (`:856-921`) — pure functions in `FormRequestBuilder`, unit-tested -- [ ] Per-form extra fields UI (choice / person / text) from `FormInfo.extraFields` (`:512-570`); `FormRequestBuilder.build` must send them. Needed for LSGS and book-out forms to fill fully +- [x] Nature (private/commercial) and Reason for Visit picker (`FlightEditView.swift:264-272`) +- [x] Responsible person picker; sets `contact`, auto-fills telephone/email extras (`:273-294`, `:815-829`) +- [x] Connecting flight (nearest leg ≤14 days through this airport) and return flight (`has_return_flight`) (`:856-921`) — pure functions in `FormRequestBuilder`, unit-tested +- [x] Per-form extra fields UI (choice / person / text) from `FormInfo.extraFields` (`:512-570`); `FormRequestBuilder.build` must send them. Needed for LSGS and book-out forms to fill fully ### 1d — Leg actions and schedule sync (S, Sonnet) -- [ ] Return Flight / Next Leg / Duplicate (`FlightEditView.swift:361-376`, `:996-1034`) -- [ ] Arrival date follows departure (`autoSyncArrivalDate`, `:408`) -- [ ] Past flights collapsible, registration on the row (`FlightsListView.swift:49-117`) +- [x] Return Flight / Next Leg / Duplicate (`FlightEditView.swift:361-376`, `:996-1034`) +- [x] Arrival date follows departure (`autoSyncArrivalDate`, `:408`) +- [x] Past flights collapsible, registration on the row (`FlightsListView.swift:49-117`) ### 1e — Email export and form grouping (M, Sonnet) -- [ ] Email: call `/email-text` (in `net/FormsApi.kt`, unused), to/cc from `email`/`send_to`, body in a spoken language (`FlightEditView.swift:702-795`). Check iOS #26 (AIP customs e-mails, form to e-mail first) first -- [ ] "Languages you speak" setting in DataStore (`ContentView.swift:104-190`) -- [ ] Primary form, then web forms, then collapsed "Other forms" (`FlightEditView.swift:418-443`) +- [x] Email: call `/email-text` (in `net/FormsApi.kt`, unused), to/cc from `email`/`send_to`, body in a spoken language (`FlightEditView.swift:702-795`). Check iOS #26 (AIP customs e-mails, form to e-mail first) first +- [x] "Languages you speak" setting in DataStore (`ContentView.swift:104-190`) +- [x] Primary form, then web forms, then collapsed "Other forms" (`FlightEditView.swift:418-443`) --- @@ -203,3 +203,21 @@ Newest last. One line per decision: date, section, what was decided, why. - 2026-09-26 — branches — `android-app` retired; parity work lands on `main` through per-PR branches. - 2026-09-26 — survey — iOS `1173620` (per-flight document choice, CSV name+DOB matching) added to 2a after the survey. - 2026-09-26 — pre-work — `PeopleViewModel.applyScan` upserts by document number on the same person only; cross-person duplicates are left to 2b. +- 2026-09-26 — 1a — The flight editor edits a draft held by the ViewModel; Save stores the flight, crew and passengers together, and Back with unsaved changes asks Save / Discard. iOS autosaves through SwiftData; a draft is the Android idiom for a screen that already had a Save button, and it is what makes "create on first save" possible. +- 2026-09-26 — 1a — + opens `flight/new`, a draft that exists only in the ViewModel until saved. Backing out of an untouched one asks nothing and stores nothing. +- 2026-09-26 — 1a — Generated forms and data exports share `cacheDir/forms`, cleared on every cold start and, on returning to the app, of anything older than 15 minutes. Deleting right after the share intent would break mail apps that read the attachment later. +- 2026-09-26 — 1a — The OAuth `state` nonce lives in app-private SharedPreferences with a 10-minute TTL. It is a one-use anti-forgery value, not a credential. +- 2026-09-26 — 1a — A 401 to a request that carried a token clears it; the UI observes `TokenStore.signedIn` and returns to sign-in. The nav controller sits above the sign-in screen so a flight draft survives signing in again. Settings offers "Sign in" after "Enter data without signing in". +- 2026-09-26 — 1a — Pure logic this PR needs (`FormSides`, `TripExtras`, and later sections' leg and e-mail helpers) goes in `:core-logic`, so it is tested on the JVM without the Android SDK. +- 2026-09-26 — 1a — Built in a cloud session with no route to Google Maven (`dl.google.com`), so `./gradlew :app:…` could not run and nothing was driven on the emulator. Checked instead by compiling `app/src/main` against Compose Multiplatform 1.6 desktop plus stubs for the Android APIs, and running `app/src/test` and `:core-logic:test` on the JVM. Emulator runs are still owed before merge. +- 2026-09-26 — 1b — Deletes run on the app's scope against the repositories, with one app-level Snackbar for Undo. A delete from an edit screen pops the screen, and a ViewModel-scoped Undo would be cancelled with it. Restore clears the tombstone and bumps `updatedAt`, so the restore wins a later merge. +- 2026-09-26 — 1b — List rows are keyed on id + `updatedAt`, so a restored row gets fresh swipe state instead of coming back already swiped away. +- 2026-09-26 — 1b — Delete account keeps people, aircraft and flights on the device: they were never on the server. +- 2026-09-26 — 1c — Connecting and return legs are found by `FlightLegs` in `:core-logic` over a plain `Leg`, matching the iOS rules: 14 days either side, by airport, no trip linkage; a local flight connects as an arrival and is its own return. Forms are built from the draft, and other legs as stored. +- 2026-09-26 — 1c — An untouched choice extra field sends the option it shows. iOS shows the first option but sends nothing until it is changed, which the server rejects as missing when the field is required. Deliberate departure; the iOS side needs the same fix. +- 2026-09-26 — 1c — Per-form extra values live in the flight screen's ViewModel only, keyed by airport + form as on iOS, and are not stored. +- 2026-09-26 — 1d — Return / Next leg / Duplicate store the current flight, then open the new leg as a draft marked unsaved, so Back asks before dropping it. iOS inserts the new leg straight away. The leg actions sit in an Actions section at the foot of the editor, as on the iPhone; Delete stays in the overflow menu. +- 2026-09-26 — 1d — Upcoming flights stay sorted soonest first (iOS sorts newest first); past flights are newest first, collapsed by default. +- 2026-09-26 — 1e — "Languages you speak" is stored in SharedPreferences under the iOS key `spokenLanguageCodes`, same comma-separated format, rather than DataStore: one string does not justify a new dependency. +- 2026-09-26 — 1e — E-mail is `ACTION_SEND` with a `mailto:` selector and the file as `EXTRA_STREAM` + ClipData, falling back to the share sheet when no mail app answers (iOS falls back the same way without a mail account). The subject is the server's local subject, or the English one when that is empty; the body is local only when the pilot speaks the airport's language. #26 needed nothing client-side: the server already lists the form to e-mail first, and the primary form is simply the first document form. +- 2026-09-26 — 1e — Document forms keep their "Generate" button (file, then a Share dialog) next to the new "Email"; iOS's "Share" opens the share sheet directly.