From bf2af5b037e1eb820b53f50c626ed802fedc94a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 07:32:18 +0000 Subject: [PATCH 1/7] fix(android): 1a - forms come out right, and edits and sign-in stop getting lost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness fixes from the iOS parity survey (designs/future/android-parity.md §4 1a): - The first crew member is sent as "Pilot", as iOS does; "PIC" was being printed on the gendec and LSGS forms. - A local flight shows both its arrival and its departure forms. Only web forms are filtered by direction; document forms show on both sides. - The flight editor edits a draft: Save stores flight, crew and passengers together, Back with changes asks Save / Discard, and + no longer leaves a "???? -> ????" row when abandoned. - The OAuth state nonce survives process death (10-minute TTL). A 401 clears the token and returns to sign-in, and Settings offers sign-in after skipping it. - Web forms: Back walks the page history first; cookies, storage and cache are cleared on the way out. - Generated forms and exports are cleared on start and, 15 minutes after a share, on returning to the app. - Move my data keeps trip extra fields; aircraft chips scroll, people chips wrap, and the aircraft editor scrolls above the keyboard. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011pXA3ei7MugJjuxWmZ12Y2 --- .../flyfun/forms/data/DataTransferTest.kt | 18 ++ .../kotlin/aero/flyfun/forms/MainActivity.kt | 24 ++- .../aero/flyfun/forms/auth/AuthService.kt | 36 +++- .../aero/flyfun/forms/auth/TokenStore.kt | 26 ++- .../aero/flyfun/forms/data/DataTransfer.kt | 4 +- .../aero/flyfun/forms/data/FormFiles.kt | 31 +++ .../flyfun/forms/data/FormRequestBuilder.kt | 20 +- .../kotlin/aero/flyfun/forms/net/ApiClient.kt | 14 +- .../kotlin/aero/flyfun/forms/ui/FlyFunApp.kt | 70 ++++--- .../forms/ui/aircraft/AircraftScreens.kt | 4 +- .../flyfun/forms/ui/flights/FlightScreens.kt | 123 +++++++++--- .../forms/ui/flights/FlightsViewModel.kt | 189 ++++++++++++++---- .../ui/settings/DataTransferViewModel.kt | 4 +- .../forms/ui/settings/SettingsScreen.kt | 15 ++ .../flyfun/forms/ui/webform/WebFormScreen.kt | 29 +++ .../forms/data/FormRequestBuilderTest.kt | 19 +- .../aero/flyfun/forms/net/ApiTypesTest.kt | 2 +- .../aero/flyfun/forms/logic/FormSides.kt | 40 ++++ .../aero/flyfun/forms/logic/TripExtras.kt | 26 +++ .../aero/flyfun/forms/logic/FormSidesTest.kt | 55 +++++ .../aero/flyfun/forms/logic/TripExtrasTest.kt | 31 +++ designs/future/android-parity.md | 31 +-- 22 files changed, 650 insertions(+), 161 deletions(-) create mode 100644 app/android/app/src/main/kotlin/aero/flyfun/forms/data/FormFiles.kt create mode 100644 app/android/core-logic/src/main/kotlin/aero/flyfun/forms/logic/FormSides.kt create mode 100644 app/android/core-logic/src/main/kotlin/aero/flyfun/forms/logic/TripExtras.kt create mode 100644 app/android/core-logic/src/test/kotlin/aero/flyfun/forms/logic/FormSidesTest.kt create mode 100644 app/android/core-logic/src/test/kotlin/aero/flyfun/forms/logic/TripExtrasTest.kt 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..d59974f 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,24 @@ 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) + val password = "golf-hotel-india-juliet-kilo".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/main/kotlin/aero/flyfun/forms/MainActivity.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/MainActivity.kt index 1616309..5074374 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 @@ -26,12 +27,27 @@ class MainActivity : ComponentActivity() { 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) } } - handleAuthRedirect(intent) + // Not again on rotation: the nonce is spent, and a second pass would + // report a sign-in that just worked as failed. + if (savedInstanceState == null) handleAuthRedirect(intent) + } + + 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) } /** @@ -44,12 +60,16 @@ class MainActivity : ComponentActivity() { handleAuthRedirect(intent) } + private companion object { + var purgedThisProcess = false + } + private fun handleAuthRedirect(intent: Intent?) { val uri = intent?.data ?: return if (uri.scheme != ApiConfig.CALLBACK_SCHEME) return 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..4659d7d 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 } @@ -91,6 +117,12 @@ class AuthService( 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/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/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..a61cf1e 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 @@ -87,25 +87,13 @@ object FormRequestBuilder { form = formId, flight = flightPayload(flight), 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)) }, 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/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/ui/FlyFunApp.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/FlyFunApp.kt index af8b6d2..19faade 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 @@ -3,7 +3,6 @@ package aero.flyfun.forms.ui import aero.flyfun.forms.auth.AuthService import aero.flyfun.forms.auth.TokenStore import aero.flyfun.forms.data.AircraftEntity -import aero.flyfun.forms.data.FlightEntity import aero.flyfun.forms.data.FlightRepository import aero.flyfun.forms.data.FlyFunDatabase import aero.flyfun.forms.data.PeopleRepository @@ -53,6 +52,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 @@ -70,8 +70,6 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument 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, @@ -128,20 +126,31 @@ fun FlyFunApp(auth: AuthService, tokens: TokenStore, api: ApiClient) { }.getOrDefault(""), ) } - 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 an expired token mid-edit keeps the back + // stack, and with it the flight draft, for after signing in again. + val navController = rememberNavController() + + 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 @@ -175,7 +184,7 @@ fun FlyFunApp(auth: AuthService, tokens: TokenStore, api: ApiClient) { flightRoutes(navController, factory, context) peopleRoutes(navController, factory) aircraftRoutes(navController, factory) - settingsRoute(factory, context, tokens, auth) { signedIn = false } + settingsRoute(factory, context, tokens, auth) } } } @@ -188,25 +197,11 @@ private fun androidx.navigation.NavGraphBuilder.flightRoutes( composable(Tab.FLIGHTS.route) { val vm: FlightsViewModel = viewModel(factory = factory) val flights by vm.allFlights.collectAsState() - val scope = rememberCoroutineScope() FlightListScreen( flights = flights, 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}") }, ) } @@ -218,12 +213,14 @@ 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 scope = rememberCoroutineScope() - androidx.compose.runtime.LaunchedEffect(flightId) { vm.load(flightId) } + androidx.compose.runtime.LaunchedEffect(flightId) { vm.open(flightId) } // A fetched fill plan takes over the screen until it is dismissed. (generate as? aero.flyfun.forms.ui.flights.GenerateState.WebPlan)?.let { web -> @@ -233,13 +230,17 @@ 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) }, + onSetAircraft = { vm.setAircraft(it) }, + onSetCrew = { vm.setCrew(it) }, + onSetPassengers = { vm.setPassengers(it) }, + onSave = { vm.save() }, + onSaveAndBack = { scope.launch { vm.save().join(); nav.popBackStack() } }, onGenerate = { airport, form -> vm.generateForm(airport, form) }, onOpenWebForm = { airport, form -> vm.prefillWebForm(airport, form) }, onShare = { shareFile(context, it) }, @@ -417,6 +418,11 @@ 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()) { @@ -437,11 +443,11 @@ private fun androidx.navigation.NavGraphBuilder.settingsRoute( context: Context, tokens: TokenStore, auth: AuthService, - onSignedOut: () -> Unit, ) { composable(Tab.SETTINGS.route) { val vm: DataTransferViewModel = viewModel(factory = factory) val state by vm.state.collectAsState() + val signedIn by tokens.signedIn.collectAsState() val scope = rememberCoroutineScope() // OpenDocument rather than GetContent: this reads one file the user @@ -458,7 +464,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,7 +477,8 @@ 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() }, ) } 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..2c3de41 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 @@ -10,6 +10,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 @@ -123,7 +125,7 @@ fun AircraftEditScreen( }, ) { 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/flights/FlightScreens.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/flights/FlightScreens.kt index 1a1e518..47ffee5 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,11 @@ 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 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 @@ -124,13 +128,18 @@ 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, + onSetAircraft: (String?) -> Unit, + onSetCrew: (List) -> Unit, + onSetPassengers: (List) -> Unit, + onSave: () -> Unit, + /** Save, and leave once it is stored: leaving first would cancel the write. */ + onSaveAndBack: () -> Unit, onGenerate: (String, FormInfo) -> Unit, onOpenWebForm: (String, FormInfo) -> Unit, onShare: (java.io.File) -> Unit, @@ -146,29 +155,33 @@ 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") } }, ) }, @@ -178,14 +191,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) { t -> onEditFlight { it.copy(departureInstant = t) } } + ScheduleField("Arrival", flight.arrivalInstant) { t -> onEditFlight { it.copy(arrivalInstant = t) } } if (flight.arrivalInstant.isBefore(flight.departureInstant)) { Text( "Arrival is before departure.", @@ -195,11 +224,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,11 +238,17 @@ 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) + PeoplePicker("Crew", people, detail.crew, onSetCrew) + PeoplePicker("Passengers", people, detail.passengers, onSetPassengers) - OutlinedTextField(observations, { observations = it }, - label = { Text("Observations") }, modifier = Modifier.fillMaxWidth()) + 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 -> @@ -220,35 +256,55 @@ fun FlightEditScreen( } 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, ) } } } + 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) } +/** + * 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" }) }, ) @@ -269,11 +325,12 @@ private fun AirportFormsCard( 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() -> 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..504257a 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,63 @@ 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.FormSides 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.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.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(), + /** 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 @@ -66,68 +89,151 @@ 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), ) - loadForms(flight) } - fun save(flight: FlightEntity) = viewModelScope.launch { - flights.saveFlight(flight) - load(flight.id) + 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, + ) } - fun delete(id: String) = viewModelScope.launch { flights.deleteFlight(id) } + /** Make [detail] the draft and what it is compared against. */ + private fun show(detail: FlightDetail) { + _detail.value = detail + baseline.value = detail + 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 setCrew(flightId: String, ids: List) = viewModelScope.launch { - flights.setCrew(flightId, ids); load(flightId) + fun editFlight(transform: (FlightEntity) -> FlightEntity) = edit { it.copy(flight = transform(it.flight)) } + + fun setAircraft(aircraftId: String?) = edit { detail -> + detail.copy( + flight = detail.flight.copy(aircraftId = aircraftId), + aircraft = aircraft.value.firstOrNull { it.id == aircraftId }, + ) } - fun setPassengers(flightId: String, ids: List) = viewModelScope.launch { - flights.setPassengers(flightId, ids); load(flightId) + fun setCrew(crew: List) = edit { it.copy(crew = crew) } + + fun setPassengers(passengers: List) = edit { it.copy(passengers = passengers) } + + /** 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 } - suspend fun newFlightDefaults(): AircraftEntity? = flights.suggestedAircraft() + fun delete(id: String) = viewModelScope.launch { flights.deleteFlight(id) } /** * 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 { @@ -163,7 +269,7 @@ class FlightsViewModel( 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) + val file = FormFiles.dir(cacheDir).resolve(name) file.writeBytes(bytes) GenerateState.Ready(file, form.label) } @@ -212,6 +318,11 @@ 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" + } } /** 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..7ca7669 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 @@ -34,6 +34,7 @@ import java.io.File fun SettingsScreen( state: TransferState, signedIn: Boolean, + onSignIn: () -> Unit, onExportEncrypted: () -> Unit, onExportPlain: () -> Unit, onPickFile: () -> Unit, @@ -83,6 +84,20 @@ fun SettingsScreen( if (signedIn) { OutlinedButton(onClick = onSignOut) { Text("Sign out") } + } 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() 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..d9a4d25 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 @@ -76,7 +76,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 +87,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) } @@ -99,19 +99,4 @@ class FormRequestBuilderTest { assertNull(p.owner) assertNull(p.usualBase) } - - @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)) - } - - @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)) - } } 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/FormSides.kt b/app/android/core-logic/src/main/kotlin/aero/flyfun/forms/logic/FormSides.kt new file mode 100644 index 0000000..6c6787d --- /dev/null +++ b/app/android/core-logic/src/main/kotlin/aero/flyfun/forms/logic/FormSides.kt @@ -0,0 +1,40 @@ +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 } + + 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/FormSidesTest.kt b/app/android/core-logic/src/test/kotlin/aero/flyfun/forms/logic/FormSidesTest.kt new file mode 100644 index 0000000..6428bdb --- /dev/null +++ b/app/android/core-logic/src/test/kotlin/aero/flyfun/forms/logic/FormSidesTest.kt @@ -0,0 +1,55 @@ +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)) + } +} 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..4e86cf9 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) | In progress | | 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,17 +77,17 @@ 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) @@ -203,3 +203,10 @@ 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. From 11b2749f67e445946baf89fdb6f6855d1ee72002 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 07:35:39 +0000 Subject: [PATCH 2/7] feat(android): 1b - delete account, and delete with Undo - Settings > Delete account calls DELETE /auth/account after a confirm, then signs out. Local people, aircraft and flights are kept. Play requires the in-app deletion. - People, aircraft and flights delete with a swipe or from the edit screen's overflow menu, and offer Undo in a snackbar. Deletion stays a tombstone; Undo clears it and bumps updatedAt. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011pXA3ei7MugJjuxWmZ12Y2 --- .../flyfun/forms/data/FlyFunDatabaseTest.kt | 23 +++++ .../aero/flyfun/forms/auth/AuthService.kt | 12 +++ .../kotlin/aero/flyfun/forms/data/Daos.kt | 10 ++ .../flyfun/forms/data/FlightRepository.kt | 5 + .../flyfun/forms/data/PeopleRepository.kt | 3 + .../kotlin/aero/flyfun/forms/net/FormsApi.kt | 5 + .../kotlin/aero/flyfun/forms/ui/FlyFunApp.kt | 94 ++++++++++++++++++- .../forms/ui/aircraft/AircraftScreens.kt | 24 +++-- .../aero/flyfun/forms/ui/common/Deletion.kt | 79 ++++++++++++++++ .../flyfun/forms/ui/flights/FlightScreens.kt | 32 ++++--- .../flyfun/forms/ui/people/PeopleScreens.kt | 40 ++++---- .../forms/ui/settings/SettingsScreen.kt | 47 ++++++++++ designs/future/android-parity.md | 9 +- 13 files changed, 340 insertions(+), 43 deletions(-) create mode 100644 app/android/app/src/main/kotlin/aero/flyfun/forms/ui/common/Deletion.kt 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/auth/AuthService.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/auth/AuthService.kt index 4659d7d..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 @@ -115,6 +115,18 @@ 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 { 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/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/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/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 19faade..ea4db55 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 @@ -3,6 +3,7 @@ package aero.flyfun.forms.ui import aero.flyfun.forms.auth.AuthService import aero.flyfun.forms.auth.TokenStore import aero.flyfun.forms.data.AircraftEntity +import aero.flyfun.forms.data.FlightEntity import aero.flyfun.forms.data.FlightRepository import aero.flyfun.forms.data.FlyFunDatabase import aero.flyfun.forms.data.PeopleRepository @@ -45,6 +46,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 @@ -68,6 +73,7 @@ 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 @@ -96,6 +102,49 @@ private class Factory( } } +/** + * 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), @@ -138,6 +187,11 @@ fun FlyFunApp(auth: AuthService, tokens: TokenStore, api: ApiClient) { // Above the sign-in screen, so an expired token mid-edit keeps the back // stack, and with it the flight draft, for after signing in again. val navController = rememberNavController() + val snackbar = remember { SnackbarHostState() } + val appScope = rememberCoroutineScope() + val deletions = remember { + Deletions(repositories.first, repositories.second, appScope, snackbar) + } if (!signedIn && !skippedSignIn) { SignInScreen( @@ -155,6 +209,7 @@ fun FlyFunApp(auth: AuthService, tokens: TokenStore, api: ApiClient) { val currentRoute = backStack?.destination?.route Scaffold( + snackbarHost = { SnackbarHost(snackbar) }, bottomBar = { if (Tab.entries.any { it.route == currentRoute }) { NavigationBar { @@ -181,9 +236,9 @@ 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) + flightRoutes(navController, factory, context, deletions) + peopleRoutes(navController, factory, deletions) + aircraftRoutes(navController, factory, deletions) settingsRoute(factory, context, tokens, auth) } } @@ -193,6 +248,7 @@ 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) @@ -202,6 +258,7 @@ private fun androidx.navigation.NavGraphBuilder.flightRoutes( onOpen = { nav.navigate("flight/$it") }, // A draft, not a row: backing out of it leaves nothing behind. onAdd = { nav.navigate("flight/${FlightsViewModel.NEW_FLIGHT}") }, + onDelete = { deletions.flight(it) }, ) } @@ -246,6 +303,10 @@ private fun androidx.navigation.NavGraphBuilder.flightRoutes( onShare = { shareFile(context, it) }, onDismissGenerate = { vm.clearGenerateState() }, onBack = { nav.popBackStack() }, + onDelete = { + detail?.flight?.let { deletions.flight(it) } + nav.popBackStack() + }, ) } } @@ -253,6 +314,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) @@ -261,6 +323,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") { @@ -355,6 +418,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() }, ) } } @@ -363,6 +427,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) @@ -371,6 +436,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") { @@ -386,7 +452,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() }, + ) } } } @@ -448,6 +519,8 @@ private fun androidx.navigation.NavGraphBuilder.settingsRoute( val vm: DataTransferViewModel = viewModel(factory = factory) val state by vm.state.collectAsState() val signedIn by tokens.signedIn.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 @@ -480,6 +553,19 @@ private fun androidx.navigation.NavGraphBuilder.settingsRoute( // The sign-in screen follows from the token going; see FlyFunApp. onSignOut = { scope.launch { auth.signOut() } }, onDismiss = { vm.reset() }, + 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 2c3de41..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 @@ -43,6 +45,7 @@ fun AircraftListScreen( aircraft: List, onOpen: (String) -> Unit, onAdd: () -> Unit, + onDelete: (AircraftEntity) -> Unit, ) { Scaffold( topBar = { TopAppBar(title = { Text("Aircraft") }) }, @@ -66,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() } } @@ -87,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()) } @@ -120,6 +127,7 @@ fun AircraftEditScreen( ) }, ) { Text("Save") } + onDelete?.let { DeleteOverflowMenu(onDelete = it) } }, ) }, 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 47ffee5..2e26183 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,6 +5,8 @@ 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.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 @@ -61,6 +63,7 @@ fun FlightListScreen( flights: List, onOpen: (String) -> Unit, onAdd: () -> Unit, + onDelete: (FlightEntity) -> Unit, ) { val now = Instant.now() val upcoming = flights.filter { !it.departureInstant.isBefore(now) }.sortedBy { it.departureInstant } @@ -90,11 +93,11 @@ fun FlightListScreen( LazyColumn(Modifier.fillMaxSize().padding(padding)) { if (upcoming.isNotEmpty()) { item { SectionHeader("Upcoming") } - items(upcoming, key = { it.id }) { FlightRow(it, onOpen) } + items(upcoming, key = { "${it.id}:${it.updatedAt}" }) { FlightRow(it, onOpen, onDelete) } } if (past.isNotEmpty()) { item { SectionHeader("Past") } - items(past, key = { it.id }) { FlightRow(it, onOpen) } + items(past, key = { "${it.id}:${it.updatedAt}" }) { FlightRow(it, onOpen, onDelete) } } } } @@ -111,16 +114,18 @@ 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, onOpen: (String) -> Unit, onDelete: (FlightEntity) -> Unit) { + SwipeToDelete(onDelete = { onDelete(flight) }) { + 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) }, + ) + } HorizontalDivider() } @@ -145,6 +150,7 @@ fun FlightEditScreen( onShare: (java.io.File) -> Unit, onDismissGenerate: () -> Unit, onBack: () -> Unit, + onDelete: () -> Unit, ) { if (detail == null) { Scaffold(topBar = { TopAppBar(title = { Text("Flight") }) }) { p -> @@ -182,6 +188,8 @@ fun FlightEditScreen( }, actions = { TextButton(onClick = onSave, enabled = hasUnsavedChanges) { Text("Save") } + // A draft has nothing stored to delete; Back discards it. + if (!detail.isNew) DeleteOverflowMenu(onDelete = onDelete) }, ) }, 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/SettingsScreen.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/settings/SettingsScreen.kt index 7ca7669..69a1b8b 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 @@ -2,6 +2,7 @@ package aero.flyfun.forms.ui.settings 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 +11,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 @@ -25,6 +27,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 @@ -43,7 +46,12 @@ fun SettingsScreen( onShare: (File) -> Unit, onSignOut: () -> Unit, onDismiss: () -> 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()), @@ -84,6 +92,30 @@ 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. @@ -104,6 +136,21 @@ fun SettingsScreen( } } + 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/designs/future/android-parity.md b/designs/future/android-parity.md index 4e86cf9..4d0504c 100644 --- a/designs/future/android-parity.md +++ b/designs/future/android-parity.md @@ -91,9 +91,9 @@ and Settings, which is why it is one PR. ### 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) @@ -210,3 +210,6 @@ Newest last. One line per decision: date, section, what was decided, why. - 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. From aa3057edefab8db948d4a9ec54e0f390a4621657 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 07:40:19 +0000 Subject: [PATCH 3/7] feat(android): 1c - the flight fields the forms depend on - Nature, Reason for Visit and Responsible Person on the flight. The responsible person is the form's contact, keeps `contact` in step with their phone as iOS does, and fills the telephone / e-mail extras. - Connecting flight (the next or previous leg through this airport within 14 days) and return flight (has_return_flight) are sent to the forms that ask for them. The matching is FlightLegs in :core-logic, unit-tested. - Each form shows its own extra fields (choice, person, text) and sends them. An untouched choice sends the option it shows. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011pXA3ei7MugJjuxWmZ12Y2 --- .../flyfun/forms/data/FormRequestBuilder.kt | 83 +++++- .../kotlin/aero/flyfun/forms/ui/FlyFunApp.kt | 4 + .../flyfun/forms/ui/common/ChoiceField.kt | 65 +++++ .../flyfun/forms/ui/flights/FlightScreens.kt | 248 +++++++++++++++--- .../forms/ui/flights/FlightsViewModel.kt | 120 +++++++-- .../forms/data/FormRequestBuilderTest.kt | 86 ++++++ .../aero/flyfun/forms/logic/FlightLegs.kt | 67 +++++ .../aero/flyfun/forms/logic/FlightLegsTest.kt | 79 ++++++ designs/future/android-parity.md | 11 +- 9 files changed, 700 insertions(+), 63 deletions(-) create mode 100644 app/android/app/src/main/kotlin/aero/flyfun/forms/ui/common/ChoiceField.kt create mode 100644 app/android/core-logic/src/main/kotlin/aero/flyfun/forms/logic/FlightLegs.kt create mode 100644 app/android/core-logic/src/test/kotlin/aero/flyfun/forms/logic/FlightLegsTest.kt 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 a61cf1e..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,10 +152,14 @@ 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. "Pilot", not "PIC": // the word is printed as-is on the gendec and LSGS forms, and it is @@ -94,6 +168,9 @@ object FormRequestBuilder { 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() }, ) } 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 ea4db55..652b914 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 @@ -275,6 +275,7 @@ private fun androidx.navigation.NavGraphBuilder.flightRoutes( 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.open(flightId) } @@ -296,6 +297,9 @@ private fun androidx.navigation.NavGraphBuilder.flightRoutes( 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) }, 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/flights/FlightScreens.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/flights/FlightScreens.kt index 2e26183..dd17b71 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,6 +5,9 @@ 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.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 @@ -142,6 +145,9 @@ fun FlightEditScreen( 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, @@ -246,6 +252,32 @@ fun FlightEditScreen( Text("Add an aircraft first.", style = MaterialTheme.typography.bodySmall) } + 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) } + } + PeoplePicker("Crew", people, detail.crew, onSetCrew) PeoplePicker("Passengers", people, detail.passengers, onSetPassengers) @@ -259,9 +291,16 @@ fun FlightEditScreen( ) 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, + onOpenWebForm = onOpenWebForm, + ) + airportForms.forEach { airport -> AirportFormsCard(airport, rowContext) } if (airportForms.isEmpty()) { Text( "Enter the route to see which forms these airports need.", @@ -288,6 +327,17 @@ fun FlightEditScreen( GenerateFeedback(generateState, onShare, onDismissGenerate) } +/** 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. @@ -320,13 +370,23 @@ 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 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( @@ -344,37 +404,161 @@ private fun AirportFormsCard( 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) - } - } - 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 (form.isWebForm) WebFormRow(airport.icao, form, ctx) else FormRow(airport.icao, form, 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.End, Alignment.CenterVertically) { + OutlinedButton( + enabled = ctx.state !is GenerateState.Working, + onClick = { ctx.onGenerate(airport, form) }, + ) { + if (working) CircularProgressIndicator(Modifier.padding(2.dp)) + else 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 504257a..f2ab667 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 @@ -7,7 +7,11 @@ 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.FlightLegs import aero.flyfun.forms.logic.FormSides +import aero.flyfun.forms.logic.Leg +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 @@ -20,6 +24,7 @@ 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 @@ -39,6 +44,8 @@ data class FlightDetail( 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, ) @@ -142,6 +149,7 @@ class FlightsViewModel( aircraft = flight.aircraftId?.let { flights.aircraft(it) }, crew = flights.crew(flightId), passengers = flights.passengers(flightId), + responsiblePerson = flight.responsiblePersonId?.let { people.person(it)?.person }, ) } @@ -193,6 +201,30 @@ class FlightsViewModel( 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 @@ -236,12 +268,17 @@ class FlightsViewModel( } } - 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) @@ -250,7 +287,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, @@ -258,8 +331,15 @@ class FlightsViewModel( crew = current.crew, passengers = current.passengers, documentFor = { documents[it.id] }, + responsiblePerson = current.responsiblePerson, + extraFields = extras, + connectingFlight = connecting, + returnFlight = returning, ) + } + fun generateForm(airport: String, form: FormInfo) = viewModelScope.launch { + val request = buildRequest(airport, form) ?: return@launch _generate.value = runCatching { api.forms.generate(request) }.fold( onSuccess = { response -> when { @@ -291,26 +371,7 @@ class FlightsViewModel( * /generate - only the endpoint and what comes back differ. */ fun prefillWebForm(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.") - return@launch - } - _generate.value = GenerateState.Working(form.id) - - 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] }, - ) + val request = buildRequest(airport, form) ?: return@launch _generate.value = runCatching { api.forms.prefill(request) }.fold( onSuccess = { GenerateState.WebPlan(it) }, onFailure = { GenerateState.Failed(it.friendlyMessage()) }, @@ -322,6 +383,9 @@ class FlightsViewModel( 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" } } @@ -364,3 +428,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/test/kotlin/aero/flyfun/forms/data/FormRequestBuilderTest.kt b/app/android/app/src/test/kotlin/aero/flyfun/forms/data/FormRequestBuilderTest.kt index d9a4d25..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 @@ -99,4 +101,88 @@ class FormRequestBuilderTest { assertNull(p.owner) 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 `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 `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/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..a274420 --- /dev/null +++ b/app/android/core-logic/src/main/kotlin/aero/flyfun/forms/logic/FlightLegs.kt @@ -0,0 +1,67 @@ +package aero.flyfun.forms.logic + +import java.time.Duration +import java.time.Instant + +/** 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 } + } + + private fun String.icao() = trim().uppercase() +} 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..b1127ca --- /dev/null +++ b/app/android/core-logic/src/test/kotlin/aero/flyfun/forms/logic/FlightLegsTest.kt @@ -0,0 +1,79 @@ +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))) + } +} diff --git a/designs/future/android-parity.md b/designs/future/android-parity.md index 4d0504c..45b14ce 100644 --- a/designs/future/android-parity.md +++ b/designs/future/android-parity.md @@ -99,10 +99,10 @@ and Settings, which is why it is one PR. 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) @@ -213,3 +213,6 @@ Newest last. One line per decision: date, section, what was decided, why. - 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. From 2cc8d048a5676ba491a7707f740c605fd772ac0d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 07:42:01 +0000 Subject: [PATCH 4/7] feat(android): 1d - leg actions, arrival follows departure, past flights fold away - Create Return Flight, Create Next Leg and Duplicate Flight, as on iOS: the aircraft, people, nature, reason and responsible person carry over. The current flight is stored first; the new leg opens as an unsaved draft. - Moving the departure moves an arrival on the same UTC day with it, keeping its time of day. FlightLegs.arrivalFollowing, unit-tested. - Past flights sit under a collapsed "Past Flights" row, and every row shows the aircraft registration. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011pXA3ei7MugJjuxWmZ12Y2 --- .../kotlin/aero/flyfun/forms/ui/FlyFunApp.kt | 6 ++ .../flyfun/forms/ui/flights/FlightScreens.kt | 92 ++++++++++++++++--- .../forms/ui/flights/FlightsViewModel.kt | 75 ++++++++++++++- .../aero/flyfun/forms/logic/FlightLegs.kt | 23 +++++ .../aero/flyfun/forms/logic/FlightLegsTest.kt | 34 +++++++ designs/future/android-parity.md | 8 +- 6 files changed, 220 insertions(+), 18 deletions(-) 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 652b914..fd023c1 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 @@ -253,8 +253,10 @@ private fun androidx.navigation.NavGraphBuilder.flightRoutes( composable(Tab.FLIGHTS.route) { val vm: FlightsViewModel = viewModel(factory = factory) val flights by vm.allFlights.collectAsState() + val aircraft by vm.aircraft.collectAsState() FlightListScreen( flights = flights, + aircraft = aircraft, onOpen = { nav.navigate("flight/$it") }, // A draft, not a row: backing out of it leaves nothing behind. onAdd = { nav.navigate("flight/${FlightsViewModel.NEW_FLIGHT}") }, @@ -294,6 +296,7 @@ private fun androidx.navigation.NavGraphBuilder.flightRoutes( airportForms = forms, generateState = generate, onEditFlight = { vm.editFlight(it) }, + onSetDeparture = { vm.setDeparture(it) }, onSetAircraft = { vm.setAircraft(it) }, onSetCrew = { vm.setCrew(it) }, onSetPassengers = { vm.setPassengers(it) }, @@ -311,6 +314,9 @@ private fun androidx.navigation.NavGraphBuilder.flightRoutes( detail?.flight?.let { deletions.flight(it) } nav.popBackStack() }, + onCreateReturn = { vm.createReturnFlight() }, + onCreateNextLeg = { vm.createNextLeg() }, + onDuplicate = { vm.duplicateFlight() }, ) } } 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 dd17b71..be7434d 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 @@ -26,7 +26,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 @@ -49,11 +54,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 @@ -64,13 +72,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") }) }, @@ -94,13 +107,39 @@ fun FlightListScreen( } } else { LazyColumn(Modifier.fillMaxSize().padding(padding)) { - if (upcoming.isNotEmpty()) { - item { SectionHeader("Upcoming") } - items(upcoming, key = { "${it.id}:${it.updatedAt}" }) { FlightRow(it, onOpen, onDelete) } + 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}:${it.updatedAt}" }) { FlightRow(it, onOpen, onDelete) } + // 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) + } + } } } } @@ -117,14 +156,25 @@ private fun SectionHeader(text: String) { } @Composable -private fun FlightRow(flight: FlightEntity, onOpen: (String) -> Unit, onDelete: (FlightEntity) -> Unit) { +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("${DAY.format(flight.departureInstant)} · ${HHMM.format(flight.departureInstant)}Z") + Text( + listOfNotNull( + DAY.format(flight.departureInstant), + "${HHMM.format(flight.departureInstant)}Z", + registration?.takeIf { it.isNotBlank() }, + ).joinToString(" · "), + ) }, modifier = Modifier.clickable { onOpen(flight.id) }, ) @@ -142,6 +192,7 @@ fun FlightEditScreen( airportForms: List, generateState: GenerateState, onEditFlight: ((FlightEntity) -> FlightEntity) -> Unit, + onSetDeparture: (java.time.Instant) -> Unit, onSetAircraft: (String?) -> Unit, onSetCrew: (List) -> Unit, onSetPassengers: (List) -> Unit, @@ -157,6 +208,9 @@ fun FlightEditScreen( onDismissGenerate: () -> Unit, onBack: () -> Unit, onDelete: () -> Unit, + onCreateReturn: () -> Unit, + onCreateNextLeg: () -> Unit, + onDuplicate: () -> Unit, ) { if (detail == null) { Scaffold(topBar = { TopAppBar(title = { Text("Flight") }) }) { p -> @@ -227,7 +281,7 @@ fun FlightEditScreen( ) } - ScheduleField("Departure", flight.departureInstant) { t -> onEditFlight { it.copy(departureInstant = t) } } + ScheduleField("Departure", flight.departureInstant, onSetDeparture) ScheduleField("Arrival", flight.arrivalInstant) { t -> onEditFlight { it.copy(arrivalInstant = t) } } if (flight.arrivalInstant.isBefore(flight.departureInstant)) { Text( @@ -307,6 +361,12 @@ fun FlightEditScreen( 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) } } @@ -327,6 +387,14 @@ fun FlightEditScreen( 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") 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 f2ab667..16f7201 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 @@ -169,10 +169,15 @@ class FlightsViewModel( ) } - /** Make [detail] the draft and what it is compared against. */ - private fun show(detail: FlightDetail) { + /** + * 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 = detail + baseline.value = if (unsaved) null else detail + _extraValues.value = emptyMap() fetchForms(detail.flight) } @@ -190,6 +195,14 @@ class FlightsViewModel( 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), @@ -239,6 +252,62 @@ class FlightsViewModel( fun delete(id: String) = viewModelScope.launch { flights.deleteFlight(id) } + /** 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, + ) + } + + /** 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, + ) + } + + /** + * 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. * 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 index a274420..a9c96f4 100644 --- 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 @@ -2,6 +2,8 @@ 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( @@ -63,5 +65,26 @@ object FlightLegs { .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/test/kotlin/aero/flyfun/forms/logic/FlightLegsTest.kt b/app/android/core-logic/src/test/kotlin/aero/flyfun/forms/logic/FlightLegsTest.kt index b1127ca..b0cc391 100644 --- 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 @@ -76,4 +76,38 @@ class FlightLegsTest { 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/designs/future/android-parity.md b/designs/future/android-parity.md index 45b14ce..6274133 100644 --- a/designs/future/android-parity.md +++ b/designs/future/android-parity.md @@ -106,9 +106,9 @@ Entity already has `nature`, `reasonForVisit`, `responsiblePersonId` — no migr ### 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) @@ -216,3 +216,5 @@ Newest last. One line per decision: date, section, what was decided, why. - 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. From c03612bbad5637360570c1b2718004c519bfe9d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 07:44:45 +0000 Subject: [PATCH 5/7] feat(android): 1e - e-mail a form, in a language the pilot speaks - Email next to Generate: the form is generated while /email-text writes the covering text, then a mail app opens addressed to the form's email.to / send_to and cc, with the file attached. With no mail app it falls back to the share sheet. - Settings > Languages you speak. The body is in the airport's language when the pilot speaks it, English otherwise; stored as on iOS. - Each side lists its primary form first, then the airport's web forms, then the other forms folded under "Other forms". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011pXA3ei7MugJjuxWmZ12Y2 --- .../aero/flyfun/forms/data/Preferences.kt | 32 +++++ .../kotlin/aero/flyfun/forms/ui/FlyFunApp.kt | 64 ++++++++-- .../flyfun/forms/ui/flights/FlightScreens.kt | 33 ++++-- .../forms/ui/flights/FlightsViewModel.kt | 109 ++++++++++++++---- .../forms/ui/settings/SettingsScreen.kt | 25 ++++ .../aero/flyfun/forms/logic/EmailText.kt | 63 ++++++++++ .../aero/flyfun/forms/logic/FormSides.kt | 14 +++ .../aero/flyfun/forms/logic/EmailTextTest.kt | 44 +++++++ .../aero/flyfun/forms/logic/FormSidesTest.kt | 16 +++ designs/future/android-parity.md | 11 +- 10 files changed, 369 insertions(+), 42 deletions(-) create mode 100644 app/android/app/src/main/kotlin/aero/flyfun/forms/data/Preferences.kt create mode 100644 app/android/core-logic/src/main/kotlin/aero/flyfun/forms/logic/EmailText.kt create mode 100644 app/android/core-logic/src/test/kotlin/aero/flyfun/forms/logic/EmailTextTest.kt 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/ui/FlyFunApp.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/FlyFunApp.kt index fd023c1..d7037ff 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 @@ -89,13 +90,14 @@ 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}") @@ -163,6 +165,7 @@ fun FlyFunApp(auth: AuthService, tokens: TokenStore, api: ApiClient) { db, ) } + val preferences = remember { Preferences(context) } val factory = remember { Factory( people = repositories.first, @@ -173,6 +176,7 @@ fun FlyFunApp(auth: AuthService, tokens: TokenStore, api: ApiClient) { appVersion = runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName.orEmpty() }.getOrDefault(""), + preferences = preferences, ) } val signedIn by tokens.signedIn.collectAsState() @@ -239,7 +243,7 @@ fun FlyFunApp(auth: AuthService, tokens: TokenStore, api: ApiClient) { flightRoutes(navController, factory, context, deletions) peopleRoutes(navController, factory, deletions) aircraftRoutes(navController, factory, deletions) - settingsRoute(factory, context, tokens, auth) + settingsRoute(factory, context, tokens, auth, preferences) } } } @@ -282,6 +286,14 @@ private fun androidx.navigation.NavGraphBuilder.flightRoutes( 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 -> WebFormScreen(plan = web.plan, onBack = { vm.clearGenerateState() }) @@ -306,6 +318,7 @@ private fun androidx.navigation.NavGraphBuilder.flightRoutes( 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() }, @@ -506,12 +519,7 @@ private fun shareFile(context: Context, file: File) { } 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) } @@ -519,16 +527,54 @@ 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, + 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() @@ -563,6 +609,8 @@ private fun androidx.navigation.NavGraphBuilder.settingsRoute( // 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 = { 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 be7434d..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 @@ -6,6 +6,7 @@ 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 @@ -203,6 +204,7 @@ fun FlightEditScreen( /** 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, @@ -352,6 +354,7 @@ fun FlightEditScreen( extraValues = extraValues, onSetExtra = onSetExtra, onGenerate = onGenerate, + onEmail = onEmail, onOpenWebForm = onOpenWebForm, ) airportForms.forEach { airport -> AirportFormsCard(airport, rowContext) } @@ -450,6 +453,7 @@ class FormRowContext( 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, ) @@ -471,8 +475,21 @@ private fun AirportFormsCard(airport: AirportForms, ctx: FormRowContext) { Text(airport.error, style = MaterialTheme.typography.bodySmall) airport.forms.isEmpty() -> Text("No forms needed here.", style = MaterialTheme.typography.bodySmall) - else -> airport.forms.forEach { form -> - if (form.isWebForm) WebFormRow(airport.icao, form, ctx) else FormRow(airport.icao, form, ctx) + 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 (showOthers) grouped.others.forEach { FormRow(airport.icao, it, ctx) } + } } } } @@ -485,14 +502,16 @@ private fun FormRow(airport: String, form: FormInfo, ctx: FormRowContext) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text(form.label, style = MaterialTheme.typography.titleSmall) ExtraFields(airport, form, ctx) - Row(Modifier.fillMaxWidth(), Arrangement.End, Alignment.CenterVertically) { + 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) }, - ) { - if (working) CircularProgressIndicator(Modifier.padding(2.dp)) - else Text("Generate") - } + ) { Text("Generate") } } } } 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 16f7201..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 @@ -7,9 +7,11 @@ 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 @@ -19,6 +21,7 @@ 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 @@ -76,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( @@ -83,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> = @@ -408,32 +422,81 @@ class FlightsViewModel( } fun generateForm(airport: String, form: FormInfo) = viewModelScope.launch { - val request = buildRequest(airport, form) ?: return@launch - _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 = FormFiles.dir(cacheDir).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()) }, + val file = generateFile(airport, form) ?: return@launch + _generate.value = GenerateState.Ready(file, form.label) + } + + /** + * 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 emailForm(airport: String, form: FormInfo) = viewModelScope.launch { + val current = _detail.value ?: return@launch + 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 + } + 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) + + _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 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 69a1b8b..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,5 +1,6 @@ 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 @@ -19,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 @@ -46,6 +48,8 @@ fun SettingsScreen( onShare: (File) -> Unit, onSignOut: () -> Unit, onDismiss: () -> Unit, + spokenLanguages: Set, + onSetSpeaks: (code: String, speaks: Boolean) -> Unit, deletingAccount: Boolean, deleteAccountError: String?, onDeleteAccount: () -> Unit, @@ -57,6 +61,27 @@ fun SettingsScreen( 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) 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/FormSides.kt b/app/android/core-logic/src/main/kotlin/aero/flyfun/forms/logic/FormSides.kt index 6c6787d..78419b9 100644 --- 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 @@ -36,5 +36,19 @@ object FormSides { 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/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/FormSidesTest.kt b/app/android/core-logic/src/test/kotlin/aero/flyfun/forms/logic/FormSidesTest.kt index 6428bdb..7b1b47a 100644 --- 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 @@ -52,4 +52,20 @@ class FormSidesTest { 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/designs/future/android-parity.md b/designs/future/android-parity.md index 6274133..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) | In progress | +| 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) | @@ -112,9 +112,9 @@ Entity already has `nature`, `reasonForVisit`, `responsiblePersonId` — no migr ### 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`) --- @@ -218,3 +218,6 @@ Newest last. One line per decision: date, section, what was decided, why. - 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. From 2c3549f9108f220458034373f2e5fc8eb9edf168 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 07:45:33 +0000 Subject: [PATCH 6/7] fix(android): handle each sign-in redirect once, however the activity is recreated Skipping the redirect whenever there was saved state could drop a callback that arrives as the creating intent after process death. Remember the one already handled instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011pXA3ei7MugJjuxWmZ12Y2 --- .../kotlin/aero/flyfun/forms/MainActivity.kt | 18 +++++++++++++++--- .../kotlin/aero/flyfun/forms/ui/FlyFunApp.kt | 5 +++-- 2 files changed, 18 insertions(+), 5 deletions(-) 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 5074374..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 @@ -21,6 +21,9 @@ 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) @@ -39,9 +42,13 @@ class MainActivity : ComponentActivity() { FlyFunApp(auth = auth, tokens = tokens, api = api) } } - // Not again on rotation: the nonce is spent, and a second pass would - // report a sign-in that just worked as failed. - if (savedInstanceState == null) handleAuthRedirect(intent) + 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() { @@ -62,11 +69,16 @@ class MainActivity : ComponentActivity() { 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) 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 d7037ff..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 @@ -188,8 +188,9 @@ fun FlyFunApp(auth: AuthService, tokens: TokenStore, api: ApiClient) { // quietly on every form. androidx.compose.runtime.LaunchedEffect(signedIn) { if (signedIn) skippedSignIn = false } - // Above the sign-in screen, so an expired token mid-edit keeps the back - // stack, and with it the flight draft, for after signing in again. + // 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() From 9d1392e700074ee7c8b9da562ca0c6b9d5e7a428 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 08:43:33 +0000 Subject: [PATCH 7/7] test(android): generate the trip-extras test's passphrase instead of a literal GitGuardian flagged the literal as a generic password. It only ever encrypted an in-memory test database, so nothing needs rotating; this stops the new line tripping the scanner. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011pXA3ei7MugJjuxWmZ12Y2 --- .../kotlin/aero/flyfun/forms/data/DataTransferTest.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 d59974f..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 @@ -87,7 +87,9 @@ class DataTransferTest { fun trip_extra_fields_survive_the_round_trip() = runTest { val trip = TripEntity(name = "Alps", extraFieldsJson = """{"reason_for_visit":"Maintenance"}""") source.tripDao().upsert(trip) - val password = "golf-hotel-india-juliet-kilo".toCharArray() + // 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)