diff --git a/app/android/app/build.gradle.kts b/app/android/app/build.gradle.kts index e8575b3..03d6c14 100644 --- a/app/android/app/build.gradle.kts +++ b/app/android/app/build.gradle.kts @@ -95,6 +95,9 @@ dependencies { implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material.icons) + implementation(libs.androidx.compose.material3.navigation.suite) + implementation(libs.androidx.compose.adaptive) + implementation(libs.androidx.compose.adaptive.layout) implementation(libs.androidx.navigation.compose) debugImplementation(libs.androidx.compose.ui.tooling) @@ -105,7 +108,6 @@ dependencies { implementation(libs.retrofit) implementation(libs.okhttp) implementation(libs.androidx.browser) - implementation(libs.androidx.security.crypto) implementation(libs.androidx.camera.camera2) implementation(libs.androidx.camera.lifecycle) implementation(libs.androidx.camera.view) diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml index f3a5bda..66f78a3 100644 --- a/app/android/app/src/main/AndroidManifest.xml +++ b/app/android/app/src/main/AndroidManifest.xml @@ -13,8 +13,9 @@ android:dataExtractionRules="@xml/data_extraction_rules" android:fullBackupContent="false" android:icon="@mipmap/ic_launcher" - android:roundIcon="@mipmap/ic_launcher_round" + android:roundIcon="@mipmap/ic_launcher" android:label="@string/app_name" + android:localeConfig="@xml/locales_config" android:supportsRtl="true" android:theme="@style/Theme.FlyFunForms"> + + (null) + /** The redirect already handled, so a recreated activity does not handle it twice. */ private var handledCallback: String? = null override fun onCreate(savedInstanceState: Bundle?) { + // Target 35+ draws edge to edge regardless; this also makes the system + // bar icons follow the theme, light or dark. + enableEdgeToEdge() super.onCreate(savedInstanceState) tokens = TokenStore(this) api = ApiClient(tokens) @@ -38,12 +47,15 @@ class MainActivity : ComponentActivity() { } setContent { - MaterialTheme { - FlyFunApp(auth = auth, tokens = tokens, api = api) + FlyFunTheme { + FlyFunApp(auth = auth, tokens = tokens, api = api, shortcut = shortcut) } } handledCallback = savedInstanceState?.getString(KEY_HANDLED_CALLBACK) handleAuthRedirect(intent) + // Only on a fresh start: a recreated activity keeps its old intent, + // and the shortcut was acted on the first time. + if (savedInstanceState == null) shortcut.value = AppShortcut.from(intent?.action) } override fun onSaveInstanceState(outState: Bundle) { @@ -65,6 +77,7 @@ class MainActivity : ComponentActivity() { super.onNewIntent(intent) setIntent(intent) handleAuthRedirect(intent) + AppShortcut.from(intent.action)?.let { shortcut.value = it } } private companion object { @@ -85,7 +98,7 @@ class MainActivity : ComponentActivity() { .onFailure { Toast.makeText( this@MainActivity, - it.message ?: "Sign-in failed", + it.message ?: getString(R.string.app_sign_in_failed), Toast.LENGTH_LONG, ).show() } 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 398f076..29af3e3 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 @@ -1,5 +1,6 @@ package aero.flyfun.forms.auth +import aero.flyfun.forms.R import aero.flyfun.forms.net.ApiClient import aero.flyfun.forms.net.ApiConfig import aero.flyfun.forms.net.ExchangeRequest @@ -12,6 +13,19 @@ import kotlinx.coroutines.flow.asStateFlow import java.security.SecureRandom import android.util.Base64 +/** The sign-in providers the flyfun server offers, by their path segment in `/auth/login/{provider}`. */ +enum class SignInProvider(val path: String) { + GOOGLE("google"), + + /** + * Sign in with Apple through the same web flow as Google: Apple posts back + * to the server (`response_mode=form_post`), which then redirects to the + * app with the auth code, so nothing Apple-specific happens on Android. + * The native `POST /auth/apple/token` route needs the iOS SDK. + */ + APPLE("apple"), +} + /** * Google / Apple sign-in through a Chrome Custom Tab. * @@ -69,12 +83,12 @@ class AuthService( }.commit() } - fun startSignIn(provider: String = "google") { + fun startSignIn(provider: SignInProvider) { val state = newState().also { pendingState = it } val url = Uri.parse(ApiConfig.BASE_URL).buildUpon() .appendPath("auth") .appendPath("login") - .appendPath(provider) + .appendPath(provider.path) // The server's native branch keys off `platform=ios`. That name is // historical - it means "native app", not the OS - and it is what // selects the custom-scheme redirect instead of a web session @@ -106,11 +120,11 @@ class AuthService( // no `state`. We always send one, so seeing this means something // else produced the redirect - refuse it rather than trusting a // token that arrived over a scheme any app can claim. - return Result.failure(IllegalStateException("Sign-in did not return an auth code")) + return Result.failure(IllegalStateException(context.getString(R.string.app_sign_in_no_code))) } val expected = pendingState if (expected == null || state != expected) { - return Result.failure(IllegalStateException("Sign-in state did not match")) + return Result.failure(IllegalStateException(context.getString(R.string.app_sign_in_state_mismatch))) } pendingState = null @@ -138,9 +152,9 @@ class AuthService( if (response.code() == 401) { // ApiClient has already dropped the token, so the sign-in screen // replaces Settings before its error could show. Say it there. - _signInNotice.value = "Your session had expired, so your account was not deleted. Sign in again to delete it." + _signInNotice.value = context.getString(R.string.app_delete_account_expired) } - if (!response.isSuccessful) error("The server returned ${response.code()}. Your account was not deleted.") + if (!response.isSuccessful) error(context.getString(R.string.app_delete_account_server_error, response.code())) tokens.clear() } 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 70fa014..63ce59b 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 @@ -2,45 +2,57 @@ package aero.flyfun.forms.auth import android.content.Context import android.content.SharedPreferences -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec /** - * The session JWT, in EncryptedSharedPreferences backed by a Keystore master - * key - the nearest equivalent to the iOS app's Keychain storage. + * The session JWT, encrypted with an AES-GCM key that never leaves the Android + * Keystore - the nearest equivalent to the iOS app's Keychain storage. * * App-private storage is already encrypted at rest on modern Android, but only * up to first unlock after boot. A bearer token for an account holding passport * data is worth the extra key. + * + * Replaces `EncryptedSharedPreferences`, which androidx.security deprecated: + * one value does not need an encrypted key-value store, only its own key. The + * ciphertext (IV first) sits in plain app-private preferences. */ class TokenStore(context: Context) { - private val prefs: SharedPreferences = run { - val key = MasterKey.Builder(context) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() - EncryptedSharedPreferences.create( - context, - "flyfun-auth", - key, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, - ) + private val prefs: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + init { + // The EncryptedSharedPreferences file an earlier build kept the token + // in. Not carried over: the app was never released, and signing in + // once more is the whole cost. + context.deleteSharedPreferences(LEGACY_PREFS) } - private val _signedIn = MutableStateFlow(prefs.getString(KEY_TOKEN, null) != null) + @Volatile + private var cached: String? = read() + + private val _signedIn = MutableStateFlow(cached != 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) + get() = cached + @Synchronized set(value) { + cached = value + val stored = value?.let { runCatching { encrypt(it) }.getOrNull() } prefs.edit().apply { - if (value == null) remove(KEY_TOKEN) else putString(KEY_TOKEN, value) + if (stored == null) remove(KEY_TOKEN) else putString(KEY_TOKEN, stored) }.apply() _signedIn.value = value != null } @@ -60,7 +72,61 @@ class TokenStore(context: Context) { if (token == rejected) clear() } + /** + * Take the successor the server minted for [sent] as it neared expiry + * (flyfun-common's rolling sessions, `X-Renewed-Token`). Only while [sent] + * is still the one held: a sign-out or a newer renewal meanwhile wins. + */ + @Synchronized + fun replaceIfCurrent(sent: String, renewed: String) { + if (token == sent && renewed.isNotBlank()) token = renewed + } + + /** The stored token, or null when there is none or it no longer decrypts (key lost with a reset lock screen, say). */ + private fun read(): String? { + val stored = prefs.getString(KEY_TOKEN, null) ?: return null + return runCatching { decrypt(stored) }.getOrElse { + prefs.edit().remove(KEY_TOKEN).apply() + null + } + } + + private fun key(): SecretKey { + val keyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) } + (keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it } + val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE) + generator.init( + KeyGenParameterSpec.Builder(KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build(), + ) + return generator.generateKey() + } + + private fun encrypt(plain: String): String { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, key()) + val sealed = cipher.iv + cipher.doFinal(plain.toByteArray(Charsets.UTF_8)) + return Base64.encodeToString(sealed, Base64.NO_WRAP) + } + + private fun decrypt(stored: String): String { + val sealed = Base64.decode(stored, Base64.NO_WRAP) + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(TAG_BITS, sealed, 0, IV_BYTES)) + return String(cipher.doFinal(sealed, IV_BYTES, sealed.size - IV_BYTES), Charsets.UTF_8) + } + private companion object { + const val PREFS = "flyfun-session" + const val LEGACY_PREFS = "flyfun-auth" const val KEY_TOKEN = "session_jwt" + const val KEYSTORE = "AndroidKeyStore" + const val KEY_ALIAS = "flyfun-session-token" + const val TRANSFORMATION = "AES/GCM/NoPadding" + const val IV_BYTES = 12 + const val TAG_BITS = 128 } } 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 505be41..3c0a920 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 @@ -23,6 +23,9 @@ object ApiConfig { /** The public privacy notice, served by the same backend. Linked from Settings. */ const val PRIVACY_URL = "${BASE_URL}privacy" + /** Flights planned in FlyFun Weather, for import; see [WeatherApi]. */ + const val WEATHER_URL = "https://weather.flyfun.aero/" + /** Reused from iOS: the allowlist already contains it, and the two platforms cannot collide on one device. */ const val CALLBACK_SCHEME = "flyfunforms" const val CALLBACK_URL = "$CALLBACK_SCHEME://auth/callback" @@ -30,6 +33,11 @@ object ApiConfig { class ApiClient(private val tokens: TokenStore, baseUrl: String = ApiConfig.BASE_URL) { + private companion object { + /** flyfun-common `SlidingSessionMiddleware`: the renewed JWT for a Bearer request. */ + const val RENEWED_TOKEN_HEADER = "X-Renewed-Token" + } + private val json = Json { ignoreUnknownKeys = true explicitNulls = false @@ -45,7 +53,14 @@ class ApiClient(private val tokens: TokenStore, baseUrl: String = ApiConfig.BASE * 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 -> + private val authInterceptor = bearer(signOutOn401 = true) + + /** + * The same account's token for another flyfun service. A 401 there says + * that service would not take it, not that the forms session is over, so + * it signs nothing out; renewals are still kept. + */ + private fun bearer(signOutOn401: Boolean) = Interceptor { chain -> val token = tokens.token val request = if (token != null) { chain.request().newBuilder().addHeader("Authorization", "Bearer $token").build() @@ -53,7 +68,15 @@ class ApiClient(private val tokens: TokenStore, baseUrl: String = ApiConfig.BASE chain.request() } val response = chain.proceed(request) - if (response.code == 401 && token != null) tokens.clearIfCurrent(token) + if (token != null) { + if (response.code == 401) { + if (signOutOn401) tokens.clearIfCurrent(token) + } else { + // Rolling sessions: a token near expiry comes back with its + // successor, so a pilot who keeps using the app stays signed in. + response.header(RENEWED_TOKEN_HEADER)?.let { tokens.replaceIfCurrent(token, it) } + } + } response } @@ -82,6 +105,22 @@ class ApiClient(private val tokens: TokenStore, baseUrl: String = ApiConfig.BASE .build() .create(NotificationsApi::class.java) + /** + * FlyFun Weather, signed in as the same account: the flyfun services + * share the account and its tokens, as on iOS (`RollingBearerSession`). + */ + val weather: WeatherApi = Retrofit.Builder() + .baseUrl(ApiConfig.WEATHER_URL) + .client( + OkHttpClient.Builder() + .addInterceptor(bearer(signOutOn401 = false)) + .connectTimeout(20, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build(), + ) + .build() + .create(WeatherApi::class.java) + /** Parses a 422 body into the structured errors the UI shows. */ fun parseValidationErrors(body: String): List = runCatching { json.decodeFromString(ValidationErrorEnvelope.serializer(), body).detail diff --git a/app/android/app/src/main/kotlin/aero/flyfun/forms/net/WeatherApi.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/net/WeatherApi.kt new file mode 100644 index 0000000..0de9607 --- /dev/null +++ b/app/android/app/src/main/kotlin/aero/flyfun/forms/net/WeatherApi.kt @@ -0,0 +1,56 @@ +package aero.flyfun.forms.net + +import aero.flyfun.forms.R +import aero.flyfun.forms.logic.FlightExchange +import aero.flyfun.forms.logic.WeatherFlightSummary +import android.content.res.Resources +import androidx.annotation.StringRes +import okhttp3.ResponseBody +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Path + +/** + * FlyFun Weather (`weather.flyfun.aero`), as the "Import from FlyFun Weather" + * method uses it: the pilot's flights, and one of them in the cross-app + * `FlightExchange` format. Port of iOS `WeatherImportService`. + * + * Bodies stay raw: `:core-logic` decodes them, where the format is tested. + */ +interface WeatherApi { + @GET("api/flights") + suspend fun flights(): Response + + @GET("api/flights/{id}/export") + suspend fun export(@Path("id") id: String): Response +} + +/** A failed weather call, with what to tell the pilot as a string resource. */ +class WeatherImportException(@StringRes val text: Int, private vararg val args: Any) : Exception() { + fun message(resources: Resources): String = resources.getString(text, *args) +} + +/** The pilot's weather flights, newest departure first. */ +suspend fun WeatherApi.listFlights(): List { + val response = flights() + return WeatherFlightSummary.decodeList(response.bodyOrThrow()) +} + +/** One weather flight as a `FlightExchange`. */ +suspend fun WeatherApi.exportFlight(id: String): FlightExchange { + val response = export(id) + if (response.code() == 422) throw WeatherImportException(R.string.app_weather_no_route) + val body = response.bodyOrThrow() + return runCatching { FlightExchange.decode(body) }.getOrElse { + throw WeatherImportException(R.string.app_weather_unreadable, it.message.orEmpty()) + } +} + +private fun Response.bodyOrThrow(): String { + if (code() == 401) throw WeatherImportException(R.string.app_weather_sign_in) + if (!isSuccessful) { + val detail = errorBody()?.string()?.take(200)?.takeIf { it.isNotBlank() }?.let { ": $it" }.orEmpty() + throw WeatherImportException(R.string.app_weather_server_error, code(), detail) + } + return body()?.string() ?: throw WeatherImportException(R.string.app_weather_empty) +} diff --git a/app/android/app/src/main/kotlin/aero/flyfun/forms/scan/ScanScreen.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/scan/ScanScreen.kt index 4dd4b53..17962e7 100644 --- a/app/android/app/src/main/kotlin/aero/flyfun/forms/scan/ScanScreen.kt +++ b/app/android/app/src/main/kotlin/aero/flyfun/forms/scan/ScanScreen.kt @@ -1,5 +1,6 @@ package aero.flyfun.forms.scan +import aero.flyfun.forms.R import aero.flyfun.forms.logic.MRZScanResult import android.Manifest import android.content.pm.PackageManager @@ -46,6 +47,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat @@ -97,20 +99,20 @@ fun ScanScreen(onScanned: (MRZScanResult) -> Unit, onBack: () -> Unit) { Scaffold( topBar = { TopAppBar( - title = { Text("Scan document") }, + title = { Text(stringResource(R.string.scan_title)) }, navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.Default.ArrowBack, contentDescription = "Back") + Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.people_back)) } }, actions = { Box { IconButton(onClick = { sourceMenu = true }, enabled = !reading) { - Icon(Icons.Default.PhotoLibrary, contentDescription = "Scan from a photo or PDF") + Icon(Icons.Default.PhotoLibrary, contentDescription = stringResource(R.string.scan_from_photo_or_pdf)) } DropdownMenu(expanded = sourceMenu, onDismissRequest = { sourceMenu = false }) { DropdownMenuItem( - text = { Text("Choose Photo") }, + text = { Text(stringResource(R.string.scan_choose_photo)) }, leadingIcon = { Icon(Icons.Default.Image, contentDescription = null) }, onClick = { sourceMenu = false @@ -118,7 +120,7 @@ fun ScanScreen(onScanned: (MRZScanResult) -> Unit, onBack: () -> Unit) { }, ) DropdownMenuItem( - text = { Text("Choose PDF") }, + text = { Text(stringResource(R.string.scan_choose_pdf)) }, leadingIcon = { Icon(Icons.Default.PictureAsPdf, contentDescription = null) }, onClick = { sourceMenu = false; pdf.launch(arrayOf("application/pdf")) }, ) @@ -135,21 +137,20 @@ fun ScanScreen(onScanned: (MRZScanResult) -> Unit, onBack: () -> Unit) { Arrangement.Center, Alignment.CenterHorizontally, ) { - Text("Camera access", style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.scan_camera_access), style = MaterialTheme.typography.titleMedium) Text( - "Used only to read the two lines at the bottom of a passport. " + - "No image is saved.", + stringResource(R.string.scan_camera_rationale), style = MaterialTheme.typography.bodyMedium, ) Button( onClick = { request.launch(Manifest.permission.CAMERA) }, modifier = Modifier.padding(top = 12.dp), - ) { Text("Allow camera") } + ) { Text(stringResource(R.string.scan_allow_camera)) } } } else { CameraPreview(onScanned) Text( - "Line up the two lines at the bottom of the passport or ID card", + stringResource(R.string.scan_line_up), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth().padding(24.dp), ) @@ -161,14 +162,11 @@ fun ScanScreen(onScanned: (MRZScanResult) -> Unit, onBack: () -> Unit) { if (notFound) { AlertDialog( onDismissRequest = { notFound = false }, - title = { Text("No Document Found") }, + title = { Text(stringResource(R.string.scan_no_document_found)) }, text = { - Text( - "No machine-readable zone (MRZ) was found in the file. " + - "Try a clearer image or PDF of the passport page.", - ) + Text(stringResource(R.string.scan_no_mrz_found)) }, - confirmButton = { TextButton(onClick = { notFound = false }) { Text("OK") } }, + confirmButton = { TextButton(onClick = { notFound = false }) { Text(stringResource(R.string.people_ok)) } }, ) } diff --git a/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/Adaptive.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/Adaptive.kt new file mode 100644 index 0000000..8a78695 --- /dev/null +++ b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/Adaptive.kt @@ -0,0 +1,89 @@ +package aero.flyfun.forms.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.adaptive.layout.AnimatedPane +import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold +import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldDefaults +import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole +import androidx.compose.material3.adaptive.layout.PaneScaffoldDirective +import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldDestinationItem +import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective +import androidx.compose.material3.adaptive.layout.calculateThreePaneScaffoldValue +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +/** + * The window's pane layout: one pane on a phone, list and detail side by side + * once there is room (a tablet, an unfolded foldable, a phone in landscape on + * some sizes). + */ +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +fun paneDirective(): PaneScaffoldDirective = calculatePaneScaffoldDirective(currentWindowAdaptiveInfo()) + +/** Whether list and detail show together. */ +@Composable +fun isTwoPane(): Boolean = paneDirective().maxHorizontalPartitions > 1 + +/** + * A list and the item open from it, laid out by [ListDetailPaneScaffold]. + * + * The navigation back stack is the navigator: a tab's list route shows the list + * (and [placeholder] beside it when there is room), an item's route shows the + * item (and the list beside it). On a phone that is exactly one screen per + * route, as before; on a tablet the list stays put while items change. Keeping + * the back stack in charge leaves every screen's ViewModel scoped to its route, + * and Back, the unsaved-changes prompt and the pickers work as on a phone. + */ +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +fun ListDetail( + showingDetail: Boolean, + list: @Composable () -> Unit, + detail: @Composable () -> Unit, +) { + val directive = paneDirective() + val value = calculateThreePaneScaffoldValue( + maxHorizontalPartitions = directive.maxHorizontalPartitions, + adaptStrategies = ListDetailPaneScaffoldDefaults.adaptStrategies(), + currentDestination = ThreePaneScaffoldDestinationItem( + if (showingDetail) ListDetailPaneScaffoldRole.Detail else ListDetailPaneScaffoldRole.List, + ), + ) + ListDetailPaneScaffold( + directive = directive, + value = value, + listPane = { AnimatedPane { list() } }, + detailPane = { AnimatedPane { detail() } }, + ) +} + +/** The detail pane before anything is picked from the list. */ +@Composable +fun NothingSelected(text: String) { + Surface(color = MaterialTheme.colorScheme.surfaceContainerLow, modifier = Modifier.fillMaxSize()) { + Column( + Modifier.fillMaxSize().padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } +} 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 c03c257..142fcdc 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 @@ -1,6 +1,9 @@ package aero.flyfun.forms.ui +import aero.flyfun.forms.R import aero.flyfun.forms.auth.AuthService +import aero.flyfun.forms.auth.SignInProvider +import aero.flyfun.forms.ui.common.SignInButtons import aero.flyfun.forms.auth.TokenStore import aero.flyfun.forms.data.AircraftEntity import aero.flyfun.forms.data.FlightEntity @@ -28,6 +31,7 @@ import aero.flyfun.forms.ui.flights.NewFlightScreen import aero.flyfun.forms.ui.flights.NewFlightStep import aero.flyfun.forms.ui.flights.PastFlightPickerScreen import aero.flyfun.forms.ui.flights.PastFlightRow +import aero.flyfun.forms.ui.flights.WeatherFlightPickerScreen import aero.flyfun.forms.ui.flights.SuggestionChoice import aero.flyfun.forms.logic.PeopleSuggestion import aero.flyfun.forms.ui.flights.FlightListScreen @@ -51,7 +55,18 @@ import aero.flyfun.forms.ui.settings.SettingsScreen import android.content.Context import android.content.Intent import androidx.core.content.FileProvider +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -63,9 +78,10 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.NavigationBar -import androidx.compose.material3.NavigationBarItem -import androidx.compose.material3.Scaffold +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldDefaults +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState @@ -84,10 +100,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.annotation.StringRes import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavBackStackEntry import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -95,6 +114,8 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -115,15 +136,16 @@ private class Factory( private val appVersion: String, private val preferences: Preferences, private val airports: AirportDatabase, + private val resources: android.content.res.Resources, ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T = when { - modelClass.isAssignableFrom(PeopleViewModel::class.java) -> PeopleViewModel(people, flights) as T + modelClass.isAssignableFrom(PeopleViewModel::class.java) -> PeopleViewModel(people, flights, resources) as T modelClass.isAssignableFrom(AircraftViewModel::class.java) -> AircraftViewModel(flights) as T modelClass.isAssignableFrom(FlightsViewModel::class.java) -> - FlightsViewModel(flights, people, api, cacheDir, { preferences.spokenLanguages.value }, airports) as T + FlightsViewModel(flights, people, api, cacheDir, { preferences.spokenLanguages.value }, airports, resources) as T modelClass.isAssignableFrom(DataTransferViewModel::class.java) -> - DataTransferViewModel(transfer, eraser, cacheDir, appVersion) as T + DataTransferViewModel(transfer, eraser, cacheDir, appVersion, resources) as T else -> error("Unknown ViewModel ${modelClass.name}") } } @@ -141,21 +163,22 @@ private class Deletions( private val flights: FlightRepository, private val scope: CoroutineScope, private val snackbar: SnackbarHostState, + private val resources: android.content.res.Resources, ) { fun person(person: PersonEntity) = delete( - "Deleted ${person.displayName.ifBlank { "person" }}", + resources.getString(R.string.app_deleted, person.displayName.ifBlank { resources.getString(R.string.app_deleted_person) }), { people.deletePerson(person.id) }, { people.restorePerson(person.id) }, ) fun aircraft(aircraft: AircraftEntity) = delete( - "Deleted ${aircraft.registration.ifBlank { "aircraft" }}", + resources.getString(R.string.app_deleted, aircraft.registration.ifBlank { resources.getString(R.string.app_deleted_aircraft) }), { flights.deleteAircraft(aircraft.id) }, { flights.restoreAircraft(aircraft.id) }, ) fun flight(flight: FlightEntity) = delete( - "Deleted ${flight.originICAO.ifBlank { "????" }} → ${flight.destinationICAO.ifBlank { "????" }}", + resources.getString(R.string.app_deleted, "${flight.originICAO.ifBlank { "????" }} → ${flight.destinationICAO.ifBlank { "????" }}"), { flights.deleteFlight(flight.id) }, { flights.restoreFlight(flight.id) }, ) @@ -165,7 +188,7 @@ private class Deletions( 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) + val result = snackbar.showSnackbar(message, actionLabel = resources.getString(R.string.app_undo), duration = SnackbarDuration.Long) if (result == SnackbarResult.ActionPerformed) restore() } } @@ -180,18 +203,27 @@ private const val ADDED_PERSON = "addedPersonId" /** A person from the address book. */ private const val CONTACT_IMPORT = "people/contact" +/** The id in "person/new": a person not stored yet. */ +private const val NEW_PERSON = "new" + /** A scan that is not for anyone yet. */ private const val STANDALONE_SCAN = "people/scan" -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), - AIRCRAFT("aircraft", "Aircraft", Icons.Default.AirplanemodeActive), - SETTINGS("settings", "Settings", Icons.Default.Settings), +private enum class Tab(val route: String, @StringRes val label: Int, val icon: ImageVector) { + FLIGHTS("flights", R.string.app_tab_flights, Icons.Default.Flight), + PEOPLE("people", R.string.app_tab_people, Icons.Default.People), + AIRCRAFT("aircraft", R.string.app_tab_aircraft, Icons.Default.AirplanemodeActive), + SETTINGS("settings", R.string.app_tab_settings, Icons.Default.Settings), } @Composable -fun FlyFunApp(auth: AuthService, tokens: TokenStore, api: ApiClient) { +fun FlyFunApp( + auth: AuthService, + tokens: TokenStore, + api: ApiClient, + /** Set when the app is opened from a launcher shortcut; cleared once acted on. */ + shortcut: MutableStateFlow, +) { val context = LocalContext.current val repositories = remember { val db = FlyFunDatabase.get(context) @@ -219,6 +251,8 @@ fun FlyFunApp(auth: AuthService, tokens: TokenStore, api: ApiClient) { }.getOrDefault(""), preferences = preferences, airports = AirportDatabase.get(context), + // The application's: ViewModels outlive the activity. + resources = context.applicationContext.resources, ) } val signedIn by tokens.signedIn.collectAsState() @@ -237,14 +271,14 @@ fun FlyFunApp(auth: AuthService, tokens: TokenStore, api: ApiClient) { val snackbar = remember { SnackbarHostState() } val appScope = rememberCoroutineScope() val deletions = remember { - Deletions(repositories.first, repositories.second, appScope, snackbar) + Deletions(repositories.first, repositories.second, appScope, snackbar, context.applicationContext.resources) } val signInNotice by auth.signInNotice.collectAsState() if (!signedIn && !skippedSignIn) { SignInScreen( notice = signInNotice, - onSignIn = { auth.startSignIn() }, + onSignIn = { auth.startSignIn(it) }, // 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. @@ -256,60 +290,116 @@ fun FlyFunApp(auth: AuthService, tokens: TokenStore, api: ApiClient) { val backStack by navController.currentBackStackEntryAsState() val currentRoute = backStack?.destination?.route + val stack by navController.currentBackStack.collectAsState() + // The tab whose list the current screen was opened from. + val currentTab = stack.lastOrNull { entry -> Tab.entries.any { it.route == entry.destination.route } } + ?.let { entry -> Tab.entries.first { it.route == entry.destination.route } } + val twoPane = isTwoPane() + val atTabRoot = Tab.entries.any { it.route == currentRoute } - Scaffold( - snackbarHost = { SnackbarHost(snackbar) }, - bottomBar = { - if (Tab.entries.any { it.route == currentRoute }) { - NavigationBar { - Tab.entries.forEach { tab -> - NavigationBarItem( - selected = currentRoute == tab.route, - onClick = { - navController.navigate(tab.route) { - popUpTo(Tab.FLIGHTS.route) { saveState = true } - launchSingleTop = true - restoreState = true - } - }, - icon = { Icon(tab.icon, contentDescription = tab.label) }, - label = { Text(tab.label) }, - ) - } - } + // A bar on a phone, a rail on a tablet. The phone's bar shows only on the + // lists, as before; beside two panes the rail stays, since the list does. + val defaultLayout = NavigationSuiteScaffoldDefaults.calculateFromAdaptiveInfo(currentWindowAdaptiveInfo()) + NavigationSuiteScaffold( + layoutType = if (atTabRoot || (twoPane && defaultLayout != NavigationSuiteType.NavigationBar)) { + defaultLayout + } else { + NavigationSuiteType.None + }, + navigationSuiteItems = { + Tab.entries.forEach { tab -> + item( + selected = currentTab == tab, + onClick = { + navController.navigate(tab.route) { + popUpTo(Tab.FLIGHTS.route) { saveState = true } + launchSingleTop = true + restoreState = true + } + }, + icon = { Icon(tab.icon, contentDescription = null) }, + label = { Text(stringResource(tab.label)) }, + ) } }, - ) { padding -> - NavHost( - navController = navController, - startDestination = Tab.FLIGHTS.route, - modifier = Modifier.padding(padding), - ) { - flightRoutes(navController, factory, context, deletions) - peopleRoutes(navController, factory, deletions) - aircraftRoutes(navController, factory, deletions) - settingsRoute(factory, context, tokens, auth, preferences) + ) { + Box(Modifier.fillMaxSize()) { + NavHost( + navController = navController, + startDestination = Tab.FLIGHTS.route, + // Beside a list that stays put, only the detail pane should + // change; a whole-window fade would flash the list too. + enterTransition = { if (twoPane) EnterTransition.None else fadeIn(tween(700)) }, + exitTransition = { if (twoPane) ExitTransition.None else fadeOut(tween(700)) }, + ) { + flightRoutes(navController, factory, context, deletions, tokens) + peopleRoutes(navController, factory, deletions) + aircraftRoutes(navController, factory, deletions) + settingsRoute(factory, context, tokens, auth, preferences) + } + SnackbarHost( + snackbar, + Modifier + .align(Alignment.BottomCenter) + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal)), + ) + } + } + + // Once the NavHost has set its graph. On top of whatever is open, so + // an unsaved flight underneath is still there on Back. + val pendingShortcut by shortcut.collectAsState() + androidx.compose.runtime.LaunchedEffect(pendingShortcut) { + val target = pendingShortcut ?: return@LaunchedEffect + navController.currentBackStackEntryFlow.first() + when (target) { + AppShortcut.NEW_FLIGHT -> navController.navigate("flight/${FlightsViewModel.NEW_FLIGHT}") + AppShortcut.SCAN_DOCUMENT -> navController.navigate(STANDALONE_SCAN) } + shortcut.value = null + } +} + +/** The launcher's long-press shortcuts (res/xml/shortcuts.xml), by intent action. */ +enum class AppShortcut(val action: String) { + NEW_FLIGHT("aero.flyfun.forms.action.NEW_FLIGHT"), + SCAN_DOCUMENT("aero.flyfun.forms.action.SCAN_DOCUMENT"); + + companion object { + fun from(action: String?): AppShortcut? = entries.firstOrNull { it.action == action } } } +/** + * Whether [entry] was opened from [tab]'s list, so the list belongs beside it. + * A person opened from a flight's picker, say, is not: it shows alone. + */ +@Composable +private fun openedFrom(nav: androidx.navigation.NavHostController, entry: NavBackStackEntry, tab: Tab): Boolean { + val stack by nav.currentBackStack.collectAsState() + val index = stack.indexOfFirst { it.id == entry.id } + return index > 0 && stack[index - 1].destination.route == tab.route +} + +/** Open an item beside its tab's list, replacing whichever item was open. */ +private fun androidx.navigation.NavHostController.openFromList(route: String, tab: Tab) = + navigate(route) { popUpTo(tab.route) } + private fun androidx.navigation.NavGraphBuilder.flightRoutes( nav: androidx.navigation.NavHostController, factory: ViewModelProvider.Factory, context: Context, deletions: Deletions, + tokens: TokenStore, ) { 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}") }, - onDelete = { deletions.flight(it) }, + ListDetail( + showingDetail = false, + list = { + FlightList(vm, deletions, selectedId = null, onOpen = { nav.openFromList("flight/$it", Tab.FLIGHTS) }) + }, + detail = { NothingSelected(stringResource(R.string.app_pick_flight)) }, ) } @@ -318,236 +408,329 @@ private fun androidx.navigation.NavGraphBuilder.flightRoutes( arguments = listOf(navArgument("flightId") { type = NavType.StringType }), ) { entry -> val flightId = entry.arguments?.getString("flightId").orEmpty() + if (!openedFrom(nav, entry, Tab.FLIGHTS)) { + FlightRoute(entry, flightId, nav, factory, context, deletions, tokens) + return@composable + } val vm: FlightsViewModel = viewModel(factory = factory) - val peopleVm: PeopleViewModel = viewModel(factory = factory) - val detail by vm.detail.collectAsState() val unsaved by vm.hasUnsavedChanges.collectAsState() - val aircraft by vm.aircraft.collectAsState() - val people by peopleVm.people.collectAsState() - val forms by vm.airportForms.collectAsState() - val generate by vm.generate.collectAsState() - val extraValues by vm.extraValues.collectAsState() - val lastFlights by peopleVm.lastFlights.collectAsState() - val flightPeople by peopleVm.flightPeople.collectAsState() val scope = rememberCoroutineScope() - var pickingPeople by rememberSaveable { mutableStateOf(false) } - var pickingRoute by rememberSaveable { mutableStateOf(false) } - // The pickers below replace the flight screen, which drops its saveable - // state (a schedule's chosen zone, say); the holder keeps it for the return. - val screens = rememberSaveableStateHolder() - val airportInfo by vm.airportInfo.collectAsState() - // + opens the two-step flow; a leg made from another flight opens the editor. - var newFlow by rememberSaveable { mutableStateOf(flightId == FlightsViewModel.NEW_FLIGHT) } - var newStep by rememberSaveable { mutableStateOf(NewFlightStep.ROUTE) } - var pickingPrevious by rememberSaveable { mutableStateOf(false) } - var pickingCrewSource by rememberSaveable { mutableStateOf(false) } - val importSummary by vm.importSummary.collectAsState() - val allFlights by vm.allFlights.collectAsState() + // Another flight picked from the list while this one has edits: the + // same question Back asks. + var pendingOpen by rememberSaveable { mutableStateOf(null) } + ListDetail( + showingDetail = true, + list = { + FlightList(vm, deletions, selectedId = flightId, onOpen = { id -> + if (id == flightId) return@FlightList + if (unsaved) pendingOpen = id else nav.openFromList("flight/$id", Tab.FLIGHTS) + }) + }, + detail = { FlightRoute(entry, flightId, nav, factory, context, deletions, tokens) }, + ) + pendingOpen?.let { next -> + androidx.compose.material3.AlertDialog( + onDismissRequest = { pendingOpen = null }, + title = { Text(stringResource(R.string.app_save_changes_title)) }, + text = { Text(stringResource(R.string.app_open_other_flight_discards)) }, + confirmButton = { + androidx.compose.material3.TextButton(onClick = { + pendingOpen = null + scope.launch { vm.save().join(); nav.openFromList("flight/$next", Tab.FLIGHTS) } + }) { Text(stringResource(R.string.app_save)) } + }, + dismissButton = { + androidx.compose.material3.TextButton(onClick = { + pendingOpen = null + nav.openFromList("flight/$next", Tab.FLIGHTS) + }) { Text(stringResource(R.string.app_discard)) } + }, + ) + } + } +} + +/** The flight list, as the Flights tab and beside an open flight. */ +@Composable +private fun FlightList( + vm: FlightsViewModel, + deletions: Deletions, + selectedId: String?, + onOpen: (String) -> Unit, +) { + val flights by vm.allFlights.collectAsState() + val aircraft by vm.aircraft.collectAsState() + FlightListScreen( + flights = flights, + aircraft = aircraft, + onOpen = onOpen, + // A draft, not a row: backing out of it leaves nothing behind. + onAdd = { onOpen(FlightsViewModel.NEW_FLIGHT) }, + onDelete = { deletions.flight(it) }, + selectedId = selectedId, + ) +} - androidx.compose.runtime.LaunchedEffect(flightId) { vm.open(flightId) } +/** + * One flight: the two-step flow for a new one, the editor otherwise, and the + * pickers and web form that take its place while they are open. + */ +@Composable +private fun FlightRoute( + entry: NavBackStackEntry, + flightId: String, + nav: androidx.navigation.NavHostController, + factory: ViewModelProvider.Factory, + context: Context, + deletions: Deletions, + tokens: TokenStore, +) { + val vm: FlightsViewModel = viewModel(viewModelStoreOwner = entry, factory = factory) + val signedIn by tokens.signedIn.collectAsState() + val stagedAircraft by vm.stagedAircraft.collectAsState() + val peopleVm: PeopleViewModel = viewModel(factory = factory) + val detail by vm.detail.collectAsState() + val unsaved by vm.hasUnsavedChanges.collectAsState() + val aircraft by vm.aircraft.collectAsState() + val people by peopleVm.people.collectAsState() + val forms by vm.airportForms.collectAsState() + val generate by vm.generate.collectAsState() + val extraValues by vm.extraValues.collectAsState() + val lastFlights by peopleVm.lastFlights.collectAsState() + val flightPeople by peopleVm.flightPeople.collectAsState() + val scope = rememberCoroutineScope() + var pickingPeople by rememberSaveable { mutableStateOf(false) } + var pickingRoute by rememberSaveable { mutableStateOf(false) } + // The pickers below replace the flight screen, which drops its saveable + // state (a schedule's chosen zone, say); the holder keeps it for the return. + val screens = rememberSaveableStateHolder() + val airportInfo by vm.airportInfo.collectAsState() + // + opens the two-step flow; a leg made from another flight opens the editor. + var newFlow by rememberSaveable { mutableStateOf(flightId == FlightsViewModel.NEW_FLIGHT) } + var newStep by rememberSaveable { mutableStateOf(NewFlightStep.ROUTE) } + var pickingPrevious by rememberSaveable { mutableStateOf(false) } + var pickingWeather by rememberSaveable { mutableStateOf(false) } + var pickingCrewSource by rememberSaveable { mutableStateOf(false) } + val importSummary by vm.importSummary.collectAsState() + val allFlights by vm.allFlights.collectAsState() - // Someone edited from here (opened from the picker, say) comes back - // with their stored details, which is what forms are built from. - androidx.compose.runtime.LaunchedEffect(people) { - vm.refreshPeople(people.associate { it.person.id to it.person }) - } + androidx.compose.runtime.LaunchedEffect(flightId) { vm.open(flightId) } - // A person created from the picker's + menu joins this flight. - val added by entry.savedStateHandle.getStateFlow(ADDED_PERSON, null).collectAsState() - androidx.compose.runtime.LaunchedEffect(added, people) { - val id = added ?: return@LaunchedEffect - val person = people.firstOrNull { it.person.id == id }?.person ?: return@LaunchedEffect - entry.savedStateHandle[ADDED_PERSON] = null - vm.addPerson(person) - } + // Someone edited from here (opened from the picker, say) comes back + // with their stored details, which is what forms are built from. + androidx.compose.runtime.LaunchedEffect(people) { + vm.refreshPeople(people.associate { it.person.id to it.person }) + } - // 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 person created from the picker's + menu joins this flight. + val added by entry.savedStateHandle.getStateFlow(ADDED_PERSON, null).collectAsState() + androidx.compose.runtime.LaunchedEffect(added, people) { + val id = added ?: return@LaunchedEffect + val person = people.firstOrNull { it.person.id == id }?.person ?: return@LaunchedEffect + entry.savedStateHandle[ADDED_PERSON] = null + vm.addPerson(person) + } - // 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() }) - return@composable + // 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() } + } - if (pickingRoute) { - detail?.let { current -> - RoutePickerScreen( - origin = current.flight.originICAO, - destination = current.flight.destinationICAO, - lookup = AirportLookup(vm::searchAirports, vm::airport), - recentRoutes = vm::recentRoutes, - onChange = { origin, destination -> - vm.editFlight { it.copy(originICAO = origin, destinationICAO = destination) } - }, - onDone = { pickingRoute = false }, - ) - return@composable - } - } + // 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() }) + return + } - if (pickingPeople) { - detail?.let { current -> - PeoplePickerScreen( - people = people, - lastFlights = lastFlights, - flightPeople = flightPeople, - crew = current.crew, - passengers = current.passengers, - onChange = { crew, passengers -> vm.setPeople(crew, passengers) }, - add = AddPersonActions( - onAdd = { nav.navigate("person/new") }, - onScan = { nav.navigate(STANDALONE_SCAN) }, - onFromContact = { nav.navigate(CONTACT_IMPORT) }, - ), - onAddNamed = { name -> - scope.launch { - val person = peopleVm.addNamed(name) - vm.addPerson(person) - nav.navigate("person/${person.id}") - } - }, - onDone = { pickingPeople = false }, - ) - return@composable - } + if (pickingRoute) { + detail?.let { current -> + RoutePickerScreen( + origin = current.flight.originICAO, + destination = current.flight.destinationICAO, + lookup = AirportLookup(vm::searchAirports, vm::airport), + recentRoutes = vm::recentRoutes, + onChange = { origin, destination -> + vm.editFlight { it.copy(originICAO = origin, destinationICAO = destination) } + }, + onDone = { pickingRoute = false }, + ) + return } + } - val current = detail - if (newFlow && current != null) { - val peopleById = people.associate { it.person.id to it.person } - val flightsById = allFlights.associateBy { it.id } - val registrations = aircraft.associate { it.id to it.registration } - val others = flightPeople.filter { it.flightId != current.flight.id } - fun namesOn(id: String) = others.firstOrNull { it.flightId == id }?.everyone.orEmpty() - .mapNotNull { peopleById[it]?.displayName } - fun row(flight: FlightEntity) = PastFlightRow(flight, registrations[flight.aircraftId], namesOn(flight.id)) + if (pickingPeople) { + detail?.let { current -> + PeoplePickerScreen( + people = people, + lastFlights = lastFlights, + flightPeople = flightPeople, + crew = current.crew, + passengers = current.passengers, + onChange = { crew, passengers -> vm.setPeople(crew, passengers) }, + add = AddPersonActions( + onAdd = { nav.navigate("person/new") }, + onScan = { nav.navigate(STANDALONE_SCAN) }, + onFromContact = { nav.navigate(CONTACT_IMPORT) }, + ), + onAddNamed = { name -> + scope.launch { + val person = peopleVm.addNamed(name) + vm.addPerson(person) + nav.navigate("person/${person.id}") + } + }, + onDone = { pickingPeople = false }, + ) + return + } + } - if (pickingPrevious) { - PastFlightPickerScreen( - title = "Previous Flight", - emptyText = "No earlier flights yet.", - rows = allFlights - .filter { it.id != current.flight.id && (it.originICAO.isNotBlank() || it.destinationICAO.isNotBlank()) } - .sortedByDescending { it.departureInstant } - .take(50) - .map(::row), - onPick = { vm.importPreviousFlight(it.id); pickingPrevious = false }, - onCancel = { pickingPrevious = false }, - ) - return@composable - } - if (pickingCrewSource) { - PastFlightPickerScreen( - title = "Copy Crew From", - emptyText = "Once a flight has crew or passengers, you can copy them here.", - rows = PeopleSuggestion.crewSources(others).mapNotNull { flightsById[it.flightId] }.map(::row), - onPick = { flight -> - others.firstOrNull { it.flightId == flight.id }?.let { source -> - vm.setPeople(source.crew.mapNotNull(peopleById::get), source.passengers.mapNotNull(peopleById::get)) - } - pickingCrewSource = false - }, - onCancel = { pickingCrewSource = false }, - ) - return@composable - } + val current = detail + if (newFlow && current != null) { + val peopleById = people.associate { it.person.id to it.person } + val flightsById = allFlights.associateBy { it.id } + val registrations = aircraft.associate { it.id to it.registration } + val others = flightPeople.filter { it.flightId != current.flight.id } + fun namesOn(id: String) = others.firstOrNull { it.flightId == id }?.everyone.orEmpty() + .mapNotNull { peopleById[it]?.displayName } + fun row(flight: FlightEntity) = PastFlightRow(flight, registrations[flight.aircraftId], namesOn(flight.id)) - val suggestion = PeopleSuggestion.suggest( - others, - current.flight.aircraftId, - people.filter { it.person.isUsualCrew }.map { it.person.id }, - )?.let { s -> - val crew = s.crew.mapNotNull(peopleById::get) - val passengers = s.passengers.mapNotNull(peopleById::get) - if (crew.isEmpty() && passengers.isEmpty()) return@let null - SuggestionChoice( - label = s.fromFlightId?.let(flightsById::get) - ?.let { "Same as ${it.originICAO.ifBlank { "????" }} → ${it.destinationICAO.ifBlank { "????" }}" } - ?: "Usual crew", - summary = PeopleSuggestion.summary((crew + passengers).map { it.displayName }), - crew = crew, - passengers = passengers, - ) - } - screens.SaveableStateProvider("new-flight") { - NewFlightScreen( - step = newStep, - detail = current, - aircraftOptions = aircraft, - airportInfo = airportInfo, - importSummary = importSummary, - hasPreviousFlights = allFlights.any { it.id != current.flight.id }, - suggestion = suggestion, - hasCrewSources = others.any { it.everyone.isNotEmpty() }, - onImportPrevious = { pickingPrevious = true }, - onOpenRoutePicker = { pickingRoute = true }, - onSetDeparture = { vm.setDeparture(it) }, - onSetArrival = { t -> vm.editFlight { it.copy(arrivalInstant = t) } }, - onSetAircraft = { vm.setAircraft(it) }, - onApplySuggestion = { vm.setPeople(it.crew, it.passengers) }, - onOpenCrewSources = { pickingCrewSource = true }, - onOpenPeoplePicker = { pickingPeople = true }, - onNext = { newStep = NewFlightStep.PEOPLE }, - onBack = { newStep = NewFlightStep.ROUTE }, - onCancel = { nav.popBackStack() }, - onCreate = { scope.launch { vm.save().join(); newFlow = false } }, - ) - } - return@composable + if (pickingPrevious) { + PastFlightPickerScreen( + title = stringResource(R.string.app_previous_flight), + emptyText = stringResource(R.string.app_no_earlier_flights), + rows = allFlights + .filter { it.id != current.flight.id && (it.originICAO.isNotBlank() || it.destinationICAO.isNotBlank()) } + .sortedByDescending { it.departureInstant } + .take(50) + .map(::row), + onPick = { vm.importPreviousFlight(it.id); pickingPrevious = false }, + onCancel = { pickingPrevious = false }, + ) + return } - - screens.SaveableStateProvider("flight-edit") { - FlightEditScreen( - detail = detail, - hasUnsavedChanges = unsaved, - aircraftOptions = aircraft, - people = people.map { it.person }, - documents = people.associate { row -> - row.person.id to row.documents.filter { it.isActive && it.deletedAt == null } - }, - onChooseDocument = { person, document -> - vm.chooseDocument( - person, - people.firstOrNull { it.person.id == person.id }?.documents.orEmpty().filter { it.deletedAt == null }, - document, - ) + if (pickingWeather) { + WeatherFlightPickerScreen( + load = vm::weatherFlights, + export = vm::weatherFlight, + onImport = { vm.importWeather(it); pickingWeather = false }, + onCancel = { pickingWeather = false }, + ) + return + } + if (pickingCrewSource) { + PastFlightPickerScreen( + title = stringResource(R.string.app_copy_crew_from), + emptyText = stringResource(R.string.app_copy_crew_empty), + rows = PeopleSuggestion.crewSources(others).mapNotNull { flightsById[it.flightId] }.map(::row), + onPick = { flight -> + others.firstOrNull { it.flightId == flight.id }?.let { source -> + vm.setPeople(source.crew.mapNotNull(peopleById::get), source.passengers.mapNotNull(peopleById::get)) + } + pickingCrewSource = false }, - onOpenPeoplePicker = { pickingPeople = true }, + onCancel = { pickingCrewSource = false }, + ) + return + } + + val usualCrewLabel = stringResource(R.string.app_usual_crew) + val sameAsFormat = stringResource(R.string.app_same_as) + val suggestion = PeopleSuggestion.suggest( + others, + current.flight.aircraftId, + people.filter { it.person.isUsualCrew }.map { it.person.id }, + )?.let { s -> + val crew = s.crew.mapNotNull(peopleById::get) + val passengers = s.passengers.mapNotNull(peopleById::get) + if (crew.isEmpty() && passengers.isEmpty()) return@let null + SuggestionChoice( + label = s.fromFlightId?.let(flightsById::get) + ?.let { sameAsFormat.format("${it.originICAO.ifBlank { "????" }} → ${it.destinationICAO.ifBlank { "????" }}") } + ?: usualCrewLabel, + summary = PeopleSuggestion.summary((crew + passengers).map { it.displayName }), + crew = crew, + passengers = passengers, + ) + } + screens.SaveableStateProvider("new-flight") { + NewFlightScreen( + step = newStep, + detail = current, + // With the aircraft an import named, until the flight stores it. + aircraftOptions = aircraft + listOfNotNull(stagedAircraft), airportInfo = airportInfo, + importSummary = importSummary, + hasPreviousFlights = allFlights.any { it.id != current.flight.id }, + signedIn = signedIn, + suggestion = suggestion, + hasCrewSources = others.any { it.everyone.isNotEmpty() }, + onImportPrevious = { pickingPrevious = true }, + onImportWeather = { pickingWeather = true }, onOpenRoutePicker = { pickingRoute = true }, - airportForms = forms, - generateState = generate, - onEditFlight = { vm.editFlight(it) }, onSetDeparture = { vm.setDeparture(it) }, + onSetArrival = { t -> vm.editFlight { it.copy(arrivalInstant = t) } }, onSetAircraft = { vm.setAircraft(it) }, - onSetCrew = { vm.setCrew(it) }, - onSetPassengers = { vm.setPassengers(it) }, - onSetResponsiblePerson = { vm.setResponsiblePerson(it) }, - extraValues = extraValues, - onSetExtra = { airport, formId, key, value -> vm.setExtra(airport, formId, key, value) }, - onSave = { vm.save() }, - onSaveAndBack = { scope.launch { vm.save().join(); nav.popBackStack() } }, - onGenerate = { airport, form -> vm.generateForm(airport, form) }, - onEmail = { airport, form -> vm.emailForm(airport, form) }, - onOpenWebForm = { airport, form -> vm.prefillWebForm(airport, form) }, - onShare = { shareFile(context, it) }, - onDismissGenerate = { vm.clearGenerateState() }, - onBack = { nav.popBackStack() }, - onDelete = { - detail?.flight?.let { deletions.flight(it) } - nav.popBackStack() - }, - onCreateReturn = { vm.createReturnFlight() }, - onCreateNextLeg = { vm.createNextLeg() }, - onDuplicate = { vm.duplicateFlight() }, + onApplySuggestion = { vm.setPeople(it.crew, it.passengers) }, + onOpenCrewSources = { pickingCrewSource = true }, + onOpenPeoplePicker = { pickingPeople = true }, + onNext = { newStep = NewFlightStep.PEOPLE }, + onBack = { newStep = NewFlightStep.ROUTE }, + onCancel = { nav.popBackStack() }, + onCreate = { scope.launch { vm.save().join(); newFlow = false } }, ) } + return + } + + screens.SaveableStateProvider("flight-edit") { + FlightEditScreen( + detail = detail, + hasUnsavedChanges = unsaved, + aircraftOptions = aircraft, + people = people.map { it.person }, + documents = people.associate { row -> + row.person.id to row.documents.filter { it.isActive && it.deletedAt == null } + }, + onChooseDocument = { person, document -> + vm.chooseDocument( + person, + people.firstOrNull { it.person.id == person.id }?.documents.orEmpty().filter { it.deletedAt == null }, + document, + ) + }, + onOpenPeoplePicker = { pickingPeople = true }, + airportInfo = airportInfo, + onOpenRoutePicker = { pickingRoute = true }, + airportForms = forms, + generateState = generate, + onEditFlight = { vm.editFlight(it) }, + onSetDeparture = { vm.setDeparture(it) }, + onSetAircraft = { vm.setAircraft(it) }, + onSetCrew = { vm.setCrew(it) }, + onSetPassengers = { vm.setPassengers(it) }, + onSetResponsiblePerson = { vm.setResponsiblePerson(it) }, + extraValues = extraValues, + onSetExtra = { airport, formId, key, value -> vm.setExtra(airport, formId, key, value) }, + onSave = { vm.save() }, + onSaveAndBack = { scope.launch { vm.save().join(); nav.popBackStack() } }, + onGenerate = { airport, form -> vm.generateForm(airport, form) }, + onEmail = { airport, form -> vm.emailForm(airport, form) }, + onOpenWebForm = { airport, form -> vm.prefillWebForm(airport, form) }, + onShare = { shareFile(context, it) }, + onDismissGenerate = { vm.clearGenerateState() }, + onBack = { nav.popBackStack() }, + onDelete = { + detail?.flight?.let { deletions.flight(it) } + nav.popBackStack() + }, + onCreateReturn = { vm.createReturnFlight() }, + onCreateNextLeg = { vm.createNextLeg() }, + onDuplicate = { vm.duplicateFlight() }, + ) } } @@ -557,69 +740,19 @@ private fun androidx.navigation.NavGraphBuilder.peopleRoutes( deletions: Deletions, ) { composable(Tab.PEOPLE.route) { - val vm: PeopleViewModel = viewModel(factory = factory) - val people by vm.people.collectAsState() - val lastFlights by vm.lastFlights.collectAsState() - val csvResult by vm.csvResult.collectAsState() - val context = LocalContext.current - val scope = rememberCoroutineScope() - - // SAF both ways: no storage permission, and only the file picked. - val importCsv = androidx.activity.compose.rememberLauncherForActivityResult( - androidx.activity.result.contract.ActivityResultContracts.OpenDocument(), - ) { uri -> - uri ?: return@rememberLauncherForActivityResult - scope.launch { - // Off the main thread: a cloud provider may download the file here. - val text = withContext(Dispatchers.IO) { - runCatching { - context.contentResolver.openInputStream(uri)?.use { it.readBytes().decodeToString() } - }.getOrNull() - } - if (text == null) vm.reportCsv("Import Failed", "Could not read that file.") else vm.importCsv(text) - } - } - val exportCsv = androidx.activity.compose.rememberLauncherForActivityResult( - androidx.activity.result.contract.ActivityResultContracts.CreateDocument("text/csv"), - ) { uri -> - uri ?: return@rememberLauncherForActivityResult - scope.launch { - runCatching { - val csv = vm.exportCsv() - withContext(Dispatchers.IO) { - context.contentResolver.openOutputStream(uri)?.use { it.write(csv.toByteArray()) } - } ?: error("Could not write that file.") - }.onSuccess { - android.widget.Toast.makeText(context, "People exported", android.widget.Toast.LENGTH_SHORT).show() - }.onFailure { - vm.reportCsv("Export Failed", it.message ?: "Could not write that file.") - } - } - } - - PeopleListScreen( - people = people, - lastFlights = lastFlights, - onOpen = { nav.navigate("person/$it") }, - add = AddPersonActions( - onAdd = { nav.navigate("person/new") }, - onScan = { nav.navigate(STANDALONE_SCAN) }, - onFromContact = { nav.navigate(CONTACT_IMPORT) }, - onImportCsv = { importCsv.launch(arrayOf("text/*", "application/csv")) }, - ), - onExportCsv = { exportCsv.launch("people.csv") }, - onDelete = { deletions.person(it) }, + ListDetail( + showingDetail = false, + list = { + PeopleList(nav, factory, deletions, selectedId = null, onOpen = { nav.openFromList("person/$it", Tab.PEOPLE) }) + }, + detail = { NothingSelected(stringResource(R.string.app_pick_person)) }, ) - csvResult?.let { result -> - androidx.compose.material3.AlertDialog( - onDismissRequest = { vm.dismissCsvResult() }, - title = { Text(result.title) }, - text = { Text(result.message) }, - confirmButton = { androidx.compose.material3.TextButton(onClick = { vm.dismissCsvResult() }) { Text("OK") } }, - ) - } } - composable("person/new") { + composable("person/$NEW_PERSON") { entry -> + val beside = openedFrom(nav, entry, Tab.PEOPLE) + WithList(beside, list = { + PeopleList(nav, factory, deletions, selectedId = null, onOpen = { nav.openFromList("person/$it", Tab.PEOPLE) }) + }) { val vm: PeopleViewModel = viewModel(factory = factory) val scope = rememberCoroutineScope() // Tell whoever opened this - a flight's picker - who was created. @@ -642,6 +775,7 @@ private fun androidx.navigation.NavGraphBuilder.peopleRoutes( onBack = { nav.popBackStack() }, onScan = { person -> persistThen(person, "person/${person.id}/scan") }, ) + } } // A contact from the address book: the system picker first, then create or merge. composable(CONTACT_IMPORT) { @@ -661,7 +795,7 @@ private fun androidx.navigation.NavGraphBuilder.peopleRoutes( scope.launch { val read = ContactReader.read(context, uri) if (read == null) { - android.widget.Toast.makeText(context, "That contact has no name to import.", android.widget.Toast.LENGTH_LONG).show() + android.widget.Toast.makeText(context, context.getString(R.string.app_contact_no_name), android.widget.Toast.LENGTH_LONG).show() nav.popBackStack() } else { vm.setPickedContact(read) @@ -796,12 +930,16 @@ private fun androidx.navigation.NavGraphBuilder.peopleRoutes( val scope = rememberCoroutineScope() val people by vm.people.collectAsState() val row: PersonWithDocuments? = people.firstOrNull { it.person.id == personId } + val beside = openedFrom(nav, entry, Tab.PEOPLE) // Unsaved edits are stored before leaving for a document or the // scanner; see PersonEditScreen. fun persistThen(person: PersonEntity, next: String) = scope.launch { vm.save(person).join() nav.navigate(next) } + WithList(beside, list = { + PeopleList(nav, factory, deletions, selectedId = personId, onOpen = { nav.openFromList("person/$it", Tab.PEOPLE) }) + }) { row?.let { PersonEditScreen( initial = it, @@ -815,6 +953,85 @@ private fun androidx.navigation.NavGraphBuilder.peopleRoutes( onDelete = { deletions.person(it.person); nav.popBackStack() }, ) } + } + } +} + +/** The people list, as the People tab and beside an open person. */ +@Composable +private fun PeopleList( + nav: androidx.navigation.NavHostController, + factory: ViewModelProvider.Factory, + deletions: Deletions, + selectedId: String?, + /** A person's id, or [NEW_PERSON]. */ + onOpen: (String) -> Unit, +) { + val vm: PeopleViewModel = viewModel(factory = factory) + val people by vm.people.collectAsState() + val lastFlights by vm.lastFlights.collectAsState() + val csvResult by vm.csvResult.collectAsState() + val context = LocalContext.current + val scope = rememberCoroutineScope() + + // SAF both ways: no storage permission, and only the file picked. + val importCsv = androidx.activity.compose.rememberLauncherForActivityResult( + androidx.activity.result.contract.ActivityResultContracts.OpenDocument(), + ) { uri -> + uri ?: return@rememberLauncherForActivityResult + scope.launch { + // Off the main thread: a cloud provider may download the file here. + val text = withContext(Dispatchers.IO) { + runCatching { + context.contentResolver.openInputStream(uri)?.use { it.readBytes().decodeToString() } + }.getOrNull() + } + if (text == null) { + vm.reportCsv(context.getString(R.string.app_import_failed), context.getString(R.string.app_could_not_read_file)) + } else { + vm.importCsv(text) + } + } + } + val exportCsv = androidx.activity.compose.rememberLauncherForActivityResult( + androidx.activity.result.contract.ActivityResultContracts.CreateDocument("text/csv"), + ) { uri -> + uri ?: return@rememberLauncherForActivityResult + scope.launch { + runCatching { + val csv = vm.exportCsv() + withContext(Dispatchers.IO) { + context.contentResolver.openOutputStream(uri)?.use { it.write(csv.toByteArray()) } + } ?: error(context.getString(R.string.app_could_not_write_file)) + }.onSuccess { + android.widget.Toast.makeText(context, context.getString(R.string.app_people_exported), android.widget.Toast.LENGTH_SHORT).show() + }.onFailure { + vm.reportCsv(context.getString(R.string.app_export_failed), it.message ?: context.getString(R.string.app_could_not_write_file)) + } + } + } + + PeopleListScreen( + people = people, + lastFlights = lastFlights, + onOpen = onOpen, + add = AddPersonActions( + onAdd = { onOpen(NEW_PERSON) }, + onScan = { nav.navigate(STANDALONE_SCAN) }, + onFromContact = { nav.navigate(CONTACT_IMPORT) }, + onImportCsv = { importCsv.launch(arrayOf("text/*", "application/csv")) }, + ), + onExportCsv = { exportCsv.launch("people.csv") }, + onDelete = { deletions.person(it) }, + selectedId = selectedId, + ) + csvResult?.let { result -> + androidx.compose.material3.AlertDialog( + onDismissRequest = { vm.dismissCsvResult() }, + title = { Text(result.title) }, + text = { Text(result.message) }, + confirmButton = { androidx.compose.material3.TextButton(onClick = { vm.dismissCsvResult() }) { Text(stringResource(R.string.app_ok)) } }, + ) } } @@ -824,16 +1041,16 @@ private fun androidx.navigation.NavGraphBuilder.aircraftRoutes( deletions: Deletions, ) { composable(Tab.AIRCRAFT.route) { - val vm: AircraftViewModel = viewModel(factory = factory) - val aircraft by vm.aircraft.collectAsState() - AircraftListScreen( - aircraft = aircraft, - onOpen = { nav.navigate("aircraft/$it") }, - onAdd = { nav.navigate("aircraft/new") }, - onDelete = { deletions.aircraft(it) }, + ListDetail( + showingDetail = false, + list = { AircraftList(factory, deletions, selectedId = null) { nav.openFromList("aircraft/$it", Tab.AIRCRAFT) } }, + detail = { NothingSelected(stringResource(R.string.app_pick_aircraft)) }, ) } - composable("aircraft/new") { + composable("aircraft/new") { entry -> + WithList(openedFrom(nav, entry, Tab.AIRCRAFT), list = { + AircraftList(factory, deletions, selectedId = null) { nav.openFromList("aircraft/$it", Tab.AIRCRAFT) } + }) { val vm: AircraftViewModel = viewModel(factory = factory) val peopleVm: PeopleViewModel = viewModel(factory = factory) val people by peopleVm.people.collectAsState() @@ -844,6 +1061,7 @@ private fun androidx.navigation.NavGraphBuilder.aircraftRoutes( onSave = { vm.save(it); nav.popBackStack() }, onBack = { nav.popBackStack() }, ) + } } composable( "aircraft/{aircraftId}", @@ -855,6 +1073,9 @@ private fun androidx.navigation.NavGraphBuilder.aircraftRoutes( val existing: AircraftEntity? = aircraft.firstOrNull { it.id == id } val peopleVm: PeopleViewModel = viewModel(factory = factory) val people by peopleVm.people.collectAsState() + WithList(openedFrom(nav, entry, Tab.AIRCRAFT), list = { + AircraftList(factory, deletions, selectedId = id) { nav.openFromList("aircraft/$it", Tab.AIRCRAFT) } + }) { existing?.let { AircraftEditScreen( it, @@ -865,24 +1086,51 @@ private fun androidx.navigation.NavGraphBuilder.aircraftRoutes( onDelete = { deletions.aircraft(it); nav.popBackStack() }, ) } + } } } +/** The aircraft list, as the Aircraft tab and beside an open aircraft. */ +@Composable +private fun AircraftList( + factory: ViewModelProvider.Factory, + deletions: Deletions, + selectedId: String?, + /** An aircraft's id, or "new". */ + onOpen: (String) -> Unit, +) { + val vm: AircraftViewModel = viewModel(factory = factory) + val aircraft by vm.aircraft.collectAsState() + AircraftListScreen( + aircraft = aircraft, + onOpen = onOpen, + onAdd = { onOpen("new") }, + onDelete = { deletions.aircraft(it) }, + selectedId = selectedId, + ) +} + +/** [content] with [list] beside it when [beside], alone otherwise. */ +@Composable +private fun WithList(beside: Boolean, list: @Composable () -> Unit, content: @Composable () -> Unit) { + if (beside) ListDetail(showingDetail = true, list = list, detail = content) else content() +} + private fun airportLookup(context: Context): AirportLookup { val db = AirportDatabase.get(context) return AirportLookup(db::search, db::airport) } @Composable -private fun SignInScreen(notice: String?, onSignIn: () -> Unit, onContinueOffline: () -> Unit) { +private fun SignInScreen(notice: String?, onSignIn: (SignInProvider) -> Unit, onContinueOffline: () -> Unit) { Column( Modifier.fillMaxSize().padding(32.dp), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { - Text("FlyFun Forms", style = MaterialTheme.typography.headlineMedium) + Text(stringResource(R.string.app_name), style = MaterialTheme.typography.headlineMedium) Text( - "Sign in to generate customs and immigration forms.", + stringResource(R.string.app_sign_in_intro), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(vertical = 12.dp), ) @@ -895,9 +1143,9 @@ private fun SignInScreen(notice: String?, onSignIn: () -> Unit, onContinueOfflin modifier = Modifier.padding(bottom = 12.dp), ) } - Button(onClick = onSignIn) { Text("Sign in with Google") } + SignInButtons(onSignIn) androidx.compose.material3.TextButton(onClick = onContinueOffline) { - Text("Enter data without signing in") + Text(stringResource(R.string.app_continue_offline)) } } } @@ -911,7 +1159,7 @@ private fun SignInScreen(notice: String?, onSignIn: () -> Unit, onContinueOfflin 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() + android.widget.Toast.makeText(context, context.getString(R.string.app_file_cleared), android.widget.Toast.LENGTH_LONG).show() return } val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) @@ -920,7 +1168,7 @@ private fun shareFile(context: Context, file: File) { putExtra(Intent.EXTRA_STREAM, uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } - context.startActivity(Intent.createChooser(intent, "Share ${file.name}")) + context.startActivity(Intent.createChooser(intent, context.getString(R.string.app_share_file, file.name))) } @@ -993,7 +1241,7 @@ private fun androidx.navigation.NavGraphBuilder.settingsRoute( SettingsScreen( state = state, signedIn = signedIn, - onSignIn = { auth.startSignIn() }, + onSignIn = { auth.startSignIn(it) }, onExportEncrypted = { vm.exportEncrypted() }, onExportPlain = { vm.exportPlain() }, onPickFile = { picker.launch(arrayOf("*/*")) }, @@ -1018,7 +1266,7 @@ private fun androidx.navigation.NavGraphBuilder.settingsRoute( deleteAccountError = null // Success clears the token, and the sign-in screen follows. auth.deleteAccount().onFailure { - deleteAccountError = it.message ?: "Could not delete the account." + deleteAccountError = it.message ?: context.getString(R.string.app_delete_account_failed) } deletingAccount = false } @@ -1046,8 +1294,8 @@ private fun openInBrowser(context: Context, url: String) { private fun sharePassengerNote(context: Context) { val intent = Intent(Intent.ACTION_SEND).apply { type = "text/plain" - putExtra(Intent.EXTRA_SUBJECT, "How I use your details for this flight") + putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.settings_passenger_note_subject)) putExtra(Intent.EXTRA_TEXT, PASSENGER_PRIVACY_NOTE) } - context.startActivity(Intent.createChooser(intent, "Privacy note for passengers")) + context.startActivity(Intent.createChooser(intent, context.getString(R.string.settings_passenger_note))) } diff --git a/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/Theme.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/Theme.kt new file mode 100644 index 0000000..7e111af --- /dev/null +++ b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/Theme.kt @@ -0,0 +1,23 @@ +package aero.flyfun.forms.ui + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +/** + * Material You: the wallpaper's colours, light or dark with the system. + * + * Dynamic colour needs API 31 and the app's minimum is 33, so there is no + * fallback palette to keep in step with the icon. The app has no brand colour + * beyond the icon, and matching the rest of the phone is what an Android app is + * expected to do. + */ +@Composable +fun FlyFunTheme(content: @Composable () -> Unit) { + val context = LocalContext.current + val colors = if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + MaterialTheme(colorScheme = colors, content = content) +} 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 d97e639..1391381 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,5 +1,6 @@ package aero.flyfun.forms.ui.aircraft +import aero.flyfun.forms.R import aero.flyfun.forms.data.AircraftEntity import aero.flyfun.forms.data.PersonEntity import aero.flyfun.forms.ui.common.ChoiceField @@ -28,6 +29,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold @@ -47,6 +49,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.dp @@ -57,12 +60,14 @@ fun AircraftListScreen( onOpen: (String) -> Unit, onAdd: () -> Unit, onDelete: (AircraftEntity) -> Unit, + /** The aircraft open beside the list, on a screen wide enough for both. */ + selectedId: String? = null, ) { Scaffold( - topBar = { TopAppBar(title = { Text("Aircraft") }) }, + topBar = { TopAppBar(title = { Text(stringResource(R.string.aircraft_title)) }) }, floatingActionButton = { FloatingActionButton(onClick = onAdd) { - Icon(Icons.Default.Add, contentDescription = "Add aircraft") + Icon(Icons.Default.Add, contentDescription = stringResource(R.string.aircraft_add)) } }, ) { padding -> @@ -72,21 +77,27 @@ fun AircraftListScreen( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { - Text("No aircraft yet", style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.aircraft_empty_title), style = MaterialTheme.typography.titleMedium) Text( - "Add the aircraft you fly so forms can carry its registration and type.", + stringResource(R.string.aircraft_empty_message), style = MaterialTheme.typography.bodyMedium, ) } } else { + val newAircraft = stringResource(R.string.aircraft_new_aircraft) LazyColumn(Modifier.fillMaxSize().padding(padding)) { items(aircraft, key = { "${it.id}:${it.updatedAt}" }) { a -> SwipeToDelete(onDelete = { onDelete(a) }) { ListItem( - headlineContent = { Text(a.registration.ifBlank { "New Aircraft" }) }, + headlineContent = { Text(a.registration.ifBlank { newAircraft }) }, supportingContent = { Text(listOfNotNull(a.type.ifBlank { null }, a.usualBase).joinToString(" · ")) }, + colors = if (a.id == selectedId) { + ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.secondaryContainer) + } else { + ListItemDefaults.colors() + }, modifier = Modifier.clickable { onOpen(a.id) }, ) } @@ -131,7 +142,7 @@ fun AircraftEditScreen( if (pickingBase) { AirportPickerScreen( - title = "Usual Base", + title = stringResource(R.string.aircraft_usual_base), selected = usualBase, lookup = airports, onPick = { usualBase = it }, @@ -162,14 +173,14 @@ fun AircraftEditScreen( Scaffold( topBar = { TopAppBar( - title = { Text(if (initial == null) "New Aircraft" else "Edit Aircraft") }, + title = { Text(if (initial == null) stringResource(R.string.aircraft_new_aircraft) else stringResource(R.string.aircraft_edit_aircraft)) }, navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.Default.ArrowBack, contentDescription = "Back") + Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.common_back)) } }, actions = { - TextButton(enabled = registration.isNotBlank(), onClick = { onSave(edited()) }) { Text("Save") } + TextButton(enabled = registration.isNotBlank(), onClick = { onSave(edited()) }) { Text(stringResource(R.string.common_save)) } onDelete?.let { DeleteOverflowMenu(onDelete = it) } }, ) @@ -179,19 +190,19 @@ fun AircraftEditScreen( Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Text("Aircraft", style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.aircraft_title), style = MaterialTheme.typography.titleMedium) OutlinedTextField( - registration, { registration = it }, label = { Text("Registration") }, + registration, { registration = it }, label = { Text(stringResource(R.string.aircraft_registration)) }, singleLine = true, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Characters), ) OutlinedTextField( - type, { type = it }, label = { Text("Type (e.g. SR22)") }, + type, { type = it }, label = { Text(stringResource(R.string.aircraft_type_hint)) }, singleLine = true, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Characters), ) SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) { - listOf(true to "Airplane", false to "Helicopter").forEachIndexed { index, (value, label) -> + listOf(true to stringResource(R.string.aircraft_airplane), false to stringResource(R.string.aircraft_helicopter)).forEachIndexed { index, (value, label) -> SegmentedButton( selected = isAirplane == value, onClick = { isAirplane = value }, @@ -200,47 +211,50 @@ fun AircraftEditScreen( } } - Text("Operator", style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.aircraft_operator), style = MaterialTheme.typography.titleMedium) Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text("Company operator", style = MaterialTheme.typography.bodyLarge) + Text(stringResource(R.string.aircraft_company_operator), style = MaterialTheme.typography.bodyLarge) Switch(checked = useCompanyOperator, onCheckedChange = { useCompanyOperator = it }) } if (useCompanyOperator) { OutlinedTextField( - operatorName, { operatorName = it }, label = { Text("Company name & address") }, + operatorName, { operatorName = it }, label = { Text(stringResource(R.string.aircraft_company_name_address)) }, minLines = 2, maxLines = 4, modifier = Modifier.fillMaxWidth(), ) } else { + val unnamed = stringResource(R.string.aircraft_unnamed) + val select = stringResource(R.string.common_select) ChoiceField( - label = "Owner", + label = stringResource(R.string.aircraft_owner), selected = ownerPerson, options = listOf(null) + people, - display = { it?.displayName?.ifBlank { "Unnamed" } ?: "Select…" }, + display = { it?.displayName?.ifBlank { unnamed } ?: select }, onSelect = { ownerPersonId = it?.id }, ) if (ownerPerson == null && initial?.ownerPersonId == null && !initial?.owner.isNullOrBlank()) { // Typed before owners were picked from People; kept until one is picked. - DetailLine("On file", initial?.owner.orEmpty()) + DetailLine(stringResource(R.string.aircraft_on_file), initial?.owner.orEmpty()) } ownerPerson?.let { person -> - person.email?.takeIf { it.isNotBlank() }?.let { DetailLine("Email", it) } - person.phone?.takeIf { it.isNotBlank() }?.let { DetailLine("Phone", it) } - person.address?.takeIf { it.isNotBlank() }?.let { DetailLine("Address", it) } + person.email?.takeIf { it.isNotBlank() }?.let { DetailLine(stringResource(R.string.aircraft_email), it) } + person.phone?.takeIf { it.isNotBlank() }?.let { DetailLine(stringResource(R.string.aircraft_phone), it) } + person.address?.takeIf { it.isNotBlank() }?.let { DetailLine(stringResource(R.string.aircraft_address), it) } } if (people.isEmpty()) { - Text("Add the owner in People to pick them here.", style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.aircraft_add_owner_in_people), style = MaterialTheme.typography.bodySmall) } } - Text("Base", style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.aircraft_base), style = MaterialTheme.typography.titleMedium) + val selectBase = stringResource(R.string.common_select) ListItem( - headlineContent = { Text("Usual Base") }, + headlineContent = { Text(stringResource(R.string.aircraft_usual_base)) }, supportingContent = baseName?.let { { Text(it) } }, - trailingContent = { Text(usualBase.ifBlank { "Select…" }, style = MaterialTheme.typography.titleMedium) }, + trailingContent = { Text(usualBase.ifBlank { selectBase }, style = MaterialTheme.typography.titleMedium) }, modifier = Modifier.clickable { pickingBase = true }, ) } 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 index df016a0..39f952d 100644 --- 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 @@ -1,5 +1,6 @@ package aero.flyfun.forms.ui.common +import aero.flyfun.forms.R import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -24,6 +25,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp /** @@ -52,7 +54,7 @@ fun SwipeToDelete(onDelete: () -> Unit, content: @Composable () -> Unit) { ) { Icon( Icons.Default.Delete, - contentDescription = "Delete", + contentDescription = stringResource(R.string.common_delete), tint = MaterialTheme.colorScheme.onErrorContainer, ) } @@ -66,11 +68,11 @@ fun DeleteOverflowMenu(onDelete: () -> Unit) { var open by remember { mutableStateOf(false) } Box { IconButton(onClick = { open = true }) { - Icon(Icons.Default.MoreVert, contentDescription = "More") + Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.common_more)) } DropdownMenu(expanded = open, onDismissRequest = { open = false }) { DropdownMenuItem( - text = { Text("Delete") }, + text = { Text(stringResource(R.string.common_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/common/SignInButtons.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/common/SignInButtons.kt new file mode 100644 index 0000000..7be214e --- /dev/null +++ b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/common/SignInButtons.kt @@ -0,0 +1,26 @@ +package aero.flyfun.forms.ui.common + +import aero.flyfun.forms.R +import aero.flyfun.forms.auth.SignInProvider +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp + +/** + * Google, then Apple: the same account either way the pilot signed in on iOS + * or the web, so an Apple-only account can use the Android app too. + */ +@Composable +fun SignInButtons(onSignIn: (SignInProvider) -> Unit, modifier: Modifier = Modifier) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = { onSignIn(SignInProvider.GOOGLE) }) { Text(stringResource(R.string.common_sign_in_with_google)) } + OutlinedButton(onClick = { onSignIn(SignInProvider.APPLE) }) { Text(stringResource(R.string.common_sign_in_with_apple)) } + } +} diff --git a/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/flights/AirportPickers.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/flights/AirportPickers.kt index 6ce1bf5..4ef2741 100644 --- a/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/flights/AirportPickers.kt +++ b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/flights/AirportPickers.kt @@ -1,9 +1,11 @@ package aero.flyfun.forms.ui.flights +import aero.flyfun.forms.R import aero.flyfun.forms.logic.AirportSummary import aero.flyfun.forms.logic.RecentRoute import aero.flyfun.forms.ui.people.SearchField import androidx.activity.compose.BackHandler +import androidx.annotation.StringRes import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -38,6 +40,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -85,9 +88,9 @@ fun RoutePickerScreen( Scaffold( topBar = { TopAppBar( - title = { Text("Route") }, - navigationIcon = { IconButton(onClick = onDone) { Icon(Icons.Default.Close, contentDescription = "Close") } }, - actions = { TextButton(onClick = onDone) { Text("Done") } }, + title = { Text(stringResource(R.string.flights_route)) }, + navigationIcon = { IconButton(onClick = onDone) { Icon(Icons.Default.Close, contentDescription = stringResource(R.string.flights_close)) } }, + actions = { TextButton(onClick = onDone) { Text(stringResource(R.string.flights_done)) } }, ) }, ) { padding -> @@ -97,22 +100,22 @@ fun RoutePickerScreen( horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, ) { - RouteEnd("FROM", origin, lookup, active = editingOrigin, Modifier.weight(1f)) { + RouteEnd(stringResource(R.string.flights_route_from), origin, lookup, active = editingOrigin, Modifier.weight(1f)) { editingOrigin = true query = "" } Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null) - RouteEnd("TO", destination, lookup, active = !editingOrigin, Modifier.weight(1f)) { + RouteEnd(stringResource(R.string.flights_route_to), destination, lookup, active = !editingOrigin, Modifier.weight(1f)) { editingOrigin = false query = "" } } - SearchField(query, { query = it }, "Search airport name or ICAO") + SearchField(query, { query = it }, stringResource(R.string.flights_search_airport)) LazyColumn(Modifier.fillMaxSize()) { airportResults(query, results, if (editingOrigin) origin else destination, ::pick) val routes = recentRoutes(query) if (routes.isNotEmpty()) { - header("Recent Routes") + header(R.string.flights_recent_routes) items(routes, key = { "route:${it.origin}-${it.destination}" }) { route -> ListItem( headlineContent = { @@ -151,15 +154,15 @@ fun AirportPickerScreen( topBar = { TopAppBar( title = { Text(title) }, - navigationIcon = { IconButton(onClick = onDone) { Icon(Icons.Default.Close, contentDescription = "Close") } }, + navigationIcon = { IconButton(onClick = onDone) { Icon(Icons.Default.Close, contentDescription = stringResource(R.string.flights_close)) } }, actions = { - if (selected.isNotEmpty()) TextButton(onClick = { onPick(""); onDone() }) { Text("Clear") } + if (selected.isNotEmpty()) TextButton(onClick = { onPick(""); onDone() }) { Text(stringResource(R.string.flights_clear)) } }, ) }, ) { padding -> Column(Modifier.fillMaxSize().padding(padding)) { - SearchField(query, { query = it }, "Search airport name or ICAO") + SearchField(query, { query = it }, stringResource(R.string.flights_search_airport)) LazyColumn(Modifier.fillMaxSize()) { airportResults(query, results, selected) { onPick(it); onDone() } } @@ -189,14 +192,14 @@ private fun LazyListScope.airportResults( onPick: (String) -> Unit, ) { if (results.isNotEmpty()) { - header("Airports") + header(R.string.flights_airports) items(results, key = { "airport:${it.icao}" }) { airport -> ListItem( leadingContent = { Text(airport.icao, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold) }, headlineContent = { Text(airport.name, maxLines = 1, overflow = TextOverflow.Ellipsis) }, supportingContent = { Text(listOf(airport.city, airport.country).filter { it.isNotBlank() }.joinToString(", ")) }, trailingContent = if (airport.icao == selected) { - { Icon(Icons.Default.Check, contentDescription = "Selected") } + { Icon(Icons.Default.Check, contentDescription = stringResource(R.string.flights_selected)) } } else { null }, @@ -209,8 +212,8 @@ private fun LazyListScope.airportResults( if (code.length == 4 && code.all { it.isLetterOrDigit() } && results.none { it.icao == code }) { item(key = "use-code") { ListItem( - headlineContent = { Text("Use $code") }, - supportingContent = { Text("Not in the airport database") }, + headlineContent = { Text(stringResource(R.string.flights_use_code, code)) }, + supportingContent = { Text(stringResource(R.string.flights_not_in_database)) }, modifier = Modifier.clickable { onPick(code) }, ) } @@ -249,10 +252,10 @@ private fun RouteEnd( } } -private fun LazyListScope.header(text: String) { +private fun LazyListScope.header(@StringRes text: Int) { item(key = "header:$text") { Text( - text, + stringResource(text), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp), 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 4251476..ea6be8e 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 @@ -1,5 +1,6 @@ package aero.flyfun.forms.ui.flights +import aero.flyfun.forms.R import aero.flyfun.forms.data.AircraftEntity import aero.flyfun.forms.data.FlightEntity import aero.flyfun.forms.data.PersonEntity @@ -53,6 +54,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedCard @@ -70,6 +72,7 @@ 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.res.stringResource import androidx.compose.ui.unit.dp import java.time.LocalDate import java.time.ZoneId @@ -87,6 +90,8 @@ fun FlightListScreen( onOpen: (String) -> Unit, onAdd: () -> Unit, onDelete: (FlightEntity) -> Unit, + /** The flight open beside the list, on a screen wide enough for both. */ + selectedId: String? = null, ) { // Split at the start of today, as iOS does: a flight earlier today is // still one the pilot is working on. @@ -97,10 +102,10 @@ fun FlightListScreen( var showPast by rememberSaveable { mutableStateOf(false) } Scaffold( - topBar = { TopAppBar(title = { Text("Flights") }) }, + topBar = { TopAppBar(title = { Text(stringResource(R.string.flights_title)) }) }, floatingActionButton = { FloatingActionButton(onClick = onAdd) { - Icon(Icons.Default.Add, contentDescription = "Add flight") + Icon(Icons.Default.Add, contentDescription = stringResource(R.string.flights_add_flight)) } }, ) { padding -> @@ -110,19 +115,19 @@ fun FlightListScreen( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { - Text("No flights yet", style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.flights_no_flights_yet), style = MaterialTheme.typography.titleMedium) Text( - "Add a flight to generate its customs and immigration forms.", + stringResource(R.string.flights_empty_message), style = MaterialTheme.typography.bodyMedium, ) } } else { LazyColumn(Modifier.fillMaxSize().padding(padding)) { - item { SectionHeader("Upcoming") } + item { SectionHeader(stringResource(R.string.flights_upcoming)) } if (upcoming.isEmpty()) { item { Text( - "No upcoming flights", + stringResource(R.string.flights_no_upcoming), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), @@ -130,17 +135,17 @@ fun FlightListScreen( } } items(upcoming, key = { "${it.id}:${it.updatedAt}" }) { - FlightRow(it, registrations[it.aircraftId], onOpen, onDelete) + FlightRow(it, registrations[it.aircraftId], it.id == selectedId, onOpen, onDelete) } if (past.isNotEmpty()) { // Collapsed by default: the list is for what is coming up. item { ListItem( - headlineContent = { Text("Past Flights (${past.size})") }, + headlineContent = { Text(stringResource(R.string.flights_past_flights_count, past.size)) }, trailingContent = { Icon( if (showPast) Icons.Default.ExpandLess else Icons.Default.ExpandMore, - contentDescription = if (showPast) "Hide past flights" else "Show past flights", + contentDescription = stringResource(if (showPast) R.string.flights_hide_past else R.string.flights_show_past), ) }, modifier = Modifier.clickable { showPast = !showPast }, @@ -148,7 +153,7 @@ fun FlightListScreen( } if (showPast) { items(past, key = { "${it.id}:${it.updatedAt}" }) { - FlightRow(it, registrations[it.aircraftId], onOpen, onDelete) + FlightRow(it, registrations[it.aircraftId], it.id == selectedId, onOpen, onDelete) } } } @@ -170,6 +175,7 @@ private fun SectionHeader(text: String) { private fun FlightRow( flight: FlightEntity, registration: String?, + selected: Boolean, onOpen: (String) -> Unit, onDelete: (FlightEntity) -> Unit, ) { @@ -187,6 +193,7 @@ private fun FlightRow( ).joinToString(" · "), ) }, + colors = if (selected) ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.secondaryContainer) else ListItemDefaults.colors(), modifier = Modifier.clickable { onOpen(flight.id) }, ) } @@ -232,7 +239,7 @@ fun FlightEditScreen( onDuplicate: () -> Unit, ) { if (detail == null) { - Scaffold(topBar = { TopAppBar(title = { Text("Flight") }) }) { p -> + Scaffold(topBar = { TopAppBar(title = { Text(stringResource(R.string.flights_flight)) }) }) { p -> Column(Modifier.fillMaxSize().padding(p), Arrangement.Center, Alignment.CenterHorizontally) { CircularProgressIndicator() } @@ -246,6 +253,9 @@ fun FlightEditScreen( // keyed on the flight so a different leg starts from its own values. var observations by remember(flight.id) { mutableStateOf(flight.observations.orEmpty()) } var confirmDiscard by remember { mutableStateOf(false) } + val unnamed = stringResource(R.string.flights_unnamed) + val commercialLabel = stringResource(R.string.flights_commercial) + val privateLabel = stringResource(R.string.flights_private) // Back with edits asks first, whether it came from the arrow or the system. val leave = { if (hasUnsavedChanges) confirmDiscard = true else onBack() } @@ -256,17 +266,17 @@ fun FlightEditScreen( TopAppBar( title = { Text( - if (detail.isNew && origin.isBlank() && destination.isBlank()) "New Flight" + if (detail.isNew && origin.isBlank() && destination.isBlank()) stringResource(R.string.flights_new_flight) else "${origin.ifBlank { "????" }} → ${destination.ifBlank { "????" }}", ) }, navigationIcon = { IconButton(onClick = leave) { - Icon(Icons.Default.ArrowBack, contentDescription = "Back") + Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.flights_back)) } }, actions = { - TextButton(onClick = onSave, enabled = hasUnsavedChanges) { Text("Save") } + TextButton(onClick = onSave, enabled = hasUnsavedChanges) { Text(stringResource(R.string.flights_save)) } // A draft has nothing stored to delete; Back discards it. if (!detail.isNew) DeleteOverflowMenu(onDelete = onDelete) }, @@ -278,28 +288,28 @@ fun FlightEditScreen( verticalArrangement = Arrangement.spacedBy(12.dp), ) { RouteCard(origin, destination, airportInfo, onOpenRoutePicker) - AirportNotice("Departure", origin, airportInfo) - if (destination != origin) AirportNotice("Arrival", destination, airportInfo) + AirportNotice(stringResource(R.string.flights_departure), origin, airportInfo) + if (destination != origin) AirportNotice(stringResource(R.string.flights_arrival), destination, airportInfo) // Zones in route order, once each: a local flight has one. val zones = listOfNotNull(airportInfo[origin]?.timeZone, airportInfo[destination]?.timeZone).distinct() ScheduleField( - "Departure", flight.departureInstant, onSetDeparture, + stringResource(R.string.flights_departure), flight.departureInstant, onSetDeparture, zones = zones, preferredZone = airportInfo[origin]?.timeZone, ) ScheduleField( - "Arrival", flight.arrivalInstant, { t -> onEditFlight { it.copy(arrivalInstant = t) } }, + stringResource(R.string.flights_arrival), flight.arrivalInstant, { t -> onEditFlight { it.copy(arrivalInstant = t) } }, zones = zones, preferredZone = airportInfo[destination]?.timeZone, ) if (flight.arrivalInstant.isBefore(flight.departureInstant)) { Text( - "Arrival is before departure.", + stringResource(R.string.flights_arrival_before_departure), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, ) } - Text("Aircraft", style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.flights_aircraft), style = MaterialTheme.typography.titleMedium) // Scrolls sideways: a fleet does not fit across a phone. Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), Arrangement.spacedBy(8.dp)) { aircraftOptions.forEach { a -> @@ -311,33 +321,33 @@ fun FlightEditScreen( } } if (aircraftOptions.isEmpty()) { - Text("Add an aircraft first.", style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.flights_add_aircraft_first), style = MaterialTheme.typography.bodySmall) } ChoiceField( - label = "Nature", + label = stringResource(R.string.flights_nature), selected = flight.nature, options = listOf("private", "commercial"), - display = { if (it == "commercial") "Commercial" else "Private" }, + display = { if (it == "commercial") commercialLabel else privateLabel }, onSelect = { value -> onEditFlight { it.copy(nature = value) } }, ) ChoiceField( - label = "Reason for Visit", + label = stringResource(R.string.flights_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", + label = stringResource(R.string.flights_responsible_person), selected = detail.responsiblePerson, options = listOf(null) + people, - display = { it?.displayName?.ifBlank { "Unnamed" } ?: "—" }, + 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) } + person.phone?.takeIf { it.isNotBlank() }?.let { DetailLine(stringResource(R.string.flights_phone), it) } + person.address?.takeIf { it.isNotBlank() }?.let { DetailLine(stringResource(R.string.flights_address), it) } } PeopleOnBoard( @@ -355,10 +365,10 @@ fun FlightEditScreen( observations = value onEditFlight { it.copy(observations = value.trim().ifBlank { null }) } }, - label = { Text("Observations") }, modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.flights_observations)) }, modifier = Modifier.fillMaxWidth(), ) - Text("Forms", style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.flights_forms), style = MaterialTheme.typography.titleMedium) val rowContext = FormRowContext( state = generateState, flightPeople = detail.crew + detail.passengers, @@ -372,29 +382,29 @@ fun FlightEditScreen( airportForms.forEach { airport -> AirportFormsCard(airport, rowContext) } if (airportForms.isEmpty()) { Text( - "Enter the route to see which forms these airports need.", + stringResource(R.string.flights_enter_route_for_forms), 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) + Text(stringResource(R.string.flights_actions), style = MaterialTheme.typography.titleMedium) + LegAction(Icons.AutoMirrored.Filled.Undo, stringResource(R.string.flights_create_return), onCreateReturn) + LegAction(Icons.AutoMirrored.Filled.ArrowForward, stringResource(R.string.flights_create_next_leg), onCreateNextLeg) + LegAction(Icons.Default.ContentCopy, stringResource(R.string.flights_duplicate), onDuplicate) } } if (confirmDiscard) { AlertDialog( onDismissRequest = { confirmDiscard = false }, - title = { Text(if (detail.isNew) "Save this flight?" else "Save your changes?") }, - text = { Text("Leaving now discards what you have not saved.") }, + title = { Text(stringResource(if (detail.isNew) R.string.flights_save_this_flight else R.string.flights_save_your_changes)) }, + text = { Text(stringResource(R.string.flights_leaving_discards)) }, confirmButton = { - TextButton(onClick = { confirmDiscard = false; onSaveAndBack() }) { Text("Save") } + TextButton(onClick = { confirmDiscard = false; onSaveAndBack() }) { Text(stringResource(R.string.flights_save)) } }, dismissButton = { - TextButton(onClick = { confirmDiscard = false; onBack() }) { Text("Discard") } + TextButton(onClick = { confirmDiscard = false; onBack() }) { Text(stringResource(R.string.flights_discard)) } }, ) } @@ -407,10 +417,10 @@ fun FlightEditScreen( private fun RouteCard(origin: String, destination: String, info: Map, onClick: () -> Unit) { OutlinedCard(onClick = onClick, modifier = Modifier.fillMaxWidth()) { Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) { - RouteEndLabel("From", origin, info[origin]?.summary?.name, Modifier.weight(1f)) + RouteEndLabel(stringResource(R.string.flights_from), origin, info[origin]?.summary?.name, Modifier.weight(1f)) Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null) - RouteEndLabel("To", destination, info[destination]?.summary?.name, Modifier.weight(1f)) - Icon(Icons.Default.Edit, contentDescription = "Change route") + RouteEndLabel(stringResource(R.string.flights_to), destination, info[destination]?.summary?.name, Modifier.weight(1f)) + Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.flights_change_route)) } } } @@ -443,7 +453,7 @@ private fun AirportNotice(label: String, icao: String, info: Map Unit, ) { val chosen = detail.flight.chosenDocNumbers.orEmpty() - Text("Crew", style = MaterialTheme.typography.titleMedium) - if (detail.crew.isEmpty()) Text("No crew yet.", style = MaterialTheme.typography.bodySmall) + val pic = stringResource(R.string.flights_pic) + Text(stringResource(R.string.flights_crew), style = MaterialTheme.typography.titleMedium) + if (detail.crew.isEmpty()) Text(stringResource(R.string.flights_no_crew_yet), style = MaterialTheme.typography.bodySmall) detail.crew.forEachIndexed { index, person -> - OnBoardRow(person, if (index == 0) "PIC" else null, documents[person.id].orEmpty(), chosen, + OnBoardRow(person, if (index == 0) pic else null, documents[person.id].orEmpty(), chosen, { onChooseDocument(person, it) }, { onRemoveCrew(person) }) } - Text("Passengers", style = MaterialTheme.typography.titleMedium) - if (detail.passengers.isEmpty()) Text("No passengers.", style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.flights_passengers), style = MaterialTheme.typography.titleMedium) + if (detail.passengers.isEmpty()) Text(stringResource(R.string.flights_no_passengers), style = MaterialTheme.typography.bodySmall) detail.passengers.forEach { person -> OnBoardRow(person, null, documents[person.id].orEmpty(), chosen, { onChooseDocument(person, it) }, { onRemovePassenger(person) }) } OutlinedButton(onClick = onOpenPicker) { Icon(Icons.Default.Groups, contentDescription = null) - Text("Choose Crew & Passengers", Modifier.padding(start = 8.dp)) + Text(stringResource(R.string.flights_choose_crew_passengers), Modifier.padding(start = 8.dp)) } } @@ -519,7 +530,7 @@ private fun OnBoardRow( Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Column(Modifier.weight(1f)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - Text(person.displayName.ifBlank { "Unnamed" }, style = MaterialTheme.typography.bodyLarge) + Text(person.displayName.ifBlank { stringResource(R.string.flights_unnamed) }, style = MaterialTheme.typography.bodyLarge) tag?.let { CrewPill(it) } } if (documents.size > 1) { @@ -529,10 +540,10 @@ private fun OnBoardRow( Box { TextButton(onClick = { open = true }) { Icon(Icons.Default.Badge, contentDescription = null) - Text(chosen?.let(::documentLabel) ?: "Document: Automatic", Modifier.padding(start = 4.dp)) + Text(chosen?.let(::documentLabel) ?: stringResource(R.string.flights_document_automatic), Modifier.padding(start = 4.dp)) } DropdownMenu(expanded = open, onDismissRequest = { open = false }) { - DropdownMenuItem(text = { Text("Automatic") }, onClick = { open = false; onChoose(null) }) + DropdownMenuItem(text = { Text(stringResource(R.string.flights_automatic)) }, onClick = { open = false; onChoose(null) }) documents.filter { it.docNumber.isNotEmpty() }.forEach { doc -> DropdownMenuItem(text = { Text(documentLabel(doc)) }, onClick = { open = false; onChoose(doc) }) } @@ -540,7 +551,7 @@ private fun OnBoardRow( } } } - IconButton(onClick = onRemove) { Icon(Icons.Default.Close, contentDescription = "Remove from flight") } + IconButton(onClick = onRemove) { Icon(Icons.Default.Close, contentDescription = stringResource(R.string.flights_remove_from_flight)) } } } @@ -581,7 +592,7 @@ private fun AirportFormsCard(airport: AirportForms, ctx: FormRowContext) { airport.error != null -> Text(airport.error, style = MaterialTheme.typography.bodySmall) airport.forms.isEmpty() -> - Text("No forms needed here.", style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.flights_no_forms_needed), style = MaterialTheme.typography.bodySmall) else -> { val grouped = FormSides.group(airport.forms) { it.isWebForm } grouped.primary?.let { FormRow(airport.icao, it, ctx) } @@ -589,7 +600,7 @@ private fun AirportFormsCard(airport: AirportForms, ctx: FormRowContext) { if (grouped.others.isNotEmpty()) { var showOthers by rememberSaveable(airport.icao, airport.direction) { mutableStateOf(false) } TextButton(onClick = { showOthers = !showOthers }) { - Text("Other forms (${grouped.others.size})") + Text(stringResource(R.string.flights_other_forms_count, grouped.others.size)) Icon( if (showOthers) Icons.Default.ExpandLess else Icons.Default.ExpandMore, contentDescription = null, @@ -614,11 +625,11 @@ private fun FormRow(airport: String, form: FormInfo, ctx: FormRowContext) { OutlinedButton( enabled = ctx.state !is GenerateState.Working, onClick = { ctx.onEmail(airport, form) }, - ) { Text("Email") } + ) { Text(stringResource(R.string.flights_email)) } OutlinedButton( enabled = ctx.state !is GenerateState.Working, onClick = { ctx.onGenerate(airport, form) }, - ) { Text("Generate") } + ) { Text(stringResource(R.string.flights_generate)) } } } } @@ -630,7 +641,7 @@ private fun WebFormRow(airport: String, form: FormInfo, ctx: FormRowContext) { 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) + Text(stringResource(R.string.flights_official_web_form), style = MaterialTheme.typography.bodySmall) } // The airport's own page: prefilled via /prefill and submitted by // the pilot, never by the app. @@ -639,13 +650,13 @@ private fun WebFormRow(airport: String, form: FormInfo, ctx: FormRowContext) { onClick = { ctx.onOpenWebForm(airport, form) }, ) { if (working) CircularProgressIndicator(Modifier.padding(2.dp)) - else Text("Open prefilled") + else Text(stringResource(R.string.flights_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") + Hint(stringResource(R.string.flights_pick_responsible_for_phone_email)) } } } @@ -660,6 +671,7 @@ private fun WebFormRow(airport: String, form: FormInfo, ctx: FormRowContext) { 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) } + val unnamed = stringResource(R.string.flights_unnamed) form.extraFields .filter { it.key !in FormRequestBuilder.FLIGHT_SUPPLIED_EXTRAS } @@ -682,7 +694,7 @@ private fun ExtraFields(airport: String, form: FormInfo, ctx: FormRowContext) { label = field.label, selected = ctx.flightPeople.firstOrNull { it.displayName == chosen["name"] }, options = listOf(null) + ctx.flightPeople, - display = { it?.displayName?.ifBlank { "Unnamed" } ?: "—" }, + display = { it?.displayName?.ifBlank { unnamed } ?: "—" }, onSelect = { person -> set( field.key, @@ -702,7 +714,7 @@ private fun ExtraFields(airport: String, form: FormInfo, ctx: FormRowContext) { address = value set(field.key, ExtraFieldValue.Person(chosen + ("address" to value))) }, - label = { Text("Address") }, + label = { Text(stringResource(R.string.flights_address)) }, modifier = Modifier.fillMaxWidth(), ) } @@ -740,8 +752,8 @@ private fun ResponsiblePersonValue(label: String, key: String, person: PersonEnt 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}") + person == null -> Hint(stringResource(R.string.flights_pick_responsible)) + value.isNullOrBlank() -> Hint(stringResource(R.string.flights_set_field_on_person, label.lowercase(), person.displayName)) else -> Text(value, color = MaterialTheme.colorScheme.onSurfaceVariant) } } @@ -763,25 +775,25 @@ private fun GenerateFeedback( is GenerateState.Ready -> AlertDialog( onDismissRequest = onDismiss, title = { Text(state.label) }, - text = { Text("${state.file.name} is ready.") }, - confirmButton = { TextButton(onClick = { onShare(state.file); onDismiss() }) { Text("Share") } }, - dismissButton = { TextButton(onClick = onDismiss) { Text("Close") } }, + text = { Text(stringResource(R.string.flights_file_ready, state.file.name)) }, + confirmButton = { TextButton(onClick = { onShare(state.file); onDismiss() }) { Text(stringResource(R.string.flights_share)) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.flights_close)) } }, ) is GenerateState.Invalid -> AlertDialog( onDismissRequest = onDismiss, - title = { Text("Missing information") }, + title = { Text(stringResource(R.string.flights_missing_information)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { state.errors.forEach { Text("${it.displayField}: ${it.error}") } } }, - confirmButton = { TextButton(onClick = onDismiss) { Text("OK") } }, + confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.flights_ok)) } }, ) is GenerateState.Failed -> AlertDialog( onDismissRequest = onDismiss, - title = { Text("Could not generate") }, + title = { Text(stringResource(R.string.flights_could_not_generate)) }, text = { Text(state.message) }, - confirmButton = { TextButton(onClick = onDismiss) { Text("OK") } }, + confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.flights_ok)) } }, ) else -> Unit } 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 307a108..815e575 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 @@ -1,5 +1,6 @@ package aero.flyfun.forms.ui.flights +import aero.flyfun.forms.R import aero.flyfun.forms.data.AircraftEntity import aero.flyfun.forms.data.Airports import aero.flyfun.forms.data.FlightEntity @@ -28,6 +29,13 @@ import aero.flyfun.forms.logic.AirportSummary import aero.flyfun.forms.logic.RecentRoute import aero.flyfun.forms.logic.RecentRoutes import aero.flyfun.forms.logic.NextOccurrence +import aero.flyfun.forms.logic.FlightExchange +import aero.flyfun.forms.logic.ImportedRoute +import aero.flyfun.forms.logic.WeatherFlightSummary +import aero.flyfun.forms.net.WeatherImportException +import aero.flyfun.forms.net.exportFlight +import aero.flyfun.forms.net.listFlights +import android.content.res.Resources import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Job @@ -116,6 +124,8 @@ class FlightsViewModel( private val spokenLanguages: () -> Set = { emptySet() }, /** The bundled airport database; null where there is none (tests). */ private val airports: Airports? = null, + /** The app's (not an activity's) resources, for messages this shows; see strings.xml. */ + private val resources: Resources, ) : ViewModel() { val allFlights: StateFlow> = @@ -278,7 +288,7 @@ class FlightsViewModel( fun setAircraft(aircraftId: String?) = edit { detail -> detail.copy( flight = detail.flight.copy(aircraftId = aircraftId), - aircraft = aircraft.value.firstOrNull { it.id == aircraftId }, + aircraft = (aircraft.value + listOfNotNull(_stagedAircraft.value)).firstOrNull { it.id == aircraftId }, ) } @@ -288,6 +298,8 @@ class FlightsViewModel( /** Crew in seat order (PIC first) and passengers; someone in both stays crew. */ fun setPeople(crew: List, passengers: List) = edit { detail -> + // Chosen, not imported: a later import leaves them alone. + peopleFromImport = false val byId = (crew + passengers).associateBy { it.id } val (crewIds, passengerIds) = PeopleRanking.withoutDuplicates(crew.map { it.id }, passengers.map { it.id }) detail.copy(crew = crewIds.map { byId.getValue(it) }, passengers = passengerIds.map { byId.getValue(it) }) @@ -394,12 +406,116 @@ class FlightsViewModel( responsiblePerson = source.responsiblePerson, ) } - _importSummary.value = "Copied from ${from.originICAO.ifBlank { "????" }} → ${from.destinationICAO.ifBlank { "????" }}" + peopleFromImport = true + _stagedAircraft.value = null + _importSummary.value = resources.getString( + R.string.flights_copied_from, + from.originICAO.ifBlank { "????" }, + from.destinationICAO.ifBlank { "????" }, + ) + } + + /** + * Whether the draft's people came from an import rather than the pilot: + * the next import replaces those, and leaves a hand-picked crew alone. + * iOS `NewFlightFlow.peopleCameFromImport`. + */ + private var peopleFromImport = false + + private val _stagedAircraft = MutableStateFlow(null) + + /** + * An aircraft an import named that is not on file yet. Offered with the + * others, and stored only when the flight is: a cancelled import leaves + * nothing behind (iOS `importedAircraft`). + */ + val stagedAircraft: StateFlow = _stagedAircraft.asStateFlow() + + /** + * A flight planned in FlyFun Weather: its route, schedule and aircraft. + * Port of iOS `NewFlightFlow.apply(_:)` for a `FlightExchange`. + * + * The exchange carries no people, so an earlier import's people are + * cleared (a customs form must not list a previous trip's crew), while a + * crew the pilot chose stays. The form-level settings, which have no + * editor in the new-flight flow, go back to a new flight's. + */ + fun importWeather(exchange: FlightExchange) = viewModelScope.launch { + val draft = _detail.value ?: return@launch + val route = ImportedRoute.from( + exchange, + origin = draft.flight.originICAO, + destination = draft.flight.destinationICAO, + departure = draft.flight.departureInstant, + arrival = draft.flight.arrivalInstant, + ) + val registration = route.registration + val onFile = registration?.let { flights.aircraftByRegistration(it) } + val staged = if (registration != null && onFile == null) { + _stagedAircraft.value?.takeIf { ImportedRoute.sameRegistration(it.registration, registration) } + ?: AircraftEntity(registration = registration.uppercase(), type = route.aircraftType.orEmpty()) + } else { + null + } + // No registration keeps the draft's aircraft (below), so it must keep + // the staged one behind it too: dropping it would leave aircraftId + // pointing at a row save() never writes, failing the foreign key. + if (registration != null) _stagedAircraft.value = staged + val fresh = FlightEntity(departureInstant = route.departure, arrivalInstant = route.arrival) + val clearPeople = peopleFromImport + edit { current -> + val chosen = onFile ?: staged + current.copy( + flight = current.flight.copy( + originICAO = route.origin, + destinationICAO = route.destination, + departureInstant = route.departure, + arrivalInstant = route.arrival, + aircraftId = if (registration != null) chosen?.id else current.flight.aircraftId, + nature = fresh.nature, + contact = fresh.contact, + observations = fresh.observations, + reasonForVisit = fresh.reasonForVisit, + responsiblePersonId = null, + chosenDocNumbers = null, + ), + aircraft = if (registration != null) chosen else current.aircraft, + crew = if (clearPeople) emptyList() else current.crew, + passengers = if (clearPeople) emptyList() else current.passengers, + responsiblePerson = null, + ) + } + peopleFromImport = false + _importSummary.value = resources.getString(R.string.flights_imported_from_weather) + } + + /** The pilot's FlyFun Weather flights, newest first; throws with a message to show. */ + suspend fun weatherFlights(): List = weatherCall { api.weather.listFlights() } + + /** One FlyFun Weather flight, ready for [importWeather]; throws with a message to show. */ + suspend fun weatherFlight(id: String): FlightExchange = weatherCall { api.weather.exportFlight(id) } + + /** A weather call, its failure worded in the app's language; network failures as for forms. */ + private suspend fun weatherCall(call: suspend () -> T): T = try { + call() + } catch (e: WeatherImportException) { + throw Exception(e.message(resources)) + } catch (e: java.io.IOException) { + throw Exception(e.friendlyMessage(resources)) } /** Store the draft: the flight row, then who is on it. */ fun save(): Job = viewModelScope.launch { - val draft = _detail.value ?: return@launch + var draft = _detail.value ?: return@launch + // An aircraft an import named becomes real with the flight - unless + // one with that registration was added meanwhile. + _stagedAircraft.value?.takeIf { it.id == draft.flight.aircraftId }?.let { staged -> + val stored = flights.aircraftByRegistration(staged.registration) + ?: staged.also { flights.saveAircraft(it) } + _stagedAircraft.value = null + val withAircraft = draft.copy(flight = draft.flight.copy(aircraftId = stored.id), aircraft = stored) + if (_detail.compareAndSet(draft, withAircraft)) draft = withAircraft + } val id = draft.flight.id flights.saveFlight(draft.flight) flights.setCrew(id, draft.crew.map { it.id }) @@ -490,7 +606,7 @@ class FlightsViewModel( viewModelScope.launch { val result: FetchedForms = runCatching { api.forms.airport(icao) }.fold( onSuccess = { FetchedForms.Loaded(it.name, it.forms) }, - onFailure = { FetchedForms.Failed(it.friendlyMessage()) }, + onFailure = { FetchedForms.Failed(it.friendlyMessage(resources)) }, ) fetched.update { it + (icao to result) } } @@ -506,7 +622,7 @@ class FlightsViewModel( val current = _detail.value ?: return null val aircraft = current.aircraft if (aircraft == null) { - _generate.value = GenerateState.Failed("Pick an aircraft for this flight first.") + _generate.value = GenerateState.Failed(resources.getString(R.string.flights_pick_aircraft_first)) return null } _generate.value = GenerateState.Working(form.id) @@ -620,14 +736,14 @@ class FlightsViewModel( 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()) + _generate.value = GenerateState.Failed(it.friendlyMessage(resources)) return null } when { response.isSuccessful -> { val bytes = response.body()?.bytes() if (bytes == null) { - _generate.value = GenerateState.Failed("The server returned an empty file.") + _generate.value = GenerateState.Failed(resources.getString(R.string.flights_empty_file)) return null } val file = FormFiles.dir(cacheDir).resolve(response.suggestedFileName(airport, form.id)) @@ -638,7 +754,7 @@ class FlightsViewModel( val body = response.errorBody()?.string().orEmpty() _generate.value = GenerateState.Invalid(api.parseValidationErrors(body)) } - else -> _generate.value = GenerateState.Failed("Server returned ${response.code()}.") + else -> _generate.value = GenerateState.Failed(resources.getString(R.string.flights_server_returned_code, response.code())) } return null } @@ -652,7 +768,7 @@ class FlightsViewModel( val request = buildRequest(airport, form) ?: return@launch _generate.value = runCatching { api.forms.prefill(request) }.fold( onSuccess = { GenerateState.WebPlan(it) }, - onFailure = { GenerateState.Failed(it.friendlyMessage()) }, + onFailure = { GenerateState.Failed(it.friendlyMessage(resources)) }, ) } @@ -671,18 +787,21 @@ class FlightsViewModel( * A pilot at an airfield can act on "sign in" or "no connection". They cannot * act on "HTTP 401", which is what Retrofit hands us. */ -private fun Throwable.friendlyMessage(): String = when { +private fun Throwable.friendlyMessage(resources: Resources): String = when { this is java.net.UnknownHostException -> - "No connection. Loading forms needs the server." + resources.getString(R.string.flights_error_no_connection) this is java.net.SocketTimeoutException -> - "The server took too long to respond." + resources.getString(R.string.flights_error_timeout) this is retrofit2.HttpException -> when (code()) { - 401, 403 -> "Sign in to load the forms for this airport." - 404 -> "This airport has no forms on file." - in 500..599 -> "The forms server is having trouble. Try again shortly." - else -> "The server returned ${code()}." + 401, 403 -> resources.getString(R.string.flights_error_sign_in) + 404 -> resources.getString(R.string.flights_error_no_forms) + in 500..599 -> resources.getString(R.string.flights_error_server_trouble) + else -> resources.getString(R.string.flights_error_server_returned, code()) } - this is java.io.IOException -> "Network problem: ${message ?: "connection failed"}" + this is java.io.IOException -> resources.getString( + R.string.flights_error_network, + message ?: resources.getString(R.string.flights_error_connection_failed), + ) else -> message ?: this::class.simpleName.orEmpty() } diff --git a/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/flights/NewFlightScreens.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/flights/NewFlightScreens.kt index 5312f63..2ad6251 100644 --- a/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/flights/NewFlightScreens.kt +++ b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/flights/NewFlightScreens.kt @@ -1,5 +1,6 @@ package aero.flyfun.forms.ui.flights +import aero.flyfun.forms.R import aero.flyfun.forms.data.AircraftEntity import aero.flyfun.forms.data.FlightEntity import aero.flyfun.forms.data.PersonEntity @@ -19,6 +20,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.GroupAdd import androidx.compose.material.icons.filled.History import androidx.compose.material3.Card @@ -37,6 +39,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import java.time.Instant import java.time.ZoneId @@ -67,9 +72,12 @@ fun NewFlightScreen( airportInfo: Map, importSummary: String?, hasPreviousFlights: Boolean, + /** FlyFun Weather needs the account; offered greyed with the reason otherwise. */ + signedIn: Boolean, suggestion: SuggestionChoice?, hasCrewSources: Boolean, onImportPrevious: () -> Unit, + onImportWeather: () -> Unit, onOpenRoutePicker: () -> Unit, onSetDeparture: (Instant) -> Unit, onSetArrival: (Instant) -> Unit, @@ -84,16 +92,17 @@ fun NewFlightScreen( ) { val flight = detail.flight val steps = rememberSaveableStateHolder() + val none = stringResource(R.string.flights_none) BackHandler { if (step == NewFlightStep.PEOPLE) onBack() else onCancel() } Scaffold( topBar = { TopAppBar( - title = { Text(if (step == NewFlightStep.ROUTE) "New Flight" else "Add People") }, + title = { Text(stringResource(if (step == NewFlightStep.ROUTE) R.string.flights_new_flight else R.string.flights_add_people)) }, navigationIcon = { if (step == NewFlightStep.PEOPLE) { - IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } + IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.flights_back)) } } else { - IconButton(onClick = onCancel) { Icon(Icons.Default.Close, contentDescription = "Cancel") } + IconButton(onClick = onCancel) { Icon(Icons.Default.Close, contentDescription = stringResource(R.string.flights_cancel)) } } }, actions = { @@ -101,9 +110,9 @@ fun NewFlightScreen( TextButton( onClick = onNext, enabled = flight.originICAO.isNotBlank() || flight.destinationICAO.isNotBlank(), - ) { Text("Next") } + ) { Text(stringResource(R.string.flights_next)) } } else { - TextButton(onClick = onCreate) { Text("Create Flight") } + TextButton(onClick = onCreate) { Text(stringResource(R.string.flights_create_flight)) } } }, ) @@ -117,44 +126,51 @@ fun NewFlightScreen( steps.SaveableStateProvider(step) { when (step) { NewFlightStep.ROUTE -> { - Text("Import", style = MaterialTheme.typography.titleMedium) - OutlinedButton(onClick = onImportPrevious, enabled = hasPreviousFlights) { - Icon(Icons.Default.History, contentDescription = null) - Text("Previous Flight", Modifier.padding(start = 8.dp)) - } - Text( - if (hasPreviousFlights) "Repeat a flight with the same crew, rescheduled" else "No earlier flights yet", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + // Every method listed, an unusable one greyed with its + // reason, as iOS's import list (FlightImportMethod). + Text(stringResource(R.string.flights_import), style = MaterialTheme.typography.titleMedium) + ImportMethod( + icon = Icons.Default.History, + title = stringResource(R.string.flights_previous_flight), + subtitle = stringResource(if (hasPreviousFlights) R.string.flights_previous_flight_subtitle else R.string.flights_no_earlier_flights), + enabled = hasPreviousFlights, + onClick = onImportPrevious, + ) + ImportMethod( + icon = Icons.Default.Cloud, + title = stringResource(R.string.flights_flyfun_weather), + subtitle = stringResource(if (signedIn) R.string.flights_weather_subtitle else R.string.flights_sign_in_to_import), + enabled = signedIn, + onClick = onImportWeather, ) importSummary?.let { Text(it, style = MaterialTheme.typography.bodySmall) } - Text("Route", style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.flights_route), style = MaterialTheme.typography.titleMedium) ListItem( - headlineContent = { Text("Route") }, + headlineContent = { Text(stringResource(R.string.flights_route)) }, trailingContent = { Text( - if (flight.originICAO.isBlank() && flight.destinationICAO.isBlank()) "Tap to select" + if (flight.originICAO.isBlank() && flight.destinationICAO.isBlank()) stringResource(R.string.flights_tap_to_select) else "${flight.originICAO.ifBlank { "----" }} → ${flight.destinationICAO.ifBlank { "----" }}", ) }, modifier = Modifier.clickable(onClick = onOpenRoutePicker), ) - Text("Schedule", style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.flights_schedule), style = MaterialTheme.typography.titleMedium) val zones = listOfNotNull( airportInfo[flight.originICAO]?.timeZone, airportInfo[flight.destinationICAO]?.timeZone, ).distinct() - ScheduleField("Departure", flight.departureInstant, onSetDeparture, zones, airportInfo[flight.originICAO]?.timeZone) - ScheduleField("Arrival", flight.arrivalInstant, onSetArrival, zones, airportInfo[flight.destinationICAO]?.timeZone) + ScheduleField(stringResource(R.string.flights_departure), flight.departureInstant, onSetDeparture, zones, airportInfo[flight.originICAO]?.timeZone) + ScheduleField(stringResource(R.string.flights_arrival), flight.arrivalInstant, onSetArrival, zones, airportInfo[flight.destinationICAO]?.timeZone) - Text("Aircraft", style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.flights_aircraft), style = MaterialTheme.typography.titleMedium) ChoiceField( - label = "Aircraft", + label = stringResource(R.string.flights_aircraft), selected = detail.aircraft, options = listOf(null) + aircraftOptions, - display = { a -> a?.let { "${it.registration} (${it.type.ifBlank { "?" }})" } ?: "None" }, + display = { a -> a?.let { "${it.registration} (${it.type.ifBlank { "?" }})" } ?: none }, onSelect = { onSetAircraft(it?.id) }, ) } @@ -162,7 +178,7 @@ fun NewFlightScreen( NewFlightStep.PEOPLE -> { // Only while nobody is chosen: an import that brought people hides it. if (suggestion != null && detail.crew.isEmpty() && detail.passengers.isEmpty()) { - Text("Suggestion", style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.flights_suggestion), style = MaterialTheme.typography.titleMedium) Card(Modifier.fillMaxWidth().clickable { onApplySuggestion(suggestion) }) { ListItem( leadingContent = { Icon(Icons.Default.GroupAdd, contentDescription = null) }, @@ -174,18 +190,18 @@ fun NewFlightScreen( if (hasCrewSources) { TextButton(onClick = onOpenCrewSources) { Icon(Icons.AutoMirrored.Filled.List, contentDescription = null) - Text("Choose another flight…", Modifier.padding(start = 8.dp)) + Text(stringResource(R.string.flights_choose_another_flight), Modifier.padding(start = 8.dp)) } } - Text("Crew", style = MaterialTheme.typography.titleMedium) - if (detail.crew.isEmpty()) Hint("No crew selected") + Text(stringResource(R.string.flights_crew), style = MaterialTheme.typography.titleMedium) + if (detail.crew.isEmpty()) Hint(stringResource(R.string.flights_no_crew_selected)) detail.crew.forEach { Text(it.displayName) } - Text("Passengers", style = MaterialTheme.typography.titleMedium) - if (detail.passengers.isEmpty()) Hint("No passengers selected") + Text(stringResource(R.string.flights_passengers), style = MaterialTheme.typography.titleMedium) + if (detail.passengers.isEmpty()) Hint(stringResource(R.string.flights_no_passengers_selected)) detail.passengers.forEach { Text(it.displayName) } OutlinedButton(onClick = onOpenPeoplePicker) { Icon(Icons.Default.GroupAdd, contentDescription = null) - Text("Select People", Modifier.padding(start = 8.dp)) + Text(stringResource(R.string.flights_select_people), Modifier.padding(start = 8.dp)) } } } @@ -194,6 +210,17 @@ fun NewFlightScreen( } } +@Composable +private fun ImportMethod(icon: ImageVector, title: String, subtitle: String, enabled: Boolean, onClick: () -> Unit) { + val alpha = if (enabled) 1f else 0.38f + ListItem( + leadingContent = { Icon(icon, contentDescription = null, Modifier.alpha(alpha)) }, + headlineContent = { Text(title, Modifier.alpha(alpha)) }, + supportingContent = { Text(subtitle, Modifier.alpha(alpha)) }, + modifier = Modifier.clickable(enabled = enabled, onClick = onClick), + ) +} + @Composable private fun Hint(text: String) { Text(text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) @@ -221,7 +248,7 @@ fun PastFlightPickerScreen( topBar = { TopAppBar( title = { Text(title) }, - navigationIcon = { IconButton(onClick = onCancel) { Icon(Icons.Default.Close, contentDescription = "Cancel") } }, + navigationIcon = { IconButton(onClick = onCancel) { Icon(Icons.Default.Close, contentDescription = stringResource(R.string.flights_cancel)) } }, ) }, ) { padding -> diff --git a/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/flights/ScheduleField.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/flights/ScheduleField.kt index faae56f..1126dda 100644 --- a/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/flights/ScheduleField.kt +++ b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/flights/ScheduleField.kt @@ -1,5 +1,6 @@ package aero.flyfun.forms.ui.flights +import aero.flyfun.forms.R import aero.flyfun.forms.logic.ZonedWallClock import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -25,6 +26,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import java.time.Instant import java.time.ZoneOffset @@ -74,7 +76,7 @@ fun ScheduleField( val options = ZonedWallClock.timeZoneOptions(zones, instant) if (options.size > 1) { ChoiceField( - label = "$label zone", + label = stringResource(R.string.flights_zone_label, label), selected = options.firstOrNull { it.identifier == zoneId } ?: options.first(), options = options, display = { it.label }, @@ -101,9 +103,9 @@ fun ScheduleField( onChange(clock.settingDate(picked.year, picked.monthValue, picked.dayOfMonth).instant) } showDate = false - }) { Text("OK") } + }) { Text(stringResource(R.string.flights_ok)) } }, - dismissButton = { TextButton(onClick = { showDate = false }) { Text("Cancel") } }, + dismissButton = { TextButton(onClick = { showDate = false }) { Text(stringResource(R.string.flights_cancel)) } }, ) { DatePicker(state = state) } } @@ -115,15 +117,15 @@ fun ScheduleField( ) AlertDialog( onDismissRequest = { showTime = false }, - title = { Text("$label time (${if (isUtc) "UTC" else clock.zoneId})") }, + title = { Text(stringResource(R.string.flights_time_title, label, if (isUtc) "UTC" else clock.zoneId)) }, text = { TimePicker(state = state) }, confirmButton = { TextButton(onClick = { onChange(clock.settingHour(state.hour).settingMinute(state.minute).instant) showTime = false - }) { Text("OK") } + }) { Text(stringResource(R.string.flights_ok)) } }, - dismissButton = { TextButton(onClick = { showTime = false }) { Text("Cancel") } }, + dismissButton = { TextButton(onClick = { showTime = false }) { Text(stringResource(R.string.flights_cancel)) } }, ) } } diff --git a/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/flights/WeatherFlightPicker.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/flights/WeatherFlightPicker.kt new file mode 100644 index 0000000..b55a2b3 --- /dev/null +++ b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/flights/WeatherFlightPicker.kt @@ -0,0 +1,166 @@ +package aero.flyfun.forms.ui.flights + +import aero.flyfun.forms.R +import aero.flyfun.forms.logic.FlightExchange +import aero.flyfun.forms.logic.WeatherFlightSummary +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Download +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +private val WHEN: DateTimeFormatter = DateTimeFormatter.ofPattern("d MMM yyyy HH:mm'Z'").withZone(ZoneOffset.UTC) + +/** + * The pilot's FlyFun Weather flights; the one picked comes back as a + * `FlightExchange`. Port of iOS `WeatherFlightPickerView`. + * + * The calls run in this screen's scope, so leaving it mid-import cancels the + * import rather than filling the flight after the picker has gone. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WeatherFlightPickerScreen( + load: suspend () -> List, + export: suspend (String) -> FlightExchange, + onImport: (FlightExchange) -> Unit, + onCancel: () -> Unit, +) { + var flights by remember { mutableStateOf?>(null) } + var loadError by remember { mutableStateOf(null) } + var attempt by remember { mutableIntStateOf(0) } + var importing by remember { mutableStateOf(null) } + var importError by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + val couldNotReach = stringResource(R.string.flights_could_not_reach_weather) + val couldNotImport = stringResource(R.string.flights_could_not_import) + + LaunchedEffect(attempt) { + loadError = null + flights = null + try { + flights = load() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + loadError = e.message ?: couldNotReach + } + } + + BackHandler(onBack = onCancel) + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.flights_import_from_weather)) }, + navigationIcon = { IconButton(onClick = onCancel) { Icon(Icons.Default.Close, contentDescription = stringResource(R.string.flights_cancel)) } }, + ) + }, + ) { padding -> + val loaded = flights + when { + loadError != null -> Centered(padding) { + Text(stringResource(R.string.flights_couldnt_load), style = MaterialTheme.typography.titleMedium) + Text(loadError.orEmpty(), style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center) + OutlinedButton(onClick = { attempt++ }) { Text(stringResource(R.string.flights_retry)) } + } + loaded == null -> Centered(padding) { + CircularProgressIndicator() + Text(stringResource(R.string.flights_loading_your_flights), style = MaterialTheme.typography.bodyMedium) + } + loaded.isEmpty() -> Centered(padding) { + Text(stringResource(R.string.flights_no_flights), style = MaterialTheme.typography.titleMedium) + Text( + stringResource(R.string.flights_weather_empty), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + } + else -> LazyColumn(Modifier.fillMaxSize().padding(padding)) { + items(loaded, key = { it.id }) { flight -> + ListItem( + headlineContent = { Text(flight.routeLabel, fontFamily = FontFamily.Monospace) }, + supportingContent = { flight.departure?.let { Text(WHEN.format(it)) } }, + trailingContent = { + if (importing == flight.id) { + CircularProgressIndicator(Modifier.size(24.dp)) + } else { + Icon(Icons.Default.Download, contentDescription = null) + } + }, + modifier = Modifier.clickable(enabled = importing == null) { + importing = flight.id + scope.launch { + try { + onImport(export(flight.id)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + importError = e.message ?: couldNotImport + } finally { + importing = null + } + } + }, + ) + HorizontalDivider() + } + } + } + } + + importError?.let { message -> + AlertDialog( + onDismissRequest = { importError = null }, + title = { Text(stringResource(R.string.flights_import_failed)) }, + text = { Text(message) }, + confirmButton = { TextButton(onClick = { importError = null }) { Text(stringResource(R.string.flights_ok)) } }, + ) + } +} + +@Composable +private fun Centered(padding: androidx.compose.foundation.layout.PaddingValues, content: @Composable () -> Unit) { + Column( + Modifier.fillMaxSize().padding(padding).padding(32.dp), + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + ) { content() } +} diff --git a/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/people/ContactResolveScreen.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/people/ContactResolveScreen.kt index 5d84503..d8cc128 100644 --- a/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/people/ContactResolveScreen.kt +++ b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/people/ContactResolveScreen.kt @@ -1,5 +1,6 @@ package aero.flyfun.forms.ui.people +import aero.flyfun.forms.R import aero.flyfun.forms.data.PersonEntity import aero.flyfun.forms.logic.ContactImport import aero.flyfun.forms.logic.ImportedContact @@ -36,6 +37,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import java.time.format.DateTimeFormatter @@ -70,8 +72,8 @@ fun ContactResolveScreen( Scaffold( topBar = { TopAppBar( - title = { Text("Import Contact") }, - navigationIcon = { IconButton(onClick = onCancel) { Icon(Icons.Default.Close, contentDescription = "Cancel") } }, + title = { Text(stringResource(R.string.people_import_contact)) }, + navigationIcon = { IconButton(onClick = onCancel) { Icon(Icons.Default.Close, contentDescription = stringResource(R.string.people_cancel)) } }, ) }, ) { padding -> @@ -79,18 +81,18 @@ fun ContactResolveScreen( Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(4.dp), ) { - Section("Contact") + Section(stringResource(R.string.people_contact)) Column(Modifier.padding(horizontal = 16.dp)) { - Line("Name", "${contact.firstName} ${contact.lastName}".trim()) - Pick("Phone", contact.phones, phone) { phone = it } - Pick("Email", contact.emails, email) { email = it } - contact.dateOfBirth?.let { Line("Date of Birth", it.format(DAY)) } - Pick("Address", contact.addresses, address) { address = it } + Line(stringResource(R.string.people_name), "${contact.firstName} ${contact.lastName}".trim()) + Pick(stringResource(R.string.people_phone), contact.phones, phone) { phone = it } + Pick(stringResource(R.string.people_email), contact.emails, email) { email = it } + contact.dateOfBirth?.let { Line(stringResource(R.string.people_date_of_birth_title), it.format(DAY)) } + Pick(stringResource(R.string.people_address), contact.addresses, address) { address = it } } ListItem( leadingContent = { Icon(Icons.Default.PersonAdd, contentDescription = null) }, - headlineContent = { Text("Create as New Person", color = MaterialTheme.colorScheme.primary) }, + headlineContent = { Text(stringResource(R.string.people_create_as_new_person), color = MaterialTheme.colorScheme.primary) }, modifier = Modifier.clickable { onResult( PersonEntity( @@ -106,9 +108,9 @@ fun ContactResolveScreen( ) if (matches.isNotEmpty()) { - Section("Update Existing") + Section(stringResource(R.string.people_update_existing)) SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { - listOf(false to "Fill Missing Only", true to "Override All").forEachIndexed { index, (value, label) -> + listOf(false to stringResource(R.string.people_fill_missing_only), true to stringResource(R.string.people_override_all)).forEachIndexed { index, (value, label) -> SegmentedButton( selected = override == value, onClick = { override = value }, @@ -121,7 +123,7 @@ fun ContactResolveScreen( headlineContent = { Text(person.displayName) }, supportingContent = listOfNotNull(person.phone, person.email).joinToString(" · ") .takeIf { it.isNotEmpty() }?.let { { Text(it, maxLines = 1) } }, - trailingContent = { Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = "Update") }, + trailingContent = { Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = stringResource(R.string.people_update)) }, modifier = Modifier.clickable { val merged = ContactImport.merge( person.fields(), contact, diff --git a/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/people/OptionalDateField.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/people/OptionalDateField.kt index 96b3f95..f6a0b60 100644 --- a/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/people/OptionalDateField.kt +++ b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/people/OptionalDateField.kt @@ -1,5 +1,6 @@ package aero.flyfun.forms.ui.people +import aero.flyfun.forms.R import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -25,6 +26,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import java.time.LocalDate import java.time.format.DateTimeFormatter @@ -63,11 +65,11 @@ fun OptionalDateField( Text(label, style = MaterialTheme.typography.bodyLarge) Row(verticalAlignment = Alignment.CenterVertically) { OutlinedButton(onClick = { showPicker = true }) { - Text(value?.format(displayFormat) ?: "Set") + Text(value?.format(displayFormat) ?: stringResource(R.string.people_set)) } if (value != null) { IconButton(onClick = { onChange(null) }) { - Icon(Icons.Default.Clear, contentDescription = "Clear $label") + Icon(Icons.Default.Clear, contentDescription = stringResource(R.string.people_clear_label, label)) } } } @@ -90,9 +92,9 @@ fun OptionalDateField( TextButton(onClick = { state.selectedDateMillis?.let { onChange(LocalDate.ofEpochDay(Math.floorDiv(it, MILLIS_PER_DAY))) } showPicker = false - }) { Text("OK") } + }) { Text(stringResource(R.string.people_ok)) } }, - dismissButton = { TextButton(onClick = { showPicker = false }) { Text("Cancel") } }, + dismissButton = { TextButton(onClick = { showPicker = false }) { Text(stringResource(R.string.people_cancel)) } }, ) { DatePicker(state = state, title = { Text(label, modifier = Modifier.padding(start = 24.dp, end = 12.dp, top = 16.dp)) }) } } } diff --git a/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/people/PeoplePickerScreen.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/people/PeoplePickerScreen.kt index ed1a483..a69e8d4 100644 --- a/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/people/PeoplePickerScreen.kt +++ b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/people/PeoplePickerScreen.kt @@ -1,5 +1,6 @@ package aero.flyfun.forms.ui.people +import aero.flyfun.forms.R import aero.flyfun.forms.data.PersonEntity import aero.flyfun.forms.data.PersonWithDocuments import aero.flyfun.forms.logic.FlightPeople @@ -42,6 +43,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import java.time.Instant import java.time.ZoneId @@ -90,45 +92,51 @@ fun PeoplePickerScreen( everyone.filter { PeopleRanking.matches(query, it.firstName, it.lastName) }.map { it.ranked(lastFlights) }, ).mapNotNull { byId[it.id] } } - val group: Pair>? = remember(crew, passengers, everyone, flightPeople) { + val usualCrewTitle = stringResource(R.string.people_usual_crew_group) + val frequentWith = stringResource(R.string.people_frequent_with) + val group: Pair>? = remember(crew, passengers, everyone, flightPeople, usualCrewTitle, frequentWith) { val anchor = crew.firstOrNull() ?: passengers.firstOrNull() if (anchor == null) { - everyone.filter { it.isUsualCrew }.takeIf { it.isNotEmpty() }?.let { "Usual Crew" to it } + everyone.filter { it.isUsualCrew }.takeIf { it.isNotEmpty() }?.let { usualCrewTitle to it } } else { PeopleRanking.coTravelers(anchor.id, flightPeople) .mapNotNull { byId[it] } .filter { it.id !in selectedIds } .takeIf { it.isNotEmpty() } - ?.let { "Frequent with ${anchor.displayName}" to it } + ?.let { frequentWith.format(anchor.displayName) to it } } } Scaffold( topBar = { TopAppBar( - title = { Text("Crew & Passengers") }, + title = { Text(stringResource(R.string.people_crew_and_passengers)) }, navigationIcon = { - IconButton(onClick = onDone) { Icon(Icons.Default.Close, contentDescription = "Close") } + IconButton(onClick = onDone) { Icon(Icons.Default.Close, contentDescription = stringResource(R.string.people_close)) } }, actions = { Box { IconButton(onClick = { addMenu = true }) { - Icon(Icons.Default.Add, contentDescription = "Add person") + Icon(Icons.Default.Add, contentDescription = stringResource(R.string.people_add_person_description)) } DropdownMenu(expanded = addMenu, onDismissRequest = { addMenu = false }) { AddPersonMenuItems(add) { addMenu = false } } } - TextButton(onClick = onDone) { Text("Done") } + TextButton(onClick = onDone) { Text(stringResource(R.string.people_done)) } }, ) }, ) { padding -> Column(Modifier.fillMaxSize().padding(padding)) { - SearchField(query, { query = it }, "Search people") + SearchField(query, { query = it }, stringResource(R.string.people_search_people)) + // Headers are added outside composition, so their titles are read here. + val selectedTitle = stringResource(R.string.people_selected) + val groupsTitle = stringResource(R.string.people_groups) + val peopleTitle = stringResource(R.string.people_title) LazyColumn(Modifier.fillMaxSize()) { if (crew.isNotEmpty() || passengers.isNotEmpty()) { - header("Selected") + header(selectedTitle) crew.forEachIndexed { index, person -> item(key = "crew:${person.id}") { SelectedRow( @@ -155,12 +163,12 @@ fun PeoplePickerScreen( // The groups widen a selection, so they give way to a search. if (group != null && query.isBlank()) { - header("Groups") + header(groupsTitle) item(key = "group") { ListItem( headlineContent = { Text(group.first) }, supportingContent = { Text(group.second.joinToString(", ") { it.displayName }, maxLines = 2) }, - trailingContent = { Icon(Icons.Default.Add, contentDescription = "Add all") }, + trailingContent = { Icon(Icons.Default.Add, contentDescription = stringResource(R.string.people_add_all)) }, modifier = Modifier.clickable { val (addCrew, addPax) = group.second.partition { it.isUsualCrew } onChange(crew + addCrew, passengers + addPax) @@ -170,15 +178,15 @@ fun PeoplePickerScreen( } if (matching.isNotEmpty()) { - header("People") + header(peopleTitle) items(matching, key = { "person:${it.id}" }) { person -> val selected = person.id in selectedIds ListItem( - headlineContent = { Text(person.displayName.ifBlank { "New Person" }) }, - supportingContent = lastFlights[person.id]?.let { { Text("Flew ${dayFormat.format(it)}") } }, + headlineContent = { Text(person.displayName.ifBlank { stringResource(R.string.people_new_person) }) }, + supportingContent = lastFlights[person.id]?.let { { Text(stringResource(R.string.people_flew, dayFormat.format(it))) } }, leadingContent = if (person.isUsualCrew) { { CrewPill() } } else null, trailingContent = if (selected) { - { Icon(Icons.Default.Check, contentDescription = "On this flight") } + { Icon(Icons.Default.Check, contentDescription = stringResource(R.string.people_on_this_flight)) } } else { null }, @@ -191,7 +199,7 @@ fun PeoplePickerScreen( if (name.isNotEmpty() && matching.isEmpty()) { item(key = "add-named") { ListItem( - headlineContent = { Text("Add “$name” as new person") }, + headlineContent = { Text(stringResource(R.string.people_add_named, name)) }, leadingContent = { Icon(Icons.Default.PersonAdd, contentDescription = null) }, modifier = Modifier.clickable { query = "" @@ -226,30 +234,31 @@ private fun SelectedRow( onUp: (() -> Unit)? = null, onDown: (() -> Unit)? = null, ) { + val newPerson = stringResource(R.string.people_new_person) ListItem( - headlineContent = { Text(person.displayName.ifBlank { "New Person" }) }, + headlineContent = { Text(person.displayName.ifBlank { newPerson }) }, supportingContent = { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { // Tap to move between crew and passengers; one person is never both. FilterChip( selected = isCrew, onClick = onToggle, - label = { Text(if (isCrew) "Crew" else "Passenger") }, + label = { Text(stringResource(if (isCrew) R.string.people_crew else R.string.people_passenger)) }, ) - if (isPic) Text("Pilot in command", style = MaterialTheme.typography.bodySmall) + if (isPic) Text(stringResource(R.string.people_pilot_in_command), style = MaterialTheme.typography.bodySmall) } }, trailingContent = { Row { if (isCrew) { IconButton(onClick = { onUp?.invoke() }, enabled = onUp != null) { - Icon(Icons.Default.KeyboardArrowUp, contentDescription = "Move up") + Icon(Icons.Default.KeyboardArrowUp, contentDescription = stringResource(R.string.people_move_up)) } IconButton(onClick = { onDown?.invoke() }, enabled = onDown != null) { - Icon(Icons.Default.KeyboardArrowDown, contentDescription = "Move down") + Icon(Icons.Default.KeyboardArrowDown, contentDescription = stringResource(R.string.people_move_down)) } } - IconButton(onClick = onRemove) { Icon(Icons.Default.Close, contentDescription = "Remove") } + IconButton(onClick = onRemove) { Icon(Icons.Default.Close, contentDescription = stringResource(R.string.people_remove)) } } }, ) 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 add6147..4e21e75 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 @@ -1,5 +1,6 @@ package aero.flyfun.forms.ui.people +import aero.flyfun.forms.R import aero.flyfun.forms.data.PersonEntity import aero.flyfun.forms.data.PersonWithDocuments import aero.flyfun.forms.data.TravelDocumentEntity @@ -47,6 +48,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold @@ -63,6 +65,8 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp @@ -93,6 +97,8 @@ fun PeopleListScreen( add: AddPersonActions, onExportCsv: () -> Unit, onDelete: (PersonEntity) -> Unit, + /** The person open beside the list, on a screen wide enough for both. */ + selectedId: String? = null, ) { var query by rememberSaveable { mutableStateOf("") } var sortByRecent by rememberSaveable { mutableStateOf(false) } @@ -112,21 +118,21 @@ fun PeopleListScreen( Scaffold( topBar = { TopAppBar( - title = { Text("People") }, + title = { Text(stringResource(R.string.people_title)) }, actions = { IconButton(onClick = { sortByRecent = !sortByRecent }) { Icon( if (sortByRecent) Icons.Default.SortByAlpha else Icons.Default.Schedule, - contentDescription = if (sortByRecent) "Sort A-Z" else "Sort by Recent", + contentDescription = stringResource(if (sortByRecent) R.string.people_sort_az else R.string.people_sort_by_recent), ) } Box { IconButton(onClick = { overflow = true }) { - Icon(Icons.Default.MoreVert, contentDescription = "More") + Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.people_more)) } DropdownMenu(expanded = overflow, onDismissRequest = { overflow = false }) { DropdownMenuItem( - text = { Text("Export to CSV") }, + text = { Text(stringResource(R.string.people_export_csv)) }, leadingIcon = { Icon(Icons.Default.Upload, contentDescription = null) }, enabled = people.isNotEmpty(), onClick = { overflow = false; onExportCsv() }, @@ -139,7 +145,7 @@ fun PeopleListScreen( floatingActionButton = { Box { FloatingActionButton(onClick = { addMenu = true }) { - Icon(Icons.Default.Add, contentDescription = "Add person") + Icon(Icons.Default.Add, contentDescription = stringResource(R.string.people_add_person_description)) } DropdownMenu(expanded = addMenu, onDismissRequest = { addMenu = false }) { AddPersonMenuItems(add) { addMenu = false } @@ -149,7 +155,7 @@ fun PeopleListScreen( ) { padding -> Column(Modifier.fillMaxSize().padding(padding)) { if (people.isNotEmpty()) { - SearchField(query, { query = it }, "Search by name") + SearchField(query, { query = it }, stringResource(R.string.people_search_by_name)) } when { people.isEmpty() -> Column( @@ -157,22 +163,22 @@ fun PeopleListScreen( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { - Text("No crew or passengers yet", style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.people_empty_title), style = MaterialTheme.typography.titleMedium) Spacer(Modifier.height(8.dp)) Text( - "Add the people you fly with, and their passports, so forms fill themselves.", + stringResource(R.string.people_empty_message), style = MaterialTheme.typography.bodyMedium, ) } shown.isEmpty() -> Text( - "No one matches “${query.trim()}”.", + stringResource(R.string.people_no_match, query.trim()), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(16.dp), ) else -> LazyColumn(modifier = Modifier.fillMaxSize()) { items(shown, key = { "${it.person.id}:${it.person.updatedAt}" }) { row -> SwipeToDelete(onDelete = { onDelete(row.person) }) { - PersonRow(row, lastFlight = lastFlights[row.person.id].takeIf { sortByRecent }) { + PersonRow(row, lastFlight = lastFlights[row.person.id].takeIf { sortByRecent }, selected = row.person.id == selectedId) { onOpen(row.person.id) } } @@ -188,27 +194,27 @@ fun PeopleListScreen( @Composable fun AddPersonMenuItems(add: AddPersonActions, close: () -> Unit) { DropdownMenuItem( - text = { Text("Add Person") }, + text = { Text(stringResource(R.string.people_add_person)) }, leadingIcon = { Icon(Icons.Default.PersonAdd, contentDescription = null) }, onClick = { close(); add.onAdd() }, ) add.onScan?.let { scan -> DropdownMenuItem( - text = { Text("Scan Document") }, + text = { Text(stringResource(R.string.people_scan_document)) }, leadingIcon = { Icon(Icons.Default.DocumentScanner, contentDescription = null) }, onClick = { close(); scan() }, ) } add.onFromContact?.let { contact -> DropdownMenuItem( - text = { Text("Import from Contact") }, + text = { Text(stringResource(R.string.people_import_from_contact)) }, leadingIcon = { Icon(Icons.Default.ContactPage, contentDescription = null) }, onClick = { close(); contact() }, ) } add.onImportCsv?.let { csv -> DropdownMenuItem( - text = { Text("Import from CSV") }, + text = { Text(stringResource(R.string.people_import_from_csv)) }, leadingIcon = { Icon(Icons.Default.Download, contentDescription = null) }, onClick = { close(); csv() }, ) @@ -216,28 +222,36 @@ fun AddPersonMenuItems(add: AddPersonActions, close: () -> Unit) { } @Composable -private fun PersonRow(row: PersonWithDocuments, lastFlight: Instant?, onClick: () -> Unit) { +private fun PersonRow(row: PersonWithDocuments, lastFlight: Instant?, selected: Boolean, onClick: () -> Unit) { val active = row.documents.filter { it.isActive && it.deletedAt == null } + val newPerson = stringResource(R.string.people_new_person) + val documents = if (active.isEmpty()) { + stringResource(R.string.people_no_documents) + } else { + pluralStringResource(R.plurals.people_documents_count, active.size, active.size) + } + val flew = lastFlight?.let { stringResource(R.string.people_flew, lastFlightFormat.format(it)) } ListItem( - headlineContent = { Text(row.person.displayName.ifBlank { "New Person" }) }, + headlineContent = { Text(row.person.displayName.ifBlank { newPerson }) }, supportingContent = { Text( listOfNotNull( // Nationality derives from the documents, never the person. active.mapNotNull { it.issuingCountry }.distinct().joinToString("/").ifBlank { null }, - if (active.isEmpty()) "No documents" else "${active.size} document${if (active.size == 1) "" else "s"}", - lastFlight?.let { "Flew ${lastFlightFormat.format(it)}" }, + documents, + flew, ).joinToString(" · "), ) }, trailingContent = if (row.person.isUsualCrew) { { CrewPill() } } else null, + colors = if (selected) ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.secondaryContainer) else ListItemDefaults.colors(), modifier = Modifier.clickable(onClick = onClick), ) } /** The small "Crew" tag iOS puts on usual crew. */ @Composable -fun CrewPill(text: String = "Crew") { +fun CrewPill(text: String = stringResource(R.string.people_crew)) { Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = CircleShape) { Text( text, @@ -260,7 +274,7 @@ fun SearchField(query: String, onChange: (String) -> Unit, placeholder: String, placeholder = { Text(placeholder) }, leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, trailingIcon = if (query.isNotEmpty()) { - { IconButton(onClick = { onChange("") }) { Icon(Icons.Default.Close, contentDescription = "Clear search") } } + { IconButton(onClick = { onChange("") }) { Icon(Icons.Default.Close, contentDescription = stringResource(R.string.people_clear_search)) } } } else { null }, @@ -287,6 +301,23 @@ private val dateFormat: DateTimeFormatter = DateTimeFormatter.ofPattern("d MMM y private val sexOptions = listOf("Male", "Female") private val documentTypes = listOf("Passport", "Identity card", "Other") +/** The shown label for a stored sex value; anything unknown is shown as stored. */ +@Composable +private fun sexLabel(value: String): String = when (value) { + "Male" -> stringResource(R.string.people_sex_male) + "Female" -> stringResource(R.string.people_sex_female) + else -> value +} + +/** The shown label for a stored document type; anything unknown is shown as stored. */ +@Composable +private fun documentTypeLabel(value: String): String = when (value) { + "Passport" -> stringResource(R.string.people_doc_type_passport) + "Identity card" -> stringResource(R.string.people_doc_type_identity_card) + "Other" -> stringResource(R.string.people_doc_type_other) + else -> value +} + /** * The person's own fields, plus the list of their documents. * @@ -336,14 +367,14 @@ fun PersonEditScreen( Scaffold( topBar = { TopAppBar( - title = { Text(if (existing == null) "New Person" else "Edit Person") }, + title = { Text(stringResource(if (existing == null) R.string.people_new_person else R.string.people_edit_person)) }, navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.Default.ArrowBack, contentDescription = "Back") + Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.people_back)) } }, actions = { - TextButton(onClick = { onSave(edited()) }) { Text("Save") } + TextButton(onClick = { onSave(edited()) }) { Text(stringResource(R.string.people_save)) } onDelete?.let { DeleteOverflowMenu(onDelete = it) } }, ) @@ -360,7 +391,7 @@ fun PersonEditScreen( OutlinedTextField( value = firstName, onValueChange = { firstName = it }, - label = { Text("First name") }, + label = { Text(stringResource(R.string.people_first_name)) }, singleLine = true, keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Words), modifier = Modifier.fillMaxWidth(), @@ -368,16 +399,16 @@ fun PersonEditScreen( OutlinedTextField( value = lastName, onValueChange = { lastName = it }, - label = { Text("Last name") }, + label = { Text(stringResource(R.string.people_last_name)) }, singleLine = true, keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Words), modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(4.dp)) - Text("Details", style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.people_details), style = MaterialTheme.typography.titleMedium) OptionalDateField( - label = "Date of birth", + label = stringResource(R.string.people_date_of_birth), value = dateOfBirth, onChange = { dateOfBirth = it }, yearRange = 1900..LocalDate.now().year, @@ -386,7 +417,7 @@ fun PersonEditScreen( OutlinedTextField( value = placeOfBirth, onValueChange = { placeOfBirth = it }, - label = { Text("Place of birth") }, + label = { Text(stringResource(R.string.people_place_of_birth)) }, singleLine = true, keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Words), modifier = Modifier.fillMaxWidth(), @@ -396,14 +427,14 @@ fun PersonEditScreen( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text("Sex", style = MaterialTheme.typography.bodyLarge) + Text(stringResource(R.string.people_sex), style = MaterialTheme.typography.bodyLarge) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { sexOptions.forEach { option -> FilterChip( selected = sex == option, // Tapping the selected chip again clears it. onClick = { sex = if (sex == option) null else option }, - label = { Text(option) }, + label = { Text(sexLabel(option)) }, ) } } @@ -411,7 +442,7 @@ fun PersonEditScreen( OutlinedTextField( value = email, onValueChange = { email = it }, - label = { Text("Email") }, + label = { Text(stringResource(R.string.people_email)) }, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), modifier = Modifier.fillMaxWidth(), @@ -419,7 +450,7 @@ fun PersonEditScreen( OutlinedTextField( value = phone, onValueChange = { phone = it }, - label = { Text("Phone") }, + label = { Text(stringResource(R.string.people_phone)) }, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), modifier = Modifier.fillMaxWidth(), @@ -427,7 +458,7 @@ fun PersonEditScreen( OutlinedTextField( value = address, onValueChange = { address = it }, - label = { Text("Address") }, + label = { Text(stringResource(R.string.people_address)) }, minLines = 2, maxLines = 3, modifier = Modifier.fillMaxWidth(), @@ -437,12 +468,12 @@ fun PersonEditScreen( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text("Usual crew", style = MaterialTheme.typography.bodyLarge) + Text(stringResource(R.string.people_usual_crew), style = MaterialTheme.typography.bodyLarge) Switch(checked = usualCrew, onCheckedChange = { usualCrew = it }) } Spacer(Modifier.height(8.dp)) - Text("Travel documents", style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.people_travel_documents), style = MaterialTheme.typography.titleMedium) DocumentSection( documents = initial?.documents.orEmpty().filter { it.deletedAt == null }, onOpen = { docId -> onOpenDocument(edited(), docId) }, @@ -463,22 +494,26 @@ private fun DocumentSection( Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { documents.forEach { doc -> Card(modifier = Modifier.fillMaxWidth().clickable { onOpen(doc.id) }) { + val type = documentTypeLabel(doc.docType) + val noNumber = stringResource(R.string.people_no_number) + val expires = doc.expiryDate?.let { stringResource(R.string.people_expires, it.format(dateFormat)) } + val inactive = stringResource(R.string.people_inactive) ListItem( headlineContent = { - Text("${doc.docType} (${doc.issuingCountry ?: "?"})") + Text("$type (${doc.issuingCountry ?: "?"})") }, supportingContent = { Text( buildString { - append(doc.docNumber.ifBlank { "No number" }) - doc.expiryDate?.let { append(" · expires ${it.format(dateFormat)}") } - if (!doc.isActive) append(" · inactive") + append(doc.docNumber.ifBlank { noNumber }) + expires?.let { append(" · $it") } + if (!doc.isActive) append(" · $inactive") }, ) }, trailingContent = { IconButton(onClick = { onDelete(doc.id) }) { - Icon(Icons.Default.Delete, contentDescription = "Remove document") + Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.people_remove_document)) } }, ) @@ -486,9 +521,9 @@ private fun DocumentSection( } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - TextButton(onClick = { onOpen(null) }) { Text("Add document") } + TextButton(onClick = { onOpen(null) }) { Text(stringResource(R.string.people_add_document)) } if (onScan != null) { - TextButton(onClick = onScan) { Text("Scan passport") } + TextButton(onClick = onScan) { Text(stringResource(R.string.people_scan_passport)) } } } } @@ -517,10 +552,10 @@ fun DocumentEditScreen( Scaffold( topBar = { TopAppBar( - title = { Text(if (isNew) "New Document" else "Edit Document") }, + title = { Text(stringResource(if (isNew) R.string.people_new_document else R.string.people_edit_document)) }, navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.Default.ArrowBack, contentDescription = "Back") + Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.people_back)) } }, actions = { @@ -537,7 +572,7 @@ fun DocumentEditScreen( ), ) }, - ) { Text("Save") } + ) { Text(stringResource(R.string.people_save)) } }, ) }, @@ -550,13 +585,13 @@ fun DocumentEditScreen( .padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Text("Document type", style = MaterialTheme.typography.bodyLarge) + Text(stringResource(R.string.people_document_type), style = MaterialTheme.typography.bodyLarge) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { documentTypes.forEach { type -> FilterChip( selected = docType == type, onClick = { docType = type }, - label = { Text(type) }, + label = { Text(documentTypeLabel(type)) }, ) } } @@ -565,7 +600,7 @@ fun DocumentEditScreen( // Upper-cased on save, not per keystroke: rewriting the text under // the keyboard breaks its composing span and drops characters. onValueChange = { number = it }, - label = { Text("Document number") }, + label = { Text(stringResource(R.string.people_document_number)) }, singleLine = true, keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Characters), modifier = Modifier.fillMaxWidth(), @@ -573,13 +608,13 @@ fun DocumentEditScreen( OutlinedTextField( value = country, onValueChange = { if (it.length <= 3) country = it.uppercase() }, - label = { Text("Issuing country (e.g. FRA)") }, + label = { Text(stringResource(R.string.people_issuing_country_hint)) }, singleLine = true, keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Characters), modifier = Modifier.fillMaxWidth(), ) OptionalDateField( - label = "Expiry date", + label = stringResource(R.string.people_expiry_date), value = expiry, onChange = { expiry = it }, yearRange = LocalDate.now().year - 20..LocalDate.now().year + 20, @@ -589,13 +624,13 @@ fun DocumentEditScreen( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text("Active", style = MaterialTheme.typography.bodyLarge) + Text(stringResource(R.string.people_active), style = MaterialTheme.typography.bodyLarge) Switch(checked = active, onCheckedChange = { active = it }) } if (!isNew) { Spacer(Modifier.height(8.dp)) TextButton(onClick = onDelete) { - Text("Remove document", color = MaterialTheme.colorScheme.error) + Text(stringResource(R.string.people_remove_document), color = MaterialTheme.colorScheme.error) } } } diff --git a/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/people/PeopleViewModel.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/people/PeopleViewModel.kt index 030faa6..4787324 100644 --- a/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/people/PeopleViewModel.kt +++ b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/people/PeopleViewModel.kt @@ -1,5 +1,7 @@ package aero.flyfun.forms.ui.people +import android.content.res.Resources +import aero.flyfun.forms.R import aero.flyfun.forms.data.FlightRepository import aero.flyfun.forms.data.PersonEntity import aero.flyfun.forms.data.PersonWithDocuments @@ -29,6 +31,8 @@ import java.time.Instant class PeopleViewModel( private val repository: PeopleRepository, private val flights: FlightRepository, + /** The app's (not an activity's) resources, for messages this shows; see strings.xml. */ + private val resources: Resources, ) : ViewModel() { val people: StateFlow> = @@ -52,8 +56,13 @@ class PeopleViewModel( fun importCsv(content: String) = viewModelScope.launch { _csvResult.value = runCatching { repository.importCsv(content) }.fold( - onSuccess = { CsvResult("Import Complete", it.summary) }, - onFailure = { CsvResult("Import Failed", it.message ?: "The file could not be read.") }, + onSuccess = { CsvResult(resources.getString(R.string.people_import_complete), it.summary) }, + onFailure = { + CsvResult( + resources.getString(R.string.people_import_failed), + it.message ?: resources.getString(R.string.people_file_could_not_be_read), + ) + }, ) } diff --git a/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/people/ScanResultSheet.kt b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/people/ScanResultSheet.kt index dbf7acd..6f90df6 100644 --- a/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/people/ScanResultSheet.kt +++ b/app/android/app/src/main/kotlin/aero/flyfun/forms/ui/people/ScanResultSheet.kt @@ -1,5 +1,6 @@ package aero.flyfun.forms.ui.people +import aero.flyfun.forms.R import aero.flyfun.forms.data.PersonEntity import aero.flyfun.forms.logic.MRZFormat import aero.flyfun.forms.logic.ScanContext @@ -30,6 +31,7 @@ import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import java.time.format.DateTimeFormatter @@ -62,16 +64,19 @@ fun ScanResultSheet( verticalArrangement = Arrangement.spacedBy(4.dp), ) { Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically) { - Text("Scan Result", style = MaterialTheme.typography.titleLarge, modifier = Modifier.weight(1f)) - TextButton(onClick = onCancel) { Text("Cancel") } + Text(stringResource(R.string.people_scan_result), style = MaterialTheme.typography.titleLarge, modifier = Modifier.weight(1f)) + TextButton(onClick = onCancel) { Text(stringResource(R.string.people_cancel)) } } - Section("Scanned Document") - Field("Name", scannedName) - Field("Document", "${if (result.format == MRZFormat.TD1) "ID Card" else "Passport"} ${result.passportNumber}") - Field("Nationality", result.nationality) - Field("Date of Birth", result.dateOfBirth.format(DAY)) - Field("Expiry", result.expiryDate.format(DAY)) + Section(stringResource(R.string.people_scanned_document)) + Field(stringResource(R.string.people_name), scannedName) + Field( + stringResource(R.string.people_document), + "${stringResource(if (result.format == MRZFormat.TD1) R.string.people_id_card else R.string.people_doc_type_passport)} ${result.passportNumber}", + ) + Field(stringResource(R.string.people_nationality), result.nationality) + Field(stringResource(R.string.people_date_of_birth_title), result.dateOfBirth.format(DAY)) + Field(stringResource(R.string.people_expiry), result.expiryDate.format(DAY)) if (duplicate != null) { Card( @@ -80,8 +85,8 @@ fun ScanResultSheet( ) { ListItem( leadingContent = { Icon(Icons.Default.Warning, contentDescription = null) }, - headlineContent = { Text("Document already exists") }, - supportingContent = people[duplicate.personId]?.let { { Text("Assigned to ${it.displayName}") } }, + headlineContent = { Text(stringResource(R.string.people_document_already_exists)) }, + supportingContent = people[duplicate.personId]?.let { { Text(stringResource(R.string.people_assigned_to, it.displayName)) } }, ) } } @@ -89,26 +94,29 @@ fun ScanResultSheet( when (val context = decision.context) { is ScanContext.ForPerson -> { val person = people[context.personId] - val name = person?.displayName?.ifBlank { null } ?: "this person" + val name = person?.displayName?.ifBlank { null } ?: stringResource(R.string.people_this_person) val ownDuplicate = duplicate?.personId == context.personId - Section(if (duplicate != null && !ownDuplicate) "Actions (document already exists)" else "Actions") + Section(stringResource(if (duplicate != null && !ownDuplicate) R.string.people_actions_document_exists else R.string.people_actions)) when { duplicate == null -> - Action(Icons.Default.AddCircleOutline, "Add document to $name") { onApply(context.personId, false, true) } + Action(Icons.Default.AddCircleOutline, stringResource(R.string.people_add_document_to, name)) { onApply(context.personId, false, true) } ownDuplicate -> - Action(Icons.Default.Badge, "Update the document on $name") { onApply(context.personId, false, true) } + Action(Icons.Default.Badge, stringResource(R.string.people_update_document_on, name)) { onApply(context.personId, false, true) } } if (decision.namesMismatch) { if (duplicate == null) { - Action(Icons.Default.Badge, "Add document and update name to $scannedName") { + Action(Icons.Default.Badge, stringResource(R.string.people_add_document_update_name, scannedName)) { onApply(context.personId, true, true) } } - Action(Icons.Default.PersonAdd, "Create new person instead", onCreatePerson) + Action(Icons.Default.PersonAdd, stringResource(R.string.people_create_new_person_instead), onCreatePerson) } if (duplicate != null && !ownDuplicate && !decision.namesMismatch) { Text( - "Nothing to add: open ${people[duplicate.personId]?.displayName ?: "its owner"} to edit it.", + stringResource( + R.string.people_nothing_to_add, + people[duplicate.personId]?.displayName ?: stringResource(R.string.people_its_owner), + ), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(horizontal = 16.dp), ) @@ -120,20 +128,20 @@ fun ScanResultSheet( ScanContext.Standalone -> { val matches = decision.matchingPeople.mapNotNull { people[it] } if (matches.isNotEmpty()) { - Section("Matching People") + Section(stringResource(R.string.people_matching_people)) matches.forEach { person -> // Their own copy is refreshed; someone else's is not copied onto them. val addDocument = duplicate == null || duplicate.personId == person.id ListItem( headlineContent = { Text(person.displayName) }, - supportingContent = person.dateOfBirth?.let { { Text("Born ${it.format(DAY)}") } }, - trailingContent = { Icon(Icons.Default.AddCircleOutline, contentDescription = "Use") }, + supportingContent = person.dateOfBirth?.let { { Text(stringResource(R.string.people_born, it.format(DAY))) } }, + trailingContent = { Icon(Icons.Default.AddCircleOutline, contentDescription = stringResource(R.string.people_use)) }, modifier = Modifier.clickable { onApply(person.id, false, addDocument) }, ) } } - Section(if (matches.isEmpty()) "Actions" else "Or") - Action(Icons.Default.PersonAdd, "Create new person", onCreatePerson) + Section(stringResource(if (matches.isEmpty()) R.string.people_actions else R.string.people_or)) + Action(Icons.Default.PersonAdd, stringResource(R.string.people_create_new_person), onCreatePerson) } } } 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 61a5020..8d1cd74 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,5 +1,7 @@ package aero.flyfun.forms.ui.settings +import android.content.res.Resources +import aero.flyfun.forms.R import aero.flyfun.forms.data.DataTransfer import aero.flyfun.forms.data.FormFiles import aero.flyfun.forms.data.LocalDataEraser @@ -38,6 +40,8 @@ class DataTransferViewModel( private val eraser: LocalDataEraser, private val cacheDir: File, private val appVersion: String, + /** The app's (not an activity's) resources, for messages this shows; see strings.xml. */ + private val resources: Resources, ) : ViewModel() { private val _state = MutableStateFlow(TransferState.Idle) @@ -53,7 +57,7 @@ class DataTransferViewModel( val file = outDir().resolve("flyfun-forms-data.ffdata") file.writeBytes(bytes) TransferState.Exported(file, passphrase) - }.getOrElse { TransferState.Failed(it.message ?: "Export failed") } + }.getOrElse { TransferState.Failed(it.message ?: resources.getString(R.string.settings_export_failed)) } } /** GDPR Art. 20: machine-readable, and deliberately not encrypted. */ @@ -63,7 +67,7 @@ class DataTransferViewModel( val file = outDir().resolve("flyfun-forms-export.json") file.writeText(transfer.exportPlain(appVersion)) TransferState.Exported(file, null) - }.getOrElse { TransferState.Failed(it.message ?: "Export failed") } + }.getOrElse { TransferState.Failed(it.message ?: resources.getString(R.string.settings_export_failed)) } } fun previewImport(bytes: ByteArray, password: String? = null) = viewModelScope.launch { @@ -79,8 +83,8 @@ class DataTransferViewModel( }.getOrElse { when (it) { is DataFileCrypto.WrongPasswordException -> - TransferState.Failed("That password does not match this file.") - else -> TransferState.Failed(it.message ?: "Could not read that file") + TransferState.Failed(resources.getString(R.string.settings_wrong_password)) + else -> TransferState.Failed(it.message ?: resources.getString(R.string.settings_could_not_read_file)) } } } @@ -92,7 +96,7 @@ class DataTransferViewModel( transfer.apply(summary) pending = null TransferState.Imported(summary) - }.getOrElse { TransferState.Failed(it.message ?: "Import failed") } + }.getOrElse { TransferState.Failed(it.message ?: resources.getString(R.string.settings_import_failed)) } } /** GDPR Art. 17 on the device: every person, document, aircraft, flight and trip. */ @@ -102,7 +106,7 @@ class DataTransferViewModel( _state.value = runCatching { eraser.eraseAll() TransferState.Erased - }.getOrElse { TransferState.Failed(it.message ?: "Could not delete the data") } + }.getOrElse { TransferState.Failed(it.message ?: resources.getString(R.string.settings_delete_all_data_failed)) } } fun reset() { 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 1a62f7a..c61f982 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,8 @@ package aero.flyfun.forms.ui.settings +import aero.flyfun.forms.R +import aero.flyfun.forms.auth.SignInProvider +import aero.flyfun.forms.ui.common.SignInButtons import aero.flyfun.forms.logic.SpokenLanguages import aero.flyfun.forms.net.ApiConfig import androidx.compose.foundation.layout.Arrangement @@ -32,6 +35,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import java.io.File @@ -53,7 +57,7 @@ More: ${ApiConfig.PRIVACY_URL}#passengers""" fun SettingsScreen( state: TransferState, signedIn: Boolean, - onSignIn: () -> Unit, + onSignIn: (SignInProvider) -> Unit, onExportEncrypted: () -> Unit, onExportPlain: () -> Unit, onPickFile: () -> Unit, @@ -74,14 +78,14 @@ fun SettingsScreen( var confirmDeleteAccount by remember { mutableStateOf(false) } var confirmEraseAll by remember { mutableStateOf(false) } - Scaffold(topBar = { TopAppBar(title = { Text("Settings") }) }) { padding -> + Scaffold(topBar = { TopAppBar(title = { Text(stringResource(R.string.settings_title)) }) }) { padding -> Column( Modifier.fillMaxSize().padding(padding).padding(16.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(16.dp), ) { Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp)) { - Text("Languages you speak", style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.settings_languages_you_speak), style = MaterialTheme.typography.titleMedium) SpokenLanguages.ALL.forEach { (code, name) -> Row( Modifier.fillMaxWidth(), @@ -93,8 +97,7 @@ fun SettingsScreen( } } Text( - "When an airport's local language matches one you speak, emails are written " + - "in that language. Otherwise English is used.", + stringResource(R.string.settings_languages_footer), style = MaterialTheme.typography.bodySmall, ) } @@ -102,45 +105,40 @@ fun SettingsScreen( Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp), Arrangement.spacedBy(8.dp)) { - Text("Move my data", style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.settings_move_my_data), style = MaterialTheme.typography.titleMedium) Text( - "Creates one encrypted file holding your people, aircraft and flights, " + - "protected by a passphrase shown once. Use it to move everything to " + - "another device.", + stringResource(R.string.settings_move_my_data_footer), style = MaterialTheme.typography.bodySmall, ) Button(onClick = onExportEncrypted, enabled = state !is TransferState.Working) { - Text("Export encrypted file") + Text(stringResource(R.string.settings_export_encrypted_file)) } OutlinedButton(onClick = onPickFile, enabled = state !is TransferState.Working) { - Text("Import from a file") + Text(stringResource(R.string.settings_import_from_a_file)) } } } Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp), Arrangement.spacedBy(8.dp)) { - Text("Download a copy of my data", style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.settings_download_copy), style = MaterialTheme.typography.titleMedium) Text( - "A plain JSON copy of everything this app holds about you, for your own " + - "records. It is NOT encrypted, and it contains passport details - " + - "keep it somewhere safe.", + stringResource(R.string.settings_download_copy_footer), style = MaterialTheme.typography.bodySmall, ) OutlinedButton(onClick = onExportPlain, enabled = state !is TransferState.Working) { - Text("Export unencrypted JSON") + Text(stringResource(R.string.settings_export_unencrypted_json)) } } } Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp), Arrangement.spacedBy(8.dp)) { - Text("Privacy", style = MaterialTheme.typography.titleMedium) - OutlinedButton(onClick = onOpenPrivacyPolicy) { Text("Privacy policy") } - OutlinedButton(onClick = onSharePassengerNote) { Text("Privacy note for passengers") } + Text(stringResource(R.string.settings_privacy), style = MaterialTheme.typography.titleMedium) + OutlinedButton(onClick = onOpenPrivacyPolicy) { Text(stringResource(R.string.settings_privacy_policy)) } + OutlinedButton(onClick = onSharePassengerNote) { Text(stringResource(R.string.settings_passenger_note)) } Text( - "Your passengers' details come from you, not from them. Share this note " + - "so they know how their details are used.", + stringResource(R.string.settings_passenger_note_footer), style = MaterialTheme.typography.bodySmall, ) } @@ -148,10 +146,9 @@ fun SettingsScreen( Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp), Arrangement.spacedBy(8.dp)) { - Text("Delete all data", style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.settings_delete_all_data), style = MaterialTheme.typography.titleMedium) Text( - "Removes every person, document, aircraft, flight and trip from this phone, " + - "with any generated forms and exports. You stay signed in.", + stringResource(R.string.settings_delete_all_data_footer), style = MaterialTheme.typography.bodySmall, ) OutlinedButton( @@ -160,21 +157,19 @@ fun SettingsScreen( colors = ButtonDefaults.outlinedButtonColors( contentColor = MaterialTheme.colorScheme.error, ), - ) { Text("Delete all data") } + ) { Text(stringResource(R.string.settings_delete_all_data)) } } } if (signedIn) { - OutlinedButton(onClick = onSignOut) { Text("Sign out") } + OutlinedButton(onClick = onSignOut) { Text(stringResource(R.string.settings_sign_out)) } Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp), Arrangement.spacedBy(8.dp)) { - Text("Delete account", style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.settings_delete_account), style = MaterialTheme.typography.titleMedium) Text( deleteAccountError - ?: "Permanently deletes your FlightForms account and its usage records " + - "on the server. People, aircraft, flights and trips stay on this phone - " + - "use Delete all data to remove them.", + ?: stringResource(R.string.settings_delete_account_footer), style = MaterialTheme.typography.bodySmall, color = if (deleteAccountError != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, @@ -186,7 +181,7 @@ fun SettingsScreen( colors = ButtonDefaults.outlinedButtonColors( contentColor = MaterialTheme.colorScheme.error, ), - ) { Text("Delete account") } + ) { Text(stringResource(R.string.settings_delete_account)) } if (deletingAccount) CircularProgressIndicator(Modifier.padding(start = 12.dp)) } } @@ -196,13 +191,12 @@ fun SettingsScreen( // 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(stringResource(R.string.settings_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.", + stringResource(R.string.settings_not_signed_in_footer), style = MaterialTheme.typography.bodySmall, ) - Button(onClick = onSignIn) { Text("Sign in with Google") } + SignInButtons(onSignIn) } } } @@ -214,81 +208,72 @@ fun SettingsScreen( if (confirmDeleteAccount) { AlertDialog( onDismissRequest = { confirmDeleteAccount = false }, - title = { Text("Delete account") }, - text = { - Text( - "Deletes your FlightForms account and its usage records on the server. " + - "This cannot be undone.\n\nPeople, aircraft, flights and trips stay on this " + - "phone. To remove them too, use Delete all data.", - ) - }, + title = { Text(stringResource(R.string.settings_delete_account)) }, + text = { Text(stringResource(R.string.settings_delete_account_confirm_message)) }, confirmButton = { TextButton( onClick = { confirmDeleteAccount = false; onDeleteAccount() }, colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), - ) { Text("Delete my account") } + ) { Text(stringResource(R.string.settings_delete_my_account)) } }, - dismissButton = { TextButton(onClick = { confirmDeleteAccount = false }) { Text("Cancel") } }, + dismissButton = { TextButton(onClick = { confirmDeleteAccount = false }) { Text(stringResource(R.string.common_cancel)) } }, ) } if (confirmEraseAll) { AlertDialog( onDismissRequest = { confirmEraseAll = false }, - title = { Text("Delete all data?") }, + title = { Text(stringResource(R.string.settings_delete_all_data_confirm_title)) }, text = { - Text( - "Deletes all people, documents, aircraft, flights and trips from this phone. " + - "This cannot be undone. Your FlightForms account is not affected.", - ) + Text(stringResource(R.string.settings_delete_all_data_confirm_message)) }, confirmButton = { TextButton( onClick = { confirmEraseAll = false; onEraseAll() }, colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), - ) { Text("Delete all data") } + ) { Text(stringResource(R.string.settings_delete_all_data)) } }, - dismissButton = { TextButton(onClick = { confirmEraseAll = false }) { Text("Cancel") } }, + dismissButton = { TextButton(onClick = { confirmEraseAll = false }) { Text(stringResource(R.string.common_cancel)) } }, ) } when (state) { TransferState.Erased -> AlertDialog( onDismissRequest = onDismiss, - title = { Text("Data deleted") }, - text = { Text("All people, documents, aircraft, flights and trips have been removed from this phone.") }, - confirmButton = { TextButton(onClick = onDismiss) { Text("OK") } }, + title = { Text(stringResource(R.string.settings_data_deleted)) }, + text = { Text(stringResource(R.string.settings_data_deleted_message)) }, + confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_ok)) } }, ) is TransferState.Exported -> AlertDialog( onDismissRequest = onDismiss, - title = { Text(if (state.passphrase != null) "Passphrase" else "Export ready") }, + title = { Text(if (state.passphrase != null) stringResource(R.string.settings_passphrase) else stringResource(R.string.settings_export_ready)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { if (state.passphrase != null) { - Text("Type this on the other device to open the file. It is not stored anywhere, so write it down now.") + Text(stringResource(R.string.settings_passphrase_instructions)) SelectionContainer { Text(state.passphrase, style = MaterialTheme.typography.titleMedium) } } else { - Text("${state.file.name} is ready. It is not encrypted.") + Text(stringResource(R.string.settings_file_ready_not_encrypted, state.file.name)) } } }, - confirmButton = { TextButton(onClick = { onShare(state.file) }) { Text("Share file") } }, - dismissButton = { TextButton(onClick = onDismiss) { Text("Done") } }, + confirmButton = { TextButton(onClick = { onShare(state.file) }) { Text(stringResource(R.string.settings_share_file)) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_done)) } }, ) is TransferState.NeedsPassword -> { var password by remember { mutableStateOf("") } AlertDialog( onDismissRequest = onDismiss, - title = { Text("Passphrase") }, + title = { Text(stringResource(R.string.settings_passphrase)) }, text = { OutlinedTextField( value = password, onValueChange = { password = it }, - label = { Text("Passphrase from the other device") }, + label = { Text(stringResource(R.string.settings_passphrase_from_other_device)) }, singleLine = true, ) }, @@ -296,40 +281,40 @@ fun SettingsScreen( TextButton( enabled = password.isNotBlank(), onClick = { onSubmitPassword(password) }, - ) { Text("Open") } + ) { Text(stringResource(R.string.settings_open)) } }, - dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) } }, ) } is TransferState.Previewed -> AlertDialog( onDismissRequest = onDismiss, - title = { Text("Import this file?") }, + title = { Text(stringResource(R.string.settings_import_this_file)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text(state.summary.describe()) Text( - "Nothing already on this device is removed unless the file says it was deleted.", + stringResource(R.string.settings_import_nothing_removed), style = MaterialTheme.typography.bodySmall, ) } }, - confirmButton = { TextButton(onClick = onConfirmImport) { Text("Import") } }, - dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + confirmButton = { TextButton(onClick = onConfirmImport) { Text(stringResource(R.string.settings_import)) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) } }, ) is TransferState.Imported -> AlertDialog( onDismissRequest = onDismiss, - title = { Text("Imported") }, + title = { Text(stringResource(R.string.settings_imported)) }, text = { Text(state.summary.describe()) }, - confirmButton = { TextButton(onClick = onDismiss) { Text("OK") } }, + confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_ok)) } }, ) is TransferState.Failed -> AlertDialog( onDismissRequest = onDismiss, - title = { Text("Could not do that") }, + title = { Text(stringResource(R.string.settings_could_not_do_that)) }, text = { Text(state.message) }, - confirmButton = { TextButton(onClick = onDismiss) { Text("OK") } }, + confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_ok)) } }, ) else -> Unit 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 ec99249..6c45044 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 @@ -1,5 +1,6 @@ package aero.flyfun.forms.ui.webform +import aero.flyfun.forms.R import aero.flyfun.forms.net.FillPlan import android.annotation.SuppressLint import android.content.Context @@ -32,6 +33,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import kotlinx.serialization.builtins.serializer @@ -77,13 +79,13 @@ fun WebFormScreen(plan: FillPlan, onBack: () -> Unit) { title = { Text(plan.label) }, navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.Default.ArrowBack, contentDescription = "Back") + Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.common_back)) } }, actions = { TextButton(onClick = { webView?.let { status = null; it.applyPlan(plan) { s -> status = s } } - }) { Text("Fill again") } + }) { Text(stringResource(R.string.webform_fill_again)) } }, ) }, @@ -100,7 +102,7 @@ fun WebFormScreen(plan: FillPlan, onBack: () -> Unit) { } } Text( - "Check the details, then submit on the page itself.", + stringResource(R.string.webform_submit_on_page), Modifier.padding(horizontal = 12.dp, vertical = 4.dp), style = MaterialTheme.typography.labelSmall, ) @@ -176,9 +178,13 @@ private fun WebView.applyPlan(plan: FillPlan, onStatus: (String) -> Unit) { onStatus( when { - result == null -> "Could not fill this page automatically." - result.missing.isEmpty() -> "Filled ${result.filled.size} fields." - else -> "Filled ${result.filled.size} fields. Not found: ${result.missing.joinToString(", ")}" + result == null -> context.getString(R.string.webform_could_not_fill) + result.missing.isEmpty() -> context.getString(R.string.webform_filled_fields, result.filled.size) + else -> context.getString( + R.string.webform_filled_fields_not_found, + result.filled.size, + result.missing.joinToString(", "), + ) }, ) } diff --git a/app/android/app/src/main/res/drawable-nodpi/ic_launcher_foreground.png b/app/android/app/src/main/res/drawable-nodpi/ic_launcher_foreground.png deleted file mode 100644 index 91cfd45..0000000 Binary files a/app/android/app/src/main/res/drawable-nodpi/ic_launcher_foreground.png and /dev/null differ diff --git a/app/android/app/src/main/res/drawable/ic_shortcut_new_flight.xml b/app/android/app/src/main/res/drawable/ic_shortcut_new_flight.xml new file mode 100644 index 0000000..d94bf79 --- /dev/null +++ b/app/android/app/src/main/res/drawable/ic_shortcut_new_flight.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/android/app/src/main/res/drawable/ic_shortcut_scan.xml b/app/android/app/src/main/res/drawable/ic_shortcut_scan.xml new file mode 100644 index 0000000..69ffc17 --- /dev/null +++ b/app/android/app/src/main/res/drawable/ic_shortcut_scan.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml index c103812..f0ec5cf 100644 --- a/app/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml +++ b/app/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -1,11 +1,11 @@ - - + + + diff --git a/app/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml deleted file mode 100644 index c103812..0000000 --- a/app/android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - diff --git a/app/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png new file mode 100644 index 0000000..972f336 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png differ diff --git a/app/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..f9534d1 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png differ diff --git a/app/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png new file mode 100644 index 0000000..267eb7f Binary files /dev/null and b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png differ diff --git a/app/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..4773e51 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png differ diff --git a/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png new file mode 100644 index 0000000..4340724 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png differ diff --git a/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..5fb3575 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png differ diff --git a/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png new file mode 100644 index 0000000..8d37b9a Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png differ diff --git a/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..8ed7c3a Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png differ diff --git a/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png new file mode 100644 index 0000000..91909f9 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png differ diff --git a/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..b0bcbd7 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png differ diff --git a/app/android/app/src/main/res/values-de/strings.xml b/app/android/app/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..bf01dcf --- /dev/null +++ b/app/android/app/src/main/res/values-de/strings.xml @@ -0,0 +1,141 @@ + + + + Neuer Flug + Flüge + Personen + Luftfahrzeuge + Einstellungen + OK + Luftfahrzeuge + Übliche Besatzung + Import fehlgeschlagen + Flüge + Flug hinzufügen + Neuer Flug + Zurück + Luftfahrzeuge + Art + Gewerblich + Privat + Reisegrund + Verantwortliche Person + Telefon + Adresse + Bemerkungen + Aktionen + Rückflug erstellen + Flug duplizieren + VON + NACH + Besatzung + Passagiere + Dokument: Automatisch + Automatisch + Erstellen + OK + Personen hinzufügen + Abbrechen + Weiter + Flug erstellen + Route + Zum Auswählen tippen + Zeitplan + Keines + Keine Besatzung ausgewählt + Keine Passagiere ausgewählt + Personen auswählen + Fertig + VON + NACH + Flughafen oder ICAO suchen... + Letzte Routen + Flughäfen + Ausgewählt + Import fehlgeschlagen + Zurück + Abbrechen + OK + Fertig + Entfernen + Neue Person + Besatzung + Person hinzufügen + Name + Telefon + Adresse + Geburtsdatum + Personen + A-Z sortieren + Nach Aktualität sortieren + Nach Name suchen + Person hinzufügen + Dokument scannen + Aus CSV importieren + Vorname + Nachname + Details + Geburtsdatum + Geburtsort + Geschlecht + Männlich + Weiblich + Übliche Besatzung + Gültig bis %1$s + Dokument hinzufügen + Dokumenttyp + Reisepass + Personalausweis + Sonstiges + Dokumentnummer + Ausstellungsland (z.B. DEU) + Ablaufdatum + Übliche Besatzung + Häufig mit %1$s + Personen suchen... + Ausgewählt + Gruppen + „%1$s“ als neue Person hinzufügen + Passagier + Scan-Ergebnis + Gescanntes Dokument + Dokument + Personalausweis + Nationalität + Ablauf + Dokument existiert bereits + Zugewiesen an %1$s + Aktionen (Dokument existiert bereits) + Aktionen + Dokument zu %1$s hinzufügen + Stattdessen neue Person erstellen + Übereinstimmende Personen + Oder + Neue Person erstellen + Festlegen + Import abgeschlossen + Import fehlgeschlagen + Dokument scannen + Abbrechen + OK + Fertig + Zurück + Mit Google anmelden + Einstellungen + Abmelden + Import fehlgeschlagen + Luftfahrzeuge + Luftfahrzeug hinzufügen + Neues Luftfahrzeug + Kennzeichen + Flugzeug + Hubschrauber + Eigentümer + Telefon + Adresse + Basis + Datenschutz + Datenschutzerklärung + Datenschutzhinweis für Passagiere + Alle Daten löschen + diff --git a/app/android/app/src/main/res/values-es/strings.xml b/app/android/app/src/main/res/values-es/strings.xml new file mode 100644 index 0000000..c25f37f --- /dev/null +++ b/app/android/app/src/main/res/values-es/strings.xml @@ -0,0 +1,141 @@ + + + + Nuevo vuelo + Vuelos + Personas + Aeronaves + Ajustes + OK + Aeronaves + Tripulación habitual + Error en la importación + Vuelos + Añadir vuelo + Nuevo vuelo + Atrás + Aeronaves + Naturaleza + Comercial + Privado + Motivo de la visita + Persona responsable + Teléfono + Dirección + Observaciones + Acciones + Crear vuelo de regreso + Duplicar vuelo + DE + A + Tripulación + Pasajeros + Documento: automático + Automático + Generar + OK + Añadir personas + Cancelar + Siguiente + Crear vuelo + Ruta + Toque para seleccionar + Horario + Ninguno + Ninguna tripulación seleccionada + Ningún pasajero seleccionado + Seleccionar personas + Listo + DE + A + Buscar aeropuerto o OACI... + Rutas recientes + Aeropuertos + Seleccionado(s) + Error en la importación + Atrás + Cancelar + OK + Listo + Eliminar + Nueva persona + Tripulación + Añadir persona + Nombre + Teléfono + Dirección + Fecha de nacimiento + Personas + Ordenar A-Z + Ordenar por reciente + Buscar por nombre + Añadir persona + Escanear documento + Importar desde CSV + Nombre + Apellido + Detalles + Fecha de nacimiento + Lugar de nacimiento + Sexo + Masculino + Femenino + Tripulación habitual + Vence el %1$s + Añadir documento + Tipo de documento + Pasaporte + Documento de identidad + Otro + Número de documento + País emisor (ej. ESP) + Fecha de vencimiento + Tripulación habitual + Frecuente con %1$s + Buscar personas... + Seleccionado(s) + Grupos + Añadir «%1$s» como nueva persona + Pasajero + Resultado del escaneo + Documento escaneado + Documento + Documento de identidad + Nacionalidad + Vencimiento + El documento ya existe + Asignado a %1$s + Acciones (el documento ya existe) + Acciones + Añadir documento a %1$s + Crear nueva persona en su lugar + Personas coincidentes + O + Crear nueva persona + Establecer + Importación completada + Error en la importación + Escanear documento + Cancelar + OK + Listo + Atrás + Iniciar sesión con Google + Ajustes + Cerrar sesión + Error en la importación + Aeronaves + Añadir aeronave + Nueva aeronave + Matrícula + Avión + Helicóptero + Propietario + Teléfono + Dirección + Base + Privacidad + Política de privacidad + Aviso de privacidad para pasajeros + Eliminar todos los datos + diff --git a/app/android/app/src/main/res/values-fr/strings.xml b/app/android/app/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000..2d6bf3b --- /dev/null +++ b/app/android/app/src/main/res/values-fr/strings.xml @@ -0,0 +1,141 @@ + + + + Nouveau vol + Vols + Personnes + Aéronefs + Réglages + OK + Aéronefs + Équipage habituel + Échec de l\'import + Vols + Ajouter un vol + Nouveau vol + Retour + Aéronefs + Nature + Commercial + Privé + Motif de la visite + Personne responsable + Téléphone + Adresse + Observations + Actions + Créer un vol retour + Dupliquer le vol + DE + VERS + Équipage + Passagers + Document : automatique + Automatique + Générer + OK + Ajouter des personnes + Annuler + Suivant + Créer le vol + Route + Appuyez pour sélectionner + Horaires + Aucun + Aucun équipage sélectionné + Aucun passager sélectionné + Sélectionner des personnes + Terminé + DE + VERS + Rechercher aéroport ou OACI... + Routes récentes + Aéroports + Sélectionné(s) + Échec de l\'import + Retour + Annuler + OK + Terminé + Retirer + Nouvelle personne + Équipage + Ajouter une personne + Nom + Téléphone + Adresse + Date de naissance + Personnes + Trier A-Z + Trier par récent + Rechercher par nom + Ajouter une personne + Scanner un document + Importer depuis CSV + Prénom + Nom de famille + Détails + Date de naissance + Lieu de naissance + Sexe + Masculin + Féminin + Équipage habituel + Expire le %1$s + Ajouter un document + Type de document + Passeport + Carte d\'identité + Autre + Numéro de document + Pays émetteur (ex. FRA) + Date d\'expiration + Équipage habituel + Fréquent avec %1$s + Rechercher des personnes... + Sélectionné(s) + Groupes + Ajouter « %1$s » comme nouvelle personne + Passager + Résultat du scan + Document scanné + Document + Carte d\'identité + Nationalité + Expiration + Le document existe déjà + Assigné à %1$s + Actions (le document existe déjà) + Actions + Ajouter un document à %1$s + Créer une nouvelle personne à la place + Personnes correspondantes + Ou + Créer une nouvelle personne + Définir + Import terminé + Échec de l\'import + Scanner un document + Annuler + OK + Terminé + Retour + Se connecter avec Google + Réglages + Se déconnecter + Échec de l\'import + Aéronefs + Ajouter un aéronef + Nouvel aéronef + Immatriculation + Avion + Hélicoptère + Propriétaire + Téléphone + Adresse + Base + Confidentialité + Politique de confidentialité + Note de confidentialité pour les passagers + Supprimer toutes les données + diff --git a/app/android/app/src/main/res/values/colors.xml b/app/android/app/src/main/res/values/colors.xml deleted file mode 100644 index 3bf2743..0000000 --- a/app/android/app/src/main/res/values/colors.xml +++ /dev/null @@ -1,3 +0,0 @@ - - #84AAF7 - diff --git a/app/android/app/src/main/res/values/strings.xml b/app/android/app/src/main/res/values/strings.xml index 853c636..9ec25e5 100644 --- a/app/android/app/src/main/res/values/strings.xml +++ b/app/android/app/src/main/res/values/strings.xml @@ -1,3 +1,73 @@ + + - FlyFun Forms + FlyFun Forms + New Flight + Scan Passport + + Flights + People + Aircraft + Settings + + Pick a flight, or add one with +. + Pick someone, or add them with +. + Pick an aircraft, or add one with +. + + OK + Save + Discard + Undo + Save your changes? + Opening another flight discards what you have not saved. + + + Deleted %1$s + person + aircraft + + Previous Flight + No earlier flights yet. + Copy Crew From + Once a flight has crew or passengers, you can copy them here. + Usual crew + + Same as %1$s + + Import Failed + Export Failed + Could not read that file. + Could not write that file. + People exported + That file has been cleared. Generate it again. + + Share %1$s + + Sign in to generate customs and immigration forms. + Enter data without signing in + Sign-in failed + Sign-in did not return an auth code + Sign-in state did not match + Could not delete the account. + Your session had expired, so your account was not deleted. Sign in again to delete it. + + The server returned %1$d. Your account was not deleted. + + Sign in to the same FlyFun account to import your weather flights. + This flight has no route to import. + + Weather server error (%1$d)%2$s + Weather server sent nothing back. + + That flight could not be read. %1$s + That contact has no name to import. diff --git a/app/android/app/src/main/res/values/strings_flights.xml b/app/android/app/src/main/res/values/strings_flights.xml new file mode 100644 index 0000000..638381f --- /dev/null +++ b/app/android/app/src/main/res/values/strings_flights.xml @@ -0,0 +1,138 @@ + + + + Flights + Add flight + No flights yet + Add a flight to generate its customs and immigration forms. + Upcoming + No upcoming flights + Past Flights (%1$d) + Hide past flights + Show past flights + + + Flight + New Flight + Back + Save + Departure + Arrival + Arrival is before departure. + Aircraft + Add an aircraft first. + Nature + Commercial + Private + Reason for Visit + Responsible Person + Unnamed + Phone + Address + Observations + Forms + Enter the route to see which forms these airports need. + Actions + Create Return Flight + Create Next Leg + Duplicate Flight + Save this flight? + Save your changes? + Leaving now discards what you have not saved. + Discard + From + To + Change route + Less + More + Crew + No crew yet. + PIC + Passengers + No passengers. + Choose Crew & Passengers + Document: Automatic + Automatic + Remove from flight + + + No forms needed here. + Other forms (%1$d) + Email + Generate + Official web form + Open prefilled + Pick a responsible person to fill in phone and email + Pick a responsible person + Set %1$s on %2$s + %1$s is ready. + Share + Close + Missing information + OK + Could not generate + + + Add People + Cancel + Next + Create Flight + Import + Previous Flight + Repeat a flight with the same crew, rescheduled + No earlier flights yet + FlyFun Weather + A flight you planned in FlyFun Weather + Sign in to import + Route + Tap to select + Schedule + None + Suggestion + Choose another flight… + No crew selected + No passengers selected + Select People + + + Done + FROM + TO + Search airport name or ICAO + Recent Routes + Clear + Airports + Selected + Use %1$s + Not in the airport database + + + %1$s zone + %1$s time (%2$s) + + + Could not reach FlyFun Weather. + Import from Weather + Couldn\'t load flights + Retry + Loading your flights… + No flights + Flights you plan in FlyFun Weather appear here. + That flight could not be imported. + Import Failed + + + Copied from %1$s → %2$s + Imported from FlyFun Weather + Pick an aircraft for this flight first. + The server returned an empty file. + Server returned %1$d. + No connection. Loading forms needs the server. + The server took too long to respond. + Sign in to load the forms for this airport. + This airport has no forms on file. + The forms server is having trouble. Try again shortly. + The server returned %1$d. + Network problem: %1$s + connection failed + diff --git a/app/android/app/src/main/res/values/strings_people.xml b/app/android/app/src/main/res/values/strings_people.xml new file mode 100644 index 0000000..b96bf33 --- /dev/null +++ b/app/android/app/src/main/res/values/strings_people.xml @@ -0,0 +1,141 @@ + + + + Back + Save + Cancel + OK + Close + Done + Remove + New Person + Crew + Flew %1$s + Add person + Name + Email + Phone + Address + Date of Birth + + + People + Sort A-Z + Sort by Recent + More + Export to CSV + Search by name + No crew or passengers yet + Add the people you fly with, and their passports, so forms fill themselves. + No one matches “%1$s”. + Add Person + Scan Document + Import from Contact + Import from CSV + No documents + + %1$d document + %1$d documents + + Clear search + + + Edit Person + First name + Last name + Details + Date of birth + Place of birth + Sex + Male + Female + Usual crew + Travel documents + No number + expires %1$s + inactive + Remove document + Add document + Scan passport + + + New Document + Edit Document + Document type + Passport + Identity card + Other + Document number + Issuing country (e.g. FRA) + Expiry date + Active + + + Crew & Passengers + Usual Crew + Frequent with %1$s + Search people + Selected + Groups + Add all + On this flight + Add “%1$s” as new person + Passenger + Pilot in command + Move up + Move down + + + Scan Result + Scanned Document + Document + ID Card + Nationality + Expiry + Document already exists + Assigned to %1$s + this person + Actions (document already exists) + Actions + Add document to %1$s + Update the document on %1$s + Add document and update name to %1$s + Create new person instead + Nothing to add: open %1$s to edit it. + its owner + Matching People + Born %1$s + Use + Or + Create new person + + + Import Contact + Contact + Create as New Person + Update Existing + Fill Missing Only + Override All + Update + + + Set + Clear %1$s + + + Import Complete + Import Failed + The file could not be read. + + + Scan document + Scan from a photo or PDF + Choose Photo + Choose PDF + Camera access + Used only to read the two lines at the bottom of a passport. No image is saved. + Allow camera + Line up the two lines at the bottom of the passport or ID card + No Document Found + No machine-readable zone (MRZ) was found in the file. Try a clearer image or PDF of the passport page. + diff --git a/app/android/app/src/main/res/values/strings_settings.xml b/app/android/app/src/main/res/values/strings_settings.xml new file mode 100644 index 0000000..e9f8a13 --- /dev/null +++ b/app/android/app/src/main/res/values/strings_settings.xml @@ -0,0 +1,92 @@ + + + + Cancel + OK + Done + Back + Save + Delete + More + Select… + Sign in with Google + Sign in with Apple + + + Settings + Languages you speak + When an airport\'s local language matches one you speak, emails are written in that language. Otherwise English is used. + Move my data + Creates one encrypted file holding your people, aircraft and flights, protected by a passphrase shown once. Use it to move everything to another device. + Export encrypted file + Import from a file + Download a copy of my data + A plain JSON copy of everything this app holds about you, for your own records. It is NOT encrypted, and it contains passport details - keep it somewhere safe. + Export unencrypted JSON + Sign out + Delete account + Permanently deletes your FlightForms account and its usage records on the server. People, aircraft, flights and trips stay on this phone - use Delete all data to remove them. + Not signed in + Sign in to load each airport\'s forms and generate them. Your people, aircraft and flights stay on this device either way. + Deletes your FlightForms account and its usage records on the server. This cannot be undone.\n\nPeople, aircraft, flights and trips stay on this phone. To remove them too, use Delete all data. + Delete my account + Passphrase + Export ready + Type this on the other device to open the file. It is not stored anywhere, so write it down now. + %1$s is ready. It is not encrypted. + Share file + Passphrase from the other device + Open + Import this file? + Nothing already on this device is removed unless the file says it was deleted. + Import + Imported + Could not do that + Export failed + That password does not match this file. + Could not read that file + Import failed + + + Aircraft + Add aircraft + No aircraft yet + Add the aircraft you fly so forms can carry its registration and type. + New Aircraft + Edit Aircraft + Usual Base + Registration + Type (e.g. SR22) + Airplane + Helicopter + Operator + Company operator + Company name & address + Owner + Unnamed + On file + Email + Phone + Address + Add the owner in People to pick them here. + Base + + + Fill again + Check the details, then submit on the page itself. + Could not fill this page automatically. + Filled %1$d fields. + Filled %1$d fields. Not found: %2$s + Privacy + Privacy policy + Privacy note for passengers + Your passengers\' details come from you, not from them. Share this note so they know how their details are used. + How I use your details for this flight + Delete all data + Removes every person, document, aircraft, flight and trip from this phone, with any generated forms and exports. You stay signed in. + Delete all data? + Deletes all people, documents, aircraft, flights and trips from this phone. This cannot be undone. Your FlightForms account is not affected. + Could not delete the data + Data deleted + All people, documents, aircraft, flights and trips have been removed from this phone. + diff --git a/app/android/app/src/main/res/values/themes.xml b/app/android/app/src/main/res/values/themes.xml index 019a835..c221ef9 100644 --- a/app/android/app/src/main/res/values/themes.xml +++ b/app/android/app/src/main/res/values/themes.xml @@ -1,3 +1,10 @@ - diff --git a/app/android/app/src/main/res/xml/locales_config.xml b/app/android/app/src/main/res/xml/locales_config.xml new file mode 100644 index 0000000..a739601 --- /dev/null +++ b/app/android/app/src/main/res/xml/locales_config.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/app/android/app/src/main/res/xml/shortcuts.xml b/app/android/app/src/main/res/xml/shortcuts.xml new file mode 100644 index 0000000..1df4332 --- /dev/null +++ b/app/android/app/src/main/res/xml/shortcuts.xml @@ -0,0 +1,29 @@ + + + + + + + + + + diff --git a/app/android/core-logic/src/main/kotlin/aero/flyfun/forms/logic/FlightExchange.kt b/app/android/core-logic/src/main/kotlin/aero/flyfun/forms/logic/FlightExchange.kt new file mode 100644 index 0000000..4e17969 --- /dev/null +++ b/app/android/core-logic/src/main/kotlin/aero/flyfun/forms/logic/FlightExchange.kt @@ -0,0 +1,157 @@ +package aero.flyfun.forms.logic + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.time.Instant +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneOffset + +/** + * The cross-app flight format the flyfun services exchange (rzflight + * `designs/flight_exchange_design.md`; `FlightExchange` in euro_aip and + * RZFlight). FlyFun Weather serves it at `GET /api/flights/{id}/export`. + * + * Only what forms uses is kept: the route's endpoints and times, and the + * aircraft. It is PII-free by design, so there are never people in it. + */ +@Serializable +data class FlightExchange( + @SerialName("schema_version") val schemaVersion: Int = CURRENT_SCHEMA_VERSION, + val name: String? = null, + val route: Route, + val aircraft: Aircraft? = null, + val source: Source? = null, +) { + @Serializable + data class Route( + val departure: String = "", + val destination: String = "", + @SerialName("aircraft_type") val aircraftType: String? = null, + @SerialName("departure_time") val departureTime: String? = null, + @SerialName("arrival_time") val arrivalTime: String? = null, + ) + + @Serializable + data class Aircraft(val registration: String? = null, val type: String? = null) + + @Serializable + data class Source( + val app: String? = null, + @SerialName("flight_id") val flightId: String? = null, + ) + + companion object { + const val CURRENT_SCHEMA_VERSION = 1 + + private val json = Json { ignoreUnknownKeys = true } + + /** + * Decode a payload, refusing a newer schema version than this build + * knows: a v2 may change what fields mean, and reading it as v1 would + * put a wrong route on a customs form. + */ + fun decode(text: String): FlightExchange { + val exchange = json.decodeFromString(serializer(), text) + require(exchange.schemaVersion <= CURRENT_SCHEMA_VERSION) { + "Unsupported flight format (version ${exchange.schemaVersion}). Update the app to import it." + } + return exchange + } + } +} + +/** One of the pilot's FlyFun Weather flights, as `GET /api/flights` lists it (a subset of weather's `FlightResponse`). */ +@Serializable +data class WeatherFlightSummary( + val id: String, + @SerialName("route_name") val routeName: String = "", + val waypoints: List = emptyList(), + @SerialName("departure_time") val departureTime: String? = null, +) { + /** "EGTK → LSGS" when the endpoints are known, else the flight's name. */ + val routeLabel: String + get() = if (waypoints.size >= 2) "${waypoints.first()} → ${waypoints.last()}" else routeName + + val departure: Instant? get() = ExchangeTime.parse(departureTime) + + companion object { + private val json = Json { ignoreUnknownKeys = true } + + /** Newest departure first, as iOS lists them; flights without a time last. */ + fun decodeList(text: String): List = + json.decodeFromString>(text) + .sortedWith(compareByDescending(nullsFirst()) { it.departure }) + } +} + +/** ISO 8601 times on the exchange wire. */ +object ExchangeTime { + /** + * An instant from ISO 8601 with an offset or `Z`; a time with no offset is + * read as UTC, which is how the flyfun servers store times (Python's + * `isoformat()` of a naive UTC datetime drops the offset). Null for + * anything unreadable: a missing time is better than a wrong one. + */ + fun parse(text: String?): Instant? { + val value = text?.trim()?.takeIf { it.isNotEmpty() } ?: return null + return runCatching { OffsetDateTime.parse(value).toInstant() }.getOrNull() + ?: runCatching { LocalDateTime.parse(value).toInstant(ZoneOffset.UTC) }.getOrNull() + } +} + +/** + * What an imported route puts on a new flight. Port of the route half of iOS + * `NewFlightFlow.apply(_:)` over plain values. + */ +data class ImportedRoute( + val origin: String, + val destination: String, + val departure: Instant, + val arrival: Instant, + /** Registration and type of the aircraft it names, or null. */ + val registration: String?, + val aircraftType: String?, +) { + companion object { + /** + * Apply [exchange] over the current route values. + * + * An empty endpoint keeps the current one: an empty departure is a gap + * in the source, not an instruction to clear a good value. A departure + * with no arrival moves the arrival onto the departure's UTC day, keeping + * its time, as iOS does; the pilot then corrects the time, not the date. + */ + fun from( + exchange: FlightExchange, + origin: String, + destination: String, + departure: Instant, + arrival: Instant, + ): ImportedRoute { + val route = exchange.route + val newDeparture = ExchangeTime.parse(route.departureTime) + val newArrival = ExchangeTime.parse(route.arrivalTime) + return ImportedRoute( + origin = route.departure.trim().uppercase().ifEmpty { origin }, + destination = route.destination.trim().uppercase().ifEmpty { destination }, + departure = newDeparture ?: departure, + arrival = newArrival ?: newDeparture?.let { alignUtcDay(arrival, it) } ?: arrival, + registration = exchange.aircraft?.registration?.trim()?.ifEmpty { null }, + aircraftType = (exchange.aircraft?.type ?: route.aircraftType)?.trim()?.ifEmpty { null }, + ) + } + + /** [time]'s UTC time of day on [day]'s UTC date. Port of iOS `Flight.alignUTCDay`. */ + fun alignUtcDay(time: Instant, day: Instant): Instant { + val clock = time.atOffset(ZoneOffset.UTC).toLocalTime() + return day.atOffset(ZoneOffset.UTC).toLocalDate().atTime(clock).toInstant(ZoneOffset.UTC) + } + + /** Registrations compared as iOS does: without dashes, any case. `G-ABCD` is `gabcd`. */ + fun sameRegistration(a: String, b: String): Boolean = normalise(a) == normalise(b) + + private fun normalise(registration: String) = registration.replace("-", "").trim().uppercase() + } +} diff --git a/app/android/core-logic/src/test/kotlin/aero/flyfun/forms/logic/FlightExchangeTest.kt b/app/android/core-logic/src/test/kotlin/aero/flyfun/forms/logic/FlightExchangeTest.kt new file mode 100644 index 0000000..1ca1cf7 --- /dev/null +++ b/app/android/core-logic/src/test/kotlin/aero/flyfun/forms/logic/FlightExchangeTest.kt @@ -0,0 +1,153 @@ +package aero.flyfun.forms.logic + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import java.time.Instant + +/** + * The weather import: the exchange payload (rzflight + * `RZFlightFlightExchangeTests`) and how it lands on a new flight (iOS + * `NewFlightFlow.apply`). + */ +class FlightExchangeTest { + + /** What weather's `flight_to_exchange` emits for a flight with an aircraft the viewer owns. */ + private val weatherExport = """ + { + "schema_version": 1, + "route": { + "departure": "EGTK", + "destination": "LSGS", + "alternates": [], + "waypoints": ["EGTK", "LSGS"], + "departure_coords": [51.83, -1.32], + "destination_coords": [46.22, 7.33], + "alternate_coords": {}, + "waypoint_coords": [], + "rejected_waypoints": [], + "aircraft_type": "TBM9", + "departure_time": "2026-10-03T08:30:00+00:00", + "arrival_time": "2026-10-03T10:45:00+00:00", + "flight_level": null, + "cruise_altitude_ft": 28000 + }, + "name": "Oxford to Sion", + "source": {"app": "weather", "flight_id": "f-123", "share_code": "abc"}, + "aircraft": {"registration": "N-123AB", "type": "TBM9"} + } + """.trimIndent() + + private val t = Instant.parse("2026-09-26T09:00:00Z") + private val arrival = Instant.parse("2026-09-26T11:15:00Z") + + @Test + fun `decodes weather's export`() { + val exchange = FlightExchange.decode(weatherExport) + assertEquals("EGTK", exchange.route.departure) + assertEquals("LSGS", exchange.route.destination) + assertEquals("N-123AB", exchange.aircraft?.registration) + assertEquals("f-123", exchange.source?.flightId) + assertEquals(Instant.parse("2026-10-03T08:30:00Z"), ExchangeTime.parse(exchange.route.departureTime)) + } + + @Test + fun `a minimal payload is enough`() { + val exchange = FlightExchange.decode("""{"route": {"departure": "EGTF", "destination": "EGLL"}}""") + assertEquals(1, exchange.schemaVersion) + assertNull(exchange.aircraft) + } + + @Test + fun `refuses a newer schema version`() { + try { + FlightExchange.decode("""{"schema_version": 999, "route": {"departure": "EGTF", "destination": "EGLL"}}""") + fail("A v999 payload must not be read as v1") + } catch (expected: IllegalArgumentException) { + } + } + + @Test + fun `reads times with an offset, with Z, and without an offset as UTC`() { + val expected = Instant.parse("2026-10-03T08:30:00Z") + assertEquals(expected, ExchangeTime.parse("2026-10-03T10:30:00+02:00")) + assertEquals(expected, ExchangeTime.parse("2026-10-03T08:30:00Z")) + assertEquals(expected, ExchangeTime.parse("2026-10-03T08:30:00")) + assertEquals(expected, ExchangeTime.parse("2026-10-03T08:30:00.000000")) + assertNull(ExchangeTime.parse("tomorrow")) + assertNull(ExchangeTime.parse("")) + assertNull(ExchangeTime.parse(null)) + } + + @Test + fun `applies route, times and aircraft`() { + val route = ImportedRoute.from(FlightExchange.decode(weatherExport), "", "", t, arrival) + assertEquals("EGTK", route.origin) + assertEquals("LSGS", route.destination) + assertEquals(Instant.parse("2026-10-03T08:30:00Z"), route.departure) + assertEquals(Instant.parse("2026-10-03T10:45:00Z"), route.arrival) + assertEquals("N-123AB", route.registration) + assertEquals("TBM9", route.aircraftType) + } + + @Test + fun `an empty endpoint keeps the current one`() { + val exchange = FlightExchange.decode("""{"route": {"departure": "", "destination": "lfmn"}}""") + val route = ImportedRoute.from(exchange, "EGTF", "EGLL", t, arrival) + assertEquals("EGTF", route.origin) + assertEquals("LFMN", route.destination) + } + + @Test + fun `no times keeps the schedule`() { + val exchange = FlightExchange.decode("""{"route": {"departure": "EGTF", "destination": "LFMN"}}""") + val route = ImportedRoute.from(exchange, "", "", t, arrival) + assertEquals(t, route.departure) + assertEquals(arrival, route.arrival) + assertNull(route.registration) + } + + @Test + fun `a departure without an arrival moves the arrival onto its day`() { + val exchange = FlightExchange.decode( + """{"route": {"departure": "EGTF", "destination": "LFMN", "departure_time": "2026-10-03T08:30:00Z"}}""", + ) + val route = ImportedRoute.from(exchange, "", "", t, arrival) + assertEquals(Instant.parse("2026-10-03T08:30:00Z"), route.departure) + assertEquals(Instant.parse("2026-10-03T11:15:00Z"), route.arrival) + } + + @Test + fun `the type falls back to the route's`() { + val exchange = FlightExchange.decode( + """{"route": {"departure": "EGTF", "destination": "LFMN", "aircraft_type": "SR22"}, "aircraft": {"registration": "G-ABCD"}}""", + ) + assertEquals("SR22", ImportedRoute.from(exchange, "", "", t, arrival).aircraftType) + } + + @Test + fun `registrations match without dashes or case`() { + assertTrue(ImportedRoute.sameRegistration("G-ABCD", "gabcd")) + assertTrue(ImportedRoute.sameRegistration("N123AB", "N-123AB")) + assertFalse(ImportedRoute.sameRegistration("G-ABCD", "G-ABCE")) + } + + @Test + fun `lists weather flights newest first, labelled by their endpoints`() { + val flights = WeatherFlightSummary.decodeList( + """ + [ + {"id": "a", "route_name": "Old", "waypoints": ["EGTF", "LFAT"], "departure_time": "2026-05-01T09:00:00Z", "extra": 1}, + {"id": "b", "route_name": "Alps", "waypoints": ["EGTK", "LSZH", "LSGS"], "departure_time": "2026-10-03T08:30:00Z"}, + {"id": "c", "route_name": "Draft", "waypoints": ["EGTK"]} + ] + """.trimIndent(), + ) + assertEquals(listOf("b", "a", "c"), flights.map { it.id }) + assertEquals("EGTK → LSGS", flights[0].routeLabel) + assertEquals("Draft", flights[2].routeLabel) + } +} diff --git a/app/android/gradle/libs.versions.toml b/app/android/gradle/libs.versions.toml index 593c45a..6d54023 100644 --- a/app/android/gradle/libs.versions.toml +++ b/app/android/gradle/libs.versions.toml @@ -16,10 +16,10 @@ androidxTestJunit = "1.3.0" androidxTestRunner = "1.7.0" coroutines = "1.10.2" browser = "1.10.0" -securityCrypto = "1.1.0" okhttp = "5.5.0" camerax = "1.6.2" mlkitText = "16.0.1" +adaptive = "1.2.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -31,6 +31,9 @@ androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-compose-material3-navigation-suite = { group = "androidx.compose.material3", name = "material3-adaptive-navigation-suite" } +androidx-compose-adaptive = { group = "androidx.compose.material3.adaptive", name = "adaptive", version.ref = "adaptive" } +androidx-compose-adaptive-layout = { group = "androidx.compose.material3.adaptive", name = "adaptive-layout", version.ref = "adaptive" } androidx-compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" } androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } @@ -39,7 +42,6 @@ androidx-room-compiler = { group = "androidx.room", name = "room-compiler", vers retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" } -androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" } androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" } androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" } androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" } diff --git a/designs/future/android-parity.md b/designs/future/android-parity.md index dae1b84..3e7bf36 100644 --- a/designs/future/android-parity.md +++ b/designs/future/android-parity.md @@ -23,8 +23,8 @@ 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) | Merged (#33); unit + instrumented tests pass, manual emulator drive still owed (§8) | -| 2 | #30 | Getting data in fast | §5 (2a–2e) | Code done; `:core-logic` tests pass, app compiled against stubs, emulator drive owed (§8) | -| 3 | #31 | Platform and integrations | §6 (3a–3d) | Not started — after PR 2 | +| 2 | #30 | Getting data in fast | §5 (2a–2e) | Merged | +| 3 | #31 | Platform and integrations | §6 (3a–3d) | Code done except Autorouter (skipped, §7); built in a cloud session, compile and emulator drive owed (§8) | | — | — | Blocked follow-ups | §7 | Blocked (G4, translator, device) | Already done before this tracker: person details (DOB, place of birth, sex, @@ -171,29 +171,30 @@ the other FlyFun services. ### 3a — Look and layout (S–M, Sonnet) -- [ ] Dark theme + dynamic colour; a real launcher icon (currently `sym_def_app_icon`) -- [ ] Adaptive layout: `NavigationSuiteScaffold` + `ListDetailPaneScaffold` +- [x] Dark theme + dynamic colour; a real launcher icon (currently `sym_def_app_icon`) +- [x] Adaptive layout: `NavigationSuiteScaffold` + `ListDetailPaneScaffold` ### 3b — Auth and platform (S–M, Sonnet) -- [ ] Apple sign-in through the web flow (`provider="apple"` in `AuthService.startSignIn`) if the server path supports it -- [ ] `TokenStore` off deprecated `EncryptedSharedPreferences` to Keystore-wrapped DataStore -- [ ] Static app shortcuts (New flight, Scan passport) +- [x] Apple sign-in through the web flow (`provider="apple"` in `AuthService.startSignIn`) if the server path supports it +- [x] `TokenStore` off deprecated `EncryptedSharedPreferences` to Keystore-wrapped DataStore +- [x] Static app shortcuts (New flight, Scan passport) ### 3c — Server-backed imports (M, Opus) -- [ ] FlyFun Weather import (`WeatherImportService.swift`) -- [ ] Autorouter import (`AutorouterImportService.swift`) +- [x] FlyFun Weather import (`WeatherImportService.swift`) +- [ ] ~~Autorouter import (`AutorouterImportService.swift`)~~ — skipped, moved to §7 ### 3d — Localisation infrastructure (M, Sonnet) -- [ ] Extract every literal to `strings.xml`; reuse the existing translated `Localizable.xcstrings` entries -- [ ] `locales_config.xml` for the system per-app language picker +- [x] Extract every literal to `strings.xml`; reuse the existing translated `Localizable.xcstrings` entries +- [x] `locales_config.xml` for the system per-app language picker --- ## 7. Blocked follow-ups (outside the three PRs) +- [ ] **Autorouter import** — the server lists routes (`GET /api/autorouter/routes`), but the route and times are in each row's raw ICAO `fplan`, which iOS parses on the device. Android has no parser and must not grow a third one (android-app.md §4), so this waits on G4 with the paste below. Skipped from PR 3 by decision (§8) - [ ] **ICAO flight-plan paste + share target** — blocked on gate G4 (`POST /flightplan/parse` on `main`; see [android-app.md §4](./android-app.md)) - [ ] **Remaining translations** — ~33 strings need real aviation fr/de/es; blocked on a translator (execution plan §2b) - [ ] **Real-passport scan test** — needs a physical device; does not block merging PR 2 once photo scan works on the emulator @@ -247,3 +248,19 @@ Newest last. One line per decision: date, section, what was decided, why. - 2026-09-26 — 2d — + opens the two-step flow on the same draft; Create Flight stores it and the editor takes over in place. Leg actions still open the editor directly. Only "Previous Flight" is offered as an import: FPL paste is blocked on G4, Weather and Autorouter are PR 3. Repeating a flight also carries reason for visit and the document choices, which iOS's draft leaves out. - 2026-09-26 — 2e — The contact is read through the picked contact's entity directory, with no READ_CONTACTS; the display name is read from the contact itself and is enough on its own. Merging is iOS's Fill Missing Only / Override All, in `ContactImport` with tests. - 2026-09-26 — PR 2 — Built in a cloud session with no route to Google Maven, as 1a was: `:core-logic` tests run on the JVM, and `app/src/main` was type-checked against Compose Multiplatform desktop plus stubs for the Android APIs. Room's generated code, the migration and every screen still need the emulator run (execution plan §1) before merge. +- 2026-09-26 — PR 3 — Re-sync with iOS (`83e47fd..7595b4f`): only `8b2f018` (Flights first in the tab bar), which Android already does. No new gaps. +- 2026-09-26 — 3a — The launcher icon is the iOS artwork, whole, as the adaptive icon's background layer, with a transparent foreground and a monochrome layer cut from its white parts for themed icons. `scripts/android_launcher_icon.py` writes it; re-run it when the iOS icon changes. Splitting the artwork into layers would have needed a redraw, and parallax would pull its parts apart. +- 2026-09-26 — 3a — Dynamic colour only, no fallback palette: it needs API 31 and the app's minimum is 33, and the app has no brand colour beyond its icon. +- 2026-09-26 — 3a — `ListDetailPaneScaffold` lays out the navigation back stack rather than driving its own navigator: a tab's list route shows the list (and a placeholder beside it when there is room), an item's route shows the item (and the list beside it). On a phone that is one screen per route, as before; ViewModels stay scoped to routes, and Back, pickers and the unsaved-changes prompt work unchanged. The list shows beside an item only when the item was opened from that list: a person opened from a flight's picker shows alone. Picking another flight from the side list while the open one has edits asks Save / Discard, as Back does; the person and aircraft editors do not ask on Back either, so neither does switching. +- 2026-09-26 — 3a — `NavigationSuiteScaffold` gives a bar on a phone (on the lists only, as before) and a rail on a tablet, where it stays beside open items since the list does. Route changes do not fade when two panes show, so the list does not flash. +- 2026-09-26 — 3b — Apple sign-in is the web flow: `/auth/login/apple` with the same `platform`, `scheme` and `state` as Google; the server's callback takes Apple's `form_post` and redirects with an auth code, so the app needs nothing Apple-specific. Offered on the sign-in screen and in Settings, under Google. +- 2026-09-26 — 3b — `TokenStore` keeps the JWT AES-GCM-encrypted under its own Android Keystore key, the ciphertext in plain app-private preferences, rather than moving to DataStore: one value needs a key, not a store, and it drops the deprecated `androidx.security:security-crypto` without adding a dependency. The old encrypted file is deleted, not migrated: the app was never released, so the cost is signing in once more. A token that no longer decrypts reads as signed out. +- 2026-09-26 — 3b — Found on the way: Android ignored `X-Renewed-Token`, so a session ended at the JWT's expiry however often the app was used. The interceptor now takes the renewed token when the one it sent is still current, as iOS's `RollingBearerSession` does. +- 2026-09-26 — 3b — Static shortcuts New flight and Scan passport are explicit intents with the app's own actions; MainActivity hands them to the UI, which opens `flight/new` or the standalone scan on top of whatever is open (so an unsaved flight underneath survives), after sign-in if the sign-in screen is showing. +- 2026-09-26 — 3c — Autorouter import is skipped (decided with the owner): its rows carry the plan as raw ICAO text, and the only route to it without a third parser is the summary fields, which lose the arrival time. It waits on G4 (§7). +- 2026-09-26 — 3c — FlyFun Weather import reads `weather.flyfun.aero` with the forms account's token, as iOS does: the flyfun services share accounts and JWTs. The weather client keeps renewed tokens but a 401 from it does not sign the pilot out of forms; it says to sign in to the same account instead. The `FlightExchange` format, its lenient times (no offset is UTC) and the mapping onto the draft are in `:core-logic` with tests; a newer `schema_version` is refused. +- 2026-09-26 — 3c — A weather import applies as iOS's `apply(_:)`: route and times (an arrival-less departure moves the arrival onto its day), the aircraft matched by registration without dashes, or staged as a new aircraft that is stored only with the flight. People an earlier import brought are cleared, a hand-picked crew stays; the settings with no editor in the flow go back to a new flight's. The picker's calls run in its own scope, so leaving it mid-import cancels the import. The import list shows Weather greyed with "Sign in to import" when signed out. +- 2026-09-26 — 3d — Strings live in `res/values/strings*.xml`, one file per area (`strings.xml` for the app shell, `strings_flights.xml`, `strings_people.xml`, `strings_settings.xml`), names prefixed by area. ViewModels get the application's `Resources` for the messages they hold in state; composables use `stringResource`. English wording was kept as it was, which is mostly iOS's. +- 2026-09-26 — 3d — `scripts/android_strings.py` writes `values-fr/de/es` from iOS `Localizable.xcstrings`: a string whose English equals an iOS key (placeholders compared by position) takes its translations, `needs_review` ones included, as iOS ships them. The rest fall back to English. Generated, never edited by hand; re-run after adding strings. The remaining translations stay blocked on a translator (§7). +- 2026-09-26 — 3d — `locales_config.xml` offers en/fr/de/es in the system's per-app language setting, as iOS offers the same four, even though fr/de/es are partial: Android falls back to English per string. +- 2026-09-26 — 3d — Left in English: text built in `:core-logic`, which has no Android resources (`MergeSummary.describe`, `PeopleSuggestion.summary`, exception messages from the MRZ, CSV and data-file code), and the few messages thrown from classes with no `Context`. Moving them means returning structured values from `:core-logic` for the UI to word; not worth it before a translator is lined up. diff --git a/scripts/android_launcher_icon.py b/scripts/android_launcher_icon.py new file mode 100644 index 0000000..9bc1fe1 --- /dev/null +++ b/scripts/android_launcher_icon.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Regenerate the Android launcher icon from the iOS app icon. + +The iOS icon (``AppIcon.appiconset/FlightFormsLight-1024.png``) is the one +piece of artwork the app has. Android wants an adaptive icon instead: layers on +a 108 dp canvas of which only the middle 72 dp shows, cut to whatever shape the +launcher uses. This writes, for each density: + +- ``ic_launcher_background.png``: the iOS artwork scaled to the visible 72 dp, + its edges stretched out to fill the 18 dp margin the launcher uses for + parallax and shape cropping. The artwork already has its own background, so + it is the background layer. +- no foreground: ``mipmap-anydpi/ic_launcher.xml`` uses a transparent colour. + The artwork is one picture; split into layers it would drift apart as the + launcher animates them. +- ``ic_launcher_monochrome.png``: the white parts of the artwork (the form, the + ring, the aeroplane and the tick) as an alpha mask, for Android 13 themed + icons, which tint it with the wallpaper's colours. + +Run it when the iOS icon changes, and commit the result: + + pip install pillow numpy + python scripts/android_launcher_icon.py +""" + +from pathlib import Path + +import numpy as np +from PIL import Image + +ROOT = Path(__file__).resolve().parent.parent +SOURCE = ( + ROOT / "app" / "flyfun-forms" / "flyfun-forms" / "Assets.xcassets" + / "AppIcon.appiconset" / "FlightFormsLight-1024.png" +) +RES = ROOT / "app" / "android" / "app" / "src" / "main" / "res" + +# Adaptive icon canvas in px at each density: 108 dp. +DENSITIES = {"mdpi": 108, "hdpi": 162, "xhdpi": 216, "xxhdpi": 324, "xxxhdpi": 432} + +# The visible part of the canvas: 72 of 108 dp. +VISIBLE = 72 / 108 + + +def full_canvas(art: np.ndarray) -> np.ndarray: + """The artwork centred on the 108 dp canvas, its edge pixels stretched outwards.""" + size = art.shape[0] + margin = round(size * (1 / VISIBLE - 1) / 2) + pad = ((margin, margin), (margin, margin), (0, 0)) + return np.pad(art, pad, mode="edge") + + +def white_mask(rgb: np.ndarray) -> np.ndarray: + """Alpha for the white parts of the artwork, soft at the edges.""" + rgb = rgb.astype(np.float32) + lightest = rgb.max(axis=2) + darkest = rgb.min(axis=2) + # White is bright and grey; the blue sky and the red badge are bright but + # coloured, the form's text lines are grey but dark. + whiteness = np.clip((darkest - 175) / 30, 0, 1) * np.clip(1 - (lightest - darkest - 18) / 17, 0, 1) + return (whiteness * 255).astype(np.uint8) + + +def main() -> None: + art = np.asarray(Image.open(SOURCE).convert("RGB")) + canvas = full_canvas(art) + mask = white_mask(canvas) + background = Image.fromarray(canvas, "RGB") + monochrome = Image.fromarray( + np.dstack([np.full_like(mask, 255)] * 3 + [mask]), "RGBA" + ) + + for density, px in DENSITIES.items(): + out = RES / f"mipmap-{density}" + out.mkdir(parents=True, exist_ok=True) + background.resize((px, px), Image.LANCZOS).save(out / "ic_launcher_background.png", optimize=True) + monochrome.resize((px, px), Image.LANCZOS).save(out / "ic_launcher_monochrome.png", optimize=True) + print(f"wrote {out.relative_to(ROOT)}") + + +if __name__ == "__main__": + main() diff --git a/scripts/android_strings.py b/scripts/android_strings.py new file mode 100644 index 0000000..67aa141 --- /dev/null +++ b/scripts/android_strings.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""Write the Android app's translations from the iOS app's string catalogue. + +The iOS app is translated (``Localizable.xcstrings``, fr/de/es); Android's +strings are English in ``res/values/strings*.xml``. A string whose English is +the same as an iOS key, placeholders aside, takes that key's translations; +failing that, one that differs only in case or a trailing full stop or +ellipsis ("First name" and iOS's "First Name"). The +rest stay English: Android falls back to ``values/`` for anything a language +folder lacks, so a partly translated language shows English for the gaps +rather than failing. + +Writes ``res/values-{fr,de,es}/strings.xml``; never edit those by hand, they are +overwritten. Run it after adding or rewording strings, and commit the result: + + python scripts/android_strings.py + +Placeholders are compared by position: iOS ``%@``/``%lld`` and Android +``%1$s``/``%1$d`` are the same slot. A translation that reorders them +(``%2$@ … %1$@``) keeps its order, rewritten to Android's types. +""" + +import json +import re +import sys +import xml.etree.ElementTree as ET +from pathlib import Path +from xml.sax.saxutils import escape + +ROOT = Path(__file__).resolve().parent.parent +CATALOGUE = ROOT / "app" / "flyfun-forms" / "flyfun-forms" / "Localizable.xcstrings" +RES = ROOT / "app" / "android" / "app" / "src" / "main" / "res" +LANGUAGES = ["fr", "de", "es"] + +# %@, %lld, %d, %1$@, %2$s ... (not %%). +PLACEHOLDER = re.compile(r"%(?:(\d+)\$)?(@|lld|ld|lu|d|s|f|\.\d+f)") + + +def android_text(raw: str) -> str: + """The text of a string resource as the app shows it (Android escapes undone).""" + text = raw.strip() + if len(text) >= 2 and text[0] == '"' and text[-1] == '"': + text = text[1:-1] + return re.sub(r"\\(['\"@?n])", lambda m: "\n" if m.group(1) == "n" else m.group(1), text) + + +def android_escape(text: str) -> str: + """Text for a string resource: XML-escaped, with Android's own escapes.""" + text = escape(text).replace("\\", "\\\\").replace("'", "\\'").replace('"', '\\"').replace("\n", "\\n") + if text.startswith("@") or text.startswith("?"): + text = "\\" + text + return text + + +def shape(text: str) -> str: + """The text with every placeholder replaced by one marker, for matching.""" + return PLACEHOLDER.sub("\u0000", text) + + +def loose(text: str) -> str: + """[shape] ignoring case and a trailing full stop or ellipsis: "First name" is iOS's "First Name".""" + return re.sub(r"(\.\.\.|…|\.)$", "", shape(text).strip()).casefold() + + +def android_types(text: str) -> list[str]: + """Android conversion of each placeholder, in order: s or d.""" + return ["d" if kind in ("d", "lld", "ld", "lu") else "s" for _, kind in PLACEHOLDER.findall(text)] + + +def to_android(translation: str, types: list[str]) -> str | None: + """An iOS translation with its placeholders rewritten as Android positional ones.""" + count = 0 + + def replace(match: re.Match) -> str: + nonlocal count + count += 1 + position = int(match.group(1)) if match.group(1) else count + if position > len(types): + raise ValueError + return f"%{position}${types[position - 1]}" + + try: + # Escape bare % before rewriting, then restore our placeholders. + converted = PLACEHOLDER.sub(replace, translation) + except ValueError: + return None + return converted if count == len(types) else None + + +def unit(entry: dict) -> str | None: + return entry.get("stringUnit", {}).get("value") + + +def load_android() -> tuple[dict[str, str], dict[str, dict[str, str]]]: + """English strings and plurals by name, from every values/strings*.xml.""" + strings: dict[str, str] = {} + plurals: dict[str, dict[str, str]] = {} + for path in sorted((RES / "values").glob("strings*.xml")): + for element in ET.parse(path).getroot(): + name = element.get("name") + if element.get("translatable") == "false": + continue + if element.tag == "string": + strings[name] = android_text("".join(element.itertext())) + elif element.tag == "plurals": + plurals[name] = {item.get("quantity"): android_text("".join(item.itertext())) for item in element} + return strings, plurals + + +def main() -> None: + catalogue = json.loads(CATALOGUE.read_text(encoding="utf-8"))["strings"] + by_shape: dict[str, dict] = {} + by_loose: dict[str, dict] = {} + for key, entry in catalogue.items(): + if entry.get("localizations"): + by_shape.setdefault(shape(key), entry) + by_loose.setdefault(loose(key), entry) + + def lookup(english: str) -> dict | None: + return by_shape.get(shape(english)) or by_loose.get(loose(english)) + + strings, plurals = load_android() + for language in LANGUAGES: + lines = [ + '', + "", + "", + ] + found = 0 + for name, english in strings.items(): + entry = lookup(english) + local = entry and entry["localizations"].get(language) + value = local and unit(local) + if value is None and local and "variations" in local: + # A plural key used for a plain string: take its "other". + value = unit(local["variations"].get("plural", {}).get("other", {})) + converted = value and to_android(value, android_types(english)) + if converted: + lines.append(f' {android_escape(converted)}') + found += 1 + for name, forms in plurals.items(): + english = forms.get("other", "") + entry = lookup(english) + local = entry and entry["localizations"].get(language) + if not local: + continue + variations = local.get("variations", {}).get("plural", {}) + items = {q: unit(v) for q, v in variations.items()} if variations else {"other": unit(local)} + types = android_types(english) + converted = {q: to_android(v, types) for q, v in items.items() if v} + if converted.get("other"): + lines.append(f' ') + for quantity, text in converted.items(): + if text: + lines.append(f' {android_escape(text)}') + lines.append(" ") + found += 1 + lines.append("") + out = RES / f"values-{language}" + out.mkdir(parents=True, exist_ok=True) + (out / "strings.xml").write_text("\n".join(lines) + "\n", encoding="utf-8") + total = len(strings) + len(plurals) + print(f"{language}: {found} of {total} translated from iOS", file=sys.stderr) + + +if __name__ == "__main__": + main()