├── .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 | 
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 |
102 |
103 | **Android (Jetpack Compose)**
104 |
105 |
106 |
107 |
108 |
109 | **Wear OS (Wear Compose)**
110 |
111 |
112 |
113 |
114 |
115 |
116 | **Compose for Desktop**
117 |
118 |
119 |
120 |
121 | **Compose for Web (Wasm based)**
122 |
123 |
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 | 
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 |
19 |
20 |
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 |
--------------------------------------------------------------------------------