commit f434af89387a7844b12b2b9168d054c87f94815f Author: handfly Date: Sat May 2 21:38:19 2026 -0400 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..7643783 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,123 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..ca16a99 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,11 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..02c4aa5 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,18 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..7061a0d --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,61 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..b2c751a --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..c81d807 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,58 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.harshmallow.pilottoolkit" + compileSdk { + version = release(36) { + minorApiLevel = 1 + } + } + + defaultConfig { + applicationId = "com.harshmallow.pilottoolkit" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + buildFeatures { + compose = true + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.test.manifest) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/release/baselineProfiles/0/app-release.dm b/app/release/baselineProfiles/0/app-release.dm new file mode 100644 index 0000000..0dd34d7 Binary files /dev/null and b/app/release/baselineProfiles/0/app-release.dm differ diff --git a/app/release/baselineProfiles/1/app-release.dm b/app/release/baselineProfiles/1/app-release.dm new file mode 100644 index 0000000..bb13c05 Binary files /dev/null and b/app/release/baselineProfiles/1/app-release.dm differ diff --git a/app/release/output-metadata.json b/app/release/output-metadata.json new file mode 100644 index 0000000..06ce2aa --- /dev/null +++ b/app/release/output-metadata.json @@ -0,0 +1,37 @@ +{ + "version": 3, + "artifactType": { + "type": "APK", + "kind": "Directory" + }, + "applicationId": "com.harshmallow.pilottoolkit", + "variantName": "release", + "elements": [ + { + "type": "SINGLE", + "filters": [], + "attributes": [], + "versionCode": 1, + "versionName": "1.0", + "outputFile": "app-release.apk" + } + ], + "elementType": "File", + "baselineProfiles": [ + { + "minApi": 28, + "maxApi": 30, + "baselineProfiles": [ + "baselineProfiles/1/app-release.dm" + ] + }, + { + "minApi": 31, + "maxApi": 2147483647, + "baselineProfiles": [ + "baselineProfiles/0/app-release.dm" + ] + } + ], + "minSdkVersionForDexing": 26 +} \ No newline at end of file diff --git a/app/release/ptk.apk b/app/release/ptk.apk new file mode 100644 index 0000000..57fdea2 Binary files /dev/null and b/app/release/ptk.apk differ diff --git a/app/src/androidTest/java/com/harshmallow/pilottoolkit/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/harshmallow/pilottoolkit/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..ed5d03a --- /dev/null +++ b/app/src/androidTest/java/com/harshmallow/pilottoolkit/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.harshmallow.pilottoolkit + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.harshmallow.pilottoolkit", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..ab8e9ac --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/assets/aircraft/G450.json b/app/src/main/assets/aircraft/G450.json new file mode 100644 index 0000000..072a47b --- /dev/null +++ b/app/src/main/assets/aircraft/G450.json @@ -0,0 +1,498 @@ +{ + "_meta": { + "format": "pilot-toolkit-aircraft", + "version": 3, + "aircraftId": "G450", + "generated": "2026-02-28" + }, + "label": "G450", + "available": true, + "weightLimits": { + "minBew": 42000, + "mtow": 74600, + "maxRamp": 75000 + }, + "eswl": { + "type": "formula", + "weightFactor": 0.45, + "wheelFactor": 1.23, + "formula": "weight × weightFactor ÷ wheelFactor" + }, + "pcn": { + "rigid": { + "A": { + "slope": 0.0003669, + "intercept": -3.0215 + }, + "B": { + "slope": 0.0003742, + "intercept": -2.777 + }, + "C": { + "slope": 0.000378, + "intercept": -2.58 + }, + "D": { + "slope": 0.0003813, + "intercept": -2.3455 + } + }, + "flexible": { + "A": { + "slope": 0.0003187, + "intercept": -2.9945 + }, + "B": { + "slope": 0.0003296, + "intercept": -3.036 + }, + "C": { + "slope": 0.0003467, + "intercept": -3.0745 + }, + "D": { + "slope": 0.0003416, + "intercept": -1.796 + } + } + }, + "pcr": { + "rigid": { + "A": { + "slope": 0.003596, + "intercept": -32.304 + }, + "B": { + "slope": 0.003658, + "intercept": -29.142 + }, + "C": { + "slope": 0.003692, + "intercept": -26.508 + }, + "D": { + "slope": 0.003731, + "intercept": -25.019 + } + }, + "flexible": { + "A": { + "slope": 0.002592, + "intercept": -21.282 + }, + "B": { + "slope": 0.003723, + "intercept": -65.027 + }, + "C": { + "slope": 0.004177, + "intercept": -68.073 + }, + "D": { + "slope": 0.0041, + "intercept": -41 + } + } + }, + "pcnError": "±3%", + "pcrError": "not calculated", + "windLimitations": null, + "fuelBuckets": [ + [ + 0.1, + 650 + ], + [ + 0.2, + 650 + ], + [ + 0.3, + 650 + ], + [ + 0.4, + 650 + ], + [ + 0.5, + 650 + ], + [ + 0.6, + 650 + ], + [ + 0.7, + 650 + ], + [ + 0.8, + 650 + ], + [ + 0.9, + 650 + ], + [ + 1, + 548.5 + ], + [ + 1.1, + 534 + ], + [ + 1.2, + 523 + ], + [ + 1.3, + 513.5 + ], + [ + 1.4, + 504.5 + ], + [ + 1.5, + 496.5 + ], + [ + 1.6, + 489.5 + ], + [ + 1.7, + 483.5 + ], + [ + 1.8, + 478 + ], + [ + 1.9, + 474 + ], + [ + 2, + 470.5 + ], + [ + 2.1, + 467.5 + ], + [ + 2.2, + 464.5 + ], + [ + 2.3, + 462 + ], + [ + 2.4, + 459.5 + ], + [ + 2.5, + 457 + ], + [ + 2.6, + 455 + ], + [ + 2.7, + 453 + ], + [ + 2.8, + 451.5 + ], + [ + 2.9, + 450 + ], + [ + 3, + 449 + ], + [ + 3.1, + 448.3 + ], + [ + 3.2, + 448.5 + ], + [ + 3.3, + 448.7 + ], + [ + 3.4, + 448.9 + ], + [ + 3.5, + 449.1 + ], + [ + 3.6, + 449.3 + ], + [ + 3.7, + 449.5 + ], + [ + 3.8, + 449.7 + ], + [ + 3.9, + 449.9 + ], + [ + 4, + 450.1 + ], + [ + 4.1, + 450.3 + ], + [ + 4.2, + 450.5 + ], + [ + 4.3, + 450.7 + ], + [ + 4.4, + 450.9 + ], + [ + 4.5, + 451.1 + ], + [ + 4.6, + 451.3 + ], + [ + 4.7, + 451.5 + ], + [ + 4.8, + 451.7 + ], + [ + 4.9, + 451.9 + ], + [ + 5, + 452.1 + ], + [ + 5.1, + 452.3 + ], + [ + 5.2, + 452.5 + ], + [ + 5.3, + 452.7 + ], + [ + 5.4, + 452.9 + ], + [ + 5.5, + 453.1 + ], + [ + 5.6, + 453.3 + ], + [ + 5.7, + 453.5 + ], + [ + 5.8, + 453.7 + ], + [ + 5.9, + 453.9 + ], + [ + 6, + 454.2 + ], + [ + 6.1, + 454.5 + ], + [ + 6.2, + 454.8 + ], + [ + 6.3, + 455.1 + ], + [ + 6.4, + 455.4 + ], + [ + 6.5, + 455.7 + ], + [ + 6.6, + 456 + ], + [ + 6.7, + 456.3 + ], + [ + 6.8, + 456.6 + ], + [ + 6.9, + 456.6 + ], + [ + 7, + 456.6 + ], + [ + 7.1, + 456.6 + ], + [ + 7.2, + 456.6 + ], + [ + 7.3, + 456.6 + ], + [ + 7.4, + 456.6 + ], + [ + 7.5, + 456.6 + ], + [ + 7.6, + 456.6 + ], + [ + 7.7, + 456.6 + ], + [ + 7.8, + 456.6 + ], + [ + 7.9, + 456.6 + ], + [ + 8, + 456.6 + ], + [ + 8.1, + 456.6 + ], + [ + 8.2, + 456.6 + ], + [ + 8.3, + 456.6 + ], + [ + 8.4, + 456.6 + ], + [ + 8.5, + 456.6 + ], + [ + 8.6, + 456.6 + ], + [ + 8.7, + 456.6 + ], + [ + 8.8, + 456.6 + ], + [ + 8.9, + 456.6 + ], + [ + 9, + 456.6 + ], + [ + 9.1, + 456.6 + ], + [ + 9.2, + 456.6 + ], + [ + 9.3, + 456.6 + ], + [ + 9.4, + 456.6 + ], + [ + 9.5, + 456.6 + ], + [ + 9.6, + 456.6 + ], + [ + 9.7, + 456.6 + ], + [ + 9.8, + 456.6 + ], + [ + 9.9, + 456.6 + ] + ] +} \ No newline at end of file diff --git a/app/src/main/assets/aircraft/G500.json b/app/src/main/assets/aircraft/G500.json new file mode 100644 index 0000000..798fa12 --- /dev/null +++ b/app/src/main/assets/aircraft/G500.json @@ -0,0 +1,18 @@ +{ + "_meta": { + "format": "pilot-toolkit-aircraft", + "version": 3, + "aircraftId": "G500", + "generated": "2026-02-28" + }, + "label": "G500", + "available": false, + "weightLimits": null, + "eswl": null, + "pcn": null, + "pcr": null, + "pcnError": null, + "pcrError": null, + "windLimitations": null, + "fuelBuckets": null +} \ No newline at end of file diff --git a/app/src/main/assets/aircraft/G650.json b/app/src/main/assets/aircraft/G650.json new file mode 100644 index 0000000..9752e73 --- /dev/null +++ b/app/src/main/assets/aircraft/G650.json @@ -0,0 +1,27 @@ +{ + "_meta": { + "format": "pilot-toolkit-aircraft", + "version": 3, + "aircraftId": "G650", + "generated": "2026-02-28" + }, + "label": "G650", + "available": true, + "weightLimits": { + "minBew": 52600, + "mtow": 99600, + "maxRamp": 100000 + }, + "eswl": { + "type": "formula", + "weightFactor": 0.45, + "wheelFactor": 1.28, + "formula": "weight × weightFactor ÷ wheelFactor" + }, + "pcn": null, + "pcr": null, + "pcnError": null, + "pcrError": null, + "windLimitations": null, + "fuelBuckets": null +} \ No newline at end of file diff --git a/app/src/main/assets/aircraft/G700.json b/app/src/main/assets/aircraft/G700.json new file mode 100644 index 0000000..33afe59 --- /dev/null +++ b/app/src/main/assets/aircraft/G700.json @@ -0,0 +1,109 @@ +{ + "_meta": { + "format": "pilot-toolkit-aircraft", + "version": 3, + "aircraftId": "G700", + "generated": "2026-02-28" + }, + "label": "G700", + "available": true, + "weightLimits": { + "minBew": 56000, + "mtow": 107600, + "maxRamp": 108000 + }, + "eswl": { + "type": "thresholds", + "singleWheel": { + "label": "S-85", + "value": 85000, + "unit": "lbs" + }, + "dualWheel": { + "label": "D-108", + "value": 108000, + "unit": "lbs" + }, + "note": "Unrestricted operations at or above these values." + }, + "pcn": { + "rigid": { + "A": { + "slope": 0.0003323, + "intercept": -2.4247 + }, + "B": { + "slope": 0.0003268, + "intercept": -1.2011 + }, + "C": { + "slope": 0.0003521, + "intercept": -2.1385 + }, + "D": { + "slope": 0.0003547, + "intercept": -1.5244 + } + }, + "flexible": { + "A": { + "slope": 0.000286, + "intercept": -2.4517 + }, + "B": { + "slope": 0.0003167, + "intercept": -3.794 + }, + "C": { + "slope": 0.0003145, + "intercept": -1.7641 + }, + "D": { + "slope": 0.0003221, + "intercept": -0.2135 + } + } + }, + "pcr": { + "rigid": { + "A": { + "slope": 0.0033054, + "intercept": -25.3477 + }, + "B": { + "slope": 0.0033973, + "intercept": -24.1697 + }, + "C": { + "slope": 0.0034952, + "intercept": -24.4997 + }, + "D": { + "slope": 0.0035379, + "intercept": -21.5928 + } + }, + "flexible": { + "A": { + "slope": 0.0022591, + "intercept": -3.1378 + }, + "B": { + "slope": 0.0027682, + "intercept": -29.7287 + }, + "C": { + "slope": 0.0034418, + "intercept": -62.6803 + }, + "D": { + "slope": 0.0038994, + "intercept": -66.621 + } + } + }, + "pcnError": "±2%", + "pcrError": "±2%", + "windLimitations": null, + "fuelBuckets": null +} \ No newline at end of file diff --git a/app/src/main/assets/reference.json b/app/src/main/assets/reference.json new file mode 100644 index 0000000..a64fd4f --- /dev/null +++ b/app/src/main/assets/reference.json @@ -0,0 +1,680 @@ +{ + "_meta": { + "format": "pilot-toolkit-data", + "version": 3, + "generated": "2026-02-28", + "notes": "Reference data is airframe-agnostic. Not included in per-aircraft export/import." + }, + "pavementSubgrades": { + "A": "A – High", + "B": "B – Medium", + "C": "C – Low", + "D": "D – Ultra Low" + }, + "metersToFeet": { + "source": "GVIII-G700 Operating Manual Table 15, 06-10-00, 2024-03-29", + "caution": "Do not use for approach minima. Values rounded to nearest 100 ft.", + "note": "Because of rounding differences, most metric flight levels can be satisfied by two equivalent feet values. Of the two, the closest value in feet is used in this table.", + "entries": [ + [ + 15100, + 49500 + ], + [ + 14100, + 46300 + ], + [ + 13100, + 43000 + ], + [ + 12800, + 42000 + ], + [ + 12500, + 41000 + ], + [ + 12200, + 40000 + ], + [ + 12100, + 39700 + ], + [ + 11900, + 39000 + ], + [ + 11600, + 38100 + ], + [ + 11300, + 37100 + ], + [ + 11100, + 36400 + ], + [ + 10900, + 35800 + ], + [ + 10600, + 34800 + ], + [ + 10300, + 33800 + ], + [ + 10100, + 33100 + ], + [ + 9900, + 32500 + ], + [ + 9600, + 31500 + ], + [ + 9300, + 30500 + ], + [ + 9100, + 29900 + ], + [ + 8900, + 29200 + ], + [ + 8600, + 28200 + ], + [ + 8300, + 27200 + ], + [ + 8100, + 26600 + ], + [ + 8000, + 26200 + ], + [ + 7900, + 25900 + ], + [ + 7800, + 25600 + ], + [ + 7700, + 25300 + ], + [ + 7600, + 24900 + ], + [ + 7500, + 24600 + ], + [ + 7400, + 24300 + ], + [ + 7300, + 24000 + ], + [ + 7200, + 23600 + ], + [ + 7100, + 23300 + ], + [ + 7000, + 23000 + ], + [ + 6900, + 22600 + ], + [ + 6800, + 22300 + ], + [ + 6700, + 22000 + ], + [ + 6600, + 21700 + ], + [ + 6500, + 21300 + ], + [ + 6400, + 21000 + ], + [ + 6300, + 20700 + ], + [ + 6200, + 20300 + ], + [ + 6100, + 20000 + ], + [ + 6000, + 19700 + ], + [ + 5900, + 19400 + ], + [ + 5800, + 19000 + ], + [ + 5700, + 18700 + ], + [ + 5600, + 18400 + ], + [ + 5500, + 18000 + ], + [ + 5400, + 17700 + ], + [ + 5300, + 17400 + ], + [ + 5200, + 17100 + ], + [ + 5100, + 16700 + ], + [ + 5000, + 16400 + ], + [ + 4900, + 16100 + ], + [ + 4800, + 15700 + ], + [ + 4700, + 15400 + ], + [ + 4600, + 15100 + ], + [ + 4500, + 14800 + ], + [ + 4400, + 14400 + ], + [ + 4300, + 14100 + ], + [ + 4200, + 13800 + ], + [ + 4100, + 13500 + ], + [ + 4000, + 13100 + ], + [ + 3900, + 12800 + ], + [ + 3800, + 12500 + ], + [ + 3700, + 12100 + ], + [ + 3600, + 11800 + ], + [ + 3500, + 11500 + ], + [ + 3400, + 11200 + ], + [ + 3300, + 10800 + ], + [ + 3200, + 10500 + ], + [ + 3100, + 10200 + ], + [ + 3000, + 9800 + ], + [ + 2900, + 9500 + ], + [ + 2800, + 9200 + ], + [ + 2700, + 8900 + ], + [ + 2600, + 8500 + ], + [ + 2500, + 8200 + ], + [ + 2400, + 7900 + ], + [ + 2300, + 7500 + ], + [ + 2200, + 7200 + ], + [ + 2100, + 6900 + ], + [ + 2000, + 6600 + ], + [ + 1900, + 6200 + ], + [ + 1800, + 5900 + ], + [ + 1700, + 5600 + ], + [ + 1600, + 5200 + ], + [ + 1500, + 4900 + ], + [ + 1400, + 4600 + ], + [ + 1300, + 4300 + ], + [ + 1200, + 3900 + ], + [ + 1100, + 3600 + ], + [ + 1000, + 3300 + ], + [ + 900, + 3000 + ], + [ + 850, + 2800 + ], + [ + 800, + 2600 + ], + [ + 750, + 2500 + ], + [ + 700, + 2300 + ], + [ + 650, + 2100 + ], + [ + 600, + 2000 + ], + [ + 550, + 1800 + ], + [ + 500, + 1600 + ], + [ + 450, + 1500 + ], + [ + 400, + 1300 + ], + [ + 350, + 1100 + ], + [ + 300, + 1000 + ] + ] + }, + "chinaFLAS": { + "source": "GVIII-G700 Operating Manual Figure 5, 06-10-00, 2024-03-29", + "caution": "Do not use for approach minima. Values rounded to nearest 100 ft.", + "notes": [ + "ATC will issue the Flight Level clearance in meters.", + "Pilots shall use the China RVSM FLAS table to determine the corresponding flight level in feet.", + "The aircraft shall be flown using the flight level in FEET.", + "Due to rounding differences, the metric readout of the onboard avionics will not necessarily correspond to the cleared Flight Level in meters however the difference will never be more than 30 meters." + ], + "entries": [ + [ + 15500, + 50900 + ], + [ + 14900, + 48900 + ], + [ + 14300, + 46900 + ], + [ + 13700, + 44900 + ], + [ + 13100, + 43000 + ], + [ + 12500, + 41100 + ], + [ + 12200, + 40100 + ], + [ + 11900, + 39100 + ], + [ + 11600, + 38100 + ], + [ + 11300, + 37100 + ], + [ + 11000, + 36100 + ], + [ + 10700, + 35100 + ], + [ + 10400, + 34100 + ], + [ + 10100, + 33100 + ], + [ + 9800, + 32100 + ], + [ + 9500, + 31100 + ], + [ + 9200, + 30100 + ], + [ + 8900, + 29100 + ], + [ + 8400, + 27600 + ], + [ + 8100, + 26600 + ], + [ + 7800, + 25600 + ], + [ + 7500, + 24600 + ], + [ + 7200, + 23600 + ], + [ + 6900, + 22600 + ], + [ + 6600, + 21700 + ], + [ + 6300, + 20700 + ], + [ + 6000, + 19700 + ], + [ + 5700, + 18700 + ], + [ + 5400, + 17700 + ], + [ + 5100, + 16700 + ], + [ + 4800, + 15700 + ], + [ + 4500, + 14800 + ], + [ + 4200, + 13800 + ], + [ + 3900, + 12800 + ], + [ + 3600, + 11800 + ], + [ + 3300, + 10800 + ], + [ + 3000, + 9800 + ], + [ + 2700, + 8900 + ], + [ + 2400, + 7900 + ], + [ + 2100, + 6900 + ], + [ + 1800, + 5900 + ], + [ + 1500, + 4900 + ], + [ + 1200, + 3900 + ], + [ + 900, + 3000 + ], + [ + 600, + 2000 + ], + [ + 500, + 1600 + ], + [ + 400, + 1300 + ], + [ + 300, + 1000 + ], + [ + 200, + 700 + ], + [ + 100, + 300 + ] + ], + "rvsmEastbound": [ + 29100, + 31100, + 33100, + 35100, + 37100, + 39100, + 41100 + ], + "rvsmWestbound": [ + 30100, + 32100, + 34100, + 36100, + 38100, + 40100 + ], + "eastboundHeading": "0° to 179°", + "westboundHeading": "186° to 359°" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/harshmallow/pilottoolkit/MainActivity.kt b/app/src/main/java/com/harshmallow/pilottoolkit/MainActivity.kt new file mode 100644 index 0000000..59abed7 --- /dev/null +++ b/app/src/main/java/com/harshmallow/pilottoolkit/MainActivity.kt @@ -0,0 +1,47 @@ +package com.harshmallow.pilottoolkit + +import android.os.Bundle +import android.net.Uri +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.runtime.* +import com.harshmallow.pilottoolkit.ui.PilotToolkitApp +import com.harshmallow.pilottoolkit.ui.getSavedDarkMode +import com.harshmallow.pilottoolkit.ui.saveDarkMode +import com.harshmallow.pilottoolkit.data.DataLoader +import com.harshmallow.pilottoolkit.ui.theme.PilotToolkitTheme + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + DataLoader.init(this) + + setContent { + var darkMode by remember { mutableStateOf(getSavedDarkMode(this@MainActivity)) } + var importUri by remember { mutableStateOf(extractImportUri(intent)) } + + PilotToolkitTheme(darkTheme = darkMode) { + PilotToolkitApp( + darkMode = darkMode, + onToggleDarkMode = { + darkMode = !darkMode + saveDarkMode(this@MainActivity, darkMode) + }, + importUri = importUri, + onImportConsumed = { importUri = null } + ) + } + } + } + + private fun extractImportUri(intent: android.content.Intent?): Uri? { + if (intent == null) return null + return when (intent.action) { + android.content.Intent.ACTION_VIEW -> intent.data + android.content.Intent.ACTION_SEND -> intent.getParcelableExtra(android.content.Intent.EXTRA_STREAM) + else -> null + } + } +} diff --git a/app/src/main/java/com/harshmallow/pilottoolkit/SETUP.md b/app/src/main/java/com/harshmallow/pilottoolkit/SETUP.md new file mode 100644 index 0000000..7eee0fb --- /dev/null +++ b/app/src/main/java/com/harshmallow/pilottoolkit/SETUP.md @@ -0,0 +1,79 @@ +# Pilot's Toolkit — Android Setup Guide (v2) + +## What's New Since Last Build + +- **G650 data** — ESWL formula, weight limits (52,600 / 99,600 / 100,000) +- **G700 data** — Full ACN + ACR formulas (16 total), ESWL thresholds (S-85 / D-108), weight limits +- **HF Frequencies tab** — WebView embed of ARINC Atlantic/Pacific pages with refresh, staleness timer, direct link +- **Pavement module** — Airport comparison banner, ACN/ACR labels, ESWL thresholds display +- **Crosswind module** — Wind input as xxx/xx format with auto-slash +- **Fuel module** — Split temperature warnings (red below -20°C, amber above +50°C) +- **Hamburger menu** — Dark mode toggle, About section +- **Theme** — Warning/danger colors for all banners + +## Step 1: Android Manifest — Add Internet Permission + +The HF module uses a WebView which requires internet access. Open `app/src/main/AndroidManifest.xml` and add this line **before** the `` tag: + +```xml + +``` + +## Step 2: Drop in the Source Files + +Your project's Kotlin source root is: +``` +app/src/main/java/com/harshmallow/pilottoolkit/ +``` + +Replace all files with the provided ones. File layout: + +``` +app/src/main/java/com/harshmallow/pilottoolkit/ +├── MainActivity.kt +├── data/ +│ └── AircraftData.kt +└── ui/ + ├── PilotToolkitApp.kt + ├── components/ + │ └── SharedComponents.kt + ├── modules/ + │ ├── FuelOrderModule.kt + │ ├── PavementModule.kt + │ ├── CrosswindModule.kt + │ ├── FuelBucketsModule.kt + │ └── HFModule.kt ← NEW + └── theme/ + ├── Color.kt + └── Theme.kt +``` + +**11 files total.** Delete any leftover `Type.kt` from the auto-generated template. + +## Step 3: Build and Run + +1. **Build → Rebuild Project** (Ctrl+F9) +2. Fix any import issues (most likely if a file landed in the wrong package) +3. **Run** on emulator or device + +## What to Verify + +- [ ] Fuel tab: enter temp below -20 → red warning; above 50 → amber warning +- [ ] Pavement tab (G450): enter weight + airport PCN → green/red comparison banner +- [ ] Pavement tab (G700): ACR/ACN values, ESWL thresholds display (no formula ESWL) +- [ ] Pavement tab (G650): ESWL formula works, ACN/ACR shows "not yet available" +- [ ] Crosswind tab: type "31015" → auto-formats to "310/15" +- [ ] Buckets tab: slider works, shows "Flight Time" label +- [ ] HF Freq tab: WebView loads ARINC page, refresh button works, staleness timer ticks +- [ ] HF Freq tab: Atlantic/Pacific toggle switches pages +- [ ] HF Freq tab: "Open ARINC Page" link opens browser +- [ ] Hamburger menu: dark mode toggle, About section +- [ ] Aircraft selector: G650 and G700 are selectable and functional + +## Troubleshooting + +**WebView blank/error:** Make sure `INTERNET` permission is in the manifest. Some emulators need a network connection configured. + +**"Unresolved reference" errors:** Verify each file's `package` declaration matches its directory. E.g. `HFModule.kt` must be in `.../ui/modules/`. + +**Gradle sync issues:** Ensure you're using the Compose "Empty Activity" template, not legacy Views. diff --git a/app/src/main/java/com/harshmallow/pilottoolkit/data/AircraftData.kt b/app/src/main/java/com/harshmallow/pilottoolkit/data/AircraftData.kt new file mode 100644 index 0000000..9a22af8 --- /dev/null +++ b/app/src/main/java/com/harshmallow/pilottoolkit/data/AircraftData.kt @@ -0,0 +1,92 @@ +package com.harshmallow.pilottoolkit.data + +import kotlin.math.ceil + +/* ─── Weight Limits (nullable fields for partial data) ─── */ +data class WeightLimits( + val minBew: Int? = null, + val mtow: Int? = null, + val maxRamp: Int +) + +data class LinearFormula(val slope: Double, val intercept: Double) +data class EswlConfig(val weightFactor: Double, val wheelFactor: Double) + +data class EswlThresholdEntry(val label: String, val value: Int, val unit: String = "lbs") +data class EswlThresholds( + val singleWheel: EswlThresholdEntry, + val dualWheel: EswlThresholdEntry, + val note: String +) + +data class SubgradeFormulas( + val a: LinearFormula, val b: LinearFormula, + val c: LinearFormula, val d: LinearFormula +) { + fun forCategory(cat: SubgradeCategory): LinearFormula = when (cat) { + SubgradeCategory.A -> a; SubgradeCategory.B -> b + SubgradeCategory.C -> c; SubgradeCategory.D -> d + } +} + +data class PavementFormulas(val rigid: SubgradeFormulas, val flexible: SubgradeFormulas) { + fun forType(type: PavementType): SubgradeFormulas = when (type) { + PavementType.RIGID -> rigid; PavementType.FLEXIBLE -> flexible + } +} + +data class FuelBucket(val stageLength: Double, val fuelBurn: Double) + +data class Aircraft( + val id: String, val label: String, val available: Boolean, + val weightLimits: WeightLimits? = null, + val eswl: EswlConfig? = null, + val eswlThresholds: EswlThresholds? = null, + val pcn: PavementFormulas? = null, val pcr: PavementFormulas? = null, + val pcnError: String? = null, val pcrError: String? = null, + val fuelBuckets: List? = null +) + +enum class PavementSystem(val label: String) { PCN("PCN"), PCR("PCR") } +enum class PavementType(val label: String) { RIGID("Rigid"), FLEXIBLE("Flexible") } +enum class SubgradeCategory(val label: String, val shortLabel: String) { + A("A – High", "A High"), B("B – Medium", "B Med"), + C("C – Low", "C Low"), D("D – Ultra Low", "D Ultra") +} + +object Calculations { + const val LB_PER_GAL_PER_KGL = 8.34540 // 1 kg/L = 8.34540 lb/US gal + // ASTM D1250 Table 6B — Generalized Products density correction + fun astmDensityLbGal(sg15: Double, tempC: Double): Double { + val rho15 = sg15 * 1000.0 // kg/m³ + val K0 = 346.4228; val K1 = 0.4388 + val alpha15 = K0 / (rho15 * rho15) + K1 / rho15 + val deltaT = tempC - 15.0 + val VCF = kotlin.math.exp(-alpha15 * deltaT * (1.0 + 0.8 * alpha15 * deltaT)) + return (rho15 * VCF / 1000.0) * LB_PER_GAL_PER_KGL + } + fun roundUpToTen(value: Double) = (ceil(value / 10.0) * 10.0).toInt() + fun fahrenheitToCelsius(f: Double) = (f - 32.0) / 1.8 + fun calculateESWL(weightLbs: Double, config: EswlConfig) = (weightLbs * config.weightFactor) / config.wheelFactor + fun evaluateFormula(formula: LinearFormula, weightLbs: Double) = formula.slope * weightLbs + formula.intercept +} + +enum class WarningLevel { WARNING, DANGER } +data class WeightWarning(val level: WarningLevel, val message: String, val blocksCalculation: Boolean = false) + +fun checkWeightLimits(weight: Double, aircraft: Aircraft): WeightWarning? { + val limits = aircraft.weightLimits ?: return null + return when { + weight > limits.maxRamp -> WeightWarning(WarningLevel.DANGER, + "Exceeds ${aircraft.label} max ramp weight (${"%,d".format(limits.maxRamp)} lbs) — calculation blocked", true) + limits.mtow != null && weight > limits.mtow -> WeightWarning(WarningLevel.WARNING, + "Exceeds ${aircraft.label} MTOW (${"%,d".format(limits.mtow)} lbs)") + limits.minBew != null && weight <= limits.minBew -> WeightWarning(WarningLevel.WARNING, + "Weight is at or below typical BEW (${"%,d".format(limits.minBew)} lbs) — verify input") + else -> null + } +} + +val aircraftRegistry: List get() = DataLoader.aircraftRegistry + +fun getAircraftById(id: String): Aircraft = aircraftRegistry.first { it.id == id } diff --git a/app/src/main/java/com/harshmallow/pilottoolkit/data/DataLoader.kt b/app/src/main/java/com/harshmallow/pilottoolkit/data/DataLoader.kt new file mode 100644 index 0000000..baaa236 --- /dev/null +++ b/app/src/main/java/com/harshmallow/pilottoolkit/data/DataLoader.kt @@ -0,0 +1,178 @@ +package com.harshmallow.pilottoolkit.data + +import android.content.Context +import android.util.Log +import org.json.JSONObject + +/** + * Loads aircraft and reference data from assets/ at app startup. + * Call DataLoader.init(context) once from MainActivity.onCreate(). + * + * File structure: + * assets/ + * reference.json ← flight levels, subgrade labels + * aircraft/ + * G450.json, G500.json, ... ← one file per airframe + * + * Each aircraft file loads independently — a malformed file is skipped + * with a warning rather than crashing the app. + */ +object DataLoader { + + private const val TAG = "DataLoader" + + var aircraftRegistry: List = emptyList() + private set + var metersToFeetTable: List = emptyList() + private set + var chinaFLAS: List = emptyList() + private set + var chinaRVSMEastbound: Set = emptySet() + private set + var chinaRVSMWestbound: Set = emptySet() + private set + var pavementSubgrades: Map = emptyMap() + private set + + /** Aircraft IDs that failed to load, with error messages. */ + var loadErrors: Map = emptyMap() + private set + + fun init(context: Context) { + loadReference(context) + loadAircraft(context) + } + + private fun loadReference(context: Context) { + try { + val json = context.assets.open("reference.json") + .bufferedReader().use { it.readText() } + val root = JSONObject(json) + + // Pavement subgrades + root.optJSONObject("pavementSubgrades")?.let { sg -> + pavementSubgrades = sg.keys().asSequence().associateWith { sg.getString(it) } + } + + // Meters to feet + root.optJSONObject("metersToFeet")?.optJSONArray("entries")?.let { arr -> + metersToFeetTable = (0 until arr.length()).map { i -> + val pair = arr.getJSONArray(i) + FlightLevelEntry(pair.getInt(0), pair.getInt(1)) + } + } + + // China FLAS + root.optJSONObject("chinaFLAS")?.let { flas -> + flas.optJSONArray("entries")?.let { arr -> + chinaFLAS = (0 until arr.length()).map { i -> + val pair = arr.getJSONArray(i) + FlightLevelEntry(pair.getInt(0), pair.getInt(1)) + } + } + flas.optJSONArray("rvsmEastbound")?.let { arr -> + chinaRVSMEastbound = (0 until arr.length()).map { arr.getInt(it) }.toSet() + } + flas.optJSONArray("rvsmWestbound")?.let { arr -> + chinaRVSMWestbound = (0 until arr.length()).map { arr.getInt(it) }.toSet() + } + } + + Log.i(TAG, "reference.json loaded: ${metersToFeetTable.size} m/ft entries, ${chinaFLAS.size} FLAS entries") + } catch (e: Exception) { + Log.e(TAG, "Failed to load reference.json: ${e.message}", e) + } + } + + private fun loadAircraft(context: Context) { + val aircraft = mutableListOf() + val errors = mutableMapOf() + + // List all .json files in assets/aircraft/ + val files = try { + context.assets.list("aircraft") ?: emptyArray() + } catch (e: Exception) { + Log.e(TAG, "Cannot list aircraft/ directory: ${e.message}", e) + emptyArray() + } + + for (filename in files.sorted()) { + if (!filename.endsWith(".json")) continue + val id = filename.removeSuffix(".json") + try { + val json = context.assets.open("aircraft/$filename") + .bufferedReader().use { it.readText() } + val ac = parseAircraft(id, JSONObject(json)) + aircraft.add(ac) + Log.i(TAG, "$filename loaded: ${ac.label}") + } catch (e: Exception) { + val msg = e.message ?: "Unknown error" + errors[id] = msg + Log.e(TAG, "Failed to load $filename: $msg", e) + } + } + + aircraftRegistry = aircraft + loadErrors = errors + + if (errors.isNotEmpty()) { + Log.w(TAG, "Aircraft load errors: ${errors.keys.joinToString()}") + } + Log.i(TAG, "Loaded ${aircraft.size} aircraft, ${errors.size} failed") + } + + private fun parseAircraft(id: String, ac: JSONObject): Aircraft { + return Aircraft( + id = id, + label = ac.getString("label"), + available = ac.getBoolean("available"), + weightLimits = ac.optJSONObject("weightLimits")?.let { wl -> + WeightLimits( + minBew = wl.optInt("minBew", 0).takeIf { wl.has("minBew") }, + mtow = wl.optInt("mtow", 0).takeIf { wl.has("mtow") }, + maxRamp = wl.getInt("maxRamp") + ) + }, + eswl = ac.optJSONObject("eswl")?.let { e -> + if (e.optString("type") == "formula") + EswlConfig(e.getDouble("weightFactor"), e.getDouble("wheelFactor")) + else null + }, + eswlThresholds = ac.optJSONObject("eswl")?.let { e -> + if (e.optString("type") == "thresholds") { + val sw = e.getJSONObject("singleWheel") + val dw = e.getJSONObject("dualWheel") + EswlThresholds( + singleWheel = EswlThresholdEntry(sw.getString("label"), sw.getInt("value"), sw.optString("unit", "lbs")), + dualWheel = EswlThresholdEntry(dw.getString("label"), dw.getInt("value"), dw.optString("unit", "lbs")), + note = e.getString("note") + ) + } else null + }, + pcn = ac.optJSONObject("pcn")?.let { parsePavementFormulas(it) }, + pcr = ac.optJSONObject("pcr")?.let { parsePavementFormulas(it) }, + pcnError = ac.optString("pcnError").takeIf { it != "null" && it.isNotEmpty() }, + pcrError = ac.optString("pcrError").takeIf { it != "null" && it.isNotEmpty() }, + fuelBuckets = ac.optJSONArray("fuelBuckets")?.let { arr -> + (0 until arr.length()).map { i -> + val pair = arr.getJSONArray(i) + FuelBucket(pair.getDouble(0), pair.getDouble(1)) + } + } + ) + } + + private fun parsePavementFormulas(obj: JSONObject): PavementFormulas { + fun parseSubgrade(sub: JSONObject): SubgradeFormulas { + fun lf(key: String): LinearFormula { + val f = sub.getJSONObject(key) + return LinearFormula(f.getDouble("slope"), f.getDouble("intercept")) + } + return SubgradeFormulas(lf("A"), lf("B"), lf("C"), lf("D")) + } + return PavementFormulas( + rigid = parseSubgrade(obj.getJSONObject("rigid")), + flexible = parseSubgrade(obj.getJSONObject("flexible")) + ) + } +} diff --git a/app/src/main/java/com/harshmallow/pilottoolkit/data/FlightLevelData.kt b/app/src/main/java/com/harshmallow/pilottoolkit/data/FlightLevelData.kt new file mode 100644 index 0000000..9ee1817 --- /dev/null +++ b/app/src/main/java/com/harshmallow/pilottoolkit/data/FlightLevelData.kt @@ -0,0 +1,9 @@ +package com.harshmallow.pilottoolkit.data + +data class FlightLevelEntry(val meters: Int, val feet: Int) + +// Derived from aircraft-data.json reference section via DataLoader +val metersToFeetTable: List get() = DataLoader.metersToFeetTable +val chinaFLAS: List get() = DataLoader.chinaFLAS +val chinaRVSMEastbound: Set get() = DataLoader.chinaRVSMEastbound +val chinaRVSMWestbound: Set get() = DataLoader.chinaRVSMWestbound diff --git a/app/src/main/java/com/harshmallow/pilottoolkit/data/FuelProfiles.kt b/app/src/main/java/com/harshmallow/pilottoolkit/data/FuelProfiles.kt new file mode 100644 index 0000000..664fbda --- /dev/null +++ b/app/src/main/java/com/harshmallow/pilottoolkit/data/FuelProfiles.kt @@ -0,0 +1,151 @@ +package com.harshmallow.pilottoolkit.data + +import android.content.Context +import android.content.Intent +import androidx.core.content.FileProvider +import org.json.JSONArray +import org.json.JSONObject +import java.io.File + +private const val PREFS_NAME = "fuel_profiles" + +data class FuelProfile(val name: String, val buckets: List) + +private fun getPrefs(context: Context) = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + +fun getFuelProfile(context: Context, aircraftId: String): FuelProfile? { + val prefs = getPrefs(context) + val raw = prefs.getString("profile_$aircraftId", null) ?: return null + return try { + val obj = JSONObject(raw) + // Migration: old format was bare JSON array + if (raw.trimStart().startsWith("[")) { + val arr = JSONArray(raw) + val buckets = (0 until arr.length()).map { i -> + val b = arr.getJSONArray(i) + FuelBucket(b.getDouble(0), b.getDouble(1)) + } + FuelProfile("Custom", buckets) + } else { + val name = obj.optString("name", "Custom") + val arr = obj.getJSONArray("buckets") + val buckets = (0 until arr.length()).map { i -> + val b = arr.getJSONArray(i) + FuelBucket(b.getDouble(0), b.getDouble(1)) + } + FuelProfile(name, buckets) + } + } catch (e: Exception) { + // Try as bare array (old format) + try { + val arr = JSONArray(raw) + val buckets = (0 until arr.length()).map { i -> + val b = arr.getJSONArray(i) + FuelBucket(b.getDouble(0), b.getDouble(1)) + } + FuelProfile("Custom", buckets) + } catch (e2: Exception) { null } + } +} + +fun saveFuelProfile(context: Context, aircraftId: String, name: String, buckets: List) { + val obj = JSONObject() + obj.put("name", name) + val arr = JSONArray() + buckets.forEach { b -> + val entry = JSONArray() + entry.put(b.stageLength) + entry.put(b.fuelBurn) + arr.put(entry) + } + obj.put("buckets", arr) + getPrefs(context).edit().putString("profile_$aircraftId", obj.toString()).apply() +} + +fun clearFuelProfile(context: Context, aircraftId: String) { + getPrefs(context).edit().remove("profile_$aircraftId").apply() +} + +fun getAllFuelProfiles(context: Context): Map { + val map = mutableMapOf() + aircraftRegistry.forEach { ac -> + val profile = getFuelProfile(context, ac.id) + if (profile != null) map[ac.id] = profile + } + return map +} + +/* ── CSV Parser ── */ +fun parseBucketCSV(text: String): Pair?, String?> { + val lines = text.trim().lines().filter { it.isNotBlank() } + if (lines.isEmpty()) return null to "File is empty." + + val firstFields = lines[0].split(",").map { it.trim() } + val startsWithHeader = firstFields[0].toDoubleOrNull() == null + val dataLines = if (startsWithHeader) lines.drop(1) else lines + if (dataLines.isEmpty()) return null to "No data rows found." + + val buckets = mutableListOf() + dataLines.forEachIndexed { i, line -> + val fields = line.split(",").map { it.trim() } + if (fields.size < 2) return null to "Row ${i + 1}: expected 2 columns, found ${fields.size}." + val stage = fields[0].toDoubleOrNull() + val burn = fields[1].toDoubleOrNull() + if (stage == null || burn == null) return null to "Row ${i + 1}: non-numeric value." + if (stage < 0 || burn < 0) return null to "Row ${i + 1}: negative values not allowed." + buckets.add(FuelBucket(stage, burn)) + } + buckets.sortBy { it.stageLength } + return buckets to null +} + +/* ── JSON Profile Export ── */ +fun exportFuelProfileJSON(context: Context, aircraftId: String, profile: FuelProfile) { + val obj = JSONObject() + obj.put("format", "pilot-toolkit-fuel-profile") + obj.put("version", 1) + obj.put("aircraft", aircraftId) + obj.put("aircraftLabel", getAircraftById(aircraftId).label) + obj.put("name", profile.name) + obj.put("entries", profile.buckets.size) + val arr = JSONArray() + profile.buckets.forEach { b -> + val entry = JSONArray() + entry.put(b.stageLength) + entry.put(b.fuelBurn) + arr.put(entry) + } + obj.put("buckets", arr) + + val safeName = profile.name.replace(Regex("[^A-Za-z0-9_-]"), "_") + val filename = "FUEL_PROFILE_${safeName}_${aircraftId}.json" + val dir = File(context.cacheDir, "exports") + dir.mkdirs() + val file = File(dir, filename) + file.writeText(obj.toString(2)) + + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) + val intent = Intent(Intent.ACTION_SEND).apply { + type = "application/json" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(intent, "Share fuel profile")) +} + +/* ── JSON Profile Import ── */ +fun parseFuelProfileJSON(text: String): Triple> { + val obj = JSONObject(text) + if (obj.optString("format") != "pilot-toolkit-fuel-profile") { + throw IllegalArgumentException("Not a valid fuel profile file.") + } + val arr = obj.getJSONArray("buckets") + if (arr.length() == 0) throw IllegalArgumentException("No bucket data found.") + val name = obj.optString("name", "Imported") + val aircraft = obj.optString("aircraft", "") + val buckets = (0 until arr.length()).map { i -> + val b = arr.getJSONArray(i) + FuelBucket(b.getDouble(0), b.getDouble(1)) + }.sortedBy { it.stageLength } + return Triple(name, aircraft, buckets) +} diff --git a/app/src/main/java/com/harshmallow/pilottoolkit/ui/PilotToolkitApp.kt b/app/src/main/java/com/harshmallow/pilottoolkit/ui/PilotToolkitApp.kt new file mode 100644 index 0000000..806aa50 --- /dev/null +++ b/app/src/main/java/com/harshmallow/pilottoolkit/ui/PilotToolkitApp.kt @@ -0,0 +1,495 @@ +package com.harshmallow.pilottoolkit.ui + +import android.content.Context +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.* +import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.harshmallow.pilottoolkit.data.* +import com.harshmallow.pilottoolkit.ui.modules.* +import com.harshmallow.pilottoolkit.ui.theme.ToolkitTheme +import kotlinx.coroutines.delay + +enum class Module(val label: String, val title: String, val subtitle: String) { + PASSDOWN("Passdown", "Passdown", "Crew passdown · Maintenance status · History"), + FUEL("Fuel", "Fuel Order Calculator", "ASTM D1250 Table 6B density correction · Quantities rounded up to nearest 10"), + PAVEMENT("Pavement", "Pavement Strength / ESWL", "PCN/PCR · ESWL · Pavement classification"), + CROSSWIND("Crosswind", "Crosswind Calculator", "Headwind, tailwind, and crosswind components"), + BUCKETS("Buckets", "Fuel Buckets", "Stage length vs. fuel burn schedule"), + FL("FL / m", "Flight Level ↔ Meters", "ICAO conversion table · China RVSM FLAS"), + HF("HF Freq", "HF Frequencies", "Active ARINC HF assignments · Atlantic & Pacific"), + REST("Crew Rest", "Crew Rest", "Augmented operations · Sequential rest rotation"), +} + +private const val PREFS_NAME = "pilot_toolkit_prefs" +private const val KEY_DEFAULT_AIRCRAFT = "default_aircraft" +private const val KEY_DARK_MODE = "dark_mode" + +private fun getPrefs(context: Context) = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) +fun getSavedDefaultAircraft(context: Context): String = getPrefs(context).getString(KEY_DEFAULT_AIRCRAFT, "G450") ?: "G450" +fun saveDefaultAircraft(context: Context, id: String) = getPrefs(context).edit().putString(KEY_DEFAULT_AIRCRAFT, id).apply() +fun getSavedDarkMode(context: Context): Boolean = getPrefs(context).getBoolean(KEY_DARK_MODE, false) +fun saveDarkMode(context: Context, value: Boolean) = getPrefs(context).edit().putBoolean(KEY_DARK_MODE, value).apply() + +private const val KEY_VOLUME_UNIT = "volume_unit" +fun detectDefaultVolumeUnit(): String { + val locale = java.util.Locale.getDefault() + val country = locale.country.uppercase() + return when { + country == "US" || country == "PR" || country == "VI" || country == "GU" -> "usgal" + country == "GB" || country == "LC" || country == "AG" || country == "KY" + || country == "BZ" || country == "MS" || country == "VG" || country == "AI" -> "impgal" + else -> "liters" + } +} +fun getSavedVolumeUnit(context: Context): String { + val saved = getPrefs(context).getString(KEY_VOLUME_UNIT, null) + return if (saved != null && saved in listOf("usgal", "liters", "impgal")) saved else detectDefaultVolumeUnit() +} +fun saveVolumeUnit(context: Context, unit: String) = getPrefs(context).edit().putString(KEY_VOLUME_UNIT, unit).apply() + +@Composable +fun PilotToolkitApp(darkMode: Boolean, onToggleDarkMode: () -> Unit, importUri: android.net.Uri? = null, onImportConsumed: () -> Unit = {}) { + val colors = ToolkitTheme.colors + val context = LocalContext.current + + var selectedAircraftId by remember { mutableStateOf(getSavedDefaultAircraft(context)) } + var defaultAircraftId by remember { mutableStateOf(getSavedDefaultAircraft(context)) } + var activeModule by remember { mutableStateOf(Module.FUEL) } + var showDefaultConfirm by remember { mutableStateOf(false) } + var menuOpen by remember { mutableStateOf(false) } + var profilesMap by remember { mutableStateOf(getAllFuelProfiles(context)) } + var profileImportStatus by remember { mutableStateOf?>(null) } + var volumeUnit by remember { mutableStateOf(getSavedVolumeUnit(context)) } + + // File picker for CSV import + val csvImportLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + try { + val text = context.contentResolver.openInputStream(uri)?.bufferedReader()?.readText() + ?: throw Exception("Cannot read file") + val (buckets, error) = parseBucketCSV(text) + if (error != null) { + profileImportStatus = "error" to error + } else if (buckets != null) { + // Use filename as default name + val cursor = context.contentResolver.query(uri, null, null, null, null) + val fileName = cursor?.use { c -> + if (c.moveToFirst()) c.getString(c.getColumnIndexOrThrow(android.provider.OpenableColumns.DISPLAY_NAME)) + else null + }?.replace(Regex("\\.[^.]+$"), "")?.replace(Regex("[_-]"), " ")?.trim() ?: "Custom" + // For now use filename directly — Android doesn't have prompt() so we use the filename + saveFuelProfile(context, selectedAircraftId, fileName, buckets) + profilesMap = getAllFuelProfiles(context) + profileImportStatus = "success" to "Imported \"$fileName\" (${buckets.size} entries) for ${getAircraftById(selectedAircraftId).label}." + } + } catch (e: Exception) { + profileImportStatus = "error" to "Import failed: ${e.message}" + } + } + + // File picker for JSON profile import + val jsonImportLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + try { + val text = context.contentResolver.openInputStream(uri)?.bufferedReader()?.readText() + ?: throw Exception("Cannot read file") + val (name, aircraft, buckets) = parseFuelProfileJSON(text) + val targetAircraft = aircraft.ifEmpty { selectedAircraftId } + saveFuelProfile(context, targetAircraft, name, buckets) + profilesMap = getAllFuelProfiles(context) + profileImportStatus = "success" to "Imported \"$name\" (${buckets.size} entries) for ${getAircraftById(targetAircraft).label}." + } catch (e: Exception) { + profileImportStatus = "error" to "Import failed: ${e.message}" + } + } + + LaunchedEffect(profileImportStatus) { + if (profileImportStatus != null) { delay(4000); profileImportStatus = null } + } + + // Auto-switch to Passdown tab when import URI arrives + LaunchedEffect(importUri) { + if (importUri != null) activeModule = Module.PASSDOWN + } + + val isDefault = selectedAircraftId == defaultAircraftId + val selectedAircraft = getAircraftById(selectedAircraftId) + + // Purge old passdowns on launch + LaunchedEffect(Unit) { + val months = getRetentionMonths(context) + if (months > 0) purgeOldPassdowns(context, months) + } + + LaunchedEffect(showDefaultConfirm) { + if (showDefaultConfirm) { delay(2000); showDefaultConfirm = false } + } + + Box(Modifier.fillMaxSize()) { + Column(Modifier.fillMaxSize().background(colors.bg)) { + /* ── Top Bar ── */ + Row( + Modifier.fillMaxWidth().background(colors.navBg).padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(8.dp).clip(CircleShape).background(colors.accent)) + Spacer(Modifier.width(10.dp)) + Text("PILOT'S TOOLKIT", fontSize = 14.sp, fontWeight = FontWeight.Bold, + color = Color.White, letterSpacing = 0.5.sp) + Spacer(Modifier.width(8.dp)) + Text("ALPHA", fontSize = 10.sp, fontWeight = FontWeight.SemiBold, color = colors.accent, + modifier = Modifier.clip(RoundedCornerShape(4.dp)) + .background(colors.accent.copy(alpha = 0.15f)).padding(horizontal = 8.dp, vertical = 2.dp)) + } + Row(verticalAlignment = Alignment.CenterVertically) { + // Compact aircraft indicator + Box(modifier = Modifier.clip(RoundedCornerShape(4.dp)) + .background(Color.White.copy(alpha = 0.1f)) + .clickable { menuOpen = true } + .padding(horizontal = 10.dp, vertical = 4.dp)) { + Text(selectedAircraft.label, fontSize = 12.sp, + fontWeight = if (isDefault) FontWeight.Bold else FontWeight.SemiBold, + color = if (isDefault) colors.accent else colors.warning, + letterSpacing = 0.5.sp) + } + Spacer(Modifier.width(4.dp)) + IconButton(onClick = { menuOpen = true }) { + Icon(Icons.Default.Menu, "Menu", tint = Color.White) + } + } + } + + /* ── Tabs ── */ + ScrollableTabRow( + selectedTabIndex = Module.entries.indexOf(activeModule), + containerColor = colors.surface, contentColor = colors.accent, + edgePadding = 8.dp, divider = { HorizontalDivider(color = colors.border) }, + indicator = { tabPositions -> + val idx = Module.entries.indexOf(activeModule) + if (idx < tabPositions.size) { + TabRowDefaults.SecondaryIndicator( + Modifier.tabIndicatorOffset(tabPositions[idx]), height = 2.5.dp, color = colors.accent + ) + } + } + ) { + Module.entries.forEach { mod -> + val isActive = mod == activeModule + Tab(selected = isActive, onClick = { activeModule = mod }, + text = { + Text(mod.label, fontWeight = if (isActive) FontWeight.Bold else FontWeight.Medium, + fontSize = 13.sp, color = if (isActive) colors.accent else colors.textMuted) + }) + } + } + + /* ── Content ── */ + Column( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Card( + modifier = Modifier.widthIn(max = if (activeModule == Module.PASSDOWN) 540.dp else 460.dp).fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = colors.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, colors.border) + ) { + Column(Modifier.padding(24.dp)) { + Text(activeModule.title, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.text) + Spacer(Modifier.height(4.dp)) + Text(activeModule.subtitle, fontSize = 13.sp, color = colors.textMuted, lineHeight = 18.sp) + Spacer(Modifier.height(20.dp)) + HorizontalDivider(color = colors.border) + Spacer(Modifier.height(20.dp)) + + when (activeModule) { + Module.PASSDOWN -> PassdownModule( + importUri = importUri, + onImportConsumed = onImportConsumed + ) + Module.FUEL -> FuelOrderModule(volumeUnit = volumeUnit) + Module.PAVEMENT -> PavementModule(aircraft = selectedAircraft) + Module.CROSSWIND -> CrosswindModule() + Module.BUCKETS -> FuelBucketsModule( + aircraft = selectedAircraft, + fuelProfile = profilesMap[selectedAircraftId] + ) + Module.FL -> FlightLevelModule() + Module.HF -> HFModule() + Module.REST -> CrewRestModule() + } + } + } + Spacer(Modifier.height(24.dp)) + Text("Not for operational use · Verify all calculations independently", + fontSize = 11.sp, color = colors.textMuted, letterSpacing = 0.3.sp) + Spacer(Modifier.height(16.dp)) + } + } + + /* ── Hamburger Menu Overlay ── */ + if (menuOpen) { + Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.5f)).clickable { menuOpen = false }) + Box(Modifier.fillMaxHeight().width(280.dp).align(Alignment.TopEnd).background(colors.surface)) { + Column(Modifier.padding(24.dp).verticalScroll(rememberScrollState())) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically) { + Text("Settings", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = colors.text) + IconButton(onClick = { menuOpen = false }) { + Icon(Icons.Default.Close, "Close", tint = colors.textMuted) + } + } + Spacer(Modifier.height(20.dp)) + + // Aircraft selector + Text("AIRCRAFT", fontSize = 11.sp, fontWeight = FontWeight.Bold, + color = colors.textMuted, letterSpacing = 0.5.sp) + Spacer(Modifier.height(10.dp)) + aircraftRegistry.forEach { ac -> + val isSelected = ac.id == selectedAircraftId + Row( + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .background(if (isSelected) colors.accent.copy(alpha = 0.1f) else Color.Transparent) + .clickable { selectedAircraftId = ac.id } + .padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(ac.label, fontSize = 14.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, + color = if (isSelected) colors.accent else colors.text) + if (isSelected && isDefault) { + Text("DEFAULT", fontSize = 10.sp, fontWeight = FontWeight.Bold, + color = colors.accent.copy(alpha = 0.6f), letterSpacing = 0.3.sp) + } + } + } + Spacer(Modifier.height(8.dp)) + if (!isDefault) { + TextButton( + onClick = { + saveDefaultAircraft(context, selectedAircraftId) + defaultAircraftId = selectedAircraftId; showDefaultConfirm = true + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Set ${selectedAircraft.label} as Default", fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, color = colors.accent) + } + } + if (showDefaultConfirm) { + Text("✓ Default saved", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, + color = colors.accent, modifier = Modifier.padding(start = 12.dp, top = 4.dp)) + } + + Spacer(Modifier.height(16.dp)) + HorizontalDivider(color = colors.border) + Spacer(Modifier.height(16.dp)) + + // Dark mode + Row(Modifier.fillMaxWidth().clickable { onToggleDarkMode() }.padding(vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text("Dark Mode", fontSize = 14.sp, color = colors.text) + Text(if (darkMode) "☀" else "🌙", fontSize = 16.sp) + } + HorizontalDivider(color = colors.border) + Spacer(Modifier.height(16.dp)) + + // Volume Unit Preference + Text("PRIMARY VOLUME UNIT", fontSize = 11.sp, fontWeight = FontWeight.Bold, + color = colors.textMuted, letterSpacing = 0.5.sp) + Spacer(Modifier.height(10.dp)) + Row(Modifier.fillMaxWidth().clip(RoundedCornerShape(6.dp)) + .border(1.dp, colors.border, RoundedCornerShape(6.dp))) { + listOf("usgal" to "US Gal", "liters" to "Liters", "impgal" to "Imp Gal").forEach { (key, label) -> + val active = volumeUnit == key + Box( + Modifier.weight(1f) + .background(if (active) colors.accent else Color.Transparent) + .clickable { volumeUnit = key; saveVolumeUnit(context, key) } + .padding(vertical = 10.dp), + contentAlignment = Alignment.Center + ) { + Text(label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, + color = if (active) Color.White else colors.textSecondary) + } + } + } + Text("Shown first in fuel order results", fontSize = 11.sp, color = colors.textMuted, + modifier = Modifier.padding(start = 12.dp, top = 4.dp)) + Spacer(Modifier.height(16.dp)) + HorizontalDivider(color = colors.border) + Spacer(Modifier.height(16.dp)) + + // Passdown Retention + Text("PASSDOWN RETENTION", fontSize = 11.sp, fontWeight = FontWeight.Bold, + color = colors.textMuted, letterSpacing = 0.5.sp) + Spacer(Modifier.height(10.dp)) + var retentionMonths by remember { mutableStateOf(getRetentionMonths(context)) } + val retentionOptions = listOf(3 to "3 months", 6 to "6 months", 12 to "12 months", + 24 to "24 months", 36 to "36 months", 0 to "Never delete") + retentionOptions.forEach { (months, label) -> + Row( + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .background(if (retentionMonths == months) colors.accent.copy(alpha = 0.1f) else Color.Transparent) + .clickable { retentionMonths = months; saveRetentionMonths(context, months) } + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(label, fontSize = 13.sp, + fontWeight = if (retentionMonths == months) FontWeight.Bold else FontWeight.Normal, + color = if (retentionMonths == months) colors.accent else colors.text) + } + } + Text("Auto-purge on app open", fontSize = 11.sp, color = colors.textMuted, + modifier = Modifier.padding(start = 12.dp, top = 4.dp)) + + Spacer(Modifier.height(16.dp)) + HorizontalDivider(color = colors.border) + Spacer(Modifier.height(16.dp)) + + // Fuel Profiles + Text("FUEL DATA", fontSize = 11.sp, fontWeight = FontWeight.Bold, + color = colors.textMuted, letterSpacing = 0.5.sp) + Spacer(Modifier.height(10.dp)) + + // Import CSV button + OutlinedButton( + onClick = { csvImportLauncher.launch("*/*") }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, colors.border) + ) { + Column(Modifier.fillMaxWidth()) { + Text("Import CSV for ${selectedAircraft.label}", fontSize = 13.sp, + fontWeight = FontWeight.Medium, color = colors.text) + Text("Two columns: stage_length, fuel_burn", fontSize = 11.sp, color = colors.textMuted) + } + } + Spacer(Modifier.height(6.dp)) + + // Import JSON profile button + OutlinedButton( + onClick = { jsonImportLauncher.launch("*/*") }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, colors.border) + ) { + Column(Modifier.fillMaxWidth()) { + Text("Import Fuel Profile (.json)", fontSize = 13.sp, + fontWeight = FontWeight.Medium, color = colors.text) + Text("Shared profile from another device or crew member", fontSize = 11.sp, color = colors.textMuted) + } + } + + // Loaded profiles list + if (profilesMap.isNotEmpty()) { + Spacer(Modifier.height(10.dp)) + Text("Loaded profiles:", fontSize = 11.sp, color = colors.textMuted) + Spacer(Modifier.height(6.dp)) + profilesMap.forEach { (acId, profile) -> + Row( + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .background(colors.surfaceAlt) + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(Modifier.weight(1f)) { + Text(profile.name, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colors.text) + Text("${getAircraftById(acId).label} · ${profile.buckets.size} entries", + fontSize = 11.sp, color = colors.textMuted) + } + Row { + IconButton( + onClick = { exportFuelProfileJSON(context, acId, profile) }, + modifier = Modifier.size(32.dp) + ) { + Icon(Icons.Default.Share, "Export", tint = colors.textMuted, + modifier = Modifier.size(16.dp)) + } + IconButton( + onClick = { + clearFuelProfile(context, acId) + profilesMap = getAllFuelProfiles(context) + profileImportStatus = "success" to "Cleared profile for ${getAircraftById(acId).label}." + }, + modifier = Modifier.size(32.dp) + ) { + Icon(Icons.Default.Delete, "Clear", tint = colors.textMuted, + modifier = Modifier.size(16.dp)) + } + } + } + Spacer(Modifier.height(4.dp)) + } + } + + // Import status + if (profileImportStatus != null) { + Spacer(Modifier.height(8.dp)) + Box( + Modifier.fillMaxWidth().clip(RoundedCornerShape(6.dp)) + .background( + if (profileImportStatus!!.first == "error") colors.dangerBg + else colors.accent.copy(alpha = 0.1f) + ) + .border( + 1.dp, + if (profileImportStatus!!.first == "error") colors.dangerBorder + else colors.accent.copy(alpha = 0.25f), + RoundedCornerShape(6.dp) + ) + .padding(8.dp) + ) { + Text( + profileImportStatus!!.second, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, + color = if (profileImportStatus!!.first == "error") colors.danger else colors.accent + ) + } + } + + Spacer(Modifier.height(16.dp)) + HorizontalDivider(color = colors.border) + Spacer(Modifier.height(16.dp)) + Text("About", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = colors.textMuted, letterSpacing = 0.5.sp) + Spacer(Modifier.height(8.dp)) + Text("Pilot's Toolkit v0.1.0 alpha", fontSize = 13.sp, color = colors.textSecondary) + } + } + } + } +} diff --git a/app/src/main/java/com/harshmallow/pilottoolkit/ui/components/SharedComponents.kt b/app/src/main/java/com/harshmallow/pilottoolkit/ui/components/SharedComponents.kt new file mode 100644 index 0000000..71f3832 --- /dev/null +++ b/app/src/main/java/com/harshmallow/pilottoolkit/ui/components/SharedComponents.kt @@ -0,0 +1,190 @@ +package com.harshmallow.pilottoolkit.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.harshmallow.pilottoolkit.data.WarningLevel +import com.harshmallow.pilottoolkit.data.WeightWarning +import com.harshmallow.pilottoolkit.ui.theme.ToolkitTheme + +@Composable +fun ToolkitTextField( + value: String, onValueChange: (String) -> Unit, + label: String, placeholder: String = "", + keyboardType: KeyboardType = KeyboardType.Text, + sublabel: String? = null +) { + val colors = ToolkitTheme.colors + if (label.isNotEmpty()) { + Row(verticalAlignment = Alignment.CenterVertically) { + FieldLabel(label) + if (sublabel != null) { + Spacer(Modifier.width(6.dp)) + Text(sublabel, fontSize = 10.sp, color = colors.textMuted, fontWeight = FontWeight.Normal) + } + } + } + OutlinedTextField( + value = value, onValueChange = onValueChange, + placeholder = { Text(placeholder, fontSize = 14.sp, color = colors.textMuted) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(8.dp), + keyboardOptions = KeyboardOptions(keyboardType = keyboardType), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = colors.accent, + unfocusedBorderColor = colors.border, + focusedTextColor = colors.text, + unfocusedTextColor = colors.text, + cursorColor = colors.accent, + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + ) + ) +} + +@Composable +fun FieldLabel(text: String) { + val colors = ToolkitTheme.colors + Text(text, fontSize = 11.sp, fontWeight = FontWeight.Bold, color = colors.textMuted, + letterSpacing = 0.5.sp, modifier = Modifier.padding(bottom = 6.dp)) +} + +@Composable +fun ClearButton(onClick: () -> Unit) { + val colors = ToolkitTheme.colors + TextButton(onClick = onClick, modifier = Modifier.fillMaxWidth()) { + Text("Clear", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.textMuted) + } +} + +@Composable +fun ResultCard(content: @Composable ColumnScope.() -> Unit) { + val colors = ToolkitTheme.colors + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(10.dp), + colors = CardDefaults.cardColors(containerColor = colors.resultBg), + border = CardDefaults.outlinedCardBorder().let { + androidx.compose.foundation.BorderStroke(1.dp, colors.resultBorder) + } + ) { + Column(modifier = Modifier.padding(20.dp), content = content) + } +} + +@Composable +fun ResultRow(label: String, value: String, unit: String? = null) { + val colors = ToolkitTheme.colors + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text(label, fontSize = 14.sp, fontWeight = FontWeight.Medium, color = colors.textSecondary) + Row(verticalAlignment = Alignment.Bottom) { + Text(value, fontSize = 20.sp, fontWeight = FontWeight.Bold, color = colors.accent, + fontFamily = FontFamily.Monospace) + if (unit != null) { + Spacer(Modifier.width(4.dp)) + Text(unit, fontSize = 12.sp, color = colors.textMuted) + } + } + } +} + +@Composable +fun ResultRowColored(label: String, value: String, color: Color) { + val colors = ToolkitTheme.colors + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text(label, fontSize = 14.sp, fontWeight = FontWeight.Medium, color = if (color == colors.danger) color else colors.textSecondary) + Text(value, fontSize = 20.sp, fontWeight = FontWeight.Bold, color = color, fontFamily = FontFamily.Monospace) + } +} + +@Composable +fun ResultDivider() { + val colors = ToolkitTheme.colors + HorizontalDivider(modifier = Modifier.padding(vertical = 10.dp), color = colors.border) +} + +@Composable +fun SegmentedSelector(options: List, selected: Int, onSelect: (Int) -> Unit) { + val colors = ToolkitTheme.colors + Row(Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).border(1.dp, colors.border, RoundedCornerShape(8.dp))) { + options.forEachIndexed { i, label -> + val isActive = i == selected + Box( + modifier = Modifier.weight(1f) + .background(if (isActive) colors.accent.copy(alpha = 0.12f) else Color.Transparent) + .clickable { onSelect(i) } + .padding(vertical = 10.dp), + contentAlignment = Alignment.Center + ) { + Text(label, fontSize = 13.sp, + fontWeight = if (isActive) FontWeight.Bold else FontWeight.Medium, + color = if (isActive) colors.accent else colors.textMuted) + } + } + } +} + +@Composable +fun WarningBanner(warning: WeightWarning) { + val colors = ToolkitTheme.colors + val bg = if (warning.level == WarningLevel.DANGER) colors.dangerBg else colors.warningBg + val border = if (warning.level == WarningLevel.DANGER) colors.dangerBorder else colors.warningBorder + val fg = if (warning.level == WarningLevel.DANGER) colors.danger else colors.warning + val icon = if (warning.level == WarningLevel.DANGER) "⛔" else "⚠" + Box(modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)) + .background(bg).border(1.dp, border, RoundedCornerShape(8.dp)).padding(12.dp)) { + Text("$icon ${warning.message}", fontSize = 13.sp, fontWeight = FontWeight.Medium, color = fg) + } +} + +@Composable +fun UnavailableMessage(message: String) { + val colors = ToolkitTheme.colors + Box(modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)) + .background(colors.surfaceAlt).border(1.dp, colors.border, RoundedCornerShape(8.dp)).padding(16.dp), + contentAlignment = Alignment.Center) { + Text(message, fontSize = 13.sp, color = colors.textMuted, textAlign = TextAlign.Center, lineHeight = 20.sp) + } +} + +@Composable +fun ToggleSwitch( + checked: Boolean, onCheckedChange: (Boolean) -> Unit, + labelLeft: String, labelRight: String +) { + val colors = ToolkitTheme.colors + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(labelLeft, fontSize = 12.sp, fontWeight = if (!checked) FontWeight.Bold else FontWeight.Normal, + color = if (!checked) colors.accent else colors.textMuted) + Switch( + checked = checked, onCheckedChange = onCheckedChange, + colors = SwitchDefaults.colors( + checkedThumbColor = colors.accent, + checkedTrackColor = colors.accent.copy(alpha = 0.25f), + uncheckedThumbColor = colors.accent, + uncheckedTrackColor = colors.accent.copy(alpha = 0.25f), + ), + modifier = Modifier.height(24.dp) + ) + Text(labelRight, fontSize = 12.sp, fontWeight = if (checked) FontWeight.Bold else FontWeight.Normal, + color = if (checked) colors.accent else colors.textMuted) + } +} diff --git a/app/src/main/java/com/harshmallow/pilottoolkit/ui/modules/CrewRestModule.kt b/app/src/main/java/com/harshmallow/pilottoolkit/ui/modules/CrewRestModule.kt new file mode 100644 index 0000000..0292dbd --- /dev/null +++ b/app/src/main/java/com/harshmallow/pilottoolkit/ui/modules/CrewRestModule.kt @@ -0,0 +1,318 @@ +package com.harshmallow.pilottoolkit.ui.modules + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.harshmallow.pilottoolkit.ui.components.* +import com.harshmallow.pilottoolkit.ui.theme.ToolkitTheme +import kotlin.math.floor + +@Composable +fun CrewRestModule() { + val colors = ToolkitTheme.colors + + var deptInput by rememberSaveable { mutableStateOf("") } + var eteInput by rememberSaveable { mutableStateOf("") } + var crew1 by rememberSaveable { mutableStateOf("") } + var crew2 by rememberSaveable { mutableStateOf("") } + var crew3 by rememberSaveable { mutableStateOf("") } + var crew4 by rememberSaveable { mutableStateOf("") } + var locked by rememberSaveable { mutableStateOf(false) } + + val HANDS_ON = 30 // minutes all crew on deck after departure and before arrival + + // Parse "HHMM" or "HH:MM" into total minutes — departure (0–23 hours) + fun parseTime(str: String): Int? { + val clean = str.replace(Regex("[^0-9]"), "") + if (clean.length !in 3..4) return null + val padded = clean.padStart(4, '0') + val h = padded.substring(0, 2).toIntOrNull() ?: return null + val m = padded.substring(2, 4).toIntOrNull() ?: return null + if (h > 23 || m > 59) return null + return h * 60 + m + } + + // Parse ETE — supports HH:MM (with colon), HHMM (4 digits), or total minutes (1-3 digits) + fun parseEte(str: String): Int? { + val hasColon = str.contains(":") + val clean = str.replace(Regex("[^0-9]"), "") + if (clean.isEmpty()) return null + if (hasColon) { + val parts = str.split(":") + if (parts.size != 2) return null + val h = parts[0].toIntOrNull() ?: return null + val m = parts[1].toIntOrNull() ?: return null + if (m > 59) return null + return h * 60 + m + } + if (clean.length <= 3) { + val mins = clean.toIntOrNull() ?: return null + return if (mins > 0) mins else null + } + if (clean.length == 4) { + val h = clean.substring(0, 2).toIntOrNull() ?: return null + val m = clean.substring(2, 4).toIntOrNull() ?: return null + if (m > 59) return null + return h * 60 + m + } + return null + } + + // Format minutes to "HH:MMz" (wraps at 24h) + fun fmtZulu(mins: Int): String { + val wrapped = ((mins % 1440) + 1440) % 1440 + val h = wrapped / 60 + val m = wrapped % 60 + return "%02d:%02dz".format(h, m) + } + + // Format duration in minutes to "Xh Ym (N min)" + fun fmtDuration(mins: Int): String { + val h = mins / 60 + val m = mins % 60 + val hm = when { + h == 0 -> "$m min" + m == 0 -> "${h}h" + else -> "${h}h ${m}m" + } + return if (h > 0) "$hm ($mins min)" else hm + } + + // Short duration format without total minutes + fun fmtDurationShort(mins: Int): String { + val h = mins / 60 + val m = mins % 60 + return when { + h == 0 -> "$m min" + m == 0 -> "${h}h" + else -> "${h}h ${m}m" + } + } + + // Auto-format on focus loss — departure always formats, ETE only formats 4-digit HHMM + fun formatDeptOnBlur(value: String): String { + val clean = value.replace(Regex("[^0-9]"), "") + return if (clean.length in 3..4) { + val padded = clean.padStart(4, '0') + padded.substring(0, 2) + ":" + padded.substring(2) + } else value + } + + fun formatEteOnBlur(value: String): String { + val clean = value.replace(Regex("[^0-9]"), "") + return if (!value.contains(":") && clean.length == 4) { + clean.substring(0, 2) + ":" + clean.substring(2) + } else value + } + + fun handleTimeInput(raw: String): String { + return raw.replace(Regex("[^0-9:]"), "").take(5) + } + + val activeCrew = listOf(crew1, crew2, crew3, crew4).filter { it.trim().isNotEmpty() } + val crewCount = activeCrew.size + val deptMinutes = parseTime(deptInput) + val eteMinutes = parseEte(eteInput) + val restWindow = if (eteMinutes != null) eteMinutes - (HANDS_ON * 2) else null + val canCalculate = deptMinutes != null && eteMinutes != null && crewCount >= 3 && (restWindow ?: 0) > 0 + + data class RestSlot(val id: String, val restMinutes: Int, val begins: Int, val ends: Int) + + val results: List? = if (canCalculate && deptMinutes != null && restWindow != null) { + val perPerson = floor(restWindow.toDouble() / crewCount).toInt() + val restStart = deptMinutes + HANDS_ON + activeCrew.mapIndexed { i, id -> + RestSlot(id, perPerson, restStart + (perPerson * i), restStart + (perPerson * (i + 1))) + } + } else null + + Column(verticalArrangement = Arrangement.spacedBy(18.dp)) { + // Departure and ETE side by side + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Column(Modifier.weight(1f)) { + FieldLabel("Departure (Zulu)") + OutlinedTextField( + value = deptInput, + onValueChange = { deptInput = handleTimeInput(it) }, + placeholder = { Text("e.g. 14:30", fontSize = 14.sp, color = colors.textMuted) }, + modifier = Modifier.fillMaxWidth() + .onFocusChanged { if (!it.isFocused) deptInput = formatDeptOnBlur(deptInput) }, + singleLine = true, shape = RoundedCornerShape(8.dp), + readOnly = locked, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = colors.accent, unfocusedBorderColor = colors.border, + focusedTextColor = colors.text, unfocusedTextColor = colors.text, + cursorColor = colors.accent, + focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent, + ) + ) + } + Column(Modifier.weight(1f)) { + FieldLabel("ETE") + OutlinedTextField( + value = eteInput, + onValueChange = { eteInput = handleTimeInput(it) }, + placeholder = { Text("e.g. 10:30 or 630", fontSize = 14.sp, color = colors.textMuted) }, + modifier = Modifier.fillMaxWidth() + .onFocusChanged { if (!it.isFocused) eteInput = formatEteOnBlur(eteInput) }, + singleLine = true, shape = RoundedCornerShape(8.dp), + readOnly = locked, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = colors.accent, unfocusedBorderColor = colors.border, + focusedTextColor = colors.text, unfocusedTextColor = colors.text, + cursorColor = colors.accent, + focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent, + ) + ) + } + } + + // Crew members header + Text("CREW MEMBERS", fontSize = 11.sp, fontWeight = FontWeight.Bold, + color = colors.textMuted, letterSpacing = 0.5.sp) + + // Crew fields in 2x2 grid + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Column(Modifier.weight(1f)) { + ToolkitTextField(value = crew1, onValueChange = { if (!locked) crew1 = it }, + label = "Crew 1", placeholder = "ID or name") + } + Column(Modifier.weight(1f)) { + ToolkitTextField(value = crew2, onValueChange = { if (!locked) crew2 = it }, + label = "Crew 2", placeholder = "ID or name") + } + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Column(Modifier.weight(1f)) { + ToolkitTextField(value = crew3, onValueChange = { if (!locked) crew3 = it }, + label = "Crew 3", placeholder = "ID or name") + } + Column(Modifier.weight(1f)) { + ToolkitTextField(value = crew4, onValueChange = { if (!locked) crew4 = it }, + label = "Crew 4", placeholder = "optional", sublabel = "optional") + } + } + + // Lock toggle + Clear button + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically) { + // Lock toggle + Box( + modifier = Modifier.clip(RoundedCornerShape(8.dp)) + .background(if (locked) colors.accent.copy(alpha = 0.15f) else colors.surfaceAlt) + .clickable { locked = !locked } + .padding(horizontal = 14.dp, vertical = 10.dp) + ) { + Text( + if (locked) "\uD83D\uDD12 Locked" else "\uD83D\uDD13 Unlocked", + fontSize = 13.sp, fontWeight = FontWeight.SemiBold, + color = if (locked) colors.accent else colors.textMuted + ) + } + if (!locked) { + ClearButton(onClick = { + deptInput = ""; eteInput = "" + crew1 = ""; crew2 = ""; crew3 = ""; crew4 = "" + }) + } + } + + // Warning: ETE too short + if (eteMinutes != null && (restWindow ?: 0) <= 0 && crewCount >= 3) { + Box( + Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)) + .background(colors.warningBg) + .border(1.dp, colors.warningBorder, RoundedCornerShape(8.dp)) + .padding(12.dp) + ) { + Text("ETE of ${fmtDurationShort(eteMinutes)} is too short for rest periods. Minimum ETE is 1h 01m (60 minutes all-hands + at least 1 minute rest).", + fontSize = 13.sp, fontWeight = FontWeight.Medium, color = colors.warning, lineHeight = 20.sp) + } + } + + // Warning: not enough crew + if (deptMinutes != null && eteMinutes != null && crewCount < 3) { + Box( + Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)) + .background(colors.warningBg) + .border(1.dp, colors.warningBorder, RoundedCornerShape(8.dp)) + .padding(12.dp) + ) { + Text("Enter at least 3 crew members for augmented rest calculation.", + fontSize = 13.sp, fontWeight = FontWeight.Medium, color = colors.warning, lineHeight = 20.sp) + } + } + + // Results + if (results != null && deptMinutes != null && eteMinutes != null && restWindow != null) { + ResultCard { + // Flight summary + Text("FLIGHT SUMMARY", fontSize = 11.sp, fontWeight = FontWeight.Bold, + color = colors.textMuted, letterSpacing = 0.5.sp) + Spacer(Modifier.height(8.dp)) + + Text("Departure: ${fmtZulu(deptMinutes)} ETE: ${fmtDuration(eteMinutes)} Arrival: ${fmtZulu(deptMinutes + eteMinutes)}", + fontSize = 13.sp, color = colors.textSecondary, fontFamily = FontFamily.Monospace) + Spacer(Modifier.height(4.dp)) + + Text("All hands: ${fmtZulu(deptMinutes)}–${fmtZulu(deptMinutes + HANDS_ON)} & ${fmtZulu(deptMinutes + eteMinutes - HANDS_ON)}–${fmtZulu(deptMinutes + eteMinutes)}", + fontSize = 13.sp, color = colors.textSecondary) + Spacer(Modifier.height(4.dp)) + + Row { + Text("Rest window: ", fontSize = 13.sp, color = colors.textSecondary) + Text(fmtDuration(restWindow), fontSize = 13.sp, fontWeight = FontWeight.Bold, color = colors.text) + Text(" Per crew: ", fontSize = 13.sp, color = colors.textSecondary) + Text(fmtDuration(floor(restWindow.toDouble() / crewCount).toInt()), + fontSize = 13.sp, fontWeight = FontWeight.Bold, color = colors.text) + } + + Spacer(Modifier.height(12.dp)) + HorizontalDivider(color = colors.border) + Spacer(Modifier.height(12.dp)) + + Text("REST SCHEDULE", fontSize = 11.sp, fontWeight = FontWeight.Bold, + color = colors.textMuted, letterSpacing = 0.5.sp) + Spacer(Modifier.height(12.dp)) + + results.forEachIndexed { i, slot -> + Row( + Modifier.fillMaxWidth().padding(vertical = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(slot.id, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.text) + Column(horizontalAlignment = Alignment.End) { + Text("${fmtZulu(slot.begins)} – ${fmtZulu(slot.ends)}", + fontSize = 14.sp, fontWeight = FontWeight.SemiBold, + color = colors.accent, fontFamily = FontFamily.Monospace) + Text("(${fmtDurationShort(slot.restMinutes)})", + fontSize = 12.sp, color = colors.textMuted) + } + } + if (i < results.size - 1) { + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp), color = colors.border) + } + } + } + } + } +} diff --git a/app/src/main/java/com/harshmallow/pilottoolkit/ui/modules/CrosswindModule.kt b/app/src/main/java/com/harshmallow/pilottoolkit/ui/modules/CrosswindModule.kt new file mode 100644 index 0000000..a88cab7 --- /dev/null +++ b/app/src/main/java/com/harshmallow/pilottoolkit/ui/modules/CrosswindModule.kt @@ -0,0 +1,130 @@ +package com.harshmallow.pilottoolkit.ui.modules + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.harshmallow.pilottoolkit.ui.components.* +import com.harshmallow.pilottoolkit.ui.theme.ToolkitTheme +import kotlin.math.* + +@Composable +fun CrosswindModule() { + val colors = ToolkitTheme.colors + var runwayInput by remember { mutableStateOf("") } + var windInput by remember { mutableStateOf("") } + var gustInput by remember { mutableStateOf("") } + + // Wind input: accept raw digits while typing, format on focus loss + fun handleWindInput(raw: String) { + // Strip non-digits and slash, cap at 5 digits + windInput = raw.replace(Regex("[^0-9]"), "").take(5) + } + + fun formatWindInput() { + val digits = windInput.replace(Regex("[^0-9]"), "") + windInput = if (digits.length > 3) { + digits.substring(0, 3) + "/" + digits.substring(3) + } else { + digits + } + } + + // Parse direction and speed from "xxxxx" or "xxx/xx" + fun parseWind(): Pair? { + val clean = windInput.replace("/", "") + if (clean.length >= 4) { + val dir = clean.substring(0, 3).toDoubleOrNull() + val spd = clean.substring(3).toDoubleOrNull() + if (dir != null && spd != null) return Pair(dir, spd) + } + return null + } + + val rwyRaw = runwayInput.toDoubleOrNull() + val wind = parseWind() + val gustSpd = gustInput.toDoubleOrNull() + + data class XWindResult( + val crosswind: Int, val headwind: Int, val isTailwind: Boolean, + val gustCrosswind: Int? = null, val gustHeadwind: Int? = null, val gustIsTailwind: Boolean = false + ) + + val results: XWindResult? = if (rwyRaw != null && wind != null) { + val (windDir, windSpd) = wind + val rwyHeading = if (rwyRaw <= 36) rwyRaw * 10 else rwyRaw + val angleRad = ((windDir - rwyHeading) * PI) / 180.0 + val xw = abs(windSpd * sin(angleRad)) + val hw = windSpd * cos(angleRad) + + var gxw: Int? = null; var ghw: Int? = null; var gtw = false + if (gustSpd != null && gustSpd > windSpd) { + gxw = abs(gustSpd * sin(angleRad)).roundToInt() + ghw = (gustSpd * cos(angleRad)).roundToInt() + gtw = (gustSpd * cos(angleRad)) < 0 + } + + XWindResult(xw.roundToInt(), hw.roundToInt(), hw < 0, gxw, ghw, gtw) + } else null + + Column(verticalArrangement = Arrangement.spacedBy(18.dp)) { + ToolkitTextField(value = runwayInput, + onValueChange = { runwayInput = it.replace(Regex("^0+(?=\\d)"), "") }, + label = "Runway (01–36 or heading)", placeholder = "e.g. 27 or 270", + keyboardType = KeyboardType.Number) + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Column(Modifier.weight(1f)) { + FieldLabel("Wind (dir/spd)") + OutlinedTextField( + value = windInput, + onValueChange = { handleWindInput(it) }, + placeholder = { Text("e.g. 28025", fontSize = 14.sp, color = colors.textMuted) }, + modifier = Modifier.fillMaxWidth() + .onFocusChanged { if (!it.isFocused) formatWindInput() }, + singleLine = true, shape = RoundedCornerShape(8.dp), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = colors.accent, unfocusedBorderColor = colors.border, + focusedTextColor = colors.text, unfocusedTextColor = colors.text, + cursorColor = colors.accent, + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + ) + ) + } + Column(Modifier.weight(1f)) { + ToolkitTextField(value = gustInput, onValueChange = { gustInput = it }, + label = "Gust (kts)", placeholder = "e.g. 25", + keyboardType = KeyboardType.Number, sublabel = "optional") + } + } + + ClearButton(onClick = { runwayInput = ""; windInput = ""; gustInput = "" }) + + if (results != null) { + ResultCard { + val hwLabel = if (results.isTailwind) "Tailwind" else "Headwind" + val hwColor = if (results.isTailwind) colors.danger else colors.text + ResultRow(label = "Crosswind", value = "${results.crosswind} kts") + ResultRowColored(label = hwLabel, value = "${abs(results.headwind)} kts", color = hwColor) + + if (results.gustCrosswind != null && results.gustHeadwind != null) { + ResultDivider() + val gLabel = if (results.gustIsTailwind) "Gust Tailwind" else "Gust Headwind" + val gColor = if (results.gustIsTailwind) colors.danger else colors.text + ResultRow(label = "Gust Crosswind", value = "${results.gustCrosswind} kts") + ResultRowColored(label = gLabel, value = "${abs(results.gustHeadwind)} kts", color = gColor) + } + } + } + } +} diff --git a/app/src/main/java/com/harshmallow/pilottoolkit/ui/modules/FlightLevelModule.kt b/app/src/main/java/com/harshmallow/pilottoolkit/ui/modules/FlightLevelModule.kt new file mode 100644 index 0000000..e856d55 --- /dev/null +++ b/app/src/main/java/com/harshmallow/pilottoolkit/ui/modules/FlightLevelModule.kt @@ -0,0 +1,346 @@ +package com.harshmallow.pilottoolkit.ui.modules + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.BorderStroke +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.harshmallow.pilottoolkit.data.* +import com.harshmallow.pilottoolkit.ui.components.* +import com.harshmallow.pilottoolkit.ui.theme.ToolkitTheme + +private val eastColor = Color(0xFF4DABF7) +private val westColor = Color(0xFFF59F00) + +@Composable +fun FlightLevelModule() { + val colors = ToolkitTheme.colors + var mode by remember { mutableStateOf("flas") } + var searchInput by remember { mutableStateOf("") } + var searchUnit by remember { mutableStateOf("meters") } + + Column { + // Mode selector + Row( + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + ) { + listOf("flas" to "China FLAS", "lookup" to "Meters ↔ Feet").forEach { (key, label) -> + val active = mode == key + Box( + Modifier + .weight(1f) + .background(if (active) colors.accent else colors.surfaceAlt) + .clickable { mode = key } + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + Text(label, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, + color = if (active) Color.White else colors.textSecondary) + } + } + } + + Spacer(Modifier.height(16.dp)) + + if (mode == "lookup") { + LookupMode(searchInput, { searchInput = it }, searchUnit, { searchUnit = it }) + } else { + FLASMode() + } + } +} + +@Composable +private fun LookupMode( + searchInput: String, onSearchChange: (String) -> Unit, + searchUnit: String, onUnitChange: (String) -> Unit +) { + val colors = ToolkitTheme.colors + val searchVal = searchInput.trim().toIntOrNull() + val filtered = remember(searchInput, searchUnit) { + if (searchVal == null || searchVal < 0) metersToFeetTable + else metersToFeetTable.filter { + val target = if (searchUnit == "meters") it.meters else it.feet + target.toString().startsWith(searchInput.trim()) + } + } + val exactMatch = remember(searchVal, searchUnit) { + if (searchVal == null) null + else metersToFeetTable.find { + if (searchUnit == "meters") it.meters == searchVal else it.feet == searchVal + } + } + + // Search bar + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = searchInput, onValueChange = onSearchChange, + placeholder = { + Text( + if (searchUnit == "meters") "Type meters..." else "Type feet...", + fontSize = 14.sp, color = colors.textMuted + ) + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f), + singleLine = true, + textStyle = LocalTextStyle.current.copy(fontFamily = FontFamily.Monospace, fontSize = 16.sp) + ) + Row(Modifier.clip(RoundedCornerShape(6.dp))) { + listOf("meters" to "m", "feet" to "ft").forEach { (key, label) -> + val active = searchUnit == key + Box( + Modifier + .background(if (active) colors.accent else colors.surfaceAlt) + .clickable { onUnitChange(key); onSearchChange("") } + .padding(horizontal = 14.dp, vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + Text(label, fontSize = 12.sp, fontWeight = FontWeight.Bold, + color = if (active) Color.White else colors.textSecondary) + } + } + } + } + + // Exact match callout + if (exactMatch != null) { + Spacer(Modifier.height(12.dp)) + ResultCard { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("${"%,d".format(exactMatch.meters)} m", fontSize = 14.sp, color = colors.textSecondary) + Text("${"%,d".format(exactMatch.feet)} ft", fontSize = 24.sp, fontWeight = FontWeight.Bold, + color = colors.accent, fontFamily = FontFamily.Monospace) + } + } + } + + Spacer(Modifier.height(12.dp)) + + // Table + Card( + modifier = Modifier.fillMaxWidth().height(350.dp), + shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors(containerColor = colors.surface), + border = BorderStroke(1.dp, colors.border) + ) { + LazyColumn { + item { + Row( + Modifier.fillMaxWidth().background(colors.surfaceAlt) + .padding(horizontal = 16.dp, vertical = 10.dp) + ) { + Text("METERS", Modifier.weight(1f), fontSize = 11.sp, fontWeight = FontWeight.Bold, + color = colors.textMuted, letterSpacing = 0.5.sp) + Text("FEET", fontSize = 11.sp, fontWeight = FontWeight.Bold, + color = colors.textMuted, letterSpacing = 0.5.sp) + } + HorizontalDivider(color = colors.border, thickness = 2.dp) + } + items(filtered) { entry -> + val isExact = exactMatch != null && entry.meters == exactMatch.meters + Row( + Modifier.fillMaxWidth() + .background(if (isExact) colors.accent.copy(alpha = 0.1f) else Color.Transparent) + .padding(horizontal = 16.dp, vertical = 6.dp) + ) { + Text( + "%,d".format(entry.meters), Modifier.weight(1f), fontSize = 13.sp, + fontFamily = FontFamily.Monospace, + fontWeight = if (isExact) FontWeight.Bold else FontWeight.Normal, + color = if (isExact) colors.accent else colors.text + ) + Text( + "%,d".format(entry.feet), fontSize = 13.sp, + fontFamily = FontFamily.Monospace, + fontWeight = if (isExact) FontWeight.Bold else FontWeight.Normal, + color = if (isExact) colors.accent else colors.text + ) + } + HorizontalDivider(color = colors.border) + } + } + } + + Spacer(Modifier.height(8.dp)) + Text( + "GVIII-G700 Operating Manual Table 15. Values rounded to nearest 100 ft.\nDo not use for approach minima.", + fontSize = 11.sp, color = colors.textMuted, lineHeight = 16.sp + ) +} + +@Composable +private fun FLASMode() { + val colors = ToolkitTheme.colors + var eastSelected by remember { mutableStateOf(true) } + var westSelected by remember { mutableStateOf(true) } + + // If user deselects both, treat as both selected + val showEast = eastSelected || !westSelected + val showWest = westSelected || !eastSelected + + val filtered = remember(showEast, showWest) { + if (showEast && showWest) chinaFLAS + else chinaFLAS.filter { entry -> + val isEast = entry.feet in chinaRVSMEastbound + val isWest = entry.feet in chinaRVSMWestbound + when { + isEast -> showEast + isWest -> showWest + else -> true // non-RVSM levels always shown + } + } + } + + // Direction toggle buttons + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Eastbound button + Box( + Modifier + .weight(1f) + .clip(RoundedCornerShape(6.dp)) + .background(if (eastSelected) eastColor.copy(alpha = 0.15f) else colors.surfaceAlt) + .clickable { + eastSelected = !eastSelected + // If turning off would leave both off, turn on the other + if (!eastSelected && !westSelected) westSelected = true + } + .padding(vertical = 10.dp), + contentAlignment = Alignment.Center + ) { + Text( + "Eastbound 0°–179°", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, + color = if (eastSelected) eastColor else colors.textMuted + ) + } + // Westbound button + Box( + Modifier + .weight(1f) + .clip(RoundedCornerShape(6.dp)) + .background(if (westSelected) westColor.copy(alpha = 0.15f) else colors.surfaceAlt) + .clickable { + westSelected = !westSelected + if (!eastSelected && !westSelected) eastSelected = true + } + .padding(vertical = 10.dp), + contentAlignment = Alignment.Center + ) { + Text( + "Westbound 186°–359°", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, + color = if (westSelected) westColor else colors.textMuted + ) + } + } + + Spacer(Modifier.height(12.dp)) + + // FLAS Table + Card( + modifier = Modifier.fillMaxWidth().height(400.dp), + shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors(containerColor = colors.surface), + border = BorderStroke(1.dp, colors.border) + ) { + LazyColumn { + item { + Row( + Modifier.fillMaxWidth().background(colors.surfaceAlt) + .padding(horizontal = 16.dp, vertical = 10.dp) + ) { + Text("CLEARED (m)", Modifier.weight(1f), fontSize = 11.sp, fontWeight = FontWeight.Bold, + color = colors.textMuted, letterSpacing = 0.5.sp) + Text("SET (ft)", Modifier.weight(1f), fontSize = 11.sp, fontWeight = FontWeight.Bold, + color = colors.textMuted, letterSpacing = 0.5.sp) + Text("RVSM", fontSize = 11.sp, fontWeight = FontWeight.Bold, + color = colors.textMuted, letterSpacing = 0.5.sp) + } + HorizontalDivider(color = colors.border, thickness = 2.dp) + } + items(filtered) { entry -> + val isEast = entry.feet in chinaRVSMEastbound + val isWest = entry.feet in chinaRVSMWestbound + val isRVSM = isEast || isWest + val rowColor = when { + isEast -> eastColor + isWest -> westColor + else -> colors.text + } + val rowBg = when { + isEast -> eastColor.copy(alpha = 0.08f) + isWest -> westColor.copy(alpha = 0.08f) + else -> Color.Transparent + } + Row( + Modifier.fillMaxWidth() + .background(rowBg) + .padding(horizontal = 16.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "%,d".format(entry.meters), Modifier.weight(1f), fontSize = 13.sp, + fontFamily = FontFamily.Monospace, + fontWeight = if (isRVSM) FontWeight.Bold else FontWeight.Normal, + color = rowColor + ) + Text( + "%,d".format(entry.feet), Modifier.weight(1f), fontSize = 13.sp, + fontFamily = FontFamily.Monospace, + fontWeight = if (isRVSM) FontWeight.Bold else FontWeight.Normal, + color = rowColor + ) + Text( + when { + isEast -> "E" + isWest -> "W" + else -> "" + }, + fontSize = 11.sp, fontWeight = FontWeight.Bold, color = rowColor + ) + } + HorizontalDivider(color = colors.border) + } + } + } + + Spacer(Modifier.height(8.dp)) + + // Legend + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Text("● E Eastbound 0°–179°", fontSize = 11.sp, color = eastColor, fontWeight = FontWeight.SemiBold) + Text("● W Westbound 186°–359°", fontSize = 11.sp, color = westColor, fontWeight = FontWeight.SemiBold) + } + Spacer(Modifier.height(4.dp)) + Text( + "ATC clears flight level in meters. Set altimeter to corresponding feet value.\n" + + "Onboard metric readout may differ from cleared value by up to 30 m due to rounding.\n" + + "Do not use for approach minima.", + fontSize = 11.sp, color = colors.textMuted, lineHeight = 16.sp + ) +} diff --git a/app/src/main/java/com/harshmallow/pilottoolkit/ui/modules/FuelBucketsModule.kt b/app/src/main/java/com/harshmallow/pilottoolkit/ui/modules/FuelBucketsModule.kt new file mode 100644 index 0000000..cfa0727 --- /dev/null +++ b/app/src/main/java/com/harshmallow/pilottoolkit/ui/modules/FuelBucketsModule.kt @@ -0,0 +1,57 @@ +package com.harshmallow.pilottoolkit.ui.modules + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.harshmallow.pilottoolkit.data.Aircraft +import com.harshmallow.pilottoolkit.data.FuelBucket +import com.harshmallow.pilottoolkit.data.FuelProfile +import com.harshmallow.pilottoolkit.ui.components.* +import com.harshmallow.pilottoolkit.ui.theme.ToolkitTheme + +@Composable +fun FuelBucketsModule(aircraft: Aircraft, fuelProfile: FuelProfile? = null) { + val colors = ToolkitTheme.colors + val buckets = fuelProfile?.buckets ?: aircraft.fuelBuckets + val isCustom = fuelProfile != null + + if (buckets == null || buckets.isEmpty()) { + UnavailableMessage("${aircraft.label} fuel bucket data is not yet available.\nImport a CSV or fuel profile via the menu.") + return + } + + var selectedIndex by remember(aircraft.id, isCustom) { mutableIntStateOf(0) } + + val bucket = buckets[selectedIndex] + val maxIndex = buckets.size - 1 + + Column(verticalArrangement = Arrangement.spacedBy(18.dp)) { + FieldLabel("Stage Length (hrs)") + Slider( + value = selectedIndex.toFloat(), + onValueChange = { selectedIndex = it.toInt() }, + valueRange = 0f..maxIndex.toFloat(), + steps = maxIndex - 1, + colors = SliderDefaults.colors( + thumbColor = colors.accent, + activeTrackColor = colors.accent, + inactiveTrackColor = colors.border + ) + ) + + ResultCard { + ResultRow(label = "Flight Time", value = "${"%.1f".format(bucket.stageLength)} hr") + ResultRow(label = "Fuel Burn", value = "${"%.1f".format(bucket.fuelBurn)} lbs/hr") + } + + Text( + if (isCustom) "Using \"${fuelProfile!!.name}\" profile. Verify against current company fuel schedules." + else "Using built-in data. Verify against current company fuel schedules.", + fontSize = 12.sp, color = colors.textMuted, lineHeight = 16.sp + ) + } +} diff --git a/app/src/main/java/com/harshmallow/pilottoolkit/ui/modules/FuelOrderModule.kt b/app/src/main/java/com/harshmallow/pilottoolkit/ui/modules/FuelOrderModule.kt new file mode 100644 index 0000000..b6e9770 --- /dev/null +++ b/app/src/main/java/com/harshmallow/pilottoolkit/ui/modules/FuelOrderModule.kt @@ -0,0 +1,292 @@ +package com.harshmallow.pilottoolkit.ui.modules + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.harshmallow.pilottoolkit.data.Calculations +import com.harshmallow.pilottoolkit.ui.components.* +import com.harshmallow.pilottoolkit.ui.theme.ToolkitTheme + +@Composable +fun FuelOrderModule(volumeUnit: String = "usgal") { + val colors = ToolkitTheme.colors + var tempInput by remember { mutableStateOf("") } + var totalInput by remember { mutableStateOf("") } + var onBoardInput by remember { mutableStateOf("") } + var fuelInput by remember { mutableStateOf("") } + var fuelManual by remember { mutableStateOf(false) } + var useFahrenheit by remember { mutableStateOf(false) } + var densityInput by remember { mutableStateOf("6.76") } + var densityUnit by remember { mutableStateOf("lbgal") } // "sg" | "kgl" | "lbgal" + + // Auto-calculate Fuel Order from Total - OnBoard (unless user typed directly) + LaunchedEffect(totalInput, onBoardInput, fuelManual) { + if (fuelManual) return@LaunchedEffect + val total = totalInput.toDoubleOrNull() + val onBoard = onBoardInput.toDoubleOrNull() + if (total != null && onBoard != null) { + val diff = total - onBoard + fuelInput = if (diff > 0) diff.toLong().toString() else "0" + } + } + + fun handleTempUnitToggle(toFahrenheit: Boolean) { + val v = tempInput.toDoubleOrNull() + if (v != null) { + tempInput = if (toFahrenheit) { + Math.round(v * 1.8 + 32.0).toString() + } else { + Math.round((v - 32.0) / 1.8).toString() + } + } + useFahrenheit = toFahrenheit + } + + val tempVal = tempInput.toDoubleOrNull() + val fuelVal = fuelInput.toDoubleOrNull() + val densityVal = densityInput.toDoubleOrNull() + val tempC = if (tempVal != null && useFahrenheit) Calculations.fahrenheitToCelsius(tempVal) else tempVal + val sg15 = if (densityVal != null) { + if (densityUnit == "lbgal") densityVal / Calculations.LB_PER_GAL_PER_KGL else densityVal + } else null + val isAutoCalc = !fuelManual && totalInput.isNotEmpty() && onBoardInput.isNotEmpty() + + // Warnings + data class FuelWarning(val level: String, val msg: String) + val warning: FuelWarning? = when { + tempC != null && tempC < -40 -> FuelWarning("danger", "Temperature is below −40°C. Approaching Jet-A freeze point.") + tempC != null && tempC > 55 -> FuelWarning("warning", "Temperature is above +55°C. Verify conditions.") + sg15 != null && sg15 < 0.775 -> FuelWarning("warning", "Density ${"%.3f".format(sg15)} SG is below Jet-A minimum spec (0.775).") + sg15 != null && sg15 > 0.840 -> FuelWarning("warning", "Density ${"%.3f".format(sg15)} SG is above Jet-A maximum spec (0.840).") + else -> null + } + + // Results + data class FuelResults(val usGal: Int, val impGal: Int, val liters: Int, + val refDensityLbGal: String, val correctedLbGal: String, val tempCDisplay: Int) + val results: FuelResults? = if (tempC != null && fuelVal != null && fuelVal > 0 && sg15 != null) { + val corrected = Calculations.astmDensityLbGal(sg15, tempC) + val usGal = fuelVal / corrected + val liters = usGal * 3.78541 + val impGal = liters / 4.54609 + FuelResults( + Calculations.roundUpToTen(usGal), + Calculations.roundUpToTen(impGal), + Calculations.roundUpToTen(liters), + "%.3f".format(sg15 * Calculations.LB_PER_GAL_PER_KGL), + "%.3f".format(corrected), + tempC.toInt() + ) + } else null + + // Unit conversion when switching density toggle + fun handleDensityUnitChange(newUnit: String) { + val v = densityInput.toDoubleOrNull() + if (v != null) { + val sg = if (densityUnit == "lbgal") v / Calculations.LB_PER_GAL_PER_KGL else v + val converted = if (newUnit == "lbgal") sg * Calculations.LB_PER_GAL_PER_KGL else sg + densityInput = if (newUnit == "lbgal") "%.2f".format(converted) else "%.3f".format(converted) + } + densityUnit = newUnit + } + + Column(verticalArrangement = Arrangement.spacedBy(18.dp)) { + // Temperature + Column { + FieldLabel("Ambient Temperature (${if (useFahrenheit) "°F" else "°C"})") + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = tempInput, onValueChange = { tempInput = it }, + placeholder = { Text(if (useFahrenheit) "e.g. 59" else "e.g. 15", fontSize = 14.sp, color = colors.textMuted) }, + modifier = Modifier.weight(2f), singleLine = true, shape = RoundedCornerShape(8.dp), + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Number), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = colors.accent, unfocusedBorderColor = colors.border, + focusedTextColor = colors.text, unfocusedTextColor = colors.text, + cursorColor = colors.accent, + focusedContainerColor = androidx.compose.ui.graphics.Color.Transparent, + unfocusedContainerColor = androidx.compose.ui.graphics.Color.Transparent, + ) + ) + Box(Modifier.weight(3f), contentAlignment = Alignment.CenterEnd) { + ToggleSwitch(checked = useFahrenheit, onCheckedChange = { handleTempUnitToggle(it) }, + labelLeft = "°C", labelRight = "°F") + } + } + } + + // Density at 15°C + Column { + FieldLabel("Fuel Density at 15°C") + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = densityInput, onValueChange = { densityInput = it }, + placeholder = { Text(if (densityUnit == "lbgal") "e.g. 6.76" else "e.g. 0.810", fontSize = 14.sp, color = colors.textMuted) }, + modifier = Modifier.weight(2f), singleLine = true, shape = RoundedCornerShape(8.dp), + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Decimal), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = colors.accent, unfocusedBorderColor = colors.border, + focusedTextColor = colors.text, unfocusedTextColor = colors.text, + cursorColor = colors.accent, + focusedContainerColor = androidx.compose.ui.graphics.Color.Transparent, + unfocusedContainerColor = androidx.compose.ui.graphics.Color.Transparent, + ) + ) + Box(Modifier.weight(3f), contentAlignment = Alignment.CenterEnd) { + // Three-way segmented control + Row(Modifier.clip(RoundedCornerShape(6.dp)).border(1.dp, colors.border, RoundedCornerShape(6.dp))) { + listOf("sg" to "SG", "kgl" to "kg/L", "lbgal" to "lb/gal").forEach { (key, label) -> + val active = densityUnit == key + Box( + Modifier.weight(1f) + .background(if (active) colors.accent else androidx.compose.ui.graphics.Color.Transparent) + .clickable { handleDensityUnitChange(key) } + .padding(vertical = 8.dp), + contentAlignment = Alignment.Center + ) { + Text(label, fontSize = 11.sp, fontWeight = FontWeight.SemiBold, + color = if (active) androidx.compose.ui.graphics.Color.White else colors.textSecondary) + } + } + } + } + } + Text( + "From fuel ticket. Jet-A spec: ${if (densityUnit == "lbgal") "6.47 – 7.01 lb/gal" else "0.775 – 0.840"}", + fontSize = 10.sp, color = colors.textMuted, modifier = Modifier.padding(top = 4.dp) + ) + } + + // Warning + if (warning != null) { + val bg = if (warning.level == "danger") colors.dangerBg else colors.warningBg + val border = if (warning.level == "danger") colors.dangerBorder else colors.warningBorder + val fg = if (warning.level == "danger") colors.danger else colors.warning + val icon = if (warning.level == "danger") "⛔" else "⚠" + Box(modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)) + .background(bg).border(1.dp, border, RoundedCornerShape(8.dp)).padding(12.dp)) { + Text("$icon ${warning.msg}", fontSize = 13.sp, fontWeight = FontWeight.Medium, color = fg) + } + } + + // Optional: Total Required and Fuel On Board + Column(modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)) + .background(colors.surfaceAlt).border(1.dp, colors.border, RoundedCornerShape(8.dp)) + .padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + + Text("OPTIONAL — CALCULATE FUEL ORDER", fontSize = 11.sp, fontWeight = FontWeight.Bold, + color = colors.textMuted, letterSpacing = 0.5.sp) + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Column(Modifier.weight(1f)) { + ToolkitTextField(value = totalInput, + onValueChange = { totalInput = it; fuelManual = false }, + label = "Total Required (lbs)", placeholder = "e.g. 12000", + keyboardType = KeyboardType.Number) + } + Column(Modifier.weight(1f)) { + ToolkitTextField(value = onBoardInput, + onValueChange = { onBoardInput = it; fuelManual = false }, + label = "On Board (lbs)", placeholder = "e.g. 7000", + keyboardType = KeyboardType.Number) + } + } + } + + // Fuel Order field + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + FieldLabel("Fuel Order (lbs)") + if (isAutoCalc) { + Spacer(Modifier.width(8.dp)) + Text("← calculated", fontSize = 11.sp, color = colors.accent, fontWeight = FontWeight.Normal) + } + } + OutlinedTextField( + value = fuelInput, + onValueChange = { fuelInput = it; fuelManual = true }, + placeholder = { Text("e.g. 5000", fontSize = 14.sp, color = colors.textMuted) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(8.dp), + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( + keyboardType = KeyboardType.Number + ), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = if (isAutoCalc) colors.accent else colors.accent, + unfocusedBorderColor = if (isAutoCalc) colors.accent.copy(alpha = 0.5f) else colors.border, + focusedTextColor = colors.text, unfocusedTextColor = colors.text, + cursorColor = colors.accent, + focusedContainerColor = if (isAutoCalc) colors.accent.copy(alpha = 0.05f) else androidx.compose.ui.graphics.Color.Transparent, + unfocusedContainerColor = if (isAutoCalc) colors.accent.copy(alpha = 0.05f) else androidx.compose.ui.graphics.Color.Transparent, + ) + ) + } + + ClearButton(onClick = { + tempInput = ""; totalInput = ""; onBoardInput = "" + fuelInput = ""; fuelManual = false + densityInput = if (densityUnit == "lbgal") "%.2f".format(0.810 * Calculations.LB_PER_GAL_PER_KGL) else "0.810" + }) + + // Results + if (results != null) { + data class VolumeRow(val key: String, val label: String, val value: Int) + val allVolumes = listOf( + VolumeRow("usgal", "US Gallons", results.usGal), + VolumeRow("liters", "Liters", results.liters), + VolumeRow("impgal", "Imperial Gallons", results.impGal), + ) + val primary = allVolumes.find { it.key == volumeUnit } ?: allVolumes[0] + val secondary = allVolumes.filter { it.key != volumeUnit } + + ResultCard { + Text("ORDER QUANTITIES (ROUNDED UP TO NEAREST 10)", fontSize = 11.sp, + fontWeight = FontWeight.Bold, color = colors.textMuted, letterSpacing = 0.5.sp) + Spacer(Modifier.height(14.dp)) + + ResultRow(label = primary.label, value = "%,d".format(primary.value)) + secondary.forEach { vol -> + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically) { + Text(vol.label, fontSize = 14.sp, fontWeight = FontWeight.Medium, + color = colors.textSecondary.copy(alpha = 0.65f)) + Text("%,d".format(vol.value), fontSize = 20.sp, fontWeight = FontWeight.Bold, + color = colors.accent.copy(alpha = 0.65f), fontFamily = FontFamily.Monospace) + } + } + + // Density footer + ResultDivider() + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically) { + Text("Reference density (15°C)", fontSize = 12.sp, color = colors.textMuted) + Text("${results.refDensityLbGal} lb/gal", fontSize = 13.sp, color = colors.textMuted, + fontFamily = FontFamily.Monospace) + } + Spacer(Modifier.height(4.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically) { + Text("Corrected density (${results.tempCDisplay}°C)", fontSize = 12.sp, color = colors.accent) + Text("${results.correctedLbGal} lb/gal", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, + color = colors.accent, fontFamily = FontFamily.Monospace) + } + } + } + } +} diff --git a/app/src/main/java/com/harshmallow/pilottoolkit/ui/modules/HFModule.kt b/app/src/main/java/com/harshmallow/pilottoolkit/ui/modules/HFModule.kt new file mode 100644 index 0000000..f44c340 --- /dev/null +++ b/app/src/main/java/com/harshmallow/pilottoolkit/ui/modules/HFModule.kt @@ -0,0 +1,301 @@ +package com.harshmallow.pilottoolkit.ui.modules + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.harshmallow.pilottoolkit.ui.components.* +import com.harshmallow.pilottoolkit.ui.theme.ToolkitTheme +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.net.HttpURLConnection +import java.net.URL +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +private val ARINC_URLS = mapOf( + "atlantic" to listOf( + "https://www.radio.arinc.net/atlantic/", + "https://radio.arinc.net/atlantic/" + ), + "pacific" to listOf( + "https://www.radio.arinc.net/pacific/", + "https://radio.arinc.net/pacific/" + ) +) + +@Composable +fun HFModule() { + val colors = ToolkitTheme.colors + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var region by remember { mutableStateOf("atlantic") } + var pageDate by remember { mutableStateOf(null) } + var dateMismatch by remember { mutableStateOf(false) } + var fetchError by remember { mutableStateOf(null) } + var isFetching by remember { mutableStateOf(false) } + var resolvedUrl by remember { mutableStateOf(ARINC_URLS["atlantic"]!!.first()) } + var now by remember { mutableStateOf(System.currentTimeMillis()) } + + // Tick every minute to update staleness display + LaunchedEffect(Unit) { + while (true) { + delay(60_000) + now = System.currentTimeMillis() + } + } + + // Staleness calculation + val staleMins = if (pageDate != null) ((now - pageDate!!.time) / 60_000).toInt() else -1 + val isStale = staleMins >= 240 + val stalenessText = when { + staleMins < 0 -> null + staleMins < 1 -> "Just now" + staleMins < 60 -> "${staleMins}m ago" + staleMins < 1440 -> "${staleMins / 60}h ${staleMins % 60}m ago" + else -> "${staleMins / 1440}d ${(staleMins % 1440) / 60}h ago" + } + + data class ParseResult(val time: Date, val year: Int, val mon: Int, val day: Int) + + // Parse "Valid from Feb. 22, 2026, 1200Z - 1900Z" (Atlantic) or "Valid from Feb. 22, 2026, 1615Z" (Pacific) + // Atlantic: use END time (when frequencies expire) + // Pacific: use the single time + fun parseValidFrom(html: String, reg: String): ParseResult? { + val months = mapOf("Jan" to 0, "Feb" to 1, "Mar" to 2, "Apr" to 3, "May" to 4, "Jun" to 5, + "Jul" to 6, "Aug" to 7, "Sep" to 8, "Oct" to 9, "Nov" to 10, "Dec" to 11) + + // Try Atlantic format: "Valid from Mon. DD, YYYY, HHmmZ - HHmmZ" + val atlRegex = Regex("""Valid from\s+(\w+)\.\s+(\d+),\s+(\d{4}),\s+(\d{4})Z\s*-\s*(\d{4})Z""", RegexOption.IGNORE_CASE) + val atlMatch = atlRegex.find(html) + if (atlMatch != null) { + val mon = months[atlMatch.groupValues[1]] ?: return null + val day = atlMatch.groupValues[2].toInt() + val year = atlMatch.groupValues[3].toInt() + val hhmm = atlMatch.groupValues[5] // END time + val hh = hhmm.substring(0, 2).toInt() + val mm = hhmm.substring(2, 4).toInt() + val cal = java.util.Calendar.getInstance(TimeZone.getTimeZone("UTC")) + cal.set(year, mon, day, hh, mm, 0) + cal.set(java.util.Calendar.MILLISECOND, 0) + return ParseResult(cal.time, year, mon, day) + } + + // Pacific format: "Valid from Mon. DD, YYYY, HHmmZ" + val pacRegex = Regex("""Valid from\s+(\w+)\.\s+(\d+),\s+(\d{4}),\s+(\d{4})Z""", RegexOption.IGNORE_CASE) + val pacMatch = pacRegex.find(html) ?: return null + val mon = months[pacMatch.groupValues[1]] ?: return null + val day = pacMatch.groupValues[2].toInt() + val year = pacMatch.groupValues[3].toInt() + val hhmm = pacMatch.groupValues[4] + val hh = hhmm.substring(0, 2).toInt() + val mm = hhmm.substring(2, 4).toInt() + val cal = java.util.Calendar.getInstance(TimeZone.getTimeZone("UTC")) + cal.set(year, mon, day, hh, mm, 0) + cal.set(java.util.Calendar.MILLISECOND, 0) + return ParseResult(cal.time, year, mon, day) + } + + fun fetchPageAge() { + scope.launch { + isFetching = true + fetchError = null + dateMismatch = false + val urls = ARINC_URLS[region]!! + var lastError: String? = null + for (tryUrl in urls) { + try { + val result = withContext(Dispatchers.IO) { + val url = URL(tryUrl) + val conn = url.openConnection() as HttpURLConnection + conn.requestMethod = "GET" + conn.connectTimeout = 10_000 + conn.readTimeout = 10_000 + conn.setRequestProperty("User-Agent", "PilotToolkit/1.0") + try { + conn.connect() + val html = conn.inputStream.bufferedReader().readText() + parseValidFrom(html, region) + } finally { + conn.disconnect() + } + } + if (result != null) { + pageDate = result.time + now = System.currentTimeMillis() + resolvedUrl = tryUrl + // Date mismatch check: accept today or yesterday UTC + val todayCal = java.util.Calendar.getInstance(TimeZone.getTimeZone("UTC")) + val yesterdayCal = java.util.Calendar.getInstance(TimeZone.getTimeZone("UTC")).apply { + add(java.util.Calendar.DAY_OF_MONTH, -1) + } + val matchesToday = result.year == todayCal.get(java.util.Calendar.YEAR) && + result.mon == todayCal.get(java.util.Calendar.MONTH) && + result.day == todayCal.get(java.util.Calendar.DAY_OF_MONTH) + val matchesYesterday = result.year == yesterdayCal.get(java.util.Calendar.YEAR) && + result.mon == yesterdayCal.get(java.util.Calendar.MONTH) && + result.day == yesterdayCal.get(java.util.Calendar.DAY_OF_MONTH) + if (!matchesToday && !matchesYesterday) { + dateMismatch = true + } + isFetching = false + return@launch // Success — stop trying further URLs + } else { + lastError = "Could not find \"Valid from\" timestamp on ARINC page." + } + } catch (e: Exception) { + lastError = "Unable to reach ARINC: ${e.message?.take(60) ?: "unknown error"}" + } + } + // All URLs exhausted + fetchError = lastError + resolvedUrl = urls.first() + isFetching = false + } + } + + val url = resolvedUrl + + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + // Region selector + SegmentedSelector( + options = listOf("Atlantic", "Pacific"), + selected = if (region == "atlantic") 0 else 1, + onSelect = { + val newRegion = if (it == 0) "atlantic" else "pacific" + region = newRegion + pageDate = null; fetchError = null; dateMismatch = false + resolvedUrl = ARINC_URLS[newRegion]!!.first() + } + ) + + // Refresh button + staleness + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically) { + Button( + onClick = { fetchPageAge() }, + enabled = !isFetching, + colors = ButtonDefaults.buttonColors( + containerColor = colors.accent.copy(alpha = 0.1f), contentColor = colors.accent), + shape = RoundedCornerShape(6.dp), + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp) + ) { + Text(if (isFetching) "Checking…" else "↻ Check Page Age", + fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + } + if (stalenessText != null) { + Text( + text = (if (isStale) "⚠ " else "") + stalenessText, + fontSize = 12.sp, fontWeight = FontWeight.Medium, + color = if (isStale) colors.warning else colors.textMuted + ) + } + } + + // Staleness warning + if (isStale) { + Box(modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)) + .background(colors.warningBg).border(1.dp, colors.warningBorder, RoundedCornerShape(8.dp)) + .padding(12.dp)) { + Text("⚠ ARINC page is over 4 hours old. Frequencies may have changed.", + fontSize = 13.sp, fontWeight = FontWeight.Medium, color = colors.warning) + } + } + + // Fetch error + if (fetchError != null) { + Box(modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)) + .background(colors.dangerBg).border(1.dp, colors.dangerBorder, RoundedCornerShape(8.dp)) + .padding(12.dp)) { + Text(fetchError!!, fontSize = 13.sp, color = colors.danger) + } + } + + // Date mismatch warning + if (dateMismatch) { + Box(modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)) + .background(colors.dangerBg).border(1.dp, colors.dangerBorder, RoundedCornerShape(8.dp)) + .padding(12.dp)) { + Text("⛔ ARINC page date does not match today's date — verify data is current.", + fontSize = 13.sp, fontWeight = FontWeight.Medium, color = colors.danger) + } + } + + // Page date detail + if (pageDate != null) { + val fmt = SimpleDateFormat("dd MMM yyyy, HHmm", Locale.US) + fmt.timeZone = TimeZone.getTimeZone("UTC") + val dateStr = fmt.format(pageDate!!) + "Z" + val validLabel = if (region == "atlantic") "FREQUENCIES VALID UNTIL" else "FREQUENCIES VALID FROM" + Box(modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)) + .background(colors.resultBg).border(1.dp, colors.resultBorder, RoundedCornerShape(8.dp)) + .padding(14.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(validLabel, fontSize = 11.sp, fontWeight = FontWeight.Bold, + color = colors.textMuted, letterSpacing = 0.5.sp) + Text(dateStr, fontSize = 15.sp, fontWeight = FontWeight.Bold, + color = colors.accent, fontFamily = FontFamily.Monospace) + } + } + } + + // Open in browser buttons + OutlinedButton( + onClick = { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) }, + modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(6.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = colors.accent) + ) { + Text("Open ARINC ${region.replaceFirstChar { it.uppercase() }} Page ↗", + fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + } + + // Disclaimer + Box(modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)) + .background(colors.surfaceAlt).border(1.dp, colors.border, RoundedCornerShape(8.dp)) + .padding(12.dp)) { + Text("Frequency information sourced from ARINC (Collins Aerospace). Frequencies change based on propagation conditions. Verify against the official source before use. This application is not affiliated with ARINC or Collins Aerospace.", + fontSize = 11.sp, color = colors.textMuted, lineHeight = 16.sp) + } + } +} + +/* + * --- WEBVIEW APPROACH (commented out for future use) --- + * + * To embed the ARINC page directly in the app, replace the body of HFModule + * with a WebView. Requires: import android.webkit.* + * import androidx.compose.ui.viewinterop.AndroidView + * + * AndroidView( + * factory = { ctx -> + * WebView(ctx).apply { + * settings.javaScriptEnabled = true + * settings.domStorageEnabled = true + * webViewClient = object : WebViewClient() { + * override fun onPageFinished(view: WebView?, url: String?) { ... } + * override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) { ... } + * } + * loadUrl(url) + * } + * }, + * update = { webView -> webView.loadUrl(url) } + * ) + */ diff --git a/app/src/main/java/com/harshmallow/pilottoolkit/ui/modules/PassdownModule.kt b/app/src/main/java/com/harshmallow/pilottoolkit/ui/modules/PassdownModule.kt new file mode 100644 index 0000000..8a01eaf --- /dev/null +++ b/app/src/main/java/com/harshmallow/pilottoolkit/ui/modules/PassdownModule.kt @@ -0,0 +1,1290 @@ +package com.harshmallow.pilottoolkit.ui.modules + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.harshmallow.pilottoolkit.ui.theme.ToolkitTheme +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.* + +/* ─── Data Model ─── */ +data class Passdown( + val id: String = UUID.randomUUID().toString().take(12), + val date: String = "", // yyyy-MM-dd + val registration: String = "", + val airport: String = "", + val fbo: String = "", + val fboPhone: String = "", + val pic: String = "", + val sic: String = "", + val ca1: String = "", + val oncomingPic: String = "", + val oncomingSic: String = "", + val maintenance: String = "", + val narrative: String = "", + val createdAt: String = "", + val updatedAt: String = "", + val lastSentAt: String = "", +) + +/* ─── JSON Storage ─── */ +private const val PREFS_NAME = "passdown_storage" +private const val KEY_PASSDOWNS = "passdowns_json" +private const val KEY_LOOKUPS = "lookups_json" +private const val KEY_RETENTION = "retention_months" + +private fun getPrefs(context: Context) = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + +fun getRetentionMonths(context: Context): Int = getPrefs(context).getInt(KEY_RETENTION, 12) +fun saveRetentionMonths(context: Context, months: Int) = getPrefs(context).edit().putInt(KEY_RETENTION, months).apply() + +private fun passdownToJson(p: Passdown): JSONObject = JSONObject().apply { + put("id", p.id); put("date", p.date); put("registration", p.registration) + put("airport", p.airport); put("fbo", p.fbo); put("fboPhone", p.fboPhone) + put("pic", p.pic); put("sic", p.sic); put("ca1", p.ca1) + put("oncomingPic", p.oncomingPic); put("oncomingSic", p.oncomingSic) + put("maintenance", p.maintenance); put("narrative", p.narrative) + put("createdAt", p.createdAt); put("updatedAt", p.updatedAt) + if (p.lastSentAt.isNotEmpty()) put("lastSentAt", p.lastSentAt) +} + +private fun jsonToPassdown(j: JSONObject): Passdown = Passdown( + id = j.optString("id"), date = j.optString("date"), registration = j.optString("registration"), + airport = j.optString("airport"), fbo = j.optString("fbo"), fboPhone = j.optString("fboPhone"), + pic = j.optString("pic"), sic = j.optString("sic"), ca1 = j.optString("ca1"), + oncomingPic = j.optString("oncomingPic"), oncomingSic = j.optString("oncomingSic"), + maintenance = j.optString("maintenance"), narrative = j.optString("narrative"), + createdAt = j.optString("createdAt"), updatedAt = j.optString("updatedAt"), + lastSentAt = j.optString("lastSentAt", ""), +) + +fun getAllPassdowns(context: Context): List { + val json = getPrefs(context).getString(KEY_PASSDOWNS, "[]") ?: "[]" + val arr = JSONArray(json) + return (0 until arr.length()).map { jsonToPassdown(arr.getJSONObject(it)) } + .sortedByDescending { it.createdAt } +} + +fun savePassdownRecord(context: Context, p: Passdown) { + val all = getAllPassdowns(context).toMutableList() + val idx = all.indexOfFirst { it.id == p.id } + if (idx >= 0) all[idx] = p else all.add(0, p) + val arr = JSONArray() + all.forEach { arr.put(passdownToJson(it)) } + getPrefs(context).edit().putString(KEY_PASSDOWNS, arr.toString()).apply() +} + +fun deletePassdownRecord(context: Context, id: String) { + val all = getAllPassdowns(context).filter { it.id != id } + val arr = JSONArray() + all.forEach { arr.put(passdownToJson(it)) } + getPrefs(context).edit().putString(KEY_PASSDOWNS, arr.toString()).apply() +} + +fun purgeOldPassdowns(context: Context, months: Int): Int { + if (months <= 0) return 0 + val cal = Calendar.getInstance().apply { add(Calendar.MONTH, -months) } + val cutoff = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).format(cal.time) + val all = getAllPassdowns(context) + val keep = all.filter { it.createdAt.isEmpty() || it.createdAt >= cutoff } + val purged = all.size - keep.size + if (purged > 0) { + val arr = JSONArray() + keep.forEach { arr.put(passdownToJson(it)) } + getPrefs(context).edit().putString(KEY_PASSDOWNS, arr.toString()).apply() + } + return purged +} + +/* ─── Lookups ─── */ +fun getLookups(context: Context, type: String): List { + val json = getPrefs(context).getString(KEY_LOOKUPS, "{}") ?: "{}" + val obj = JSONObject(json) + val arr = obj.optJSONArray(type) ?: return emptyList() + return (0 until arr.length()).map { arr.getString(it) }.sorted() +} + +fun addLookup(context: Context, type: String, value: String) { + if (value.isBlank()) return + val json = getPrefs(context).getString(KEY_LOOKUPS, "{}") ?: "{}" + val obj = JSONObject(json) + val arr = obj.optJSONArray(type) ?: JSONArray() + val existing = (0 until arr.length()).map { arr.getString(it) }.toMutableSet() + existing.add(value.trim()) + obj.put(type, JSONArray(existing.toList())) + getPrefs(context).edit().putString(KEY_LOOKUPS, obj.toString()).apply() +} + +fun deleteLookup(context: Context, type: String, value: String) { + val json = getPrefs(context).getString(KEY_LOOKUPS, "{}") ?: "{}" + val obj = JSONObject(json) + val arr = obj.optJSONArray(type) ?: return + val remaining = (0 until arr.length()).map { arr.getString(it) }.filter { it != value } + obj.put(type, JSONArray(remaining)) + getPrefs(context).edit().putString(KEY_LOOKUPS, obj.toString()).apply() +} + +fun clearAllLookups(context: Context) { + getPrefs(context).edit().putString(KEY_LOOKUPS, "{}").apply() +} + +/* ─── Phone Formatting ─── */ +fun formatPhone(raw: String): String { + val digits = raw.replace(Regex("\\D"), "") + return if (digits.length == 10) "(${digits.substring(0, 3)}) ${digits.substring(3, 6)}-${digits.substring(6)}" + else raw +} + +fun phoneToTel(formatted: String): String { + val digits = formatted.replace(Regex("\\D"), "") + return when { + digits.length == 10 -> "+1$digits" + formatted.trim().startsWith("+") -> "+$digits" + else -> digits + } +} + +/* ─── Text Export ─── */ +fun formatDatePilot(dateStr: String): String { + val months = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") + val parts = dateStr.split("-") + if (parts.size != 3) return dateStr + return "${parts[2].toIntOrNull() ?: parts[2]} ${months.getOrElse((parts[1].toIntOrNull() ?: 1) - 1) { "?" }} ${parts[0]}" +} + +fun passdownToPlainText(p: Passdown): String { + val lines = mutableListOf( + "PASSDOWN — ${formatDatePilot(p.date).uppercase()}", + "REG: ${p.registration.ifEmpty { "—" }} | APT: ${p.airport.ifEmpty { "—" }} | FBO: ${p.fbo.ifEmpty { "—" }}", + ) + if (p.fboPhone.isNotEmpty()) lines.add("FBO Phone: ${p.fboPhone}") + lines.add("") + lines.add("CREW:") + lines.add(" PIC: ${p.pic.ifEmpty { "—" }}") + lines.add(" SIC: ${p.sic.ifEmpty { "—" }}") + if (p.ca1.isNotEmpty()) lines.add(" Lead CA: ${p.ca1}") + if (p.oncomingPic.isNotEmpty() || p.oncomingSic.isNotEmpty()) { + lines.add("") + lines.add("ONCOMING CREW:") + if (p.oncomingPic.isNotEmpty()) lines.add(" PIC: ${p.oncomingPic}") + if (p.oncomingSic.isNotEmpty()) lines.add(" SIC: ${p.oncomingSic}") + } + lines.add("") + lines.add("MAINTENANCE STATUS:") + lines.add(p.maintenance.ifEmpty { "(none)" }) + lines.add("") + lines.add("NARRATIVE:") + lines.add(p.narrative.ifEmpty { "(none)" }) + return lines.joinToString("\n") +} + +fun todayDate(): String { + val sdf = SimpleDateFormat("yyyy-MM-dd", Locale.US) + sdf.timeZone = TimeZone.getDefault() + return sdf.format(Date()) +} + +data class ImportResult(val imported: Int, val skipped: Int, val total: Int) + +fun parsePtzStream(inputStream: java.io.InputStream): List { + val json = java.util.zip.GZIPInputStream(inputStream).use { gz -> + gz.bufferedReader(Charsets.UTF_8).readText() + } + val arr = JSONArray(json) + return (0 until arr.length()) + .map { jsonToPassdown(arr.getJSONObject(it)) } + .filter { it.id.isNotBlank() } +} + +fun importSelectedPassdownRecords(context: Context, selected: List): Int { + var imported = 0 + for (p in selected) { + savePassdownRecord(context, p) + if (p.registration.isNotBlank()) addLookup(context, "registration", p.registration) + if (p.airport.isNotBlank()) addLookup(context, "airport", p.airport) + if (p.fbo.isNotBlank()) addLookup(context, "fbo", p.fbo) + listOf(p.pic, p.sic, p.ca1, p.oncomingPic, p.oncomingSic).forEach { v -> + if (v.isNotBlank()) addLookup(context, "crew", v) + } + imported++ + } + return imported +} + +fun importPassdownPtz(context: Context, inputStream: java.io.InputStream): ImportResult { + val entries = parsePtzStream(inputStream) + val existingIds = getAllPassdowns(context).map { it.id }.toSet() + val toImport = entries.filter { !existingIds.contains(it.id) } + val imported = importSelectedPassdownRecords(context, toImport) + return ImportResult(imported, entries.size - imported, entries.size) +} + +/* ─── Autocomplete Field ─── */ +@Composable +fun AutocompleteField( + value: String, onValueChange: (String) -> Unit, + options: List, placeholder: String, + uppercase: Boolean = false, + keyboardType: KeyboardType = KeyboardType.Email, + onBlurTransform: ((String) -> String)? = null +) { + val colors = ToolkitTheme.colors + var expanded by remember { mutableStateOf(false) } + var hasFocus by remember { mutableStateOf(false) } + val filtered = options.filter { it.contains(value, ignoreCase = true) } + val showDropdown = expanded && hasFocus && filtered.isNotEmpty() && value.isNotEmpty() + + Column { + OutlinedTextField( + value = value, + onValueChange = { v -> + onValueChange(if (uppercase) v.uppercase() else v) + expanded = true + }, + placeholder = { Text(placeholder, fontSize = 13.sp, color = colors.textMuted) }, + modifier = Modifier.fillMaxWidth() + .onFocusChanged { fs -> + hasFocus = fs.isFocused + if (fs.isFocused) expanded = true + if (!fs.isFocused) { + expanded = false + if (onBlurTransform != null && value.isNotBlank()) { + onValueChange(onBlurTransform(value)) + } + } + }, + singleLine = true, + shape = RoundedCornerShape(8.dp), + keyboardOptions = KeyboardOptions(keyboardType = keyboardType), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = colors.accent, unfocusedBorderColor = colors.border, + focusedTextColor = colors.text, unfocusedTextColor = colors.text, + cursorColor = colors.accent, + focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent, + ), + textStyle = LocalTextStyle.current.copy(fontSize = 13.sp, fontWeight = FontWeight.Medium), + ) + if (showDropdown) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(0.dp, 0.dp, 8.dp, 8.dp), + colors = CardDefaults.cardColors(containerColor = colors.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, colors.border) + ) { + Column(modifier = Modifier.heightIn(max = 160.dp).verticalScroll(rememberScrollState())) { + filtered.take(6).forEach { opt -> + Text( + text = opt, fontSize = 13.sp, color = colors.text, + modifier = Modifier.fillMaxWidth() + .clickable { onValueChange(opt); expanded = false } + .padding(horizontal = 12.dp, vertical = 10.dp) + ) + } + } + } + } + } +} + +/* ═══════════════════════════════════ + PassdownModule Composable + ═══════════════════════════════════ */ +@Composable +fun PassdownModule(importUri: android.net.Uri? = null, onImportConsumed: () -> Unit = {}) { + val colors = ToolkitTheme.colors + val context = LocalContext.current + val scope = rememberCoroutineScope() + val clipboardManager = LocalClipboardManager.current + + var view by remember { mutableStateOf("form") } // "form" | "history" | "detail" | "manage" | "import" + var passdowns by remember { mutableStateOf(getAllPassdowns(context)) } + var lookupCache by remember { mutableStateOf(mapOf>()) } + var selectedPassdown by remember { mutableStateOf(null) } + var editingId by remember { mutableStateOf(null) } + var saveStatus by remember { mutableStateOf?>(null) } // type, msg + var copyStatus by remember { mutableStateOf(false) } + var importStatus by remember { mutableStateOf?>(null) } + var importPreview by remember { mutableStateOf?>(null) } + var importPreviewExistingIds by remember { mutableStateOf(emptySet()) } + var importSelected by remember { mutableStateOf(emptySet()) } + var confirmClearAll by remember { mutableStateOf(false) } + var filterReg by remember { mutableStateOf("") } + var filterDateFrom by remember { mutableStateOf("") } + var filterDateTo by remember { mutableStateOf("") } + + // File picker for .ptz import + val importLauncher = androidx.activity.compose.rememberLauncherForActivityResult( + contract = androidx.activity.result.contract.ActivityResultContracts.GetContent() + ) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + try { + val inputStream = context.contentResolver.openInputStream(uri) ?: throw Exception("Cannot read file") + val entries = parsePtzStream(inputStream) + if (entries.isEmpty()) { + importStatus = "error" to "No valid passdowns found in file." + return@rememberLauncherForActivityResult + } + val existingIds = getAllPassdowns(context).map { it.id }.toSet() + importPreview = entries + importPreviewExistingIds = existingIds + importSelected = entries.filter { !existingIds.contains(it.id) }.map { it.id }.toSet() + view = "import" + } catch (e: Exception) { + importStatus = "error" to "Import failed: ${e.message}" + } + } + + // Form fields + var reg by remember { mutableStateOf("") } + var date by remember { mutableStateOf(todayDate()) } + var airport by remember { mutableStateOf("") } + var fbo by remember { mutableStateOf("") } + var fboPhone by remember { mutableStateOf("") } + var pic by remember { mutableStateOf("") } + var sic by remember { mutableStateOf("") } + var ca1 by remember { mutableStateOf("") } + var oncomingPic by remember { mutableStateOf("") } + var oncomingSic by remember { mutableStateOf("") } + var maintenance by remember { mutableStateOf("") } + var narrative by remember { mutableStateOf("") } + + fun refreshData() { + passdowns = getAllPassdowns(context) + lookupCache = mapOf( + "registration" to getLookups(context, "registration"), + "airport" to getLookups(context, "airport"), + "fbo" to getLookups(context, "fbo"), + "crew" to getLookups(context, "crew"), + ) + } + + fun clearForm() { + reg = ""; date = todayDate(); airport = ""; fbo = ""; fboPhone = "" + pic = ""; sic = ""; ca1 = "" + oncomingPic = ""; oncomingSic = "" + maintenance = ""; narrative = ""; editingId = null; saveStatus = null + } + + LaunchedEffect(Unit) { refreshData() } + + // Clear save status after delay + LaunchedEffect(saveStatus) { + if (saveStatus != null) { + kotlinx.coroutines.delay(2500) + saveStatus = null + } + } + + LaunchedEffect(copyStatus) { + if (copyStatus) { + kotlinx.coroutines.delay(2000) + copyStatus = false + } + } + + LaunchedEffect(importStatus) { + if (importStatus != null) { + kotlinx.coroutines.delay(4000) + importStatus = null + } + } + + // Handle incoming .ptz file from intent + LaunchedEffect(importUri) { + if (importUri != null) { + try { + val inputStream = context.contentResolver.openInputStream(importUri) + ?: throw Exception("Cannot read file") + val entries = parsePtzStream(inputStream) + if (entries.isEmpty()) { + view = "history" + importStatus = "error" to "No valid passdowns found in file." + } else { + val existingIds = getAllPassdowns(context).map { it.id }.toSet() + importPreview = entries + importPreviewExistingIds = existingIds + importSelected = entries.filter { !existingIds.contains(it.id) }.map { it.id }.toSet() + view = "import" + } + } catch (e: Exception) { + view = "history" + importStatus = "error" to "Import failed: ${e.message}" + } + onImportConsumed() + } + } + + fun handleSave() { + if (reg.isBlank() && date.isBlank()) { + saveStatus = "error" to "Registration and date are required." + return + } + val now = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).format(Date()) + val existing = if (editingId != null) passdowns.find { it.id == editingId } else null + val record = Passdown( + id = editingId ?: UUID.randomUUID().toString().take(12), + date = date, registration = reg.trim().uppercase(), airport = airport.trim().uppercase(), + fbo = fbo.trim(), fboPhone = fboPhone.trim(), + pic = pic.trim(), sic = sic.trim(), ca1 = ca1.trim(), + oncomingPic = oncomingPic.trim(), oncomingSic = oncomingSic.trim(), + maintenance = maintenance.trim(), narrative = narrative.trim(), + createdAt = existing?.createdAt ?: now, updatedAt = now, + lastSentAt = existing?.lastSentAt ?: "", + ) + savePassdownRecord(context, record) + listOf(record.registration to "registration", record.airport to "airport", record.fbo to "fbo").forEach { (v, t) -> + if (v.isNotBlank()) addLookup(context, t, v) + } + listOf(record.pic, record.sic, record.ca1, record.oncomingPic, record.oncomingSic).forEach { v -> + if (v.isNotBlank()) addLookup(context, "crew", v) + } + refreshData() + saveStatus = "success" to if (editingId != null) "Passdown updated." else "Passdown saved." + if (editingId == null) clearForm() + editingId = null + } + + fun handleEdit(p: Passdown) { + reg = p.registration; date = p.date; airport = p.airport + fbo = p.fbo; fboPhone = p.fboPhone + pic = p.pic; sic = p.sic; ca1 = p.ca1 + oncomingPic = p.oncomingPic; oncomingSic = p.oncomingSic + maintenance = p.maintenance; narrative = p.narrative + editingId = p.id + view = "form" + } + + fun handleDelete(id: String) { + deletePassdownRecord(context, id) + refreshData() + if (selectedPassdown?.id == id) selectedPassdown = null + view = "history" + } + + fun handleSend(p: Passdown) { + try { + // Stamp lastSentAt and persist + val sentAt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + }.format(Date()) + val updated = p.copy(lastSentAt = sentAt) + savePassdownRecord(context, updated) + passdowns = getAllPassdowns(context) + selectedPassdown = updated + + // Generate .ptz attachment + val json = JSONArray().apply { put(passdownToJson(updated)) }.toString(2) + val reg = updated.registration.ifEmpty { "NOREG" }.replace(Regex("[^A-Za-z0-9]"), "") + val date = updated.date.ifEmpty { "nodate" }.replace(Regex("[^0-9-]"), "") + val filename = "PASSDOWN_${reg}_${date}.ptz" + val file = java.io.File(context.cacheDir, filename) + java.util.zip.GZIPOutputStream(file.outputStream()).use { gz -> + gz.write(json.toByteArray(Charsets.UTF_8)) + } + val fileUri = androidx.core.content.FileProvider.getUriForFile( + context, "${context.packageName}.fileprovider", file + ) + // Unified intent: .ptz attachment + plain text body + recipients + val to = listOf(updated.pic).filter { it.contains("@") }.toTypedArray() + val cc = listOf(updated.sic, updated.ca1, updated.oncomingPic, updated.oncomingSic) + .filter { it.contains("@") }.toTypedArray() + val subject = "Passdown — ${formatDatePilot(updated.date)} — ${updated.registration.ifEmpty { "N/A" }}" + val body = passdownToPlainText(updated) + val intent = Intent(Intent.ACTION_SEND).apply { + type = "message/rfc822" + putExtra(Intent.EXTRA_STREAM, fileUri) + putExtra(Intent.EXTRA_TITLE, filename) + putExtra(Intent.EXTRA_EMAIL, to) + putExtra(Intent.EXTRA_CC, cc) + putExtra(Intent.EXTRA_SUBJECT, subject) + putExtra(Intent.EXTRA_TEXT, body) + clipData = android.content.ClipData.newUri(context.contentResolver, filename, fileUri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(intent, "Send Passdown")) + } catch (_: Exception) { } + } + + var showStalePrompt by remember { mutableStateOf(false) } + + fun handleSendWithStaleCheck(p: Passdown) { + val today = todayDate() + if (p.date.isNotEmpty() && p.date != today) { + showStalePrompt = true + } else { + handleSend(p) + } + } + + fun handleUpdateDateAndSend(p: Passdown) { + val today = todayDate() + val now = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).format(Date()) + val updated = p.copy(date = today, updatedAt = now) + savePassdownRecord(context, updated) + passdowns = getAllPassdowns(context) + selectedPassdown = updated + showStalePrompt = false + handleSend(updated) + } + + fun exportBatchPtz(batch: List) { + try { + val json = JSONArray().apply { + batch.forEach { put(passdownToJson(it)) } + }.toString(2) + val regs = batch.map { it.registration }.filter { it.isNotEmpty() }.distinct() + val regPart = if (regs.size == 1) regs[0].replace(Regex("[^A-Za-z0-9]"), "") else "${batch.size}records" + val dates = batch.map { it.date }.filter { it.isNotEmpty() }.sorted() + val datePart = if (dates.size >= 2) "${dates.first()}_${dates.last()}" else dates.firstOrNull() ?: "nodate" + val filename = "PASSDOWN_BATCH_${regPart}_${datePart}.ptz" + val file = java.io.File(context.cacheDir, filename) + java.util.zip.GZIPOutputStream(file.outputStream()).use { gz -> + gz.write(json.toByteArray(Charsets.UTF_8)) + } + val fileUri = androidx.core.content.FileProvider.getUriForFile( + context, "${context.packageName}.fileprovider", file + ) + val intent = Intent(Intent.ACTION_SEND).apply { + type = "application/octet-stream" + putExtra(Intent.EXTRA_STREAM, fileUri) + putExtra(Intent.EXTRA_TITLE, filename) + clipData = android.content.ClipData.newUri(context.contentResolver, filename, fileUri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(intent, "Export Batch Passdown")) + } catch (_: Exception) { } + } + + /* ── FORM VIEW ── */ + if (view == "form") { + Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { + // Header + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically) { + Text( + if (editingId != null) "✎ Editing" else "New Passdown", + fontSize = 13.sp, fontWeight = FontWeight.SemiBold, + color = if (editingId != null) colors.warning else colors.textMuted + ) + TextButton(onClick = { view = "history" }) { + Text("History (${passdowns.size})", fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, color = colors.accent) + } + } + + // Crew (Email) + Column( + Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)) + .background(colors.surfaceAlt).border(1.dp, colors.border, RoundedCornerShape(8.dp)) + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("CREW (EMAIL)", fontSize = 11.sp, fontWeight = FontWeight.Bold, + color = colors.textMuted, letterSpacing = 0.5.sp) + AutocompleteField(value = pic, onValueChange = { pic = it }, + options = lookupCache["crew"] ?: emptyList(), placeholder = "pic@company.com") + AutocompleteField(value = sic, onValueChange = { sic = it }, + options = lookupCache["crew"] ?: emptyList(), placeholder = "sic@company.com") + AutocompleteField(value = ca1, onValueChange = { ca1 = it }, + options = lookupCache["crew"] ?: emptyList(), placeholder = "leadca@company.com") + } + + // Registration + Date + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Column(Modifier.weight(1f)) { + Text("REGISTRATION", fontSize = 11.sp, fontWeight = FontWeight.Bold, + color = colors.textMuted, letterSpacing = 0.5.sp, modifier = Modifier.padding(bottom = 6.dp)) + AutocompleteField(value = reg, onValueChange = { reg = it }, + options = lookupCache["registration"] ?: emptyList(), + placeholder = "e.g. N292ME", uppercase = true, keyboardType = KeyboardType.Text) + } + Column(Modifier.weight(1f)) { + Text("DATE", fontSize = 11.sp, fontWeight = FontWeight.Bold, + color = colors.textMuted, letterSpacing = 0.5.sp, modifier = Modifier.padding(bottom = 6.dp)) + OutlinedTextField( + value = date, onValueChange = { date = it }, + placeholder = { Text("yyyy-mm-dd", fontSize = 13.sp, color = colors.textMuted) }, + modifier = Modifier.fillMaxWidth(), singleLine = true, + shape = RoundedCornerShape(8.dp), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = colors.accent, unfocusedBorderColor = colors.border, + focusedTextColor = colors.text, unfocusedTextColor = colors.text, + cursorColor = colors.accent, + focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent, + ), + textStyle = LocalTextStyle.current.copy(fontSize = 13.sp, fontWeight = FontWeight.Medium), + ) + } + } + + // Airport + FBO + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Column(Modifier.weight(2f)) { + Text("AIRPORT (ICAO)", fontSize = 11.sp, fontWeight = FontWeight.Bold, + color = colors.textMuted, letterSpacing = 0.5.sp, modifier = Modifier.padding(bottom = 6.dp)) + AutocompleteField(value = airport, onValueChange = { airport = it }, + options = lookupCache["airport"] ?: emptyList(), + placeholder = "e.g. KPTK", uppercase = true, keyboardType = KeyboardType.Text) + } + Column(Modifier.weight(3f)) { + Text("FBO", fontSize = 11.sp, fontWeight = FontWeight.Bold, + color = colors.textMuted, letterSpacing = 0.5.sp, modifier = Modifier.padding(bottom = 6.dp)) + AutocompleteField(value = fbo, onValueChange = { fbo = it }, + options = lookupCache["fbo"] ?: emptyList(), + placeholder = "e.g. Aerodynamics, Inc.", keyboardType = KeyboardType.Text) + } + } + + // FBO Phone + Column { + Text("FBO PHONE", fontSize = 11.sp, fontWeight = FontWeight.Bold, + color = colors.textMuted, letterSpacing = 0.5.sp, modifier = Modifier.padding(bottom = 6.dp)) + OutlinedTextField( + value = fboPhone, onValueChange = { fboPhone = it }, + placeholder = { Text("e.g. (248) 666-3500", fontSize = 13.sp, color = colors.textMuted) }, + modifier = Modifier.fillMaxWidth().onFocusChanged { fs -> + if (!fs.isFocused && fboPhone.isNotBlank()) fboPhone = formatPhone(fboPhone.trim()) + }, + singleLine = true, shape = RoundedCornerShape(8.dp), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = colors.accent, unfocusedBorderColor = colors.border, + focusedTextColor = colors.text, unfocusedTextColor = colors.text, + cursorColor = colors.accent, + focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent, + ), + textStyle = LocalTextStyle.current.copy(fontSize = 13.sp, fontWeight = FontWeight.Medium), + ) + } + + // Maintenance Status + Column { + Text("MAINTENANCE STATUS", fontSize = 11.sp, fontWeight = FontWeight.Bold, + color = colors.textMuted, letterSpacing = 0.5.sp, modifier = Modifier.padding(bottom = 2.dp)) + Text("Oil levels, MELs, nuisances, open items", fontSize = 11.sp, + color = colors.textMuted, modifier = Modifier.padding(bottom = 6.dp)) + OutlinedTextField( + value = maintenance, onValueChange = { maintenance = it }, + placeholder = { Text("Oil: _/_/_\nMEL: \nNuisances: ", fontSize = 13.sp, color = colors.textMuted) }, + modifier = Modifier.fillMaxWidth().heightIn(min = 100.dp), + shape = RoundedCornerShape(8.dp), maxLines = 8, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = colors.accent, unfocusedBorderColor = colors.border, + focusedTextColor = colors.text, unfocusedTextColor = colors.text, + cursorColor = colors.accent, + focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent, + ), + textStyle = LocalTextStyle.current.copy(fontSize = 13.sp, lineHeight = 20.sp), + ) + } + + // Narrative + Column { + Text("PASSDOWN NARRATIVE", fontSize = 11.sp, fontWeight = FontWeight.Bold, + color = colors.textMuted, letterSpacing = 0.5.sp, modifier = Modifier.padding(bottom = 6.dp)) + OutlinedTextField( + value = narrative, onValueChange = { narrative = it }, + placeholder = { Text("Anything else the next crew should know...", fontSize = 13.sp, color = colors.textMuted) }, + modifier = Modifier.fillMaxWidth().heightIn(min = 100.dp), + shape = RoundedCornerShape(8.dp), maxLines = 8, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = colors.accent, unfocusedBorderColor = colors.border, + focusedTextColor = colors.text, unfocusedTextColor = colors.text, + cursorColor = colors.accent, + focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent, + ), + textStyle = LocalTextStyle.current.copy(fontSize = 13.sp, lineHeight = 20.sp), + ) + } + + // Oncoming Crew (Email) + Column( + Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)) + .background(colors.surfaceAlt).border(1.dp, colors.border, RoundedCornerShape(8.dp)) + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("ONCOMING CREW (EMAIL)", fontSize = 11.sp, fontWeight = FontWeight.Bold, + color = colors.textMuted, letterSpacing = 0.5.sp) + AutocompleteField(value = oncomingPic, onValueChange = { oncomingPic = it }, + options = lookupCache["crew"] ?: emptyList(), placeholder = "oncoming_pic@company.com") + AutocompleteField(value = oncomingSic, onValueChange = { oncomingSic = it }, + options = lookupCache["crew"] ?: emptyList(), placeholder = "oncoming_sic@company.com") + } + + // Save / Clear + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically) { + Button( + onClick = { handleSave() }, + colors = ButtonDefaults.buttonColors(containerColor = colors.accent), + shape = RoundedCornerShape(6.dp), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 10.dp) + ) { + Text(if (editingId != null) "Update Passdown" else "Save Passdown", + fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = Color.White) + } + TextButton(onClick = { clearForm() }) { + Text("Clear", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.accent) + } + } + if (saveStatus != null) { + Text(saveStatus!!.second, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, + color = if (saveStatus!!.first == "error") colors.danger else colors.accent) + } + } + } + + /* ── HISTORY VIEW ── */ + else if (view == "history") { + val hasFilters = filterReg.isNotEmpty() || filterDateFrom.isNotEmpty() || filterDateTo.isNotEmpty() + val filtered = passdowns.filter { p -> + if (filterReg.isNotEmpty() && !p.registration.equals(filterReg, ignoreCase = true)) return@filter false + if (filterDateFrom.isNotEmpty() && p.date < filterDateFrom) return@filter false + if (filterDateTo.isNotEmpty() && p.date > filterDateTo) return@filter false + true + } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically) { + Text("${passdowns.size} passdown${if (passdowns.size != 1) "s" else ""} saved", + fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.textMuted) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = { importLauncher.launch("*/*") }) { + Text("Import", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.accent) + } + TextButton(onClick = { clearForm(); view = "form" }) { + Text("+ New", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.accent) + } + } + } + + // Filter bar + if (passdowns.isNotEmpty()) { + Column( + Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)) + .background(colors.surfaceAlt).border(1.dp, colors.border, RoundedCornerShape(8.dp)) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + AutocompleteField(value = filterReg, onValueChange = { filterReg = it }, + options = lookupCache["registration"] ?: emptyList(), + placeholder = "Registration (all)", uppercase = true, keyboardType = KeyboardType.Text) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = filterDateFrom, onValueChange = { filterDateFrom = it }, + placeholder = { Text("From (yyyy-mm-dd)", fontSize = 12.sp, color = colors.textMuted) }, + modifier = Modifier.weight(1f), + textStyle = androidx.compose.ui.text.TextStyle(fontSize = 12.sp, color = colors.text), + singleLine = true, + shape = RoundedCornerShape(8.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = colors.accent, + unfocusedBorderColor = colors.border, + cursorColor = colors.accent, + ) + ) + OutlinedTextField( + value = filterDateTo, onValueChange = { filterDateTo = it }, + placeholder = { Text("To (yyyy-mm-dd)", fontSize = 12.sp, color = colors.textMuted) }, + modifier = Modifier.weight(1f), + textStyle = androidx.compose.ui.text.TextStyle(fontSize = 12.sp, color = colors.text), + singleLine = true, + shape = RoundedCornerShape(8.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = colors.accent, + unfocusedBorderColor = colors.border, + cursorColor = colors.accent, + ) + ) + } + if (hasFilters) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically) { + Text(buildString { + append("${filtered.size} result${if (filtered.size != 1) "s" else ""}") + if (filterReg.isNotEmpty()) append(" for ${filterReg.uppercase()}") + if (filterDateFrom.isNotEmpty()) append(" from ${formatDatePilot(filterDateFrom)}") + if (filterDateTo.isNotEmpty()) append(" to ${formatDatePilot(filterDateTo)}") + }, fontSize = 12.sp, color = colors.textMuted) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + if (filtered.isNotEmpty()) { + TextButton(onClick = { exportBatchPtz(filtered) }) { + Text("Export (${filtered.size})", fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, color = colors.accent) + } + } + TextButton(onClick = { filterReg = ""; filterDateFrom = ""; filterDateTo = "" }) { + Text("Clear", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colors.accent) + } + } + } + } + } + } + + if (importStatus != null) { + Box(Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)) + .background(if (importStatus!!.first == "error") colors.dangerBg else colors.resultBg) + .border(1.dp, + if (importStatus!!.first == "error") colors.dangerBorder else colors.resultBorder, + RoundedCornerShape(8.dp)) + .padding(10.dp, 10.dp)) { + Text(importStatus!!.second, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, + color = if (importStatus!!.first == "error") colors.danger else colors.accent) + } + } + + if (filtered.isEmpty()) { + Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) { + Text( + if (hasFilters) "No passdowns match the current filters." + else "No passdowns yet. Create your first one.", + fontSize = 13.sp, color = colors.textMuted + ) + } + } else { + val today = todayDate() + val yesterday = run { + val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + cal.add(Calendar.DAY_OF_MONTH, -1) + String.format("%04d-%02d-%02d", cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH)) + } + filtered.forEach { p -> + val age = when (p.date) { today -> "today"; yesterday -> "yesterday"; else -> "older" } + val regColor = when (age) { "today" -> colors.accent; "yesterday" -> colors.textSecondary; else -> colors.textMuted } + val regWeight = if (age == "today") FontWeight.Bold else FontWeight.SemiBold + val itemAlpha = if (age == "older") 0.6f else 1f + val leftBorderColor = when (age) { "today" -> colors.accent; "yesterday" -> colors.textMuted.copy(alpha = 0.25f); else -> colors.border } + Box( + Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)) + .border(1.dp, colors.border, RoundedCornerShape(8.dp)) + .drawWithContent { + drawContent() + drawRect( + color = leftBorderColor, + topLeft = androidx.compose.ui.geometry.Offset.Zero, + size = androidx.compose.ui.geometry.Size(3.dp.toPx(), size.height) + ) + } + .clickable { selectedPassdown = p; showStalePrompt = false; view = "detail" } + .padding(start = 6.dp, top = 12.dp, end = 12.dp, bottom = 12.dp) + ) { + Column(modifier = Modifier.alpha(itemAlpha)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically) { + Text(p.registration.ifEmpty { "—" }, fontSize = 14.sp, + fontWeight = regWeight, color = regColor, letterSpacing = 0.3.sp) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically) { + if (p.lastSentAt.isNotEmpty()) { + Text("✉", fontSize = 10.sp, color = colors.textMuted) + } + Text(formatDatePilot(p.date), fontSize = 12.sp, color = colors.textMuted) + } + } + Spacer(Modifier.height(4.dp)) + Text( + listOfNotNull( + p.airport.ifEmpty { null }, p.fbo.ifEmpty { null }, + if (p.pic.isNotEmpty()) "PIC: ${p.pic}" else null + ).joinToString(" · ").ifEmpty { "No location" }, + fontSize = 12.sp, color = colors.textSecondary + ) + } + } + } + } + + // Manage link + Box(Modifier.fillMaxWidth().padding(top = 12.dp), contentAlignment = Alignment.Center) { + TextButton(onClick = { confirmClearAll = false; view = "manage" }) { + Text("Manage Saved Entries", fontSize = 12.sp, color = colors.textMuted, + fontWeight = FontWeight.Normal) + } + } + } + } + + /* ── IMPORT PREVIEW VIEW ── */ + else if (view == "import" && importPreview != null) { + val entries = importPreview!! + val existingIds = importPreviewExistingIds + val selectedCount = importSelected.size + val dupCount = entries.count { existingIds.contains(it.id) } + + fun handleImportCommit() { + val toImport = entries.filter { importSelected.contains(it.id) } + if (toImport.isEmpty()) { + importPreview = null; importSelected = emptySet(); view = "history" + return + } + val imported = importSelectedPassdownRecords(context, toImport) + passdowns = getAllPassdowns(context) + lookupCache = mapOf( + "registration" to getLookups(context, "registration"), + "airport" to getLookups(context, "airport"), + "fbo" to getLookups(context, "fbo"), + "crew" to getLookups(context, "crew"), + ) + importPreview = null; importSelected = emptySet() + importStatus = "success" to "Imported $imported passdown${if (imported != 1) "s" else ""}." + view = "history" + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + // Nav + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = { importPreview = null; view = "history" }) { + Text("← Cancel", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colors.accent) + } + Text("Import Preview", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.textMuted) + } + + // Summary + Box(Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)) + .background(colors.surfaceAlt).border(1.dp, colors.border, RoundedCornerShape(8.dp)) + .padding(12.dp)) { + Text(buildString { + append("${entries.size} passdown${if (entries.size != 1) "s" else ""} in file") + if (dupCount > 0) append(" · $dupCount duplicate${if (dupCount != 1) "s" else ""}") + append(" · $selectedCount selected") + }, fontSize = 12.sp, color = colors.textSecondary) + } + + // Select all / none + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton( + onClick = { importSelected = entries.map { it.id }.toSet() }, + enabled = importSelected.size < entries.size + ) { + Text("Select All", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, color = colors.accent) + } + TextButton( + onClick = { importSelected = emptySet() }, + enabled = importSelected.isNotEmpty() + ) { + Text("Select None", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, color = colors.accent) + } + } + + // Entry list + entries.forEach { p -> + val isDup = existingIds.contains(p.id) + val checked = importSelected.contains(p.id) + Row( + Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)) + .border(1.dp, + if (isDup) colors.warning.copy(alpha = 0.25f) else colors.border, + RoundedCornerShape(8.dp)) + .background(if (checked) colors.accent.copy(alpha = 0.04f) else colors.surface) + .clickable { + importSelected = if (checked) importSelected - p.id else importSelected + p.id + } + .padding(10.dp) + .alpha(if (checked) 1f else 0.5f), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.Top + ) { + Checkbox( + checked = checked, + onCheckedChange = { isChecked -> + importSelected = if (isChecked) importSelected + p.id else importSelected - p.id + }, + colors = CheckboxDefaults.colors(checkedColor = colors.accent), + modifier = Modifier.size(20.dp) + ) + Column(Modifier.weight(1f)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically) { + Text(p.registration.ifEmpty { "—" }, fontSize = 13.sp, + fontWeight = FontWeight.Bold, color = colors.accent, letterSpacing = 0.3.sp) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically) { + if (isDup) { + Text("DUPLICATE", fontSize = 10.sp, fontWeight = FontWeight.SemiBold, color = colors.warning) + } + Text(formatDatePilot(p.date), fontSize = 12.sp, color = colors.textMuted) + } + } + Text( + listOfNotNull( + p.airport.ifEmpty { null }, p.fbo.ifEmpty { null }, + if (p.pic.isNotEmpty()) "PIC: ${p.pic}" else null + ).joinToString(" · ").ifEmpty { "No location" }, + fontSize = 11.sp, color = colors.textSecondary + ) + } + } + } + + // Action buttons + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Button( + onClick = { handleImportCommit() }, + enabled = selectedCount > 0, + colors = ButtonDefaults.buttonColors(containerColor = colors.accent), + shape = RoundedCornerShape(6.dp), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 10.dp) + ) { + Text( + "Import${if (selectedCount > 0) " ($selectedCount)" else ""}", + fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = Color.White + ) + } + TextButton(onClick = { importPreview = null; view = "history" }) { + Text("Cancel", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.accent) + } + } + } + } + + /* ── MANAGE LOOKUPS VIEW ── */ + else if (view == "manage") { + val categories = listOf("registration" to "Registration", "airport" to "Airport", "fbo" to "FBO", "crew" to "Crew") + + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = { view = "history" }) { + Text("← History", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colors.accent) + } + Text("Manage Saved Entries", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.textMuted) + } + + categories.forEach { (key, label) -> + val items = lookupCache[key] ?: emptyList() + Column( + Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)) + .background(colors.surfaceAlt).border(1.dp, colors.border, RoundedCornerShape(8.dp)) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text("$label (${items.size})", fontSize = 11.sp, fontWeight = FontWeight.Bold, + color = colors.textMuted, letterSpacing = 0.5.sp, modifier = Modifier.padding(bottom = 4.dp)) + if (items.isEmpty()) { + Text("No entries", fontSize = 12.sp, color = colors.textMuted) + } else { + items.forEach { item -> + Row( + Modifier.fillMaxWidth().clip(RoundedCornerShape(4.dp)) + .background(colors.surface).padding(horizontal = 8.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(item, fontSize = 13.sp, color = colors.text) + TextButton( + onClick = { deleteLookup(context, key, item); refreshData() }, + contentPadding = PaddingValues(0.dp) + ) { + Text("✕", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colors.danger) + } + } + } + } + } + } + + HorizontalDivider(color = colors.border) + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically) { + if (!confirmClearAll) { + TextButton(onClick = { confirmClearAll = true }) { + Text("Clear All Saved Entries", fontSize = 12.sp, color = colors.danger) + } + } else { + TextButton(onClick = { clearAllLookups(context); refreshData(); confirmClearAll = false }) { + Text("Confirm Clear All", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = colors.danger) + } + TextButton(onClick = { confirmClearAll = false }) { + Text("Cancel", fontSize = 12.sp, color = colors.textMuted) + } + } + } + } + } + + /* ── DETAIL VIEW ── */ + else if (view == "detail" && selectedPassdown != null) { + val p = selectedPassdown!! + var showDeleteConfirm by remember { mutableStateOf(false) } + + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + // Nav + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = { view = "history" }) { + Text("← History", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colors.accent) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = { + clipboardManager.setText(AnnotatedString(passdownToPlainText(p))) + copyStatus = true + }) { + Text(if (copyStatus) "✓ Copied" else "Copy", fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, color = colors.accent) + } + TextButton(onClick = { handleSendWithStaleCheck(p) }) { + Text("Send", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colors.accent) + } + } + } + + // Stale date prompt + if (showStalePrompt) { + Box(Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)) + .background(colors.warningBg).border(1.dp, colors.warning.copy(alpha = 0.25f), RoundedCornerShape(8.dp)) + .padding(14.dp) + ) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("⚠ Passdown date (${formatDatePilot(p.date)}) is not today.", + fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.warning) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = { showStalePrompt = false; handleSend(p) }) { + Text("Send Anyway", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colors.warning) + } + Button( + onClick = { handleUpdateDateAndSend(p) }, + colors = ButtonDefaults.buttonColors(containerColor = colors.accent), + shape = RoundedCornerShape(6.dp), + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp) + ) { + Text("Update to Today & Send", fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + } + TextButton(onClick = { showStalePrompt = false }) { + Text("Cancel", fontSize = 12.sp, color = colors.textMuted) + } + } + } + } + } + + // Last sent timestamp + if (p.lastSentAt.isNotEmpty()) { + val sentFmt = try { + val parsed = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + }.parse(p.lastSentAt) + SimpleDateFormat("d MMM yyyy, HHmm", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + }.format(parsed!!) + "Z" + } catch (_: Exception) { p.lastSentAt } + Text("Sent $sentFmt", fontSize = 11.sp, color = colors.textMuted) + } + + // Header card + Box(Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)) + .background(colors.resultBg).border(1.dp, colors.resultBorder, RoundedCornerShape(8.dp)) + .padding(16.dp) + ) { + Column { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically) { + Text(p.registration.ifEmpty { "—" }, fontSize = 18.sp, + fontWeight = FontWeight.Bold, color = colors.accent, letterSpacing = 0.4.sp) + Text(formatDatePilot(p.date), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.text) + } + Spacer(Modifier.height(8.dp)) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + if (p.airport.isNotEmpty()) Text("Airport: ${p.airport}", fontSize = 13.sp, color = colors.textSecondary) + if (p.fbo.isNotEmpty()) Text("FBO: ${p.fbo}", fontSize = 13.sp, color = colors.textSecondary) + if (p.fboPhone.isNotEmpty()) { + Row(modifier = Modifier.clickable { + val intent = Intent(Intent.ACTION_DIAL, Uri.parse("tel:${phoneToTel(p.fboPhone)}")) + try { context.startActivity(intent) } catch (_: Exception) { } + }) { + Text("Phone: ", fontSize = 13.sp, color = colors.textSecondary) + Text(p.fboPhone, fontSize = 13.sp, color = colors.accent, fontWeight = FontWeight.Medium) + } + } + } + Spacer(Modifier.height(8.dp)) + // Crew + Text("PIC: ${p.pic.ifEmpty { "—" }}", fontSize = 13.sp, color = colors.textSecondary) + Text("SIC: ${p.sic.ifEmpty { "—" }}", fontSize = 13.sp, color = colors.textSecondary) + if (p.ca1.isNotEmpty()) Text("Lead CA: ${p.ca1}", fontSize = 13.sp, color = colors.textSecondary) + // Oncoming crew + if (p.oncomingPic.isNotEmpty() || p.oncomingSic.isNotEmpty()) { + Spacer(Modifier.height(6.dp)) + Text("ONCOMING CREW", fontSize = 10.sp, fontWeight = FontWeight.Bold, + color = colors.textMuted, letterSpacing = 0.5.sp) + if (p.oncomingPic.isNotEmpty()) Text("PIC: ${p.oncomingPic}", fontSize = 13.sp, color = colors.textSecondary) + if (p.oncomingSic.isNotEmpty()) Text("SIC: ${p.oncomingSic}", fontSize = 13.sp, color = colors.textSecondary) + } + } + } + + // Maintenance + if (p.maintenance.isNotEmpty()) { + Column { + Text("MAINTENANCE STATUS", fontSize = 11.sp, fontWeight = FontWeight.Bold, + color = colors.textMuted, letterSpacing = 0.5.sp, modifier = Modifier.padding(bottom = 4.dp)) + Text(p.maintenance, fontSize = 13.sp, color = colors.text, lineHeight = 20.sp) + } + } + + // Narrative + if (p.narrative.isNotEmpty()) { + Column { + Text("NARRATIVE", fontSize = 11.sp, fontWeight = FontWeight.Bold, + color = colors.textMuted, letterSpacing = 0.5.sp, modifier = Modifier.padding(bottom = 4.dp)) + Text(p.narrative, fontSize = 13.sp, color = colors.text, lineHeight = 20.sp) + } + } + + HorizontalDivider(color = colors.border) + + // Edit / Delete + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + TextButton(onClick = { handleEdit(p) }) { + Text("Edit", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.accent) + } + if (!showDeleteConfirm) { + TextButton(onClick = { showDeleteConfirm = true }) { + Text("Delete", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.danger) + } + } else { + TextButton(onClick = { handleDelete(p.id) }) { + Text("Confirm Delete", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = colors.danger) + } + TextButton(onClick = { showDeleteConfirm = false }) { + Text("Cancel", fontSize = 13.sp, color = colors.textMuted) + } + } + } + } + } +} diff --git a/app/src/main/java/com/harshmallow/pilottoolkit/ui/modules/PavementModule.kt b/app/src/main/java/com/harshmallow/pilottoolkit/ui/modules/PavementModule.kt new file mode 100644 index 0000000..fee4daa --- /dev/null +++ b/app/src/main/java/com/harshmallow/pilottoolkit/ui/modules/PavementModule.kt @@ -0,0 +1,208 @@ +package com.harshmallow.pilottoolkit.ui.modules + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.harshmallow.pilottoolkit.data.* +import com.harshmallow.pilottoolkit.ui.components.* +import com.harshmallow.pilottoolkit.ui.theme.ToolkitTheme +import kotlin.math.ceil +import kotlin.math.floor + +@Composable +fun PavementModule(aircraft: Aircraft) { + val colors = ToolkitTheme.colors + var weightInput by remember { mutableStateOf("") } + var system by remember { mutableStateOf(PavementSystem.PCR) } + var pavementType by remember { mutableStateOf(PavementType.RIGID) } + var subgrade by remember { mutableStateOf(SubgradeCategory.A) } + var airportInput by remember { mutableStateOf("") } + + // Reset on aircraft change + LaunchedEffect(aircraft.id) { + weightInput = ""; airportInput = "" + } + + val hasFormulas = when (system) { + PavementSystem.PCN -> aircraft.pcn != null + PavementSystem.PCR -> aircraft.pcr != null + } + val hasEswl = aircraft.eswl != null + + // Aircraft rating labels: PCN→ACN, PCR→ACR + val acrLabel = if (system == PavementSystem.PCN) "ACN" else "ACR" + val systemLabel = system.label + + // Not available at all + if (!aircraft.available) { + if (aircraft.eswlThresholds != null) { + // Show thresholds card + Box(modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)) + .background(colors.surfaceAlt).border(1.dp, colors.border, RoundedCornerShape(8.dp)).padding(16.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("ESWL Thresholds", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = colors.text) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(aircraft.eswlThresholds.singleWheel.label, fontSize = 13.sp, color = colors.textSecondary) + Text("${"%,d".format(aircraft.eswlThresholds.singleWheel.value)} lbs", + fontSize = 15.sp, fontWeight = FontWeight.Bold, color = colors.accent) + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(aircraft.eswlThresholds.dualWheel.label, fontSize = 13.sp, color = colors.textSecondary) + Text("${"%,d".format(aircraft.eswlThresholds.dualWheel.value)} lbs", + fontSize = 15.sp, fontWeight = FontWeight.Bold, color = colors.accent) + } + Text(aircraft.eswlThresholds.note, fontSize = 11.sp, color = colors.textMuted, + fontFamily = FontFamily.Monospace) + } + } + Spacer(Modifier.height(12.dp)) + UnavailableMessage("${aircraft.label} $acrLabel/$acrLabel formulas are not yet available.") + } else { + UnavailableMessage("${aircraft.label} pavement data is not yet available.") + } + return + } + + Column(verticalArrangement = Arrangement.spacedBy(18.dp)) { + ToolkitTextField(value = weightInput, onValueChange = { weightInput = it }, + label = "Aircraft Weight (lbs)", placeholder = "e.g. 73600", keyboardType = KeyboardType.Number) + + // Weight warning + val weight = weightInput.toDoubleOrNull() + val warning = if (weight != null) checkWeightLimits(weight, aircraft) else null + if (warning != null) { + WarningBanner(warning) + } + + if (hasFormulas) { + // System selector + airport input side by side + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Column(Modifier.weight(1f)) { + FieldLabel("System") + SegmentedSelector( + options = PavementSystem.entries.map { it.label }, + selected = system.ordinal, + onSelect = { system = PavementSystem.entries[it] } + ) + } + Column(Modifier.weight(1f)) { + ToolkitTextField(value = airportInput, onValueChange = { airportInput = it }, + label = "Airport $systemLabel", placeholder = "e.g. 55", keyboardType = KeyboardType.Number) + } + } + + FieldLabel("Pavement Type") + SegmentedSelector( + options = PavementType.entries.map { it.label }, + selected = pavementType.ordinal, + onSelect = { pavementType = PavementType.entries[it] } + ) + + FieldLabel("Subgrade Strength") + SegmentedSelector( + options = SubgradeCategory.entries.map { it.shortLabel }, + selected = subgrade.ordinal, + onSelect = { subgrade = SubgradeCategory.entries[it] } + ) + } else { + UnavailableMessage("${aircraft.label} $acrLabel formulas are not yet available.\nESWL calculation is available below.") + } + + ClearButton(onClick = { weightInput = ""; airportInput = "" }) + + // Calculate results + if (weight != null && weight > 0 && (warning == null || !warning.blocksCalculation)) { + val eswl = if (hasEswl) Calculations.calculateESWL(weight, aircraft.eswl!!) else null + val hasAcrAcn = hasFormulas + + var acrAcnValue: Int? = null + var activeFormula: LinearFormula? = null + if (hasAcrAcn) { + val formulas = if (system == PavementSystem.PCN) aircraft.pcn!! else aircraft.pcr!! + val subFormulas = formulas.forType(pavementType) + val formula = subFormulas.forCategory(subgrade) + activeFormula = formula + val raw = Calculations.evaluateFormula(formula, weight) + acrAcnValue = ceil(raw).toInt() + } + + val errorNote = if (system == PavementSystem.PCN) aircraft.pcnError else aircraft.pcrError + + ResultCard { + if (acrAcnValue != null) { + ResultRow( + label = "$acrLabel (${pavementType.label}, ${subgrade.label})", + value = acrAcnValue.toString() + ) + } + + if (eswl != null) { + if (acrAcnValue != null) ResultDivider() + ResultRow(label = "ESWL", value = "%,.0f".format(eswl), unit = "lbs") + } else if (aircraft.eswlThresholds != null && acrAcnValue != null) { + ResultDivider() + ResultRow(label = "ESWL Single (${aircraft.eswlThresholds.singleWheel.label})", + value = "%,d".format(aircraft.eswlThresholds.singleWheel.value), unit = "lbs") + ResultRow(label = "ESWL Dual (${aircraft.eswlThresholds.dualWheel.label})", + value = "%,d".format(aircraft.eswlThresholds.dualWheel.value), unit = "lbs") + } + + // Airport comparison + val airportVal = airportInput.toDoubleOrNull() + if (acrAcnValue != null && airportVal != null && airportVal > 0) { + Spacer(Modifier.height(12.dp)) + val ok = acrAcnValue <= airportVal + val bg = if (ok) colors.accent.copy(alpha = 0.1f) else colors.dangerBg + val border = if (ok) colors.accent.copy(alpha = 0.25f) else colors.dangerBorder + val fg = if (ok) colors.accent else colors.danger + val icon = if (ok) "✅" else "⛔" + val msg = if (ok) { + "$icon $acrLabel $acrAcnValue ≤ $systemLabel ${airportVal.toInt()} — Pavement is suitable." + } else { + val pct = ((acrAcnValue - airportVal) / airportVal * 100).toInt() + val f = activeFormula!! + val maxWeightRaw = (airportVal - f.intercept) / f.slope + val maxWeight = (floor(maxWeightRaw / 500) * 500).toInt() + val minBew = aircraft.weightLimits?.minBew ?: 0 + val weightMsg = if (maxWeight <= minBew) { + "Pavement cannot support this aircraft at any operational weight." + } else { + "Maximum weight for this pavement: %,d lbs.".format(maxWeight) + } + "$icon $acrLabel $acrAcnValue > $systemLabel ${airportVal.toInt()} — Pavement strength exceeded by $pct%. $weightMsg" + } + Box(modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)) + .background(bg).border(1.dp, border, RoundedCornerShape(8.dp)).padding(12.dp)) { + Text(msg, fontSize = 13.sp, fontWeight = FontWeight.Medium, color = fg, lineHeight = 20.sp) + } + } + + // Footer notes + Spacer(Modifier.height(12.dp)) + if (eswl != null) { + Text("ESWL = ${"%,.0f".format(weight)} × ${aircraft.eswl!!.weightFactor} ÷ ${aircraft.eswl.wheelFactor}", + fontSize = 12.sp, color = colors.textMuted, fontFamily = FontFamily.Monospace) + } + if (aircraft.eswlThresholds != null && eswl == null) { + Text(aircraft.eswlThresholds.note, fontSize = 12.sp, color = colors.textMuted, + fontFamily = FontFamily.Monospace) + } + if (hasAcrAcn && errorNote != null) { + Text("$acrLabel est. error: $errorNote", fontSize = 12.sp, color = colors.textMuted, + fontFamily = FontFamily.Monospace) + } + } + } + } +} diff --git a/app/src/main/java/com/harshmallow/pilottoolkit/ui/theme/Color.kt b/app/src/main/java/com/harshmallow/pilottoolkit/ui/theme/Color.kt new file mode 100644 index 0000000..8bffd22 --- /dev/null +++ b/app/src/main/java/com/harshmallow/pilottoolkit/ui/theme/Color.kt @@ -0,0 +1,33 @@ +package com.harshmallow.pilottoolkit.ui.theme + +import androidx.compose.ui.graphics.Color + +data class ToolkitColors( + val bg: Color, val surface: Color, val surfaceAlt: Color, + val text: Color, val textSecondary: Color, val textMuted: Color, + val border: Color, val accent: Color, + val navBg: Color, + val resultBg: Color, val resultBorder: Color, + val danger: Color, val dangerBg: Color, val dangerBorder: Color, + val warning: Color, val warningBg: Color, val warningBorder: Color, +) + +val LightColors = ToolkitColors( + bg = Color(0xFFF0F4F8), surface = Color.White, surfaceAlt = Color(0xFFF7F9FB), + text = Color(0xFF1A2332), textSecondary = Color(0xFF4A5568), textMuted = Color(0xFF8A9AB5), + border = Color(0xFFE2E8F0), accent = Color(0xFF2EC4B6), + navBg = Color(0xFF1A2332), + resultBg = Color(0xFFF7FDFC), resultBorder = Color(0xFFD5F0ED), + danger = Color(0xFFDC2626), dangerBg = Color(0xFFFEF2F2), dangerBorder = Color(0xFFFECACA), + warning = Color(0xFFD97706), warningBg = Color(0xFFFFFBEB), warningBorder = Color(0xFFFDE68A), +) + +val DarkColors = ToolkitColors( + bg = Color(0xFF0F1923), surface = Color(0xFF1A2736), surfaceAlt = Color(0xFF1E2D3D), + text = Color(0xFFE2EAF0), textSecondary = Color(0xFFB0BEC5), textMuted = Color(0xFF6B8299), + border = Color(0xFF2A3A4A), accent = Color(0xFF2EC4B6), + navBg = Color(0xFF0D1520), + resultBg = Color(0xFF142028), resultBorder = Color(0xFF1A3A35), + danger = Color(0xFFEF4444), dangerBg = Color(0xFF1A0F0F), dangerBorder = Color(0xFF5C2020), + warning = Color(0xFFF59E0B), warningBg = Color(0xFF1A1508), warningBorder = Color(0xFF5C4A15), +) diff --git a/app/src/main/java/com/harshmallow/pilottoolkit/ui/theme/Theme.kt b/app/src/main/java/com/harshmallow/pilottoolkit/ui/theme/Theme.kt new file mode 100644 index 0000000..a71f27c --- /dev/null +++ b/app/src/main/java/com/harshmallow/pilottoolkit/ui/theme/Theme.kt @@ -0,0 +1,21 @@ +package com.harshmallow.pilottoolkit.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf + +private val LocalToolkitColors = compositionLocalOf { LightColors } + +object ToolkitTheme { + val colors: ToolkitColors + @Composable get() = LocalToolkitColors.current +} + +@Composable +fun PilotToolkitTheme(darkTheme: Boolean = false, content: @Composable () -> Unit) { + val colors = if (darkTheme) DarkColors else LightColors + CompositionLocalProvider(LocalToolkitColors provides colors) { + MaterialTheme(content = content) + } +} diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..b98b1ad --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + PilotToolKit + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..1e7ccd4 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +