├── .github └── workflows │ ├── android.yml │ ├── ios.yml │ ├── wearos.yml │ └── web.yml ├── .gitignore ├── LICENSE ├── PeopleInSpaceSwiftUI ├── PeopleInSpaceSwiftUI.xcodeproj │ ├── project.pbxproj │ └── project.xcworkspace │ │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── PeopleInSpaceSwiftUI │ ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json │ ├── ContentView.swift │ ├── ISSPositionScreen.swift │ ├── Info.plist │ ├── NativeISSMapView.swift │ ├── NativeViewFactory.swift │ ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json │ └── iOSApp.swift ├── README.md ├── SwiftExecutablePackage ├── .gitignore ├── Package.resolved ├── Package.swift └── Sources │ └── main.swift ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── surrus │ │ └── peopleinspace │ │ ├── PeopleInSpaceRepositoryFake.kt │ │ └── PeopleInSpaceTest.kt │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── com │ │ └── surrus │ │ └── peopleinspace │ │ ├── MainActivity.kt │ │ ├── PeopleInSpaceApplication.kt │ │ ├── di │ │ └── AppModule.kt │ │ ├── glance │ │ ├── ISSMapWidget.kt │ │ ├── ISSMapWidgetReceiver.kt │ │ ├── PeopleInSpaceWidget.kt │ │ ├── PeopleInSpaceWidgetReceiver.kt │ │ └── util │ │ │ ├── BaseGlanceAppWidget.kt │ │ │ └── BaseGlanceAppWidgetReceiver.kt │ │ ├── issposition │ │ └── ISSPositionScreen.kt │ │ ├── persondetails │ │ └── PersonDetailsScreen.kt │ │ ├── personlist │ │ └── PersonListScreen.kt │ │ └── ui │ │ ├── Color.kt │ │ ├── PeopleInSpaceApp.kt │ │ ├── PersonData.kt │ │ ├── Theme.kt │ │ └── Type.kt │ └── res │ ├── drawable-v24 │ └── ic_launcher_foreground.xml │ ├── drawable │ ├── ic_american_astronaut.xml │ ├── ic_iss.xml │ └── ic_launcher_background.xml │ ├── mipmap-anydpi-v26 │ ├── ic_launcher.xml │ └── ic_launcher_round.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 │ ├── values-night │ └── themes.xml │ ├── values-v23 │ └── themes.xml │ ├── values-v27 │ └── themes.xml │ ├── values │ ├── colors.xml │ ├── strings.xml │ └── themes.xml │ └── xml │ ├── iss_widget_info.xml │ └── widget_info.xml ├── backend ├── .gitignore ├── build.gradle.kts └── src │ └── jvmMain │ ├── appengine │ ├── .gcloudignore │ └── app.yaml │ └── kotlin │ ├── PeopleData.kt │ └── Server.kt ├── build.gradle.kts ├── common ├── .gitignore ├── build.gradle.kts ├── common.podspec ├── consumer-rules.pro ├── proguard-rules.pro └── src │ ├── androidMain │ ├── AndroidManifest.xml │ └── kotlin │ │ └── com │ │ └── surrus │ │ └── common │ │ ├── repository │ │ └── actual.kt │ │ └── ui │ │ └── ISSMapView.android.kt │ ├── androidUnitTest │ └── kotlin │ │ └── dev │ │ └── johnoreilly │ │ └── peopleinspace │ │ └── TestKoinGraph.kt │ ├── commonMain │ ├── composeResources │ │ └── values │ │ │ └── strings.xml │ ├── kotlin │ │ └── com │ │ │ └── surrus │ │ │ └── common │ │ │ ├── di │ │ │ ├── Koin.kt │ │ │ └── PeopleInSpaceDatabaseWrapper.kt │ │ │ ├── remote │ │ │ └── PeopleInSpaceApi.kt │ │ │ ├── repository │ │ │ ├── Expect.kt │ │ │ └── PeopleInSpaceRepository.kt │ │ │ ├── ui │ │ │ ├── ISSMapView.kt │ │ │ └── ISSPositionContent.kt │ │ │ └── viewmodel │ │ │ ├── ISSPositionViewModel.kt │ │ │ └── PersonListViewModel.kt │ └── sqldelight │ │ └── com │ │ └── surrus │ │ └── peopleinspace │ │ └── db │ │ ├── 1.sqm │ │ └── PeopleInSpace.sq │ ├── commonTest │ └── kotlin │ │ └── com │ │ └── surrus │ │ └── peopleinspace │ │ └── PeopleInSpaceTest.kt │ ├── iOSMain │ └── kotlin │ │ └── com │ │ └── surrus │ │ └── common │ │ ├── repository │ │ └── actual.kt │ │ └── ui │ │ ├── ISSMapView.ios.kt │ │ ├── NativeViewFactory.kt │ │ └── SharedViewControllers.kt │ ├── jvmMain │ └── kotlin │ │ └── com │ │ └── surrus │ │ ├── Main.kt │ │ └── common │ │ ├── repository │ │ └── actual.kt │ │ └── ui │ │ └── ISSMapView.jvm.kt │ └── wasmJsMain │ └── kotlin │ └── com │ └── surrus │ └── common │ ├── repository │ └── actual.kt │ └── ui │ └── ISSMapView.wasmJs.kt ├── compose-desktop ├── .gitignore ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── main.kt ├── compose-web ├── build.gradle.kts ├── src │ └── wasmJsMain │ │ ├── kotlin │ │ └── Main.kt │ │ └── resources │ │ ├── index.html │ │ └── sqljs.worker.js └── webpack.config.d │ ├── config.js │ └── sqljs-config.js ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── graphql-server ├── .gitignore ├── build.gradle.kts └── src │ └── jvmMain │ ├── appengine │ └── app.yaml │ ├── kotlin │ └── com │ │ └── surrus │ │ └── peopleinspace │ │ ├── DefaultApplication.kt │ │ ├── IssPositionSubscription.kt │ │ ├── graph.kt │ │ └── main.kt │ └── resources │ └── application.yml ├── mcp-server ├── build.gradle.kts └── src │ └── main │ └── kotlin │ ├── main.kt │ └── server.kt ├── renovate.json ├── settings.gradle.kts └── wearApp ├── .gitignore ├── benchmark-rules.pro ├── build.gradle.kts ├── proguard-rules.pro └── src ├── androidTest └── java │ └── com │ └── surrus │ └── peopleinspace │ └── wear │ └── PeopleInSpaceTest.kt └── main ├── AndroidManifest.xml ├── baseline-prof.txt ├── java └── com │ └── surrus │ └── peopleinspace │ ├── MainActivity.kt │ ├── PeopleInSpaceApp.kt │ ├── PeopleInSpaceApplication.kt │ ├── di │ └── AppModule.kt │ ├── list │ ├── PersonListScreen.kt │ └── PersonListViewModel.kt │ ├── map │ ├── IssMap.kt │ └── MapViewModel.kt │ ├── person │ ├── PersonDetailsScreen.kt │ └── PersonDetailsViewModel.kt │ └── tile │ ├── PeopleInSpaceTile.kt │ └── util │ └── BaseGlanceTileService.kt └── res ├── drawable ├── ic_american_astronaut.xml └── ic_iss.xml ├── mipmap-hdpi └── ic_launcher.webp ├── mipmap-mdpi └── ic_launcher.webp ├── mipmap-xhdpi └── ic_launcher.webp ├── mipmap-xxhdpi └── ic_launcher.webp ├── mipmap-xxxhdpi └── ic_launcher.webp ├── values-round └── strings.xml └── values └── strings.xml /.github/workflows/android.yml: -------------------------------------------------------------------------------- 1 | name: Android CI 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-22.04 9 | 10 | steps: 11 | - uses: actions/checkout@v3 12 | - name: set up JDK 17 13 | uses: actions/setup-java@v3 14 | with: 15 | distribution: 'zulu' 16 | java-version: 17 17 | 18 | - name: kotlinUpgradeYarnLock 19 | run: ./gradlew kotlinUpgradeYarnLock 20 | 21 | 22 | - name: Build android app 23 | run: ./gradlew :app:assembleDebug 24 | 25 | - name: Run Unit Tests 26 | run: ./gradlew :app:test 27 | 28 | 29 | androidTest: 30 | runs-on: macos-13 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v3 34 | 35 | - name: Set up JDK 17 36 | uses: actions/setup-java@v3 37 | with: 38 | distribution: 'zulu' 39 | java-version: 17 40 | 41 | - name: Android Instrumentation Tests 42 | uses: reactivecircus/android-emulator-runner@v2 43 | with: 44 | api-level: 26 45 | arch: x86 46 | disable-animations: true 47 | script: ./gradlew app:connectedAndroidTest -------------------------------------------------------------------------------- /.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-14 14 | steps: 15 | - uses: actions/checkout@v3 16 | - uses: actions/setup-java@v3 17 | with: 18 | distribution: 'zulu' 19 | java-version: 17 20 | 21 | - name: Build iOS app 22 | run: xcodebuild -workspace PeopleInSpaceSwiftUI/PeopleInSpaceSwiftUI.xcodeproj/project.xcworkspace -configuration Debug -scheme PeopleInSpaceSwiftUI -sdk iphonesimulator 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /.github/workflows/wearos.yml: -------------------------------------------------------------------------------- 1 | name: Wear OS CI 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | 7 | wearOSTest: 8 | runs-on: macos-13 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v3 12 | 13 | - name: Set up JDK 17 14 | uses: actions/setup-java@v3 15 | with: 16 | distribution: 'zulu' 17 | java-version: 17 18 | 19 | # - name: Wear Instrumentation Tests 20 | # uses: reactivecircus/android-emulator-runner@v2 21 | # with: 22 | # api-level: 26 23 | # target: android-wear 24 | # script: ./gradlew wearApp:connectedAndroidTest 25 | 26 | -------------------------------------------------------------------------------- /.github/workflows/web.yml: -------------------------------------------------------------------------------- 1 | name: Web CI 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-22.04 9 | 10 | steps: 11 | - uses: actions/checkout@v3 12 | - name: set up JDK 17 13 | uses: actions/setup-java@v3 14 | with: 15 | distribution: 'zulu' 16 | java-version: 17 17 | 18 | - name: kotlinUpgradeYarnLock 19 | run: ./gradlew kotlinUpgradeYarnLock 20 | 21 | 22 | - name: Build web app 23 | run: ./gradlew :compose-web:assemble 24 | 25 | # If main branch update, deploy to gh-pages 26 | # - name: Deploy 27 | # if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' 28 | # uses: JamesIves/github-pages-deploy-action@v4.5.0 29 | # with: 30 | # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | # BRANCH: gh-pages # The branch the action should deploy to. 32 | # FOLDER: web/build/distributions # The folder the action should deploy. 33 | # CLEAN: true # Automatically remove deleted files from the deploy branch 34 | 35 | -------------------------------------------------------------------------------- /PeopleInSpaceSwiftUI/PeopleInSpaceSwiftUI.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /PeopleInSpaceSwiftUI/PeopleInSpaceSwiftUI/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 | } -------------------------------------------------------------------------------- /PeopleInSpaceSwiftUI/PeopleInSpaceSwiftUI/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } -------------------------------------------------------------------------------- /PeopleInSpaceSwiftUI/PeopleInSpaceSwiftUI/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } -------------------------------------------------------------------------------- /PeopleInSpaceSwiftUI/PeopleInSpaceSwiftUI/ContentView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import common 3 | 4 | 5 | struct ContentView: View { 6 | var body: some View { 7 | TabView { 8 | PeopleListScreen() 9 | .tabItem { 10 | Label("People", systemImage: "person") 11 | } 12 | ISSPositionScreen() 13 | .tabItem { 14 | Label("ISS Position", systemImage: "location") 15 | } 16 | } 17 | } 18 | } 19 | 20 | struct PeopleListScreen: View { 21 | @State var viewModel = PersonListViewModel() 22 | 23 | @State private var path: [Assignment] = [] 24 | 25 | var body: some View { 26 | NavigationStack(path: $path) { 27 | VStack { 28 | Observing(viewModel.uiState) { playerListUIState in 29 | switch onEnum(of: playerListUIState) { 30 | case .loading: 31 | ProgressView() 32 | .progressViewStyle(CircularProgressViewStyle()) 33 | case .error(let error): 34 | Text("Error: \(error)") 35 | case .success(let success): 36 | List(success.result, id: \.name) { person in 37 | NavigationLink(value: person) { 38 | PersonView(person: person) 39 | } 40 | } 41 | } 42 | } 43 | } 44 | .navigationDestination(for: Assignment.self) { person in 45 | PersonDetailsScreen(person: person) 46 | } 47 | .navigationBarTitle(Text("People In Space")) 48 | .navigationBarTitleDisplayMode(.inline) 49 | } 50 | } 51 | } 52 | 53 | struct PersonView: View { 54 | let person: Assignment 55 | 56 | var body: some View { 57 | HStack { 58 | AsyncImage(url: URL(string: person.personImageUrl ?? "")) { image in 59 | image.resizable() 60 | .aspectRatio(contentMode: .fit) 61 | } placeholder: { 62 | ProgressView() 63 | } 64 | .frame(width: 64, height: 64) 65 | 66 | 67 | VStack(alignment: .leading) { 68 | Text(person.name).font(.headline) 69 | Text(person.craft).font(.subheadline) 70 | } 71 | } 72 | } 73 | } 74 | 75 | 76 | struct PersonDetailsScreen: View { 77 | let person: Assignment 78 | 79 | var body: some View { 80 | ScrollView { 81 | VStack(alignment: .center, spacing: 32) { 82 | Text(person.name).font(.title) 83 | 84 | AsyncImage(url: URL(string: person.personImageUrl ?? "")) { image in 85 | image.resizable() 86 | .aspectRatio(contentMode: .fit) 87 | } placeholder: { 88 | ProgressView() 89 | } 90 | .frame(width: 240, height: 240) 91 | 92 | Text(person.personBio ?? "").font(.body) 93 | Spacer() 94 | } 95 | .padding() 96 | } 97 | } 98 | } 99 | 100 | struct ContentView_Previews: PreviewProvider { 101 | static var previews: some View { 102 | ContentView() 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /PeopleInSpaceSwiftUI/PeopleInSpaceSwiftUI/ISSPositionScreen.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | import MapKit 4 | import common 5 | 6 | 7 | struct ISSPositionScreen: View { 8 | @State var viewModel = ISSPositionViewModel() 9 | 10 | var body: some View { 11 | NavigationView { 12 | VStack { 13 | ISSPositionContentViewController(viewModel: viewModel) 14 | } 15 | .navigationBarTitle(Text("ISS Position")) 16 | .navigationBarTitleDisplayMode(.inline) 17 | } 18 | } 19 | } 20 | 21 | struct ISSPositionContentViewController: UIViewControllerRepresentable { 22 | let viewModel: ISSPositionViewModel 23 | 24 | func makeUIViewController(context: Context) -> UIViewController { 25 | SharedViewControllersKt.ISSPositionContentViewController( 26 | viewModel: viewModel, 27 | nativeViewFactory: iOSNativeViewFactory.shared 28 | ) 29 | } 30 | 31 | func updateUIViewController(_ uiViewController: UIViewController, context: Context) { 32 | } 33 | } 34 | 35 | -------------------------------------------------------------------------------- /PeopleInSpaceSwiftUI/PeopleInSpaceSwiftUI/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CADisableMinimumFrameDurationOnPhone 6 | CFBundleDevelopmentRegion 7 | $(DEVELOPMENT_LANGUAGE) 8 | CFBundleExecutable 9 | $(EXECUTABLE_NAME) 10 | CFBundleIdentifier 11 | $(PRODUCT_BUNDLE_IDENTIFIER) 12 | CFBundleInfoDictionaryVersion 13 | 6.0 14 | CFBundleName 15 | $(PRODUCT_NAME) 16 | CFBundlePackageType 17 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 18 | CFBundleShortVersionString 19 | 1.0 20 | CFBundleVersion 21 | 1 22 | LSRequiresIPhoneOS 23 | 24 | UIApplicationSceneManifest 25 | 26 | UIApplicationSupportsMultipleScenes 27 | 28 | 29 | UIRequiredDeviceCapabilities 30 | 31 | armv7 32 | 33 | UISupportedInterfaceOrientations 34 | 35 | UIInterfaceOrientationPortrait 36 | UIInterfaceOrientationLandscapeLeft 37 | UIInterfaceOrientationLandscapeRight 38 | 39 | UISupportedInterfaceOrientations~ipad 40 | 41 | UIInterfaceOrientationPortrait 42 | UIInterfaceOrientationPortraitUpsideDown 43 | UIInterfaceOrientationLandscapeLeft 44 | UIInterfaceOrientationLandscapeRight 45 | 46 | UILaunchScreen 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /PeopleInSpaceSwiftUI/PeopleInSpaceSwiftUI/NativeISSMapView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import common 3 | import MapKit 4 | 5 | struct NativeISSMapView : View { 6 | var viewModel: ISSPositionViewModel 7 | 8 | var body: some View { 9 | VStack { 10 | Observing(viewModel.position) { issPosition in 11 | let issCoordinatePosition = CLLocationCoordinate2D(latitude: issPosition.latitude, longitude: issPosition.longitude) 12 | let regionBinding = Binding( 13 | get: { 14 | MKCoordinateRegion(center: issCoordinatePosition, span: MKCoordinateSpan(latitudeDelta: 150, longitudeDelta: 150)) 15 | }, 16 | set: { _ in } 17 | ) 18 | 19 | Map(coordinateRegion: regionBinding, showsUserLocation: true, 20 | annotationItems: [ Location(coordinate: issCoordinatePosition) ]) { (location) -> MapPin in 21 | MapPin(coordinate: location.coordinate) 22 | } 23 | } 24 | } 25 | } 26 | } 27 | 28 | struct Location: Identifiable { 29 | let id = UUID() 30 | let coordinate: CLLocationCoordinate2D 31 | } 32 | -------------------------------------------------------------------------------- /PeopleInSpaceSwiftUI/PeopleInSpaceSwiftUI/NativeViewFactory.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | import UIKit 4 | import common 5 | 6 | class iOSNativeViewFactory : NativeViewFactory { 7 | static var shared = iOSNativeViewFactory() 8 | 9 | func createISSMapView(viewModel: ISSPositionViewModel) -> UIViewController { 10 | let mapView = NativeISSMapView(viewModel: viewModel) 11 | return UIHostingController(rootView: mapView) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /PeopleInSpaceSwiftUI/PeopleInSpaceSwiftUI/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } -------------------------------------------------------------------------------- /PeopleInSpaceSwiftUI/PeopleInSpaceSwiftUI/iOSApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import common 3 | 4 | @main 5 | struct iOSApp: App { 6 | init() { 7 | KoinKt.doInitKoin() 8 | } 9 | 10 | var body: some Scene { 11 | WindowGroup { 12 | ContentView() 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PeopleInSpace 2 | 3 | ![kotlin-version](https://img.shields.io/badge/kotlin-2.1.0-blue?logo=kotlin) 4 | 5 | 6 | **Kotlin Multiplatform** project with SwiftUI, Jetpack Compose, Compose for Wear OS, Compose for Desktop and Compose for Web clients along with Ktor backend. Currently running on 7 | * Android (Jetpack Compose) 8 | * Android App Widget (Compose based Glance API - contributed by https://github.com/yschimke) 9 | * Wear OS (Compose for Wear OS - primarily developed by https://github.com/yschimke) 10 | * iOS (SwiftUI) 11 | * Swift Executable Package 12 | * Desktop (Compose for Desktop) 13 | * Web (Compose for Web - Wasm based) 14 | * JVM (small Ktor back end service + `Main.kt` in `common` module) 15 | * MCP server (using same shared KMP code) 16 | 17 | It makes use of [Open Notify PeopleInSpace API](http://open-notify.org/Open-Notify-API/People-In-Space/) to show list of people currently in 18 | space and also the position of the International Space Station (inspired by https://kousenit.org/2019/12/19/a-few-astronomical-examples-in-kotlin/)! 19 | 20 | The project is included as sample in the official [Kotlin Multiplatform Mobile docs](https://kotlinlang.org/docs/mobile/samples.html#peopleinspace) and also the [Google Dev Library](https://devlibrary.withgoogle.com/products/android) 21 | 22 | Related posts: 23 | * [Minimal Kotlin Multiplatform project using Compose and SwiftUI](https://johnoreilly.dev/posts/minimal-kotlin-platform-compose-swiftui/) 24 | * [Adding some Storage (to) Space](https://johnoreilly.dev/posts/adding-sqldelight-to-peopleinspace/) 25 | * [Kotlin Multiplatform running on macOS](https://johnoreilly.dev/posts/kotlinmultiplatform-macos/) 26 | * [PeopleInSpace hits the web with Kotlin/JS and React](https://johnoreilly.dev/posts/peopleinspace-kotlinjs/) 27 | * [Using Koin in a Kotlin Multiplatform Project](https://johnoreilly.dev/posts/kotlinmultiplatform-koin/) 28 | * [Jetpack Compose for the Desktop!](https://johnoreilly.dev/posts/jetpack-compose-desktop-copy/) 29 | * [Comparing use of LiveData and StateFlow in a Jetpack Compose project](https://johnoreilly.dev/posts/jetpack-compose-stateflow-livedata/) 30 | * [Wrapping Kotlin Flow with Swift Combine Publisher in a Kotlin Multiplatform project](https://johnoreilly.dev/posts/kotlinmultiplatform-swift-combine_publisher-flow/) 31 | * [Using Swift Packages in a Kotlin Multiplatform project](https://johnoreilly.dev/posts/kotlinmultiplatform-swift-package/) 32 | * [Using Swift's new async/await when invoking Kotlin Multiplatform code](https://johnoreilly.dev/posts/swift_async_await_kotlin_coroutines/) 33 | * [Exploring new AWS SDK for Kotlin](https://johnoreilly.dev/posts/aws-sdk-kotlin/) 34 | * [Creating a Swift command line app that consumes Kotlin Multiplatform code](https://johnoreilly.dev/posts/swift-command-line-kotlin-multiplatform/) 35 | * [Exploring New Worlds of UI sharing possibilities in PeopleInSpace using Compose Multiplatform](https://johnoreilly.dev/posts/exploring-compose_multiplatform_sharing_ios/) 36 | 37 | 38 | Note that this repository very much errs on the side of minimalism to help more clearly illustrate key moving parts of a Kotlin 39 | Multiplatform project and also to hopefully help someone just starting to explore KMP to get up and running for first time (and is of course 40 | primarily focused on use of Jetpack Compose and SwiftUI). If you're at the stage of moving 41 | beyond this then I'd definitely recommend checking out [KaMPKit](https://github.com/touchlab/KaMPKit) from Touchlab. 42 | I also have the following samples that demonstrate the use of a variety of Kotlin Multiplatform libraries (and also use Jetpack Compose and SwiftUI). 43 | 44 | * GalwayBus (https://github.com/joreilly/GalwayBus) 45 | * Confetti (https://github.com/joreilly/Confetti) 46 | * BikeShare (https://github.com/joreilly/BikeShare) 47 | * FantasyPremierLeague (https://github.com/joreilly/FantasyPremierLeague) 48 | * ClimateTrace (https://github.com/joreilly/ClimateTraceKMP) 49 | * GeminiKMP (https://github.com/joreilly/GeminiKMP) 50 | * MortyComposeKMM (https://github.com/joreilly/MortyComposeKMM) 51 | * StarWars (https://github.com/joreilly/StarWars) 52 | * WordMasterKMP (https://github.com/joreilly/WordMasterKMP) 53 | * Chip-8 (https://github.com/joreilly/chip-8) 54 | 55 | 56 | ### Building 57 | You need to use at least Android Studio Flamingo (**note: Java 17 is now the minimum version required**). Requires Xcode 13.2 or later (due to use of new Swift 5.5 concurrency APIs). 58 | 59 | Open `PeopleInSpaceSwiftUI' for iOS projects. 60 | 61 | To exercise (React based) web client run `./gradlew :web:browserDevelopmentRun`. 62 | 63 | To run backend you can either run `./gradlew :backend:run` or run `Server.kt` directly from Android Studio. After doing that you should then for example be able to open `http://localhost:9090/astros_local.json` in a browser. 64 | 65 | 66 | 67 | ### Compose for Web client (Wasm) 68 | 69 | Similarly for Kotlin/Wasm based version 70 | `./gradlew :compose-web:wasmBrowserDevelopmentRun 71 | ` 72 | 73 | ### Compose for Desktop client 74 | 75 | This client is available in `compose-desktop` module and can be run using `./gradlew :compose-desktop:run`. Note that you 76 | need to use appropriate version of JVM when running (works for example with Java 11) 77 | 78 | ### Compose for iOS client 79 | 80 | Can be run using for example `./gradlew :compose-ios:iosDeployIPhone13ProDebug` 81 | 82 | ### Backend code 83 | 84 | Have tested this out in Google App Engine deployment. Using shadowJar plugin to create an "uber" jar and then deploying it as shown below. Should be possible to deploy this jar to other services as well. 85 | 86 | ``` 87 | ./gradlew :backend:shadowJar 88 | gcloud app deploy backend/build/libs/backend-all.jar --appyaml=backend/src/jvmMain/appengine/app.yaml 89 | ``` 90 | 91 | ### GraphQL backend 92 | 93 | There's a GraphQL module (`graphql-server`) which can be run locally using `./gradlew :graphql-server:bootRun` with "playground" then available at http://localhost:8080/playground 94 | 95 | 96 | 97 | ### Screenshots 98 | 99 | **iOS (SwiftUI)** 100 |
101 | Screenshot 2021-02-27 at 12 09 02 102 | 103 | **Android (Jetpack Compose)** 104 |
105 | 106 | Screenshot 2022-11-11 at 21 24 59 107 | 108 | 109 | **Wear OS (Wear Compose)** 110 |
111 | Wear Compose Screenshot 1 112 | Wear Compose Screenshot 2 113 | Wear Compose Screenshot 3 114 | 115 | 116 | **Compose for Desktop** 117 |
118 | Screenshot 2021-10-01 at 16 45 06 119 | 120 | 121 | **Compose for Web (Wasm based)** 122 |
123 | Screenshot 2024-03-02 at 21 03 23 124 | 125 | 126 | **MCP** 127 | 128 | The `mcp-server` module uses the [Kotlin MCP SDK](https://github.com/modelcontextprotocol/kotlin-sdk) to expose an MCP tools endpoint (returning list of people in space) that 129 | can for example be plugged in to Claude Desktop as shown below. That module uses same KMP shared code (that uses for example Ktor, SQLDelight and Koin) 130 | 131 | ![Gry_C3FXkAAxVvN](https://github.com/user-attachments/assets/74c210b9-9a0a-4de8-8845-81380f11e4a5) 132 | 133 | 134 | To integrate the MCP server with Claude Desktop you need to firstly run gradle `shadowJar` task and then select "Edit Config" under Developer Settings and add something 135 | like the following (update with your path) 136 | 137 | ``` 138 | { 139 | "mcpServers": { 140 | "kotlin-peopleinspace": { 141 | "command": "java", 142 | "args": [ 143 | "-jar", 144 | "/Users/john.oreilly/github/PeopleInSpace/mcp-server/build/libs/serverAll.jar", 145 | "--stdio" 146 | ] 147 | } 148 | } 149 | } 150 | ``` 151 | 152 | 153 | ### Languages, libraries and tools used 154 | 155 | * [Kotlin](https://kotlinlang.org/) 156 | * [Kotlin Coroutines](https://kotlinlang.org/docs/reference/coroutines-overview.html) 157 | * [Kotlinx Serialization](https://github.com/Kotlin/kotlinx.serialization) 158 | * [Ktor client library](https://github.com/ktorio/ktor) 159 | * [Android Architecture Components](https://developer.android.com/topic/libraries/architecture/index.html) 160 | * [Koin](https://github.com/InsertKoinIO/koin) 161 | * [SQLDelight](https://github.com/cashapp/sqldelight) 162 | * [Jetpack Compose](https://developer.android.com/jetpack/compose) 163 | * [SwiftUI](https://developer.apple.com/documentation/swiftui) 164 | * [SKIE](https://skie.touchlab.co/intro) 165 | * [Coil](https://coil-kt.github.io/coil/) 166 | -------------------------------------------------------------------------------- /SwiftExecutablePackage/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /SwiftExecutablePackage/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "kmp-nativecoroutines", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/rickclephas/KMP-NativeCoroutines.git", 7 | "state" : { 8 | "revision" : "7f2c99fb4a93dcd5327fafd577aa3cd7fff47a30", 9 | "version" : "1.0.0-ALPHA-18" 10 | } 11 | }, 12 | { 13 | "identity" : "peopleinspacepackage", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/joreilly/PeopleInSpacePackage", 16 | "state" : { 17 | "branch" : "main", 18 | "revision" : "af7f4e6adbc5f9016ec4b035e64a9aa696d1df34" 19 | } 20 | }, 21 | { 22 | "identity" : "rxswift", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/ReactiveX/RxSwift.git", 25 | "state" : { 26 | "revision" : "9dcaa4b333db437b0fbfaf453fad29069044a8b4", 27 | "version" : "6.6.0" 28 | } 29 | } 30 | ], 31 | "version" : 2 32 | } 33 | -------------------------------------------------------------------------------- /SwiftExecutablePackage/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "PeopleInSpace", 8 | platforms: [.macOS(.v13)], 9 | dependencies: [ 10 | .package(url: "https://github.com/rickclephas/KMP-NativeCoroutines.git", exact: "1.0.0-ALPHA-18"), 11 | .package(url: "https://github.com/joreilly/PeopleInSpacePackage", branch: "main") 12 | ], 13 | targets: [ 14 | .executableTarget( 15 | name: "peopleinspace", 16 | dependencies: [ 17 | .product(name: "KMPNativeCoroutinesAsync", package: "KMP-NativeCoroutines"), 18 | .product(name: "PeopleInSpaceKit", package: "PeopleInSpacePackage") 19 | ] 20 | ) 21 | ] 22 | ) 23 | -------------------------------------------------------------------------------- /SwiftExecutablePackage/Sources/main.swift: -------------------------------------------------------------------------------- 1 | import KMPNativeCoroutinesAsync 2 | import PeopleInSpaceKit 3 | 4 | KoinKt.doInitKoin() 5 | let repository = PeopleInSpaceRepository() 6 | let people = try await asyncFunction(for: repository.fetchPeople()) 7 | people.forEach { person in 8 | print(person.name) 9 | } 10 | 11 | let issPositionStream = asyncSequence(for: repository.pollISSPosition()) 12 | for try await issPosition in issPositionStream { 13 | print(issPosition) 14 | } 15 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | *.iml 3 | -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.application") 3 | kotlin("android") 4 | id("kotlinx-serialization") 5 | id("com.github.ben-manes.versions") 6 | alias(libs.plugins.compose.compiler) 7 | } 8 | 9 | kotlin { 10 | jvmToolchain(17) 11 | } 12 | 13 | android { 14 | compileSdk = libs.versions.compileSdk.get().toInt() 15 | 16 | defaultConfig { 17 | applicationId = "com.surrus.peopleinspace" 18 | minSdk = libs.versions.minSdk.get().toInt() 19 | targetSdk = libs.versions.targetSdk.get().toInt() 20 | 21 | versionCode = 1 22 | versionName = "1.0" 23 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 24 | } 25 | 26 | buildFeatures { 27 | compose = true 28 | buildConfig = true 29 | } 30 | 31 | buildTypes { 32 | getByName("release") { 33 | isMinifyEnabled = true 34 | isShrinkResources = true 35 | proguardFiles( 36 | getDefaultProguardFile("proguard-android-optimize.txt"), 37 | "proguard-rules.pro" 38 | ) 39 | } 40 | } 41 | 42 | namespace = "com.surrus.peopleinspace" 43 | 44 | testOptions { 45 | managedDevices { 46 | devices { 47 | create("pixel5api32") { 48 | device = "Pixel 5" 49 | apiLevel = 32 50 | systemImageSource = "google" 51 | } 52 | } 53 | } 54 | } 55 | } 56 | 57 | dependencies { 58 | implementation(libs.osmdroidAndroid) 59 | 60 | implementation(libs.androidx.lifecycle.compose) 61 | implementation(libs.androidx.lifecycle.runtime.ktx) 62 | implementation(libs.androidx.lifecycle.viewmodel.ktx) 63 | 64 | implementation(libs.androidx.activity.compose) 65 | implementation(libs.splash.screen) 66 | 67 | implementation(platform(libs.androidx.compose.bom)) 68 | implementation(libs.androidx.compose.foundation) 69 | implementation(libs.androidx.compose.foundation.layout) 70 | implementation(libs.androidx.compose.material) 71 | implementation(libs.androidx.compose.runtime) 72 | implementation(libs.androidx.compose.ui) 73 | implementation(libs.androidx.compose.ui.tooling) 74 | implementation(libs.androidx.navigation.compose) 75 | implementation(libs.androidx.compose.material3) 76 | implementation(libs.androidx.compose.material3.adaptive) 77 | implementation(libs.androidx.compose.material3.adaptive.layout) 78 | implementation(libs.androidx.compose.material3.adaptive.navigation) 79 | implementation(libs.androidx.compose.material3.adaptive.navigation.suite) 80 | 81 | implementation(libs.coil3.compose) 82 | implementation(libs.coil3.network.ktor) 83 | 84 | implementation(libs.glance.appwidget) 85 | 86 | implementation(libs.koin.core) 87 | implementation(libs.koin.android) 88 | implementation(libs.koin.androidx.compose) 89 | 90 | // Compose testing dependencies 91 | androidTestImplementation(platform(libs.androidx.compose.bom)) 92 | androidTestImplementation(libs.androidx.compose.ui.test) 93 | androidTestImplementation(libs.androidx.compose.ui.test.junit) 94 | androidTestImplementation(libs.androidx.navigation.compose.testing) 95 | debugImplementation(libs.androidx.compose.ui.test.manifest) 96 | 97 | 98 | implementation(projects.common) 99 | } 100 | 101 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/surrus/peopleinspace/PeopleInSpaceRepositoryFake.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.peopleinspace 2 | 3 | import com.surrus.common.remote.Assignment 4 | import com.surrus.common.remote.IssPosition 5 | import com.surrus.common.repository.PeopleInSpaceRepositoryInterface 6 | import kotlinx.coroutines.flow.Flow 7 | import kotlinx.coroutines.flow.flowOf 8 | 9 | class PeopleInSpaceRepositoryFake: PeopleInSpaceRepositoryInterface { 10 | val peopleList = listOf(Assignment("Apollo 11", "Neil Armstrong"), 11 | Assignment("Apollo 11", "Buzz Aldrin")) 12 | 13 | val issPosition = IssPosition(53.2743394, -9.0514163) 14 | 15 | override fun fetchPeopleAsFlow(): Flow> { 16 | return flowOf(peopleList) 17 | } 18 | 19 | override fun pollISSPosition(): Flow { 20 | return flowOf(issPosition) 21 | } 22 | 23 | override suspend fun fetchPeople(): List { 24 | return emptyList() 25 | } 26 | 27 | override suspend fun fetchAndStorePeople() { 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/surrus/peopleinspace/PeopleInSpaceTest.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.peopleinspace 2 | 3 | import androidx.compose.ui.test.* 4 | import androidx.compose.ui.test.junit4.createComposeRule 5 | import com.surrus.common.viewmodel.PersonListUiState 6 | import com.surrus.peopleinspace.personlist.PersonListScreen 7 | import com.surrus.peopleinspace.personlist.PersonListTag 8 | import org.junit.Rule 9 | import org.junit.Test 10 | 11 | class PeopleInSpaceTest { 12 | @get:Rule 13 | val composeTestRule = createComposeRule() 14 | 15 | private val peopleInSpaceRepository = PeopleInSpaceRepositoryFake() 16 | 17 | @Test 18 | fun testPeopleListScreen() { 19 | composeTestRule.setContent { 20 | PersonListScreen(uiState = PersonListUiState.Success(peopleInSpaceRepository.peopleList), navigateToPerson = {}, onRefresh = {}) 21 | } 22 | 23 | val peopleList = peopleInSpaceRepository.peopleList 24 | val personListNode = composeTestRule.onNodeWithTag(PersonListTag) 25 | personListNode.assertIsDisplayed() 26 | .onChildren().assertCountEquals(peopleList.size) 27 | 28 | peopleList.forEachIndexed { index, person -> 29 | val rowNode = personListNode.onChildAt(index) 30 | rowNode.assertTextContains(person.name) 31 | rowNode.assertTextContains(person.craft) 32 | } 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 14 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 33 | 34 | 35 | 36 | 37 | 40 | 41 | 42 | 45 | 46 | 47 | 48 | 49 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /app/src/main/java/com/surrus/peopleinspace/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.peopleinspace 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import androidx.activity.enableEdgeToEdge 7 | import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen 8 | import com.surrus.peopleinspace.ui.PeopleInSpaceApp 9 | 10 | 11 | class MainActivity : ComponentActivity() { 12 | override fun onCreate(savedInstanceState: Bundle?) { 13 | installSplashScreen() 14 | super.onCreate(savedInstanceState) 15 | enableEdgeToEdge() 16 | 17 | setContent { 18 | PeopleInSpaceApp() 19 | } 20 | } 21 | } 22 | 23 | 24 | -------------------------------------------------------------------------------- /app/src/main/java/com/surrus/peopleinspace/PeopleInSpaceApplication.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.peopleinspace 2 | 3 | import android.app.Application 4 | import co.touchlab.kermit.Logger 5 | import com.surrus.common.di.initKoin 6 | import com.surrus.peopleinspace.di.appModule 7 | import org.koin.android.ext.koin.androidContext 8 | import org.koin.android.ext.koin.androidLogger 9 | import org.osmdroid.config.Configuration 10 | import java.io.File 11 | 12 | class PeopleInSpaceApplication : Application() { 13 | 14 | override fun onCreate() { 15 | super.onCreate() 16 | 17 | // needed for osmandroid 18 | Configuration.getInstance().userAgentValue = BuildConfig.APPLICATION_ID 19 | Configuration.getInstance().osmdroidTileCache = File(cacheDir, "osm").also { 20 | it.mkdir() 21 | } 22 | 23 | initKoin { 24 | androidLogger() 25 | androidContext(this@PeopleInSpaceApplication) 26 | modules(appModule) 27 | } 28 | 29 | Logger.d { "PeopleInSpaceApplication" } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/java/com/surrus/peopleinspace/di/AppModule.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.peopleinspace.di 2 | 3 | import com.surrus.common.viewmodel.ISSPositionViewModel 4 | import com.surrus.common.viewmodel.PersonListViewModel 5 | import org.koin.androidx.viewmodel.dsl.viewModelOf 6 | import org.koin.dsl.module 7 | 8 | val appModule = module { 9 | viewModelOf(::PersonListViewModel) 10 | viewModelOf(::ISSPositionViewModel) 11 | } 12 | -------------------------------------------------------------------------------- /app/src/main/java/com/surrus/peopleinspace/glance/ISSMapWidget.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.peopleinspace.glance 2 | 3 | import android.content.Context 4 | import android.graphics.Bitmap 5 | import androidx.compose.ui.graphics.Color 6 | import androidx.glance.GlanceId 7 | import androidx.glance.GlanceModifier 8 | import androidx.glance.Image 9 | import androidx.glance.ImageProvider 10 | import androidx.glance.action.actionStartActivity 11 | import androidx.glance.action.clickable 12 | import androidx.glance.appwidget.GlanceAppWidget 13 | import androidx.glance.appwidget.provideContent 14 | import androidx.glance.background 15 | import androidx.glance.layout.Box 16 | import androidx.glance.layout.fillMaxSize 17 | import com.surrus.common.remote.IssPosition 18 | import com.surrus.common.repository.PeopleInSpaceRepositoryInterface 19 | import com.surrus.peopleinspace.MainActivity 20 | import com.surrus.peopleinspace.R 21 | import kotlinx.coroutines.Dispatchers 22 | import kotlinx.coroutines.flow.first 23 | import kotlinx.coroutines.launch 24 | import kotlinx.coroutines.withContext 25 | import org.koin.core.component.KoinComponent 26 | import org.koin.core.component.inject 27 | import org.osmdroid.tileprovider.MapTileProviderBasic 28 | import org.osmdroid.tileprovider.tilesource.TileSourceFactory 29 | import org.osmdroid.util.GeoPoint 30 | import org.osmdroid.views.Projection 31 | import org.osmdroid.views.drawing.MapSnapshot 32 | import org.osmdroid.views.overlay.IconOverlay 33 | import kotlin.coroutines.resume 34 | import kotlin.coroutines.suspendCoroutine 35 | 36 | class ISSMapWidget: GlanceAppWidget(), KoinComponent { 37 | private val repository: PeopleInSpaceRepositoryInterface by inject() 38 | 39 | override suspend fun provideGlance(context: Context, id: GlanceId) { 40 | val issPosition: IssPosition = withContext(Dispatchers.Main) { 41 | repository.pollISSPosition().first() 42 | } 43 | 44 | val issPositionPoint = GeoPoint(issPosition.latitude, issPosition.longitude) 45 | println("ISS Position: $issPositionPoint") 46 | 47 | val stationMarker = IconOverlay( 48 | issPositionPoint, 49 | context.resources.getDrawable(R.drawable.ic_iss, context.theme) 50 | ) 51 | 52 | val source = TileSourceFactory.DEFAULT_TILE_SOURCE 53 | val projection = Projection(1.0, 480, 240, issPositionPoint, 0f, true, false, 0, 0) 54 | 55 | val bitmap = withContext(Dispatchers.Main) { 56 | suspendCoroutine { cont -> 57 | val mapSnapshot = MapSnapshot( 58 | { 59 | if (it.status == MapSnapshot.Status.CANVAS_OK) { 60 | val bitmap = Bitmap.createBitmap(it.bitmap) 61 | cont.resume(bitmap) 62 | } 63 | }, 64 | MapSnapshot.INCLUDE_FLAG_UPTODATE or MapSnapshot.INCLUDE_FLAG_SCALED, 65 | MapTileProviderBasic(context, source, null), 66 | listOf(stationMarker), 67 | projection 68 | ) 69 | 70 | launch(Dispatchers.IO) { 71 | mapSnapshot.run() 72 | } 73 | } 74 | } 75 | 76 | provideContent { 77 | Box( 78 | modifier = GlanceModifier.background(Color.DarkGray).fillMaxSize().clickable( 79 | actionStartActivity() 80 | ) 81 | ) { 82 | Image( 83 | modifier = GlanceModifier.fillMaxSize(), 84 | provider = ImageProvider(bitmap), 85 | contentDescription = "ISS Location" 86 | ) 87 | } 88 | } 89 | } 90 | } -------------------------------------------------------------------------------- /app/src/main/java/com/surrus/peopleinspace/glance/ISSMapWidgetReceiver.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.peopleinspace.glance 2 | 3 | import androidx.glance.appwidget.GlanceAppWidget 4 | import androidx.glance.appwidget.GlanceAppWidgetReceiver 5 | 6 | class ISSMapWidgetReceiver: GlanceAppWidgetReceiver() { 7 | override val glanceAppWidget: GlanceAppWidget = ISSMapWidget() 8 | } -------------------------------------------------------------------------------- /app/src/main/java/com/surrus/peopleinspace/glance/PeopleInSpaceWidget.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.peopleinspace.glance 2 | 3 | import android.content.Context 4 | import androidx.compose.ui.graphics.Color 5 | import androidx.compose.ui.unit.TextUnit 6 | import androidx.compose.ui.unit.TextUnitType 7 | import androidx.compose.ui.unit.dp 8 | import androidx.glance.GlanceId 9 | import androidx.glance.GlanceModifier 10 | import androidx.glance.action.actionStartActivity 11 | import androidx.glance.action.clickable 12 | import androidx.glance.appwidget.GlanceAppWidget 13 | import androidx.glance.appwidget.lazy.LazyColumn 14 | import androidx.glance.appwidget.provideContent 15 | import androidx.glance.background 16 | import androidx.glance.layout.Alignment 17 | import androidx.glance.layout.Column 18 | import androidx.glance.layout.Row 19 | import androidx.glance.layout.fillMaxSize 20 | import androidx.glance.layout.fillMaxWidth 21 | import androidx.glance.layout.padding 22 | import androidx.glance.text.FontWeight 23 | import androidx.glance.text.Text 24 | import androidx.glance.text.TextStyle 25 | import androidx.glance.unit.ColorProvider 26 | import com.surrus.common.remote.Assignment 27 | import com.surrus.common.repository.PeopleInSpaceRepositoryInterface 28 | import com.surrus.peopleinspace.MainActivity 29 | import kotlinx.coroutines.Dispatchers 30 | import kotlinx.coroutines.flow.first 31 | import kotlinx.coroutines.withContext 32 | import org.koin.core.component.KoinComponent 33 | import org.koin.core.component.inject 34 | 35 | class PeopleInSpaceWidget: GlanceAppWidget(), KoinComponent { 36 | private val repository: PeopleInSpaceRepositoryInterface by inject() 37 | 38 | override suspend fun provideGlance(context: Context, id: GlanceId) { 39 | 40 | val people: List = withContext(Dispatchers.IO) { 41 | repository.fetchPeopleAsFlow().first() 42 | } 43 | 44 | provideContent { 45 | Column( 46 | modifier = GlanceModifier.fillMaxSize().background(Color.Gray).padding(8.dp) 47 | .clickable( 48 | actionStartActivity() 49 | ) 50 | ) { 51 | LazyColumn { 52 | item { 53 | Row( 54 | GlanceModifier.fillMaxWidth(), 55 | horizontalAlignment = Alignment.Horizontal.CenterHorizontally 56 | ) { 57 | Text( 58 | modifier = GlanceModifier.padding(16.dp), 59 | text = "People in Space", 60 | style = TextStyle( 61 | color = ColorProvider(Color.White), 62 | fontSize = TextUnit(16f, TextUnitType.Sp), 63 | fontWeight = FontWeight.Bold 64 | ) 65 | ) 66 | } 67 | } 68 | items(people.size) { 69 | Row( 70 | GlanceModifier.fillMaxWidth(), 71 | horizontalAlignment = Alignment.Horizontal.CenterHorizontally 72 | ) { 73 | Text( 74 | text = people[it].name, 75 | style = TextStyle( 76 | color = ColorProvider(Color.White), 77 | fontSize = TextUnit(14f, TextUnitType.Sp) 78 | ) 79 | ) 80 | } 81 | } 82 | } 83 | } 84 | } 85 | } 86 | } -------------------------------------------------------------------------------- /app/src/main/java/com/surrus/peopleinspace/glance/PeopleInSpaceWidgetReceiver.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.peopleinspace.glance 2 | 3 | import androidx.glance.appwidget.GlanceAppWidget 4 | import androidx.glance.appwidget.GlanceAppWidgetReceiver 5 | 6 | class PeopleInSpaceWidgetReceiver: GlanceAppWidgetReceiver() { 7 | override val glanceAppWidget: GlanceAppWidget = PeopleInSpaceWidget() 8 | } -------------------------------------------------------------------------------- /app/src/main/java/com/surrus/peopleinspace/glance/util/BaseGlanceAppWidget.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.peopleinspace.glance.util 2 | 3 | import android.content.Context 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.getValue 6 | import androidx.compose.runtime.mutableStateOf 7 | import androidx.compose.runtime.setValue 8 | import androidx.compose.runtime.snapshotFlow 9 | import androidx.compose.ui.unit.DpSize 10 | import androidx.glance.GlanceId 11 | import androidx.glance.LocalGlanceId 12 | import androidx.glance.LocalSize 13 | import androidx.glance.appwidget.GlanceAppWidget 14 | import kotlinx.coroutines.MainScope 15 | import kotlinx.coroutines.flow.filterNotNull 16 | import kotlinx.coroutines.flow.firstOrNull 17 | import kotlinx.coroutines.launch 18 | import org.koin.core.component.KoinComponent 19 | import org.koin.core.component.inject 20 | 21 | //abstract class BaseGlanceAppWidget(initialData: T? = null) : GlanceAppWidget(), KoinComponent { 22 | // val context: Context by inject() 23 | // 24 | // var glanceId by mutableStateOf(null) 25 | // var size by mutableStateOf(null) 26 | // var data by mutableStateOf(initialData) 27 | // 28 | // private val coroutineScope = MainScope() 29 | // 30 | // abstract suspend fun loadData(): T 31 | // 32 | // fun initiateLoad() { 33 | // coroutineScope.launch { 34 | // data = loadData() 35 | // 36 | // val currentGlanceId = snapshotFlow { glanceId }.filterNotNull().firstOrNull() 37 | // 38 | // if (currentGlanceId != null) { 39 | // update(context, currentGlanceId) 40 | // } 41 | // } 42 | // } 43 | // 44 | // @Composable 45 | // override fun Content() { 46 | // glanceId = LocalGlanceId.current 47 | // size = LocalSize.current 48 | // 49 | // Content(data) 50 | // } 51 | // 52 | // @Composable 53 | // abstract fun Content(data: T?) 54 | //} -------------------------------------------------------------------------------- /app/src/main/java/com/surrus/peopleinspace/glance/util/BaseGlanceAppWidgetReceiver.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.peopleinspace.glance.util 2 | 3 | import androidx.glance.appwidget.GlanceAppWidget 4 | import androidx.glance.appwidget.GlanceAppWidgetReceiver 5 | import org.koin.core.component.KoinComponent 6 | 7 | //abstract class BaseGlanceAppWidgetReceiver> : GlanceAppWidgetReceiver(), 8 | // KoinComponent { 9 | // override val glanceAppWidget: GlanceAppWidget 10 | // get() { 11 | // return createWidget().apply { 12 | // this.initiateLoad() 13 | // } 14 | // } 15 | // 16 | // abstract fun createWidget(): T 17 | //} -------------------------------------------------------------------------------- /app/src/main/java/com/surrus/peopleinspace/issposition/ISSPositionScreen.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalMaterial3Api::class) 2 | 3 | package com.surrus.peopleinspace.issposition 4 | 5 | import com.surrus.common.ui.ISSPositionContent 6 | import androidx.compose.foundation.layout.* 7 | import androidx.compose.material3.* 8 | import androidx.compose.runtime.* 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.graphics.Color 11 | import androidx.compose.ui.res.stringResource 12 | import androidx.compose.ui.semantics.* 13 | import com.surrus.common.viewmodel.ISSPositionViewModel 14 | import com.surrus.peopleinspace.R 15 | import org.koin.androidx.compose.koinViewModel 16 | 17 | 18 | @Composable 19 | fun ISSPositionRoute(viewModel: ISSPositionViewModel = koinViewModel()) { 20 | Scaffold( 21 | topBar = { 22 | CenterAlignedTopAppBar( 23 | title = { Text(text = stringResource(id = R.string.iss_position)) }, 24 | colors = TopAppBarDefaults.centerAlignedTopAppBarColors( 25 | containerColor = Color.Transparent 26 | ), 27 | modifier = Modifier.semantics { contentDescription = "ISSPosition" } 28 | ) 29 | } 30 | ) { innerPadding -> 31 | Column(Modifier.padding(innerPadding)) { 32 | ISSPositionContent(viewModel) 33 | } 34 | } 35 | } 36 | 37 | -------------------------------------------------------------------------------- /app/src/main/java/com/surrus/peopleinspace/persondetails/PersonDetailsScreen.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalMaterial3Api::class) 2 | 3 | package com.surrus.peopleinspace.persondetails 4 | 5 | import android.util.Log 6 | import androidx.compose.foundation.background 7 | import androidx.compose.foundation.layout.* 8 | import androidx.compose.foundation.rememberScrollState 9 | import androidx.compose.foundation.shape.CircleShape 10 | import androidx.compose.foundation.verticalScroll 11 | import androidx.compose.material.icons.Icons 12 | import androidx.compose.material.icons.automirrored.filled.ArrowBack 13 | import androidx.compose.material3.CenterAlignedTopAppBar 14 | import androidx.compose.material3.ExperimentalMaterial3Api 15 | import androidx.compose.material3.Icon 16 | import androidx.compose.material3.IconButton 17 | import androidx.compose.material3.MaterialTheme 18 | import androidx.compose.material3.Scaffold 19 | import androidx.compose.material3.Text 20 | import androidx.compose.material3.TopAppBarDefaults 21 | import androidx.compose.runtime.Composable 22 | import androidx.compose.ui.Alignment 23 | import androidx.compose.ui.Modifier 24 | import androidx.compose.ui.draw.clip 25 | import androidx.compose.ui.graphics.Color 26 | import androidx.compose.ui.layout.ContentScale 27 | import androidx.compose.ui.unit.dp 28 | import coil3.compose.AsyncImage 29 | import com.surrus.common.remote.Assignment 30 | import com.surrus.peopleinspace.ui.PurpleGray50 31 | 32 | 33 | @Composable 34 | fun PersonDetailsScreen(person: Assignment, showBackButton: Boolean, popBack: () -> Unit) { 35 | Scaffold( 36 | topBar = { 37 | PersonDetailsTopAppBar(personName = person.name, showBackButton, popBack = popBack) 38 | }, 39 | containerColor = Color.Transparent, 40 | contentWindowInsets = WindowInsets(0, 0, 0, 0) 41 | ) { innerPadding -> 42 | PersonDetailsContent(person, innerPadding) 43 | } 44 | } 45 | 46 | @Composable 47 | fun PersonDetailsTopAppBar(personName: String, showBackButton: Boolean, popBack: () -> Unit) { 48 | CenterAlignedTopAppBar( 49 | title = { Text(personName) }, 50 | navigationIcon = { 51 | if (showBackButton) { 52 | IconButton(onClick = { popBack() }) { 53 | Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") 54 | } 55 | } 56 | }, 57 | colors = TopAppBarDefaults.centerAlignedTopAppBarColors( 58 | containerColor = Color.Transparent 59 | ) 60 | ) 61 | } 62 | 63 | @Composable 64 | fun PersonDetailsContent(person: Assignment, innerPadding: PaddingValues) { 65 | Column( 66 | modifier = Modifier 67 | .verticalScroll(rememberScrollState()) 68 | .padding(innerPadding) 69 | .fillMaxWidth(), 70 | horizontalAlignment = Alignment.CenterHorizontally 71 | ) { 72 | Spacer(modifier = Modifier.size(12.dp)) 73 | PersonImage(person.personImageUrl, person.name) 74 | Spacer(modifier = Modifier.size(24.dp)) 75 | PersonBio(person.personBio) 76 | } 77 | } 78 | 79 | @Composable 80 | fun PersonImage(imageUrl: String?, name: String) { 81 | imageUrl?.let { 82 | AsyncImage( 83 | model = imageUrl, 84 | contentDescription = name, 85 | contentScale = ContentScale.Fit, 86 | modifier = Modifier 87 | .size(240.dp) 88 | .clip(CircleShape) 89 | .background(color = PurpleGray50) 90 | ) 91 | } ?: run { 92 | Log.e("PersonImage", "Something went wrong!") 93 | } 94 | } 95 | 96 | @Composable 97 | fun PersonBio(bio: String?) { 98 | bio?.let { 99 | Text( 100 | bio, 101 | style = MaterialTheme.typography.bodyLarge, 102 | modifier = Modifier.padding(16.dp) 103 | ) 104 | } ?: run { 105 | Log.e("PersonBio", "Something went wrong!") 106 | } 107 | } -------------------------------------------------------------------------------- /app/src/main/java/com/surrus/peopleinspace/personlist/PersonListScreen.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class, 2 | ExperimentalMaterialApi::class 3 | ) 4 | 5 | package com.surrus.peopleinspace.personlist 6 | 7 | import androidx.compose.foundation.clickable 8 | import androidx.compose.foundation.layout.* 9 | import androidx.compose.foundation.lazy.LazyColumn 10 | import androidx.compose.foundation.lazy.items 11 | import androidx.compose.material.ExperimentalMaterialApi 12 | import androidx.compose.material.pullrefresh.PullRefreshIndicator 13 | import androidx.compose.material.pullrefresh.pullRefresh 14 | import androidx.compose.material.pullrefresh.rememberPullRefreshState 15 | import androidx.compose.material3.CenterAlignedTopAppBar 16 | import androidx.compose.material3.ExperimentalMaterial3Api 17 | import androidx.compose.material3.MaterialTheme 18 | import androidx.compose.material3.Scaffold 19 | import androidx.compose.material3.Text 20 | import androidx.compose.material3.TopAppBarDefaults 21 | import androidx.compose.runtime.Composable 22 | import androidx.compose.runtime.getValue 23 | import androidx.compose.runtime.mutableStateOf 24 | import androidx.compose.runtime.remember 25 | import androidx.compose.runtime.rememberCoroutineScope 26 | import androidx.compose.runtime.setValue 27 | import androidx.compose.ui.Alignment 28 | import androidx.compose.ui.Modifier 29 | import androidx.compose.ui.graphics.Color 30 | import androidx.compose.ui.layout.ContentScale 31 | import androidx.compose.ui.platform.testTag 32 | import androidx.compose.ui.res.stringResource 33 | import androidx.compose.ui.semantics.contentDescription 34 | import androidx.compose.ui.semantics.semantics 35 | import androidx.compose.ui.tooling.preview.Preview 36 | import androidx.compose.ui.tooling.preview.PreviewParameter 37 | import androidx.compose.ui.unit.dp 38 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 39 | import coil3.compose.AsyncImage 40 | import com.surrus.common.remote.Assignment 41 | import com.surrus.common.viewmodel.PersonListUiState 42 | import com.surrus.common.viewmodel.PersonListViewModel 43 | import com.surrus.peopleinspace.R 44 | import com.surrus.peopleinspace.ui.PersonProvider 45 | import kotlinx.coroutines.delay 46 | import kotlinx.coroutines.launch 47 | import org.koin.androidx.compose.koinViewModel 48 | 49 | const val PersonListTag = "PersonList" 50 | 51 | 52 | @Composable 53 | fun PersonListRoute( 54 | navigateToPerson: (Assignment) -> Unit, ) { 55 | val viewModel: PersonListViewModel = koinViewModel() 56 | val uiState by viewModel.uiState.collectAsStateWithLifecycle() 57 | 58 | PersonListScreen(uiState, navigateToPerson, onRefresh = { 59 | viewModel.refresh() 60 | }) 61 | 62 | } 63 | 64 | @Composable 65 | fun PersonListScreen( 66 | uiState: PersonListUiState, 67 | navigateToPerson: (Assignment) -> Unit, 68 | onRefresh: () -> Unit 69 | ) { 70 | val refreshScope = rememberCoroutineScope() 71 | var refreshing by remember { mutableStateOf(false) } 72 | 73 | fun refresh() = refreshScope.launch { 74 | refreshing = true 75 | delay(500) 76 | onRefresh() 77 | refreshing = false 78 | } 79 | 80 | val refreshState = rememberPullRefreshState(refreshing, ::refresh) 81 | 82 | Scaffold( 83 | topBar = { 84 | CenterAlignedTopAppBar( 85 | title = { Text(text = stringResource(id = R.string.people_in_space)) }, 86 | colors = TopAppBarDefaults.centerAlignedTopAppBarColors( 87 | containerColor = Color.Transparent 88 | ), 89 | modifier = Modifier.semantics { contentDescription = "PeopleInSpace" } 90 | ) 91 | }, 92 | containerColor = Color.Transparent, 93 | contentWindowInsets = WindowInsets(0, 0, 0, 0) 94 | ) { innerPadding -> 95 | 96 | when(uiState) { 97 | is PersonListUiState.Error -> {} 98 | is PersonListUiState.Loading -> {} 99 | is PersonListUiState.Success -> { 100 | Box(Modifier.pullRefresh(refreshState)) { 101 | LazyColumn( 102 | modifier = Modifier 103 | .testTag(PersonListTag) 104 | .padding(innerPadding) 105 | .consumeWindowInsets(innerPadding) 106 | .fillMaxSize() 107 | ) { 108 | if (!refreshing) { 109 | items(uiState.result) { person -> 110 | PersonView(person, navigateToPerson) 111 | } 112 | } 113 | } 114 | 115 | PullRefreshIndicator(refreshing, refreshState, Modifier.align(Alignment.TopCenter)) 116 | } 117 | 118 | } 119 | } 120 | } 121 | } 122 | 123 | @Composable 124 | fun PersonView(person: Assignment, personSelected: (person: Assignment) -> Unit) { 125 | 126 | Row( 127 | modifier = Modifier 128 | .fillMaxWidth() 129 | .clickable(onClick = { personSelected(person) }) 130 | .padding(16.dp), 131 | verticalAlignment = Alignment.CenterVertically 132 | ) { 133 | 134 | val personImageUrl = person.personImageUrl ?: "" 135 | if (personImageUrl.isNotEmpty()) { 136 | AsyncImage( 137 | model = person.personImageUrl, 138 | contentDescription = person.name, 139 | contentScale = ContentScale.Fit, 140 | modifier = Modifier.size(60.dp) 141 | ) 142 | } else { 143 | Spacer(modifier = Modifier.size(60.dp)) 144 | } 145 | 146 | Spacer(modifier = Modifier.size(12.dp)) 147 | 148 | Column { 149 | Text(text = person.name, style = MaterialTheme.typography.bodyLarge) 150 | Text(text = person.craft, style = MaterialTheme.typography.bodyMedium) 151 | } 152 | } 153 | } 154 | 155 | @Preview 156 | @Composable 157 | fun PersonViewPreview(@PreviewParameter(PersonProvider::class) person: Assignment) { 158 | MaterialTheme { 159 | PersonView(person, personSelected = {}) 160 | } 161 | } -------------------------------------------------------------------------------- /app/src/main/java/com/surrus/peopleinspace/ui/Color.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.peopleinspace.ui 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | internal val Blue10 = Color(0xFF001F29) 6 | internal val Blue20 = Color(0xFF003544) 7 | internal val Blue30 = Color(0xFF004D61) 8 | internal val Blue40 = Color(0xFF006781) 9 | internal val Blue80 = Color(0xFF5DD4FB) 10 | internal val Blue90 = Color(0xFFB5EAFF) 11 | internal val Blue95 = Color(0xFFDCF5FF) 12 | internal val DarkGreen10 = Color(0xFF0D1F12) 13 | internal val DarkGreen20 = Color(0xFF223526) 14 | internal val DarkGreen30 = Color(0xFF394B3C) 15 | internal val DarkGreen40 = Color(0xFF4F6352) 16 | internal val DarkGreen80 = Color(0xFFB7CCB8) 17 | internal val DarkGreen90 = Color(0xFFD3E8D3) 18 | internal val DarkGreenGray10 = Color(0xFF1A1C1A) 19 | internal val DarkGreenGray90 = Color(0xFFE2E3DE) 20 | internal val DarkGreenGray95 = Color(0xFFF0F1EC) 21 | internal val DarkGreenGray99 = Color(0xFFFBFDF7) 22 | internal val DarkPurpleGray10 = Color(0xFF201A1B) 23 | internal val DarkPurpleGray90 = Color(0xFFECDFE0) 24 | internal val DarkPurpleGray95 = Color(0xFFFAEEEF) 25 | internal val DarkPurpleGray99 = Color(0xFFFCFCFC) 26 | internal val Green10 = Color(0xFF00210B) 27 | internal val Green20 = Color(0xFF003919) 28 | internal val Green30 = Color(0xFF005227) 29 | internal val Green40 = Color(0xFF006D36) 30 | internal val Green80 = Color(0xFF0EE37C) 31 | internal val Green90 = Color(0xFF5AFF9D) 32 | internal val GreenGray30 = Color(0xFF414941) 33 | internal val GreenGray50 = Color(0xFF727971) 34 | internal val GreenGray60 = Color(0xFF8B938A) 35 | internal val GreenGray80 = Color(0xFFC1C9BF) 36 | internal val GreenGray90 = Color(0xFFDDE5DB) 37 | internal val Orange10 = Color(0xFF390C00) 38 | internal val Orange20 = Color(0xFF5D1900) 39 | internal val Orange30 = Color(0xFF812800) 40 | internal val Orange40 = Color(0xFFA23F16) 41 | internal val Orange80 = Color(0xFFFFB599) 42 | internal val Orange90 = Color(0xFFFFDBCE) 43 | internal val Orange95 = Color(0xFFFFEDE6) 44 | internal val Purple10 = Color(0xFF36003D) 45 | internal val Purple20 = Color(0xFF560A5E) 46 | internal val Purple30 = Color(0xFF702776) 47 | internal val Purple40 = Color(0xFF8C4190) 48 | internal val Purple80 = Color(0xFFFFA8FF) 49 | internal val Purple90 = Color(0xFFFFD5FC) 50 | internal val Purple95 = Color(0xFFFFEBFB) 51 | internal val PurpleGray30 = Color(0xFF4E444C) 52 | internal val PurpleGray50 = Color(0xFF7F747C) 53 | internal val PurpleGray60 = Color(0xFF998D96) 54 | internal val PurpleGray80 = Color(0xFFD0C2CC) 55 | internal val PurpleGray90 = Color(0xFFEDDEE8) 56 | internal val Red10 = Color(0xFF410001) 57 | internal val Red20 = Color(0xFF680003) 58 | internal val Red30 = Color(0xFF930006) 59 | internal val Red40 = Color(0xFFBA1B1B) 60 | internal val Red80 = Color(0xFFFFB4A9) 61 | internal val Red90 = Color(0xFFFFDAD4) 62 | internal val Teal10 = Color(0xFF001F26) 63 | internal val Teal20 = Color(0xFF02363F) 64 | internal val Teal30 = Color(0xFF214D56) 65 | internal val Teal40 = Color(0xFF3A656F) 66 | internal val Teal80 = Color(0xFFA2CED9) 67 | internal val Teal90 = Color(0xFFBEEAF6) 68 | -------------------------------------------------------------------------------- /app/src/main/java/com/surrus/peopleinspace/ui/PeopleInSpaceApp.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.peopleinspace.ui 2 | 3 | 4 | import androidx.activity.compose.BackHandler 5 | import androidx.annotation.StringRes 6 | import androidx.compose.material.icons.Icons 7 | import androidx.compose.material.icons.filled.LocationOn 8 | import androidx.compose.material.icons.filled.Person 9 | import androidx.compose.material3.Icon 10 | import androidx.compose.material3.Text 11 | import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi 12 | import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold 13 | import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole 14 | import androidx.compose.material3.adaptive.layout.PaneAdaptedValue 15 | import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator 16 | import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator 17 | import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold 18 | import androidx.compose.runtime.Composable 19 | import androidx.compose.runtime.getValue 20 | import androidx.compose.runtime.mutableStateOf 21 | import androidx.compose.runtime.saveable.rememberSaveable 22 | import androidx.compose.runtime.setValue 23 | import androidx.compose.ui.graphics.vector.ImageVector 24 | import androidx.compose.ui.res.stringResource 25 | import com.surrus.common.remote.Assignment 26 | import com.surrus.peopleinspace.R 27 | import com.surrus.peopleinspace.issposition.ISSPositionRoute 28 | import com.surrus.peopleinspace.persondetails.PersonDetailsScreen 29 | import com.surrus.peopleinspace.personlist.PersonListRoute 30 | 31 | enum class AppDestinations( 32 | @StringRes val label: Int, 33 | val icon: ImageVector, 34 | @StringRes val contentDescription: Int 35 | ) { 36 | PERSON_LIST(R.string.people, Icons.Default.Person, R.string.people), 37 | ISS_POSITION(R.string.iss_position, Icons.Default.LocationOn, R.string.iss_position), 38 | } 39 | 40 | @OptIn(ExperimentalMaterial3AdaptiveApi::class) 41 | @Composable 42 | fun PeopleInSpaceApp() { 43 | var currentDestination by rememberSaveable { mutableStateOf(AppDestinations.PERSON_LIST) } 44 | val navigator = rememberListDetailPaneScaffoldNavigator() 45 | 46 | BackHandler(navigator.canNavigateBack()) { 47 | navigator.navigateBack() 48 | } 49 | 50 | PeopleInSpaceTheme { 51 | NavigationSuiteScaffold( 52 | navigationSuiteItems = { 53 | AppDestinations.entries.forEach { 54 | item( 55 | icon = { 56 | Icon( 57 | it.icon, 58 | contentDescription = stringResource(it.contentDescription) 59 | ) 60 | }, 61 | label = { Text(stringResource(it.label)) }, 62 | selected = it == currentDestination, 63 | onClick = { currentDestination = it } 64 | ) 65 | } 66 | } 67 | ) { 68 | when (currentDestination) { 69 | AppDestinations.PERSON_LIST -> { 70 | ListDetailPaneScaffold( 71 | directive = navigator.scaffoldDirective, 72 | value = navigator.scaffoldValue, 73 | listPane = { 74 | PersonListRoute { person -> 75 | navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, person) 76 | } 77 | }, 78 | detailPane = { 79 | navigator.currentDestination?.content?.let { 80 | PersonDetailsScreen( 81 | person = it, 82 | showBackButton = !navigator.isListPaneVisible(), 83 | navigator::navigateBack 84 | ) 85 | } 86 | } 87 | ) 88 | } 89 | AppDestinations.ISS_POSITION -> { 90 | ISSPositionRoute() 91 | } 92 | } 93 | } 94 | } 95 | } 96 | 97 | 98 | @OptIn(ExperimentalMaterial3AdaptiveApi::class) 99 | private fun ThreePaneScaffoldNavigator.isListPaneVisible(): Boolean = 100 | scaffoldValue[ListDetailPaneScaffoldRole.List] == PaneAdaptedValue.Expanded 101 | 102 | 103 | -------------------------------------------------------------------------------- /app/src/main/java/com/surrus/peopleinspace/ui/PersonData.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.peopleinspace.ui 2 | 3 | import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider 4 | import com.surrus.common.remote.Assignment 5 | 6 | class PersonProvider : CollectionPreviewParameterProvider( 7 | listOf( 8 | Assignment("ISS", "Chris Cassidy"), 9 | Assignment("ISS", "Anatoli Ivanishin") 10 | ) 11 | ) 12 | -------------------------------------------------------------------------------- /app/src/main/java/com/surrus/peopleinspace/ui/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.peopleinspace.ui 2 | 3 | import android.os.Build 4 | import androidx.annotation.ChecksSdkIntAtLeast 5 | import androidx.annotation.VisibleForTesting 6 | import androidx.compose.foundation.isSystemInDarkTheme 7 | import androidx.compose.material3.MaterialTheme 8 | import androidx.compose.material3.darkColorScheme 9 | import androidx.compose.material3.dynamicDarkColorScheme 10 | import androidx.compose.material3.dynamicLightColorScheme 11 | import androidx.compose.material3.lightColorScheme 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.ui.graphics.Color 14 | import androidx.compose.ui.platform.LocalContext 15 | 16 | 17 | /** 18 | * Light default theme color scheme 19 | */ 20 | @VisibleForTesting 21 | val LightDefaultColorScheme = lightColorScheme( 22 | primary = Purple40, 23 | onPrimary = Color.White, 24 | primaryContainer = Purple90, 25 | onPrimaryContainer = Purple10, 26 | secondary = Orange40, 27 | onSecondary = Color.White, 28 | secondaryContainer = Orange90, 29 | onSecondaryContainer = Orange10, 30 | tertiary = Blue40, 31 | onTertiary = Color.White, 32 | tertiaryContainer = Blue90, 33 | onTertiaryContainer = Blue10, 34 | error = Red40, 35 | onError = Color.White, 36 | errorContainer = Red90, 37 | onErrorContainer = Red10, 38 | background = DarkPurpleGray99, 39 | onBackground = DarkPurpleGray10, 40 | surface = DarkPurpleGray99, 41 | onSurface = DarkPurpleGray10, 42 | surfaceVariant = PurpleGray90, 43 | onSurfaceVariant = PurpleGray30, 44 | outline = PurpleGray50 45 | ) 46 | 47 | 48 | /** 49 | * Dark default theme color scheme 50 | */ 51 | @VisibleForTesting 52 | val DarkDefaultColorScheme = darkColorScheme( 53 | primary = Purple80, 54 | onPrimary = Purple20, 55 | primaryContainer = Purple30, 56 | onPrimaryContainer = Purple90, 57 | secondary = Orange80, 58 | onSecondary = Orange20, 59 | secondaryContainer = Orange30, 60 | onSecondaryContainer = Orange90, 61 | tertiary = Blue80, 62 | onTertiary = Blue20, 63 | tertiaryContainer = Blue30, 64 | onTertiaryContainer = Blue90, 65 | error = Red80, 66 | onError = Red20, 67 | errorContainer = Red30, 68 | onErrorContainer = Red90, 69 | background = DarkPurpleGray10, 70 | onBackground = DarkPurpleGray90, 71 | surface = DarkPurpleGray10, 72 | onSurface = DarkPurpleGray90, 73 | surfaceVariant = PurpleGray30, 74 | onSurfaceVariant = PurpleGray80, 75 | outline = PurpleGray60 76 | ) 77 | 78 | /** 79 | * Light Android theme color scheme 80 | */ 81 | @VisibleForTesting 82 | val LightAndroidColorScheme = lightColorScheme( 83 | primary = Green40, 84 | onPrimary = Color.White, 85 | primaryContainer = Green90, 86 | onPrimaryContainer = Green10, 87 | secondary = DarkGreen40, 88 | onSecondary = Color.White, 89 | secondaryContainer = DarkGreen90, 90 | onSecondaryContainer = DarkGreen10, 91 | tertiary = Teal40, 92 | onTertiary = Color.White, 93 | tertiaryContainer = Teal90, 94 | onTertiaryContainer = Teal10, 95 | error = Red40, 96 | onError = Color.White, 97 | errorContainer = Red90, 98 | onErrorContainer = Red10, 99 | background = DarkGreenGray99, 100 | onBackground = DarkGreenGray10, 101 | surface = DarkGreenGray99, 102 | onSurface = DarkGreenGray10, 103 | surfaceVariant = GreenGray90, 104 | onSurfaceVariant = GreenGray30, 105 | outline = GreenGray50 106 | ) 107 | 108 | /** 109 | * Dark Android theme color scheme 110 | */ 111 | @VisibleForTesting 112 | val DarkAndroidColorScheme = darkColorScheme( 113 | primary = Green80, 114 | onPrimary = Green20, 115 | primaryContainer = Green30, 116 | onPrimaryContainer = Green90, 117 | secondary = DarkGreen80, 118 | onSecondary = DarkGreen20, 119 | secondaryContainer = DarkGreen30, 120 | onSecondaryContainer = DarkGreen90, 121 | tertiary = Teal80, 122 | onTertiary = Teal20, 123 | tertiaryContainer = Teal30, 124 | onTertiaryContainer = Teal90, 125 | error = Red80, 126 | onError = Red20, 127 | errorContainer = Red30, 128 | onErrorContainer = Red90, 129 | background = DarkGreenGray10, 130 | onBackground = DarkGreenGray90, 131 | surface = DarkGreenGray10, 132 | onSurface = DarkGreenGray90, 133 | surfaceVariant = GreenGray30, 134 | onSurfaceVariant = GreenGray80, 135 | outline = GreenGray60 136 | ) 137 | 138 | /** 139 | * PeopleInSpace theme. 140 | * 141 | * The order of precedence for the color scheme is: Dynamic color > Android theme > Default theme. 142 | * Dark theme is independent as all the aforementioned color schemes have light and dark versions. 143 | * The default theme color scheme is used by default. 144 | * 145 | * @param darkTheme Whether the theme should use a dark color scheme (follows system by default). 146 | * @param dynamicColor Whether the theme should use a dynamic color scheme (Android 12+ only). 147 | * @param androidTheme Whether the theme should use the Android theme color scheme. 148 | */ 149 | @Composable 150 | fun PeopleInSpaceTheme( 151 | darkTheme: Boolean = isSystemInDarkTheme(), 152 | androidTheme: Boolean = false, 153 | disableDynamicTheming: Boolean = false, 154 | content: @Composable() () -> Unit 155 | ) { 156 | val colorScheme = if (androidTheme) { 157 | if (darkTheme) DarkAndroidColorScheme else LightAndroidColorScheme 158 | } else if (!disableDynamicTheming && supportsDynamicTheming()) { 159 | val context = LocalContext.current 160 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) 161 | } else { 162 | if (darkTheme) DarkDefaultColorScheme else LightDefaultColorScheme 163 | } 164 | 165 | MaterialTheme( 166 | colorScheme = colorScheme, 167 | typography = PeopleInSpaceTypography, 168 | content = content 169 | ) 170 | } 171 | 172 | @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S) 173 | private fun supportsDynamicTheming() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S 174 | -------------------------------------------------------------------------------- /app/src/main/java/com/surrus/peopleinspace/ui/Type.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.peopleinspace.ui 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 | internal val PeopleInSpaceTypography = Typography( 10 | displayLarge = TextStyle( 11 | fontWeight = FontWeight.W400, 12 | fontSize = 57.sp, 13 | lineHeight = 64.sp, 14 | letterSpacing = (-0.25).sp 15 | ), 16 | displayMedium = TextStyle( 17 | fontWeight = FontWeight.W400, 18 | fontSize = 45.sp, 19 | lineHeight = 52.sp 20 | ), 21 | displaySmall = TextStyle( 22 | fontWeight = FontWeight.W400, 23 | fontSize = 36.sp, 24 | lineHeight = 44.sp 25 | ), 26 | headlineLarge = TextStyle( 27 | fontWeight = FontWeight.W400, 28 | fontSize = 32.sp, 29 | lineHeight = 40.sp 30 | ), 31 | headlineMedium = TextStyle( 32 | fontWeight = FontWeight.W400, 33 | fontSize = 28.sp, 34 | lineHeight = 36.sp 35 | ), 36 | headlineSmall = TextStyle( 37 | fontWeight = FontWeight.W400, 38 | fontSize = 24.sp, 39 | lineHeight = 32.sp 40 | ), 41 | titleLarge = TextStyle( 42 | fontWeight = FontWeight.W700, 43 | fontSize = 22.sp, 44 | lineHeight = 28.sp 45 | ), 46 | titleMedium = TextStyle( 47 | fontWeight = FontWeight.W700, 48 | fontSize = 16.sp, 49 | lineHeight = 24.sp, 50 | letterSpacing = 0.1.sp 51 | ), 52 | titleSmall = TextStyle( 53 | fontWeight = FontWeight.W500, 54 | fontSize = 14.sp, 55 | lineHeight = 20.sp, 56 | letterSpacing = 0.1.sp 57 | ), 58 | bodyLarge = TextStyle( 59 | fontWeight = FontWeight.W400, 60 | fontSize = 16.sp, 61 | lineHeight = 24.sp, 62 | letterSpacing = 0.5.sp 63 | ), 64 | bodyMedium = TextStyle( 65 | fontWeight = FontWeight.W400, 66 | fontSize = 14.sp, 67 | lineHeight = 20.sp, 68 | letterSpacing = 0.25.sp 69 | ), 70 | bodySmall = TextStyle( 71 | fontWeight = FontWeight.W400, 72 | fontSize = 12.sp, 73 | lineHeight = 16.sp, 74 | letterSpacing = 0.4.sp 75 | ), 76 | labelLarge = TextStyle( 77 | fontWeight = FontWeight.W400, 78 | fontSize = 14.sp, 79 | lineHeight = 20.sp, 80 | letterSpacing = 0.1.sp 81 | ), 82 | labelMedium = TextStyle( 83 | fontWeight = FontWeight.W400, 84 | fontSize = 12.sp, 85 | lineHeight = 16.sp, 86 | letterSpacing = 0.5.sp 87 | ), 88 | labelSmall = TextStyle( 89 | fontFamily = FontFamily.Monospace, 90 | fontWeight = FontWeight.W500, 91 | fontSize = 10.sp, 92 | lineHeight = 16.sp 93 | ) 94 | ) 95 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | 19 | 22 | 25 | 26 | 27 | 28 | 34 | 35 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_iss.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/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 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joreilly/PeopleInSpace/e50925ece6ba05c61e963f73a0fb6f2b57530677/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joreilly/PeopleInSpace/e50925ece6ba05c61e963f73a0fb6f2b57530677/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joreilly/PeopleInSpace/e50925ece6ba05c61e963f73a0fb6f2b57530677/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joreilly/PeopleInSpace/e50925ece6ba05c61e963f73a0fb6f2b57530677/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joreilly/PeopleInSpace/e50925ece6ba05c61e963f73a0fb6f2b57530677/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joreilly/PeopleInSpace/e50925ece6ba05c61e963f73a0fb6f2b57530677/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joreilly/PeopleInSpace/e50925ece6ba05c61e963f73a0fb6f2b57530677/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joreilly/PeopleInSpace/e50925ece6ba05c61e963f73a0fb6f2b57530677/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joreilly/PeopleInSpace/e50925ece6ba05c61e963f73a0fb6f2b57530677/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joreilly/PeopleInSpace/e50925ece6ba05c61e963f73a0fb6f2b57530677/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/values-v23/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/values-v27/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | #FFBB86FC 5 | #FF6200EE 6 | #FF3700B3 7 | #FF03DAC5 8 | #FF018786 9 | 10 | 11 | #4D000000 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | PeopleInSpace 3 | People In Space 4 | 5 | People 6 | ISS Position 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 10 | 11 | 13 | 16 | 17 | 18 | 24 | 25 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /app/src/main/res/xml/iss_widget_info.xml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/src/main/res/xml/widget_info.xml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /backend/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | kotlin("multiplatform") 3 | application 4 | kotlin("plugin.serialization") 5 | id("com.github.johnrengelman.shadow") 6 | } 7 | 8 | 9 | kotlin { 10 | jvm() { 11 | withJava() 12 | } 13 | 14 | sourceSets { 15 | jvmMain.dependencies { 16 | implementation(libs.kotlinx.coroutines) 17 | implementation(libs.kotlinx.serialization) 18 | 19 | implementation("io.ktor:ktor-server-core:2.3.12") 20 | implementation("io.ktor:ktor-server-netty:2.3.12") 21 | implementation("io.ktor:ktor-server-cors:2.3.12") 22 | implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.12") 23 | implementation("io.ktor:ktor-server-content-negotiation:2.3.12") 24 | 25 | implementation("ch.qos.logback:logback-classic:1.5.8") 26 | 27 | implementation(projects.common) 28 | } 29 | } 30 | } 31 | 32 | application { 33 | mainClass.set("ServerKt") 34 | } -------------------------------------------------------------------------------- /backend/src/jvmMain/appengine/.gcloudignore: -------------------------------------------------------------------------------- 1 | # This file specifies files that are *not* uploaded to Google Cloud 2 | # using gcloud. It follows the same syntax as .gitignore, with the addition of 3 | # "#!include" directives (which insert the entries of the given .gitignore-style 4 | # file at that point). 5 | # 6 | # For more information, run: 7 | # $ gcloud topic gcloudignore 8 | # 9 | .gcloudignore 10 | # If you would like to upload your .git directory, .gitignore file or files 11 | # from your .gitignore file, remove the corresponding line 12 | # below: 13 | .git 14 | .gitignore 15 | 16 | # Target directory for maven builds 17 | target/ -------------------------------------------------------------------------------- /backend/src/jvmMain/appengine/app.yaml: -------------------------------------------------------------------------------- 1 | service: default 2 | runtime: java21 3 | 4 | entrypoint: java -Xmx64m -jar backend-all.jar 5 | -------------------------------------------------------------------------------- /backend/src/jvmMain/kotlin/Server.kt: -------------------------------------------------------------------------------- 1 | import com.surrus.common.di.initKoin 2 | import com.surrus.common.remote.Assignment 3 | import com.surrus.common.remote.AstroResult 4 | import com.surrus.common.remote.PeopleInSpaceApi 5 | import io.ktor.http.* 6 | import io.ktor.server.application.* 7 | import io.ktor.serialization.kotlinx.json.* 8 | import io.ktor.server.engine.* 9 | import io.ktor.server.netty.* 10 | import io.ktor.server.plugins.contentnegotiation.* 11 | import io.ktor.server.plugins.cors.routing.CORS 12 | import io.ktor.server.response.* 13 | import io.ktor.server.routing.* 14 | 15 | fun main() { 16 | val koin = initKoin(enableNetworkLogs = true).koin 17 | val peopleInSpaceApi = koin.get() 18 | peopleInSpaceApi.baseUrl = "http://api.open-notify.org" 19 | 20 | val port = System.getenv().getOrDefault("PORT", "8080").toInt() 21 | embeddedServer(Netty, port) { 22 | install(ContentNegotiation) { 23 | json() 24 | } 25 | 26 | install(CORS) { 27 | allowMethod(HttpMethod.Options) 28 | allowMethod(HttpMethod.Put) 29 | allowMethod(HttpMethod.Delete) 30 | allowMethod(HttpMethod.Patch) 31 | allowHeader(HttpHeaders.Authorization) 32 | allowHeader(HttpHeaders.ContentType) 33 | allowHeader(HttpHeaders.AccessControlAllowOrigin) 34 | // header("any header") if you want to add any header 35 | allowCredentials = true 36 | allowNonSimpleContentTypes = true 37 | anyHost() 38 | } 39 | 40 | routing { 41 | 42 | get("/astros.json") { 43 | val ar = peopleInSpaceApi.fetchPeople() 44 | val result = AstroResult(ar.message, ar.number, ar.people.map { 45 | val personImageUrl = personImages[it.name] 46 | val personBio = personBios[it.name] 47 | Assignment(it.craft, it.name, personImageUrl, personBio) 48 | }) 49 | call.respond(result) 50 | } 51 | 52 | get("/iss-now.json") { 53 | val result = peopleInSpaceApi.fetchISSPosition() 54 | call.respond(result) 55 | } 56 | 57 | get("/astros_local.json") { 58 | val result = AstroResult( 59 | "success", 3, 60 | listOf( 61 | Assignment("ISS", "Chris Cassidy"), 62 | Assignment("ISS", "Anatoly Ivanishin"), 63 | Assignment("ISS", "Ivan Vagner") 64 | ) 65 | ) 66 | call.respond(result) 67 | } 68 | } 69 | }.start(wait = true) 70 | } 71 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.android.application) apply false 3 | alias(libs.plugins.ksp) apply false 4 | alias(libs.plugins.android.library) apply false 5 | alias(libs.plugins.kotlinMultiplatform) apply false 6 | alias(libs.plugins.kotlinx.serialization) apply false 7 | alias(libs.plugins.sqlDelight) apply false 8 | alias(libs.plugins.gradleVersionsPlugin) apply false 9 | alias(libs.plugins.shadowPlugin) apply false 10 | alias(libs.plugins.compose.compiler) apply false 11 | alias(libs.plugins.jetbrainsCompose) apply false 12 | } 13 | -------------------------------------------------------------------------------- /common/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | *.iml 3 | 4 | -------------------------------------------------------------------------------- /common/build.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalWasmDsl::class) 2 | 3 | import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl 4 | import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig 5 | 6 | plugins { 7 | alias(libs.plugins.kotlinMultiplatform) 8 | alias(libs.plugins.android.library) 9 | alias(libs.plugins.kotlinx.serialization) 10 | alias(libs.plugins.sqlDelight) 11 | alias(libs.plugins.ksp) 12 | alias(libs.plugins.jetbrainsCompose) 13 | alias(libs.plugins.compose.compiler) 14 | alias(libs.plugins.skie) 15 | id("io.github.luca992.multiplatform-swiftpackage") version "2.2.3" 16 | } 17 | 18 | android { 19 | compileSdk = libs.versions.compileSdk.get().toInt() 20 | sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") 21 | defaultConfig { 22 | minSdk = libs.versions.minSdk.get().toInt() 23 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 24 | } 25 | namespace = "com.surrus.common" 26 | } 27 | 28 | kotlin { 29 | jvmToolchain(17) 30 | 31 | listOf( 32 | iosX64(), 33 | iosArm64(), 34 | iosSimulatorArm64() 35 | ).forEach { 36 | it.binaries.framework { 37 | baseName = "common" 38 | } 39 | } 40 | 41 | androidTarget() 42 | jvm() 43 | 44 | wasmJs { 45 | moduleName = "peopleinspaceShared" 46 | browser { 47 | commonWebpackConfig { 48 | outputFileName = "peopleinspaceShared.js" 49 | } 50 | } 51 | } 52 | 53 | 54 | sourceSets { 55 | commonMain.dependencies { 56 | implementation(libs.bundles.ktor.common) 57 | implementation(libs.kotlinx.coroutines) 58 | api(libs.kotlinx.serialization) 59 | 60 | implementation(libs.sqldelight.runtime) 61 | implementation(libs.sqldelight.coroutines.extensions) 62 | 63 | api(libs.koin.core) 64 | implementation(libs.koin.compose.multiplatform) 65 | implementation(libs.koin.test) 66 | 67 | api(libs.kermit) 68 | 69 | implementation(compose.ui) 70 | implementation(compose.runtime) 71 | implementation(compose.foundation) 72 | implementation(compose.material3) 73 | implementation(compose.components.resources) 74 | implementation(libs.androidx.lifecycle.compose.kmp) 75 | implementation(libs.androidx.lifecycle.viewmodel.kmp) 76 | } 77 | 78 | commonTest.dependencies { 79 | implementation(libs.koin.test) 80 | implementation(libs.kotlinx.coroutines.test) 81 | implementation(kotlin("test")) 82 | } 83 | 84 | androidMain.dependencies { 85 | implementation(libs.ktor.client.android) 86 | implementation(libs.sqldelight.android.driver) 87 | 88 | implementation(libs.osmdroidAndroid) 89 | implementation(libs.osm.android.compose) 90 | } 91 | 92 | jvmMain.dependencies { 93 | implementation(libs.ktor.client.java) 94 | implementation(libs.sqldelight.sqlite.driver) 95 | implementation(libs.slf4j) 96 | implementation(libs.kotlinx.coroutines.swing) 97 | } 98 | 99 | appleMain.dependencies { 100 | implementation(libs.ktor.client.darwin) 101 | implementation(libs.sqldelight.native.driver) 102 | } 103 | 104 | wasmJsMain.dependencies { 105 | implementation(libs.sqldelight.web.driver) 106 | implementation(npm("@cashapp/sqldelight-sqljs-worker", "2.1.0")) 107 | implementation(npm("sql.js", libs.versions.sqlJs.get())) 108 | implementation(devNpm("copy-webpack-plugin", libs.versions.webPackPlugin.get())) 109 | } 110 | } 111 | } 112 | 113 | sqldelight { 114 | databases { 115 | create("PeopleInSpaceDatabase") { 116 | generateAsync = true 117 | packageName.set("com.surrus.peopleinspace.db") 118 | } 119 | } 120 | } 121 | 122 | multiplatformSwiftPackage { 123 | packageName("PeopleInSpaceKit") 124 | swiftToolsVersion("5.9") 125 | targetPlatforms { 126 | iOS { v("14") } 127 | } 128 | } 129 | 130 | kotlin.sourceSets.all { 131 | languageSettings.optIn("kotlinx.cinterop.ExperimentalForeignApi") 132 | languageSettings.optIn("kotlin.experimental.ExperimentalObjCName") 133 | } 134 | 135 | skie { 136 | features { 137 | enableSwiftUIObservingPreview = true 138 | } 139 | } -------------------------------------------------------------------------------- /common/common.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |spec| 2 | spec.name = 'common' 3 | spec.version = '1.0' 4 | spec.homepage = 'https://github.com/joreilly/PeopleInSpace' 5 | spec.source = { :http=> ''} 6 | spec.authors = '' 7 | spec.license = '' 8 | spec.summary = 'PeopleInSpace' 9 | spec.vendored_frameworks = 'build/cocoapods/framework/common.framework' 10 | spec.libraries = 'c++' 11 | 12 | 13 | 14 | spec.pod_target_xcconfig = { 15 | 'KOTLIN_PROJECT_PATH' => ':common', 16 | 'PRODUCT_MODULE_NAME' => 'common', 17 | } 18 | 19 | spec.script_phases = [ 20 | { 21 | :name => 'Build common', 22 | :execution_position => :before_compile, 23 | :shell_path => '/bin/sh', 24 | :script => <<-SCRIPT 25 | if [ "YES" = "$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED" ]; then 26 | echo "Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \"YES\"" 27 | exit 0 28 | fi 29 | set -ev 30 | REPO_ROOT="$PODS_TARGET_SRCROOT" 31 | "$REPO_ROOT/../gradlew" -p "$REPO_ROOT" $KOTLIN_PROJECT_PATH:syncFramework \ 32 | -Pkotlin.native.cocoapods.platform=$PLATFORM_NAME \ 33 | -Pkotlin.native.cocoapods.archs="$ARCHS" \ 34 | -Pkotlin.native.cocoapods.configuration="$CONFIGURATION" 35 | SCRIPT 36 | } 37 | ] 38 | 39 | end -------------------------------------------------------------------------------- /common/consumer-rules.pro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joreilly/PeopleInSpace/e50925ece6ba05c61e963f73a0fb6f2b57530677/common/consumer-rules.pro -------------------------------------------------------------------------------- /common/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /common/src/androidMain/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /common/src/androidMain/kotlin/com/surrus/common/repository/actual.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.common.repository 2 | 3 | import app.cash.sqldelight.async.coroutines.synchronous 4 | import app.cash.sqldelight.driver.android.AndroidSqliteDriver 5 | import com.surrus.common.di.PeopleInSpaceDatabaseWrapper 6 | import com.surrus.peopleinspace.db.PeopleInSpaceDatabase 7 | import io.ktor.client.engine.HttpClientEngine 8 | import io.ktor.client.engine.android.* 9 | import org.koin.dsl.module 10 | 11 | 12 | 13 | actual fun platformModule() = module { 14 | single { 15 | val driver = 16 | AndroidSqliteDriver(PeopleInSpaceDatabase.Schema.synchronous(), get(), "peopleinspace.db") 17 | 18 | PeopleInSpaceDatabaseWrapper(driver, PeopleInSpaceDatabase(driver)) 19 | } 20 | single { Android.create() } 21 | } 22 | -------------------------------------------------------------------------------- /common/src/androidMain/kotlin/com/surrus/common/ui/ISSMapView.android.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.common.ui 2 | 3 | import androidx.compose.foundation.layout.fillMaxSize 4 | import androidx.compose.material3.Surface 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.collectAsState 7 | import androidx.compose.runtime.derivedStateOf 8 | import androidx.compose.runtime.getValue 9 | import androidx.compose.runtime.remember 10 | import androidx.compose.ui.Modifier 11 | import com.surrus.common.viewmodel.ISSPositionViewModel 12 | import com.utsman.osmandcompose.CameraProperty 13 | import com.utsman.osmandcompose.CameraState 14 | import com.utsman.osmandcompose.DefaultMapProperties 15 | import com.utsman.osmandcompose.Marker 16 | import com.utsman.osmandcompose.MarkerState 17 | import com.utsman.osmandcompose.OpenStreetMap 18 | import org.osmdroid.util.GeoPoint 19 | 20 | 21 | @Composable 22 | actual fun ISSMapView(modifier: Modifier, viewModel: ISSPositionViewModel) { 23 | val position by viewModel.position.collectAsState() 24 | 25 | val cameraState by remember { 26 | derivedStateOf { 27 | CameraState(CameraProperty().apply { 28 | geoPoint = GeoPoint(position.latitude, position.longitude) 29 | zoom = 4.0 30 | }) 31 | } 32 | } 33 | 34 | val issPositionMarkerState by remember { 35 | derivedStateOf { 36 | val geoPoint = GeoPoint(position.latitude, position.longitude) 37 | MarkerState(geoPoint, 0.0f) 38 | } 39 | } 40 | 41 | Surface(modifier = modifier.fillMaxSize(),) { 42 | OpenStreetMap( 43 | cameraState = cameraState, 44 | properties = DefaultMapProperties.copy(minZoomLevel = 4.0), 45 | ) { 46 | Marker(state = issPositionMarkerState, title = "ISS") 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /common/src/androidUnitTest/kotlin/dev/johnoreilly/peopleinspace/TestKoinGraph.kt: -------------------------------------------------------------------------------- 1 | package dev.johnoreilly.peopleinspace 2 | 3 | import com.surrus.common.di.commonModule 4 | import com.surrus.common.repository.platformModule 5 | import com.surrus.peopleinspace.db.PeopleInSpaceDatabase 6 | import io.ktor.client.HttpClientConfig 7 | import io.ktor.client.engine.HttpClientEngine 8 | import org.koin.core.annotation.KoinExperimentalAPI 9 | import org.koin.dsl.module 10 | import org.koin.test.verify.verify 11 | import kotlin.test.Test 12 | 13 | 14 | @OptIn(KoinExperimentalAPI::class) 15 | class TestKoinGraph { 16 | 17 | @Test 18 | fun checkKoinModules() { 19 | val modules = module { 20 | includes(commonModule(false), platformModule()) 21 | } 22 | 23 | modules.verify( 24 | extraTypes = listOf(HttpClientEngine::class, HttpClientConfig::class, PeopleInSpaceDatabase::class) 25 | ) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /common/src/commonMain/composeResources/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | ISS Position 3 | 4 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/surrus/common/di/Koin.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.common.di 2 | 3 | import com.surrus.common.remote.PeopleInSpaceApi 4 | import com.surrus.common.repository.PeopleInSpaceRepository 5 | import com.surrus.common.repository.PeopleInSpaceRepositoryInterface 6 | import com.surrus.common.repository.platformModule 7 | import com.surrus.common.viewmodel.ISSPositionViewModel 8 | import io.ktor.client.* 9 | import io.ktor.client.engine.* 10 | import io.ktor.client.plugins.contentnegotiation.* 11 | import io.ktor.client.plugins.logging.* 12 | import io.ktor.serialization.kotlinx.json.* 13 | import kotlinx.coroutines.CoroutineScope 14 | import kotlinx.coroutines.Dispatchers 15 | import kotlinx.coroutines.SupervisorJob 16 | import kotlinx.serialization.json.Json 17 | import org.koin.core.context.startKoin 18 | import org.koin.core.module.dsl.singleOf 19 | import org.koin.dsl.KoinAppDeclaration 20 | import org.koin.dsl.bind 21 | import org.koin.dsl.module 22 | 23 | fun initKoin(enableNetworkLogs: Boolean = false, appDeclaration: KoinAppDeclaration = {}) = 24 | startKoin { 25 | appDeclaration() 26 | modules(commonModule(enableNetworkLogs = enableNetworkLogs), platformModule()) 27 | } 28 | 29 | // called by iOS etc 30 | fun initKoin() = initKoin(enableNetworkLogs = false) {} 31 | 32 | fun commonModule(enableNetworkLogs: Boolean) = module { 33 | singleOf(::createJson) 34 | single { createHttpClient(get(), get(), enableNetworkLogs = enableNetworkLogs) } 35 | singleOf(::PeopleInSpaceApi) 36 | singleOf(::PeopleInSpaceRepository).bind() 37 | 38 | single { CoroutineScope(Dispatchers.Default + SupervisorJob() ) } 39 | } 40 | 41 | fun createJson() = Json { isLenient = true; ignoreUnknownKeys = true } 42 | 43 | 44 | fun createHttpClient(httpClientEngine: HttpClientEngine, json: Json, enableNetworkLogs: Boolean) = HttpClient(httpClientEngine) { 45 | install(ContentNegotiation) { 46 | json(json) 47 | } 48 | if (enableNetworkLogs) { 49 | install(Logging) { 50 | logger = Logger.DEFAULT 51 | level = LogLevel.INFO 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/surrus/common/di/PeopleInSpaceDatabaseWrapper.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.common.di 2 | 3 | import app.cash.sqldelight.db.SqlDriver 4 | import com.surrus.peopleinspace.db.PeopleInSpaceDatabase 5 | 6 | class PeopleInSpaceDatabaseWrapper(val driver: SqlDriver, val instance: PeopleInSpaceDatabase) 7 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/surrus/common/remote/PeopleInSpaceApi.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.common.remote 2 | 3 | import io.ktor.client.* 4 | import io.ktor.client.call.* 5 | import io.ktor.client.request.* 6 | import kotlinx.serialization.Serializable 7 | import org.koin.core.component.KoinComponent 8 | 9 | @Serializable 10 | data class AstroResult(val message: String, val number: Int, val people: List) 11 | 12 | @Serializable 13 | data class Assignment(val craft: String, val name: String, var personImageUrl: String? = "", var personBio: String? = "") 14 | 15 | @Serializable 16 | data class IssPosition(val latitude: Double, val longitude: Double) 17 | 18 | @Serializable 19 | data class IssResponse(val message: String, val iss_position: IssPosition, val timestamp: Long) 20 | 21 | class PeopleInSpaceApi(private val client: HttpClient) : KoinComponent { 22 | var baseUrl = "https://people-in-space-proxy.ew.r.appspot.com" 23 | 24 | suspend fun fetchPeople() = client.get("$baseUrl/astros.json").body() 25 | suspend fun fetchISSPosition() = client.get("$baseUrl/iss-now.json").body() 26 | } 27 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/surrus/common/repository/Expect.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.common.repository 2 | 3 | import org.koin.core.module.Module 4 | 5 | expect fun platformModule(): Module 6 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/surrus/common/repository/PeopleInSpaceRepository.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.common.repository 2 | 3 | import app.cash.sqldelight.async.coroutines.awaitCreate 4 | import app.cash.sqldelight.coroutines.asFlow 5 | import app.cash.sqldelight.coroutines.mapToList 6 | import co.touchlab.kermit.Logger 7 | import com.surrus.common.di.PeopleInSpaceDatabaseWrapper 8 | import com.surrus.common.remote.Assignment 9 | import com.surrus.common.remote.IssPosition 10 | import com.surrus.common.remote.PeopleInSpaceApi 11 | import com.surrus.peopleinspace.db.PeopleInSpaceDatabase 12 | import kotlinx.coroutines.* 13 | import kotlinx.coroutines.flow.* 14 | 15 | 16 | interface PeopleInSpaceRepositoryInterface { 17 | fun fetchPeopleAsFlow(): Flow> 18 | fun pollISSPosition(): Flow 19 | suspend fun fetchPeople(): List 20 | suspend fun fetchAndStorePeople() 21 | } 22 | 23 | class PeopleInSpaceRepository( 24 | private val peopleInSpaceApi: PeopleInSpaceApi, 25 | private val peopleInSpaceDatabase: PeopleInSpaceDatabaseWrapper 26 | ) : PeopleInSpaceRepositoryInterface { 27 | 28 | val coroutineScope: CoroutineScope = MainScope() 29 | private val peopleInSpaceQueries = peopleInSpaceDatabase.instance.peopleInSpaceQueries 30 | 31 | val logger = Logger.withTag("PeopleInSpaceRepository") 32 | 33 | init { 34 | coroutineScope.launch { 35 | // TODO figure out cleaner place to invoke this 36 | PeopleInSpaceDatabase.Schema.awaitCreate(peopleInSpaceDatabase.driver) 37 | fetchAndStorePeople() 38 | } 39 | } 40 | 41 | override fun fetchPeopleAsFlow(): Flow> { 42 | // the main reason we need to do this check is that sqldelight isn't currently 43 | // setup for javascript client 44 | return peopleInSpaceQueries?.selectAll( 45 | mapper = { name, craft, personImageUrl, personBio -> 46 | Assignment( 47 | name = name, 48 | craft = craft, 49 | personImageUrl = personImageUrl, 50 | personBio = personBio 51 | ) 52 | } 53 | )?.asFlow()?.mapToList(Dispatchers.Default) ?: flowOf(emptyList()) 54 | } 55 | 56 | override suspend fun fetchAndStorePeople() { 57 | logger.d { "fetchAndStorePeople" } 58 | try { 59 | val result = peopleInSpaceApi.fetchPeople() 60 | 61 | // this is very basic implementation for now that removes all existing rows 62 | // in db and then inserts results from api request 63 | // using "transaction" accelerate the batch of queries, especially inserting 64 | peopleInSpaceQueries?.transaction { 65 | peopleInSpaceQueries.deleteAll() 66 | result.people.forEach { 67 | peopleInSpaceQueries.insertItem( 68 | it.name, 69 | it.craft, 70 | it.personImageUrl, 71 | it.personBio 72 | ) 73 | } 74 | } 75 | } catch (e: Exception) { 76 | // TODO report error up to UI 77 | logger.w(e) { "Exception during fetchAndStorePeople: $e" } 78 | } 79 | } 80 | 81 | // Used by web and apple clients atm 82 | override suspend fun fetchPeople(): List = peopleInSpaceApi.fetchPeople().people 83 | 84 | override fun pollISSPosition(): Flow { 85 | return flow { 86 | while (true) { 87 | val position = peopleInSpaceApi.fetchISSPosition().iss_position 88 | emit(position) 89 | logger.d { position.toString() } 90 | delay(POLL_INTERVAL) 91 | } 92 | } 93 | } 94 | 95 | companion object { 96 | private const val POLL_INTERVAL = 10000L 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/surrus/common/ui/ISSMapView.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.common.ui 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.Modifier 5 | import com.surrus.common.viewmodel.ISSPositionViewModel 6 | 7 | @Composable 8 | expect fun ISSMapView(modifier: Modifier, viewModel: ISSPositionViewModel) 9 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/surrus/common/ui/ISSPositionContent.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.common.ui 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.Spacer 5 | import androidx.compose.foundation.layout.fillMaxHeight 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.height 8 | import androidx.compose.material3.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.getValue 11 | import androidx.compose.ui.Alignment 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.unit.dp 14 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 15 | import com.surrus.common.viewmodel.ISSPositionViewModel 16 | 17 | @Composable 18 | fun ISSPositionContent(viewModel: ISSPositionViewModel) { 19 | val position by viewModel.position.collectAsStateWithLifecycle() 20 | 21 | Column { 22 | Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { 23 | Text(text = "Latitude = ${position.latitude}") 24 | Text(text = "Longitude = ${position.longitude}") 25 | } 26 | Spacer(Modifier.height(16.dp)) 27 | ISSMapView(Modifier.fillMaxHeight().fillMaxWidth(), viewModel) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/surrus/common/viewmodel/ISSPositionViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.common.viewmodel 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.surrus.common.remote.IssPosition 6 | import com.surrus.common.repository.PeopleInSpaceRepositoryInterface 7 | import kotlinx.coroutines.flow.SharingStarted 8 | import kotlinx.coroutines.flow.stateIn 9 | import org.koin.core.component.KoinComponent 10 | import org.koin.core.component.inject 11 | 12 | class ISSPositionViewModel : ViewModel(), KoinComponent { 13 | private val peopleInSpaceRepository: PeopleInSpaceRepositoryInterface by inject() 14 | 15 | val position = peopleInSpaceRepository.pollISSPosition() 16 | .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), IssPosition(0.0, 0.0)) 17 | } 18 | -------------------------------------------------------------------------------- /common/src/commonMain/kotlin/com/surrus/common/viewmodel/PersonListViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.common.viewmodel 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.surrus.common.remote.Assignment 6 | import com.surrus.common.repository.PeopleInSpaceRepositoryInterface 7 | import kotlinx.coroutines.flow.SharingStarted 8 | import kotlinx.coroutines.flow.map 9 | import kotlinx.coroutines.flow.stateIn 10 | import kotlinx.coroutines.launch 11 | import org.koin.core.component.KoinComponent 12 | import org.koin.core.component.inject 13 | 14 | 15 | sealed class PersonListUiState { 16 | object Loading : PersonListUiState() 17 | data class Error(val message: String) : PersonListUiState() 18 | data class Success(val result: List) : PersonListUiState() 19 | } 20 | 21 | class PersonListViewModel() : ViewModel(), KoinComponent { 22 | private val peopleInSpaceRepository: PeopleInSpaceRepositoryInterface by inject() 23 | 24 | val uiState = peopleInSpaceRepository.fetchPeopleAsFlow() 25 | .map { PersonListUiState.Success(it) } 26 | .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PersonListUiState.Loading) 27 | 28 | fun refresh() { 29 | viewModelScope.launch { 30 | peopleInSpaceRepository.fetchAndStorePeople() 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /common/src/commonMain/sqldelight/com/surrus/peopleinspace/db/1.sqm: -------------------------------------------------------------------------------- 1 | ALTER TABLE People ADD COLUMN personImageUrl TEXT; 2 | ALTER TABLE People ADD COLUMN personBio TEXT; -------------------------------------------------------------------------------- /common/src/commonMain/sqldelight/com/surrus/peopleinspace/db/PeopleInSpace.sq: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS People( 2 | name TEXT NOT NULL PRIMARY KEY, 3 | craft TEXT NOT NULL, 4 | personImageUrl TEXT, 5 | personBio TEXT 6 | ); 7 | 8 | insertItem: 9 | INSERT OR REPLACE INTO People(name, craft, personImageUrl, personBio) VALUES(?,?,?,?); 10 | 11 | selectAll: 12 | SELECT * FROM People; 13 | 14 | deleteAll: 15 | DELETE FROM People; -------------------------------------------------------------------------------- /common/src/commonTest/kotlin/com/surrus/peopleinspace/PeopleInSpaceTest.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.peopleinspace 2 | 3 | import com.surrus.common.di.PeopleInSpaceDatabaseWrapper 4 | import com.surrus.common.di.commonModule 5 | import com.surrus.common.repository.PeopleInSpaceRepositoryInterface 6 | import com.surrus.common.repository.platformModule 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.test.StandardTestDispatcher 9 | import kotlinx.coroutines.test.runTest 10 | import kotlinx.coroutines.test.setMain 11 | import org.koin.core.context.startKoin 12 | import org.koin.dsl.module 13 | import org.koin.test.KoinTest 14 | import org.koin.test.inject 15 | import kotlin.test.BeforeTest 16 | import kotlin.test.Test 17 | import kotlin.test.assertTrue 18 | 19 | class PeopleInSpaceTest: KoinTest { 20 | private val repo : PeopleInSpaceRepositoryInterface by inject() 21 | 22 | @BeforeTest 23 | fun setUp() { 24 | Dispatchers.setMain(StandardTestDispatcher()) 25 | 26 | startKoin{ 27 | modules( 28 | commonModule(true), 29 | platformModule(), 30 | module { 31 | single { PeopleInSpaceDatabaseWrapper(null) } 32 | } 33 | ) 34 | } 35 | } 36 | 37 | @Test 38 | fun testGetPeople() = runTest { 39 | val result = repo.fetchPeople() 40 | println(result) 41 | assertTrue(result.isNotEmpty()) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /common/src/iOSMain/kotlin/com/surrus/common/repository/actual.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.common.repository 2 | 3 | import app.cash.sqldelight.async.coroutines.synchronous 4 | import app.cash.sqldelight.driver.native.NativeSqliteDriver 5 | import com.surrus.common.di.PeopleInSpaceDatabaseWrapper 6 | import com.surrus.peopleinspace.db.PeopleInSpaceDatabase 7 | import io.ktor.client.engine.HttpClientEngine 8 | import io.ktor.client.engine.darwin.* 9 | import org.koin.dsl.module 10 | 11 | 12 | actual fun platformModule() = module { 13 | single { 14 | val driver = NativeSqliteDriver(PeopleInSpaceDatabase.Schema.synchronous(), "peopleinspace.db") 15 | PeopleInSpaceDatabaseWrapper(driver, PeopleInSpaceDatabase(driver)) 16 | } 17 | single { Darwin.create() } 18 | } 19 | -------------------------------------------------------------------------------- /common/src/iOSMain/kotlin/com/surrus/common/ui/ISSMapView.ios.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.common.ui 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.ExperimentalComposeUiApi 5 | import androidx.compose.ui.Modifier 6 | import androidx.compose.ui.interop.UIKitViewController 7 | import androidx.compose.ui.viewinterop.UIKitInteropInteractionMode 8 | import androidx.compose.ui.viewinterop.UIKitInteropProperties 9 | import androidx.compose.ui.viewinterop.UIKitViewController 10 | import com.surrus.common.viewmodel.ISSPositionViewModel 11 | import kotlinx.cinterop.ExperimentalForeignApi 12 | 13 | @Composable 14 | actual fun ISSMapView(modifier: Modifier, viewModel: ISSPositionViewModel) { 15 | MapKitView( 16 | modifier = modifier, 17 | viewModel = viewModel, 18 | ) 19 | } 20 | 21 | @OptIn(ExperimentalComposeUiApi::class) 22 | @Composable 23 | internal fun MapKitView( 24 | modifier: Modifier, 25 | viewModel: ISSPositionViewModel 26 | ) { 27 | val factory = LocalNativeViewFactory.current 28 | 29 | UIKitViewController( 30 | factory = { 31 | factory.createISSMapView(viewModel) 32 | }, 33 | modifier = modifier, 34 | properties = UIKitInteropProperties( 35 | interactionMode = UIKitInteropInteractionMode.NonCooperative, 36 | isNativeAccessibilityEnabled = true 37 | ) 38 | ) 39 | } -------------------------------------------------------------------------------- /common/src/iOSMain/kotlin/com/surrus/common/ui/NativeViewFactory.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.common.ui 2 | 3 | import com.surrus.common.viewmodel.ISSPositionViewModel 4 | import platform.UIKit.UIViewController 5 | 6 | interface NativeViewFactory { 7 | fun createISSMapView(viewModel: ISSPositionViewModel): UIViewController 8 | } -------------------------------------------------------------------------------- /common/src/iOSMain/kotlin/com/surrus/common/ui/SharedViewControllers.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.common.ui 2 | 3 | import androidx.compose.runtime.CompositionLocalProvider 4 | import androidx.compose.runtime.staticCompositionLocalOf 5 | import androidx.compose.ui.window.ComposeUIViewController 6 | import com.surrus.common.viewmodel.ISSPositionViewModel 7 | 8 | 9 | val LocalNativeViewFactory = staticCompositionLocalOf { 10 | error("LocalNativeViewFactory not provided") 11 | } 12 | 13 | 14 | fun ISSPositionContentViewController(viewModel: ISSPositionViewModel, nativeViewFactory: NativeViewFactory) = ComposeUIViewController { 15 | CompositionLocalProvider(LocalNativeViewFactory provides nativeViewFactory) { 16 | ISSPositionContent(viewModel) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /common/src/jvmMain/kotlin/com/surrus/Main.kt: -------------------------------------------------------------------------------- 1 | package com.surrus 2 | 3 | import com.surrus.common.di.initKoin 4 | import com.surrus.common.remote.PeopleInSpaceApi 5 | 6 | suspend fun main() { 7 | val koin = initKoin(enableNetworkLogs = true).koin 8 | val api = koin.get() 9 | println(api.fetchPeople()) 10 | } 11 | -------------------------------------------------------------------------------- /common/src/jvmMain/kotlin/com/surrus/common/repository/actual.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.common.repository 2 | 3 | import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver 4 | import com.surrus.common.di.PeopleInSpaceDatabaseWrapper 5 | import com.surrus.peopleinspace.db.PeopleInSpaceDatabase 6 | import io.ktor.client.engine.java.* 7 | import org.koin.dsl.module 8 | 9 | actual fun platformModule() = module { 10 | single { 11 | val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) 12 | .also { PeopleInSpaceDatabase.Schema.create(it) } 13 | PeopleInSpaceDatabaseWrapper(driver, PeopleInSpaceDatabase(driver)) 14 | } 15 | single { Java.create() } 16 | } 17 | -------------------------------------------------------------------------------- /common/src/jvmMain/kotlin/com/surrus/common/ui/ISSMapView.jvm.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.common.ui 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.Modifier 5 | import com.surrus.common.viewmodel.ISSPositionViewModel 6 | 7 | @Composable 8 | actual fun ISSMapView(modifier: Modifier, viewModel: ISSPositionViewModel) { 9 | } -------------------------------------------------------------------------------- /common/src/wasmJsMain/kotlin/com/surrus/common/repository/actual.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.common.repository 2 | 3 | import app.cash.sqldelight.driver.worker.createDefaultWebWorkerDriver 4 | import com.surrus.common.di.PeopleInSpaceDatabaseWrapper 5 | import com.surrus.peopleinspace.db.PeopleInSpaceDatabase 6 | import io.ktor.client.engine.HttpClientEngine 7 | import io.ktor.client.engine.js.Js 8 | import org.koin.dsl.module 9 | 10 | actual fun platformModule() = module { 11 | single { 12 | val driver = createDefaultWebWorkerDriver() 13 | PeopleInSpaceDatabaseWrapper(driver, PeopleInSpaceDatabase(driver)) 14 | } 15 | single { Js.create() } 16 | } 17 | -------------------------------------------------------------------------------- /common/src/wasmJsMain/kotlin/com/surrus/common/ui/ISSMapView.wasmJs.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.common.ui 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.Modifier 5 | import com.surrus.common.viewmodel.ISSPositionViewModel 6 | 7 | @Composable 8 | actual fun ISSMapView( 9 | modifier: Modifier, 10 | viewModel: ISSPositionViewModel 11 | ) { 12 | } -------------------------------------------------------------------------------- /compose-desktop/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | *.iml 3 | -------------------------------------------------------------------------------- /compose-desktop/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | kotlin("jvm") 3 | alias(libs.plugins.compose.compiler) 4 | alias(libs.plugins.jetbrainsCompose) 5 | application 6 | } 7 | 8 | group = "me.joreilly" 9 | version = "1.0-SNAPSHOT" 10 | 11 | dependencies { 12 | implementation(compose.desktop.currentOs) 13 | implementation(libs.coil3.compose) 14 | implementation(libs.coil3.network.ktor) 15 | 16 | implementation(projects.common) 17 | } 18 | 19 | application { 20 | mainClass.set("MainKt") 21 | } 22 | -------------------------------------------------------------------------------- /compose-desktop/src/main/kotlin/main.kt: -------------------------------------------------------------------------------- 1 | import androidx.compose.foundation.background 2 | import androidx.compose.foundation.clickable 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.foundation.lazy.LazyColumn 5 | import androidx.compose.foundation.lazy.items 6 | import androidx.compose.material.MaterialTheme 7 | import androidx.compose.material.Text 8 | import androidx.compose.runtime.* 9 | import androidx.compose.ui.Alignment 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.graphics.Color 12 | import androidx.compose.ui.text.TextStyle 13 | import androidx.compose.ui.unit.dp 14 | import androidx.compose.ui.unit.sp 15 | import androidx.compose.ui.window.Window 16 | import androidx.compose.ui.window.application 17 | import androidx.compose.ui.window.rememberWindowState 18 | import coil3.compose.AsyncImage 19 | import com.surrus.common.di.initKoin 20 | import com.surrus.common.remote.Assignment 21 | import com.surrus.common.repository.PeopleInSpaceRepository 22 | 23 | private val koin = initKoin(enableNetworkLogs = true).koin 24 | 25 | fun main() = application { 26 | val windowState = rememberWindowState() 27 | 28 | var peopleState by remember { mutableStateOf(emptyList()) } 29 | var selectedPerson by remember { mutableStateOf(null) } 30 | 31 | val peopleInSpaceRepository = koin.get() 32 | val people by peopleInSpaceRepository.fetchPeopleAsFlow().collectAsState(emptyList()) 33 | 34 | Window( 35 | onCloseRequest = ::exitApplication, 36 | state = windowState, 37 | title = "People In Space" 38 | ) { 39 | 40 | Row(Modifier.fillMaxSize()) { 41 | 42 | Box(Modifier.width(250.dp).fillMaxHeight().background(color = Color.LightGray)) { 43 | PersonList(people, selectedPerson) { 44 | selectedPerson = it 45 | } 46 | } 47 | 48 | Spacer(modifier = Modifier.width(1.dp).fillMaxHeight()) 49 | 50 | Box(Modifier.fillMaxHeight()) { 51 | selectedPerson?.let { 52 | PersonDetailsView(it) 53 | } 54 | } 55 | } 56 | 57 | } 58 | } 59 | 60 | @Composable 61 | fun PersonList( 62 | people: List, 63 | selectedPerson: Assignment?, 64 | personSelected: (person: Assignment) -> Unit 65 | ) { 66 | 67 | // workaround for compose desktop but if LazyColumn is empty 68 | if (people.isNotEmpty()) { 69 | LazyColumn { 70 | items(people) { person -> 71 | PersonView(person, selectedPerson, personSelected) 72 | } 73 | } 74 | } 75 | } 76 | 77 | @Composable 78 | fun PersonView( 79 | person: Assignment, 80 | selectedPerson: Assignment?, 81 | personSelected: (person: Assignment) -> Unit 82 | ) { 83 | Row( 84 | modifier = Modifier.fillMaxWidth().clickable(onClick = { personSelected(person) }) 85 | .padding(8.dp), 86 | verticalAlignment = Alignment.CenterVertically 87 | ) { 88 | 89 | Column { 90 | Text( 91 | person.name, 92 | style = if (person.name == selectedPerson?.name) MaterialTheme.typography.h6 else MaterialTheme.typography.body1 93 | ) 94 | 95 | Text(text = person.craft, style = TextStyle(color = Color.DarkGray, fontSize = 14.sp)) 96 | } 97 | } 98 | } 99 | 100 | @Composable 101 | fun PersonDetailsView(person: Assignment) { 102 | LazyColumn( 103 | modifier = Modifier.padding(16.dp).fillMaxWidth(), 104 | horizontalAlignment = Alignment.CenterHorizontally 105 | ) { 106 | 107 | item(person) { 108 | 109 | Text(person.name, style = MaterialTheme.typography.h4) 110 | Spacer(modifier = Modifier.size(12.dp)) 111 | 112 | val personImageUrl = person.personImageUrl 113 | personImageUrl?.let { 114 | AsyncImage( 115 | modifier = Modifier.size(240.dp), 116 | model = personImageUrl, 117 | contentDescription = person.name 118 | ) 119 | } 120 | Spacer(modifier = Modifier.size(24.dp)) 121 | 122 | val bio = person.personBio ?: "" 123 | Text(bio, style = MaterialTheme.typography.body1) 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /compose-web/build.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalWasmDsl::class) 2 | 3 | import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl 4 | import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig 5 | 6 | plugins { 7 | kotlin("multiplatform") 8 | id("kotlinx-serialization") 9 | alias(libs.plugins.compose.compiler) 10 | alias(libs.plugins.jetbrainsCompose) 11 | } 12 | 13 | group = "com.example" 14 | version = "1.0-SNAPSHOT" 15 | 16 | @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) 17 | kotlin { 18 | wasmJs { 19 | moduleName = "peopleinspace" 20 | browser { 21 | commonWebpackConfig { 22 | outputFileName = "peopleinspace.js" 23 | } 24 | } 25 | binaries.executable() 26 | } 27 | sourceSets { 28 | commonMain { 29 | dependencies { 30 | implementation(compose.runtime) 31 | implementation(compose.foundation) 32 | implementation(compose.material3) 33 | implementation(compose.components.resources) 34 | 35 | implementation(libs.coil3.compose) 36 | implementation(libs.coil3.network.ktor) 37 | implementation(projects.common) 38 | } 39 | } 40 | } 41 | } 42 | 43 | 44 | compose.experimental { 45 | web.application {} 46 | } 47 | -------------------------------------------------------------------------------- /compose-web/src/wasmJsMain/kotlin/Main.kt: -------------------------------------------------------------------------------- 1 | import androidx.compose.foundation.background 2 | import androidx.compose.foundation.clickable 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.Spacer 7 | import androidx.compose.foundation.layout.fillMaxHeight 8 | import androidx.compose.foundation.layout.fillMaxSize 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.layout.size 12 | import androidx.compose.foundation.layout.width 13 | import androidx.compose.foundation.lazy.LazyColumn 14 | import androidx.compose.foundation.lazy.items 15 | import androidx.compose.material3.MaterialTheme 16 | import androidx.compose.material3.Text 17 | import androidx.compose.runtime.Composable 18 | import androidx.compose.runtime.collectAsState 19 | import androidx.compose.runtime.getValue 20 | import androidx.compose.runtime.mutableStateOf 21 | import androidx.compose.runtime.remember 22 | import androidx.compose.runtime.setValue 23 | import androidx.compose.ui.Alignment 24 | import androidx.compose.ui.ExperimentalComposeUiApi 25 | import androidx.compose.ui.Modifier 26 | import androidx.compose.ui.graphics.Color 27 | import androidx.compose.ui.text.TextStyle 28 | import androidx.compose.ui.unit.dp 29 | import androidx.compose.ui.unit.sp 30 | import androidx.compose.ui.window.CanvasBasedWindow 31 | import coil3.compose.AsyncImage 32 | import com.surrus.common.di.initKoin 33 | import com.surrus.common.remote.Assignment 34 | import com.surrus.common.repository.PeopleInSpaceRepository 35 | 36 | private val koin = initKoin(enableNetworkLogs = true).koin 37 | 38 | @OptIn(ExperimentalComposeUiApi::class) 39 | fun main() { 40 | 41 | val peopleInSpaceRepository = koin.get() 42 | 43 | CanvasBasedWindow("PeopleInSpace", canvasElementId = "peopleInSpaceCanvas") { 44 | 45 | val people by peopleInSpaceRepository.fetchPeopleAsFlow().collectAsState(emptyList()) 46 | var selectedPerson by remember { mutableStateOf(null) } 47 | 48 | 49 | Row(Modifier.fillMaxSize()) { 50 | 51 | Box(Modifier.width(250.dp).fillMaxHeight().background(color = Color.LightGray)) { 52 | PersonList(people, selectedPerson) { 53 | selectedPerson = it 54 | } 55 | } 56 | 57 | Spacer(modifier = Modifier.width(1.dp).fillMaxHeight()) 58 | 59 | Box(Modifier.fillMaxHeight()) { 60 | selectedPerson?.let { 61 | PersonDetailsView(it) 62 | } 63 | } 64 | } 65 | } 66 | } 67 | 68 | @Composable 69 | fun PersonList( 70 | people: List, 71 | selectedPerson: Assignment?, 72 | personSelected: (person: Assignment) -> Unit 73 | ) { 74 | if (people.isNotEmpty()) { 75 | LazyColumn { 76 | items(people) { person -> 77 | PersonView(person, selectedPerson, personSelected) 78 | } 79 | } 80 | } 81 | } 82 | 83 | 84 | @Composable 85 | fun PersonView( 86 | person: Assignment, 87 | selectedPerson: Assignment?, 88 | personSelected: (person: Assignment) -> Unit 89 | ) { 90 | Row( 91 | modifier = Modifier.fillMaxWidth().clickable(onClick = { personSelected(person) }) 92 | .padding(8.dp), 93 | verticalAlignment = Alignment.CenterVertically 94 | ) { 95 | 96 | Column { 97 | Text( 98 | person.name, 99 | style = if (person.name == selectedPerson?.name) MaterialTheme.typography.titleLarge else MaterialTheme.typography.bodyLarge 100 | ) 101 | 102 | Text(text = person.craft, style = TextStyle(color = Color.DarkGray, fontSize = 14.sp)) 103 | } 104 | } 105 | } 106 | 107 | @Composable 108 | fun PersonDetailsView(person: Assignment) { 109 | LazyColumn( 110 | modifier = Modifier.padding(16.dp).fillMaxWidth(), 111 | horizontalAlignment = Alignment.CenterHorizontally 112 | ) { 113 | 114 | item(person) { 115 | Text(person.name, style = MaterialTheme.typography.headlineMedium) 116 | Spacer(modifier = Modifier.size(12.dp)) 117 | 118 | val personImageUrl = person.personImageUrl 119 | personImageUrl?.let { 120 | AsyncImage( 121 | modifier = Modifier.size(240.dp), 122 | model = personImageUrl, 123 | contentDescription = person.name 124 | ) 125 | } 126 | Spacer(modifier = Modifier.size(24.dp)) 127 | 128 | val bio = person.personBio ?: "" 129 | Text(bio, style = MaterialTheme.typography.bodyLarge) 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /compose-web/src/wasmJsMain/resources/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | PeopleInSpace with Kotlin/Wasm 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /compose-web/src/wasmJsMain/resources/sqljs.worker.js: -------------------------------------------------------------------------------- 1 | import initSqlJs from "sql.js"; 2 | 3 | let db = null; 4 | 5 | async function createDatabase() { 6 | let SQL = await initSqlJs({locateFile: file => 'sql-wasm.wasm'}); 7 | db = new SQL.Database(); 8 | } 9 | 10 | function onModuleReady() { 11 | const data = this.data; 12 | 13 | switch (data && data.action) { 14 | case "exec": 15 | if (!data["sql"]) { 16 | throw new Error("exec: Missing query string"); 17 | } 18 | 19 | return postMessage({ 20 | id: data.id, 21 | results: db.exec(data.sql, data.params)[0] ?? {values: []} 22 | }); 23 | case "begin_transaction": 24 | return postMessage({ 25 | id: data.id, 26 | results: db.exec("BEGIN TRANSACTION;") 27 | }) 28 | case "end_transaction": 29 | return postMessage({ 30 | id: data.id, 31 | results: db.exec("END TRANSACTION;") 32 | }) 33 | case "rollback_transaction": 34 | return postMessage({ 35 | id: data.id, 36 | results: db.exec("ROLLBACK TRANSACTION;") 37 | }) 38 | default: 39 | throw new Error(`Unsupported action: ${data && data.action}`); 40 | } 41 | } 42 | 43 | function onError(err) { 44 | return postMessage({ 45 | id: this.data.id, 46 | error: err 47 | }); 48 | } 49 | 50 | if (typeof importScripts === "function") { 51 | db = null; 52 | const sqlModuleReady = createDatabase() 53 | self.onmessage = (event) => { 54 | return sqlModuleReady 55 | .then(onModuleReady.bind(event)) 56 | .catch(onError.bind(event)); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /compose-web/webpack.config.d/config.js: -------------------------------------------------------------------------------- 1 | const TerserPlugin = require("terser-webpack-plugin"); 2 | 3 | config.optimization = config.optimization || {}; 4 | config.optimization.minimize = true; 5 | config.optimization.minimizer = [ 6 | new TerserPlugin({ 7 | terserOptions: { 8 | mangle: true, // Note: By default, mangle is set to true. 9 | compress: false, // Disable the transformations that reduce the code size. 10 | output: { 11 | beautify: false, 12 | }, 13 | }, 14 | }), 15 | ]; -------------------------------------------------------------------------------- /compose-web/webpack.config.d/sqljs-config.js: -------------------------------------------------------------------------------- 1 | // {project}/webpack.config.d/sqljs.js 2 | config.resolve = { 3 | fallback: { 4 | fs: false, 5 | path: false, 6 | crypto: false, 7 | } 8 | }; 9 | 10 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 11 | config.plugins.push( 12 | new CopyWebpackPlugin({ 13 | patterns: [ 14 | '../../node_modules/sql.js/dist/sql-wasm.wasm' 15 | ] 16 | }) 17 | ); 18 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx4000m -XX:+UseParallelGC 2 | android.useAndroidX=true 3 | android.enableJetifier=false 4 | 5 | # Kotlin code style for this project: "official" or "obsolete": 6 | kotlin.code.style=official 7 | 8 | # XCode 9 | xcodeproj=./ios/PeopleInSpaceSwiftUI 10 | 11 | 12 | # https://blog.jetbrains.com/kotlin/2022/07/a-new-approach-to-incremental-compilation-in-kotlin/ 13 | kotlin.incremental.useClasspathSnapshot=true 14 | 15 | # https://twitter.com/Sellmair/status/1543938828062392322 16 | import_orphan_source_sets=false 17 | 18 | #org.jetbrains.compose.experimental.uikit.enabled=true 19 | kotlin.mpp.stability.nowarn=true 20 | kotlin.mpp.androidSourceSetLayoutVersion=2 21 | 22 | org.jetbrains.compose.experimental.wasm.enabled=true 23 | org.jetbrains.compose.experimental.jscanvas.enabled=true 24 | org.jetbrains.compose.experimental.macos.enabled=true -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joreilly/PeopleInSpace/e50925ece6ba05c61e963f73a0fb6f2b57530677/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Mon May 15 15:39:54 IST 2023 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | networkTimeout=10000 5 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /graphql-server/.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | -------------------------------------------------------------------------------- /graphql-server/build.gradle.kts: -------------------------------------------------------------------------------- 1 | 2 | plugins { 3 | kotlin("multiplatform") 4 | id("org.jetbrains.kotlin.plugin.spring") version("1.9.25") 5 | kotlin("plugin.serialization") 6 | id("org.springframework.boot") version("3.1.5") 7 | //id("com.google.cloud.tools.appengine") version("2.4.2") 8 | id("com.github.johnrengelman.shadow") 9 | } 10 | 11 | 12 | kotlin { 13 | jvm() { 14 | withJava() 15 | } 16 | 17 | sourceSets { 18 | val jvmMain by getting { 19 | dependencies { 20 | implementation("com.expediagroup:graphql-kotlin-spring-server:5.5.0") 21 | implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.1") 22 | implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") 23 | 24 | implementation("ch.qos.logback:logback-classic:1.5.8") 25 | 26 | implementation(projects.common) 27 | } 28 | } 29 | } 30 | } 31 | 32 | 33 | //appengine { 34 | // stage { 35 | // setArtifact(tasks.named("bootJar").flatMap { (it as Jar).archiveFile }) 36 | // } 37 | // deploy { 38 | // projectId = "peopleinspace-graphql" 39 | // version = "GCLOUD_CONFIG" 40 | // } 41 | //} -------------------------------------------------------------------------------- /graphql-server/src/jvmMain/appengine/app.yaml: -------------------------------------------------------------------------------- 1 | runtime: java17 2 | 3 | entrypoint: java -Xmx64m -jar graphql-server.jar 4 | -------------------------------------------------------------------------------- /graphql-server/src/jvmMain/kotlin/com/surrus/peopleinspace/DefaultApplication.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.peopleinspace 2 | 3 | import com.surrus.common.di.initKoin 4 | import org.springframework.boot.autoconfigure.SpringBootApplication 5 | import org.springframework.boot.runApplication 6 | import org.springframework.context.ConfigurableApplicationContext 7 | 8 | 9 | val koin = initKoin(enableNetworkLogs = true).koin 10 | 11 | @SpringBootApplication 12 | class DefaultApplication { 13 | } 14 | 15 | fun runServer(): ConfigurableApplicationContext { 16 | return runApplication() 17 | } 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /graphql-server/src/jvmMain/kotlin/com/surrus/peopleinspace/IssPositionSubscription.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.peopleinspace 2 | 3 | import com.expediagroup.graphql.server.operations.Subscription 4 | import com.surrus.common.remote.IssPosition 5 | import com.surrus.common.remote.PeopleInSpaceApi 6 | import kotlinx.coroutines.delay 7 | import kotlinx.coroutines.flow.flow 8 | import kotlinx.coroutines.reactive.asPublisher 9 | import org.reactivestreams.Publisher 10 | import org.slf4j.Logger 11 | import org.slf4j.LoggerFactory 12 | import org.springframework.stereotype.Component 13 | 14 | 15 | 16 | @Component 17 | class IssPositionSubscription : Subscription { 18 | private val logger: Logger = LoggerFactory.getLogger(IssPositionSubscription::class.java) 19 | private var peopleInSpaceApi: PeopleInSpaceApi = koin.get() 20 | 21 | 22 | fun issPosition(): Publisher { 23 | return flow { 24 | while (true) { 25 | val position = peopleInSpaceApi.fetchISSPosition().iss_position 26 | logger.info("ISS position = $position") 27 | emit(position) 28 | delay(POLL_INTERVAL) 29 | } 30 | }.asPublisher() 31 | } 32 | 33 | companion object { 34 | private const val POLL_INTERVAL = 10000L 35 | } 36 | 37 | } -------------------------------------------------------------------------------- /graphql-server/src/jvmMain/kotlin/com/surrus/peopleinspace/graph.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.peopleinspace 2 | 3 | import com.expediagroup.graphql.server.operations.Query 4 | import com.surrus.common.remote.PeopleInSpaceApi 5 | import com.surrus.common.remote.Assignment 6 | import org.springframework.stereotype.Component 7 | 8 | data class People(val people: List) 9 | 10 | @Component 11 | class RootQuery : Query { 12 | private var peopleInSpaceApi: PeopleInSpaceApi = koin.get() 13 | 14 | suspend fun allPeople(): People = People(peopleInSpaceApi.fetchPeople().people) 15 | } 16 | -------------------------------------------------------------------------------- /graphql-server/src/jvmMain/kotlin/com/surrus/peopleinspace/main.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.peopleinspace 2 | 3 | fun main(args: Array) { 4 | runServer() 5 | } 6 | 7 | -------------------------------------------------------------------------------- /graphql-server/src/jvmMain/resources/application.yml: -------------------------------------------------------------------------------- 1 | graphql: 2 | packages: "com.surrus" 3 | -------------------------------------------------------------------------------- /mcp-server/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlinJvm) 3 | alias(libs.plugins.kotlinx.serialization) 4 | alias(libs.plugins.shadowPlugin) 5 | application 6 | } 7 | 8 | dependencies { 9 | implementation(libs.mcp.kotlin) 10 | implementation(projects.common) 11 | } 12 | 13 | java { 14 | toolchain { 15 | languageVersion = JavaLanguageVersion.of(17) 16 | } 17 | } 18 | 19 | application { 20 | mainClass = "MainKt" 21 | } 22 | 23 | tasks.shadowJar { 24 | archiveFileName.set("serverAll.jar") 25 | archiveClassifier.set("") 26 | manifest { 27 | attributes["Main-Class"] = "MainKt" 28 | } 29 | } 30 | 31 | -------------------------------------------------------------------------------- /mcp-server/src/main/kotlin/main.kt: -------------------------------------------------------------------------------- 1 | /** 2 | * Entry point. 3 | * It initializes and runs the appropriate server mode based on the input arguments. 4 | * 5 | * Command-line arguments passed to the application: 6 | * - args[0]: Specifies the server mode. Supported values are: 7 | * - "--sse-server": Runs the SSE MCP server. 8 | * - "--stdio": Runs the MCP server using standard input/output. 9 | * Defaults to "--sse-server" if not provided. 10 | * - args[1]: Specifies the port number for the server. Defaults to 3001 if not provided or invalid. 11 | */ 12 | 13 | 14 | fun main(args: Array) { 15 | val command = args.firstOrNull() ?: "--sse-server" 16 | val port = args.getOrNull(1)?.toIntOrNull() ?: 3001 17 | when (command) { 18 | "--sse-server" -> `run sse mcp server`(port) 19 | "--stdio" -> `run mcp server using stdio`() 20 | else -> { 21 | System.err.println("Unknown command: $command") 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /mcp-server/src/main/kotlin/server.kt: -------------------------------------------------------------------------------- 1 | import com.surrus.common.di.initKoin 2 | import com.surrus.common.repository.PeopleInSpaceRepository 3 | import io.ktor.server.cio.* 4 | import io.ktor.server.engine.* 5 | import io.ktor.utils.io.streams.* 6 | import io.modelcontextprotocol.kotlin.sdk.* 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 kotlinx.coroutines.Job 12 | import kotlinx.coroutines.runBlocking 13 | import kotlinx.io.asSink 14 | import kotlinx.io.buffered 15 | 16 | 17 | private val koin = initKoin(enableNetworkLogs = true).koin 18 | 19 | fun configureServer(): Server { 20 | val peopleInSpaceRepository = koin.get() 21 | 22 | val server = Server( 23 | Implementation( 24 | name = "mcp-kotlin PeopleInSpace server", 25 | version = "1.0.0" 26 | ), 27 | ServerOptions( 28 | capabilities = ServerCapabilities( 29 | prompts = ServerCapabilities.Prompts(listChanged = true), 30 | resources = ServerCapabilities.Resources(subscribe = true, listChanged = true), 31 | tools = ServerCapabilities.Tools(listChanged = true) 32 | ) 33 | ) 34 | ) 35 | 36 | 37 | server.addTool( 38 | name = "get-people-in-space", 39 | description = "The list of people in space endpoint returns the list of people in space right now" 40 | ) { 41 | val people = peopleInSpaceRepository.fetchPeople() 42 | CallToolResult( 43 | content = 44 | people.map { TextContent(it.name) } 45 | ) 46 | } 47 | 48 | return server 49 | } 50 | 51 | /** 52 | * Runs an MCP (Model Context Protocol) server using standard I/O for communication. 53 | * 54 | * This function initializes a server instance configured with predefined tools and capabilities. 55 | * It sets up a transport mechanism using standard input and output for communication. 56 | * Once the server starts, it listens for incoming connections, processes requests, 57 | * and executes the appropriate tools. The server shuts down gracefully upon receiving 58 | * a close event. 59 | */ 60 | fun `run mcp server using stdio`() { 61 | val server = configureServer() 62 | val transport = StdioServerTransport( 63 | System.`in`.asInput(), 64 | System.out.asSink().buffered() 65 | ) 66 | 67 | runBlocking { 68 | server.connect(transport) 69 | val done = Job() 70 | server.onClose { 71 | done.complete() 72 | } 73 | done.join() 74 | } 75 | } 76 | 77 | /** 78 | * Launches an SSE (Server-Sent Events) MCP (Model Context Protocol) server on the specified port. 79 | * This server enables clients to connect via SSE for real-time communication and provides endpoints 80 | * for handling specific messages. 81 | * 82 | * @param port The port number on which the SSE server should be started. 83 | */ 84 | fun `run sse mcp server`(port: Int): Unit = runBlocking { 85 | val server = configureServer() 86 | embeddedServer(CIO, host = "0.0.0.0", port = port) { 87 | mcp { 88 | server 89 | } 90 | }.start(wait = true) 91 | } 92 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | listOf(repositories, dependencyResolutionManagement.repositories).forEach { 3 | it.apply { 4 | google() 5 | mavenCentral() 6 | gradlePluginPortal() 7 | } 8 | } 9 | 10 | resolutionStrategy { 11 | eachPlugin { 12 | if (requested.id.id.startsWith("com.google.cloud.tools.appengine")) { 13 | useModule("com.google.cloud.tools:appengine-gradle-plugin:${requested.version}") 14 | } 15 | } 16 | } 17 | } 18 | 19 | rootProject.name = "PeopleInSpace" 20 | enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") 21 | include(":app") 22 | include(":wearApp") 23 | include(":compose-desktop") 24 | include(":compose-web") 25 | include(":common") 26 | include(":backend") 27 | include(":graphql-server") 28 | include(":mcp-server") 29 | -------------------------------------------------------------------------------- /wearApp/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /wearApp/benchmark-rules.pro: -------------------------------------------------------------------------------- 1 | # Benchmark builds should not be obfuscated. 2 | -dontobfuscate 3 | -------------------------------------------------------------------------------- /wearApp/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.application") 3 | id("kotlin-android") 4 | alias(libs.plugins.compose.compiler) 5 | } 6 | 7 | kotlin { 8 | jvmToolchain(17) 9 | } 10 | 11 | android { 12 | compileSdk = libs.versions.compileSdk.get().toInt() 13 | 14 | defaultConfig { 15 | applicationId = "com.surrus.peopleinspace" 16 | minSdk = libs.versions.minWearSdk.get().toInt() 17 | targetSdk = libs.versions.targetWearSdk.get().toInt() 18 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 19 | } 20 | 21 | buildFeatures { 22 | compose = true 23 | buildConfig = true 24 | } 25 | 26 | buildTypes { 27 | release { 28 | isMinifyEnabled = true 29 | isShrinkResources = true 30 | proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") 31 | // temporary hack for local testing of release builds 32 | signingConfig = signingConfigs.getByName("debug") 33 | } 34 | create("benchmark") { 35 | isMinifyEnabled = true 36 | isShrinkResources = true 37 | isDebuggable = false 38 | proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "benchmark-rules.pro") 39 | // temporary hack for local testing of release builds 40 | signingConfig = signingConfigs.getByName("debug") 41 | matchingFallbacks.addAll(listOf("release", "debug")) 42 | } 43 | } 44 | 45 | namespace = "com.surrus.peopleinspace" 46 | } 47 | 48 | dependencies { 49 | implementation(libs.osmdroidAndroid) 50 | implementation(libs.osm.android.compose) 51 | 52 | implementation(libs.androidx.activity.compose) 53 | implementation(libs.metrics) 54 | 55 | implementation(libs.wear.compose.founndation) 56 | implementation(libs.wear.compose.material) 57 | implementation(libs.wear.compose.navigation) 58 | 59 | implementation(libs.androidx.compose.ui.tooling) 60 | implementation(libs.horologist.compose.layout) 61 | implementation(libs.glance.tiles) 62 | implementation(libs.coilCompose) 63 | 64 | implementation(libs.koin.core) 65 | implementation(libs.koin.android) 66 | implementation(libs.koin.androidx.compose) 67 | 68 | implementation(libs.splash.screen) 69 | 70 | implementation(libs.androidx.ui.tooling) 71 | implementation(libs.wear.ui.tooling) 72 | debugImplementation(libs.androidx.ui.tooling.preview) 73 | 74 | implementation(libs.okhttp) 75 | implementation(libs.loggingInterceptor) 76 | 77 | // Compose testing dependencies 78 | androidTestImplementation(platform(libs.androidx.compose.bom)) 79 | androidTestImplementation(libs.androidx.compose.ui.test) 80 | androidTestImplementation(libs.androidx.compose.ui.test.junit) 81 | debugImplementation(libs.androidx.compose.ui.test.manifest) 82 | debugImplementation(libs.androidx.tracing) 83 | 84 | implementation(projects.common) 85 | } -------------------------------------------------------------------------------- /wearApp/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # By default, the flags in this file are appended to flags specified 3 | # in /Users/jooreill/devtools/adt/sdk/tools/proguard/proguard-android.txt 4 | # You can edit the include path and order by changing the proguardFiles 5 | # directive in build.gradle.kts.kts.kts. 6 | # 7 | # For more details, see 8 | # http://developer.android.com/guide/developing/tools/proguard.html 9 | 10 | # Add any project specific keep options here: 11 | 12 | # keep everything in this package from being removed or renamed 13 | -keep class com.surrus.** { *; } 14 | -keep class androidx.wear.** { *; } 15 | 16 | # okhttp 17 | -keep class okhttp3.** { *; } 18 | -keep interface okhttp3.** { *; } 19 | -dontwarn okhttp3.** 20 | -dontwarn okio.** 21 | -dontwarn com.squareup.okhttp.** 22 | 23 | 24 | -dontnote android.net.http.* 25 | -dontnote org.apache.commons.codec.** 26 | -dontnote org.apache.http.** 27 | 28 | 29 | 30 | -keep,allowoptimization class com.google.android.libraries.maps.** { *; } 31 | -keep,allowoptimization class com.google.android.apps.gmm.renderer.** { *; } 32 | 33 | 34 | -keepnames class * implements android.os.Parcelable 35 | -keepclassmembers class * implements android.os.Parcelable { 36 | public static final *** CREATOR; 37 | } 38 | 39 | -dontwarn android.security.NetworkSecurityPolicy 40 | 41 | -keep class com.google.android.gms.** { *; } 42 | -dontwarn com.google.android.gms.** 43 | 44 | -dontwarn android.content.** 45 | -keep class android.content.** 46 | 47 | -keep class android.support.** { *; } 48 | -keep interface android.support.** { *; } 49 | -renamesourcefileattribute SourceFile 50 | -keepattributes SourceFile,LineNumberTable 51 | 52 | # Firebase Authentication 53 | -keepattributes Signature 54 | -keepattributes *Annotation* 55 | 56 | 57 | #Kotlin 58 | 59 | -dontwarn kotlin.** 60 | -dontwarn org.jetbrains.annotations.NotNull 61 | -------------------------------------------------------------------------------- /wearApp/src/androidTest/java/com/surrus/peopleinspace/wear/PeopleInSpaceTest.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalHorologistApi::class) 2 | 3 | package com.surrus.peopleinspace.wear 4 | 5 | import androidx.compose.ui.test.assertContentDescriptionEquals 6 | import androidx.compose.ui.test.assertIsDisplayed 7 | import androidx.compose.ui.test.junit4.createComposeRule 8 | import androidx.compose.ui.test.onNodeWithTag 9 | import androidx.compose.ui.test.onNodeWithText 10 | import androidx.compose.ui.test.onParent 11 | import com.google.android.horologist.annotations.ExperimentalHorologistApi 12 | import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults 13 | import com.surrus.common.remote.Assignment 14 | import com.surrus.peopleinspace.list.PersonList 15 | import com.surrus.peopleinspace.list.PersonListTag 16 | import org.junit.Rule 17 | import org.junit.Test 18 | 19 | class PeopleInSpaceTest { 20 | @get:Rule 21 | val composeTestRule = createComposeRule() 22 | 23 | private val peopleList = listOf( 24 | Assignment( 25 | "Apollo 11", 26 | "Neil Armstrong", 27 | "https://www.biography.com/.image/ar_1:1%2Cc_fill%2Ccs_srgb%2Cfl_progressive%2Cq_auto:good%2Cw_1200/MTc5OTk0MjgyMzk5MTE0MzYy/gettyimages-150832381.jpg" 28 | ), 29 | Assignment( 30 | "Apollo 11", 31 | "Buzz Aldrin", 32 | "https://nypost.com/wp-content/uploads/sites/2/2018/06/buzz-aldrin.jpg?quality=80&strip=all" 33 | ) 34 | ) 35 | 36 | @Test 37 | fun testPeopleListScreenEmpty() { 38 | composeTestRule.setContent { 39 | PersonList( 40 | people = listOf(), 41 | personSelected = {}, 42 | issMapClick = {}, 43 | columnState = ScalingLazyColumnDefaults.belowTimeText().create() 44 | ) 45 | } 46 | 47 | composeTestRule.onNodeWithTag("Person").assertDoesNotExist() 48 | } 49 | 50 | @Test 51 | fun testPeopleListScreen() { 52 | composeTestRule.setContent { 53 | PersonList( 54 | people = peopleList, 55 | personSelected = {}, 56 | issMapClick = {}, 57 | columnState = ScalingLazyColumnDefaults.belowTimeText().create() 58 | ) 59 | } 60 | 61 | val personListNode = composeTestRule.onNodeWithTag(PersonListTag) 62 | personListNode.assertIsDisplayed() 63 | 64 | val neilNode = 65 | composeTestRule.onNodeWithText("Neil Armstrong", useUnmergedTree = true).onParent() 66 | neilNode.assertIsDisplayed() 67 | neilNode.assertContentDescriptionEquals("Neil Armstrong on Apollo 11") 68 | 69 | val buzzNode = 70 | composeTestRule.onNodeWithText("Buzz Aldrin", useUnmergedTree = true).onParent() 71 | buzzNode.assertIsDisplayed() 72 | buzzNode.assertContentDescriptionEquals("Buzz Aldrin on Apollo 11") 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /wearApp/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 17 | 20 | 21 | 24 | 25 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 45 | 46 | 47 | 48 | 54 | 55 | 56 | 57 | 58 | 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /wearApp/src/main/java/com/surrus/peopleinspace/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.peopleinspace 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import androidx.compose.runtime.LaunchedEffect 7 | import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen 8 | import androidx.navigation.NavHostController 9 | import androidx.wear.compose.navigation.rememberSwipeDismissableNavController 10 | 11 | sealed class Screen(val route: String) { 12 | object PersonList : Screen("personList") 13 | object PersonDetails : Screen("personDetails") 14 | object IssMap : Screen("issMap") 15 | } 16 | 17 | const val PERSON_NAME_NAV_ARGUMENT = "personName" 18 | const val DEEPLINK_URI = "peopleinspace://peopleinspace.dev/" 19 | 20 | class MainActivity : ComponentActivity() { 21 | private lateinit var navController: NavHostController 22 | 23 | override fun onCreate(savedInstanceState: Bundle?) { 24 | installSplashScreen() 25 | 26 | super.onCreate(savedInstanceState) 27 | 28 | setTheme(android.R.style.Theme_DeviceDefault) 29 | 30 | setContent { 31 | navController = rememberSwipeDismissableNavController() 32 | 33 | PeopleInSpaceApp(navController = navController) 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /wearApp/src/main/java/com/surrus/peopleinspace/PeopleInSpaceApp.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalHorologistApi::class) 2 | 3 | package com.surrus.peopleinspace 4 | 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | import androidx.navigation.NavDeepLink 9 | import androidx.navigation.NavHostController 10 | import androidx.navigation.NavType 11 | import androidx.navigation.navArgument 12 | import androidx.navigation.navDeepLink 13 | import androidx.wear.compose.navigation.SwipeDismissableNavHost 14 | import androidx.wear.compose.navigation.composable 15 | import com.google.android.horologist.annotations.ExperimentalHorologistApi 16 | import com.google.android.horologist.compose.layout.AppScaffold 17 | import com.surrus.peopleinspace.list.PersonListScreen 18 | import com.surrus.peopleinspace.map.IssMapScreen 19 | import com.surrus.peopleinspace.person.PersonDetailsScreen 20 | 21 | @Composable 22 | fun PeopleInSpaceApp(navController: NavHostController) { 23 | AppScaffold { 24 | SwipeDismissableNavHost( 25 | navController = navController, 26 | startDestination = Screen.PersonList.route 27 | ) { 28 | composable( 29 | route = Screen.PersonList.route, 30 | deepLinks = listOf(navDeepLink { this.uriPattern = "${DEEPLINK_URI}personList" }), 31 | ) { 32 | PersonListScreen( 33 | modifier = Modifier.fillMaxSize(), 34 | personSelected = { 35 | navController.navigate(Screen.PersonDetails.route + "/${it.name}") 36 | }, 37 | issMapClick = { 38 | navController.navigate(Screen.IssMap.route) 39 | }, 40 | ) 41 | } 42 | 43 | composable( 44 | route = Screen.PersonDetails.route + "/{$PERSON_NAME_NAV_ARGUMENT}", 45 | arguments = listOf( 46 | navArgument(PERSON_NAME_NAV_ARGUMENT, builder = { 47 | type = NavType.StringType 48 | }) 49 | ), 50 | deepLinks = listOf(navDeepLink { 51 | uriPattern = DEEPLINK_URI + "personList/{${PERSON_NAME_NAV_ARGUMENT}}" 52 | }), 53 | ) { 54 | val personName: String = 55 | it.arguments!!.getString(PERSON_NAME_NAV_ARGUMENT)!! 56 | 57 | PersonDetailsScreen( 58 | modifier = Modifier.fillMaxSize(), 59 | personName = personName, 60 | ) 61 | } 62 | 63 | composable( 64 | route = Screen.IssMap.route, 65 | deepLinks = listOf(navDeepLink { uriPattern = "${DEEPLINK_URI}issMap" }) 66 | ) { 67 | IssMapScreen( 68 | modifier = Modifier.fillMaxSize(), 69 | ) 70 | } 71 | } 72 | } 73 | } -------------------------------------------------------------------------------- /wearApp/src/main/java/com/surrus/peopleinspace/PeopleInSpaceApplication.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.peopleinspace 2 | 3 | import android.app.Application 4 | import co.touchlab.kermit.Logger 5 | import coil.ImageLoader 6 | import coil.ImageLoaderFactory 7 | import com.surrus.common.di.initKoin 8 | import com.surrus.peopleinspace.di.wearAppModule 9 | import com.surrus.peopleinspace.di.wearImageLoader 10 | import org.koin.android.ext.koin.androidContext 11 | import org.koin.android.ext.koin.androidLogger 12 | import org.koin.core.component.KoinComponent 13 | import org.koin.core.component.get 14 | import org.koin.core.logger.Level 15 | 16 | class PeopleInSpaceApplication : Application(), KoinComponent, ImageLoaderFactory { 17 | 18 | override fun onCreate() { 19 | super.onCreate() 20 | 21 | initKoin { 22 | // https://github.com/InsertKoinIO/koin/issues/1188 23 | androidLogger(if (BuildConfig.DEBUG) Level.ERROR else Level.NONE) 24 | androidContext(this@PeopleInSpaceApplication) 25 | 26 | modules(wearImageLoader) 27 | modules(wearAppModule) 28 | } 29 | 30 | Logger.d { "PeopleInSpaceApplication" } 31 | } 32 | 33 | override fun newImageLoader(): ImageLoader = get() 34 | } 35 | 36 | -------------------------------------------------------------------------------- /wearApp/src/main/java/com/surrus/peopleinspace/di/AppModule.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.peopleinspace.di 2 | 3 | import android.util.Log 4 | import coil.ImageLoader 5 | import coil.util.DebugLogger 6 | import com.surrus.peopleinspace.BuildConfig 7 | import com.surrus.peopleinspace.list.PersonListViewModel 8 | import com.surrus.peopleinspace.map.MapViewModel 9 | import com.surrus.peopleinspace.person.PersonDetailsViewModel 10 | import org.koin.android.ext.koin.androidContext 11 | import org.koin.androidx.viewmodel.dsl.viewModel 12 | import org.koin.dsl.module 13 | 14 | val wearAppModule = module { 15 | viewModel { (personName: String) -> 16 | PersonDetailsViewModel( 17 | personName, 18 | peopleInSpaceRepository = get(), 19 | ) 20 | } 21 | viewModel { PersonListViewModel(peopleInSpaceRepository = get()) } 22 | viewModel { MapViewModel(peopleInSpaceRepository = get()) } 23 | } 24 | 25 | val wearImageLoader = module { 26 | single { 27 | ImageLoader.Builder(androidContext()) 28 | .crossfade(true) 29 | .respectCacheHeaders(false) 30 | .apply { 31 | if (BuildConfig.DEBUG) { 32 | logger(DebugLogger(Log.VERBOSE)) 33 | } 34 | } 35 | .build() 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /wearApp/src/main/java/com/surrus/peopleinspace/list/PersonListScreen.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalHorologistApi::class) 2 | 3 | package com.surrus.peopleinspace.list 4 | 5 | import androidx.activity.compose.ReportDrawn 6 | import androidx.compose.foundation.Image 7 | import androidx.compose.foundation.layout.Arrangement 8 | import androidx.compose.foundation.layout.Column 9 | import androidx.compose.foundation.layout.Row 10 | import androidx.compose.foundation.layout.Spacer 11 | import androidx.compose.foundation.layout.fillMaxWidth 12 | import androidx.compose.foundation.layout.size 13 | import androidx.compose.foundation.layout.wrapContentSize 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.runtime.getValue 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.ExperimentalComposeUiApi 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.draw.clip 20 | import androidx.compose.ui.draw.scale 21 | import androidx.compose.ui.graphics.Color 22 | import androidx.compose.ui.platform.testTag 23 | import androidx.compose.ui.res.painterResource 24 | import androidx.compose.ui.semantics.contentDescription 25 | import androidx.compose.ui.semantics.semantics 26 | import androidx.compose.ui.semantics.testTagsAsResourceId 27 | import androidx.compose.ui.unit.dp 28 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 29 | import androidx.wear.compose.material.Button 30 | import androidx.wear.compose.material.ButtonDefaults 31 | import androidx.wear.compose.material.Card 32 | import androidx.wear.compose.material.MaterialTheme 33 | import androidx.wear.compose.material.Text 34 | import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices 35 | import com.google.android.horologist.annotations.ExperimentalHorologistApi 36 | import com.google.android.horologist.compose.layout.ScalingLazyColumn 37 | import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults 38 | import com.google.android.horologist.compose.layout.ScalingLazyColumnState 39 | import com.google.android.horologist.compose.layout.ScreenScaffold 40 | import com.google.android.horologist.compose.layout.rememberColumnState 41 | import com.surrus.common.remote.Assignment 42 | import com.surrus.peopleinspace.R 43 | import com.surrus.peopleinspace.person.AstronautImage 44 | import org.koin.androidx.compose.koinViewModel 45 | 46 | const val PersonListTag = "PersonList" 47 | const val PersonTag = "Person" 48 | 49 | @Composable 50 | fun PersonListScreen( 51 | personSelected: (person: Assignment) -> Unit, 52 | issMapClick: () -> Unit, 53 | modifier: Modifier = Modifier, 54 | columnState: ScalingLazyColumnState = rememberColumnState(), 55 | ) { 56 | val viewModel = koinViewModel() 57 | val people by viewModel.peopleInSpace.collectAsStateWithLifecycle() 58 | 59 | PersonList( 60 | people = people, 61 | personSelected = personSelected, 62 | issMapClick = issMapClick, 63 | columnState = columnState, 64 | modifier = modifier, 65 | ) 66 | } 67 | 68 | @Composable 69 | fun PersonList( 70 | people: List, 71 | personSelected: (person: Assignment) -> Unit, 72 | issMapClick: () -> Unit, 73 | modifier: Modifier = Modifier, 74 | columnState: ScalingLazyColumnState = rememberColumnState(), 75 | ) { 76 | ScreenScaffold(scrollState = columnState) { 77 | ScalingLazyColumn( 78 | modifier = modifier 79 | .testTag(PersonListTag), 80 | columnState = columnState, 81 | ) { 82 | item { 83 | Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { 84 | Button( 85 | modifier = Modifier 86 | .size(ButtonDefaults.SmallButtonSize) 87 | .wrapContentSize(), 88 | onClick = issMapClick 89 | ) { 90 | // https://www.svgrepo.com/svg/170716/international-space-station 91 | Image( 92 | modifier = Modifier.scale(0.5f), 93 | painter = painterResource(id = R.drawable.ic_iss), 94 | contentDescription = "ISS Map" 95 | ) 96 | } 97 | } 98 | } 99 | items(people.size) { offset -> 100 | PersonView( 101 | person = people[offset], 102 | personSelected = personSelected 103 | ) 104 | 105 | // When the column has triggered drawing real 106 | // content - report fully drawn 107 | ReportDrawn() 108 | } 109 | } 110 | } 111 | } 112 | 113 | @OptIn(ExperimentalComposeUiApi::class) 114 | @Composable 115 | fun PersonView( 116 | modifier: Modifier = Modifier, 117 | person: Assignment, 118 | personSelected: (person: Assignment) -> Unit 119 | ) { 120 | Card( 121 | onClick = { personSelected(person) }, 122 | modifier = modifier 123 | .testTag(PersonTag) 124 | .semantics(mergeDescendants = true) { 125 | contentDescription = person.name + " on " + person.craft 126 | testTagsAsResourceId = true 127 | }, 128 | ) { 129 | Row( 130 | modifier = Modifier.fillMaxWidth(), 131 | verticalAlignment = Alignment.CenterVertically, 132 | ) { 133 | AstronautImage( 134 | modifier = Modifier 135 | .size(50.dp) 136 | .clip(MaterialTheme.shapes.medium), 137 | person = person 138 | ) 139 | 140 | Spacer(modifier = Modifier.size(12.dp)) 141 | 142 | Column { 143 | Text(text = person.name, maxLines = 2, style = MaterialTheme.typography.body1) 144 | Text( 145 | text = person.craft, 146 | maxLines = 1, 147 | style = MaterialTheme.typography.body2.copy(color = Color.Gray) 148 | ) 149 | } 150 | } 151 | } 152 | } 153 | 154 | @WearPreviewDevices 155 | @Composable 156 | fun PersonViewPreview() { 157 | PersonView( 158 | person = Assignment( 159 | "ISS", 160 | "Megan McArthur", 161 | personImageUrl = "https://www.nasa.gov/sites/default/files/styles/full_width_feature/public/thumbnails/image/jsc2021e010823.jpg" 162 | ), 163 | personSelected = {}, 164 | ) 165 | } 166 | 167 | @WearPreviewDevices 168 | @Composable 169 | fun PersonListSquarePreview() { 170 | PersonList( 171 | people = listOf( 172 | Assignment( 173 | "Apollo 11", 174 | "Neil Armstrong", 175 | "https://www.biography.com/.image/ar_1:1%2Cc_fill%2Ccs_srgb%2Cfl_progressive%2Cq_auto:good%2Cw_1200/MTc5OTk0MjgyMzk5MTE0MzYy/gettyimages-150832381.jpg" 176 | ), 177 | Assignment( 178 | "Apollo 11", 179 | "Buzz Aldrin", 180 | "https://nypost.com/wp-content/uploads/sites/2/2018/06/buzz-aldrin.jpg?quality=80&strip=all" 181 | ) 182 | ), 183 | personSelected = {}, 184 | issMapClick = {}, 185 | columnState = ScalingLazyColumnDefaults.belowTimeText().create() 186 | ) 187 | } 188 | 189 | @WearPreviewDevices 190 | @Composable 191 | fun PersonListSquareEmptyPreview() { 192 | PersonList( 193 | people = listOf(), 194 | personSelected = {}, 195 | issMapClick = {}, 196 | ) 197 | } 198 | -------------------------------------------------------------------------------- /wearApp/src/main/java/com/surrus/peopleinspace/list/PersonListViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.peopleinspace.list 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.surrus.common.repository.PeopleInSpaceRepositoryInterface 6 | import kotlinx.coroutines.flow.SharingStarted 7 | import kotlinx.coroutines.flow.stateIn 8 | 9 | class PersonListViewModel( 10 | peopleInSpaceRepository: PeopleInSpaceRepositoryInterface 11 | ) : ViewModel() { 12 | val peopleInSpace = peopleInSpaceRepository.fetchPeopleAsFlow() 13 | .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) 14 | } 15 | -------------------------------------------------------------------------------- /wearApp/src/main/java/com/surrus/peopleinspace/map/IssMap.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.peopleinspace.map 2 | 3 | import android.annotation.SuppressLint 4 | import androidx.compose.foundation.layout.fillMaxHeight 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.getValue 7 | import androidx.compose.runtime.remember 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.platform.LocalContext 10 | import androidx.compose.ui.platform.testTag 11 | import androidx.compose.ui.semantics.SemanticsPropertyKey 12 | import androidx.compose.ui.semantics.SemanticsPropertyReceiver 13 | import androidx.compose.ui.semantics.semantics 14 | import androidx.compose.ui.viewinterop.AndroidView 15 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 16 | import com.google.android.horologist.compose.layout.ScreenScaffold 17 | import com.surrus.common.remote.IssPosition 18 | import com.surrus.peopleinspace.BuildConfig 19 | import org.koin.androidx.compose.koinViewModel 20 | import org.osmdroid.config.Configuration 21 | import org.osmdroid.tileprovider.tilesource.TileSourceFactory 22 | import org.osmdroid.util.GeoPoint 23 | import org.osmdroid.views.CustomZoomButtonsController 24 | import org.osmdroid.views.MapView 25 | import org.osmdroid.views.overlay.Marker 26 | import java.io.File 27 | 28 | const val ISSPositionMapTag = "ISSPositionMap" 29 | 30 | val IssPositionKey = SemanticsPropertyKey("IssPosition") 31 | var SemanticsPropertyReceiver.observedIssPosition by IssPositionKey 32 | 33 | @Composable 34 | fun IssMapScreen( 35 | modifier: Modifier = Modifier, 36 | ) { 37 | val peopleInSpaceViewModel = koinViewModel() 38 | val issPosition by peopleInSpaceViewModel.issPosition.collectAsStateWithLifecycle() 39 | 40 | ScreenScaffold(timeText = {}) { 41 | IssMap(modifier, issPosition) 42 | } 43 | } 44 | 45 | @SuppressLint("ClickableViewAccessibility") 46 | @Composable 47 | private fun IssMap( 48 | modifier: Modifier, 49 | issPosition: IssPosition? 50 | ) { 51 | val context = LocalContext.current 52 | 53 | Configuration.getInstance().userAgentValue = BuildConfig.APPLICATION_ID 54 | Configuration.getInstance().tileFileSystemCacheMaxBytes = 50L * 1024 * 1024 55 | Configuration.getInstance().osmdroidTileCache = 56 | File(context.cacheDir, "osmdroid").also { it.mkdir() } 57 | 58 | val mapView = remember { MapView(context) } 59 | 60 | AndroidView( 61 | factory = { 62 | mapView.apply { 63 | setTileSource(TileSourceFactory.MAPNIK); 64 | zoomController.setVisibility(CustomZoomButtonsController.Visibility.SHOW_AND_FADEOUT) 65 | setMultiTouchControls(false) 66 | controller.setZoom(3.0) 67 | isClickable = false 68 | isFocusable = false 69 | setOnTouchListener { _, _ -> true } 70 | } 71 | }, 72 | modifier = modifier 73 | .fillMaxHeight() 74 | .testTag(ISSPositionMapTag) 75 | .semantics { 76 | if (issPosition != null) { 77 | observedIssPosition = issPosition 78 | } 79 | }, 80 | update = { map -> 81 | map.overlays.clear() 82 | 83 | if (issPosition != null) { 84 | val issPositionPoint = GeoPoint(issPosition.latitude, issPosition.longitude) 85 | map.controller.setCenter(issPositionPoint) 86 | 87 | val stationMarker = Marker(map) 88 | stationMarker.position = issPositionPoint 89 | stationMarker.title = "ISS" 90 | map.overlays.add(stationMarker) 91 | } 92 | } 93 | ) 94 | } -------------------------------------------------------------------------------- /wearApp/src/main/java/com/surrus/peopleinspace/map/MapViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.peopleinspace.map 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.surrus.common.repository.PeopleInSpaceRepositoryInterface 6 | import kotlinx.coroutines.flow.SharingStarted 7 | import kotlinx.coroutines.flow.stateIn 8 | 9 | class MapViewModel( 10 | peopleInSpaceRepository: PeopleInSpaceRepositoryInterface 11 | ) : ViewModel() { 12 | val issPosition = peopleInSpaceRepository.pollISSPosition() 13 | .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) 14 | } 15 | -------------------------------------------------------------------------------- /wearApp/src/main/java/com/surrus/peopleinspace/person/PersonDetailsScreen.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalHorologistApi::class) 2 | 3 | package com.surrus.peopleinspace.person 4 | 5 | import androidx.compose.foundation.background 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.size 8 | import androidx.compose.foundation.shape.CutCornerShape 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.getValue 11 | import androidx.compose.runtime.remember 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.draw.clip 14 | import androidx.compose.ui.graphics.Color 15 | import androidx.compose.ui.platform.testTag 16 | import androidx.compose.ui.res.painterResource 17 | import androidx.compose.ui.text.style.TextAlign 18 | import androidx.compose.ui.tooling.preview.Preview 19 | import androidx.compose.ui.unit.dp 20 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 21 | import androidx.wear.compose.material.MaterialTheme 22 | import androidx.wear.compose.material.Text 23 | import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices 24 | import coil.compose.AsyncImage 25 | import com.google.android.horologist.annotations.ExperimentalHorologistApi 26 | import com.google.android.horologist.compose.layout.ScalingLazyColumn 27 | import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults 28 | import com.google.android.horologist.compose.layout.ScalingLazyColumnState 29 | import com.google.android.horologist.compose.layout.ScreenScaffold 30 | import com.google.android.horologist.compose.layout.rememberColumnState 31 | import com.surrus.common.remote.Assignment 32 | import com.surrus.peopleinspace.R 33 | import com.surrus.peopleinspace.list.PersonListTag 34 | import org.koin.androidx.compose.koinViewModel 35 | import org.koin.core.parameter.parametersOf 36 | 37 | @Composable 38 | fun PersonDetailsScreen( 39 | personName: String, 40 | modifier: Modifier = Modifier, 41 | columnState: ScalingLazyColumnState = rememberColumnState(), 42 | ) { 43 | val peopleInSpaceViewModel = koinViewModel( 44 | parameters = { parametersOf(personName) } 45 | ) 46 | val person by peopleInSpaceViewModel.person.collectAsStateWithLifecycle() 47 | 48 | PersonDetails( 49 | modifier = modifier, 50 | person = person, 51 | columnState = columnState 52 | ) 53 | } 54 | 55 | @Composable 56 | private fun PersonDetails( 57 | person: Assignment?, 58 | modifier: Modifier = Modifier, 59 | columnState: ScalingLazyColumnState = rememberColumnState(), 60 | ) { 61 | ScreenScaffold(scrollState = columnState) { 62 | ScalingLazyColumn( 63 | modifier = modifier 64 | .testTag(PersonListTag), 65 | columnState = columnState 66 | ) { 67 | item { 68 | AstronautImage( 69 | modifier = Modifier 70 | .size(120.dp) 71 | .clip(CutCornerShape(30.dp)), 72 | person = person 73 | ) 74 | } 75 | 76 | item { 77 | Text( 78 | person?.name ?: "Astronaut not found.", 79 | style = MaterialTheme.typography.title1, 80 | textAlign = TextAlign.Center 81 | ) 82 | } 83 | 84 | val personBio = person?.personBio 85 | if (personBio != null) { 86 | item { 87 | Text( 88 | personBio, 89 | style = MaterialTheme.typography.body2, 90 | textAlign = TextAlign.Justify 91 | ) 92 | } 93 | } 94 | } 95 | } 96 | } 97 | 98 | @Composable 99 | fun AstronautImage( 100 | modifier: Modifier, 101 | person: Assignment? 102 | ) { 103 | AsyncImage( 104 | modifier = modifier, 105 | model = person?.personImageUrl, 106 | contentDescription = person?.name, 107 | fallback = painterResource(id = R.drawable.ic_american_astronaut), 108 | error = painterResource(id = R.drawable.ic_american_astronaut) 109 | ) 110 | } 111 | 112 | @WearPreviewDevices 113 | @Composable 114 | fun PersonDetailsScreenPreview() { 115 | val person = remember { 116 | Assignment( 117 | "Apollo 11", 118 | "Neil Armstrong", 119 | "https://www.biography.com/.image/ar_1:1%2Cc_fill%2Ccs_srgb%2Cfl_progressive%2Cq_auto:good%2Cw_1200/MTc5OTk0MjgyMzk5MTE0MzYy/gettyimages-150832381.jpg", 120 | "Mark Thomas Vande Hei (born November 10, 1966) is a retired United States Army officer and NASA astronaut who served as a flight Engineer for Expedition 53 and 54 on the International Space Station." 121 | ) 122 | } 123 | Box(modifier = Modifier.background(Color.Black)) { 124 | PersonDetails( 125 | person = person, 126 | columnState = ScalingLazyColumnDefaults.belowTimeText().create(), 127 | ) 128 | } 129 | } 130 | 131 | @WearPreviewDevices 132 | @Composable 133 | fun PersonDetailsScreenNotFoundPreview() { 134 | Box(modifier = Modifier.background(Color.Black)) { 135 | PersonDetails( 136 | person = null, 137 | ) 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /wearApp/src/main/java/com/surrus/peopleinspace/person/PersonDetailsViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.peopleinspace.person 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.surrus.common.repository.PeopleInSpaceRepositoryInterface 6 | import kotlinx.coroutines.flow.SharingStarted 7 | import kotlinx.coroutines.flow.map 8 | import kotlinx.coroutines.flow.stateIn 9 | 10 | class PersonDetailsViewModel( 11 | personName: String, 12 | peopleInSpaceRepository: PeopleInSpaceRepositoryInterface, 13 | ) : ViewModel() { 14 | val person = peopleInSpaceRepository.fetchPeopleAsFlow().map { list -> 15 | list.find { it.name == personName } 16 | } 17 | .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) 18 | } 19 | -------------------------------------------------------------------------------- /wearApp/src/main/java/com/surrus/peopleinspace/tile/PeopleInSpaceTile.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.peopleinspace.tile 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.graphics.Color 5 | import androidx.compose.ui.unit.TextUnit 6 | import androidx.compose.ui.unit.TextUnitType 7 | import androidx.compose.ui.unit.dp 8 | import androidx.glance.GlanceModifier 9 | import androidx.glance.layout.Column 10 | import androidx.glance.layout.padding 11 | import androidx.glance.text.FontWeight 12 | import androidx.glance.text.Text 13 | import androidx.glance.text.TextStyle 14 | import androidx.glance.unit.ColorProvider 15 | import com.surrus.common.remote.Assignment 16 | import com.surrus.common.repository.PeopleInSpaceRepositoryInterface 17 | import com.surrus.peopleinspace.tile.util.BaseGlanceTileService 18 | import kotlinx.coroutines.flow.first 19 | import org.koin.core.component.inject 20 | 21 | class PeopleInSpaceTile : BaseGlanceTileService() { 22 | val repository: PeopleInSpaceRepositoryInterface by inject() 23 | 24 | data class Data(val people: List) 25 | 26 | override suspend fun loadData(): Data { 27 | return Data(repository.fetchPeopleAsFlow().first()) 28 | } 29 | 30 | @Composable 31 | override fun Content(data: Data) { 32 | Column( 33 | modifier = GlanceModifier.padding(horizontal = 8.dp) 34 | ) { 35 | Text( 36 | modifier = GlanceModifier.padding(bottom = 8.dp), 37 | text = "People in Space", 38 | style = TextStyle( 39 | color = ColorProvider(Color.White), 40 | fontSize = TextUnit(12f, TextUnitType.Sp), 41 | fontWeight = FontWeight.Bold 42 | ) 43 | ) 44 | data.people.forEach { 45 | Text( 46 | text = it.name, 47 | style = TextStyle( 48 | color = ColorProvider(Color.White), 49 | fontSize = TextUnit(10f, TextUnitType.Sp) 50 | ) 51 | ) 52 | } 53 | } 54 | } 55 | } -------------------------------------------------------------------------------- /wearApp/src/main/java/com/surrus/peopleinspace/tile/util/BaseGlanceTileService.kt: -------------------------------------------------------------------------------- 1 | package com.surrus.peopleinspace.tile.util 2 | 3 | import android.content.Context 4 | import androidx.compose.runtime.Composable 5 | import androidx.glance.wear.tiles.GlanceTileService 6 | import kotlinx.coroutines.Dispatchers 7 | import kotlinx.coroutines.runBlocking 8 | import org.koin.core.component.KoinComponent 9 | import org.koin.core.component.inject 10 | 11 | abstract class BaseGlanceTileService : GlanceTileService(), KoinComponent { 12 | val context: Context by inject() 13 | 14 | @Composable 15 | override fun Content() { 16 | // Terrible hack for lack of suspend load function 17 | val data = runBlocking(Dispatchers.IO) { 18 | try { 19 | loadData() 20 | } finally { 21 | } 22 | } 23 | 24 | Content(data = data) 25 | } 26 | 27 | abstract suspend fun loadData(): T 28 | 29 | @Composable 30 | abstract fun Content(data: T) 31 | } -------------------------------------------------------------------------------- /wearApp/src/main/res/drawable/ic_iss.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /wearApp/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joreilly/PeopleInSpace/e50925ece6ba05c61e963f73a0fb6f2b57530677/wearApp/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /wearApp/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joreilly/PeopleInSpace/e50925ece6ba05c61e963f73a0fb6f2b57530677/wearApp/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /wearApp/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joreilly/PeopleInSpace/e50925ece6ba05c61e963f73a0fb6f2b57530677/wearApp/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /wearApp/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joreilly/PeopleInSpace/e50925ece6ba05c61e963f73a0fb6f2b57530677/wearApp/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /wearApp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joreilly/PeopleInSpace/e50925ece6ba05c61e963f73a0fb6f2b57530677/wearApp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /wearApp/src/main/res/values-round/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /wearApp/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | PeopleInSpace 3 | --------------------------------------------------------------------------------