├── .gitignore ├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties └── libs.versions.toml ├── iosApp ├── iosApp │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── app-icon-1024.png │ │ │ └── Contents.json │ │ └── AccentColor.colorset │ │ │ └── Contents.json │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── KMMViewModel.swift │ ├── iOSApp.swift │ ├── Info.plist │ └── ContentView.swift ├── Configuration │ └── Config.xcconfig └── iosApp.xcodeproj │ ├── project.xcworkspace │ ├── contents.xcworkspacedata │ ├── xcuserdata │ │ └── joreilly.xcuserdatad │ │ │ ├── UserInterfaceState.xcuserstate │ │ │ └── xcschemes │ │ │ └── xcschememanagement.plist │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved │ └── xcuserdata │ └── joreilly.xcuserdatad │ └── xcschemes │ ├── xcschememanagement.plist │ └── iosApp.xcscheme ├── composeApp ├── src │ ├── androidMain │ │ ├── res │ │ │ ├── values │ │ │ │ └── strings.xml │ │ │ ├── mipmap-hdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-mdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ ├── drawable-v24 │ │ │ │ └── ic_launcher_foreground.xml │ │ │ └── drawable │ │ │ │ └── ic_launcher_background.xml │ │ ├── kotlin │ │ │ └── dev │ │ │ │ └── johnoreilly │ │ │ │ └── climatetrace │ │ │ │ ├── theme │ │ │ │ ├── Color.kt │ │ │ │ ├── Type.kt │ │ │ │ └── Theme.kt │ │ │ │ ├── agent │ │ │ │ └── ClimateTraceAgent.android.kt │ │ │ │ ├── di │ │ │ │ └── Koin.android.kt │ │ │ │ └── MainActivity.kt │ │ └── AndroidManifest.xml │ ├── commonMain │ │ └── kotlin │ │ │ ├── dev │ │ │ ├── johnoreilly │ │ │ │ ├── climatetrace │ │ │ │ │ ├── remote │ │ │ │ │ │ ├── requests.http │ │ │ │ │ │ ├── PopulationApi.kt │ │ │ │ │ │ └── ClimateTraceApi.kt │ │ │ │ │ ├── .DS_Store │ │ │ │ │ ├── ui │ │ │ │ │ │ ├── theme │ │ │ │ │ │ │ ├── Type.kt │ │ │ │ │ │ │ ├── Dimension.kt │ │ │ │ │ │ │ ├── Color.kt │ │ │ │ │ │ │ └── Theme.kt │ │ │ │ │ │ ├── ChartNode.kt │ │ │ │ │ │ ├── CountryEmissionsScreen.kt │ │ │ │ │ │ ├── CountryListScreen.kt │ │ │ │ │ │ ├── utils │ │ │ │ │ │ │ └── ResizablePanel.kt │ │ │ │ │ │ ├── SectorEmissionsPieChart.kt │ │ │ │ │ │ ├── CountryAssetEmissionsInfoTreeMapChart.kt │ │ │ │ │ │ ├── CountryInfoDetailedView.kt │ │ │ │ │ │ └── ClimateTraceScreen.kt │ │ │ │ │ ├── agent │ │ │ │ │ │ ├── AgentProvider.kt │ │ │ │ │ │ ├── ExitTool.kt │ │ │ │ │ │ ├── ClimateTraceTool.kt │ │ │ │ │ │ └── ClimateTraceAgentProvider.kt │ │ │ │ │ ├── data │ │ │ │ │ │ └── ClimateTraceRepository.kt │ │ │ │ │ ├── viewmodel │ │ │ │ │ │ ├── CountryListViewModel.kt │ │ │ │ │ │ ├── CountryDetailsViewModel.kt │ │ │ │ │ │ └── AgentViewModel.kt │ │ │ │ │ └── di │ │ │ │ │ │ └── Koin.kt │ │ │ │ └── .DS_Store │ │ │ └── .DS_Store │ │ │ └── App.kt │ ├── wasmJsMain │ │ ├── resources │ │ │ ├── styles.css │ │ │ └── index.html │ │ └── kotlin │ │ │ ├── main.kt │ │ │ └── dev │ │ │ └── johnoreilly │ │ │ └── climatetrace │ │ │ ├── agent │ │ │ └── ClimateTraceAgent.wasmJs.kt │ │ │ └── di │ │ │ └── Koin.wasmjs.kt │ ├── jvmMain │ │ └── kotlin │ │ │ ├── dev │ │ │ └── johnoreilly │ │ │ │ └── climatetrace │ │ │ │ ├── di │ │ │ │ ├── Koin.jvm.kt │ │ │ │ └── Koin.desktop.kt │ │ │ │ └── agent │ │ │ │ └── ClimateTraceAgent.jvm.kt │ │ │ └── main.kt │ ├── iosMain │ │ └── kotlin │ │ │ ├── dev │ │ │ └── johnoreilly │ │ │ │ └── climatetrace │ │ │ │ ├── agent │ │ │ │ └── ClimateTraceAgent.ios.kt │ │ │ │ └── di │ │ │ │ └── Koin.ios.kt │ │ │ └── MainViewController.kt │ └── commonTest │ │ └── kotlin │ │ └── dev │ │ └── johnoreilly │ │ └── climatetrace │ │ └── screen │ │ └── ClimateTraceTestsScreen.kt └── build.gradle.kts ├── agents ├── src │ └── main │ │ └── kotlin │ │ ├── main.kt │ │ └── adk │ │ ├── DevUiMain.kt │ │ ├── ClimateTraceTool.kt │ │ └── adk_agent.kt └── build.gradle.kts ├── renovate.json ├── .devcontainer └── devcontainer.json ├── .junie └── guidelines.md ├── .github └── workflows │ ├── android.yml │ ├── junie.yml │ ├── ios.yml │ └── build-and-publish-web.yml ├── settings.gradle.kts ├── gradle.properties ├── mcp-server ├── desktop-extension │ └── manifest.json ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── McpServer.kt ├── gradlew.bat ├── README.md ├── gradlew └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | /local.properties 2 | **/.DS_Store 3 | **/build/ 4 | .kotlin 5 | .gradle 6 | **/.idea/* 7 | 8 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joreilly/ClimateTraceKMP/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } -------------------------------------------------------------------------------- /iosApp/Configuration/Config.xcconfig: -------------------------------------------------------------------------------- 1 | TEAM_ID= 2 | BUNDLE_ID=dev.johnoreilly.climatetrace.ClimateTraceKMP 3 | APP_NAME=ClimateTraceKMP -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | ClimateTraceKMP 3 | -------------------------------------------------------------------------------- /agents/src/main/kotlin/main.kt: -------------------------------------------------------------------------------- 1 | import dev.johnoreilly.climatetrace.di.initKoin 2 | 3 | val koin = initKoin(enableNetworkLogs = true).koin 4 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/johnoreilly/climatetrace/remote/requests.http: -------------------------------------------------------------------------------- 1 | GET https://api.climatetrace.org/v6/definitions/countries 2 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joreilly/ClimateTraceKMP/HEAD/composeApp/src/commonMain/kotlin/dev/.DS_Store -------------------------------------------------------------------------------- /iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } -------------------------------------------------------------------------------- /iosApp/iosApp/KMMViewModel.swift: -------------------------------------------------------------------------------- 1 | import ComposeApp 2 | import KMPObservableViewModelCore 3 | 4 | extension Kmp_observableviewmodel_coreViewModel: ViewModel { } 5 | -------------------------------------------------------------------------------- /composeApp/src/wasmJsMain/resources/styles.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | width: 100%; 3 | height: 100%; 4 | margin: 0; 5 | padding: 0; 6 | overflow: hidden; 7 | } -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joreilly/ClimateTraceKMP/HEAD/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joreilly/ClimateTraceKMP/HEAD/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joreilly/ClimateTraceKMP/HEAD/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/johnoreilly/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joreilly/ClimateTraceKMP/HEAD/composeApp/src/commonMain/kotlin/dev/johnoreilly/.DS_Store -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joreilly/ClimateTraceKMP/HEAD/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joreilly/ClimateTraceKMP/HEAD/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joreilly/ClimateTraceKMP/HEAD/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joreilly/ClimateTraceKMP/HEAD/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joreilly/ClimateTraceKMP/HEAD/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joreilly/ClimateTraceKMP/HEAD/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joreilly/ClimateTraceKMP/HEAD/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joreilly/ClimateTraceKMP/HEAD/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/johnoreilly/climatetrace/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joreilly/ClimateTraceKMP/HEAD/composeApp/src/commonMain/kotlin/dev/johnoreilly/climatetrace/.DS_Store -------------------------------------------------------------------------------- /agents/src/main/kotlin/adk/DevUiMain.kt: -------------------------------------------------------------------------------- 1 | package adk 2 | 3 | import adk.ClimateTraceAgent.Companion.initAgent 4 | import com.google.adk.web.AdkWebServer 5 | 6 | fun main() { 7 | AdkWebServer.start(initAgent()) 8 | } 9 | -------------------------------------------------------------------------------- /iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } -------------------------------------------------------------------------------- /iosApp/iosApp.xcodeproj/project.xcworkspace/xcuserdata/joreilly.xcuserdatad/UserInterfaceState.xcuserstate: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joreilly/ClimateTraceKMP/HEAD/iosApp/iosApp.xcodeproj/project.xcworkspace/xcuserdata/joreilly.xcuserdatad/UserInterfaceState.xcuserstate -------------------------------------------------------------------------------- /iosApp/iosApp/iOSApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import ComposeApp 3 | 4 | @main 5 | struct iOSApp: App { 6 | 7 | init() { 8 | Koin_iosKt.doInitKoin() 9 | } 10 | 11 | var body: some Scene { 12 | WindowGroup { 13 | ContentView() 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /composeApp/src/jvmMain/kotlin/dev/johnoreilly/climatetrace/di/Koin.jvm.kt: -------------------------------------------------------------------------------- 1 | package dev.johnoreilly.climatetrace.di 2 | 3 | import io.ktor.client.engine.HttpClientEngine 4 | import io.ktor.client.engine.java.Java 5 | 6 | actual fun createHttpClientEngine(): HttpClientEngine { 7 | return Java.create() 8 | } -------------------------------------------------------------------------------- /iosApp/iosApp.xcodeproj/project.xcworkspace/xcuserdata/joreilly.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /composeApp/src/wasmJsMain/kotlin/main.kt: -------------------------------------------------------------------------------- 1 | import androidx.compose.ui.ExperimentalComposeUiApi 2 | import androidx.compose.ui.window.CanvasBasedWindow 3 | import androidx.compose.ui.window.ComposeViewport 4 | 5 | @OptIn(ExperimentalComposeUiApi::class) 6 | fun main() { 7 | ComposeViewport(content = { App() }) 8 | } -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Java", 3 | "image": "mcr.microsoft.com/devcontainers/java:1-21", 4 | "features": { 5 | "ghcr.io/devcontainers/features/java:1": { 6 | "version": "none", 7 | "installMaven": "true", 8 | "mavenVersion": "3.8.6", 9 | "installGradle": "true" 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "app-icon-1024.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | } 9 | ], 10 | "info" : { 11 | "author" : "xcode", 12 | "version" : 1 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/johnoreilly/climatetrace/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.example.ui.theme 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | val AppTypography = Typography() 10 | -------------------------------------------------------------------------------- /.junie/guidelines.md: -------------------------------------------------------------------------------- 1 | # Project Guidelines 2 | 3 | ## Project Structure 4 | This is a Kotlin Multiplatform project with Compose Multiplatform that includes: 5 | * `composeApp` - Shared Kotlin code with Compose UI 6 | * `iosApp` - iOS application 7 | 8 | ## Building the Project 9 | When building this project, Junie should use the following Gradle task: 10 | ``` 11 | :composeApp:compileKotlinJvm 12 | ``` 13 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/dev/johnoreilly/climatetrace/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package dev.johnoreilly.wordmaster.androidApp.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val Purple80 = Color(0xFFD0BCFF) 6 | val PurpleGrey80 = Color(0xFFCCC2DC) 7 | val Pink80 = Color(0xFFEFB8C8) 8 | 9 | val Purple40 = Color(0xFF6650a4) 10 | val PurpleGrey40 = Color(0xFF625b71) 11 | val Pink40 = Color(0xFF7D5260) -------------------------------------------------------------------------------- /composeApp/src/wasmJsMain/resources/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ClimateTraceKMP 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /iosApp/iosApp.xcodeproj/xcuserdata/joreilly.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | iosApp.xcscheme 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /.github/workflows/android.yml: -------------------------------------------------------------------------------- 1 | name: Android CI 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: macos-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v5 12 | - name: set up JDK 21 13 | uses: actions/setup-java@v5 14 | with: 15 | distribution: 'zulu' 16 | java-version: 21 17 | - name: Build android app 18 | run: ./gradlew assembleDebug 19 | - name: Run Unit Tests 20 | run: ./gradlew :composeApp:jvmTest 21 | 22 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/johnoreilly/climatetrace/agent/AgentProvider.kt: -------------------------------------------------------------------------------- 1 | package dev.johnoreilly.climatetrace.agent 2 | 3 | import ai.koog.agents.core.agent.AIAgent 4 | 5 | /** 6 | * Interface for agent factory 7 | */ 8 | interface AgentProvider { 9 | val description: String 10 | 11 | suspend fun provideAgent( 12 | onToolCallEvent: suspend (String) -> Unit, 13 | onErrorEvent: suspend (String) -> Unit, 14 | onAssistantMessage: suspend (String) -> String 15 | ): AIAgent 16 | } 17 | -------------------------------------------------------------------------------- /composeApp/src/iosMain/kotlin/dev/johnoreilly/climatetrace/agent/ClimateTraceAgent.ios.kt: -------------------------------------------------------------------------------- 1 | package dev.johnoreilly.climatetrace.agent 2 | 3 | import ai.koog.prompt.executor.clients.google.GoogleModels 4 | import ai.koog.prompt.executor.llms.all.simpleGoogleAIExecutor 5 | import ai.koog.prompt.executor.model.PromptExecutor 6 | import dev.johnoreilly.climatetrace.BuildKonfig 7 | 8 | actual fun getLLModel() = GoogleModels.Gemini2_5Flash 9 | 10 | actual fun getPromptExecutor(): PromptExecutor { 11 | return simpleGoogleAIExecutor(BuildKonfig.GEMINI_API_KEY) 12 | } -------------------------------------------------------------------------------- /iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "88d7673b251b3cbb9c40069df64b8e0aa1c4062dac38b066e5314b1847241136", 3 | "pins" : [ 4 | { 5 | "identity" : "kmp-observableviewmodel", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/rickclephas/KMP-ObservableViewModel.git", 8 | "state" : { 9 | "revision" : "f18dcd23199915f7134db9ef5b0d4425bf4edb91", 10 | "version" : "1.0.0-BETA-1" 11 | } 12 | } 13 | ], 14 | "version" : 3 15 | } 16 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/dev/johnoreilly/climatetrace/agent/ClimateTraceAgent.android.kt: -------------------------------------------------------------------------------- 1 | package dev.johnoreilly.climatetrace.agent 2 | 3 | import ai.koog.prompt.executor.clients.google.GoogleModels 4 | import ai.koog.prompt.executor.llms.all.simpleGoogleAIExecutor 5 | import ai.koog.prompt.executor.model.PromptExecutor 6 | import dev.johnoreilly.climatetrace.BuildKonfig 7 | 8 | actual fun getLLModel() = GoogleModels.Gemini2_5Flash 9 | 10 | actual fun getPromptExecutor(): PromptExecutor { 11 | return simpleGoogleAIExecutor(BuildKonfig.GEMINI_API_KEY) 12 | } -------------------------------------------------------------------------------- /composeApp/src/wasmJsMain/kotlin/dev/johnoreilly/climatetrace/agent/ClimateTraceAgent.wasmJs.kt: -------------------------------------------------------------------------------- 1 | package dev.johnoreilly.climatetrace.agent 2 | 3 | import ai.koog.prompt.executor.clients.google.GoogleModels 4 | import ai.koog.prompt.executor.llms.all.simpleGoogleAIExecutor 5 | import ai.koog.prompt.executor.model.PromptExecutor 6 | import dev.johnoreilly.climatetrace.BuildKonfig 7 | 8 | actual fun getLLModel() = GoogleModels.Gemini2_5Flash 9 | 10 | actual fun getPromptExecutor(): PromptExecutor { 11 | return simpleGoogleAIExecutor(BuildKonfig.GEMINI_API_KEY) 12 | } -------------------------------------------------------------------------------- /.github/workflows/junie.yml: -------------------------------------------------------------------------------- 1 | name: Junie 2 | run-name: Junie run ${{ inputs.run_id }} 3 | 4 | permissions: 5 | contents: write 6 | 7 | on: 8 | workflow_dispatch: 9 | inputs: 10 | run_id: 11 | description: "id of workflow process" 12 | required: true 13 | workflow_params: 14 | description: "stringified params" 15 | required: true 16 | 17 | jobs: 18 | call-workflow-passing-data: 19 | uses: jetbrains-junie/junie-workflows/.github/workflows/ej-issue.yml@main 20 | with: 21 | workflow_params: ${{ inputs.workflow_params }} 22 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | google { 4 | content { 5 | includeGroupByRegex("com\\.android.*") 6 | includeGroupByRegex("com\\.google.*") 7 | includeGroupByRegex("androidx.*") 8 | } 9 | } 10 | mavenCentral() 11 | gradlePluginPortal() 12 | } 13 | } 14 | dependencyResolutionManagement { 15 | repositories { 16 | google() 17 | mavenCentral() 18 | } 19 | } 20 | 21 | enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") 22 | rootProject.name = "ClimateTraceKMP" 23 | include(":composeApp") 24 | include(":mcp-server") 25 | include(":agents") 26 | -------------------------------------------------------------------------------- /composeApp/src/wasmJsMain/kotlin/dev/johnoreilly/climatetrace/di/Koin.wasmjs.kt: -------------------------------------------------------------------------------- 1 | package dev.johnoreilly.climatetrace.di 2 | 3 | import dev.johnoreilly.climatetrace.remote.Country 4 | import io.github.xxfast.kstore.KStore 5 | import io.github.xxfast.kstore.storage.storeOf 6 | import io.ktor.client.engine.HttpClientEngine 7 | import io.ktor.client.engine.js.Js 8 | import org.koin.core.module.Module 9 | import org.koin.dsl.module 10 | 11 | actual fun dataModule(): Module = module { 12 | single>> { 13 | storeOf(key = "countries", default = emptyList()) 14 | } 15 | } 16 | actual fun createHttpClientEngine(): HttpClientEngine { 17 | return Js.create() 18 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official 2 | kotlin.incremental.wasm=true 3 | kotlin.native.toolchain.enabled=false 4 | 5 | #Gradle 6 | # Increase Gradle and Kotlin compiler daemon memory to avoid GC overhead OOMs during compilation 7 | org.gradle.jvmargs=-Xmx6144M -Xms2048M -XX:MaxMetaspaceSize=1024M -Dfile.encoding=UTF-8 8 | kotlin.daemon.jvmargs=-Xmx4096M -Xms1024M 9 | 10 | 11 | #Android 12 | android.nonTransitiveRClass=true 13 | android.useAndroidX=true 14 | 15 | #Compose 16 | org.jetbrains.compose.experimental.wasm.enabled=true 17 | org.jetbrains.compose.experimental.jscanvas.enabled=true 18 | 19 | 20 | #MPP 21 | kotlin.mpp.androidSourceSetLayoutVersion=2 22 | kotlin.mpp.enableCInteropCommonization=true 23 | 24 | 25 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/johnoreilly/climatetrace/remote/PopulationApi.kt: -------------------------------------------------------------------------------- 1 | package dev.johnoreilly.climatetrace.remote 2 | 3 | import io.ktor.client.HttpClient 4 | import io.ktor.client.call.body 5 | import io.ktor.client.request.get 6 | import kotlinx.serialization.Serializable 7 | 8 | @Serializable 9 | data class CountryResponse( 10 | val population: Long 11 | ) 12 | 13 | class PopulationApi( 14 | private val client: HttpClient, 15 | private val baseUrl: String = "https://restcountries.com/v3.1/alpha/" 16 | 17 | ) { 18 | suspend fun getPopulation(countryCode: String): Long { 19 | val response = client.get("$baseUrl$countryCode") 20 | val countries = response.body>() 21 | return countries.firstOrNull()?.population ?: 0L 22 | } 23 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/johnoreilly/climatetrace/ui/ChartNode.kt: -------------------------------------------------------------------------------- 1 | package dev.johnoreilly.climatetrace.ui 2 | 3 | import androidx.compose.runtime.Stable 4 | import androidx.compose.ui.graphics.Color 5 | 6 | @Stable 7 | sealed class ChartNode { 8 | 9 | abstract val name: String 10 | abstract val value: Double 11 | abstract val percentage: Double 12 | 13 | @Stable 14 | data class Leaf( 15 | override val name: String, 16 | override val value: Double, 17 | override val percentage: Double, 18 | val color: Color, 19 | ) : ChartNode() 20 | 21 | @Stable 22 | data class Section( 23 | override val name: String, 24 | override val value: Double, 25 | override val percentage: Double, 26 | val color: Color?, 27 | ) : ChartNode() 28 | } -------------------------------------------------------------------------------- /.github/workflows/ios.yml: -------------------------------------------------------------------------------- 1 | name: iOS CI 2 | 3 | on: pull_request 4 | 5 | # Cancel any current or previous job from the same PR 6 | concurrency: 7 | group: ios-${{ github.head_ref }} 8 | cancel-in-progress: true 9 | 10 | 11 | jobs: 12 | build: 13 | runs-on: macos-latest 14 | steps: 15 | - uses: actions/checkout@v5 16 | - uses: actions/setup-java@v5 17 | with: 18 | distribution: 'zulu' 19 | java-version: 21 20 | 21 | - name: Set Xcode Version 16.4 22 | shell: bash 23 | run: | 24 | xcodes select 16.4 25 | 26 | - name: Build iOS app 27 | run: xcodebuild -allowProvisioningUpdates -allowProvisioningUpdates -workspace iosApp/iosApp.xcodeproj/project.xcworkspace -configuration Debug -scheme iosApp -sdk iphoneos -destination name='iPhone 16' 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/johnoreilly/climatetrace/agent/ExitTool.kt: -------------------------------------------------------------------------------- 1 | package dev.johnoreilly.climatetrace.agent 2 | 3 | import ai.koog.agents.core.tools.SimpleTool 4 | import ai.koog.agents.core.tools.annotations.LLMDescription 5 | import kotlinx.serialization.Serializable 6 | 7 | object ExitTool : SimpleTool() { 8 | @Serializable 9 | data class Args( 10 | @property:LLMDescription("The result of the agent session. Default is empty, if there's no particular result.") 11 | val result: String = "" 12 | ) 13 | 14 | override val argsSerializer = Args.serializer() 15 | override val description: String = 16 | "Exit the agent session with the specified result. Call this tool to finish the conversation with the user." 17 | 18 | override suspend fun doExecute(args: Args): String = args.result 19 | } 20 | -------------------------------------------------------------------------------- /agents/src/main/kotlin/adk/ClimateTraceTool.kt: -------------------------------------------------------------------------------- 1 | package adk 2 | 3 | import dev.johnoreilly.climatetrace.data.ClimateTraceRepository 4 | import io.reactivex.rxjava3.core.Single 5 | import koin 6 | import kotlinx.coroutines.rx3.rxSingle 7 | 8 | class ClimateTraceTool { 9 | 10 | companion object { 11 | val climateTraceRepository = koin.get() 12 | 13 | @JvmStatic 14 | fun getCountries(): Single> { 15 | return rxSingle { 16 | mapOf("countries" to climateTraceRepository.fetchCountries().toString()) 17 | } 18 | } 19 | 20 | @JvmStatic 21 | fun getEmissions(countryCode: String, year: String): Single> { 22 | return rxSingle { 23 | mapOf("emissions" to climateTraceRepository.fetchCountryEmissionsInfo(countryCode, year).toString()) 24 | } 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /mcp-server/desktop-extension/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "dxt_version": "0.1", 3 | "name": "desktop-extension", 4 | "display_name": "ClimateTrace", 5 | "version": "1.0.0", 6 | "description": "ClimateTrace MCP Server", 7 | "author": { 8 | "name": "John O'Reilly" 9 | }, 10 | "server": { 11 | "type": "binary", 12 | "entry_point": "server/serverAll.jar", 13 | "mcp_config": { 14 | "command": "java", 15 | "args": [ 16 | "-jar", 17 | "${__dirname}/server/serverAll.jar", 18 | "--stdio" 19 | ], 20 | "env": {} 21 | } 22 | }, 23 | "tools": [ 24 | { 25 | "name": "get-emissions", 26 | "description": "List emission info for a particular country" 27 | }, 28 | { 29 | "name": "get-countries", 30 | "description": "List of countries" 31 | } 32 | ], 33 | "license": "MIT", 34 | "repository": { 35 | "type": "git", 36 | "url": "https://github.com/joreilly/ClimateTraceKMP" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /agents/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlinJvm) 3 | } 4 | 5 | dependencies { 6 | implementation(libs.mcp.kotlin) 7 | implementation(libs.koin.core) 8 | //implementation("org.slf4j:slf4j-simple:2.0.17") 9 | implementation(libs.google.adk) 10 | implementation(libs.google.adk.dev) 11 | 12 | implementation (libs.kotlinx.coroutines.rx3) 13 | 14 | // following needed for AdkWebServer (dev UI) 15 | implementation("org.apache.httpcomponents.client5:httpclient5:5.5") 16 | 17 | implementation(projects.composeApp) 18 | } 19 | 20 | java { 21 | toolchain { 22 | languageVersion = JavaLanguageVersion.of(17) 23 | } 24 | } 25 | 26 | // Task to execute the startDevUI() Kotlin function via a small entrypoint 27 | tasks.register("startDevUI") { 28 | group = "application" 29 | description = "Starts the ADK Dev UI by invoking startDevUI()" 30 | mainClass.set("adk.DevUiMainKt") 31 | classpath = sourceSets["main"].runtimeClasspath 32 | } 33 | 34 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/dev/johnoreilly/climatetrace/di/Koin.android.kt: -------------------------------------------------------------------------------- 1 | package dev.johnoreilly.climatetrace.di 2 | 3 | import android.content.Context 4 | import dev.johnoreilly.climatetrace.remote.Country 5 | import io.github.xxfast.kstore.KStore 6 | import io.github.xxfast.kstore.file.storeOf 7 | import io.ktor.client.engine.HttpClientEngine 8 | import io.ktor.client.engine.android.Android 9 | import kotlinx.io.files.Path 10 | import org.koin.android.ext.koin.androidContext 11 | import org.koin.core.module.Module 12 | import org.koin.dsl.module 13 | 14 | fun initKoin(context: Context) = initKoin(enableNetworkLogs = false) { 15 | androidContext(context) 16 | } 17 | 18 | actual fun dataModule(): Module = module { 19 | single>> { 20 | val filesDir: String = androidContext().filesDir.path 21 | storeOf(file = Path(path = "$filesDir/countries.json"), default = emptyList()) 22 | } 23 | } 24 | 25 | actual fun createHttpClientEngine(): HttpClientEngine { 26 | return Android.create() 27 | } -------------------------------------------------------------------------------- /composeApp/src/jvmMain/kotlin/dev/johnoreilly/climatetrace/di/Koin.desktop.kt: -------------------------------------------------------------------------------- 1 | package dev.johnoreilly.climatetrace.di 2 | 3 | import dev.johnoreilly.climatetrace.remote.Country 4 | import io.github.xxfast.kstore.KStore 5 | import io.github.xxfast.kstore.file.storeOf 6 | import kotlinx.io.files.Path 7 | import kotlinx.io.files.SystemFileSystem 8 | import net.harawata.appdirs.AppDirsFactory 9 | import org.koin.core.module.Module 10 | import org.koin.dsl.module 11 | 12 | private const val PACKAGE_NAME = "dev.johnoreilly.climatetrace" 13 | private const val VERSION = "1.0.0" 14 | private const val AUTHOR = "johnoreilly" 15 | 16 | actual fun dataModule(): Module = module { 17 | single>> { 18 | val filesDir: String = AppDirsFactory.getInstance() 19 | .getUserCacheDir(PACKAGE_NAME, VERSION, AUTHOR) 20 | val files = Path(filesDir) 21 | with(SystemFileSystem) { if(!exists(files)) createDirectories(files) } 22 | storeOf(file = Path(path = "$filesDir/countries.json"), default = emptyList()) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /composeApp/src/jvmMain/kotlin/main.kt: -------------------------------------------------------------------------------- 1 | import androidx.compose.desktop.ui.tooling.preview.Preview 2 | import androidx.compose.runtime.Composable 3 | import androidx.compose.ui.window.Window 4 | import androidx.compose.ui.window.application 5 | import dev.johnoreilly.climatetrace.data.ClimateTraceRepository 6 | import dev.johnoreilly.climatetrace.di.initKoin 7 | 8 | /* 9 | val koin = initKoin(enableNetworkLogs = true).koin 10 | 11 | suspend fun main() { 12 | println("hello") 13 | 14 | val climateTraceRepository = koin.get() 15 | 16 | val agent: ClimateTraceAgent = ClimateTraceAgent(climateTraceRepository) 17 | 18 | //agent.runAgent("What were the emissions for the UK in 2024?") 19 | 20 | agent.runAgent("compare the per-capita emissions of the UK and France in 2024") 21 | } 22 | */ 23 | 24 | fun main() = application { 25 | Window(onCloseRequest = ::exitApplication, title = "ClimateTraceKMP") { 26 | App() 27 | } 28 | } 29 | 30 | @Preview 31 | @Composable 32 | fun AppDesktopPreview() { 33 | App() 34 | } 35 | 36 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/dev/johnoreilly/climatetrace/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package dev.johnoreilly.wordmaster.androidApp.theme 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | // Set of Material typography styles to start with 10 | val Typography = Typography( 11 | bodyLarge = TextStyle( 12 | fontFamily = FontFamily.Default, 13 | fontWeight = FontWeight.Normal, 14 | fontSize = 16.sp, 15 | ) 16 | /* Other default text styles to override 17 | titleLarge = TextStyle( 18 | fontFamily = FontFamily.Default, 19 | fontWeight = FontWeight.Normal, 20 | fontSize = 22.sp, 21 | lineHeight = 28.sp, 22 | letterSpacing = 0.sp 23 | ), 24 | labelSmall = TextStyle( 25 | fontFamily = FontFamily.Default, 26 | fontWeight = FontWeight.Medium, 27 | fontSize = 11.sp, 28 | lineHeight = 16.sp, 29 | letterSpacing = 0.5.sp 30 | ) 31 | */ 32 | ) -------------------------------------------------------------------------------- /composeApp/src/androidMain/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 13 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /.github/workflows/build-and-publish-web.yml: -------------------------------------------------------------------------------- 1 | name: Build and Publish 2 | 3 | # configure manual trigger 4 | on: 5 | workflow_dispatch: 6 | 7 | jobs: 8 | build: 9 | name: Test and Build 10 | runs-on: ubuntu-latest 11 | steps: 12 | 13 | # Setup Java 1.8 environment for the next steps 14 | - name: Setup Java 15 | uses: actions/setup-java@v5 16 | with: 17 | distribution: 'zulu' 18 | java-version: 17 19 | 20 | # Check out current repository 21 | - name: Fetch Sources 22 | uses: actions/checkout@v5 23 | 24 | # Build application 25 | - name: Build 26 | run: ./gradlew :composeApp:wasmJsBrowserDistribution 27 | 28 | # If main branch update, deploy to gh-pages 29 | - name: Deploy 30 | if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' 31 | uses: JamesIves/github-pages-deploy-action@v4.7.3 32 | with: 33 | BRANCH: gh-pages # The branch the action should deploy to. 34 | FOLDER: composeApp/build/dist/wasmJs/productionExecutable # The folder the action should deploy. 35 | CLEAN: true # Automatically remove deleted files from the deploy branch -------------------------------------------------------------------------------- /mcp-server/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlinJvm) 3 | alias(libs.plugins.kotlinx.serialization) 4 | alias(libs.plugins.shadowPlugin) 5 | alias(libs.plugins.jib) 6 | application 7 | } 8 | 9 | dependencies { 10 | implementation(libs.ktor.client.java) 11 | implementation(libs.mcp.kotlin) 12 | implementation(libs.koin.core) 13 | implementation(libs.ktor.server.cio) 14 | implementation(libs.ktor.server.sse) 15 | // implementation("ch.qos.logback:logback-classic:1.5.8") 16 | implementation(projects.composeApp) 17 | } 18 | 19 | java { 20 | toolchain { 21 | languageVersion = JavaLanguageVersion.of(17) 22 | } 23 | } 24 | 25 | application { 26 | mainClass = "McpServerKt" 27 | } 28 | 29 | tasks.shadowJar { 30 | archiveFileName.set("serverAll.jar") 31 | archiveClassifier.set("") 32 | manifest { 33 | attributes["Main-Class"] = "McpServerKt" 34 | } 35 | } 36 | 37 | jib { 38 | from.image = "docker.io/library/eclipse-temurin:21" 39 | 40 | to { 41 | image = "gcr.io/climatetrace-mcp/climatetrace-mcp-server" 42 | } 43 | container { 44 | ports = listOf("8080") 45 | mainClass = "McpServerKt" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /composeApp/src/jvmMain/kotlin/dev/johnoreilly/climatetrace/agent/ClimateTraceAgent.jvm.kt: -------------------------------------------------------------------------------- 1 | package dev.johnoreilly.climatetrace.agent 2 | 3 | import ai.koog.prompt.executor.llms.all.simpleOllamaAIExecutor 4 | import ai.koog.prompt.executor.model.PromptExecutor 5 | import ai.koog.prompt.llm.LLMCapability 6 | import ai.koog.prompt.llm.LLMProvider 7 | import ai.koog.prompt.llm.LLModel 8 | 9 | 10 | 11 | //actual fun getLLModel(): LLModel { 12 | // return LLModel( 13 | // provider = LLMProvider.Ollama, 14 | // //id = "llama3.1:8b", 15 | // //id = "llama3.2:3b", 16 | // id = "gpt-oss:20b", 17 | // capabilities = listOf( 18 | // LLMCapability.Temperature, 19 | // LLMCapability.Schema.JSON.Standard, 20 | // LLMCapability.Tools 21 | // ), 22 | // contextLength = 128_000, 23 | // ) 24 | //} 25 | 26 | //actual fun getPromptExecutor(apiKey: String): PromptExecutor { 27 | // return simpleOllamaAIExecutor() 28 | //} 29 | 30 | import ai.koog.prompt.executor.clients.google.GoogleModels 31 | import ai.koog.prompt.executor.llms.all.simpleGoogleAIExecutor 32 | import dev.johnoreilly.climatetrace.BuildKonfig 33 | 34 | actual fun getLLModel() = GoogleModels.Gemini2_5Flash 35 | 36 | actual fun getPromptExecutor(): PromptExecutor { 37 | return simpleGoogleAIExecutor(BuildKonfig.GEMINI_API_KEY) 38 | } -------------------------------------------------------------------------------- /composeApp/src/iosMain/kotlin/dev/johnoreilly/climatetrace/di/Koin.ios.kt: -------------------------------------------------------------------------------- 1 | package dev.johnoreilly.climatetrace.di 2 | 3 | import dev.johnoreilly.climatetrace.remote.Country 4 | import io.github.xxfast.kstore.KStore 5 | import io.github.xxfast.kstore.file.storeOf 6 | import io.github.xxfast.kstore.utils.ExperimentalKStoreApi 7 | import io.ktor.client.engine.HttpClientEngine 8 | import io.ktor.client.engine.darwin.Darwin 9 | import kotlinx.io.files.Path 10 | import org.koin.core.module.Module 11 | import org.koin.dsl.module 12 | import platform.Foundation.NSDocumentDirectory 13 | import platform.Foundation.NSFileManager 14 | import platform.Foundation.NSUserDomainMask 15 | 16 | // called by iOS etc 17 | fun initKoin() = initKoin(enableNetworkLogs = false) {} 18 | 19 | @OptIn(ExperimentalKStoreApi::class) 20 | actual fun dataModule(): Module = module { 21 | single>> { 22 | val filesDir: String? = NSFileManager.defaultManager.URLForDirectory( 23 | directory = NSDocumentDirectory, 24 | appropriateForURL = null, 25 | create = false, 26 | inDomain = NSUserDomainMask, 27 | error = null 28 | )?.relativePath 29 | requireNotNull(filesDir) { "Document directory not found" } 30 | storeOf(file = Path(path = "$filesDir/countries.json"), default = emptyList()) 31 | } 32 | } 33 | 34 | actual fun createHttpClientEngine(): HttpClientEngine { 35 | return Darwin.create() 36 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/johnoreilly/climatetrace/data/ClimateTraceRepository.kt: -------------------------------------------------------------------------------- 1 | package dev.johnoreilly.climatetrace.data 2 | 3 | import dev.johnoreilly.climatetrace.remote.ClimateTraceApi 4 | import dev.johnoreilly.climatetrace.remote.Country 5 | import dev.johnoreilly.climatetrace.remote.PopulationApi 6 | import io.github.xxfast.kstore.KStore 7 | 8 | 9 | class ClimateTraceRepository( 10 | private val store: KStore>, 11 | private val climateTraceApi: ClimateTraceApi, 12 | private val populationApi: PopulationApi 13 | ) { 14 | suspend fun fetchCountries() : List { 15 | val countries: List? = store.get() 16 | if (countries.isNullOrEmpty()) return climateTraceApi.fetchCountries().also { store.set(it) } 17 | return countries 18 | } 19 | 20 | suspend fun fetchCountryEmissionsInfo(countryCode: String, year: String) = climateTraceApi.fetchCountryEmissionsInfo(listOf(countryCode), year) 21 | suspend fun fetchCountryEmissionsInfo(countryCodeList: List, year: String) = climateTraceApi.fetchCountryEmissionsInfo(countryCodeList, year) 22 | 23 | suspend fun fetchCountryAssetEmissionsInfo(countryCode: String) = climateTraceApi.fetchCountryAssetEmissionsInfo(countryCode) 24 | suspend fun fetchCountryAssetEmissionsInfo(countryCodeList: List) = climateTraceApi.fetchCountryAssetEmissionsInfo(countryCodeList) 25 | 26 | suspend fun getPopulation(countryCode: String) = populationApi.getPopulation(countryCode) 27 | } 28 | -------------------------------------------------------------------------------- /iosApp/iosApp/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | CADisableMinimumFrameDurationOnPhone 24 | 25 | UIApplicationSceneManifest 26 | 27 | UIApplicationSupportsMultipleScenes 28 | 29 | 30 | UILaunchScreen 31 | 32 | UIRequiredDeviceCapabilities 33 | 34 | armv7 35 | 36 | UISupportedInterfaceOrientations 37 | 38 | UIInterfaceOrientationPortrait 39 | UIInterfaceOrientationLandscapeLeft 40 | UIInterfaceOrientationLandscapeRight 41 | 42 | UISupportedInterfaceOrientations~ipad 43 | 44 | UIInterfaceOrientationPortrait 45 | UIInterfaceOrientationPortraitUpsideDown 46 | UIInterfaceOrientationLandscapeLeft 47 | UIInterfaceOrientationLandscapeRight 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/johnoreilly/climatetrace/viewmodel/CountryListViewModel.kt: -------------------------------------------------------------------------------- 1 | package dev.johnoreilly.climatetrace.viewmodel 2 | 3 | import com.rickclephas.kmp.nativecoroutines.NativeCoroutinesState 4 | import com.rickclephas.kmp.observableviewmodel.MutableStateFlow 5 | import com.rickclephas.kmp.observableviewmodel.ViewModel 6 | import com.rickclephas.kmp.observableviewmodel.coroutineScope 7 | import dev.johnoreilly.climatetrace.data.ClimateTraceRepository 8 | import dev.johnoreilly.climatetrace.remote.Country 9 | import kotlinx.coroutines.flow.StateFlow 10 | import kotlinx.coroutines.flow.asStateFlow 11 | import kotlinx.coroutines.launch 12 | import org.koin.core.component.KoinComponent 13 | import org.koin.core.component.inject 14 | 15 | 16 | sealed class CountryListUIState { 17 | object Loading : CountryListUIState() 18 | data class Error(val message: String) : CountryListUIState() 19 | data class Success(val countryList: List) : CountryListUIState() 20 | } 21 | 22 | open class CountryListViewModel : ViewModel(), KoinComponent { 23 | private val climateTraceRepository: ClimateTraceRepository by inject() 24 | 25 | private val _viewState = MutableStateFlow(viewModelScope, CountryListUIState.Loading) 26 | @NativeCoroutinesState 27 | val viewState: StateFlow = _viewState.asStateFlow() 28 | 29 | 30 | init { 31 | viewModelScope.coroutineScope.launch { 32 | try { 33 | val countries = climateTraceRepository.fetchCountries().sortedBy { it.name } 34 | _viewState.value = CountryListUIState.Success(countries) 35 | 36 | } catch (e: Exception) { 37 | _viewState.value = CountryListUIState.Error(e.message ?: "Uknown Error") 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/johnoreilly/climatetrace/ui/theme/Dimension.kt: -------------------------------------------------------------------------------- 1 | package dev.johnoreilly.climatetrace.ui.theme 2 | 3 | import androidx.compose.ui.unit.dp 4 | 5 | // Material 3 spacing grid system 6 | // Based on 4dp grid: https://m3.material.io/foundations/layout/understanding-layout/spacing 7 | 8 | object AppDimension { 9 | // Base spacing units 10 | val spacingExtraSmall = 4.dp 11 | val spacingSmall = 8.dp 12 | val spacingMedium = 16.dp 13 | val spacingLarge = 24.dp 14 | val spacingExtraLarge = 32.dp 15 | val spacingXXLarge = 40.dp 16 | val spacingXXXLarge = 48.dp 17 | val spacingXXXXLarge = 56.dp 18 | 19 | // Specific spacing for common use cases 20 | val spacingScreenHorizontalPadding = spacingMedium 21 | val spacingScreenVerticalPadding = spacingMedium 22 | val spacingBetweenItems = spacingSmall 23 | val spacingBetweenSections = spacingLarge 24 | val spacingBetweenGroups = spacingMedium 25 | val spacingContentPadding = spacingMedium 26 | val spacingButtonPadding = spacingSmall 27 | 28 | // Elevation values 29 | val elevationNone = 0.dp 30 | val elevationExtraSmall = 1.dp 31 | val elevationSmall = 2.dp 32 | val elevationMedium = 4.dp 33 | val elevationLarge = 8.dp 34 | val elevationExtraLarge = 16.dp 35 | 36 | // Border radius 37 | val radiusSmall = 4.dp 38 | val radiusMedium = 8.dp 39 | val radiusLarge = 12.dp 40 | val radiusExtraLarge = 16.dp 41 | val radiusRound = 24.dp 42 | 43 | // Chat/message bubble layout 44 | // Allow bubbles to grow wider on larger screens while keeping a pleasant line length on phones 45 | val chatBubbleMaxWidth = 600.dp 46 | 47 | // Icon button sizes 48 | val iconButtonSizeSmall = 32.dp 49 | val iconButtonSizeMedium = 40.dp 50 | val iconButtonSizeLarge = 48.dp 51 | val iconButtonSizeExtraLarge = 56.dp 52 | } 53 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/dev/johnoreilly/climatetrace/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package dev.johnoreilly.wordmaster.androidApp.theme 2 | 3 | import android.os.Build 4 | import androidx.compose.foundation.isSystemInDarkTheme 5 | import androidx.compose.material3.MaterialTheme 6 | import androidx.compose.material3.darkColorScheme 7 | import androidx.compose.material3.dynamicDarkColorScheme 8 | import androidx.compose.material3.dynamicLightColorScheme 9 | import androidx.compose.material3.lightColorScheme 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.platform.LocalContext 12 | import androidx.compose.ui.graphics.Color 13 | 14 | private val DarkColorScheme = darkColorScheme( 15 | primary = Purple80, 16 | secondary = PurpleGrey80, 17 | tertiary = Pink80 18 | ) 19 | 20 | private val LightColorScheme = lightColorScheme( 21 | primary = Purple40, 22 | secondary = PurpleGrey40, 23 | tertiary = Pink40, 24 | background = Color.White, 25 | 26 | /* Other default colors to override 27 | surface = Color(0xFFFFFBFE), 28 | onPrimary = Color.White, 29 | onSecondary = Color.White, 30 | onTertiary = Color.White, 31 | onBackground = Color(0xFF1C1B1F), 32 | onSurface = Color(0xFF1C1B1F), 33 | */ 34 | ) 35 | 36 | @Composable 37 | fun ClimateTraceTheme( 38 | darkTheme: Boolean = isSystemInDarkTheme(), 39 | // Dynamic color is available on Android 12+ 40 | dynamicColor: Boolean = true, 41 | content: @Composable () -> Unit 42 | ) { 43 | val colorScheme = when { 44 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { 45 | val context = LocalContext.current 46 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) 47 | } 48 | 49 | darkTheme -> DarkColorScheme 50 | else -> LightColorScheme 51 | } 52 | 53 | MaterialTheme( 54 | colorScheme = colorScheme, 55 | typography = Typography, 56 | content = content 57 | ) 58 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/johnoreilly/climatetrace/ui/CountryEmissionsScreen.kt: -------------------------------------------------------------------------------- 1 | package dev.johnoreilly.climatetrace.ui 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.material.icons.Icons 6 | import androidx.compose.material.icons.automirrored.filled.ArrowBack 7 | import androidx.compose.material3.* 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.LaunchedEffect 10 | import androidx.compose.runtime.collectAsState 11 | import androidx.compose.runtime.getValue 12 | import androidx.compose.ui.Modifier 13 | import cafe.adriel.voyager.core.screen.Screen 14 | import cafe.adriel.voyager.navigator.LocalNavigator 15 | import cafe.adriel.voyager.navigator.currentOrThrow 16 | import dev.johnoreilly.climatetrace.remote.Country 17 | import dev.johnoreilly.climatetrace.viewmodel.CountryDetailsViewModel 18 | import org.koin.compose.koinInject 19 | 20 | @OptIn(ExperimentalMaterial3Api::class) 21 | data class CountryEmissionsScreen(val country: Country) : Screen { 22 | 23 | @Composable 24 | override fun Content() { 25 | val navigator = LocalNavigator.currentOrThrow 26 | 27 | val viewModel = koinInject() 28 | val viewState by viewModel.viewState.collectAsState() 29 | 30 | LaunchedEffect(country) { 31 | viewModel.setCountry(country) 32 | } 33 | 34 | Scaffold( 35 | topBar = { 36 | CenterAlignedTopAppBar( 37 | title = { 38 | Text(text = country.name) 39 | }, 40 | navigationIcon = { 41 | IconButton(onClick = { navigator.pop() }) { 42 | Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") 43 | } 44 | } 45 | ) 46 | } 47 | ) { 48 | Column(Modifier.padding(it)) { 49 | CountryInfoDetailedView(viewState) { year -> 50 | viewModel.setYear(year) 51 | } 52 | } 53 | } 54 | } 55 | } 56 | 57 | -------------------------------------------------------------------------------- /composeApp/src/commonTest/kotlin/dev/johnoreilly/climatetrace/screen/ClimateTraceTestsScreen.kt: -------------------------------------------------------------------------------- 1 | package dev.johnoreilly.climatetrace.screen 2 | 3 | import androidx.compose.ui.test.ExperimentalTestApi 4 | import androidx.compose.ui.test.onNodeWithText 5 | import androidx.compose.ui.test.runComposeUiTest 6 | import dev.johnoreilly.climatetrace.remote.Country 7 | import dev.johnoreilly.climatetrace.remote.CountryEmissionsInfo 8 | import dev.johnoreilly.climatetrace.remote.EmissionInfo 9 | import dev.johnoreilly.climatetrace.ui.CountryInfoDetailedView 10 | import dev.johnoreilly.climatetrace.ui.CountryListView 11 | import dev.johnoreilly.climatetrace.ui.toPercent 12 | import dev.johnoreilly.climatetrace.viewmodel.CountryDetailsUIState 13 | import kotlin.test.Test 14 | 15 | @OptIn(ExperimentalTestApi::class) 16 | class ClimateTraceScreenTest { 17 | private val country = Country("IRL", "IE", "Ireland", "Europe") 18 | private val countryList = listOf(country) 19 | private val countryEmissions = EmissionInfo(53_000_000.0f, 75_000_000.0f, 100_000_000.0f) 20 | private val worldEmissions = EmissionInfo(53_000_000_000.0f, 75_000_000_000.0f, 100_000_000_000.0f) 21 | private val countryEmissionsInfo = CountryEmissionsInfo(country = country.alpha3, 22 | rank = 73, emissions = countryEmissions, worldEmissions = worldEmissions) 23 | private val year = "2022" 24 | 25 | @Test 26 | fun testCountryListView() = runComposeUiTest { 27 | setContent { 28 | CountryListView(countryList, null, {}) 29 | } 30 | 31 | onNodeWithText(country.name).assertExists() 32 | } 33 | 34 | 35 | @Test 36 | fun testCountryInfoDetailsView() = runComposeUiTest { 37 | val state = CountryDetailsUIState.Success(country, 38 | year, listOf("2022", "2023"), countryEmissionsInfo, emptyList(), 39 | ) 40 | setContent { 41 | CountryInfoDetailedView(state, {}) 42 | } 43 | 44 | onNodeWithText(country.name).assertExists() 45 | val millionTonnes = (countryEmissions.co2 / 1_000_000).toInt() 46 | val percentage = (countryEmissions.co2 / worldEmissions.co2).toPercent(2) 47 | onNodeWithText("$millionTonnes").assertExists() 48 | onNodeWithText(percentage).assertExists() 49 | } 50 | 51 | } 52 | 53 | -------------------------------------------------------------------------------- /composeApp/src/iosMain/kotlin/MainViewController.kt: -------------------------------------------------------------------------------- 1 | 2 | import androidx.compose.foundation.layout.Column 3 | import androidx.compose.foundation.layout.fillMaxHeight 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.foundation.layout.wrapContentSize 6 | import androidx.compose.material3.CircularProgressIndicator 7 | import androidx.compose.material3.Text 8 | import androidx.compose.runtime.LaunchedEffect 9 | import androidx.compose.runtime.collectAsState 10 | import androidx.compose.runtime.getValue 11 | import androidx.compose.ui.Alignment 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.window.ComposeUIViewController 14 | import dev.johnoreilly.climatetrace.remote.Country 15 | import dev.johnoreilly.climatetrace.ui.AgentScreen 16 | import dev.johnoreilly.climatetrace.ui.CountryInfoDetailedView 17 | import dev.johnoreilly.climatetrace.ui.CountryScreenSuccess 18 | import dev.johnoreilly.climatetrace.viewmodel.CountryDetailsViewModel 19 | import dev.johnoreilly.climatetrace.viewmodel.CountryListUIState 20 | import dev.johnoreilly.climatetrace.viewmodel.CountryListViewModel 21 | import org.koin.compose.koinInject 22 | 23 | fun CountryListViewController(onCountryClicked: (country: Country) -> Unit) = ComposeUIViewController { 24 | val viewModel = koinInject() 25 | val viewState by viewModel.viewState.collectAsState() 26 | 27 | when (val state = viewState) { 28 | is CountryListUIState.Loading -> { 29 | Column(modifier = Modifier.fillMaxSize().fillMaxHeight() 30 | .wrapContentSize(Alignment.Center) 31 | ) { 32 | CircularProgressIndicator() 33 | } 34 | } 35 | is CountryListUIState.Error -> { 36 | Text("Error") 37 | } 38 | is CountryListUIState.Success -> { 39 | CountryScreenSuccess(state.countryList) 40 | } 41 | } 42 | } 43 | 44 | 45 | fun CountryInfoDetailedViewController(country: Country) = ComposeUIViewController { 46 | val viewModel = koinInject() 47 | val viewState by viewModel.viewState.collectAsState() 48 | 49 | LaunchedEffect(country) { 50 | viewModel.setCountry(country) 51 | } 52 | 53 | CountryInfoDetailedView(viewState) { 54 | viewModel.setYear(it) 55 | } 56 | } 57 | 58 | 59 | fun AgentViewController() = ComposeUIViewController { 60 | AgentScreen() 61 | } 62 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/johnoreilly/climatetrace/di/Koin.kt: -------------------------------------------------------------------------------- 1 | package dev.johnoreilly.climatetrace.di 2 | 3 | import dev.johnoreilly.climatetrace.agent.AgentProvider 4 | import dev.johnoreilly.climatetrace.agent.ClimateTraceAgentProvider 5 | import dev.johnoreilly.climatetrace.data.ClimateTraceRepository 6 | import dev.johnoreilly.climatetrace.remote.ClimateTraceApi 7 | import dev.johnoreilly.climatetrace.remote.PopulationApi 8 | import dev.johnoreilly.climatetrace.viewmodel.AgentViewModel 9 | import dev.johnoreilly.climatetrace.viewmodel.CountryDetailsViewModel 10 | import dev.johnoreilly.climatetrace.viewmodel.CountryListViewModel 11 | import io.ktor.client.HttpClient 12 | import io.ktor.client.engine.HttpClientEngine 13 | import io.ktor.client.plugins.contentnegotiation.ContentNegotiation 14 | import io.ktor.client.plugins.logging.DEFAULT 15 | import io.ktor.client.plugins.logging.LogLevel 16 | import io.ktor.client.plugins.logging.Logger 17 | import io.ktor.client.plugins.logging.Logging 18 | import io.ktor.serialization.kotlinx.json.json 19 | import kotlinx.serialization.json.Json 20 | import org.koin.core.context.loadKoinModules 21 | import org.koin.core.context.startKoin 22 | import org.koin.core.module.Module 23 | import org.koin.dsl.KoinAppDeclaration 24 | import org.koin.dsl.module 25 | 26 | fun initKoin(enableNetworkLogs: Boolean = false, appDeclaration: KoinAppDeclaration = {}) = 27 | startKoin { 28 | appDeclaration() 29 | modules(commonModule(enableNetworkLogs = enableNetworkLogs)) 30 | } 31 | 32 | fun commonModule(enableNetworkLogs: Boolean = false) = module { 33 | single { createJson() } 34 | single { createHttpClient(get(), enableNetworkLogs = enableNetworkLogs) } 35 | single { ClimateTraceApi(get()) } 36 | single { PopulationApi(get()) } 37 | single { CountryListViewModel() } 38 | single { CountryDetailsViewModel() } 39 | single { AgentViewModel(get()) } 40 | single { ClimateTraceRepository(get(), get(), get()) } 41 | single { ClimateTraceAgentProvider(get()) } 42 | includes(dataModule()) 43 | } 44 | 45 | expect fun dataModule(): Module 46 | 47 | fun createJson() = Json { isLenient = true; ignoreUnknownKeys = true } 48 | 49 | 50 | expect fun createHttpClientEngine(): HttpClientEngine 51 | 52 | fun createHttpClient(json: Json, enableNetworkLogs: Boolean) = HttpClient(createHttpClientEngine()) { 53 | install(ContentNegotiation) { 54 | json(json) 55 | } 56 | if (enableNetworkLogs) { 57 | install(Logging) { 58 | logger = Logger.DEFAULT 59 | level = LogLevel.NONE 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/johnoreilly/climatetrace/ui/CountryListScreen.kt: -------------------------------------------------------------------------------- 1 | package dev.johnoreilly.climatetrace.ui 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.fillMaxHeight 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.foundation.layout.wrapContentSize 8 | import androidx.compose.material3.CenterAlignedTopAppBar 9 | import androidx.compose.material3.CircularProgressIndicator 10 | import androidx.compose.material3.ExperimentalMaterial3Api 11 | import androidx.compose.material3.Scaffold 12 | import androidx.compose.material3.Text 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.runtime.collectAsState 15 | import androidx.compose.runtime.getValue 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import cafe.adriel.voyager.core.screen.Screen 19 | import cafe.adriel.voyager.navigator.LocalNavigator 20 | import cafe.adriel.voyager.navigator.currentOrThrow 21 | import dev.johnoreilly.climatetrace.viewmodel.CountryListUIState 22 | import dev.johnoreilly.climatetrace.viewmodel.CountryListViewModel 23 | import org.koin.compose.koinInject 24 | 25 | 26 | @OptIn(ExperimentalMaterial3Api::class) 27 | class CountryListScreen : Screen { 28 | @Composable 29 | override fun Content() { 30 | val navigator = LocalNavigator.currentOrThrow 31 | 32 | val viewModel = koinInject() 33 | val viewState by viewModel.viewState.collectAsState() 34 | 35 | Scaffold( 36 | topBar = { 37 | CenterAlignedTopAppBar(title = { 38 | Text("ClimateTraceKMP") 39 | }) 40 | } 41 | ) { 42 | Column(Modifier.padding(it)) { 43 | when (val state = viewState) { 44 | is CountryListUIState.Loading -> { 45 | Column(modifier = Modifier.fillMaxSize().fillMaxHeight() 46 | .wrapContentSize(Alignment.Center) 47 | ) { 48 | CircularProgressIndicator() 49 | } 50 | } 51 | is CountryListUIState.Error -> {} 52 | is CountryListUIState.Success -> { 53 | CountryListView(state.countryList, null) { country -> 54 | navigator.push(CountryEmissionsScreen(country)) 55 | } 56 | } 57 | } 58 | } 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /iosApp/iosApp.xcodeproj/xcuserdata/joreilly.xcuserdatad/xcschemes/iosApp.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 14 | 20 | 21 | 22 | 23 | 24 | 30 | 31 | 41 | 43 | 49 | 50 | 51 | 52 | 58 | 59 | 61 | 62 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/App.kt: -------------------------------------------------------------------------------- 1 | import androidx.compose.foundation.layout.Column 2 | import androidx.compose.foundation.layout.padding 3 | import androidx.compose.material.icons.Icons 4 | import androidx.compose.material.icons.filled.AccountTree 5 | import androidx.compose.material.icons.filled.Public 6 | import androidx.compose.material3.Icon 7 | import androidx.compose.material3.MaterialTheme 8 | import androidx.compose.material3.NavigationBar 9 | import androidx.compose.material3.NavigationBarItem 10 | import androidx.compose.material3.Scaffold 11 | import androidx.compose.material3.Text 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.runtime.getValue 14 | import androidx.compose.runtime.mutableIntStateOf 15 | import androidx.compose.runtime.remember 16 | import androidx.compose.runtime.setValue 17 | import androidx.compose.ui.Modifier 18 | import cafe.adriel.voyager.navigator.Navigator 19 | import dev.johnoreilly.climatetrace.di.commonModule 20 | import dev.johnoreilly.climatetrace.ui.AgentScreen 21 | import dev.johnoreilly.climatetrace.ui.ClimateTraceScreen 22 | import dev.johnoreilly.climatetrace.ui.theme.ClimateTraceTheme 23 | import org.jetbrains.compose.ui.tooling.preview.Preview 24 | import org.koin.compose.KoinApplication 25 | 26 | 27 | @Preview 28 | @Composable 29 | fun App() { 30 | KoinApplication(application = { 31 | modules(commonModule()) 32 | }) { 33 | ClimateTraceTheme { 34 | var selectedIndex by remember { mutableIntStateOf(0) } 35 | 36 | Scaffold( 37 | bottomBar = { 38 | NavigationBar { 39 | NavigationBarItem( 40 | selected = selectedIndex == 0, 41 | onClick = { selectedIndex = 0 }, 42 | icon = { Icon(Icons.Default.Public, contentDescription = "Climate") }, 43 | label = { Text("Climate") } 44 | ) 45 | NavigationBarItem( 46 | selected = selectedIndex == 1, 47 | onClick = { selectedIndex = 1 }, 48 | icon = { Icon(Icons.Default.AccountTree, contentDescription = "Agents") }, 49 | label = { Text("Agent") } 50 | ) 51 | } 52 | } 53 | ) { paddingValues -> 54 | Column(Modifier.padding(paddingValues)) { 55 | when (selectedIndex) { 56 | 0 -> Navigator(screen = ClimateTraceScreen()) 57 | else -> AgentScreen() 58 | } 59 | } 60 | } 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /composeApp/src/androidMain/kotlin/dev/johnoreilly/climatetrace/MainActivity.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalMaterial3Api::class) 2 | 3 | package dev.johnoreilly.climatetrace 4 | 5 | import android.os.Bundle 6 | import androidx.activity.ComponentActivity 7 | import androidx.activity.compose.setContent 8 | import androidx.activity.enableEdgeToEdge 9 | import androidx.compose.foundation.layout.Column 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.material.icons.Icons 12 | import androidx.compose.material.icons.filled.AccountTree 13 | import androidx.compose.material.icons.filled.Public 14 | import androidx.compose.material3.ExperimentalMaterial3Api 15 | import androidx.compose.material3.Icon 16 | import androidx.compose.material3.MaterialTheme 17 | import androidx.compose.material3.NavigationBar 18 | import androidx.compose.material3.NavigationBarItem 19 | import androidx.compose.material3.Scaffold 20 | import androidx.compose.material3.Text 21 | import androidx.compose.runtime.Composable 22 | import androidx.compose.runtime.getValue 23 | import androidx.compose.runtime.mutableIntStateOf 24 | import androidx.compose.runtime.remember 25 | import androidx.compose.runtime.setValue 26 | import androidx.compose.ui.Modifier 27 | import cafe.adriel.voyager.navigator.Navigator 28 | import dev.johnoreilly.climatetrace.di.initKoin 29 | import dev.johnoreilly.climatetrace.ui.AgentScreen 30 | import dev.johnoreilly.climatetrace.ui.ClimateTraceScreen 31 | import dev.johnoreilly.wordmaster.androidApp.theme.ClimateTraceTheme 32 | 33 | class MainActivity : ComponentActivity() { 34 | 35 | override fun onCreate(savedInstanceState: Bundle?) { 36 | enableEdgeToEdge() 37 | super.onCreate(savedInstanceState) 38 | 39 | initKoin(this) 40 | 41 | setContent { 42 | ClimateTraceTheme { 43 | AndroidApp() 44 | } 45 | } 46 | } 47 | } 48 | 49 | @Composable 50 | fun AndroidApp() { 51 | var selectedIndex by remember { mutableIntStateOf(0) } 52 | 53 | Scaffold( 54 | bottomBar = { 55 | NavigationBar { 56 | NavigationBarItem( 57 | selected = selectedIndex == 0, 58 | onClick = { selectedIndex = 0 }, 59 | icon = { Icon(Icons.Default.Public, contentDescription = "Climate") }, 60 | label = { Text("Climate") } 61 | ) 62 | NavigationBarItem( 63 | selected = selectedIndex == 1, 64 | onClick = { selectedIndex = 1 }, 65 | icon = { Icon(Icons.Default.AccountTree, contentDescription = "Agents") }, 66 | label = { Text("Agent") } 67 | ) 68 | } 69 | } 70 | ) { paddingValues -> 71 | Column(modifier = Modifier.padding(paddingValues)) { 72 | when (selectedIndex) { 73 | 0 -> Navigator(screen = ClimateTraceScreen()) 74 | else -> AgentScreen() 75 | } 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 48 | echo. 49 | echo Please set the JAVA_HOME variable in your environment to match the 50 | echo location of your Java installation. 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 62 | echo. 63 | echo Please set the JAVA_HOME variable in your environment to match the 64 | echo location of your Java installation. 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/johnoreilly/climatetrace/ui/utils/ResizablePanel.kt: -------------------------------------------------------------------------------- 1 | package dev.johnoreilly.climatetrace.ui.utils 2 | 3 | import androidx.compose.animation.core.* 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.clickable 6 | import androidx.compose.foundation.layout.* 7 | import androidx.compose.material3.Icon 8 | import androidx.compose.material3.LocalContentColor 9 | import androidx.compose.material3.Text 10 | import androidx.compose.material.icons.Icons 11 | import androidx.compose.material.icons.automirrored.filled.ArrowBack 12 | import androidx.compose.material.icons.automirrored.filled.ArrowForward 13 | import androidx.compose.material.icons.filled.ArrowBack 14 | import androidx.compose.material.icons.filled.ArrowForward 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.runtime.getValue 17 | import androidx.compose.runtime.mutableStateOf 18 | import androidx.compose.runtime.setValue 19 | import androidx.compose.ui.Alignment 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.draw.clipToBounds 22 | import androidx.compose.ui.graphics.Color 23 | import androidx.compose.ui.graphics.graphicsLayer 24 | import androidx.compose.ui.semantics.Role 25 | import androidx.compose.ui.semantics.SemanticsProperties 26 | import androidx.compose.ui.text.AnnotatedString 27 | import androidx.compose.ui.unit.dp 28 | import androidx.compose.ui.unit.sp 29 | 30 | class SplitterState { 31 | var isResizing by mutableStateOf(false) 32 | var isResizeEnabled by mutableStateOf(true) 33 | } 34 | 35 | 36 | class PanelState { 37 | val collapsedSize = 40.dp 38 | var expandedSize by mutableStateOf(250.dp) 39 | val expandedSizeMin = 120.dp 40 | var isExpanded by mutableStateOf(true) 41 | val splitter = SplitterState() 42 | } 43 | 44 | @Composable 45 | fun ResizablePanel( 46 | modifier: Modifier, 47 | state: PanelState, 48 | title: String, 49 | content: @Composable () -> Unit, 50 | ) { 51 | val alpha = animateFloatAsState( 52 | if (state.isExpanded) 1f else 0f, 53 | SpringSpec(stiffness = Spring.StiffnessLow), 54 | ).value 55 | 56 | Box(modifier) { 57 | Column { 58 | Row( 59 | modifier = Modifier.height(32.dp).padding(6.dp) 60 | .clickable { state.isExpanded = !state.isExpanded }, 61 | verticalAlignment = Alignment.CenterVertically 62 | ) { 63 | Icon( 64 | if (state.isExpanded) Icons.AutoMirrored.Filled.ArrowBack else Icons.AutoMirrored.Filled.ArrowForward, 65 | contentDescription = if (state.isExpanded) "Collapse" else "Expand", 66 | tint = LocalContentColor.current, 67 | modifier = Modifier 68 | .size(24.dp) 69 | .padding(start = 2.dp, end = 2.dp, bottom = 2.dp) 70 | ) 71 | } 72 | 73 | if (state.isExpanded) { 74 | Box( 75 | Modifier 76 | .fillMaxWidth() 77 | .height(1.dp) 78 | .background(Color.Gray) 79 | ) 80 | 81 | Column(Modifier.fillMaxSize().padding(top = 4.dp).graphicsLayer(alpha = alpha)) { 82 | content() 83 | } 84 | } 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/johnoreilly/climatetrace/remote/ClimateTraceApi.kt: -------------------------------------------------------------------------------- 1 | package dev.johnoreilly.climatetrace.remote 2 | 3 | import io.ktor.client.HttpClient 4 | import io.ktor.client.call.body 5 | import io.ktor.client.request.get 6 | import kotlinx.serialization.SerialName 7 | import kotlinx.serialization.Serializable 8 | 9 | @Serializable 10 | data class AssetsResult(val assets: List) 11 | 12 | @Serializable 13 | data class Asset( 14 | @SerialName("Id") 15 | val id: String, 16 | @SerialName("Name") 17 | val name: String, 18 | @SerialName("AssetType") 19 | val assetType: String, 20 | @SerialName("Sector") 21 | val sector: String, 22 | @SerialName("Thumbnail") 23 | val thumbnail: String, 24 | ) 25 | 26 | 27 | @Serializable 28 | data class Country( 29 | val alpha3: String, 30 | val alpha2: String, 31 | val name: String, 32 | val continent: String, 33 | ) 34 | 35 | @Serializable 36 | data class CountryEmissionsInfo( 37 | val country: String, 38 | val rank: Int, 39 | val emissions: EmissionInfo, 40 | val worldEmissions: EmissionInfo 41 | ) 42 | 43 | @Serializable 44 | data class CountryAssetEmissionsInfo( 45 | @SerialName("Country") 46 | val country: String? = null, 47 | @SerialName("Emissions") 48 | val emissions: Float = 0f, 49 | @SerialName("Sector") 50 | val sector: String? = null 51 | ) 52 | 53 | 54 | @Serializable 55 | data class EmissionInfo( 56 | val co2: Float, 57 | val co2e_100yr: Float, 58 | val co2e_20yr: Float 59 | ) 60 | 61 | 62 | 63 | class ClimateTraceApi( 64 | private val client: HttpClient, 65 | private val baseUrl: String = "https://api.climatetrace.org/v6", 66 | ) { 67 | suspend fun fetchContinents() = client.get("$baseUrl/definitions/continents").body>() 68 | suspend fun fetchCountries() = client.get("$baseUrl/definitions/countries").body>() 69 | suspend fun fetchSectors() = client.get("$baseUrl/definitions/sectors").body>() 70 | suspend fun fetchSubSectors() = client.get("$baseUrl/definitions/subsectors").body>() 71 | suspend fun fetchGases() = client.get("$baseUrl/definitions/gases").body>() 72 | 73 | // TODO need to implement paging on top of this 74 | suspend fun fetchAssets() = client.get("$baseUrl/assets").body() 75 | 76 | 77 | suspend fun fetchCountryEmissionsInfo(countryCodeList: List, year: String): List { 78 | return client.get("$baseUrl/country/emissions") { 79 | url { 80 | parameters.append("countries", countryCodeList.joinToString(",")) 81 | parameters.append("since", year) 82 | parameters.append("to", year) 83 | } 84 | }.body>() 85 | } 86 | 87 | suspend fun fetchCountryAssetEmissionsInfo(countryCodeList: List): Map> { 88 | return client.get("$baseUrl/assets/emissions") { 89 | url { 90 | parameters.append("countries", countryCodeList.joinToString(",")) 91 | } 92 | }.body>>() 93 | } 94 | 95 | suspend fun fetchCountryAssetEmissionsInfo(countryCode: String) = client.get("$baseUrl/assets/emissions?countries=$countryCode").body>>()[countryCode] ?: emptyList() 96 | } -------------------------------------------------------------------------------- /agents/src/main/kotlin/adk/adk_agent.kt: -------------------------------------------------------------------------------- 1 | package adk 2 | 3 | import adk.ClimateTraceAgent.Companion.initAgent 4 | import com.google.adk.agents.BaseAgent 5 | import com.google.adk.agents.LlmAgent 6 | import com.google.adk.events.Event 7 | import com.google.adk.models.Gemini 8 | import com.google.adk.runner.InMemoryRunner 9 | import com.google.adk.tools.LongRunningFunctionTool 10 | import com.google.adk.tools.mcp.McpToolset 11 | import com.google.adk.tools.mcp.StreamableHttpServerParameters 12 | import com.google.adk.web.AdkWebServer 13 | import com.google.genai.Client 14 | import com.google.genai.types.Content 15 | import com.google.genai.types.Part 16 | import io.modelcontextprotocol.client.transport.ServerParameters 17 | import io.reactivex.rxjava3.functions.Consumer 18 | import kotlin.jvm.optionals.getOrNull 19 | 20 | 21 | const val USER_ID = "MainUser" 22 | const val NAME = "ClimateTrace Agent" 23 | 24 | 25 | class ClimateTraceAgent { 26 | companion object { 27 | @JvmStatic 28 | fun initAgent(): BaseAgent { 29 | val apiKeyGoogle = "" 30 | 31 | // val mcpTools = McpToolset( 32 | // StreamableHttpServerParameters( 33 | // "https://mapstools.googleapis.com/mcp", 34 | // mapOf("X-Goog-Api-Key" to apiKeyGoogle), 35 | // null, null, null 36 | // ) 37 | // 38 | // ServerParameters 39 | // .builder("java") 40 | // .args("-jar", "/Users/joreilly/dev/github/ClimateTraceKMP/mcp-server/build/libs/serverAll.jar", "--stdio") 41 | // .build() 42 | // ) //.loadTools().join() 43 | 44 | val getCountriesTool = LongRunningFunctionTool.create(ClimateTraceTool::class.java, "getCountries") 45 | val getEmissionsTool = LongRunningFunctionTool.create(ClimateTraceTool::class.java, "getEmissions") 46 | val mcpTools = listOf(getCountriesTool, getEmissionsTool) 47 | 48 | val model = Gemini("gemini-2.5-flash", Client.builder().apiKey(apiKeyGoogle).build()) 49 | return LlmAgent.builder() 50 | .name(NAME) 51 | .model(model) 52 | .description("Agent to answer climate emissions related questions.") 53 | .instruction("You are an agent that provides climate emissions related information. Use 3 letter country codes.") 54 | .tools(mcpTools) 55 | .build() 56 | } 57 | } 58 | } 59 | 60 | 61 | fun main() { 62 | val runner = InMemoryRunner(initAgent()) 63 | val session = runner 64 | .sessionService() 65 | .createSession(NAME, USER_ID) 66 | .blockingGet() 67 | 68 | val prompt = 69 | """ 70 | Get emission data for Germany and France in 2022. 71 | Use units of millions for the emissions data. 72 | Show result in a grid or decreasing order of emissions. 73 | """.trimIndent() 74 | 75 | val userMsg = Content.fromParts(Part.fromText(prompt)) 76 | val events = runner.runAsync(USER_ID, session.id(), userMsg) 77 | 78 | events.blockingForEach(Consumer { event: Event -> 79 | event.content().get().parts().getOrNull()?.forEach { part -> 80 | part.text().getOrNull()?.let { println(it) } 81 | part.functionCall().getOrNull()?.let { println(it) } 82 | part.functionResponse().getOrNull()?.let { println(it) } 83 | } 84 | if (event.errorCode().isPresent || event.errorMessage().isPresent) { 85 | println("error: ${event.errorCode().get()}, ${event.errorMessage().get()}") 86 | } 87 | }) 88 | } 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![kotlin-version](https://img.shields.io/badge/kotlin-2.2.0-blue?logo=kotlin) 2 | 3 | Kotlin/Compose Multiplatform project to show climate related emission data from https://climatetrace.org/data. Development just started so very much work in progress right now! 4 | Have started with showing sector emission data per country but ton of other info available as well (ideas very welcome!). 5 | 6 | Running on 7 | * iOS (SwiftUI + shared Compose Multiplatform UI) 8 | * Android 9 | * Desktop 10 | * Web (Wasm) 11 | * Web (Kotlin/JS) - contributed by https://github.com/yogeshVU 12 | * Kotlin Notebook 13 | * MCP Server 14 | 15 | The iOS client as mentioned includes shared Compose Multiplatform UI code. It also includes option to use either SwiftUI or Compose code for the Country List screen (in both cases selecting a country will navigate to shared Compose emissions details screen). 16 | 17 | 18 | Screenshot 2024-04-29 at 21 13 54 19 | 20 | 21 | 22 | Related posts: 23 | * [Kotlin MCP 💜 Kotlin Multiplatform](https://johnoreilly.dev/posts/kotlin-mcp-kmp/) 24 | * [Initial exploration of using Koog for developing Kotlin based AI agents](https://johnoreilly.dev/posts/kotlin-koog/) 25 | * [Using Google's Agent Development Kit for Java from Kotlin code](https://johnoreilly.dev/posts/kotlin-adk/) 26 | 27 | 28 | ### Android (Compose) 29 | 30 | Screenshot_20250824_175635 31 | 32 | 33 | 34 | 35 | ### iOS (SwiftUI/Compose) 36 | 37 | ![Simulator Screenshot - iPhone 15 Pro - 2023-12-10 at 19 31 59](https://github.com/joreilly/ClimateTraceKMP/assets/6302/ed0f6b1c-ce30-4f99-98d5-9bbdae49bcd3) 38 | 39 | 40 | 41 | 42 | ### Compose for Desktop 43 | 44 | Screenshot 2025-08-24 at 17 54 45 45 | 46 | 47 | 48 | ### Compose for Web (Wasm) 49 | 50 | Screenshot 2025-08-24 at 17 53 09 51 | 52 | 53 | 54 | ### Kotlin Notebook 55 | 56 | Screenshot 2023-12-14 at 20 33 45 57 | 58 | **MCP Server** 59 | 60 | The `mcp-server` module uses the [Kotlin MCP SDK](https://github.com/modelcontextprotocol/kotlin-sdk) to expose an MCP tools endpoint (returning per country emission data) that 61 | can for example be plugged in to Claude Desktop as shown below. That module uses same KMP shared code. 62 | 63 | Screenshot 2025-07-06 at 17 24 20 64 | 65 | 66 | To integrate the MCP server with Claude Desktop for example you need to firstly run gradle `shadowJar` task and then select "Edit Config" under Developer Settings and add something 67 | like the following (update with your path) 68 | 69 | ``` 70 | { 71 | "mcpServers": { 72 | "kotlin-peopleinspace": { 73 | "command": "java", 74 | "args": [ 75 | "-jar", 76 | "/Users/john.oreilly/github/ClimateTraceKMP/mcp-server/build/libs/serverAll.jar", 77 | "--stdio" 78 | ] 79 | } 80 | } 81 | } 82 | ``` 83 | 84 | 85 | ## Full set of Kotlin Multiplatform/Compose/SwiftUI samples 86 | 87 | * PeopleInSpace (https://github.com/joreilly/PeopleInSpace) 88 | * GalwayBus (https://github.com/joreilly/GalwayBus) 89 | * Confetti (https://github.com/joreilly/Confetti) 90 | * BikeShare (https://github.com/joreilly/BikeShare) 91 | * FantasyPremierLeague (https://github.com/joreilly/FantasyPremierLeague) 92 | * ClimateTrace (https://github.com/joreilly/ClimateTraceKMP) 93 | * GeminiKMP (https://github.com/joreilly/GeminiKMP) 94 | * MortyComposeKMM (https://github.com/joreilly/MortyComposeKMM) 95 | * StarWars (https://github.com/joreilly/StarWars) 96 | * WordMasterKMP (https://github.com/joreilly/WordMasterKMP) 97 | * Chip-8 (https://github.com/joreilly/chip-8) 98 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/johnoreilly/climatetrace/viewmodel/CountryDetailsViewModel.kt: -------------------------------------------------------------------------------- 1 | package dev.johnoreilly.climatetrace.viewmodel 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.LaunchedEffect 5 | import androidx.compose.runtime.getValue 6 | import androidx.compose.runtime.mutableStateOf 7 | import androidx.compose.runtime.remember 8 | import androidx.compose.runtime.setValue 9 | import app.cash.molecule.RecompositionMode 10 | import app.cash.molecule.launchMolecule 11 | import com.rickclephas.kmp.observableviewmodel.ViewModel 12 | import com.rickclephas.kmp.observableviewmodel.coroutineScope 13 | import dev.johnoreilly.climatetrace.data.ClimateTraceRepository 14 | import dev.johnoreilly.climatetrace.remote.Country 15 | import dev.johnoreilly.climatetrace.remote.CountryAssetEmissionsInfo 16 | import dev.johnoreilly.climatetrace.remote.CountryEmissionsInfo 17 | import kotlinx.coroutines.flow.Flow 18 | import kotlinx.coroutines.flow.MutableSharedFlow 19 | import kotlinx.coroutines.flow.StateFlow 20 | import org.koin.core.component.KoinComponent 21 | import org.koin.core.component.inject 22 | 23 | 24 | sealed class CountryDetailsUIState { 25 | data object NoCountrySelected : CountryDetailsUIState() 26 | data object Loading : CountryDetailsUIState() 27 | data class Error(val message: String) : CountryDetailsUIState() 28 | data class Success( 29 | val country: Country, 30 | val year: String, 31 | val availableYears: List, 32 | val countryEmissionInfo: CountryEmissionsInfo?, 33 | val countryAssetEmissionsList: List 34 | ) : CountryDetailsUIState() 35 | } 36 | 37 | sealed interface CountryDetailsEvents { 38 | data class SetCountry(val country: Country): CountryDetailsEvents 39 | data class SetYear(val year: String): CountryDetailsEvents 40 | } 41 | 42 | open class CountryDetailsViewModel : ViewModel(), KoinComponent { 43 | private val climateTraceRepository: ClimateTraceRepository by inject() 44 | private val availableYears = listOf("2021", "2022", "2023", "2024") 45 | 46 | private val events = MutableSharedFlow(extraBufferCapacity = 20) 47 | 48 | val viewState: StateFlow = viewModelScope.coroutineScope.launchMolecule(mode = RecompositionMode.Immediate) { 49 | CountryDetailsPresenter(events) 50 | } 51 | 52 | fun setYear(year: String) { 53 | events.tryEmit(CountryDetailsEvents.SetYear(year)) 54 | } 55 | 56 | fun setCountry(country: Country) { 57 | events.tryEmit(CountryDetailsEvents.SetCountry(country)) 58 | } 59 | 60 | @Composable 61 | fun CountryDetailsPresenter(events: Flow): CountryDetailsUIState { 62 | var uiState by remember { mutableStateOf(CountryDetailsUIState.NoCountrySelected) } 63 | var selectedCountry by remember { mutableStateOf(null) } 64 | var selectedYear by remember { mutableStateOf("2024") } 65 | 66 | LaunchedEffect(Unit) { 67 | events.collect { event -> 68 | when (event) { 69 | is CountryDetailsEvents.SetCountry -> selectedCountry = event.country 70 | is CountryDetailsEvents.SetYear -> selectedYear = event.year 71 | } 72 | } 73 | } 74 | 75 | LaunchedEffect(selectedCountry, selectedYear) { 76 | selectedCountry?.let { country -> 77 | uiState = CountryDetailsUIState.Loading 78 | try { 79 | val countryEmissionInfo = climateTraceRepository.fetchCountryEmissionsInfo(country.alpha3, selectedYear).firstOrNull() 80 | val countryAssetEmissionsList = climateTraceRepository.fetchCountryAssetEmissionsInfo(country.alpha3) 81 | uiState = CountryDetailsUIState.Success(country, selectedYear, availableYears, countryEmissionInfo, countryAssetEmissionsList) 82 | } catch (e: Exception) { 83 | uiState = CountryDetailsUIState.Error("Error retrieving data from backend, ${e.message}") 84 | } 85 | } 86 | } 87 | 88 | return uiState 89 | } 90 | } 91 | 92 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/johnoreilly/climatetrace/ui/SectorEmissionsPieChart.kt: -------------------------------------------------------------------------------- 1 | package dev.johnoreilly.climatetrace.ui 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Spacer 6 | import androidx.compose.foundation.layout.height 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.layout.size 9 | import androidx.compose.foundation.layout.wrapContentSize 10 | import androidx.compose.material3.CardDefaults 11 | import androidx.compose.material3.ElevatedCard 12 | import androidx.compose.material3.MaterialTheme 13 | import androidx.compose.material3.Surface 14 | import androidx.compose.material3.Text 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.graphics.Color 19 | import androidx.compose.ui.graphics.SolidColor 20 | import androidx.compose.ui.unit.dp 21 | import dev.johnoreilly.climatetrace.remote.CountryAssetEmissionsInfo 22 | import io.github.koalaplot.core.Symbol 23 | import io.github.koalaplot.core.legend.FlowLegend 24 | import io.github.koalaplot.core.pie.DefaultSlice 25 | import io.github.koalaplot.core.pie.PieChart 26 | import io.github.koalaplot.core.util.ExperimentalKoalaPlotApi 27 | import io.github.koalaplot.core.util.generateHueColorPalette 28 | import io.github.koalaplot.core.util.toString 29 | 30 | @OptIn(ExperimentalKoalaPlotApi::class) 31 | @Composable 32 | fun SectorEmissionsPieChart( 33 | assetEmissionsInfoList: List, 34 | modifier: Modifier = Modifier, 35 | ) { 36 | val filteredEmissionsList = assetEmissionsInfoList 37 | .filter { it.emissions > 0 } 38 | .sortedByDescending { it.emissions } 39 | .take(10) 40 | val values = filteredEmissionsList.map { it.emissions / 1_000_000 } 41 | val labels = filteredEmissionsList.mapNotNull { it.sector } 42 | val total = values.sum() 43 | val colors = generateHueColorPalette(values.size) 44 | 45 | Column(horizontalAlignment = Alignment.CenterHorizontally) { 46 | PieChart( 47 | values = values, 48 | modifier = modifier.padding(start = 8.dp), 49 | slice = { index: Int -> 50 | DefaultSlice( 51 | color = colors[index], 52 | hoverExpandFactor = 1.05f, 53 | hoverElement = { 54 | HoverSurface { 55 | Column( 56 | modifier = Modifier 57 | .wrapContentSize(Alignment.Center) 58 | ) { 59 | Text( 60 | text = (values[index] / total).toPercent(1), 61 | style = MaterialTheme.typography.titleLarge 62 | ) 63 | Text( 64 | text = values[index].toString(), 65 | style = MaterialTheme.typography.bodySmall 66 | ) 67 | } 68 | } 69 | } 70 | ) 71 | }, 72 | label = { i -> 73 | Text((values[i] / total).toPercent(1)) 74 | } 75 | ) 76 | 77 | Spacer(modifier = Modifier.height(16.dp)) 78 | 79 | ElevatedCard( 80 | elevation = CardDefaults.cardElevation(defaultElevation = 6.dp) 81 | ) { 82 | FlowLegend( 83 | itemCount = labels.size, 84 | symbol = { i -> 85 | Symbol( 86 | modifier = Modifier.size(8.dp), 87 | fillBrush = SolidColor(colors[i]) 88 | ) 89 | }, 90 | label = { labelIndex -> 91 | Text(text = labels[labelIndex]) 92 | }, 93 | modifier = Modifier.padding(8.dp) 94 | ) 95 | } 96 | } 97 | } 98 | 99 | 100 | fun Float.toPercent(precision: Int): String = "${(this * 100.0f).toString(precision)}%" 101 | 102 | @Composable 103 | fun HoverSurface(modifier: Modifier = Modifier, content: @Composable () -> Unit) { 104 | Surface( 105 | shadowElevation = 2.dp, 106 | shape = MaterialTheme.shapes.medium, 107 | color = Color.LightGray, 108 | modifier = modifier.padding(8.dp) 109 | ) { 110 | Box(modifier = Modifier.padding(8.dp)) { 111 | content() 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /iosApp/iosApp/ContentView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import SwiftUI 3 | import ComposeApp 4 | import KMPObservableViewModelCore 5 | import KMPObservableViewModelSwiftUI 6 | 7 | 8 | struct ContentView: View { 9 | @State private var navigateToDetailsScreen = false 10 | @State private var selectedCountry: Country? = nil 11 | 12 | var body: some View { 13 | TabView { 14 | CountryListView() 15 | .tabItem { 16 | Label("Climate", systemImage: "globe") 17 | } 18 | AgentsView() 19 | .tabItem { 20 | Label("Agent", systemImage: "tree") 21 | } 22 | } 23 | } 24 | } 25 | 26 | struct AgentsView: View { 27 | var body: some View { 28 | AgentViewShared() 29 | } 30 | } 31 | 32 | // Comment in following if using shared Compose code for country list as well 33 | /* 34 | struct ContentView: View { 35 | @State private var navigateToDetailsScreen = false 36 | @State private var selectedCountry: Country? = nil 37 | 38 | var body: some View { 39 | 40 | NavigationView { 41 | VStack { 42 | //CountryListView() 43 | 44 | CountryListViewShared() { country in 45 | selectedCountry = country 46 | navigateToDetailsScreen = true 47 | } 48 | 49 | // TODO: cleaner way to do this (currently based on https://www.swiftbysundell.com/articles/swiftui-programmatic-navigation/) 50 | NavigationLink("Navigate to country details", isActive: $navigateToDetailsScreen) { 51 | if let selectedCountry { 52 | CountryInfoDetailedViewShared(country: selectedCountry) 53 | } 54 | } 55 | .hidden() 56 | 57 | } 58 | .navigationBarTitle("ClimateTrace", displayMode: .inline) 59 | } 60 | } 61 | } 62 | 63 | */ 64 | 65 | struct CountryListView: View { 66 | @StateViewModel var viewModel = CountryListViewModel() 67 | @State var query: String = "" 68 | 69 | var body: some View { 70 | 71 | switch(viewModel.viewState) { 72 | 73 | case is CountryListUIState.Loading: 74 | ProgressView() 75 | .progressViewStyle(CircularProgressViewStyle()) 76 | .scaleEffect(1.5) 77 | .frame(maxWidth: .infinity, maxHeight: .infinity) 78 | case is CountryListUIState.Error: Text("Error") 79 | case let state as CountryListUIState.Success: 80 | 81 | NavigationView { 82 | ZStack { 83 | List { 84 | ForEach(state.countryList.filter { query.isEmpty || $0.name.contains(query) }, id: \.self) { country in 85 | NavigationLink(destination: CountryInfoDetailedViewShared(country: country)) { 86 | HStack { 87 | Text(country.name).font(.headline) 88 | } 89 | } 90 | } 91 | } 92 | .searchable(text: $query) 93 | .disableAutocorrection(true) 94 | 95 | if state.countryList.filter({ query.isEmpty || $0.name.contains(query) }).isEmpty { 96 | Text("No Countries Found!") 97 | .font(.headline) 98 | .foregroundColor(.gray) 99 | .frame(maxWidth: .infinity, maxHeight: .infinity) 100 | } 101 | } 102 | .navigationBarTitleDisplayMode(.inline) 103 | .toolbar { 104 | ToolbarItem(placement: .principal) { 105 | VStack { 106 | Text("ClimateTrace").font(.headline) 107 | } 108 | } 109 | } 110 | } 111 | default: EmptyView() 112 | } 113 | } 114 | } 115 | 116 | 117 | 118 | struct CountryListViewShared: UIViewControllerRepresentable { 119 | let onCountryClicked: (Country) -> Void 120 | 121 | func makeUIViewController(context: Context) -> UIViewController { 122 | MainViewControllerKt.CountryListViewController { country in 123 | onCountryClicked(country) 124 | } 125 | } 126 | 127 | func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} 128 | } 129 | 130 | struct CountryInfoDetailedViewShared: UIViewControllerRepresentable { 131 | let country: Country 132 | 133 | func makeUIViewController(context: Context) -> UIViewController { 134 | MainViewControllerKt.CountryInfoDetailedViewController(country: country) 135 | } 136 | 137 | func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} 138 | } 139 | 140 | struct AgentViewShared: UIViewControllerRepresentable { 141 | func makeUIViewController(context: Context) -> UIViewController { 142 | MainViewControllerKt.AgentViewController() 143 | } 144 | 145 | func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} 146 | } 147 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/johnoreilly/climatetrace/agent/ClimateTraceTool.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalTime::class) 2 | 3 | package dev.johnoreilly.climatetrace.agent 4 | 5 | import ai.koog.agents.core.tools.SimpleTool 6 | import ai.koog.agents.core.tools.ToolDescriptor 7 | import ai.koog.agents.core.tools.ToolParameterDescriptor 8 | import ai.koog.agents.core.tools.ToolParameterType 9 | import ai.koog.agents.core.tools.annotations.LLMDescription 10 | import dev.johnoreilly.climatetrace.data.ClimateTraceRepository 11 | import dev.johnoreilly.climatetrace.remote.Country 12 | import kotlinx.datetime.TimeZone 13 | import kotlinx.datetime.offsetAt 14 | import kotlinx.datetime.toLocalDateTime 15 | import kotlinx.serialization.Serializable 16 | import kotlin.time.Clock 17 | import kotlin.time.ExperimentalTime 18 | 19 | 20 | class GetCountryTool(val climateTraceRepository: ClimateTraceRepository) : SimpleTool() { 21 | @Serializable 22 | data class Args( 23 | @property:LLMDescription("Country name") 24 | val countryName: String 25 | ) 26 | 27 | override val argsSerializer = Args.serializer() 28 | override val description = "Look up country code using country name" 29 | 30 | private var countryList: List? = null 31 | 32 | override suspend fun doExecute(args: Args): String { 33 | try { 34 | if (countryList == null) { 35 | countryList = climateTraceRepository.fetchCountries() 36 | } 37 | return countryList?.firstOrNull { it.name == args.countryName }?.alpha3 ?: "" 38 | } catch (e: Exception) { 39 | println("Error: $e") 40 | return "" 41 | } 42 | } 43 | } 44 | 45 | 46 | class GetEmissionsTool(val climateTraceRepository: ClimateTraceRepository) : SimpleTool() { 47 | @Serializable 48 | data class Args( 49 | @property:LLMDescription("ISO country code list (e.g., 'USA', 'GBR', 'FRA')") 50 | val countryCodeList: List, 51 | @property:LLMDescription("Year for which emissions occurred") 52 | val year: String 53 | ) 54 | override val argsSerializer = Args.serializer() 55 | override val description = "Get the emission data for a country for a particular year." 56 | 57 | override suspend fun doExecute(args: Args): String { 58 | return climateTraceRepository.fetchCountryEmissionsInfo(args.countryCodeList, args.year).joinToString { 59 | it.emissions.co2.toString() 60 | } 61 | } 62 | } 63 | 64 | 65 | class GetAssetEmissionsTool(val climateTraceRepository: ClimateTraceRepository) : SimpleTool() { 66 | @Serializable 67 | data class Args( 68 | @property:LLMDescription("ISO country code list (e.g., 'USA', 'GBR', 'FRA')") 69 | val countryCodeList: List, 70 | ) 71 | override val argsSerializer = Args.serializer() 72 | override val description = "Get the asset emission data for a country." 73 | 74 | override suspend fun doExecute(args: Args): String { 75 | return climateTraceRepository.fetchCountryAssetEmissionsInfo(args.countryCodeList).toString() 76 | } 77 | } 78 | 79 | class GetPopulationTool(val climateTraceRepository: ClimateTraceRepository) : SimpleTool() { 80 | @Serializable 81 | data class Args(val countryCode: String) 82 | 83 | override val argsSerializer = Args.serializer() 84 | override val description = "Get population data for a country by its country code" 85 | 86 | override val descriptor = ToolDescriptor( 87 | name = "GetPopulationTool", 88 | description = "Get population data for a country by its country code", 89 | requiredParameters = listOf( 90 | ToolParameterDescriptor( 91 | name = "countryCode", description = "ISO country code (e.g., 'USA', 'GBR', 'FRA')", type = ToolParameterType.String 92 | ) 93 | ), 94 | ) 95 | 96 | override suspend fun doExecute(args: Args): String { 97 | println("Getting population for ${args.countryCode}") 98 | try { 99 | val population = climateTraceRepository.getPopulation(args.countryCode) 100 | return population.toString() 101 | } catch (e: Exception) { 102 | println("Error: $e") 103 | return "" 104 | } 105 | } 106 | } 107 | 108 | 109 | /** 110 | * Tool for getting the current date and time 111 | */ 112 | class CurrentDatetimeTool( 113 | val defaultTimeZone: TimeZone = TimeZone.UTC, 114 | val clock: Clock = Clock.System, 115 | ) : SimpleTool() { 116 | @Serializable 117 | data class Args( 118 | @property:LLMDescription("The timezone to get the current date and time in (e.g., 'UTC', 'America/New_York', 'Europe/London'). Defaults to UTC.") 119 | val timezone: String = "UTC" 120 | ) 121 | 122 | override val argsSerializer = Args.serializer() 123 | 124 | override val name = "current_datetime" 125 | override val description = "Get the current date and time in the specified timezone" 126 | 127 | override suspend fun doExecute(args: Args): String { 128 | val zoneId = try { 129 | TimeZone.of(args.timezone) 130 | } catch (_: Exception) { 131 | defaultTimeZone 132 | } 133 | 134 | val now = clock.now() 135 | val localDateTime = now.toLocalDateTime(zoneId) 136 | val offset = zoneId.offsetAt(now) 137 | 138 | val time = localDateTime.time 139 | val timeStr = "${time.hour.toString().padStart(2, '0')}:${ 140 | time.minute.toString().padStart(2, '0') 141 | }:${time.second.toString().padStart(2, '0')}" 142 | 143 | return "${localDateTime.date}T$timeStr$offset" 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/johnoreilly/climatetrace/agent/ClimateTraceAgentProvider.kt: -------------------------------------------------------------------------------- 1 | package dev.johnoreilly.climatetrace.agent 2 | 3 | import ai.koog.agents.core.agent.AIAgent 4 | import ai.koog.agents.core.agent.config.AIAgentConfig 5 | import ai.koog.agents.core.agent.functionalStrategy 6 | import ai.koog.agents.core.dsl.extension.asAssistantMessage 7 | import ai.koog.agents.core.dsl.extension.containsToolCalls 8 | import ai.koog.agents.core.dsl.extension.executeMultipleTools 9 | import ai.koog.agents.core.dsl.extension.extractToolCalls 10 | import ai.koog.agents.core.dsl.extension.requestLLMMultiple 11 | import ai.koog.agents.core.dsl.extension.sendMultipleToolResults 12 | import ai.koog.agents.core.tools.ToolRegistry 13 | import ai.koog.agents.features.eventHandler.feature.EventHandler 14 | import ai.koog.prompt.dsl.prompt 15 | import ai.koog.prompt.executor.model.PromptExecutor 16 | import ai.koog.prompt.llm.LLModel 17 | import dev.johnoreilly.climatetrace.BuildKonfig 18 | import dev.johnoreilly.climatetrace.data.ClimateTraceRepository 19 | import kotlin.time.ExperimentalTime 20 | 21 | 22 | // TODO use Koin for these and inject? 23 | expect fun getLLModel(): LLModel 24 | expect fun getPromptExecutor(): PromptExecutor 25 | 26 | class ClimateTraceAgentProvider( 27 | private val climateTraceRepository: ClimateTraceRepository 28 | ) : AgentProvider { 29 | 30 | override val description: String = "Hi, I'm a climate agent. I can provide climate emission information for different countries/years." 31 | 32 | @OptIn(ExperimentalTime::class) 33 | override suspend fun provideAgent( 34 | onToolCallEvent: suspend (String) -> Unit, 35 | onErrorEvent: suspend (String) -> Unit, 36 | onAssistantMessage: suspend (String) -> String, 37 | ): AIAgent { 38 | 39 | val toolRegistry = ToolRegistry { 40 | tool(CurrentDatetimeTool()) 41 | tool(GetCountryTool(climateTraceRepository)) 42 | tool(GetEmissionsTool(climateTraceRepository)) 43 | tool(GetAssetEmissionsTool(climateTraceRepository)) 44 | tool(GetPopulationTool(climateTraceRepository)) 45 | 46 | tool(ExitTool) 47 | } 48 | 49 | val strategy = functionalStrategy { initialInput -> 50 | var inputMessage = initialInput 51 | var lastAssistantMessage = "" 52 | 53 | repeat(50) { // align with agentConfig.maxAgentIterations 54 | println("Calling LLM with Input = $inputMessage") 55 | var responses = requestLLMMultiple(inputMessage) 56 | 57 | // Resolve tools until none left, mirroring graph strategy 58 | while (responses.containsToolCalls()) { 59 | val pendingCalls = extractToolCalls(responses) 60 | println("Pending Calls") 61 | println(pendingCalls.map { "${it.tool} ${it.content}" }) 62 | 63 | val results = executeMultipleTools(pendingCalls, parallelTools = true) 64 | 65 | // Finish condition: if ExitTool is called, return its result directly 66 | if (results.size == 1 && results.first().tool == ExitTool.name) { 67 | return@functionalStrategy results.first().result!!.toString() 68 | } 69 | 70 | // Send tool results back to LLM 71 | responses = sendMultipleToolResults(results) 72 | } 73 | 74 | // No more tool calls: deliver assistant message to UI and get possible user follow-up 75 | lastAssistantMessage = responses.first().asAssistantMessage().content 76 | val userReply = onAssistantMessage(lastAssistantMessage) 77 | 78 | // If user provides no reply, consider conversation finished and return assistant response 79 | if (userReply.isBlank()) { 80 | return@functionalStrategy lastAssistantMessage 81 | } 82 | 83 | // Prepare for next loop iteration with user's reply 84 | inputMessage = userReply 85 | } 86 | 87 | // Max iterations reached; return last assistant message 88 | lastAssistantMessage 89 | } 90 | 91 | 92 | val agentConfig = AIAgentConfig( 93 | prompt = prompt("climateTrace") { 94 | system( 95 | """ 96 | You an AI assistant specialising in providing information about global climate emissions. 97 | Use 3 letter country codes. 98 | The year is currently 2025. 99 | 100 | Use the tools at your disposal to: 101 | 1. Look up country codes from country names 102 | 2. Get climate emission information. 103 | 3. Get cause of emissions using asset emission information (GetAssetEmissionsTool) 104 | 4. Get population data. 105 | 5. Get current date and time. 106 | 107 | Pass the list of country codes and the year to the GetEmissionsTool tool to get climate emission information. 108 | Use units of millions for the emissions data. 109 | """.trimIndent(), 110 | ) 111 | }, 112 | model = getLLModel(), 113 | maxAgentIterations = 50 114 | ) 115 | 116 | // Return the agent 117 | return AIAgent( 118 | promptExecutor = getPromptExecutor(), 119 | strategy = strategy, 120 | agentConfig = agentConfig, 121 | toolRegistry = toolRegistry, 122 | ) { 123 | install(EventHandler) { 124 | onToolCallStarting { ctx -> 125 | onToolCallEvent("Tool ${ctx.tool.name}, args ${ctx.toolArgs}") 126 | } 127 | 128 | 129 | onAgentExecutionFailed { ctx -> 130 | onErrorEvent("${ctx.throwable.message}") 131 | } 132 | 133 | 134 | 135 | onAgentCompleted { _ -> 136 | // Skip finish event handling 137 | } 138 | 139 | } 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /composeApp/src/androidMain/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | kotlin = "2.2.21" 3 | ksp = "2.3.2" 4 | kotlinx-coroutines = "1.10.2" 5 | kotlinx-serialization = "1.9.0" 6 | kotlinx-dateTime = "0.7.1-0.6.x-compat" 7 | 8 | agp = "8.12.0" 9 | android-compileSdk = "36" 10 | android-minSdk = "24" 11 | android-targetSdk = "36" 12 | androidx-activityCompose = "1.12.1" 13 | androidx-datastore = "1.2.0" 14 | compose = "1.10.0" 15 | compose-plugin = "1.9.3" 16 | composeAdaptiveLayout = "1.2.0" 17 | harawata-appdirs = "1.5.0" 18 | koalaplot = "0.10.1" 19 | koin = "4.1.1" 20 | koin-compose-multiplatform = "4.1.1" 21 | kmpNativeCoroutines = "1.0.0-ALPHA-48" 22 | kmpObservableViewModel = "1.0.0-BETA-15" 23 | kstore = "1.0.0" 24 | ktor = "3.3.3" 25 | treemapChart = "0.1.3" 26 | voyager= "1.1.0-beta03" 27 | molecule = "2.2.0" 28 | mcp = "0.8.0" 29 | shadowPlugin = "9.2.2" 30 | jib = "3.5.1" 31 | googleAdk = "0.4.0" 32 | koogAgents = "0.5.4" 33 | markdownRenderer = "0.38.1" 34 | buildkonfig = "0.17.1" 35 | 36 | 37 | [libraries] 38 | androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } 39 | androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "androidx-datastore" } 40 | 41 | compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } 42 | compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" } 43 | compose-adaptive = { module = "org.jetbrains.compose.material3.adaptive:adaptive", version.ref = "composeAdaptiveLayout" } 44 | compose-adaptive-layout = { module = "org.jetbrains.compose.material3.adaptive:adaptive-layout", version.ref = "composeAdaptiveLayout" } 45 | 46 | google-adk = { module = "com.google.adk:google-adk", version.ref = "googleAdk" } 47 | google-adk-dev = { module = "com.google.adk:google-adk-dev", version.ref = "googleAdk" } 48 | koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } 49 | koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin-compose-multiplatform" } 50 | koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } 51 | 52 | 53 | koog-agents = { module = "ai.koog:koog-agents", version.ref = "koogAgents" } 54 | kotlinx-coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } 55 | kotlinx-coroutines-rx3 = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-rx3", version.ref = "kotlinx-coroutines" } 56 | kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" } 57 | kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } 58 | kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinx-dateTime" } 59 | 60 | kmpObservableViewModel = { module = "com.rickclephas.kmp:kmp-observableviewmodel-core", version.ref = "kmpObservableViewModel" } 61 | 62 | kstore = { module = "io.github.xxfast:kstore", version.ref = "kstore" } 63 | kstore-file = { module = "io.github.xxfast:kstore-file", version.ref = "kstore" } 64 | kstore-storage = { module = "io.github.xxfast:kstore-storage", version.ref = "kstore" } 65 | 66 | ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } 67 | ktor-client-json = { group = "io.ktor", name = "ktor-client-json", version.ref = "ktor" } 68 | ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" } 69 | ktor-client-serialization = { group = "io.ktor", name = "ktor-client-serialization", version.ref = "ktor" } 70 | ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" } 71 | ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" } 72 | ktor-client-android = { group = "io.ktor", name = "ktor-client-android", version.ref = "ktor" } 73 | ktor-client-darwin = { group = "io.ktor", name = "ktor-client-darwin", version.ref = "ktor" } 74 | ktor-client-java = { group = "io.ktor", name = "ktor-client-java", version.ref = "ktor" } 75 | ktor-server-cio = { group = "io.ktor", name = "ktor-server-cio", version.ref = "ktor" } 76 | ktor-server-sse = { group = "io.ktor", name = "ktor-server-sse", version.ref = "ktor" } 77 | 78 | voyager = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" } 79 | harawata-appdirs = { module = "net.harawata:appdirs", version.ref = "harawata-appdirs" } 80 | koalaplot = { module = "io.github.koalaplot:koalaplot-core", version.ref = "koalaplot" } 81 | treemap-chart = { module = "io.github.overpas:treemap-chart", version.ref = "treemapChart" } 82 | treemap-chart-compose = { module = "io.github.overpas:treemap-chart-compose", version.ref = "treemapChart" } 83 | markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "markdownRenderer" } 84 | 85 | 86 | molecule = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" } 87 | mcp-kotlin = { group = "io.modelcontextprotocol", name = "kotlin-sdk", version.ref = "mcp" } 88 | 89 | [bundles] 90 | ktor-common = ["ktor-client-core", "ktor-client-json", "ktor-client-logging", "ktor-client-serialization", "ktor-client-content-negotiation", "ktor-serialization-kotlinx-json"] 91 | 92 | 93 | [plugins] 94 | androidApplication = { id = "com.android.application", version.ref = "agp" } 95 | androidLibrary = { id = "com.android.library", version.ref = "agp" } 96 | jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "compose-plugin" } 97 | compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } 98 | kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } 99 | kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } 100 | kmpNativeCoroutines = { id = "com.rickclephas.kmp.nativecoroutines", version.ref = "kmpNativeCoroutines" } 101 | ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } 102 | kotlinJvm = { id = "org.jetbrains.kotlin.jvm" } 103 | shadowPlugin = { id = "com.gradleup.shadow", version.ref = "shadowPlugin" } 104 | jib = { id = "com.google.cloud.tools.jib", version.ref = "jib" } 105 | buildkonfig = { id = "com.codingfeline.buildkonfig", version.ref = "buildkonfig" } 106 | 107 | 108 | 109 | -------------------------------------------------------------------------------- /mcp-server/src/main/kotlin/McpServer.kt: -------------------------------------------------------------------------------- 1 | import dev.johnoreilly.climatetrace.data.ClimateTraceRepository 2 | import dev.johnoreilly.climatetrace.di.initKoin 3 | import io.ktor.server.cio.CIO 4 | import io.ktor.server.engine.embeddedServer 5 | import io.ktor.utils.io.streams.asInput 6 | import io.modelcontextprotocol.kotlin.sdk.types.TextContent 7 | import io.modelcontextprotocol.kotlin.sdk.server.Server 8 | import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions 9 | import io.modelcontextprotocol.kotlin.sdk.server.StdioServerTransport 10 | import io.modelcontextprotocol.kotlin.sdk.server.mcp 11 | import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult 12 | import io.modelcontextprotocol.kotlin.sdk.types.Implementation 13 | import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities 14 | import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema 15 | import kotlinx.coroutines.Job 16 | import kotlinx.coroutines.runBlocking 17 | import kotlinx.io.asSink 18 | import kotlinx.io.buffered 19 | import kotlinx.serialization.json.JsonPrimitive 20 | import kotlinx.serialization.json.buildJsonObject 21 | import kotlinx.serialization.json.jsonArray 22 | import kotlinx.serialization.json.jsonPrimitive 23 | import kotlinx.serialization.json.putJsonObject 24 | 25 | 26 | 27 | 28 | fun main(args: Array) { 29 | val command = args.firstOrNull() ?: "--stdio" 30 | val port = args.getOrNull(1)?.toIntOrNull() ?: 8080 31 | when (command) { 32 | "--sse-server" -> `run sse mcp server`(port) 33 | "--stdio" -> `run mcp server using stdio`() 34 | else -> { 35 | System.err.println("Unknown command: $command") 36 | } 37 | } 38 | } 39 | 40 | 41 | private val koin = initKoin(enableNetworkLogs = true).koin 42 | 43 | fun configureMcpServer(): Server { 44 | val climateTraceRepository = koin.get() 45 | 46 | val server = Server( 47 | Implementation( 48 | name = "ClimateTrace MCP Server", 49 | version = "1.0.0" 50 | ), 51 | ServerOptions( 52 | capabilities = ServerCapabilities( 53 | tools = ServerCapabilities.Tools(listChanged = true) 54 | ) 55 | ) 56 | ) 57 | 58 | server.addTool( 59 | name = "get-countries", 60 | description = "List of countries" 61 | ) { 62 | val countries = climateTraceRepository.fetchCountries() 63 | CallToolResult( 64 | content = 65 | countries.map { TextContent("${it.name}, ${it.alpha3}") } 66 | ) 67 | } 68 | 69 | 70 | server.addTool( 71 | name = "get-country-asset-emissions", 72 | description = "Get sector emission information for the given countries", 73 | inputSchema = ToolSchema( 74 | properties = buildJsonObject { 75 | putJsonObject("countryCodeList") { 76 | put("type", JsonPrimitive("array")) 77 | putJsonObject("items") { 78 | put("type", JsonPrimitive("string")) 79 | } 80 | } 81 | }, 82 | required = listOf("countryCodeList") 83 | ) 84 | ) { request -> 85 | val countryCodeList = request.arguments?.get("countryCodeList") 86 | if (countryCodeList == null) { 87 | return@addTool CallToolResult( 88 | content = listOf(TextContent("The 'countryCodeList' parameters are required.")) 89 | ) 90 | } 91 | val countryAssetEmissionInfo = climateTraceRepository.fetchCountryAssetEmissionsInfo( 92 | countryCodeList = countryCodeList 93 | .jsonArray 94 | .map { it.jsonPrimitive.content } 95 | ) 96 | CallToolResult( 97 | content = countryAssetEmissionInfo.map { TextContent("${it.key}, ${it.value}") } 98 | ) 99 | } 100 | 101 | 102 | 103 | server.addTool( 104 | name = "get-emissions", 105 | description = "Get total emission information for the given countries", 106 | inputSchema = ToolSchema( 107 | properties = buildJsonObject { 108 | putJsonObject("countryCodeList") { 109 | put("type", JsonPrimitive("array")) 110 | putJsonObject("items") { 111 | put("type", JsonPrimitive("string")) 112 | } 113 | } 114 | putJsonObject("year") { put("type", JsonPrimitive("string")) } 115 | }, 116 | required = listOf("countryCodeList", "year") 117 | ) 118 | 119 | ) { request -> 120 | val countryCodeList = request.arguments?.get("countryCodeList") 121 | val year = request.arguments?.get("year") 122 | if (countryCodeList == null || year == null) { 123 | return@addTool CallToolResult( 124 | content = listOf(TextContent("The 'countryCodeList' and `year` parameters are required.")) 125 | ) 126 | } 127 | 128 | val countryEmissionInfo = climateTraceRepository.fetchCountryEmissionsInfo( 129 | countryCodeList = countryCodeList 130 | .jsonArray 131 | .map { it.jsonPrimitive.content }, 132 | year = year.jsonPrimitive.content 133 | ) 134 | CallToolResult( 135 | content = countryEmissionInfo.map { TextContent(it.emissions.co2.toString()) } 136 | ) 137 | } 138 | 139 | return server 140 | } 141 | 142 | /** 143 | * Runs an MCP (Model Context Protocol) server using standard I/O for communication. 144 | * 145 | * This function initializes a server instance configured with predefined tools and capabilities. 146 | * It sets up a transport mechanism using standard input and output for communication. 147 | * Once the server starts, it listens for incoming connections, processes requests, 148 | * and executes the appropriate tools. The server shuts down gracefully upon receiving 149 | * a close event. 150 | */ 151 | fun `run mcp server using stdio`() { 152 | val server = configureMcpServer() 153 | val transport = StdioServerTransport( 154 | System.`in`.asInput(), 155 | System.out.asSink().buffered() 156 | ) 157 | 158 | runBlocking { 159 | server.connect(transport) 160 | val done = Job() 161 | server.onClose { 162 | done.complete() 163 | } 164 | done.join() 165 | } 166 | } 167 | 168 | /** 169 | * Launches an SSE (Server-Sent Events) MCP (Model Context Protocol) server on the specified port. 170 | * This server enables clients to connect via SSE for real-time communication and provides endpoints 171 | * for handling specific messages. 172 | * 173 | * @param port The port number on which the SSE server should be started. 174 | */ 175 | fun `run sse mcp server`(port: Int): Unit = runBlocking { 176 | val server = configureMcpServer() 177 | embeddedServer(CIO, host = "0.0.0.0", port = port) { 178 | mcp { 179 | server 180 | } 181 | }.start(wait = true) 182 | } 183 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/johnoreilly/climatetrace/ui/CountryAssetEmissionsInfoTreeMapChart.kt: -------------------------------------------------------------------------------- 1 | package dev.johnoreilly.climatetrace.ui 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.border 5 | import androidx.compose.foundation.clickable 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.Column 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.height 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.material3.MaterialTheme 12 | import androidx.compose.material3.Text 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.runtime.LaunchedEffect 15 | import androidx.compose.runtime.derivedStateOf 16 | import androidx.compose.runtime.getValue 17 | import androidx.compose.runtime.mutableStateOf 18 | import androidx.compose.runtime.remember 19 | import androidx.compose.runtime.setValue 20 | import androidx.compose.ui.Alignment 21 | import androidx.compose.ui.Modifier 22 | import androidx.compose.ui.draw.drawWithContent 23 | import androidx.compose.ui.graphics.Color 24 | import androidx.compose.ui.text.TextStyle 25 | import androidx.compose.ui.text.style.TextAlign 26 | import androidx.compose.ui.unit.TextUnit 27 | import androidx.compose.ui.unit.dp 28 | import androidx.compose.ui.unit.isUnspecified 29 | import androidx.compose.ui.unit.sp 30 | import by.overpass.treemapchart.compose.TreemapChart 31 | import by.overpass.treemapchart.core.tree.Tree 32 | import by.overpass.treemapchart.core.tree.tree 33 | import dev.johnoreilly.climatetrace.remote.CountryAssetEmissionsInfo 34 | import io.github.koalaplot.core.util.generateHueColorPalette 35 | import io.github.koalaplot.core.util.toString 36 | import kotlinx.coroutines.Dispatchers 37 | import kotlinx.coroutines.withContext 38 | 39 | @Composable 40 | fun CountryAssetEmissionsInfoTreeMapChart(countryAssetEmissions: List) { 41 | var tree by remember { mutableStateOf?>(null) } 42 | 43 | LaunchedEffect(countryAssetEmissions) { 44 | tree = buildAssetTree(countryAssetEmissions) 45 | } 46 | 47 | Column(Modifier.height(500.dp).fillMaxWidth(0.8f)) { 48 | tree?.let { 49 | TreemapChart( 50 | data = it, 51 | evaluateItem = ChartNode::value 52 | ) { node, groupContent -> 53 | val export = node.data 54 | if (node.children.isEmpty() && export is ChartNode.Leaf) { 55 | LeafItem(item = export, onClick = { }) 56 | } else if (export is ChartNode.Section) { 57 | SectionItem(export.color) { 58 | groupContent(node) 59 | } 60 | } 61 | } 62 | } 63 | } 64 | } 65 | 66 | 67 | 68 | @Composable 69 | fun LeafItem( 70 | item: ChartNode.Leaf, 71 | modifier: Modifier = Modifier, 72 | onClick: (ChartNode.Leaf) -> Unit, 73 | ) { 74 | Box( 75 | contentAlignment = Alignment.Center, 76 | modifier = modifier 77 | .border(0.5.dp, Color.White) 78 | .background(item.color) 79 | .clickable { onClick(item) } 80 | .padding(4.dp), 81 | ) { 82 | ShrinkableHidableText( 83 | text = "${item.name}\n${item.percentage.toPercent(2)}", 84 | minSize = 6.sp, 85 | ) 86 | } 87 | } 88 | 89 | fun Double.toPercent(precision: Int): String = "${(this * 100.0f).toString(precision)}%" 90 | 91 | 92 | @Composable 93 | fun SectionItem( 94 | sectionColor: Color?, 95 | modifier: Modifier = Modifier, 96 | content: @Composable () -> Unit, 97 | ) { 98 | if (sectionColor != null) { 99 | Box( 100 | modifier = modifier 101 | .background(sectionColor) 102 | ) { 103 | content() 104 | } 105 | } else { 106 | content() 107 | } 108 | } 109 | 110 | 111 | suspend fun buildAssetTree(assetEmissionInfoList: List): Tree = withContext( 112 | Dispatchers.Default) { 113 | val filteredList = assetEmissionInfoList 114 | .filter { it.emissions > 0 } 115 | .sortedByDescending(CountryAssetEmissionsInfo::emissions) 116 | .take(10) 117 | 118 | val colors = generateHueColorPalette(filteredList.size) 119 | 120 | val total = filteredList.sumOf { it.emissions.toDouble() } //.sumOf(CountryAssetEmissionsInfo::emissions) 121 | tree( 122 | ChartNode.Section( 123 | name = "Total", 124 | value = total, 125 | percentage = 1.0, 126 | color = null, 127 | ), 128 | ) { 129 | assetEmissionInfoList 130 | .filter { it.emissions > 0 } 131 | .sortedByDescending(CountryAssetEmissionsInfo::emissions) 132 | .take(10) 133 | .forEachIndexed { index, assetEmissionInfo -> 134 | assetEmissionInfo.sector?.let { 135 | val productPercentage = assetEmissionInfo.emissions / total 136 | node( 137 | ChartNode.Leaf( 138 | name = assetEmissionInfo.sector, 139 | value = assetEmissionInfo.emissions.toDouble(), 140 | percentage = productPercentage, 141 | color = colors[index] 142 | ), 143 | ) 144 | } 145 | } 146 | 147 | } 148 | } 149 | 150 | 151 | 152 | @Suppress("LongParameterList") 153 | @Composable 154 | fun ShrinkableHidableText( 155 | text: String, 156 | minSize: TextUnit, 157 | modifier: Modifier = Modifier, 158 | shrinkSizeFactor: Float = 0.9F, 159 | textAlign: TextAlign = TextAlign.Center, 160 | style: TextStyle =MaterialTheme.typography.bodyMedium 161 | ) { 162 | var fontStyle by remember { mutableStateOf(style) } 163 | var shouldDraw by remember { mutableStateOf(false) } 164 | val show by remember { derivedStateOf { fontStyle.fontSize >= minSize } } 165 | if (show) { 166 | Text( 167 | text = text, 168 | modifier = modifier.drawWithContent { 169 | if (shouldDraw) { 170 | drawContent() 171 | } 172 | }, 173 | textAlign = textAlign, 174 | onTextLayout = { result -> 175 | if (result.hasVisualOverflow) { 176 | fontStyle = fontStyle.copy( 177 | fontSize = fontStyle.fontSize * shrinkSizeFactor, 178 | letterSpacing = if (fontStyle.letterSpacing.isUnspecified) { 179 | fontStyle.letterSpacing 180 | } else { 181 | fontStyle.letterSpacing * shrinkSizeFactor 182 | }, 183 | ) 184 | } else { 185 | shouldDraw = true 186 | } 187 | }, 188 | style = fontStyle, 189 | ) 190 | } 191 | } -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/johnoreilly/climatetrace/viewmodel/AgentViewModel.kt: -------------------------------------------------------------------------------- 1 | package dev.johnoreilly.climatetrace.viewmodel 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import dev.johnoreilly.climatetrace.agent.AgentProvider 6 | import kotlinx.coroutines.Dispatchers 7 | import kotlinx.coroutines.flow.MutableStateFlow 8 | import kotlinx.coroutines.flow.StateFlow 9 | import kotlinx.coroutines.flow.asStateFlow 10 | import kotlinx.coroutines.flow.first 11 | import kotlinx.coroutines.flow.update 12 | import kotlinx.coroutines.launch 13 | import kotlinx.coroutines.withContext 14 | 15 | // Define message types for the chat 16 | sealed class Message { 17 | data class UserMessage(val text: String) : Message() 18 | data class AgentMessage(val text: String) : Message() 19 | data class SystemMessage(val text: String) : Message() 20 | data class ErrorMessage(val text: String) : Message() 21 | data class ToolCallMessage(val text: String) : Message() 22 | data class ResultMessage(val text: String) : Message() 23 | } 24 | 25 | // Define UI state for the agent demo screen 26 | data class AgentDemoUiState( 27 | val messages: List = listOf(Message.SystemMessage("Hi, I'm an agent that can help you")), 28 | val inputText: String = "", 29 | val isInputEnabled: Boolean = true, 30 | val isLoading: Boolean = false, 31 | val isChatEnded: Boolean = false, 32 | 33 | // For handling user responses when agent asks a question 34 | val userResponseRequested: Boolean = false, 35 | val currentUserResponse: String? = null, 36 | ) 37 | 38 | class AgentViewModel(private val agentProvider: AgentProvider) : ViewModel() { 39 | // UI state 40 | private val _uiState = MutableStateFlow( 41 | AgentDemoUiState( 42 | messages = listOf(Message.SystemMessage(agentProvider.description)) 43 | ) 44 | ) 45 | val uiState: StateFlow = _uiState.asStateFlow() 46 | 47 | // Update input text 48 | fun updateInputText(text: String) { 49 | _uiState.update { it.copy(inputText = text) } 50 | } 51 | 52 | // Send user message and start agent processing 53 | fun sendMessage() { 54 | val userInput = _uiState.value.inputText.trim() 55 | if (userInput.isEmpty()) return 56 | 57 | // If agent is waiting for a response to a question 58 | if (_uiState.value.userResponseRequested) { 59 | // Add user message to chat and update current response 60 | _uiState.update { 61 | it.copy( 62 | messages = it.messages + Message.UserMessage(userInput), 63 | inputText = "", 64 | isLoading = true, 65 | userResponseRequested = false, 66 | currentUserResponse = userInput 67 | ) 68 | } 69 | } else { // Initial message flow - add user message and start agent 70 | _uiState.update { 71 | it.copy( 72 | messages = it.messages + Message.UserMessage(userInput), 73 | inputText = "", 74 | isInputEnabled = false, 75 | isLoading = true 76 | ) 77 | } 78 | 79 | // Start the agent processing 80 | viewModelScope.launch { 81 | runAgent(userInput) 82 | } 83 | } 84 | } 85 | 86 | // Run the agent 87 | private suspend fun runAgent(userInput: String) { 88 | withContext(Dispatchers.Default) { 89 | try { 90 | // Create and run the agent using the factory 91 | val agent = agentProvider.provideAgent( 92 | onToolCallEvent = { message -> 93 | // Add tool call messages to the chat 94 | viewModelScope.launch { 95 | _uiState.update { 96 | it.copy( 97 | messages = it.messages + Message.ToolCallMessage(message) 98 | ) 99 | } 100 | } 101 | }, 102 | onErrorEvent = { errorMessage -> 103 | // Handle agent errors 104 | viewModelScope.launch { 105 | _uiState.update { 106 | it.copy( 107 | messages = it.messages + Message.ErrorMessage(errorMessage), 108 | isInputEnabled = true, 109 | isLoading = false 110 | ) 111 | } 112 | } 113 | }, 114 | onAssistantMessage = { message -> 115 | // Handle agent asking user a question 116 | _uiState.update { 117 | it.copy( 118 | messages = it.messages + Message.AgentMessage(message), 119 | isInputEnabled = true, 120 | isLoading = false, 121 | userResponseRequested = true 122 | ) 123 | } 124 | 125 | // Wait for user response 126 | val userResponse = _uiState 127 | .first { it.currentUserResponse != null } 128 | .currentUserResponse 129 | ?: throw IllegalArgumentException("User response is null") 130 | 131 | // Update the state to reset current response 132 | _uiState.update { 133 | it.copy( 134 | currentUserResponse = null 135 | ) 136 | } 137 | 138 | // Return it to the agent 139 | userResponse 140 | }, 141 | ) 142 | 143 | // Run the agent 144 | val result = agent.run(userInput) 145 | 146 | // Update UI with final state and mark chat as ended 147 | _uiState.update { 148 | it.copy( 149 | messages = it.messages + 150 | Message.ResultMessage(result) + 151 | Message.SystemMessage("The agent has stopped."), 152 | isInputEnabled = false, 153 | isLoading = false, 154 | isChatEnded = true 155 | ) 156 | } 157 | } catch (e: Exception) { 158 | // Handle errors 159 | _uiState.update { 160 | it.copy( 161 | messages = it.messages + Message.ErrorMessage("Error: ${e.message}"), 162 | isInputEnabled = true, 163 | isLoading = false 164 | ) 165 | } 166 | } 167 | } 168 | } 169 | 170 | // Restart the chat 171 | fun restartChat() { 172 | _uiState.update { 173 | AgentDemoUiState( 174 | messages = listOf(Message.SystemMessage(agentProvider.description)) 175 | ) 176 | } 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /composeApp/build.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalWasmDsl::class) 2 | 3 | import org.jetbrains.compose.desktop.application.dsl.TargetFormat 4 | import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi 5 | import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl 6 | import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree 7 | import java.util.Properties 8 | import com.codingfeline.buildkonfig.compiler.FieldSpec 9 | 10 | plugins { 11 | alias(libs.plugins.kotlinMultiplatform) 12 | alias(libs.plugins.androidApplication) 13 | alias(libs.plugins.jetbrainsCompose) 14 | alias(libs.plugins.compose.compiler) 15 | alias(libs.plugins.kotlinx.serialization) 16 | alias(libs.plugins.ksp) 17 | alias(libs.plugins.kmpNativeCoroutines) 18 | alias(libs.plugins.buildkonfig) 19 | } 20 | 21 | kotlin { 22 | jvmToolchain(17) 23 | 24 | wasmJs { 25 | browser { 26 | commonWebpackConfig { 27 | outputFileName = "composeApp.js" 28 | } 29 | } 30 | binaries.executable() 31 | } 32 | 33 | 34 | androidTarget { 35 | @OptIn(ExperimentalKotlinGradlePluginApi::class) 36 | instrumentedTestVariant.sourceSetTree.set(KotlinSourceSetTree.test) 37 | } 38 | 39 | 40 | jvm() 41 | 42 | listOf( 43 | iosX64(), 44 | iosArm64(), 45 | iosSimulatorArm64() 46 | ).forEach { iosTarget -> 47 | iosTarget.binaries.framework { 48 | baseName = "ComposeApp" 49 | isStatic = true 50 | } 51 | } 52 | 53 | sourceSets { 54 | all { 55 | languageSettings.optIn("kotlinx.cinterop.ExperimentalForeignApi") 56 | } 57 | 58 | commonMain.dependencies { 59 | implementation(compose.runtime) 60 | implementation(compose.foundation) 61 | implementation(compose.material3) 62 | implementation(compose.materialIconsExtended) 63 | implementation(compose.ui) 64 | implementation(compose.components.resources) 65 | implementation(compose.components.uiToolingPreview) 66 | 67 | implementation(libs.molecule) 68 | 69 | implementation(libs.koin.core) 70 | implementation(libs.koin.compose) 71 | 72 | implementation(libs.kstore) 73 | 74 | implementation(libs.kotlinx.coroutines) 75 | implementation(libs.kotlinx.datetime) 76 | 77 | implementation(libs.bundles.ktor.common) 78 | 79 | implementation(libs.voyager) 80 | 81 | implementation(libs.kmpObservableViewModel) 82 | 83 | implementation(libs.koalaplot) 84 | implementation(libs.treemap.chart) 85 | implementation(libs.treemap.chart.compose) 86 | implementation("dev.carlsen.flagkit:flagkit:1.1.0") 87 | api(libs.compose.adaptive) 88 | api(libs.compose.adaptive.layout) 89 | 90 | implementation(libs.markdown.renderer) 91 | 92 | implementation(libs.koog.agents) 93 | } 94 | 95 | commonTest.dependencies { 96 | implementation(kotlin("test")) 97 | 98 | @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) 99 | implementation(compose.uiTest) 100 | } 101 | 102 | androidMain.dependencies { 103 | implementation(libs.compose.ui.tooling.preview) 104 | implementation(libs.androidx.activity.compose) 105 | implementation(libs.koin.android) 106 | implementation(libs.kstore.file) 107 | implementation(libs.ktor.client.android) 108 | } 109 | 110 | jvmMain.dependencies { 111 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:${libs.versions.kotlinx.coroutines}") 112 | implementation(compose.desktop.currentOs) 113 | implementation(libs.harawata.appdirs) 114 | implementation(libs.kstore.file) 115 | implementation(libs.ktor.client.java) 116 | } 117 | 118 | appleMain.dependencies { 119 | implementation(libs.kstore.file) 120 | implementation(libs.ktor.client.darwin) 121 | } 122 | 123 | 124 | jvmTest.dependencies { 125 | implementation(compose.desktop.currentOs) 126 | } 127 | 128 | val wasmJsMain by getting 129 | 130 | wasmJsMain.dependencies { 131 | implementation(libs.kstore.storage) 132 | } 133 | } 134 | } 135 | 136 | android { 137 | namespace = "dev.johnoreilly.climatetrace" 138 | compileSdk = libs.versions.android.compileSdk.get().toInt() 139 | 140 | sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") 141 | sourceSets["main"].res.srcDirs("src/androidMain/res") 142 | sourceSets["main"].resources.srcDirs("src/commonMain/resources") 143 | 144 | defaultConfig { 145 | applicationId = "dev.johnoreilly.climatetrace" 146 | minSdk = libs.versions.android.minSdk.get().toInt() 147 | targetSdk = libs.versions.android.targetSdk.get().toInt() 148 | versionCode = 1 149 | versionName = "1.0" 150 | 151 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 152 | } 153 | 154 | packaging { 155 | resources { 156 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 157 | excludes += "/META-INF/DEPENDENCIES" 158 | } 159 | } 160 | buildTypes { 161 | getByName("release") { 162 | isMinifyEnabled = false 163 | } 164 | } 165 | compileOptions { 166 | sourceCompatibility = JavaVersion.VERSION_17 167 | targetCompatibility = JavaVersion.VERSION_17 168 | } 169 | 170 | testOptions { 171 | unitTests { 172 | all { 173 | it.exclude("**/screen/**") 174 | } 175 | } 176 | } 177 | } 178 | 179 | compose.desktop { 180 | application { 181 | mainClass = "MainKt" 182 | 183 | nativeDistributions { 184 | targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) 185 | packageName = "dev.johnoreilly.climatetrace" 186 | packageVersion = "1.0.0" 187 | } 188 | } 189 | } 190 | 191 | kotlin.sourceSets.all { 192 | languageSettings.optIn("kotlin.experimental.ExperimentalObjCName") 193 | } 194 | 195 | configurations.all { 196 | // FIXME exclude netty from Koog dependencies? 197 | exclude(group = "io.netty", module = "*") 198 | } 199 | 200 | buildkonfig { 201 | packageName = "dev.johnoreilly.climatetrace" 202 | 203 | val localPropsFile = rootProject.file("local.properties") 204 | val localProperties = Properties() 205 | if (localPropsFile.exists()) { 206 | runCatching { 207 | localProperties.load(localPropsFile.inputStream()) 208 | }.getOrElse { 209 | it.printStackTrace() 210 | } 211 | } 212 | defaultConfigs { 213 | buildConfigField( 214 | FieldSpec.Type.STRING, 215 | "GEMINI_API_KEY", 216 | localProperties["gemini_api_key"]?.toString() ?: "" 217 | ) 218 | buildConfigField( 219 | FieldSpec.Type.STRING, 220 | "OPENAI_API_KEY", 221 | localProperties["openai_api_key"]?.toString() ?: "" 222 | ) 223 | buildConfigField( 224 | FieldSpec.Type.STRING, 225 | "OPENROUTER_API_KEY", 226 | localProperties["openrouter_api_key"]?.toString() ?: "" 227 | ) 228 | } 229 | 230 | } 231 | 232 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/johnoreilly/climatetrace/ui/CountryInfoDetailedView.kt: -------------------------------------------------------------------------------- 1 | package dev.johnoreilly.climatetrace.ui 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.Row 7 | import androidx.compose.foundation.layout.Spacer 8 | import androidx.compose.foundation.layout.fillMaxSize 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.layout.size 11 | import androidx.compose.foundation.layout.wrapContentSize 12 | import androidx.compose.foundation.rememberScrollState 13 | import androidx.compose.foundation.verticalScroll 14 | import androidx.compose.material3.AssistChip 15 | import androidx.compose.material3.CircularProgressIndicator 16 | import androidx.compose.material3.DropdownMenu 17 | import androidx.compose.material3.DropdownMenuItem 18 | import androidx.compose.material3.MaterialTheme 19 | import androidx.compose.material3.Text 20 | import androidx.compose.runtime.Composable 21 | import androidx.compose.runtime.getValue 22 | import androidx.compose.runtime.mutableStateOf 23 | import androidx.compose.runtime.remember 24 | import androidx.compose.runtime.setValue 25 | import androidx.compose.ui.Alignment 26 | import androidx.compose.ui.Modifier 27 | import androidx.compose.ui.graphics.Color 28 | import androidx.compose.ui.text.font.FontWeight 29 | import androidx.compose.ui.text.style.TextAlign 30 | import androidx.compose.ui.unit.dp 31 | import dev.johnoreilly.climatetrace.viewmodel.CountryDetailsUIState 32 | 33 | @Composable 34 | fun CountryInfoDetailedView( 35 | viewState: CountryDetailsUIState, 36 | onYearSelected: (String) -> Unit 37 | ) { 38 | when (viewState) { 39 | CountryDetailsUIState.NoCountrySelected -> { 40 | Column( 41 | modifier = Modifier.fillMaxSize() 42 | .wrapContentSize(Alignment.Center) 43 | ) { 44 | Text(text = "No Country Selected.", style = MaterialTheme.typography.titleMedium) 45 | } 46 | } 47 | is CountryDetailsUIState.Loading -> { 48 | Column( 49 | modifier = Modifier.fillMaxSize() 50 | .wrapContentSize(Alignment.Center) 51 | ) { 52 | CircularProgressIndicator() 53 | } 54 | } 55 | is CountryDetailsUIState.Error -> { Text("Error") } 56 | is CountryDetailsUIState.Success -> { 57 | CountryInfoDetailedViewSuccess(viewState, onYearSelected) 58 | } 59 | } 60 | } 61 | 62 | 63 | @Composable 64 | fun CountryInfoDetailedViewSuccess(viewState: CountryDetailsUIState.Success, onYearSelected: (String) -> Unit) { 65 | Column( 66 | modifier = Modifier 67 | .verticalScroll(rememberScrollState()) 68 | .fillMaxSize() 69 | .padding(16.dp), 70 | horizontalAlignment = Alignment.Start 71 | ) { 72 | // Header card with flag + country label info 73 | CountryHeader(viewState) 74 | 75 | Spacer(modifier = Modifier.size(16.dp)) 76 | 77 | val year = viewState.year 78 | val countryAssetEmissionsList = viewState.countryAssetEmissionsList 79 | val countryEmissionInfo = viewState.countryEmissionInfo 80 | 81 | // Year selector row 82 | Column { 83 | Text(text = "Year", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) 84 | Spacer(modifier = Modifier.size(6.dp)) 85 | YearSelector(year, viewState.availableYears, onYearSelected) 86 | } 87 | 88 | Spacer(modifier = Modifier.size(12.dp)) 89 | 90 | countryEmissionInfo?.let { 91 | val co2 = (countryEmissionInfo.emissions.co2 / 1_000_000).toInt() 92 | val percentage = (countryEmissionInfo.emissions.co2 / countryEmissionInfo.worldEmissions.co2).toPercent(2) 93 | 94 | // Key figures chips 95 | KeyFiguresRow(co2Mt = co2, rank = countryEmissionInfo.rank, share = percentage) 96 | 97 | Spacer(modifier = Modifier.size(16.dp)) 98 | 99 | val filteredCountryAssetEmissionsList = countryAssetEmissionsList.filter { it.sector != null } 100 | if (filteredCountryAssetEmissionsList.isNotEmpty()) { 101 | // Keep charts unchanged 102 | SectorEmissionsPieChart(countryAssetEmissionsList) 103 | Spacer(modifier = Modifier.size(32.dp)) 104 | CountryAssetEmissionsInfoTreeMapChart(countryAssetEmissionsList) 105 | } else { 106 | Spacer(modifier = Modifier.size(16.dp)) 107 | Column(horizontalAlignment = Alignment.CenterHorizontally) { 108 | Text( 109 | "Invalid data", 110 | style = MaterialTheme.typography.titleMedium.copy(color = Color.Red), 111 | textAlign = TextAlign.Center 112 | ) 113 | } 114 | } 115 | } 116 | } 117 | } 118 | 119 | @Composable 120 | private fun CountryHeader(viewState: CountryDetailsUIState.Success) { 121 | val c = viewState.country 122 | androidx.compose.material3.Surface( 123 | tonalElevation = 2.dp, 124 | shape = MaterialTheme.shapes.medium, 125 | color = MaterialTheme.colorScheme.surfaceVariant 126 | ) { 127 | Column(modifier = Modifier.padding(16.dp)) { 128 | Text( 129 | text = c.name, 130 | style = MaterialTheme.typography.headlineSmall 131 | ) 132 | Spacer(modifier = Modifier.size(4.dp)) 133 | Text( 134 | text = "${c.continent} • ${c.alpha2} / ${c.alpha3}", 135 | style = MaterialTheme.typography.bodyMedium, 136 | color = MaterialTheme.colorScheme.onSurfaceVariant 137 | ) 138 | } 139 | } 140 | } 141 | 142 | @Composable 143 | private fun KeyFiguresRow(co2Mt: Int, rank: Int, share: String) { 144 | Row { 145 | KeyFigureChip(label = "CO₂ (Mt)", value = co2Mt.toString()) 146 | Spacer(modifier = Modifier.size(8.dp)) 147 | KeyFigureChip(label = "Rank", value = rank.toString()) 148 | Spacer(modifier = Modifier.size(8.dp)) 149 | KeyFigureChip(label = "World Share", value = share) 150 | } 151 | } 152 | 153 | @Composable 154 | private fun KeyFigureChip(label: String, value: String) { 155 | AssistChip( 156 | onClick = {}, 157 | label = { 158 | Column { 159 | Text(text = label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSecondaryContainer) 160 | Text(text = value, style = MaterialTheme.typography.titleMedium) 161 | } 162 | } 163 | ) 164 | } 165 | 166 | 167 | 168 | @Composable 169 | fun YearSelector(selectedYear: String, availableYears: List, onYearSelected: (String) -> Unit) { 170 | var expanded by remember { mutableStateOf(false) } 171 | 172 | Box(modifier = Modifier.wrapContentSize(Alignment.TopStart)) { 173 | Text(selectedYear, modifier = Modifier.clickable(onClick = { expanded = true })) 174 | DropdownMenu( 175 | expanded = expanded, 176 | onDismissRequest = { expanded = false }, 177 | ) { 178 | availableYears.forEach { year -> 179 | DropdownMenuItem(onClick = { 180 | onYearSelected(year) 181 | expanded = false 182 | }, text = { 183 | Text(year) 184 | }) 185 | } 186 | } 187 | } 188 | } 189 | 190 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | # This is normally unused 84 | # shellcheck disable=SC2034 85 | APP_BASE_NAME=${0##*/} 86 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 87 | APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit 88 | 89 | # Use the maximum available, or set MAX_FD != -1 to use that value. 90 | MAX_FD=maximum 91 | 92 | warn () { 93 | echo "$*" 94 | } >&2 95 | 96 | die () { 97 | echo 98 | echo "$*" 99 | echo 100 | exit 1 101 | } >&2 102 | 103 | # OS specific support (must be 'true' or 'false'). 104 | cygwin=false 105 | msys=false 106 | darwin=false 107 | nonstop=false 108 | case "$( uname )" in #( 109 | CYGWIN* ) cygwin=true ;; #( 110 | Darwin* ) darwin=true ;; #( 111 | MSYS* | MINGW* ) msys=true ;; #( 112 | NONSTOP* ) nonstop=true ;; 113 | esac 114 | 115 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 116 | 117 | 118 | # Determine the Java command to use to start the JVM. 119 | if [ -n "$JAVA_HOME" ] ; then 120 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 121 | # IBM's JDK on AIX uses strange locations for the executables 122 | JAVACMD=$JAVA_HOME/jre/sh/java 123 | else 124 | JAVACMD=$JAVA_HOME/bin/java 125 | fi 126 | if [ ! -x "$JAVACMD" ] ; then 127 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 128 | 129 | Please set the JAVA_HOME variable in your environment to match the 130 | location of your Java installation." 131 | fi 132 | else 133 | JAVACMD=java 134 | if ! command -v java >/dev/null 2>&1 135 | then 136 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | fi 142 | 143 | # Increase the maximum file descriptors if we can. 144 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 145 | case $MAX_FD in #( 146 | max*) 147 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 148 | # shellcheck disable=SC2039,SC3045 149 | MAX_FD=$( ulimit -H -n ) || 150 | warn "Could not query maximum file descriptor limit" 151 | esac 152 | case $MAX_FD in #( 153 | '' | soft) :;; #( 154 | *) 155 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 156 | # shellcheck disable=SC2039,SC3045 157 | ulimit -n "$MAX_FD" || 158 | warn "Could not set maximum file descriptor limit to $MAX_FD" 159 | esac 160 | fi 161 | 162 | # Collect all arguments for the java command, stacking in reverse order: 163 | # * args from the command line 164 | # * the main class name 165 | # * -classpath 166 | # * -D...appname settings 167 | # * --module-path (only if needed) 168 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 169 | 170 | # For Cygwin or MSYS, switch paths to Windows format before running java 171 | if "$cygwin" || "$msys" ; then 172 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 173 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 174 | 175 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 176 | 177 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 178 | for arg do 179 | if 180 | case $arg in #( 181 | -*) false ;; # don't mess with options #( 182 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 183 | [ -e "$t" ] ;; #( 184 | *) false ;; 185 | esac 186 | then 187 | arg=$( cygpath --path --ignore --mixed "$arg" ) 188 | fi 189 | # Roll the args list around exactly as many times as the number of 190 | # args, so each arg winds up back in the position where it started, but 191 | # possibly modified. 192 | # 193 | # NB: a `for` loop captures its iteration list before it begins, so 194 | # changing the positional parameters here affects neither the number of 195 | # iterations, nor the values presented in `arg`. 196 | shift # remove old arg 197 | set -- "$@" "$arg" # push replacement arg 198 | done 199 | fi 200 | 201 | 202 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 203 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 204 | 205 | # Collect all arguments for the java command: 206 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 207 | # and any embedded shellness will be escaped. 208 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 209 | # treated as '${Hostname}' itself on the command line. 210 | 211 | set -- \ 212 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 213 | -classpath "$CLASSPATH" \ 214 | org.gradle.wrapper.GradleWrapperMain \ 215 | "$@" 216 | 217 | # Stop when "xargs" is not available. 218 | if ! command -v xargs >/dev/null 2>&1 219 | then 220 | die "xargs is not available" 221 | fi 222 | 223 | # Use "xargs" to parse quoted args. 224 | # 225 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 226 | # 227 | # In Bash we could simply go: 228 | # 229 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 230 | # set -- "${ARGS[@]}" "$@" 231 | # 232 | # but POSIX shell has neither arrays nor command substitution, so instead we 233 | # post-process each arg (as a line of input to sed) to backslash-escape any 234 | # character that might be a shell metacharacter, then use eval to reverse 235 | # that process (while maintaining the separation between arguments), and wrap 236 | # the whole thing up as a single "set" statement. 237 | # 238 | # This will of course break if any of these variables contains a newline or 239 | # an unmatched quote. 240 | # 241 | 242 | eval "set -- $( 243 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 244 | xargs -n1 | 245 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 246 | tr '\n' ' ' 247 | )" '"$@"' 248 | 249 | exec "$JAVACMD" "$@" 250 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/johnoreilly/climatetrace/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package dev.johnoreilly.climatetrace.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val primaryLight = Color(0xFF4C662B) 6 | val onPrimaryLight = Color(0xFFFFFFFF) 7 | val primaryContainerLight = Color(0xFFCDEDA3) 8 | val onPrimaryContainerLight = Color(0xFF354E16) 9 | val secondaryLight = Color(0xFF586249) 10 | val onSecondaryLight = Color(0xFFFFFFFF) 11 | val secondaryContainerLight = Color(0xFFDCE7C8) 12 | val onSecondaryContainerLight = Color(0xFF404A33) 13 | val tertiaryLight = Color(0xFF386663) 14 | val onTertiaryLight = Color(0xFFFFFFFF) 15 | val tertiaryContainerLight = Color(0xFFBCECE7) 16 | val onTertiaryContainerLight = Color(0xFF1F4E4B) 17 | val errorLight = Color(0xFFBA1A1A) 18 | val onErrorLight = Color(0xFFFFFFFF) 19 | val errorContainerLight = Color(0xFFFFDAD6) 20 | val onErrorContainerLight = Color(0xFF93000A) 21 | val backgroundLight = Color(0xFFF9FAEF) 22 | val onBackgroundLight = Color(0xFF1A1C16) 23 | val surfaceLight = Color(0xFFF9FAEF) 24 | val onSurfaceLight = Color(0xFF1A1C16) 25 | val surfaceVariantLight = Color(0xFFE1E4D5) 26 | val onSurfaceVariantLight = Color(0xFF44483D) 27 | val outlineLight = Color(0xFF75796C) 28 | val outlineVariantLight = Color(0xFFC5C8BA) 29 | val scrimLight = Color(0xFF000000) 30 | val inverseSurfaceLight = Color(0xFF2F312A) 31 | val inverseOnSurfaceLight = Color(0xFFF1F2E6) 32 | val inversePrimaryLight = Color(0xFFB1D18A) 33 | val surfaceDimLight = Color(0xFFDADBD0) 34 | val surfaceBrightLight = Color(0xFFF9FAEF) 35 | val surfaceContainerLowestLight = Color(0xFFFFFFFF) 36 | val surfaceContainerLowLight = Color(0xFFF3F4E9) 37 | val surfaceContainerLight = Color(0xFFEEEFE3) 38 | val surfaceContainerHighLight = Color(0xFFE8E9DE) 39 | val surfaceContainerHighestLight = Color(0xFFE2E3D8) 40 | 41 | val primaryLightMediumContrast = Color(0xFF253D05) 42 | val onPrimaryLightMediumContrast = Color(0xFFFFFFFF) 43 | val primaryContainerLightMediumContrast = Color(0xFF5A7539) 44 | val onPrimaryContainerLightMediumContrast = Color(0xFFFFFFFF) 45 | val secondaryLightMediumContrast = Color(0xFF303924) 46 | val onSecondaryLightMediumContrast = Color(0xFFFFFFFF) 47 | val secondaryContainerLightMediumContrast = Color(0xFF667157) 48 | val onSecondaryContainerLightMediumContrast = Color(0xFFFFFFFF) 49 | val tertiaryLightMediumContrast = Color(0xFF083D3A) 50 | val onTertiaryLightMediumContrast = Color(0xFFFFFFFF) 51 | val tertiaryContainerLightMediumContrast = Color(0xFF477572) 52 | val onTertiaryContainerLightMediumContrast = Color(0xFFFFFFFF) 53 | val errorLightMediumContrast = Color(0xFF740006) 54 | val onErrorLightMediumContrast = Color(0xFFFFFFFF) 55 | val errorContainerLightMediumContrast = Color(0xFFCF2C27) 56 | val onErrorContainerLightMediumContrast = Color(0xFFFFFFFF) 57 | val backgroundLightMediumContrast = Color(0xFFF9FAEF) 58 | val onBackgroundLightMediumContrast = Color(0xFF1A1C16) 59 | val surfaceLightMediumContrast = Color(0xFFF9FAEF) 60 | val onSurfaceLightMediumContrast = Color(0xFF0F120C) 61 | val surfaceVariantLightMediumContrast = Color(0xFFE1E4D5) 62 | val onSurfaceVariantLightMediumContrast = Color(0xFF34382D) 63 | val outlineLightMediumContrast = Color(0xFF505449) 64 | val outlineVariantLightMediumContrast = Color(0xFF6B6F62) 65 | val scrimLightMediumContrast = Color(0xFF000000) 66 | val inverseSurfaceLightMediumContrast = Color(0xFF2F312A) 67 | val inverseOnSurfaceLightMediumContrast = Color(0xFFF1F2E6) 68 | val inversePrimaryLightMediumContrast = Color(0xFFB1D18A) 69 | val surfaceDimLightMediumContrast = Color(0xFFC6C7BD) 70 | val surfaceBrightLightMediumContrast = Color(0xFFF9FAEF) 71 | val surfaceContainerLowestLightMediumContrast = Color(0xFFFFFFFF) 72 | val surfaceContainerLowLightMediumContrast = Color(0xFFF3F4E9) 73 | val surfaceContainerLightMediumContrast = Color(0xFFE8E9DE) 74 | val surfaceContainerHighLightMediumContrast = Color(0xFFDCDED3) 75 | val surfaceContainerHighestLightMediumContrast = Color(0xFFD1D3C8) 76 | 77 | val primaryLightHighContrast = Color(0xFF1C3200) 78 | val onPrimaryLightHighContrast = Color(0xFFFFFFFF) 79 | val primaryContainerLightHighContrast = Color(0xFF375018) 80 | val onPrimaryContainerLightHighContrast = Color(0xFFFFFFFF) 81 | val secondaryLightHighContrast = Color(0xFF262F1A) 82 | val onSecondaryLightHighContrast = Color(0xFFFFFFFF) 83 | val secondaryContainerLightHighContrast = Color(0xFF434C35) 84 | val onSecondaryContainerLightHighContrast = Color(0xFFFFFFFF) 85 | val tertiaryLightHighContrast = Color(0xFF003230) 86 | val onTertiaryLightHighContrast = Color(0xFFFFFFFF) 87 | val tertiaryContainerLightHighContrast = Color(0xFF21504E) 88 | val onTertiaryContainerLightHighContrast = Color(0xFFFFFFFF) 89 | val errorLightHighContrast = Color(0xFF600004) 90 | val onErrorLightHighContrast = Color(0xFFFFFFFF) 91 | val errorContainerLightHighContrast = Color(0xFF98000A) 92 | val onErrorContainerLightHighContrast = Color(0xFFFFFFFF) 93 | val backgroundLightHighContrast = Color(0xFFF9FAEF) 94 | val onBackgroundLightHighContrast = Color(0xFF1A1C16) 95 | val surfaceLightHighContrast = Color(0xFFF9FAEF) 96 | val onSurfaceLightHighContrast = Color(0xFF000000) 97 | val surfaceVariantLightHighContrast = Color(0xFFE1E4D5) 98 | val onSurfaceVariantLightHighContrast = Color(0xFF000000) 99 | val outlineLightHighContrast = Color(0xFF2A2D24) 100 | val outlineVariantLightHighContrast = Color(0xFF474B40) 101 | val scrimLightHighContrast = Color(0xFF000000) 102 | val inverseSurfaceLightHighContrast = Color(0xFF2F312A) 103 | val inverseOnSurfaceLightHighContrast = Color(0xFFFFFFFF) 104 | val inversePrimaryLightHighContrast = Color(0xFFB1D18A) 105 | val surfaceDimLightHighContrast = Color(0xFFB8BAAF) 106 | val surfaceBrightLightHighContrast = Color(0xFFF9FAEF) 107 | val surfaceContainerLowestLightHighContrast = Color(0xFFFFFFFF) 108 | val surfaceContainerLowLightHighContrast = Color(0xFFF1F2E6) 109 | val surfaceContainerLightHighContrast = Color(0xFFE2E3D8) 110 | val surfaceContainerHighLightHighContrast = Color(0xFFD4D5CA) 111 | val surfaceContainerHighestLightHighContrast = Color(0xFFC6C7BD) 112 | 113 | val primaryDark = Color(0xFFB1D18A) 114 | val onPrimaryDark = Color(0xFF1F3701) 115 | val primaryContainerDark = Color(0xFF354E16) 116 | val onPrimaryContainerDark = Color(0xFFCDEDA3) 117 | val secondaryDark = Color(0xFFBFCBAD) 118 | val onSecondaryDark = Color(0xFF2A331E) 119 | val secondaryContainerDark = Color(0xFF404A33) 120 | val onSecondaryContainerDark = Color(0xFFDCE7C8) 121 | val tertiaryDark = Color(0xFFA0D0CB) 122 | val onTertiaryDark = Color(0xFF003735) 123 | val tertiaryContainerDark = Color(0xFF1F4E4B) 124 | val onTertiaryContainerDark = Color(0xFFBCECE7) 125 | val errorDark = Color(0xFFFFB4AB) 126 | val onErrorDark = Color(0xFF690005) 127 | val errorContainerDark = Color(0xFF93000A) 128 | val onErrorContainerDark = Color(0xFFFFDAD6) 129 | val backgroundDark = Color(0xFF12140E) 130 | val onBackgroundDark = Color(0xFFE2E3D8) 131 | val surfaceDark = Color(0xFF12140E) 132 | val onSurfaceDark = Color(0xFFE2E3D8) 133 | val surfaceVariantDark = Color(0xFF44483D) 134 | val onSurfaceVariantDark = Color(0xFFC5C8BA) 135 | val outlineDark = Color(0xFF8F9285) 136 | val outlineVariantDark = Color(0xFF44483D) 137 | val scrimDark = Color(0xFF000000) 138 | val inverseSurfaceDark = Color(0xFFE2E3D8) 139 | val inverseOnSurfaceDark = Color(0xFF2F312A) 140 | val inversePrimaryDark = Color(0xFF4C662B) 141 | val surfaceDimDark = Color(0xFF12140E) 142 | val surfaceBrightDark = Color(0xFF383A32) 143 | val surfaceContainerLowestDark = Color(0xFF0C0F09) 144 | val surfaceContainerLowDark = Color(0xFF1A1C16) 145 | val surfaceContainerDark = Color(0xFF1E201A) 146 | val surfaceContainerHighDark = Color(0xFF282B24) 147 | val surfaceContainerHighestDark = Color(0xFF33362E) 148 | 149 | val primaryDarkMediumContrast = Color(0xFFC7E79E) 150 | val onPrimaryDarkMediumContrast = Color(0xFF172B00) 151 | val primaryContainerDarkMediumContrast = Color(0xFF7D9A59) 152 | val onPrimaryContainerDarkMediumContrast = Color(0xFF000000) 153 | val secondaryDarkMediumContrast = Color(0xFFD5E1C2) 154 | val onSecondaryDarkMediumContrast = Color(0xFF1F2814) 155 | val secondaryContainerDarkMediumContrast = Color(0xFF8A9579) 156 | val onSecondaryContainerDarkMediumContrast = Color(0xFF000000) 157 | val tertiaryDarkMediumContrast = Color(0xFFB5E6E1) 158 | val onTertiaryDarkMediumContrast = Color(0xFF002B29) 159 | val tertiaryContainerDarkMediumContrast = Color(0xFF6B9995) 160 | val onTertiaryContainerDarkMediumContrast = Color(0xFF000000) 161 | val errorDarkMediumContrast = Color(0xFFFFD2CC) 162 | val onErrorDarkMediumContrast = Color(0xFF540003) 163 | val errorContainerDarkMediumContrast = Color(0xFFFF5449) 164 | val onErrorContainerDarkMediumContrast = Color(0xFF000000) 165 | val backgroundDarkMediumContrast = Color(0xFF12140E) 166 | val onBackgroundDarkMediumContrast = Color(0xFFE2E3D8) 167 | val surfaceDarkMediumContrast = Color(0xFF12140E) 168 | val onSurfaceDarkMediumContrast = Color(0xFFFFFFFF) 169 | val surfaceVariantDarkMediumContrast = Color(0xFF44483D) 170 | val onSurfaceVariantDarkMediumContrast = Color(0xFFDBDECF) 171 | val outlineDarkMediumContrast = Color(0xFFB0B3A6) 172 | val outlineVariantDarkMediumContrast = Color(0xFF8E9285) 173 | val scrimDarkMediumContrast = Color(0xFF000000) 174 | val inverseSurfaceDarkMediumContrast = Color(0xFFE2E3D8) 175 | val inverseOnSurfaceDarkMediumContrast = Color(0xFF282B24) 176 | val inversePrimaryDarkMediumContrast = Color(0xFF364F17) 177 | val surfaceDimDarkMediumContrast = Color(0xFF12140E) 178 | val surfaceBrightDarkMediumContrast = Color(0xFF43453D) 179 | val surfaceContainerLowestDarkMediumContrast = Color(0xFF060804) 180 | val surfaceContainerLowDarkMediumContrast = Color(0xFF1C1E18) 181 | val surfaceContainerDarkMediumContrast = Color(0xFF262922) 182 | val surfaceContainerHighDarkMediumContrast = Color(0xFF31342C) 183 | val surfaceContainerHighestDarkMediumContrast = Color(0xFF3C3F37) 184 | 185 | val primaryDarkHighContrast = Color(0xFFDAFBB0) 186 | val onPrimaryDarkHighContrast = Color(0xFF000000) 187 | val primaryContainerDarkHighContrast = Color(0xFFADCD86) 188 | val onPrimaryContainerDarkHighContrast = Color(0xFF050E00) 189 | val secondaryDarkHighContrast = Color(0xFFE9F4D5) 190 | val onSecondaryDarkHighContrast = Color(0xFF000000) 191 | val secondaryContainerDarkHighContrast = Color(0xFFBCC7A9) 192 | val onSecondaryContainerDarkHighContrast = Color(0xFF060D01) 193 | val tertiaryDarkHighContrast = Color(0xFFC9F9F5) 194 | val onTertiaryDarkHighContrast = Color(0xFF000000) 195 | val tertiaryContainerDarkHighContrast = Color(0xFF9CCCC7) 196 | val onTertiaryContainerDarkHighContrast = Color(0xFF000E0D) 197 | val errorDarkHighContrast = Color(0xFFFFECE9) 198 | val onErrorDarkHighContrast = Color(0xFF000000) 199 | val errorContainerDarkHighContrast = Color(0xFFFFAEA4) 200 | val onErrorContainerDarkHighContrast = Color(0xFF220001) 201 | val backgroundDarkHighContrast = Color(0xFF12140E) 202 | val onBackgroundDarkHighContrast = Color(0xFFE2E3D8) 203 | val surfaceDarkHighContrast = Color(0xFF12140E) 204 | val onSurfaceDarkHighContrast = Color(0xFFFFFFFF) 205 | val surfaceVariantDarkHighContrast = Color(0xFF44483D) 206 | val onSurfaceVariantDarkHighContrast = Color(0xFFFFFFFF) 207 | val outlineDarkHighContrast = Color(0xFFEEF2E2) 208 | val outlineVariantDarkHighContrast = Color(0xFFC1C4B6) 209 | val scrimDarkHighContrast = Color(0xFF000000) 210 | val inverseSurfaceDarkHighContrast = Color(0xFFE2E3D8) 211 | val inverseOnSurfaceDarkHighContrast = Color(0xFF000000) 212 | val inversePrimaryDarkHighContrast = Color(0xFF364F17) 213 | val surfaceDimDarkHighContrast = Color(0xFF12140E) 214 | val surfaceBrightDarkHighContrast = Color(0xFF4F5149) 215 | val surfaceContainerLowestDarkHighContrast = Color(0xFF000000) 216 | val surfaceContainerLowDarkHighContrast = Color(0xFF1E201A) 217 | val surfaceContainerDarkHighContrast = Color(0xFF2F312A) 218 | val surfaceContainerHighDarkHighContrast = Color(0xFF3A3C35) 219 | val surfaceContainerHighestDarkHighContrast = Color(0xFF454840) 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/johnoreilly/climatetrace/ui/ClimateTraceScreen.kt: -------------------------------------------------------------------------------- 1 | package dev.johnoreilly.climatetrace.ui 2 | 3 | import androidx.compose.animation.AnimatedVisibility 4 | import androidx.compose.animation.fadeIn 5 | import androidx.compose.animation.fadeOut 6 | import androidx.compose.foundation.Image 7 | import androidx.compose.foundation.clickable 8 | import androidx.compose.foundation.layout.Arrangement 9 | import androidx.compose.foundation.layout.Box 10 | import androidx.compose.foundation.layout.Column 11 | import androidx.compose.foundation.layout.Row 12 | import androidx.compose.foundation.layout.Spacer 13 | import androidx.compose.foundation.layout.fillMaxHeight 14 | import androidx.compose.foundation.layout.fillMaxSize 15 | import androidx.compose.foundation.layout.fillMaxWidth 16 | import androidx.compose.foundation.layout.height 17 | import androidx.compose.foundation.layout.padding 18 | import androidx.compose.foundation.layout.width 19 | import androidx.compose.foundation.layout.wrapContentSize 20 | import androidx.compose.foundation.lazy.LazyColumn 21 | import androidx.compose.foundation.lazy.items 22 | import androidx.compose.material.icons.Icons 23 | import androidx.compose.material.icons.filled.Close 24 | import androidx.compose.material.icons.filled.Search 25 | import androidx.compose.material3.CircularProgressIndicator 26 | import androidx.compose.material3.ExperimentalMaterial3Api 27 | import androidx.compose.material3.Icon 28 | import androidx.compose.material3.IconButton 29 | import androidx.compose.material3.MaterialTheme 30 | import androidx.compose.material3.SearchBar 31 | import androidx.compose.material3.SearchBarDefaults 32 | import androidx.compose.material3.Text 33 | import androidx.compose.material3.VerticalDivider 34 | import androidx.compose.material3.HorizontalDivider 35 | import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo 36 | import androidx.compose.runtime.Composable 37 | import androidx.compose.runtime.MutableState 38 | import androidx.compose.runtime.collectAsState 39 | import androidx.compose.runtime.getValue 40 | import androidx.compose.runtime.mutableStateOf 41 | import androidx.compose.runtime.remember 42 | import androidx.compose.runtime.setValue 43 | import androidx.compose.ui.Alignment 44 | import androidx.compose.ui.Modifier 45 | import androidx.compose.ui.graphics.Color 46 | import androidx.compose.ui.platform.LocalSoftwareKeyboardController 47 | import androidx.compose.ui.unit.dp 48 | import androidx.window.core.layout.WindowWidthSizeClass 49 | import cafe.adriel.voyager.core.screen.Screen 50 | import dev.carlsen.flagkit.FlagKit 51 | import dev.johnoreilly.climatetrace.remote.Country 52 | import dev.johnoreilly.climatetrace.ui.utils.PanelState 53 | import dev.johnoreilly.climatetrace.ui.utils.ResizablePanel 54 | import dev.johnoreilly.climatetrace.viewmodel.CountryDetailsViewModel 55 | import dev.johnoreilly.climatetrace.viewmodel.CountryListUIState 56 | import dev.johnoreilly.climatetrace.viewmodel.CountryListViewModel 57 | import org.koin.compose.koinInject 58 | 59 | 60 | class ClimateTraceScreen: Screen { 61 | @Composable 62 | override fun Content() { 63 | val countryListViewModel = koinInject() 64 | val countryListViewState by countryListViewModel.viewState.collectAsState() 65 | 66 | Column(Modifier) { 67 | when (val state = countryListViewState) { 68 | is CountryListUIState.Loading -> { 69 | Column(modifier = Modifier.fillMaxSize().fillMaxHeight().wrapContentSize(Alignment.Center)) { 70 | CircularProgressIndicator() 71 | } 72 | } 73 | is CountryListUIState.Error -> { 74 | Text("Error") 75 | } 76 | is CountryListUIState.Success -> { 77 | CountryScreenSuccess(state.countryList) 78 | } 79 | } 80 | } 81 | } 82 | } 83 | 84 | @Composable 85 | fun CountryScreenSuccess(countryList: List) { 86 | val windowAdaptiveInfo = currentWindowAdaptiveInfo() 87 | val countryDetailsViewModel = koinInject() 88 | val countryDetailsViewState by countryDetailsViewModel.viewState.collectAsState() 89 | var selectedCountry by remember { mutableStateOf(null) } 90 | 91 | val panelState = remember { PanelState() } 92 | 93 | val animatedSize = if (panelState.splitter.isResizing) { 94 | if (panelState.isExpanded) panelState.expandedSize else panelState.collapsedSize 95 | } else { 96 | if (panelState.isExpanded) panelState.expandedSize else panelState.collapsedSize 97 | } 98 | 99 | Row(Modifier.fillMaxSize()) { 100 | val windowSizeClass = windowAdaptiveInfo.windowSizeClass 101 | if (windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT) { 102 | Column(Modifier.fillMaxWidth()) { 103 | Box( 104 | Modifier.height(250.dp).fillMaxWidth() 105 | ) { 106 | CountryListView( 107 | countryList = countryList, 108 | selectedCountry = selectedCountry, 109 | ) { country -> 110 | selectedCountry = country 111 | countryDetailsViewModel.setCountry(country) 112 | } 113 | } 114 | 115 | Spacer(modifier = Modifier.width(1.dp).fillMaxWidth()) 116 | CountryInfoDetailedView(countryDetailsViewState) { 117 | countryDetailsViewModel.setYear(it) 118 | } 119 | } 120 | } else { 121 | 122 | ResizablePanel( 123 | Modifier.width(animatedSize).fillMaxHeight(), 124 | title = "Countries", 125 | state = panelState 126 | ) { 127 | CountryListView( 128 | countryList = countryList, 129 | selectedCountry = selectedCountry, 130 | ) { country -> 131 | selectedCountry = country 132 | countryDetailsViewModel.setCountry(country) 133 | } 134 | } 135 | 136 | VerticalDivider(thickness = 1.dp, color = Color.DarkGray) 137 | Box(Modifier.fillMaxHeight()) { 138 | CountryInfoDetailedView(countryDetailsViewState) { 139 | countryDetailsViewModel.setYear(it) 140 | } 141 | } 142 | } 143 | } 144 | 145 | } 146 | 147 | 148 | @Composable 149 | fun CountryListView( 150 | countryList: List, 151 | selectedCountry: Country?, 152 | countrySelected: (country: Country) -> Unit 153 | ) { 154 | val searchQuery = remember { mutableStateOf("") } 155 | 156 | Column { 157 | SearchableList( 158 | searchQuery = searchQuery, 159 | onSearchQueryChange = { query -> searchQuery.value = query }, 160 | countryList = countryList, 161 | selectedCountry = selectedCountry, 162 | countrySelected = countrySelected 163 | ) 164 | } 165 | } 166 | 167 | @OptIn(ExperimentalMaterial3Api::class) 168 | @Composable 169 | fun SearchableList( 170 | searchQuery: MutableState, 171 | onSearchQueryChange: (String) -> Unit, 172 | countryList: List, 173 | selectedCountry: Country?, 174 | countrySelected: (country: Country) -> Unit 175 | ) { 176 | val filteredCountryList = countryList 177 | .filter { it.name.contains(searchQuery.value, ignoreCase = true) || it.alpha2.contains(searchQuery.value, true) || it.alpha3.contains(searchQuery.value, true) } 178 | .sortedBy { it.name } 179 | val keyboardController = LocalSoftwareKeyboardController.current 180 | SearchBar( 181 | query = searchQuery.value, 182 | onQueryChange = onSearchQueryChange, 183 | onSearch = { 184 | onSearchQueryChange.invoke(searchQuery.value) 185 | keyboardController?.hide() 186 | }, 187 | placeholder = { 188 | Text(text = "Search countries") 189 | }, 190 | leadingIcon = { 191 | Icon( 192 | imageVector = Icons.Default.Search, 193 | tint = MaterialTheme.colorScheme.onSurface, 194 | contentDescription = "search" 195 | ) 196 | }, 197 | trailingIcon = { 198 | AnimatedVisibility( 199 | visible = searchQuery.value.isNotBlank(), 200 | enter = fadeIn(), 201 | exit = fadeOut() 202 | ) { 203 | IconButton(onClick = { 204 | onSearchQueryChange("") 205 | }) { 206 | Icon( 207 | imageVector = Icons.Default.Close, 208 | tint = MaterialTheme.colorScheme.onSurface, 209 | contentDescription = "clear_search" 210 | ) 211 | } 212 | } 213 | }, 214 | content = { 215 | if (filteredCountryList.isEmpty()) { 216 | EmptyState(message = "") 217 | } else { 218 | LazyColumn { 219 | items(filteredCountryList) { country -> 220 | CountryRow( 221 | country = country, 222 | selectedCountry = selectedCountry, 223 | countrySelected = countrySelected 224 | ) 225 | } 226 | } 227 | } 228 | }, 229 | active = true, 230 | onActiveChange = {}, 231 | colors = SearchBarDefaults.colors(containerColor = MaterialTheme.colorScheme.background), 232 | ) 233 | } 234 | 235 | @Composable 236 | fun EmptyState( 237 | title: String? = null, 238 | message: String? = null 239 | ) { 240 | Column( 241 | modifier = Modifier.fillMaxSize(), 242 | horizontalAlignment = Alignment.CenterHorizontally, 243 | verticalArrangement = Arrangement.Center 244 | ) { 245 | Text(title ?: "No Countries Found!", style = MaterialTheme.typography.titleMedium) 246 | message?.let { 247 | Text(message, style = MaterialTheme.typography.bodyLarge) 248 | } 249 | } 250 | } 251 | 252 | 253 | @Composable 254 | fun CountryRow( 255 | country: Country, 256 | selectedCountry: Country?, 257 | countrySelected: (country: Country) -> Unit 258 | ) { 259 | Column { 260 | Row( 261 | modifier = Modifier 262 | .fillMaxWidth() 263 | .clickable(onClick = { countrySelected(country) }) 264 | .padding(horizontal = 16.dp, vertical = 12.dp), 265 | verticalAlignment = Alignment.CenterVertically 266 | ) { 267 | val imageVector = FlagKit.getFlag(countryCode = country.alpha2) 268 | imageVector?.let { 269 | Image( 270 | imageVector = imageVector, 271 | contentDescription = country.name, 272 | ) 273 | } 274 | 275 | Spacer(modifier = Modifier.width(12.dp)) 276 | 277 | // Title and subtitle 278 | Column(Modifier.weight(1f)) { 279 | Text( 280 | text = country.name, 281 | style = if (country.name == selectedCountry?.name) MaterialTheme.typography.titleLarge else MaterialTheme.typography.bodyLarge 282 | ) 283 | Text( 284 | text = "${country.continent} • ${country.alpha2} / ${country.alpha3}", 285 | style = MaterialTheme.typography.bodySmall, 286 | color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) 287 | ) 288 | } 289 | } 290 | HorizontalDivider() 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /composeApp/src/commonMain/kotlin/dev/johnoreilly/climatetrace/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package dev.johnoreilly.climatetrace.ui.theme 2 | 3 | import androidx.compose.foundation.isSystemInDarkTheme 4 | import androidx.compose.material3.MaterialTheme 5 | import androidx.compose.material3.lightColorScheme 6 | import androidx.compose.material3.darkColorScheme 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.Immutable 9 | import androidx.compose.ui.graphics.Color 10 | 11 | 12 | private val lightScheme = lightColorScheme( 13 | primary = primaryLight, 14 | onPrimary = onPrimaryLight, 15 | primaryContainer = primaryContainerLight, 16 | onPrimaryContainer = onPrimaryContainerLight, 17 | secondary = secondaryLight, 18 | onSecondary = onSecondaryLight, 19 | secondaryContainer = secondaryContainerLight, 20 | onSecondaryContainer = onSecondaryContainerLight, 21 | tertiary = tertiaryLight, 22 | onTertiary = onTertiaryLight, 23 | tertiaryContainer = tertiaryContainerLight, 24 | onTertiaryContainer = onTertiaryContainerLight, 25 | error = errorLight, 26 | onError = onErrorLight, 27 | errorContainer = errorContainerLight, 28 | onErrorContainer = onErrorContainerLight, 29 | background = backgroundLight, 30 | onBackground = onBackgroundLight, 31 | surface = surfaceLight, 32 | onSurface = onSurfaceLight, 33 | surfaceVariant = surfaceVariantLight, 34 | onSurfaceVariant = onSurfaceVariantLight, 35 | outline = outlineLight, 36 | outlineVariant = outlineVariantLight, 37 | scrim = scrimLight, 38 | inverseSurface = inverseSurfaceLight, 39 | inverseOnSurface = inverseOnSurfaceLight, 40 | inversePrimary = inversePrimaryLight, 41 | surfaceDim = surfaceDimLight, 42 | surfaceBright = surfaceBrightLight, 43 | surfaceContainerLowest = surfaceContainerLowestLight, 44 | surfaceContainerLow = surfaceContainerLowLight, 45 | surfaceContainer = surfaceContainerLight, 46 | surfaceContainerHigh = surfaceContainerHighLight, 47 | surfaceContainerHighest = surfaceContainerHighestLight, 48 | ) 49 | 50 | private val darkScheme = darkColorScheme( 51 | primary = primaryDark, 52 | onPrimary = onPrimaryDark, 53 | primaryContainer = primaryContainerDark, 54 | onPrimaryContainer = onPrimaryContainerDark, 55 | secondary = secondaryDark, 56 | onSecondary = onSecondaryDark, 57 | secondaryContainer = secondaryContainerDark, 58 | onSecondaryContainer = onSecondaryContainerDark, 59 | tertiary = tertiaryDark, 60 | onTertiary = onTertiaryDark, 61 | tertiaryContainer = tertiaryContainerDark, 62 | onTertiaryContainer = onTertiaryContainerDark, 63 | error = errorDark, 64 | onError = onErrorDark, 65 | errorContainer = errorContainerDark, 66 | onErrorContainer = onErrorContainerDark, 67 | background = backgroundDark, 68 | onBackground = onBackgroundDark, 69 | surface = surfaceDark, 70 | onSurface = onSurfaceDark, 71 | surfaceVariant = surfaceVariantDark, 72 | onSurfaceVariant = onSurfaceVariantDark, 73 | outline = outlineDark, 74 | outlineVariant = outlineVariantDark, 75 | scrim = scrimDark, 76 | inverseSurface = inverseSurfaceDark, 77 | inverseOnSurface = inverseOnSurfaceDark, 78 | inversePrimary = inversePrimaryDark, 79 | surfaceDim = surfaceDimDark, 80 | surfaceBright = surfaceBrightDark, 81 | surfaceContainerLowest = surfaceContainerLowestDark, 82 | surfaceContainerLow = surfaceContainerLowDark, 83 | surfaceContainer = surfaceContainerDark, 84 | surfaceContainerHigh = surfaceContainerHighDark, 85 | surfaceContainerHighest = surfaceContainerHighestDark, 86 | ) 87 | 88 | private val mediumContrastLightColorScheme = lightColorScheme( 89 | primary = primaryLightMediumContrast, 90 | onPrimary = onPrimaryLightMediumContrast, 91 | primaryContainer = primaryContainerLightMediumContrast, 92 | onPrimaryContainer = onPrimaryContainerLightMediumContrast, 93 | secondary = secondaryLightMediumContrast, 94 | onSecondary = onSecondaryLightMediumContrast, 95 | secondaryContainer = secondaryContainerLightMediumContrast, 96 | onSecondaryContainer = onSecondaryContainerLightMediumContrast, 97 | tertiary = tertiaryLightMediumContrast, 98 | onTertiary = onTertiaryLightMediumContrast, 99 | tertiaryContainer = tertiaryContainerLightMediumContrast, 100 | onTertiaryContainer = onTertiaryContainerLightMediumContrast, 101 | error = errorLightMediumContrast, 102 | onError = onErrorLightMediumContrast, 103 | errorContainer = errorContainerLightMediumContrast, 104 | onErrorContainer = onErrorContainerLightMediumContrast, 105 | background = backgroundLightMediumContrast, 106 | onBackground = onBackgroundLightMediumContrast, 107 | surface = surfaceLightMediumContrast, 108 | onSurface = onSurfaceLightMediumContrast, 109 | surfaceVariant = surfaceVariantLightMediumContrast, 110 | onSurfaceVariant = onSurfaceVariantLightMediumContrast, 111 | outline = outlineLightMediumContrast, 112 | outlineVariant = outlineVariantLightMediumContrast, 113 | scrim = scrimLightMediumContrast, 114 | inverseSurface = inverseSurfaceLightMediumContrast, 115 | inverseOnSurface = inverseOnSurfaceLightMediumContrast, 116 | inversePrimary = inversePrimaryLightMediumContrast, 117 | surfaceDim = surfaceDimLightMediumContrast, 118 | surfaceBright = surfaceBrightLightMediumContrast, 119 | surfaceContainerLowest = surfaceContainerLowestLightMediumContrast, 120 | surfaceContainerLow = surfaceContainerLowLightMediumContrast, 121 | surfaceContainer = surfaceContainerLightMediumContrast, 122 | surfaceContainerHigh = surfaceContainerHighLightMediumContrast, 123 | surfaceContainerHighest = surfaceContainerHighestLightMediumContrast, 124 | ) 125 | 126 | private val highContrastLightColorScheme = lightColorScheme( 127 | primary = primaryLightHighContrast, 128 | onPrimary = onPrimaryLightHighContrast, 129 | primaryContainer = primaryContainerLightHighContrast, 130 | onPrimaryContainer = onPrimaryContainerLightHighContrast, 131 | secondary = secondaryLightHighContrast, 132 | onSecondary = onSecondaryLightHighContrast, 133 | secondaryContainer = secondaryContainerLightHighContrast, 134 | onSecondaryContainer = onSecondaryContainerLightHighContrast, 135 | tertiary = tertiaryLightHighContrast, 136 | onTertiary = onTertiaryLightHighContrast, 137 | tertiaryContainer = tertiaryContainerLightHighContrast, 138 | onTertiaryContainer = onTertiaryContainerLightHighContrast, 139 | error = errorLightHighContrast, 140 | onError = onErrorLightHighContrast, 141 | errorContainer = errorContainerLightHighContrast, 142 | onErrorContainer = onErrorContainerLightHighContrast, 143 | background = backgroundLightHighContrast, 144 | onBackground = onBackgroundLightHighContrast, 145 | surface = surfaceLightHighContrast, 146 | onSurface = onSurfaceLightHighContrast, 147 | surfaceVariant = surfaceVariantLightHighContrast, 148 | onSurfaceVariant = onSurfaceVariantLightHighContrast, 149 | outline = outlineLightHighContrast, 150 | outlineVariant = outlineVariantLightHighContrast, 151 | scrim = scrimLightHighContrast, 152 | inverseSurface = inverseSurfaceLightHighContrast, 153 | inverseOnSurface = inverseOnSurfaceLightHighContrast, 154 | inversePrimary = inversePrimaryLightHighContrast, 155 | surfaceDim = surfaceDimLightHighContrast, 156 | surfaceBright = surfaceBrightLightHighContrast, 157 | surfaceContainerLowest = surfaceContainerLowestLightHighContrast, 158 | surfaceContainerLow = surfaceContainerLowLightHighContrast, 159 | surfaceContainer = surfaceContainerLightHighContrast, 160 | surfaceContainerHigh = surfaceContainerHighLightHighContrast, 161 | surfaceContainerHighest = surfaceContainerHighestLightHighContrast, 162 | ) 163 | 164 | private val mediumContrastDarkColorScheme = darkColorScheme( 165 | primary = primaryDarkMediumContrast, 166 | onPrimary = onPrimaryDarkMediumContrast, 167 | primaryContainer = primaryContainerDarkMediumContrast, 168 | onPrimaryContainer = onPrimaryContainerDarkMediumContrast, 169 | secondary = secondaryDarkMediumContrast, 170 | onSecondary = onSecondaryDarkMediumContrast, 171 | secondaryContainer = secondaryContainerDarkMediumContrast, 172 | onSecondaryContainer = onSecondaryContainerDarkMediumContrast, 173 | tertiary = tertiaryDarkMediumContrast, 174 | onTertiary = onTertiaryDarkMediumContrast, 175 | tertiaryContainer = tertiaryContainerDarkMediumContrast, 176 | onTertiaryContainer = onTertiaryContainerDarkMediumContrast, 177 | error = errorDarkMediumContrast, 178 | onError = onErrorDarkMediumContrast, 179 | errorContainer = errorContainerDarkMediumContrast, 180 | onErrorContainer = onErrorContainerDarkMediumContrast, 181 | background = backgroundDarkMediumContrast, 182 | onBackground = onBackgroundDarkMediumContrast, 183 | surface = surfaceDarkMediumContrast, 184 | onSurface = onSurfaceDarkMediumContrast, 185 | surfaceVariant = surfaceVariantDarkMediumContrast, 186 | onSurfaceVariant = onSurfaceVariantDarkMediumContrast, 187 | outline = outlineDarkMediumContrast, 188 | outlineVariant = outlineVariantDarkMediumContrast, 189 | scrim = scrimDarkMediumContrast, 190 | inverseSurface = inverseSurfaceDarkMediumContrast, 191 | inverseOnSurface = inverseOnSurfaceDarkMediumContrast, 192 | inversePrimary = inversePrimaryDarkMediumContrast, 193 | surfaceDim = surfaceDimDarkMediumContrast, 194 | surfaceBright = surfaceBrightDarkMediumContrast, 195 | surfaceContainerLowest = surfaceContainerLowestDarkMediumContrast, 196 | surfaceContainerLow = surfaceContainerLowDarkMediumContrast, 197 | surfaceContainer = surfaceContainerDarkMediumContrast, 198 | surfaceContainerHigh = surfaceContainerHighDarkMediumContrast, 199 | surfaceContainerHighest = surfaceContainerHighestDarkMediumContrast, 200 | ) 201 | 202 | private val highContrastDarkColorScheme = darkColorScheme( 203 | primary = primaryDarkHighContrast, 204 | onPrimary = onPrimaryDarkHighContrast, 205 | primaryContainer = primaryContainerDarkHighContrast, 206 | onPrimaryContainer = onPrimaryContainerDarkHighContrast, 207 | secondary = secondaryDarkHighContrast, 208 | onSecondary = onSecondaryDarkHighContrast, 209 | secondaryContainer = secondaryContainerDarkHighContrast, 210 | onSecondaryContainer = onSecondaryContainerDarkHighContrast, 211 | tertiary = tertiaryDarkHighContrast, 212 | onTertiary = onTertiaryDarkHighContrast, 213 | tertiaryContainer = tertiaryContainerDarkHighContrast, 214 | onTertiaryContainer = onTertiaryContainerDarkHighContrast, 215 | error = errorDarkHighContrast, 216 | onError = onErrorDarkHighContrast, 217 | errorContainer = errorContainerDarkHighContrast, 218 | onErrorContainer = onErrorContainerDarkHighContrast, 219 | background = backgroundDarkHighContrast, 220 | onBackground = onBackgroundDarkHighContrast, 221 | surface = surfaceDarkHighContrast, 222 | onSurface = onSurfaceDarkHighContrast, 223 | surfaceVariant = surfaceVariantDarkHighContrast, 224 | onSurfaceVariant = onSurfaceVariantDarkHighContrast, 225 | outline = outlineDarkHighContrast, 226 | outlineVariant = outlineVariantDarkHighContrast, 227 | scrim = scrimDarkHighContrast, 228 | inverseSurface = inverseSurfaceDarkHighContrast, 229 | inverseOnSurface = inverseOnSurfaceDarkHighContrast, 230 | inversePrimary = inversePrimaryDarkHighContrast, 231 | surfaceDim = surfaceDimDarkHighContrast, 232 | surfaceBright = surfaceBrightDarkHighContrast, 233 | surfaceContainerLowest = surfaceContainerLowestDarkHighContrast, 234 | surfaceContainerLow = surfaceContainerLowDarkHighContrast, 235 | surfaceContainer = surfaceContainerDarkHighContrast, 236 | surfaceContainerHigh = surfaceContainerHighDarkHighContrast, 237 | surfaceContainerHighest = surfaceContainerHighestDarkHighContrast, 238 | ) 239 | 240 | @Immutable 241 | data class ColorFamily( 242 | val color: Color, 243 | val onColor: Color, 244 | val colorContainer: Color, 245 | val onColorContainer: Color 246 | ) 247 | 248 | val unspecified_scheme = ColorFamily( 249 | Color.Unspecified, Color.Unspecified, Color.Unspecified, Color.Unspecified 250 | ) 251 | 252 | @Composable 253 | fun ClimateTraceTheme( 254 | useDarkTheme: Boolean = isSystemInDarkTheme(), 255 | content: @Composable () -> Unit 256 | ) { 257 | val colors = if (useDarkTheme) darkScheme else lightScheme 258 | MaterialTheme( 259 | colorScheme = colors, 260 | content = content 261 | ) 262 | } --------------------------------------------------------------------------------