Initial Commit
15
.gitignore
vendored
Normal 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
|
|
@ -0,0 +1,3 @@
|
|||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
6
.idea/AndroidProjectSystem.xml
Normal 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>
|
||||
123
.idea/codeStyles/Project.xml
Normal 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>
|
||||
5
.idea/codeStyles/codeStyleConfig.xml
Normal 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
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="21" />
|
||||
</component>
|
||||
</project>
|
||||
11
.idea/deploymentTargetSelector.xml
Normal 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
|
|
@ -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>
|
||||
61
.idea/inspectionProfiles/Project_Default.xml
Normal 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
|
|
@ -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>
|
||||
17
.idea/runConfigurations.xml
Normal 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
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
58
app/build.gradle.kts
Normal 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
|
|
@ -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
|
||||
BIN
app/release/baselineProfiles/0/app-release.dm
Normal file
BIN
app/release/baselineProfiles/1/app-release.dm
Normal file
37
app/release/output-metadata.json
Normal 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
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
57
app/src/main/AndroidManifest.xml
Normal 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>
|
||||
|
||||
|
||||
498
app/src/main/assets/aircraft/G450.json
Normal 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
|
||||
]
|
||||
]
|
||||
}
|
||||
18
app/src/main/assets/aircraft/G500.json
Normal 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
|
||||
}
|
||||
27
app/src/main/assets/aircraft/G650.json
Normal 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
|
||||
}
|
||||
109
app/src/main/assets/aircraft/G700.json
Normal 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
|
||||
}
|
||||
680
app/src/main/assets/reference.json
Normal 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°"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
79
app/src/main/java/com/harshmallow/pilottoolkit/SETUP.md
Normal 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.
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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"))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,318 @@
|
|||
package com.harshmallow.pilottoolkit.ui.modules
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.harshmallow.pilottoolkit.ui.components.*
|
||||
import com.harshmallow.pilottoolkit.ui.theme.ToolkitTheme
|
||||
import kotlin.math.floor
|
||||
|
||||
@Composable
|
||||
fun CrewRestModule() {
|
||||
val colors = ToolkitTheme.colors
|
||||
|
||||
var deptInput by rememberSaveable { mutableStateOf("") }
|
||||
var eteInput by rememberSaveable { mutableStateOf("") }
|
||||
var crew1 by rememberSaveable { mutableStateOf("") }
|
||||
var crew2 by rememberSaveable { mutableStateOf("") }
|
||||
var crew3 by rememberSaveable { mutableStateOf("") }
|
||||
var crew4 by rememberSaveable { mutableStateOf("") }
|
||||
var locked by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val HANDS_ON = 30 // minutes all crew on deck after departure and before arrival
|
||||
|
||||
// Parse "HHMM" or "HH:MM" into total minutes — departure (0–23 hours)
|
||||
fun parseTime(str: String): Int? {
|
||||
val clean = str.replace(Regex("[^0-9]"), "")
|
||||
if (clean.length !in 3..4) return null
|
||||
val padded = clean.padStart(4, '0')
|
||||
val h = padded.substring(0, 2).toIntOrNull() ?: return null
|
||||
val m = padded.substring(2, 4).toIntOrNull() ?: return null
|
||||
if (h > 23 || m > 59) return null
|
||||
return h * 60 + m
|
||||
}
|
||||
|
||||
// Parse ETE — supports HH:MM (with colon), HHMM (4 digits), or total minutes (1-3 digits)
|
||||
fun parseEte(str: String): Int? {
|
||||
val hasColon = str.contains(":")
|
||||
val clean = str.replace(Regex("[^0-9]"), "")
|
||||
if (clean.isEmpty()) return null
|
||||
if (hasColon) {
|
||||
val parts = str.split(":")
|
||||
if (parts.size != 2) return null
|
||||
val h = parts[0].toIntOrNull() ?: return null
|
||||
val m = parts[1].toIntOrNull() ?: return null
|
||||
if (m > 59) return null
|
||||
return h * 60 + m
|
||||
}
|
||||
if (clean.length <= 3) {
|
||||
val mins = clean.toIntOrNull() ?: return null
|
||||
return if (mins > 0) mins else null
|
||||
}
|
||||
if (clean.length == 4) {
|
||||
val h = clean.substring(0, 2).toIntOrNull() ?: return null
|
||||
val m = clean.substring(2, 4).toIntOrNull() ?: return null
|
||||
if (m > 59) return null
|
||||
return h * 60 + m
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Format minutes to "HH:MMz" (wraps at 24h)
|
||||
fun fmtZulu(mins: Int): String {
|
||||
val wrapped = ((mins % 1440) + 1440) % 1440
|
||||
val h = wrapped / 60
|
||||
val m = wrapped % 60
|
||||
return "%02d:%02dz".format(h, m)
|
||||
}
|
||||
|
||||
// Format duration in minutes to "Xh Ym (N min)"
|
||||
fun fmtDuration(mins: Int): String {
|
||||
val h = mins / 60
|
||||
val m = mins % 60
|
||||
val hm = when {
|
||||
h == 0 -> "$m min"
|
||||
m == 0 -> "${h}h"
|
||||
else -> "${h}h ${m}m"
|
||||
}
|
||||
return if (h > 0) "$hm ($mins min)" else hm
|
||||
}
|
||||
|
||||
// Short duration format without total minutes
|
||||
fun fmtDurationShort(mins: Int): String {
|
||||
val h = mins / 60
|
||||
val m = mins % 60
|
||||
return when {
|
||||
h == 0 -> "$m min"
|
||||
m == 0 -> "${h}h"
|
||||
else -> "${h}h ${m}m"
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-format on focus loss — departure always formats, ETE only formats 4-digit HHMM
|
||||
fun formatDeptOnBlur(value: String): String {
|
||||
val clean = value.replace(Regex("[^0-9]"), "")
|
||||
return if (clean.length in 3..4) {
|
||||
val padded = clean.padStart(4, '0')
|
||||
padded.substring(0, 2) + ":" + padded.substring(2)
|
||||
} else value
|
||||
}
|
||||
|
||||
fun formatEteOnBlur(value: String): String {
|
||||
val clean = value.replace(Regex("[^0-9]"), "")
|
||||
return if (!value.contains(":") && clean.length == 4) {
|
||||
clean.substring(0, 2) + ":" + clean.substring(2)
|
||||
} else value
|
||||
}
|
||||
|
||||
fun handleTimeInput(raw: String): String {
|
||||
return raw.replace(Regex("[^0-9:]"), "").take(5)
|
||||
}
|
||||
|
||||
val activeCrew = listOf(crew1, crew2, crew3, crew4).filter { it.trim().isNotEmpty() }
|
||||
val crewCount = activeCrew.size
|
||||
val deptMinutes = parseTime(deptInput)
|
||||
val eteMinutes = parseEte(eteInput)
|
||||
val restWindow = if (eteMinutes != null) eteMinutes - (HANDS_ON * 2) else null
|
||||
val canCalculate = deptMinutes != null && eteMinutes != null && crewCount >= 3 && (restWindow ?: 0) > 0
|
||||
|
||||
data class RestSlot(val id: String, val restMinutes: Int, val begins: Int, val ends: Int)
|
||||
|
||||
val results: List<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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 (01–36 or heading)", placeholder = "e.g. 27 or 270",
|
||||
keyboardType = KeyboardType.Number)
|
||||
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
FieldLabel("Wind (dir/spd)")
|
||||
OutlinedTextField(
|
||||
value = windInput,
|
||||
onValueChange = { handleWindInput(it) },
|
||||
placeholder = { Text("e.g. 28025", fontSize = 14.sp, color = colors.textMuted) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.onFocusChanged { if (!it.isFocused) formatWindInput() },
|
||||
singleLine = true, shape = RoundedCornerShape(8.dp),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = colors.accent, unfocusedBorderColor = colors.border,
|
||||
focusedTextColor = colors.text, unfocusedTextColor = colors.text,
|
||||
cursorColor = colors.accent,
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
)
|
||||
)
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
ToolkitTextField(value = gustInput, onValueChange = { gustInput = it },
|
||||
label = "Gust (kts)", placeholder = "e.g. 25",
|
||||
keyboardType = KeyboardType.Number, sublabel = "optional")
|
||||
}
|
||||
}
|
||||
|
||||
ClearButton(onClick = { runwayInput = ""; windInput = ""; gustInput = "" })
|
||||
|
||||
if (results != null) {
|
||||
ResultCard {
|
||||
val hwLabel = if (results.isTailwind) "Tailwind" else "Headwind"
|
||||
val hwColor = if (results.isTailwind) colors.danger else colors.text
|
||||
ResultRow(label = "Crosswind", value = "${results.crosswind} kts")
|
||||
ResultRowColored(label = hwLabel, value = "${abs(results.headwind)} kts", color = hwColor)
|
||||
|
||||
if (results.gustCrosswind != null && results.gustHeadwind != null) {
|
||||
ResultDivider()
|
||||
val gLabel = if (results.gustIsTailwind) "Gust Tailwind" else "Gust Headwind"
|
||||
val gColor = if (results.gustIsTailwind) colors.danger else colors.text
|
||||
ResultRow(label = "Gust Crosswind", value = "${results.gustCrosswind} kts")
|
||||
ResultRowColored(label = gLabel, value = "${abs(results.gustHeadwind)} kts", color = gColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
* )
|
||||
*/
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
170
app/src/main/res/drawable/ic_launcher_background.xml
Normal 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>
|
||||
30
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
||||
6
app/src/main/res/mipmap-anydpi/ic_launcher.xml
Normal 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>
|
||||
6
app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
Normal 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>
|
||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 982 B |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
10
app/src/main/res/values/colors.xml
Normal 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>
|
||||
3
app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<resources>
|
||||
<string name="app_name">PilotToolKit</string>
|
||||
</resources>
|
||||
5
app/src/main/res/values/themes.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.PilotToolKit" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
13
app/src/main/res/xml/backup_rules.xml
Normal 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>
|
||||
19
app/src/main/res/xml/data_extraction_rules.xml
Normal 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>
|
||||
4
app/src/main/res/xml/file_paths.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path name="ptz_exports" path="." />
|
||||
</paths>
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
12
gradle/gradle-daemon-jvm.properties
Normal 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
|
|
@ -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
9
gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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")
|
||||