Initial Commit

This commit is contained in:
handfly 2026-05-02 21:38:19 -04:00
commit f434af8938
73 changed files with 6775 additions and 0 deletions

15
.gitignore vendored Normal file
View file

@ -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

3
.idea/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>

View file

@ -0,0 +1,123 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>

View file

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

6
.idea/compiler.xml Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DialogSelection />
</SelectionState>
</selectionStates>
</component>
</project>

18
.idea/gradle.xml Normal file
View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>

View file

@ -0,0 +1,61 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>

9
.idea/misc.xml Normal file
View file

@ -0,0 +1,9 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>

1
app/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

58
app/build.gradle.kts Normal file
View file

@ -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)
}

21
app/proguard-rules.pro vendored Normal file
View file

@ -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

Binary file not shown.

Binary file not shown.

View file

@ -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
}

BIN
app/release/ptk.apk Normal file

Binary file not shown.

View file

@ -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)
}
}

View file

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.PilotToolKit">
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.PilotToolKit">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- .ptz file association -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" android:host="*" />
<data android:scheme="file" />
<data android:mimeType="*/*" />
<data android:pathPattern=".*\\.ptz" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/octet-stream" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -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
]
]
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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°"
}
}

View file

@ -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<Uri?>(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
}
}
}

View file

@ -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 `<application>` tag:
```xml
<uses-permission android:name="android.permission.INTERNET" />
```
## 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.

View file

@ -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<FuelBucket>? = 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<Aircraft> get() = DataLoader.aircraftRegistry
fun getAircraftById(id: String): Aircraft = aircraftRegistry.first { it.id == id }

View file

@ -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<Aircraft> = emptyList()
private set
var metersToFeetTable: List<FlightLevelEntry> = emptyList()
private set
var chinaFLAS: List<FlightLevelEntry> = emptyList()
private set
var chinaRVSMEastbound: Set<Int> = emptySet()
private set
var chinaRVSMWestbound: Set<Int> = emptySet()
private set
var pavementSubgrades: Map<String, String> = emptyMap()
private set
/** Aircraft IDs that failed to load, with error messages. */
var loadErrors: Map<String, String> = 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<Aircraft>()
val errors = mutableMapOf<String, String>()
// 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"))
)
}
}

View file

@ -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<FlightLevelEntry> get() = DataLoader.metersToFeetTable
val chinaFLAS: List<FlightLevelEntry> get() = DataLoader.chinaFLAS
val chinaRVSMEastbound: Set<Int> get() = DataLoader.chinaRVSMEastbound
val chinaRVSMWestbound: Set<Int> get() = DataLoader.chinaRVSMWestbound

View file

@ -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<FuelBucket>)
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<FuelBucket>) {
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<String, FuelProfile> {
val map = mutableMapOf<String, FuelProfile>()
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<List<FuelBucket>?, 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<FuelBucket>()
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<String, String, List<FuelBucket>> {
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)
}

View file

@ -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<Pair<String, String>?>(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)
}
}
}
}
}

View file

@ -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<String>, 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)
}
}

View file

@ -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 (023 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<RestSlot>? = 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)
}
}
}
}
}
}

View file

@ -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<Double, Double>? {
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 (0136 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)
}
}
}
}
}

View file

@ -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
)
}

View file

@ -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
)
}
}

View file

@ -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)
}
}
}
}
}

View file

@ -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<Date?>(null) }
var dateMismatch by remember { mutableStateOf(false) }
var fetchError by remember { mutableStateOf<String?>(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) }
* )
*/

File diff suppressed because it is too large Load diff

View file

@ -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)
}
}
}
}
}

View file

@ -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),
)

View file

@ -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)
}
}

View file

@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View file

@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View file

@ -0,0 +1,3 @@
<resources>
<string name="app_name">PilotToolKit</string>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.PilotToolKit" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="ptz_exports" path="." />
</paths>

View file

@ -0,0 +1,17 @@
package com.harshmallow.pilottoolkit
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

5
build.gradle.kts Normal file
View file

@ -0,0 +1,5 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false
}

23
gradle.properties Normal file
View file

@ -0,0 +1,23 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true

View file

@ -0,0 +1,12 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect
toolchainVersion=21

31
gradle/libs.versions.toml Normal file
View file

@ -0,0 +1,31 @@
[versions]
agp = "9.0.1"
coreKtx = "1.10.1"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
lifecycleRuntimeKtx = "2.6.1"
activityCompose = "1.8.0"
kotlin = "2.0.21"
composeBom = "2024.09.00"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View file

@ -0,0 +1,9 @@
#Fri Feb 20 12:54:38 EST 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=72f44c9f8ebcb1af43838f45ee5c4aa9c5444898b3468ab3f4af7b6076c5bc3f
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
gradlew vendored Executable file
View file

@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
gradlew.bat vendored Normal file
View file

@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

26
settings.gradle.kts Normal file
View file

@ -0,0 +1,26 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "PilotToolKit"
include(":app")